From 5ea03456bd5773431fd50477cc43dcabc1ec34b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 13:00:48 +0000 Subject: [PATCH 1/4] feat: build and publish Nemotron OCR wheels for Python 3.11, 3.12, 3.13 The build_ocr_cuda job in the Hugging Face nightly/release workflow only built Python 3.12 wheels for nemotron-ocr-v2. The C++ extension compiles per-interpreter ABI, so the only blocker to other interpreters was the upstream requires-python metadata. - Add --set-requires-python to nightly_build_publish.py to relax the upstream [project].requires-python so wheels stay pip-installable on all targeted interpreters. - Add --skip-sdist so the interpreter-agnostic sdist is emitted once. - Add a Python 3.11/3.12/3.13 matrix dimension to build_ocr_cuda, drive setup-python from it, relax requires-python to >=3.11,<3.14, build the sdist only on the x86_64+3.12 leg, and make artifact names unique per arch and Python version. Applies to both nightly and stable releases. Co-authored-by: Jeremy Dyer --- .github/workflows/huggingface-nightly.yml | 30 ++++++-- ci/scripts/nightly_build_publish.py | 67 ++++++++++++++++- ci/tests/test_huggingface_release_workflow.py | 74 +++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/.github/workflows/huggingface-nightly.yml b/.github/workflows/huggingface-nightly.yml index 74a42bc3a8..4a217dd729 100644 --- a/.github/workflows/huggingface-nightly.yml +++ b/.github/workflows/huggingface-nightly.yml @@ -168,8 +168,11 @@ jobs: needs: nightly_coordinate if: ${{ github.event_name != 'workflow_dispatch' || inputs.package == 'all' || inputs.package == 'nemotron-ocr-v2' }} # Nemotron OCR packages need nvcc/CUDA headers to build their extension. - # Build with Python 3.12 to match upstream package constraints and - # avoid producing an extension for the wrong Python ABI. + # Build a per-interpreter extension for each supported Python (3.11, 3.12, + # 3.13): torch cpp_extension compiles against the active interpreter's ABI, + # so setup-python selects both the build interpreter and the wheel ABI tag. + # We relax the upstream requires-python (see --set-requires-python) so the + # resulting wheels stay pip-installable on every targeted interpreter. # Matrix across x86_64 and aarch64 so the wheel works on ARM hosts # (e.g. DGX Spark) as well as conventional x86 machines. runs-on: ${{ matrix.platform.runner }} @@ -183,6 +186,13 @@ jobs: - runner: ubuntu-24.04-arm cuda_image: nvidia/cuda:13.0.0-devel-ubuntu24.04 arch: aarch64 + python: + - version: "3.11" + tag: cp311 + - version: "3.12" + tag: cp312 + - version: "3.13" + tag: cp313 ocr: - id: nemotron-ocr-v2 url: https://huggingface.co/nvidia/nemotron-ocr-v2 @@ -213,7 +223,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "${{ matrix.python.version }}" - name: Decide upload target id: target @@ -230,7 +240,7 @@ jobs: echo "upload_to=${upload_to}" >> "$GITHUB_OUTPUT" echo "skip_existing=${skip_existing}" >> "$GITHUB_OUTPUT" - - name: Build ${{ matrix.ocr.id }} (and maybe upload) + - name: Build ${{ matrix.ocr.id }} (and maybe upload) for Python ${{ matrix.python.version }} env: NIGHTLY_DATE_SUFFIX: ${{ needs.nightly_coordinate.outputs.nightly_date_suffix }} PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} @@ -267,6 +277,14 @@ jobs: arch_env_arg="--build-env ARCH=arm64" fi + # The sdist is interpreter-agnostic and identical across every leg of + # the platform/Python matrix, so build (and upload) it exactly once + # (x86_64 + 3.12); every other leg produces only its ABI-tagged wheel. + sdist_arg="--skip-sdist" + if [[ "${{ matrix.platform.arch }}" == "x86_64" && "${{ matrix.python.version }}" == "3.12" ]]; then + sdist_arg="" + fi + release_type="nightly" expected_version="${{ matrix.ocr.nightly_base_version }}.dev" version_args=(--nightly-base-version "${{ matrix.ocr.nightly_base_version }}") @@ -285,6 +303,8 @@ jobs: --dist-dir "dist-out" \ --project-subdir "nemotron-ocr" \ "${version_args[@]}" \ + --set-requires-python ">=3.11,<3.14" \ + ${sdist_arg} \ --hatch-force-platform-wheel \ --auditwheel-repair \ --auditwheel-exclude libtorch_cpu.so \ @@ -425,5 +445,5 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: dist-${{ matrix.ocr.id }}-${{ matrix.platform.arch }} + name: dist-${{ matrix.ocr.id }}-${{ matrix.platform.arch }}-${{ matrix.python.tag }} path: dist-out/${{ matrix.ocr.id }}/* diff --git a/ci/scripts/nightly_build_publish.py b/ci/scripts/nightly_build_publish.py index ee2a076d01..9759d2ffa0 100644 --- a/ci/scripts/nightly_build_publish.py +++ b/ci/scripts/nightly_build_publish.py @@ -6,7 +6,10 @@ - Clones a HF git repo with Git LFS smudge disabled (so large weights are not downloaded). - Patches a PEP 440 dev version into pyproject.toml or setup.cfg (default suffix: UTC YYYYMMDD; override with NIGHTLY_DATE_SUFFIX or NIGHTLY_DATE_YYYYMMDD, e.g. from CI). -- Builds sdist + wheel via `python -m build`. +- Builds sdist + wheel via `python -m build` (``--skip-sdist`` builds the wheel only, + so a platform/Python matrix can emit the identical sdist exactly once). +- Optional ``--set-requires-python`` relaxes ``[project].requires-python`` so wheels + built on a Python matrix stay pip-installable on every targeted interpreter. - Optional ``--auditwheel-repair`` rewrites ``linux_*`` wheels to ``manylinux_*`` for PyPI. - Optionally uploads to (Test)PyPI via twine. @@ -459,6 +462,46 @@ def replace_dependency(m: re.Match[str]) -> str: return True +def _patch_pyproject_requires_python(repo_dir: Path, requires_python: str) -> bool: + """ + Set or relax the ``[project].requires-python`` constraint before building. + + Upstream packages (e.g. Nemotron OCR) may pin ``requires-python`` to a single + interpreter (``>=3.12,<3.13``), which makes pip refuse the wheel on other + interpreters even though the compiled extension is ABI-tagged per interpreter. + Widening the metadata to the full supported range lets a matrix build publish + installable wheels for every targeted Python version. + """ + pyproject = repo_dir / "pyproject.toml" + if not pyproject.exists(): + return False + + text = _read_text(pyproject) + bounds = _project_table_bounds(text) + if bounds is None: + return False + + project_start, project_end = bounds + project_text = text[project_start:project_end] + m = re.search(r'(?m)^(\s*requires-python\s*=\s*")([^"]*)(")\s*$', project_text) + if m: + old_value = m.group(2) + if old_value == requires_python: + return False + patched_project_text = project_text[: m.start(2)] + requires_python + project_text[m.end(2) :] + patched_text = text[:project_start] + patched_project_text + text[project_end:] + _write_text(pyproject, patched_text) + print(f"Patched pyproject.toml requires-python: {old_value} -> {requires_python}") + return True + + # No requires-python field yet: insert one at the top of the [project] table body. + insertion = f'\nrequires-python = "{requires_python}"' + patched_text = text[:project_start] + insertion + text[project_start:] + _write_text(pyproject, patched_text) + print(f"Added pyproject.toml requires-python: {requires_python}") + return True + + def _patch_setup_cfg_version( repo_dir: Path, *, @@ -606,6 +649,7 @@ def _build( venv_system_site_packages: bool, venv_pip_install: list[str], pin_runtime_dependencies: list[str], + build_sdist: bool = True, ) -> None: venv_dir = Path(os.environ.get("ORCH_VENV_DIR", ".venv-build")) py = _ensure_venv(venv_dir, system_site_packages=venv_system_site_packages) @@ -635,7 +679,9 @@ def _build( } _patch_pyproject_runtime_dependency_pins(project_dir, pins) - cmd = [str(py), "-m", "build", "--sdist", "--wheel"] + cmd = [str(py), "-m", "build", "--wheel"] + if build_sdist: + cmd.insert(3, "--sdist") if no_isolation: cmd.append("--no-isolation") _run(cmd, cwd=project_dir, env=env) @@ -763,6 +809,12 @@ def main() -> int: help="Patch the source Python project/distribution name before building " "(e.g. publish a source tree under an alternate distribution name).", ) + ap.add_argument( + "--set-requires-python", + default=None, + help="Set or relax the [project].requires-python constraint before building " + "(e.g. '>=3.11,<3.14') so wheels built on a Python matrix stay pip-installable.", + ) ap.add_argument( "--rename-python-package", action="append", @@ -780,6 +832,12 @@ def main() -> int: action="store_true", help="Pass --no-isolation to `python -m build` (useful to reuse preinstalled deps in CI images)", ) + ap.add_argument( + "--skip-sdist", + action="store_true", + help="Only build the wheel, not the sdist. Use on all but one leg of a platform/Python " + "matrix so the identical sdist is produced (and uploaded) exactly once.", + ) ap.add_argument( "--venv-dir", default=".venv-build", @@ -867,6 +925,10 @@ def main() -> int: if not patched: print("No static version field found to patch (continuing).") + if args.set_requires_python: + if not _patch_pyproject_requires_python(project_dir, args.set_requires_python): + print(f"requires-python already set to {args.set_requires_python!r} or no [project] table found.") + if args.project_name: patched_name = _patch_pyproject_project_name( project_dir, @@ -897,6 +959,7 @@ def main() -> int: venv_system_site_packages=args.venv_system_site_packages, venv_pip_install=args.venv_pip_install, pin_runtime_dependencies=args.pin_runtime_dependency, + build_sdist=not args.skip_sdist, ) print(f"Artifacts in: {out_dir}") diff --git a/ci/tests/test_huggingface_release_workflow.py b/ci/tests/test_huggingface_release_workflow.py index 0d2f952bba..d2f430b093 100644 --- a/ci/tests/test_huggingface_release_workflow.py +++ b/ci/tests/test_huggingface_release_workflow.py @@ -66,6 +66,57 @@ def test_nightly_builder_can_patch_exact_release_version_in_setup_cfg(tmp_path: assert "version = 2.0.0" in setup_cfg.read_text(encoding="utf-8") +def test_nightly_builder_relaxes_existing_requires_python(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + """ +[build-system] +requires = ["hatchling"] + +[project] +name = "example" +version = "2.0.0" +requires-python = ">=3.12,<3.13" +""".lstrip(), + encoding="utf-8", + ) + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._patch_pyproject_requires_python(project_dir, ">=3.11,<3.14") + + text = pyproject.read_text(encoding="utf-8") + assert 'requires-python = ">=3.11,<3.14"' in text + assert ">=3.12,<3.13" not in text + + +def test_nightly_builder_adds_requires_python_when_missing(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + """ +[build-system] +requires = ["hatchling"] + +[project] +name = "example" +version = "2.0.0" +""".lstrip(), + encoding="utf-8", + ) + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._patch_pyproject_requires_python(project_dir, ">=3.11,<3.14") + + text = pyproject.read_text(encoding="utf-8") + assert 'requires-python = ">=3.11,<3.14"' in text + # A no-op re-run must not duplicate the field. + assert not nightly_build_publish._patch_pyproject_requires_python(project_dir, ">=3.11,<3.14") + assert text.count("requires-python") == 1 + + @pytest.mark.parametrize( "version", ["", "2.0.0a1", "2.0.0rc1", "2.0.0+local", "2.0.0.dev1"], @@ -125,6 +176,29 @@ def test_huggingface_non_ocr_nightlies_are_versioned_after_current_stable() -> N assert workflow.count('nightly_base_version: "1.0.1"') == 1 +def test_huggingface_ocr_builds_and_publishes_wheels_for_all_supported_pythons() -> None: + workflow = (REPO_ROOT / ".github" / "workflows" / "huggingface-nightly.yml").read_text(encoding="utf-8") + + ocr_job = workflow.split("build_ocr_cuda:", 1)[1] + + # Python matrix drives setup-python so each leg builds an ABI-specific wheel. + for version, tag in (("3.11", "cp311"), ("3.12", "cp312"), ("3.13", "cp313")): + assert f'version: "{version}"' in ocr_job + assert f"tag: {tag}" in ocr_job + assert 'python-version: "${{ matrix.python.version }}"' in ocr_job + + # Published wheels stay pip-installable on every targeted interpreter. + assert '--set-requires-python ">=3.11,<3.14"' in ocr_job + + # The identical sdist is emitted (and uploaded) exactly once across the matrix. + assert "sdist_arg=" in ocr_job + assert '"${{ matrix.platform.arch }}" == "x86_64" && "${{ matrix.python.version }}" == "3.12"' in ocr_job + assert "${sdist_arg}" in ocr_job + + # Artifact names stay unique per architecture and Python version. + assert "dist-${{ matrix.ocr.id }}-${{ matrix.platform.arch }}-${{ matrix.python.tag }}" in ocr_job + + def test_huggingface_nightly_builder_defaults_to_public_pypi() -> None: script = (REPO_ROOT / "ci" / "scripts" / "nightly_build_publish.py").read_text(encoding="utf-8") From 95f628552c5724937b18e3235d39090837c00712 Mon Sep 17 00:00:00 2001 From: Jeremy Dyer Date: Wed, 15 Jul 2026 12:01:24 -0400 Subject: [PATCH 2/4] Include license in huggingface model wheels --- .github/workflows/huggingface-nightly.yml | 14 ++ .gitignore | 4 + ci/scripts/build_ocr_wheels_local.sh | 58 ++++++ ci/scripts/nightly_build_publish.py | 182 +++++++++++++++++- ci/tests/test_huggingface_release_workflow.py | 83 ++++++++ scripts/init_nemotron_ocr_v2.py | 44 +++++ 6 files changed, 384 insertions(+), 1 deletion(-) create mode 100755 ci/scripts/build_ocr_wheels_local.sh create mode 100755 scripts/init_nemotron_ocr_v2.py diff --git a/.github/workflows/huggingface-nightly.yml b/.github/workflows/huggingface-nightly.yml index 4a217dd729..7120156b7c 100644 --- a/.github/workflows/huggingface-nightly.yml +++ b/.github/workflows/huggingface-nightly.yml @@ -356,6 +356,8 @@ jobs: "torch": f"~={os.environ['OCR_TORCH_VERSION']}", "torchvision": f"~={os.environ['OCR_TORCHVISION_VERSION']}", } + expected_license = "NVIDIA Open Model License" + expected_license_classifier = "License :: Other/Proprietary License" def expanded_compatible_specifier(specifier): if not specifier.startswith("~="): @@ -391,6 +393,18 @@ jobs: if f"Name: {expected_project}" not in metadata: raise SystemExit(f"Built wheel metadata does not declare project {expected_project!r}") metadata_version = metadata_message.get("Version") + metadata_license = metadata_message.get("License") + if metadata_license != expected_license: + raise SystemExit( + "Built wheel metadata does not declare expected license " + f"{expected_license!r}; got {metadata_license!r}" + ) + metadata_classifiers = metadata_message.get_all("Classifier", []) + if expected_license_classifier not in metadata_classifiers: + raise SystemExit( + "Built wheel metadata does not declare expected license classifier " + f"{expected_license_classifier!r}; got {metadata_classifiers!r}" + ) if release_type == "stable" and metadata_version != expected_version: raise SystemExit( "Built wheel metadata does not declare expected version " diff --git a/.gitignore b/.gitignore index 12d933a220..402eb807d6 100644 --- a/.gitignore +++ b/.gitignore @@ -275,3 +275,7 @@ spool/ # Local git worktrees .worktrees/ + +# Local huggingface cache and build directories +.work*/ +dist* diff --git a/ci/scripts/build_ocr_wheels_local.sh b/ci/scripts/build_ocr_wheels_local.sh new file mode 100755 index 0000000000..253a06207e --- /dev/null +++ b/ci/scripts/build_ocr_wheels_local.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates git git-lfs build-essential ninja-build patchelf \ + software-properties-common python3-pip +add-apt-repository -y ppa:deadsnakes/ppa +apt-get update +apt-get install -y --no-install-recommends \ + python3.11 python3.11-dev python3.11-venv \ + python3.13 python3.13-dev python3.13-venv +git lfs install +arch_env_arg="" +if [[ "$(uname -m)" == "aarch64" ]]; then + arch_env_arg="--build-env ARCH=arm64" +fi +mkdir -p dist-out/nemotron-ocr-v2 +for PY_VER in 3.11 3.13; do + PYTHON="python${PY_VER}" + TAG="cp${PY_VER//./}" + STAGING="dist-staging-${TAG}" + VENV_DIR="/tmp/.venv-build-${TAG}" + echo "=== Building nemotron-ocr for Python ${PY_VER} ===" + "${PYTHON}" --version + "${PYTHON}" -m pip install --break-system-packages "packaging>=24" + "${PYTHON}" ci/scripts/nightly_build_publish.py \ + --repo-id nemotron-ocr-v2 \ + --repo-url https://huggingface.co/nvidia/nemotron-ocr-v2 \ + --work-dir ".work-${TAG}" \ + --dist-dir "${STAGING}" \ + --venv-dir "${VENV_DIR}" \ + --project-subdir nemotron-ocr \ + --nightly-base-version 2.0.1 \ + --set-requires-python ">=3.11,<3.14" \ + --skip-sdist \ + --hatch-force-platform-wheel \ + --auditwheel-repair \ + --auditwheel-exclude libtorch_cpu.so \ + --auditwheel-exclude libtorch_cuda.so \ + --auditwheel-exclude libtorch.so \ + --auditwheel-exclude libc10.so \ + --auditwheel-exclude libc10_cuda.so \ + --auditwheel-exclude libtorch_python.so \ + --build-no-isolation \ + --venv-pip-install hatchling \ + --venv-pip-install "setuptools>=68" \ + --venv-pip-install ninja \ + --venv-pip-install "torch==${OCR_TORCH_VERSION}" \ + --venv-pip-install "torchvision==${OCR_TORCHVISION_VERSION}" \ + --pin-runtime-dependency torch \ + --pin-runtime-dependency torchvision \ + --build-env BUILD_CPP_EXTENSION=1 \ + --build-env BUILD_CPP_FORCE=1 \ + ${arch_env_arg} + cp "${STAGING}/nemotron-ocr-v2/"*.whl dist-out/nemotron-ocr-v2/ +done +ls -lh dist-out/nemotron-ocr-v2/ diff --git a/ci/scripts/nightly_build_publish.py b/ci/scripts/nightly_build_publish.py index 9759d2ffa0..d6823a33f7 100644 --- a/ci/scripts/nightly_build_publish.py +++ b/ci/scripts/nightly_build_publish.py @@ -96,6 +96,21 @@ def _venv_python(venv_dir: Path) -> Path: return venv_dir / "bin" / "python" +def _venv_is_usable(venv_dir: Path) -> bool: + py = _venv_python(venv_dir) + if not py.is_file() and not py.is_symlink(): + return False + try: + subprocess.run( + [str(py), "-c", "pass"], + check=True, + capture_output=True, + ) + return True + except (OSError, subprocess.CalledProcessError): + return False + + def _ensure_venv(venv_dir: Path, *, system_site_packages: bool) -> Path: """ Ensure a local venv exists and return its python path. @@ -105,13 +120,15 @@ def _ensure_venv(venv_dir: Path, *, system_site_packages: bool) -> Path: """ marker = venv_dir / ".orch-system-site-packages" py = _venv_python(venv_dir) - if py.exists(): + if _venv_is_usable(venv_dir): # If caller changes system_site_packages setting, recreate the venv to ensure # the correct site-packages visibility. has_marker = marker.exists() if has_marker == system_site_packages: return py shutil.rmtree(venv_dir) + elif venv_dir.exists(): + shutil.rmtree(venv_dir) venv_dir.parent.mkdir(parents=True, exist_ok=True) cmd = [sys.executable, "-m", "venv"] @@ -387,6 +404,19 @@ def _project_table_bounds(text: str) -> tuple[int, int] | None: return m.end(), len(text) +def _project_core_bounds(text: str) -> tuple[int, int] | None: + """Bounds of inline ``[project]`` keys only (excludes ``[project.*]`` subtables).""" + m = re.search(r"(?m)^\[project\]\s*(?:#.*)?$", text) + if not m: + return None + + for next_table in re.finditer(r"(?m)^\[([^\]]+)\]\s*(?:#.*)?$", text[m.end() :]): + table_name = next_table.group(1).strip() + if table_name != "project": + return m.end(), m.end() + next_table.start() + return m.end(), len(text) + + def _set_requirement_specifier(requirement: str, specifier: str) -> str: marker = "" base = requirement @@ -462,6 +492,129 @@ def replace_dependency(m: re.Match[str]) -> str: return True +_DEFAULT_LICENSE_TEXT = "NVIDIA Open Model License" +_DEFAULT_LICENSE_CLASSIFIER = "License :: Other/Proprietary License" +_LICENSE_FILE_NAMES = ("LICENSE", "LICENSE.txt", "LICENSE.md") + + +def _patch_pyproject_license( + project_dir: Path, + *, + license_text: str, + license_classifier: str | None, +) -> bool: + """ + Ensure PyPI-visible license metadata is present in ``[project]``. + + Upstream HF repos (e.g. Nemotron OCR) may omit ``license`` and Trove + classifiers even though the model is governed by the NVIDIA Open Model + License. Hatch/setuptools only emit wheel/sdist METADATA from these fields. + """ + pyproject = project_dir / "pyproject.toml" + if not pyproject.exists(): + return False + + text = _read_text(pyproject) + core_bounds = _project_core_bounds(text) + if core_bounds is None: + return False + + core_start, core_end = core_bounds + project_text = text[core_start:core_end] + changed = False + + if not re.search(r"(?m)^\s*license\s*=", project_text): + line = f'license = {{text = "{license_text}"}}\n' + version_m = re.search(r"(?m)^(\s*version\s*=\s*[^\n]+\n)", project_text) + if version_m: + insert_at = version_m.end() + project_text = project_text[:insert_at] + line + project_text[insert_at:] + else: + project_text = "\n" + line + project_text + changed = True + print(f"Added pyproject.toml license: {license_text!r}") + + if license_classifier and f'"{license_classifier}"' not in project_text: + classifiers_m = re.search(r"(?ms)^(\s*classifiers\s*=\s*\[)(.*?)(^\s*\])", project_text) + if classifiers_m: + patched_body = classifiers_m.group(2) + f' "{license_classifier}",\n' + project_text = project_text[: classifiers_m.start(2)] + patched_body + project_text[classifiers_m.end(2) :] + changed = True + print(f"Added pyproject.toml classifier: {license_classifier!r}") + else: + project_text += "\nclassifiers = [\n" f' "{license_classifier}",\n' "]\n" + changed = True + print(f"Added pyproject.toml classifiers with: {license_classifier!r}") + + if not changed: + return False + + _write_text(pyproject, text[:core_start] + project_text + text[core_end:]) + return True + + +def _patch_setup_cfg_license( + project_dir: Path, + *, + license_text: str, + license_classifier: str | None, +) -> bool: + setup_cfg = project_dir / "setup.cfg" + if not setup_cfg.exists(): + return False + + text = _read_text(setup_cfg) + changed = False + + if not re.search(r"(?ms)^\[metadata\]\s.*?^\s*license\s*=", text): + metadata_m = re.search(r"(?ms)^(\[metadata\]\s*(?:#.*)?\n)", text) + if not metadata_m: + return False + insert_at = metadata_m.end() + text = text[:insert_at] + f"license = {license_text}\n" + text[insert_at:] + changed = True + print(f"Added setup.cfg license: {license_text!r}") + + if license_classifier and license_classifier not in text: + classifiers_m = re.search(r"(?ms)^(\s*classifiers\s*=\s*\n)(.*?)(^\S|\Z)", text) + if classifiers_m: + text = text[: classifiers_m.end(2)] + f" {license_classifier}\n" + text[classifiers_m.end(2) :] + else: + metadata_m = re.search(r"(?ms)^(\[metadata\]\s*(?:#.*)?\n)", text) + if not metadata_m: + return changed + insert_at = metadata_m.end() + block = f"classifiers =\n {license_classifier}\n" + text = text[:insert_at] + block + text[insert_at:] + changed = True + print(f"Added setup.cfg classifier: {license_classifier!r}") + + if not changed: + return False + + _write_text(setup_cfg, text) + return True + + +def _ensure_license_file(project_dir: Path, *, search_roots: list[Path]) -> bool: + """Copy a LICENSE file into the Python project dir when upstream keeps it elsewhere.""" + for candidate_name in _LICENSE_FILE_NAMES: + if (project_dir / candidate_name).is_file(): + return False + + for root in search_roots: + for candidate_name in _LICENSE_FILE_NAMES: + source = root / candidate_name + if source.is_file(): + dest = project_dir / "LICENSE" + shutil.copy2(source, dest) + print(f"Copied license file into project dir: {source} -> {dest}") + return True + + print("No LICENSE file found in upstream tree; continuing without a bundled license file.") + return False + + def _patch_pyproject_requires_python(repo_dir: Path, requires_python: str) -> bool: """ Set or relax the ``[project].requires-python`` constraint before building. @@ -815,6 +968,16 @@ def main() -> int: help="Set or relax the [project].requires-python constraint before building " "(e.g. '>=3.11,<3.14') so wheels built on a Python matrix stay pip-installable.", ) + ap.add_argument( + "--license-text", + default=_DEFAULT_LICENSE_TEXT, + help="Ensure [project].license metadata is set to this text when missing (PyPI display).", + ) + ap.add_argument( + "--license-classifier", + default=_DEFAULT_LICENSE_CLASSIFIER, + help="Ensure this Trove classifier is present when missing.", + ) ap.add_argument( "--rename-python-package", action="append", @@ -929,6 +1092,23 @@ def main() -> int: if not _patch_pyproject_requires_python(project_dir, args.set_requires_python): print(f"requires-python already set to {args.set_requires_python!r} or no [project] table found.") + if args.license_text: + print("=== Ensuring PyPI license metadata ===") + if not _patch_pyproject_license( + project_dir, + license_text=args.license_text, + license_classifier=args.license_classifier or None, + ): + _patch_setup_cfg_license( + project_dir, + license_text=args.license_text, + license_classifier=args.license_classifier or None, + ) + _ensure_license_file( + project_dir, + search_roots=[project_dir, project_dir.parent, repo_dir], + ) + if args.project_name: patched_name = _patch_pyproject_project_name( project_dir, diff --git a/ci/tests/test_huggingface_release_workflow.py b/ci/tests/test_huggingface_release_workflow.py index d2f430b093..26da329b82 100644 --- a/ci/tests/test_huggingface_release_workflow.py +++ b/ci/tests/test_huggingface_release_workflow.py @@ -199,6 +199,89 @@ def test_huggingface_ocr_builds_and_publishes_wheels_for_all_supported_pythons() assert "dist-${{ matrix.ocr.id }}-${{ matrix.platform.arch }}-${{ matrix.python.tag }}" in ocr_job +def test_nightly_builder_adds_license_metadata_for_ocr_like_pyproject(tmp_path: Path) -> None: + project_dir = tmp_path / "nemotron-ocr" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + """ +[project] +name = "nemotron-ocr" +version = "1.0.0" +description = "Nemoton OCR" +authors = [{ name = "NVIDIA Nemotron" }] +requires-python = ">=3.12,<3.13" +""".lstrip(), + encoding="utf-8", + ) + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._patch_pyproject_license( + project_dir, + license_text=nightly_build_publish._DEFAULT_LICENSE_TEXT, + license_classifier=nightly_build_publish._DEFAULT_LICENSE_CLASSIFIER, + ) + + text = pyproject.read_text(encoding="utf-8") + assert 'license = {text = "NVIDIA Open Model License"}' in text + assert '"License :: Other/Proprietary License",' in text + assert not nightly_build_publish._patch_pyproject_license( + project_dir, + license_text=nightly_build_publish._DEFAULT_LICENSE_TEXT, + license_classifier=nightly_build_publish._DEFAULT_LICENSE_CLASSIFIER, + ) + + +def test_nightly_builder_copies_parent_license_into_project_subdir(tmp_path: Path) -> None: + repo_dir = tmp_path / "nemotron-ocr-v2" + project_dir = repo_dir / "nemotron-ocr" + project_dir.mkdir(parents=True) + (repo_dir / "LICENSE").write_text("NVIDIA Open Model License Agreement\n", encoding="utf-8") + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._ensure_license_file( + project_dir, + search_roots=[project_dir, project_dir.parent, repo_dir], + ) + assert (project_dir / "LICENSE").read_text(encoding="utf-8").startswith("NVIDIA Open Model License Agreement") + assert not nightly_build_publish._ensure_license_file( + project_dir, + search_roots=[project_dir, project_dir.parent, repo_dir], + ) + + +def test_nightly_builder_leaves_existing_license_metadata_unchanged(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + original = """ +[project] +name = "nemotron-page-elements-v3" +version = "3.0.1" +license = {text = "NVIDIA Open Model License"} +classifiers = [ + "License :: Other/Proprietary License", +] +""".lstrip() + pyproject.write_text(original, encoding="utf-8") + nightly_build_publish = _load_nightly_build_publish_module() + + assert not nightly_build_publish._patch_pyproject_license( + project_dir, + license_text=nightly_build_publish._DEFAULT_LICENSE_TEXT, + license_classifier=nightly_build_publish._DEFAULT_LICENSE_CLASSIFIER, + ) + assert pyproject.read_text(encoding="utf-8") == original + + +def test_huggingface_workflow_verifies_ocr_wheel_license_metadata() -> None: + workflow = (REPO_ROOT / ".github" / "workflows" / "huggingface-nightly.yml").read_text(encoding="utf-8") + + assert 'expected_license = "NVIDIA Open Model License"' in workflow + assert 'expected_license_classifier = "License :: Other/Proprietary License"' in workflow + assert "Built wheel metadata does not declare expected license" in workflow + + def test_huggingface_nightly_builder_defaults_to_public_pypi() -> None: script = (REPO_ROOT / "ci" / "scripts" / "nightly_build_publish.py").read_text(encoding="utf-8") diff --git a/scripts/init_nemotron_ocr_v2.py b/scripts/init_nemotron_ocr_v2.py new file mode 100755 index 0000000000..6f39e83db2 --- /dev/null +++ b/scripts/init_nemotron_ocr_v2.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Initialize Nemotron OCR v2 from Hugging Face (nvidia/nemotron-ocr-v2). + +Requires the ``nemotron-ocr`` package (>=2.0.0), PyTorch with CUDA, and Python 3.12. +Install: https://huggingface.co/nvidia/nemotron-ocr-v2 +""" + +from __future__ import annotations + +import argparse + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Load and initialize NemotronOCRV2 from Hugging Face.", + ) + parser.add_argument( + "--lang", + default="multi", + help='Hub bundle selector: "multi" (default), "en", or "v1"/"legacy".', + ) + parser.add_argument( + "--model-dir", + default=None, + help="Local checkpoint directory with detector.pth, recognizer.pth, etc.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + from nemotron_ocr.inference.pipeline_v2 import NemotronOCRV2 + + if args.model_dir: + ocr = NemotronOCRV2(model_dir=args.model_dir) + else: + ocr = NemotronOCRV2(lang=args.lang) + + print(f"Initialized NemotronOCRV2: {ocr!r}") + + +if __name__ == "__main__": + main() From f115f73b1ca8ac5e8e9753c5479b343be16b48ab Mon Sep 17 00:00:00 2001 From: Jeremy Dyer Date: Wed, 15 Jul 2026 12:12:13 -0400 Subject: [PATCH 3/4] Adjust for aarch64 support --- .github/workflows/huggingface-nightly.yml | 30 +++++++++++++++++-- ci/tests/test_huggingface_release_workflow.py | 8 +++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/huggingface-nightly.yml b/.github/workflows/huggingface-nightly.yml index 7120156b7c..75176b74c8 100644 --- a/.github/workflows/huggingface-nightly.yml +++ b/.github/workflows/huggingface-nightly.yml @@ -204,8 +204,34 @@ jobs: shell: bash run: | set -euo pipefail - apt-get update - apt-get install -y --no-install-recommends \ + + # GitHub arm runners often cannot reach Ubuntu HTTP mirrors (port 80). + # Rewrite apt sources to HTTPS before the first update. + for apt_list in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + [ -f "${apt_list}" ] || continue + sed -i \ + -e 's|http://ports.ubuntu.com|https://ports.ubuntu.com|g' \ + -e 's|http://archive.ubuntu.com|https://archive.ubuntu.com|g' \ + -e 's|http://security.ubuntu.com|https://security.ubuntu.com|g' \ + "${apt_list}" + done + + apt_retry() { + local attempt=1 + local max_attempts=5 + until apt-get "$@"; do + if (( attempt >= max_attempts )); then + return 1 + fi + attempt=$((attempt + 1)) + echo "apt-get $* failed (attempt ${attempt}/${max_attempts}); retrying..." + sleep $((attempt * 5)) + apt-get update || true + done + } + + apt_retry update + apt_retry install -y --no-install-recommends \ ca-certificates \ git \ git-lfs \ diff --git a/ci/tests/test_huggingface_release_workflow.py b/ci/tests/test_huggingface_release_workflow.py index 26da329b82..6efcec57d4 100644 --- a/ci/tests/test_huggingface_release_workflow.py +++ b/ci/tests/test_huggingface_release_workflow.py @@ -282,6 +282,14 @@ def test_huggingface_workflow_verifies_ocr_wheel_license_metadata() -> None: assert "Built wheel metadata does not declare expected license" in workflow +def test_huggingface_ocr_job_prefers_https_apt_mirrors_on_arm() -> None: + workflow = (REPO_ROOT / ".github" / "workflows" / "huggingface-nightly.yml").read_text(encoding="utf-8") + + ocr_job = workflow.split("build_ocr_cuda:", 1)[1] + assert "http://ports.ubuntu.com|https://ports.ubuntu.com" in ocr_job + assert "apt_retry update" in ocr_job + + def test_huggingface_nightly_builder_defaults_to_public_pypi() -> None: script = (REPO_ROOT / "ci" / "scripts" / "nightly_build_publish.py").read_text(encoding="utf-8") From 9515f1406faa9abac497fd46b8d440be68e0d715 Mon Sep 17 00:00:00 2001 From: Jeremy Dyer Date: Wed, 15 Jul 2026 16:48:06 -0400 Subject: [PATCH 4/4] Update license for wheel to ASLv2 --- .github/workflows/huggingface-nightly.yml | 4 +- ci/scripts/nightly_build_publish.py | 64 +++++++++++------ ci/tests/test_huggingface_release_workflow.py | 71 ++++++++++++++++--- nemo_retriever/pyproject.toml | 2 +- scripts/init_nemotron_ocr_v2.py | 5 +- 5 files changed, 112 insertions(+), 34 deletions(-) diff --git a/.github/workflows/huggingface-nightly.yml b/.github/workflows/huggingface-nightly.yml index 75176b74c8..0f9b32f51c 100644 --- a/.github/workflows/huggingface-nightly.yml +++ b/.github/workflows/huggingface-nightly.yml @@ -382,8 +382,8 @@ jobs: "torch": f"~={os.environ['OCR_TORCH_VERSION']}", "torchvision": f"~={os.environ['OCR_TORCHVISION_VERSION']}", } - expected_license = "NVIDIA Open Model License" - expected_license_classifier = "License :: Other/Proprietary License" + expected_license = "Apache-2.0" + expected_license_classifier = "License :: OSI Approved :: Apache Software License" def expanded_compatible_specifier(specifier): if not specifier.startswith("~="): diff --git a/ci/scripts/nightly_build_publish.py b/ci/scripts/nightly_build_publish.py index d6823a33f7..90f3bd1a67 100644 --- a/ci/scripts/nightly_build_publish.py +++ b/ci/scripts/nightly_build_publish.py @@ -492,9 +492,16 @@ def replace_dependency(m: re.Match[str]) -> str: return True -_DEFAULT_LICENSE_TEXT = "NVIDIA Open Model License" -_DEFAULT_LICENSE_CLASSIFIER = "License :: Other/Proprietary License" -_LICENSE_FILE_NAMES = ("LICENSE", "LICENSE.txt", "LICENSE.md") +_DEFAULT_LICENSE_TEXT = "Apache-2.0" +_DEFAULT_LICENSE_CLASSIFIER = "License :: OSI Approved :: Apache Software License" +_PROPRIETARY_LICENSE_CLASSIFIER = "License :: Other/Proprietary License" +_ORCHESTRATOR_APACHE_LICENSE = Path(__file__).resolve().parents[2] / "nemo_retriever" / "LICENSE" + + +def _pyproject_license_line(license_text: str) -> str: + if license_text == "Apache-2.0": + return 'license = "Apache-2.0"\n' + return f'license = {{text = "{license_text}"}}\n' def _patch_pyproject_license( @@ -506,9 +513,9 @@ def _patch_pyproject_license( """ Ensure PyPI-visible license metadata is present in ``[project]``. - Upstream HF repos (e.g. Nemotron OCR) may omit ``license`` and Trove - classifiers even though the model is governed by the NVIDIA Open Model - License. Hatch/setuptools only emit wheel/sdist METADATA from these fields. + Published Nemotron Python packages ship source code governed by Apache-2.0. + Upstream HF repos may omit license metadata or declare the NVIDIA Open Model + License for the overall model card even though the wheel/sdist is source code. """ pyproject = project_dir / "pyproject.toml" if not pyproject.exists(): @@ -524,7 +531,7 @@ def _patch_pyproject_license( changed = False if not re.search(r"(?m)^\s*license\s*=", project_text): - line = f'license = {{text = "{license_text}"}}\n' + line = _pyproject_license_line(license_text) version_m = re.search(r"(?m)^(\s*version\s*=\s*[^\n]+\n)", project_text) if version_m: insert_at = version_m.end() @@ -533,6 +540,14 @@ def _patch_pyproject_license( project_text = "\n" + line + project_text changed = True print(f"Added pyproject.toml license: {license_text!r}") + else: + license_m = re.search(r"(?m)^(\s*license\s*=\s*)(.+)$", project_text) + if license_m: + desired = _pyproject_license_line(license_text).strip() + if license_m.group(0).strip() != desired: + project_text = project_text[: license_m.start()] + desired + "\n" + project_text[license_m.end() :] + changed = True + print(f"Patched pyproject.toml license -> {license_text!r}") if license_classifier and f'"{license_classifier}"' not in project_text: classifiers_m = re.search(r"(?ms)^(\s*classifiers\s*=\s*\[)(.*?)(^\s*\])", project_text) @@ -546,6 +561,12 @@ def _patch_pyproject_license( changed = True print(f"Added pyproject.toml classifiers with: {license_classifier!r}") + if license_classifier == _DEFAULT_LICENSE_CLASSIFIER and f'"{_PROPRIETARY_LICENSE_CLASSIFIER}"' in project_text: + project_text = project_text.replace(f' "{_PROPRIETARY_LICENSE_CLASSIFIER}",\n', "") + project_text = project_text.replace(f'"{_PROPRIETARY_LICENSE_CLASSIFIER}",\n', "") + changed = True + print(f"Removed pyproject.toml classifier: {_PROPRIETARY_LICENSE_CLASSIFIER!r}") + if not changed: return False @@ -597,22 +618,21 @@ def _patch_setup_cfg_license( def _ensure_license_file(project_dir: Path, *, search_roots: list[Path]) -> bool: - """Copy a LICENSE file into the Python project dir when upstream keeps it elsewhere.""" - for candidate_name in _LICENSE_FILE_NAMES: - if (project_dir / candidate_name).is_file(): - return False + """Bundle the Apache-2.0 LICENSE that governs published Python package source.""" + _ = search_roots # kept for call-site compatibility; source is orchestrator-local. + source = _ORCHESTRATOR_APACHE_LICENSE + if not source.is_file(): + print(f"No Apache-2.0 license source found at {source}; continuing without bundled LICENSE.") + return False - for root in search_roots: - for candidate_name in _LICENSE_FILE_NAMES: - source = root / candidate_name - if source.is_file(): - dest = project_dir / "LICENSE" - shutil.copy2(source, dest) - print(f"Copied license file into project dir: {source} -> {dest}") - return True + dest = project_dir / "LICENSE" + apache_text = _read_text(source) + if dest.is_file() and _read_text(dest) == apache_text: + return False - print("No LICENSE file found in upstream tree; continuing without a bundled license file.") - return False + _write_text(dest, apache_text) + print(f"Wrote Apache-2.0 license file into project dir: {source} -> {dest}") + return True def _patch_pyproject_requires_python(repo_dir: Path, requires_python: str) -> bool: @@ -636,7 +656,7 @@ def _patch_pyproject_requires_python(repo_dir: Path, requires_python: str) -> bo project_start, project_end = bounds project_text = text[project_start:project_end] - m = re.search(r'(?m)^(\s*requires-python\s*=\s*")([^"]*)(")\s*$', project_text) + m = re.search(r"""(?m)^(\s*requires-python\s*=\s*["'])([^"']*)(['"])\s*$""", project_text) if m: old_value = m.group(2) if old_value == requires_python: diff --git a/ci/tests/test_huggingface_release_workflow.py b/ci/tests/test_huggingface_release_workflow.py index 6efcec57d4..7350dc251b 100644 --- a/ci/tests/test_huggingface_release_workflow.py +++ b/ci/tests/test_huggingface_release_workflow.py @@ -128,6 +128,28 @@ def test_nightly_builder_rejects_non_stable_release_versions(version: str) -> No nightly_build_publish._pep440_stable_release(version) +def test_nightly_builder_relaxes_single_quoted_requires_python(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + """ +[project] +name = "example" +version = "2.0.0" +requires-python = '>=3.12,<3.13' +""".lstrip(), + encoding="utf-8", + ) + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._patch_pyproject_requires_python(project_dir, ">=3.11,<3.14") + + text = pyproject.read_text(encoding="utf-8") + assert "requires-python = '>=3.11,<3.14'" in text + assert text.count("requires-python") == 1 + + def test_nightly_builder_rejects_empty_release_version_with_nightly_base( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -223,8 +245,8 @@ def test_nightly_builder_adds_license_metadata_for_ocr_like_pyproject(tmp_path: ) text = pyproject.read_text(encoding="utf-8") - assert 'license = {text = "NVIDIA Open Model License"}' in text - assert '"License :: Other/Proprietary License",' in text + assert 'license = "Apache-2.0"' in text + assert '"License :: OSI Approved :: Apache Software License",' in text assert not nightly_build_publish._patch_pyproject_license( project_dir, license_text=nightly_build_publish._DEFAULT_LICENSE_TEXT, @@ -232,7 +254,7 @@ def test_nightly_builder_adds_license_metadata_for_ocr_like_pyproject(tmp_path: ) -def test_nightly_builder_copies_parent_license_into_project_subdir(tmp_path: Path) -> None: +def test_nightly_builder_writes_orchestrator_apache_license_into_project_subdir(tmp_path: Path) -> None: repo_dir = tmp_path / "nemotron-ocr-v2" project_dir = repo_dir / "nemotron-ocr" project_dir.mkdir(parents=True) @@ -243,18 +265,21 @@ def test_nightly_builder_copies_parent_license_into_project_subdir(tmp_path: Pat project_dir, search_roots=[project_dir, project_dir.parent, repo_dir], ) - assert (project_dir / "LICENSE").read_text(encoding="utf-8").startswith("NVIDIA Open Model License Agreement") + bundled = (project_dir / "LICENSE").read_text(encoding="utf-8") + assert bundled.startswith(" Apache License") + assert "NVIDIA Open Model License Agreement" not in bundled assert not nightly_build_publish._ensure_license_file( project_dir, search_roots=[project_dir, project_dir.parent, repo_dir], ) -def test_nightly_builder_leaves_existing_license_metadata_unchanged(tmp_path: Path) -> None: +def test_nightly_builder_replaces_existing_nvidia_open_model_license_metadata(tmp_path: Path) -> None: project_dir = tmp_path / "project" project_dir.mkdir() pyproject = project_dir / "pyproject.toml" - original = """ + pyproject.write_text( + """ [project] name = "nemotron-page-elements-v3" version = "3.0.1" @@ -262,6 +287,36 @@ def test_nightly_builder_leaves_existing_license_metadata_unchanged(tmp_path: Pa classifiers = [ "License :: Other/Proprietary License", ] +""".lstrip(), + encoding="utf-8", + ) + nightly_build_publish = _load_nightly_build_publish_module() + + assert nightly_build_publish._patch_pyproject_license( + project_dir, + license_text=nightly_build_publish._DEFAULT_LICENSE_TEXT, + license_classifier=nightly_build_publish._DEFAULT_LICENSE_CLASSIFIER, + ) + + text = pyproject.read_text(encoding="utf-8") + assert 'license = "Apache-2.0"' in text + assert "NVIDIA Open Model License" not in text + assert "License :: OSI Approved :: Apache Software License" in text + assert "License :: Other/Proprietary License" not in text + + +def test_nightly_builder_leaves_existing_apache_license_metadata_unchanged(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + original = """ +[project] +name = "nemotron-page-elements-v3" +version = "3.0.1" +license = "Apache-2.0" +classifiers = [ + "License :: OSI Approved :: Apache Software License", +] """.lstrip() pyproject.write_text(original, encoding="utf-8") nightly_build_publish = _load_nightly_build_publish_module() @@ -277,8 +332,8 @@ def test_nightly_builder_leaves_existing_license_metadata_unchanged(tmp_path: Pa def test_huggingface_workflow_verifies_ocr_wheel_license_metadata() -> None: workflow = (REPO_ROOT / ".github" / "workflows" / "huggingface-nightly.yml").read_text(encoding="utf-8") - assert 'expected_license = "NVIDIA Open Model License"' in workflow - assert 'expected_license_classifier = "License :: Other/Proprietary License"' in workflow + assert 'expected_license = "Apache-2.0"' in workflow + assert 'expected_license_classifier = "License :: OSI Approved :: Apache Software License"' in workflow assert "Built wheel metadata does not declare expected license" in workflow diff --git a/nemo_retriever/pyproject.toml b/nemo_retriever/pyproject.toml index 61b794a8b7..3b7e882311 100644 --- a/nemo_retriever/pyproject.toml +++ b/nemo_retriever/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=78.1.1", "wheel>=0.46.2"] +requires = ["setuptools", "wheel>=0.46.2", "packaging"] build-backend = "setuptools.build_meta" [tool.uv] diff --git a/scripts/init_nemotron_ocr_v2.py b/scripts/init_nemotron_ocr_v2.py index 6f39e83db2..c5d0af6ba2 100755 --- a/scripts/init_nemotron_ocr_v2.py +++ b/scripts/init_nemotron_ocr_v2.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 """Initialize Nemotron OCR v2 from Hugging Face (nvidia/nemotron-ocr-v2). -Requires the ``nemotron-ocr`` package (>=2.0.0), PyTorch with CUDA, and Python 3.12. +Requires the ``nemotron-ocr`` package (>=2.0.0), PyTorch with CUDA, and Python 3.11–3.13. Install: https://huggingface.co/nvidia/nemotron-ocr-v2 """