Skip to content

feat: modernize to uv + pyproject.toml + semantic-release - #461

Open
irfanuddinahmad wants to merge 10 commits into
openedx:masterfrom
irfanuddinahmad:irfanuddinahmad/modernize-python-tooling
Open

feat: modernize to uv + pyproject.toml + semantic-release#461
irfanuddinahmad wants to merge 10 commits into
openedx:masterfrom
irfanuddinahmad:irfanuddinahmad/modernize-python-tooling

Conversation

@irfanuddinahmad

@irfanuddinahmad irfanuddinahmad commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Modernize opaque-keys to uv + pyproject.toml (PEP 621/735) + python-semantic-release, and adopt src/ layout.

Part of openedx/public-engineering#506 (tracked in openedx/public-engineering#516).

  • Move opaque_keys/ to src/opaque_keys/ (see "src/ layout" below).
  • Replace setup.py with pyproject.toml (PEP 621 static metadata); remove the
    hardcoded __version__ in favor of importlib.metadata + setuptools-scm.
  • Switch from pip-compile to uv with PEP 735 dependency groups (test-base,
    test, django42, quality, doc, ci, dev); commit uv.lock.
  • Update tox.ini to use tox-uv's uv-venv-lock-runner.
  • Update CI to install uv (pinned to a commit SHA) and run uv sync / uv run tox.
  • Add python-semantic-release + release.yml, publishing to PyPI via OIDC.
    commitlint.yml already existed and needed no changes.

src/ layout

Per the 2026-07-15 decision on openedx/public-engineering#506 ("src/ layout is in
scope for this cycle"), motivated by flat-layout editable installs of packages like
opaque-keys not being reliably resolvable by mypy. I verified the cited precedent
directly rather than trusting the decision comment's own description of it:
openedx/xblocks-extra does have a real src/ directory (used as the reference for
the package-dir/packages.find config here); openedx/XBlock, despite being named
as the other precedent, does not actually have one as of this writing.

mypy.ini switched from files = opaque_keys (a literal path, now stale) to
packages = opaque_keys (import-based resolution via the editable install) — that's
the actual fix for the problem this decision cites, not just a path rename. Verified
locally: editable install resolves opaque_keys to src/opaque_keys/__init__.py,
mypy/pylint/pycodestyle all pass, the full test suite passes (see Testing
Notes), and a built wheel correctly installs opaque_keys at the top level.

One side effect: the top-level settings test-settings package (previously bundled
in the wheel only as an artifact of setup.py's bare find_packages()) is no longer
picked up, since it lives outside src/. That's now the correct scope for it.

Removed

Deleted files: setup.py, requirements/*.in, requirements/*.txt,
.github/workflows/pypi-publish.yml (superseded by release.yml's
publish_to_pypi job — the old token-based workflow triggered on
release: published and would otherwise race the new OIDC-based publish once
semantic-release starts creating releases/tags).

Not included

  • Ruff: per the 2026-07-15 discussion on Modernize Python repos: pyproject.toml + uv + semantic-release public-engineering#506, ruff
    adoption is out of scope for this migration cycle (its own separate epic). Existing
    pylint/pycodestyle/mypy config is left untouched.
  • edx-platform-style constraint-sync script: not applicable — this repo never
    had the requirements/edx-platform-constraints.txt + check_pins.py mechanism
    that pattern replaces.

Known out-of-scope gaps (flagging, not silently working around)

  • PyPI trusted publisher (OIDC) — pre-merge blocker. release.yml's
    publish_to_pypi job publishes via OIDC (id-token: write, no stored
    credentials) instead of the old PYPI_UPLOAD_TOKEN. This requires a PyPI
    project maintainer to add a trusted publisher for edx-opaque-keys (PyPI project
    PublishingAdd a new publisher → GitHub, repo openedx/opaque-keys,
    workflow release.yml, no environment) before the first release can actually
    publish. Not a blocker for reviewing/merging this PR, but publish_to_pypi will
    fail on the first push to master until it's done.
  • .github/workflows/upgrade-python-requirements.yml. This repo has one,
    calling openedx/.github's shared reusable workflow, which hardcodes
    ADD_PATHS="requirements" for its PR-creation step with no add_paths-style
    input exposed via workflow_call. Now that requirements/ is deleted, make upgrade's real output (uv.lock/pyproject.toml changes) won't match that
    hardcoded glob — the scheduled job will keep running but silently stop producing
    real dependency-upgrade PRs (empty diff, no failure, no alert). This can't be
    fixed from this repo; it needs a fix in openedx/.github itself (parameterize
    add_paths, or auto-detect uv.lock vs. requirements/).

Versioning

Dynamic, via setuptools-scm reading the latest git tag. The latest tag (4.0.0)
matches the latest version published on PyPI, so python-semantic-release will
compute the next version from that baseline correctly.

Testing Notes

Verified locally (macOS, Python 3.12), both before and after the src/ layout move:

  • uv lock resolves cleanly (86 packages); uv lock --check confirms it stays
    in sync with pyproject.toml after each change.
  • uv sync --group dev succeeds; editable install resolves opaque_keys to
    src/opaque_keys/__init__.py.
  • uv run tox -e django42 — 407 passed.
  • uv run tox -e without-django — 394 passed.
  • uv run tox -e docs — builds cleanly.
  • uv run tox -e qualitymypy passes cleanly (30 source files) using
    packages = opaque_keys; pylint/pycodestyle pass (targeting src/opaque_keys
    now). pylint initially reported no-member false positives on Django field
    super() calls once the quality env correctly started installing Django (it
    silently hadn't before, under the old pip-based tooling) — fixed with targeted
    # pylint: disable=no-member comments; confirmed not a real bug.
  • python -m build, including with SETUPTOOLS_SCM_PRETEND_VERSION set (the
    mechanism python-semantic-release's build_command relies on) — both build
    correctly-versioned wheels/sdists with the expected metadata, entry points, and
    (post-src/-move) top-level opaque_keys package layout.
  • CI-only issues found and fixed after opening this PR: codecov/patch (an
    unreachable importlib.metadata fallback line, fixed with pragma: no cover,
    and fully moving coverage config off a stale .coveragerc that was still
    shadowing pyproject.toml); codecov/project (a one-time ~1.6 point drop from
    94.03%→92.46% once test files were correctly excluded from coverage measurement,
    not a real regression — patch coverage is 100% — absorbed with a codecov.yml
    project threshold, matching the same pattern applied to ccx-keys/
    openedx-filters in this same effort); .readthedocs.yaml (still pointed at
    the deleted requirements/doc.txt, switched to method: uv/groups: [doc],
    matching openedx/XBlock's working config).

Code reviewer notes

  • django42 is the only Django version group for now (this repo's tox.ini
    envlist has only ever tested one Django version at a time); adding a
    django52-style group later just needs [tool.uv].conflicts if two Django
    pins need to coexist in one lockfile.

This PR was created with Claude Code.

Replace setup.py with PEP 621 static metadata in pyproject.toml:
name, description, license (SPDX expression), authors, classifiers,
dependencies, optional-dependencies, entry points, and setuptools-scm
based dynamic versioning.

Remove the hardcoded __version__ from opaque_keys/__init__.py (now
permanently stale under setuptools-scm) in favor of reading it from
package metadata via importlib.metadata at runtime.

Part of openedx/public-engineering#506.
Replace requirements/*.in + pip-compile with PEP 735 dependency groups
in pyproject.toml (test-base, test, django42, quality, doc, ci, dev),
managed and locked via uv:

- Add [tool.uv].constraint-dependencies, machine-managed by
  `edx_lint write_uv_constraints` (see [tool.edx_lint].uv_constraints
  for repo-specific overrides).
- Commit uv.lock.
- Delete the requirements/ directory; update MANIFEST.in accordingly.
- Update tox.ini to use tox-uv's uv-venv-lock-runner with
  dependency_groups instead of deps/-r requirements files.
- Update the Makefile's requirements/test/upgrade targets to use
  `uv sync`, `uv run tox`, and `uv lock --upgrade`.
- Update the CI workflow to install uv (pinned to a commit SHA) and
  run `uv sync --group ci` + `uv run tox`, with per-matrix-job names
  and read-only permissions.

Part of openedx/public-engineering#506.
Add python-semantic-release configuration ([tool.semantic_release] in
pyproject.toml, with major_on_zero/allow_zero_version pinned for a
repo already well past 1.0) and a release.yml workflow that:

- Runs the existing CI suite (via workflow_call) before releasing.
- Cuts a release with python-semantic-release when master advances,
  restricted to contents: write only.
- Publishes the built dist/ to PyPI via OIDC (id-token: write, no
  stored credentials) in a separate, minimally-privileged job.

Delete the old token-based pypi-publish.yml (triggered on
`release: published`), which is superseded by release.yml's
publish_to_pypi job and would otherwise race it once
semantic-release starts pushing tags/releases.

Update ci.yml to be invoked via workflow_call and drop its own
push-to-master trigger, since release.yml now owns that path.

commitlint.yml already existed and needed no changes.

The latest git tag (4.0.0) matches the latest version published to
PyPI, so semantic-release will compute the next version from that
baseline.

Part of openedx/public-engineering#506.
@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels Jul 27, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @irfanuddinahmad!

This repository is currently maintained by @openedx/axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

Two things that worked under the old pip-based CI silently relied on
dependencies being installed into the ambient/global Python environment
rather than an isolated one, and broke once dependency-groups moved
everything into uv-managed venvs:

- codecov-action shells out to a bare `coverage` command to turn the
  .coverage data file into coverage.xml; that command isn't on PATH once
  it's only installed inside tox's isolated uv venv. Add an explicit
  `uv run --with coverage coverage xml` step before the upload so
  codecov-action finds an already-generated coverage.xml instead.
- .readthedocs.yaml still pointed at the now-deleted
  requirements/doc.txt. Switch it to `method: uv` / `command: sync` /
  `groups: [doc]`, matching openedx/XBlock's already-working RTD config.
…) calls

The pre-migration "quality" tox env never actually had Django installed
(requirements/django-test.txt had its Django== pin stripped by `make
upgrade`, and the "quality" env didn't match tox's "django42" factor
condition that would have added it back), so pylint was silently
analyzing opaque_keys/edx/django with no Django available at all.

Now that the "quality" dependency group correctly includes the "django42"
group (Django + pytest-django), pylint can actually import Django and
attempts real inference over its Field/TestCase base classes — which is
where astroid's known difficulty with Django's dynamic Field/descriptor
and TestCase machinery surfaces as spurious `no-member` errors on
`super().validate()`, `super().run_validators()`, `super().deconstruct()`,
`super().setUp()`/`tearDown()`, and a descriptor-typed attribute access.
Suppress each with a targeted `# pylint: disable=no-member` rather than
disabling the check more broadly.
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.46%. Comparing base (f864171) to head (99f945a).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #461      +/-   ##
==========================================
- Coverage   94.03%   92.46%   -1.57%     
==========================================
  Files          31       11      -20     
  Lines        3068     1328    -1740     
  Branches      191      145      -46     
==========================================
- Hits         2885     1228    -1657     
+ Misses        157       78      -79     
+ Partials       26       22       -4     
Flag Coverage Δ
unittests 92.46% <100.00%> (-1.57%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…back

pytest.ini's --cov-config .coveragerc was still overriding the
[tool.coverage.*] settings added to pyproject.toml in the metadata
commit, leaving them dead configuration. Delete .coveragerc and drop
--cov-config from pytest.ini so coverage.py picks up pyproject.toml
directly, which also brings the tests/migrations/settings.py omit
patterns and exclude_lines (including "pragma: no cover") into effect
for real.

Mark the importlib.metadata PackageNotFoundError fallback in
opaque_keys/__init__.py with "pragma: no cover": it's only reachable if
this package is imported without being installed at all, which never
happens in this repo's own test suite, so codecov's patch-coverage
check correctly flagged it as an uncovered line added by this PR.
Fixing codecov/patch (previous commit) surfaced a codecov/project
failure caused by the same root change: [tool.coverage.run].omit now
correctly excludes opaque_keys/**/tests/* from measurement, whereas the
old .coveragerc only omitted .tox/*, so test files' own (trivially
self-covered) statements were previously counted toward the package's
reported coverage.

On this PR that drops the measured total from 94.03% (31 files,
including tests) to 92.46% (11 files, production code only) -- a
one-time change in what's measured, not a regression in production-code
test coverage (patch coverage on this PR's actual changes is 100%).

Add a project-level threshold to absorb that one-time drop (with
margin) while still catching real regressions going forward, matching
the pattern already applied to ccx-keys and openedx-filters in this
same modernization effort.
Per the 2026-07-15 decision on openedx/public-engineering#506 ("src/
layout is in scope for this cycle"), motivated by flat-layout editable
installs of packages like opaque-keys not being reliably resolvable by
mypy (confirmed: openedx/xblocks-extra already does this; openedx/XBlock,
despite being named as the other precedent in that decision, does not
actually have a src/ directory as of this writing -- verified directly
rather than assumed).

- Move opaque_keys/ to src/opaque_keys/.
- pyproject.toml: [tool.setuptools] package-dir = {"" = "src"};
  [tool.setuptools.packages.find] where = ["src"]; drop the "settings*"
  include that existed only for wheel parity with the old bare
  find_packages() -- settings/ lives outside src/ and is no longer
  picked up, which is now the correct scope for a test-only Django
  settings module rather than something worth publishing on PyPI.
  [tool.coverage.run].source updated to src/opaque_keys.
- MANIFEST.in, pytest.ini (testpaths), tox.ini (pycodestyle/pylint/
  --ignore path arguments) updated for the new physical path.
- mypy.ini switched from `files = opaque_keys` (a literal, now-stale
  path) to `packages = opaque_keys` (import-based resolution) -- this
  is the actual fix for the mypy/editable-install problem the src/
  layout decision cites, not just a mechanical path rename.

Verified locally: editable install resolves opaque_keys to
src/opaque_keys/__init__.py; `uv run tox -e quality` (mypy + pylint +
pycodestyle), `-e django42` (407 passed), `-e without-django` (394
passed), and `-e docs` all pass; `python -m build` (including with
SETUPTOOLS_SCM_PRETEND_VERSION) produces a correctly-versioned wheel
installing opaque_keys at the top level.
Verified every uses:@sha in release.yml against the GitHub API
(repos/<owner>/<repo>/commits/<sha>) rather than trusting the pins as
written. Two real problems, both invisible to PR CI since release.yml
only runs on push to master:

- python-semantic-release/python-semantic-release and
  actions/upload-artifact/download-artifact were pinned to SHAs that
  don't exist in those repos at all -- the upload-artifact and
  download-artifact SHAs were specifically swapped with each other.
  Reverted to plain version tags (@v10.6.1, @v7, @v8), matching
  openedx/XBlock's actual release.yml, which has already cut real
  production releases successfully with this exact pattern.
- pypa/gh-action-pypi-publish was pinned to v1.14.1's annotated *tag
  object* SHA (2834a31...) rather than the underlying commit SHA
  (ba38be9e...) -- a tag-object SHA is a real git object but is not
  what a `uses:` checkout should reference. Corrected to the commit
  SHA, which is the one form this specific pin genuinely needs
  (per this repo's own docs on the @release/v1 floating-branch bug).
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Jul 27, 2026
@mphilbrick211 mphilbrick211 moved this from Ready for Review to Waiting on Author in Contributions Jul 27, 2026
CHANGELOG.rst was never deleted during the uv/pyproject.toml migration,
but changelog:"false" was blindly copied from a reference template with
no ticket ever requiring it disabled. Wire it up properly:

- Add the ".. changelog-insertion-marker" as the first line of
  CHANGELOG.rst so semantic-release knows where to insert new entries
  above the existing history.
- Remove changelog:"false" from the python-semantic-release step in
  release.yml.
- Configure [tool.semantic_release.changelog] in pyproject.toml with
  mode="update" and the matching insertion_flag, plus
  default_templates pointed at CHANGELOG.rst in rst format.
- Set tag_format = "{version}" to match this repo's actual tag
  convention (plain "4.0.0", not "v4.0.0").

Verified with a local dry run (semantic-release changelog on a
master-named branch at HEAD): new release notes are correctly
inserted above the marker, the pre-existing history below is left
untouched, and the resulting file is valid RST.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

3 participants