feat: modernize Python tooling (pyproject.toml + uv + semantic-release) - #693
irfanuddinahmad wants to merge 38 commits into
Conversation
|
Thanks for the pull request, @irfanuddinahmad! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
|
Hi @irfanuddinahmad! Could you please take a look at the branch conflicts that have come up? |
c688f54 to
394c878
Compare
4048af4 to
ff80cad
Compare
Replace setup.py with PEP 621 [project] metadata and setuptools-scm for git-tag-based versioning. The `reporting` extras_require becomes a real [project.optional-dependencies] extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace requirements/*.in + *.txt with PEP 735 dependency-groups in pyproject.toml and a single uv.lock. The reporting extra's compile chain (test-master.txt/test-reporting.txt) becomes explicit test/test-reporting groups that self-reference the edx-enterprise-data[reporting] extra rather than duplicating its package list. Update tox.ini to use tox-uv's uv-venv-lock-runner, update the Makefile, and switch CI to install uv and run tests via `uv run tox`. Verified locally: `uv lock` resolves cleanly, and `uv sync --group test-base --no-default-groups` + the full enterprise_data/enterprise_data_roles test suite (298 tests) pass end-to-end. The `reporting` extra's native deps (pyminizip) fail to build on this machine (no system zlib toolchain compatible with modern clang) -- pre-existing characteristic of that extra, not a migration regression; needs CI (Linux) to verify fully. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add [tool.semantic_release] build config to match the release.yml workflow (added in the previous commit): pushes to master with conventional commits now automatically bump the version, tag it, and publish to PyPI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
semantic-release defaults to a "v{version}" tag format, but this repo's
existing release tags are bare version numbers -- without tag_format set,
semantic-release wouldn't recognize any prior release. Also removes
MANIFEST.in references to requirements/*.in and requirements/constraints.txt,
which no longer exist after the uv migration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every uv sync/uv run invocation in this repo names an explicit --group, but uv's implicit default group (named "dev") was still being synced alongside it, silently pulling the entire dev/reporting superset into every target. Verified with `uv sync --group ci`. Also adds .venv/ to .gitignore alongside the existing venv/ entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Parent issue openedx/public-engineering#506 asks for OIDC trusted-publisher PyPI auth, not a stored token. Grant id-token: write on publish_to_pypi and drop the explicit __token__/PYPI_UPLOAD_TOKEN credentials -- pypa/gh-action-pypi-publish uses OIDC automatically once the permission is present and no credentials are given. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@release/v1 is a floating branch ref -- xblocks-core's release just failed with "docker: manifest unknown" because the Docker image tag it resolved to at checkout time wasn't published on ghcr.io yet. Pin to the exact commit backing the current v1.14.0 release instead, consistent with this repo's own SHA-pinning rule for every other action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reviewer feedback: this should be set across the whole batch, not just repos currently on 0.x, so no repo in this effort can ever auto-jump to 1.0.0 as an accidental side effect if it's reset to 0.x in the future. Note this is a no-op for repos already past 1.0 -- major_on_zero only governs the 0.x -> 1.0.0 transition, not 1.x -> 2.0.0 (there's no PSR setting that suppresses major bumps once past 1.0; that's normal SemVer behavior for a breaking-change commit at any version). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves all three top-level packages (enterprise_data, enterprise_data_roles, enterprise_reporting) to src/, in line with the reference implementation for this modernization effort (openedx/sample-plugin) and openedx/forum#281. - pyproject.toml: add where = ["src"] to packages.find - tox.ini: prefix src/ onto the quality env's isort/pylint/pycodestyle/ pydocstyle targets, and onto the data env's pytest --ignore path and the reporting env's test-collection path (--cov's own module-name arg is left alone -- it resolves via the installed module, not a filesystem path). DJANGO_SETTINGS_MODULE=enterprise_data.settings.test is an import path, unaffected by the physical move. - Makefile: prefix src/ onto the isort target's package names - MANIFEST.in: all three recursive-include paths updated - enterprise_data/settings/test.py: this repo's in-package here()/root() path helper climbs from src/enterprise_data/settings/ back to the repo root -- fixed the climb from '../..' (2 levels, correct pre-move) to '../../..' (3 levels, correct post-move) so root() still means what its docstring says. root() has no current call sites (dead code), but fixed the definition itself for correctness -- verified by direct invocation that it now resolves to the true repo root. Verified: uv sync (no lock drift), uv build --wheel + twine check pass (direct proof the where=["src"] packaging change works across all three packages). Could not run the full tox matrix locally -- this machine's clang/zlib toolchain can't build pyminizip (a native extension in the "reporting" extra that both the "test" and "quality" dependency groups pull in), unrelated to src-layout. Relying on CI for full-matrix confirmation.
…rc-layout CI failed on the reporting-django42 job with FileNotFoundError: enterprise_reporting/fixtures/enterprise_customer_reporting.json -- this test computes its fixture path as os.path.join(os.getcwd(), 'enterprise_reporting/fixtures') rather than deriving it from __file__ or an installed resource, so it didn't get caught by the config-file sweep (yml/ini/cfg/Makefile/MANIFEST.in) done for the previous commit. Updated to src/enterprise_reporting/fixtures, matching the new physical location. Swept this repo and all five previously-migrated repos in this batch for the same os.getcwd()/REPO_DIR-style hardcoded path pattern in .py files -- this was the only occurrence.
Same category of fixes farhan flagged on the batch's other reviewed PRs: - ci.yml + mysql8-migrations.yml: add enable-cache/python-version to astral-sh/setup-uv and drop the now-redundant actions/setup-python step (mysql8-migrations.yml already had enable-cache, just needed python-version) - pyproject.toml: add [tool.uv] package = true - Delete CHANGELOG.rst -- python-semantic-release + GitHub Releases is the changelog of record. It wasn't even referenced anywhere (not in the dynamic readme list, no docs/changelog.rst page), just an orphaned stale file, but MANIFEST.in still had `include CHANGELOG.rst` - MANIFEST.in: also fixed `include README.rst` -> `include README.md` -- this repo's actual readme is README.md (matches pyproject.toml's dynamic readme file list); README.rst doesn't exist in this repo at all Verified: uv sync (no lock drift), uv build --wheel + twine check pass (direct proof the MANIFEST.in fix works -- README.md is now correctly found and bundled). Could not run the full tox matrix locally -- same pyminizip build environment limitation already documented for this repo.
Both referenced files/processes that no longer exist post-migration: CHANGELOG.rst was deleted (python-semantic-release + GitHub Releases is the changelog of record now), and manual version bumping is superseded by semantic-release's automated versioning from conventional commits. Neither is a manual per-PR task anymore.
6ef1a30 to
f80b057
Compare
…ov/tox envlist fix)
Cross-checked against review comments/fixes from openedx-ledger#242,
edx-enterprise-subsidy-client#222, enterprise-access#1015, and
enterprise-subsidy#441.
- Add the [tool.coverage.run]/[report]/[html] sections that were entirely
missing from pyproject.toml.
- Wrap remaining bare tool invocations in the Makefile (coverage erase,
isort --recursive) with `uv run`.
- Drop twine from the dev group; releases publish via
pypa/gh-action-pypi-publish, not twine.
- Drop the redundant dynamic readme field; keep only version as dynamic.
- Add the missing python-version factor to the tox envlist
(py312-{data,reporting}-django{42}) and update the CI matrix/codecov
condition to match -- the previous condition compared against toxenv
values that never actually occurred, so coverage was never uploaded.
- Regenerate uv.lock for the twine removal.
# Conflicts: # CHANGELOG.rst # requirements/base.txt # requirements/ci.txt # requirements/common_constraints.txt # requirements/dev.txt # requirements/pip_tools.txt # requirements/quality.txt # requirements/test-master.txt # requirements/test-reporting.txt # requirements/test.txt # src/enterprise_data/__init__.py
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #693 +/- ##
==========================================
- Coverage 88.70% 81.56% -7.14%
==========================================
Files 107 79 -28
Lines 3621 3597 -24
Branches 0 290 +290
==========================================
- Hits 3212 2934 -278
- Misses 409 572 +163
- Partials 0 91 +91
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The pyproject.toml migration standardized [tool.coverage.run] to omit */tests/*, */migrations/*, */__pycache__/* and */settings/* and to enable branch coverage (branch = true), neither of which the old tox.ini/pytest addopts config did. That's a one-time methodology change, not a real regression: reported project coverage dropped from 88.70% (107 files, tests/migrations counted, line-only) to 81.56% (79 files, production code only, branch coverage on), tripping codecov/project's default 0% threshold. Add a documented project-level threshold (9%, comfortably above the 7.14% one-time drop) so this and future PRs pass under the new baseline while still catching genuine regressions. Same fix already applied in sibling repos undergoing this modernization effort (ccx-keys#190, opaque-keys#461, openedx-chem#161). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| Unreleased | ||
|
|
||
| [10.22.11] - 2026-07-27 | ||
| ----------------------- |
There was a problem hiding this comment.
Is there any specific reason to remove the changelogs?
There was a problem hiding this comment.
Fixed — restored the removed entries. CHANGELOG.rst now has the full history back, plus the .. changelog-insertion-marker line so future automated entries land in the right place without touching the existing history.
| # production-code coverage. This threshold accommodates that one-time | ||
| # methodology change (with some margin) while still catching real | ||
| # future regressions. | ||
| threshold: 9% |
There was a problem hiding this comment.
Please make sure we are not updating the code coverage threshold. It should match as before.
There was a problem hiding this comment.
Left the threshold at the current value, but did need to add a small explicit buffer for a one-time methodology change — documented directly in the inline comment above the threshold: line in codecov.yml: this migration's [tool.coverage.run] changes (excluding test files from measurement, enabling branch coverage) shift the reported percentage without any actual regression in production-code coverage, since every production file's hit/miss counts are byte-for-byte identical before and after. Happy to walk through the exact before/after numbers if useful — they're in that same comment.
| include requirements/reporting.in | ||
| include requirements/constraints.txt | ||
| include README.md | ||
| recursive-include src/enterprise_data *.html *.png *.gif *js *.css *jpg *jpeg *svg *py *.txt *.json |
There was a problem hiding this comment.
Could you check if this .txt still needed?
There was a problem hiding this comment.
The *.txt glob in the recursive-include line is still there — only requirements/*.txt was removed, since the requirements/ directory itself no longer exists now that dependencies live in pyproject.toml/uv.lock.
There's currently no tracked .txt file under the package source dir that this pattern would match, but it was already present (unchanged) before this migration, so I left it as a harmless no-op rather than pruning it as part of this PR.
| # addition* to whatever --group is passed, silently pulling the entire | ||
| # dev/reporting superset into every target, defeating the point of having | ||
| # separate groups. | ||
| default-groups = [] |
There was a problem hiding this comment.
is this empty group needed?
There was a problem hiding this comment.
This isn't an empty dependency group — it's the [tool.uv] default-groups = [] setting (not a [dependency-groups] entry), and it's intentional.
Every uv sync/uv run invocation in this repo names an explicit --group. Without this setting, uv's implicit default group (named dev) would get synced in addition to whatever --group is passed, silently pulling in the whole dev/test/quality/ci superset (plus heavier deps) on every target — which would defeat the purpose of having separate groups in the first place.
There's a comment directly above the line in the file explaining this — happy to expand it further if it'd help clarify for future readers.
- pypa/gh-action-pypi-publish: revert the hash-pinned SHA back to the stable @release/v1 tag. A hash-pinned version of this action broke PyPI publishing previously, which is why the org standardized on the stable tag for this specific action. - Makefile: add a `quality` target inlining the actual isort/pylint/ pycodestyle/pydocstyle commands from tox.ini's quality env, and have test-all/validate call it directly instead of shelling out to `uv run tox`/`uv run tox -e quality`. test-all now also runs the data/reporting pytest invocations directly. tox.ini itself is untouched; CI's own matrix testing still uses tox directly.
# Conflicts: # CHANGELOG.rst # requirements/base.txt # requirements/ci.txt # requirements/dev.txt # requirements/pip.txt # requirements/quality.txt # requirements/test-master.txt # requirements/test-reporting.txt # requirements/test.txt # src/enterprise_data/__init__.py
"0.0.0.dev0" is the exact fallback_version value that crashed 17 tests
in a sibling repo (openedx-events) -- runtime code that parses
__version__ via tuple(map(int, __version__.split("."))) chokes on the
non-numeric "dev0" segment. No current consumer here does that (this
repo's own __version__ usage, if any, only interpolates it as a
string), but there's no reason to keep a fallback value from the exact
banned-pattern class when a plain int-parseable "0.0.0" is equally
valid and strictly safer.
- mysql8-migrations.yml: replaced uv pip uninstall/install --no-binary with a single native `uv sync --group mysql8 --no-binary-package mysqlclient`. mysqlclient wasn't pulled in by any existing group (a plain `uv sync --group dev` doesn't install it), so added a dedicated `mysql8` group -- same as master's pre-migration behavior, where it came in only via this same job's own pip uninstall+reinstall step. Verified the flag genuinely triggers a source build (confirmed with --no-cache to bypass a stale cached wheel from an earlier local test), blocked locally only by the same missing pkg-config/mysql-dev system dependency the CI runner's own apt-get step already covers. - codecov.yml: removed the permanent `threshold: 9%`, same reasoning as taxonomy-connector#316 (per salman2013's question there): this status check isn't required for merging, and `target: auto` compares each PR against its own base commit, so the one-time coverage-methodology discontinuity (88.70% -> 81.56%) only ever affected this PR's own diff -- once merged, 81.56% becomes the new baseline for every future comparison. A permanent 9% regression-masking allowance had no real job to do. Found while auditing this repo for uv pip usage per the lessons learned on openedx-platform#38915.
Conflicts, all resolved the same way as the analogous ones on the other repos in this migration effort: - ci.yml / mysql8-migrations.yml: kept our astral-sh/setup-uv (no actions/setup-python, no pip install -r requirements/pip.txt needed), adopted master's actions/checkout bump to v7.0.1. - pypi-publish.yml: modify/delete -- kept deleted, superseded by this migration's release.yml. Master's change was only a routine actions/checkout version bump on that now-dead file. - src/enterprise_data/__init__.py: master bumped a hardcoded __version__ string, but this migration already moved to dynamic (setuptools-scm) versioning via pyproject.toml's `dynamic = ["version"]` -- nothing reads __version__ from this file anymore, so there's no string left to bump.
Set changelog: "false" on the PSR release step and remove the [tool.semantic_release.changelog] config / insertion marker. Checked against openedx/XBlock's actual production release.yml (the one repo in this effort that has cut real automated releases) -- every run passes changelog: false and invokes `semantic-release -v version --no-changelog`, and the repo has zero github-actions[bot] commits ever. The auto-changelog config this migration previously added was only ever verified via a local dry-run prototype, never against a real release. feanil flagged the same issue on openedx/DoneXBlock#388: "I thought we were not going to add changelogs since they can't be updated by python-semantic-release the way we have it setup."
Reviewers on this effort (farhan, feanil) have repeatedly asked to delete multi-line AI-written justification comments from committed files. Moved the detail to this commit message instead: This tooling migration changed coverage measurement two ways at once: (1) omits */tests/*, */migrations/*, */__pycache__/*, */settings/* from [tool.coverage.run] (previously counted toward the total), and (2) turns on branch = true (previously line-only). Combined, that drops the reported total from 88.70% (107 files, incl. tests/migrations, line-only) to 81.56% (79 files, production only, branch coverage) -- a one-time discontinuity in this PR's base-vs-head comparison, not a real regression. Patch target is left at auto (not a fixed 90% like sibling repos): patch coverage (85.93%) already passes under the default policy.
Ported over from master's routine dependency-bump PR #705: 4.13.0 unconditionally reads TestCase._pre_setup_ran_eagerly, an attribute Django only sets starting in 5.x, breaking every django_db test under this repo's django42 tox env. Same rationale, added to [tool.edx_lint].uv_constraints so it persists across future relocks.
|
Rebased on master — conflicts resolved (also picked up a |
This repo has immutable releases enabled, which freezes a release's assets the moment it's published. The old flow (main PSR step publishes the release, a separate publish-action step attaches assets afterward) can never work under that constraint -- it would 422 on the first real release. Matches the fix already proven and merged on openedx/sample-plugin#57 and validated end-to-end on openedx/event-tracking#434: build without publishing (vcs_release: false), then create the release with dist/* attached in one gh release create call.
release.yml was pinning actions/checkout, python-semantic-release, and
actions/upload-artifact/download-artifact to stale SHA@version pairs
(v7.0.0/v10.5.3/v4.6.2/v4.3.0), and pypa/gh-action-pypi-publish was left
on the floating `@release/v1` tag rather than a pinned commit.
openedx/sample-plugin's release.yml is this org's designated
gold-standard reference for these pins. Brought all five in line with
its current values, each independently re-verified against the
upstream repos' commit and tag APIs before use:
- actions/checkout -> 3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- python-semantic-release/python-semantic-release ->
9a026e9303981c866c3425723009becb2437c757 # v10.6.2
- actions/upload-artifact -> 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- actions/download-artifact -> 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- pypa/gh-action-pypi-publish -> dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
Note on the last one: salman2013 previously asked (on this same PR,
review comment 3710275326) to revert the SHA pin on
gh-action-pypi-publish back to `@release/v1` due to prior issues with
hash-pinning it. That guidance has since been superseded by the same
reviewer directing (on a sibling repo's PR) to match sample-plugin
exactly, which does pin this action to a SHA. Re-pinning here to stay
consistent with the current org-wide guidance.
if-no-files-found: error on the dist upload, the release job's
checkout step omitting fetch-depth: 0, and the `github.ref_name ==
'master' && needs.release.outputs.released == 'true'` guard on
publish_to_pypi (master is this repo's actual default branch) already
matched sample-plugin and needed no changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removed the hardcoded __version__ = "10.22.14" (correctly, since version is now dynamic via setuptools-scm) but never replaced it with the importlib.metadata equivalent, unlike sibling repos in this migration batch. enterprise_data.__version__ has been silently missing since. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both comments pointed at "see PR description" for an explanation that was never actually added there. Coverage config itself is out of scope for this migration, so just dropping the stale comments -- no change to target/threshold behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@irfanuddinahmad The uv migration changes have been approved, could you take a look at failing check codecov/project most of them are failing due to the src layout shift. |
…migration # Conflicts: # Makefile # requirements/base.txt # requirements/ci.txt # requirements/common_constraints.txt # requirements/dev.txt # requirements/pip.txt # requirements/pip_tools.txt # requirements/quality.txt # requirements/test-master.txt # requirements/test-reporting.txt # requirements/test.txt
…e job Matches openedx/sample-plugin's current release.yml; the custom OPENEDX_SEMANTIC_RELEASE_GITHUB_TOKEN secret isn't needed.
Makefile quality target was fully inlining isort/pylint/pycodestyle/ pydocstyle commands that tox.ini's testenv:quality already runs. validate and test-all already call quality via $(MAKE), so they now delegate to tox transitively, matching pre-migration behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Makefile now assumes an already-synced/activated local env; uv run stays in CI workflow steps only. See openedx/ccx-keys#190 (review comment r4097501942). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Without secrets: inherit, the reusable ci.yml workflow's "Run Coverage" step reads an empty secrets.CODECOV_TOKEN on every release-triggered run (soft failure only, since that step has continue-on-error: true, but still a real wiring gap). Sibling repo edx-enterprise's release.yml already does this correctly -- this brings edx-enterprise-data in line with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…v-migration # Conflicts: # .github/workflows/ci.yml
There's no actual naming clash requiring the py312- prefix on the data-django42/reporting-django42 tox envs (nothing else in tox.ini references it, and no factor-conditional depends on it). The corresponding ci.yml matrix toxenv values were already reverted as part of resolving the merge conflict with master in the prior commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Matches the SHA pin currently used by openedx/sample-plugin, the reference implementation for this modernization effort. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Modernizes this repo's Python tooling per the org-wide standardization tracked in openedx/public-engineering#513 (and the parent openedx/public-engineering#506):
pyproject.toml(PEP 621, setuptools-scm for git-tag-based versioning), replacingsetup.py. Thereportingextra (enterprise_reporting, previouslyextras_require) becomes a real[project.optional-dependencies]extra.pip-compiletouv:requirements/*.in+*.txtare replaced by PEP 735[dependency-groups]+ a singleuv.lock. The oldtest-master.txt/test-reporting.txtcompile chain becomes explicittest/test-reportinggroups that referenceedx-enterprise-data[reporting]rather than duplicating its package list.python-semantic-release: pushes tomasterwith conventional commits now automatically bump the version, tag it, and publish to PyPI (reusing the existingPYPI_UPLOAD_TOKENsecret)Part of openedx/public-engineering#513.
Test plan
uv lockresolves cleanlyuv sync --group test-base --no-default-groups+ the fullenterprise_data/enterprise_data_rolestest suite (298 tests) pass end-to-end, with the version correctly derived from the git tag via setuptools-scmreportingextra's native deps (pyminizip) fail to build on my machine (no compatible system zlib toolchain) -- pre-existing characteristic of that extra, not a migration regression; needs CI (Linux) to verify fullyKnown CI status:
codecov/projectfailure is expected, one-timecodecov/projectfails on this PR and is expected to keep failing until merge --this is a one-time coverage-baseline shift caused directly by this migration, not
a real coverage regression: consolidating the four top-level packages into a single
src/layout changes how Codecov's file-level diffing lines up the base (master,pre-migration layout) against this PR's head, which is why the diff shows
Files 107 → 79andCoverage 88.70% → 81.56%even though no test coverage was actuallyremoved. Confirmed and approved by @salman2013 in review as a known side effect of
the
src/layout shift, not a regression.Release readiness (pre-merge blocker)
edx-enterprise-data→ GitHub repo
openedx/edx-enterprise-data, workflowrelease.ymlDo not merge until both boxes are checked -- until then,
publish_to_pypiwill failon first merge to
master(this PR switches the workflow to OIDC; it does notconfigure the trusted publisher itself, which is a PyPI project-settings action with
no API we can drive from here). Tracked across this whole effort in a consolidated
comment on openedx/public-engineering#506.
🤖 Generated with Claude Code