Skip to content

feat: build and publish Nemotron OCR wheels for Python 3.11, 3.12, and 3.13#2362

Merged
jdye64 merged 5 commits into
mainfrom
cursor/ocr-wheels-py311-py313-cb4a
Jul 20, 2026
Merged

feat: build and publish Nemotron OCR wheels for Python 3.11, 3.12, and 3.13#2362
jdye64 merged 5 commits into
mainfrom
cursor/ocr-wheels-py311-py313-cb4a

Conversation

@jdye64

@jdye64 jdye64 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Description

The Hugging Face nightly/release workflow (.github/workflows/huggingface-nightly.yml) builds NVIDIA model wheels and publishes them to PyPI. The build_ocr_cuda job — which builds nemotron-ocr-v2 (a package with a compiled C++ extension, nemotron_ocr_cpp) — only produced Python 3.12 wheels because setup-python was hardcoded to "3.12".

Investigation

  • The C++ extension is built via torch's cpp_extension (BUILD_CPP_EXTENSION=1) against the active interpreter's ABI, and torch 2.11.0 ships cp311/cp312/cp313 wheels — so per-interpreter compilation works for 3.11–3.13.
  • The real reason it "only worked with 3.12" is the wheel metadata requires-python inherited from the upstream package (the old workflow comment even said 3.12 was chosen "to match upstream package constraints"). pip on 3.11/3.13 refuses to install a wheel whose metadata restricts Python to 3.12.

Changes

  • ci/scripts/nightly_build_publish.py
    • Add --set-requires-python to relax/insert [project].requires-python before building, so wheels built on a Python matrix stay pip-installable on every targeted interpreter.
    • Add --skip-sdist so the interpreter-agnostic sdist is produced (and uploaded) exactly once across a matrix, instead of once per leg.
  • .github/workflows/huggingface-nightly.yml (build_ocr_cuda job)
    • Add a python matrix dimension (3.11 / 3.12 / 3.13) and drive setup-python from it (2 archs × 3 pythons = 6 legs).
    • Relax the OCR wheel metadata to requires-python = ">=3.11,<3.14".
    • Build the sdist only on the x86_64 + 3.12 leg (--skip-sdist elsewhere) to avoid duplicate-upload failures on stable releases.
    • Make GH Actions artifact names unique per arch and Python version.

Because both the scheduled nightly and the manual stable release (workflow_dispatch with release_type=stable) flow through this same job, the new matrix covers both nightly and release-time publishing. This is the only workflow that builds/publishes OCR wheels.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

Testing notes

  • Added unit tests for --set-requires-python (replace existing constraint and insert-when-missing, with no-op re-run) and a workflow assertion that all three Python versions build, requires-python is relaxed, the sdist is built once, and artifact names are unique.
  • ci/tests/test_huggingface_release_workflow.py passes; black (pinned 25.9.0) and flake8 are clean; the workflow YAML parses to the expected 6-leg matrix.
  • The branch was synced with main to pick up the Typer 0.27 help-formatting fix (Adopt Typer 0.27 help formatting #2372) that had been failing the unit tests, and the OCR wheel-member validation from Fix OCR v2 revision pinning and validate nightly wheel contents #2375 (which coexists with the new flags). All CI checks are green.
  • The end-to-end CUDA build itself requires the gated Hugging Face repo + CUDA runners, so it was not executed in this environment.
Open in Web Open in Cursor 

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 <jdye64@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@jdye64
jdye64 marked this pull request as ready for review July 15, 2026 16:49
@jdye64
jdye64 requested review from a team as code owners July 15, 2026 16:49
@jdye64
jdye64 requested a review from jioffe502 July 15, 2026 16:49
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR expands the build_ocr_cuda CI job from a single Python 3.12 build to a 2-arch × 3-Python matrix (3.11/3.12/3.13), adds --set-requires-python and --skip-sdist to nightly_build_publish.py so each leg produces an ABI-tagged wheel while the sdist is uploaded exactly once, and adds license-metadata patching with verification in the post-build check step.

  • Workflow matrix expansion: setup-python is now driven by matrix.python.version; artifact names include the CPython tag (cp311/cp312/cp313) to avoid collisions; sdist is gated to the x86_64 + 3.12 leg.
  • Script additions: --set-requires-python \">=3.11,<3.14\" relaxes the upstream requires-python before building; --skip-sdist prevents duplicate-upload failures on stable releases.
  • Unrelated change in nemo_retriever/pyproject.toml: setuptools>=78.1.1 was silently downgraded to setuptools (no version floor) and packaging was added without a pin — this change is not mentioned in the PR description and deserves explicit confirmation.

Confidence Score: 4/5

Safe to merge for the OCR wheel matrix work; the unrelated setuptools pin removal in nemo_retriever/pyproject.toml warrants confirmation before merging.

The OCR workflow and script changes are well-structured and thoroughly tested (14 unit tests, workflow-YAML assertions, single-quote regex coverage). The one concern is the nemo_retriever/pyproject.toml change that drops setuptools>=78.1.1 to an unpinned setuptools — this is unrelated to the stated PR goals, not mentioned in the description, and could silently allow builds to use older, incompatible setuptools in fresh environments. Until that removal is confirmed intentional, there is a latent build regression risk.

nemo_retriever/pyproject.toml — the build-system setuptools version pin removal is the only change that warrants a second look before merging.

Important Files Changed

Filename Overview
.github/workflows/huggingface-nightly.yml Adds 3.11/3.12/3.13 Python matrix to build_ocr_cuda job; unique artifact names per arch+tag; apt retry with HTTPS mirror fallback for ARM runners; sdist built exactly once on x86_64+3.12
ci/scripts/nightly_build_publish.py Adds --set-requires-python, --skip-sdist, license patching functions; venv usability check improved; good test coverage for all new paths
ci/tests/test_huggingface_release_workflow.py New tests for requires-python patching (existing/missing/single-quoted), license metadata, and workflow structure assertions; coverage is thorough
nemo_retriever/pyproject.toml Drops setuptools>=78.1.1 version pin (unrelated to PR goals) and adds packaging without a version constraint; setuptools floor removal could allow old incompatible versions
ci/scripts/build_ocr_wheels_local.sh New local helper script that builds OCR wheels for Python 3.11 and 3.13 (3.12 is the system Python); correctly mirrors CI behaviour with --set-requires-python and --skip-sdist
scripts/init_nemotron_ocr_v2.py New initialisation helper with SPDX header present, updated Python range in docstring (3.11–3.13); straightforward
.gitignore Adds .work*/ and dist* gitignore globs; dist* is broad (previously flagged) but not a blocking issue

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[build_ocr_cuda trigger
nightly / workflow_dispatch] --> B{Matrix
2 arch x 3 Python}
    B --> C1[x86_64 + 3.11]
    B --> C2[x86_64 + 3.12]
    B --> C3[x86_64 + 3.13]
    B --> C4[aarch64 + 3.11]
    B --> C5[aarch64 + 3.12]
    B --> C6[aarch64 + 3.13]
    C2 -->|sdist_arg=''| D2[build wheel + sdist]
    C1 -->|--skip-sdist| D1[build wheel only]
    C3 -->|--skip-sdist| D3[build wheel only]
    C4 -->|--skip-sdist| D4[build wheel only]
    C5 -->|--skip-sdist| D5[build wheel only]
    C6 -->|--skip-sdist| D6[build wheel only]
    D1 & D2 & D3 & D4 & D5 & D6 --> E[nightly_build_publish.py]
    E --> F[patch requires-python to >=3.11,<3.14]
    F --> G[hatch build + auditwheel repair]
    G --> H{upload_to}
    H -->|nightly| I[TestPyPI]
    H -->|stable| J[PyPI]
    G --> K[upload-artifact dist-id-arch-cpXYZ]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[build_ocr_cuda trigger
nightly / workflow_dispatch] --> B{Matrix
2 arch x 3 Python}
    B --> C1[x86_64 + 3.11]
    B --> C2[x86_64 + 3.12]
    B --> C3[x86_64 + 3.13]
    B --> C4[aarch64 + 3.11]
    B --> C5[aarch64 + 3.12]
    B --> C6[aarch64 + 3.13]
    C2 -->|sdist_arg=''| D2[build wheel + sdist]
    C1 -->|--skip-sdist| D1[build wheel only]
    C3 -->|--skip-sdist| D3[build wheel only]
    C4 -->|--skip-sdist| D4[build wheel only]
    C5 -->|--skip-sdist| D5[build wheel only]
    C6 -->|--skip-sdist| D6[build wheel only]
    D1 & D2 & D3 & D4 & D5 & D6 --> E[nightly_build_publish.py]
    E --> F[patch requires-python to >=3.11,<3.14]
    F --> G[hatch build + auditwheel repair]
    G --> H{upload_to}
    H -->|nightly| I[TestPyPI]
    H -->|stable| J[PyPI]
    G --> K[upload-artifact dist-id-arch-cpXYZ]
Loading

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread scripts/init_nemotron_ocr_v2.py
Comment thread scripts/init_nemotron_ocr_v2.py Outdated
Comment thread ci/scripts/nightly_build_publish.py Outdated
Comment thread .gitignore
Comment thread .github/workflows/huggingface-nightly.yml
text = _read_text(setup_cfg)
changed = False

if not re.search(r"(?ms)^\[metadata\]\s.*?^\s*license\s*=", text):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dotall regex can match license = across section boundaries

The (?ms) flags make .*? span newlines and even entire sections. Given a setup.cfg where license lives in [options] (or any section other than [metadata]), the regex ^\[metadata\]\s.*?^\s*license\s*= will still match — the .*? bridge crosses the section boundary. The function will therefore believe [metadata] already contains a license key, skip the insertion, and silently publish the wheel to PyPI with no license metadata in [metadata]. Fixing this requires the pattern to stop at the next section header so it only matches license = within [metadata].

Suggested change
if not re.search(r"(?ms)^\[metadata\]\s.*?^\s*license\s*=", text):
if not re.search(r"(?m)^\[metadata\][ \t]*(?:#.*)?\n(?:(?!^\[).*\n)*?[ \t]*license\s*=", text):
Prompt To Fix With AI
This is a comment left during a code review.
Path: ci/scripts/nightly_build_publish.py
Line: 590

Comment:
**Dotall regex can match `license =` across section boundaries**

The `(?ms)` flags make `.*?` span newlines and even entire sections. Given a `setup.cfg` where `license` lives in `[options]` (or any section other than `[metadata]`), the regex `^\[metadata\]\s.*?^\s*license\s*=` will still match — the `.*?` bridge crosses the section boundary. The function will therefore believe `[metadata]` already contains a license key, skip the insertion, and silently publish the wheel to PyPI with no license metadata in `[metadata]`. Fixing this requires the pattern to stop at the next section header so it only matches `license =` within `[metadata]`.

```suggestion
    if not re.search(r"(?m)^\[metadata\][ \t]*(?:#.*)?\n(?:(?!^\[).*\n)*?[ \t]*license\s*=", text):
```

How can I resolve this? If you propose a fix, please make it concise.

…11-py313-cb4a

Co-authored-by: Jeremy Dyer <jdye64@gmail.com>
@jdye64
jdye64 merged commit bec3622 into main Jul 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants