Restore direct Markdown subclass; fix cold-import at the test boundary
Background
Plan 10 (collapse-impl-module) replaced the two-file __new__-redirect
architecture with a single-file abc.ABC + register() virtual-subclass
pattern, to fix a real circular import while also fixing the isinstance
breakage the old two-file split had introduced.
The traceback that motivated Plan 10's approach:
tests/test_spdx_markdown.py:12
→ src/spdx_markdown.py:20 from commitizen.changelog_formats.markdown import Markdown
→ .venv/.../changelog_formats/__init__.py:59 ep.name: ep.load()
→ importlib/metadata/__init__.py:181 functools.reduce(getattr, attrs, module)
→ AttributeError: partially initialized module has no attribute 'SPDXMarkdown'
This showed the cycle is real — but reproduction since then revealed
its scope is narrower than initially assumed.
Scope of the circular import
Two scenarios were tested with the direct-subclass version (no wrapper):
| Approach A — direct subclass |
Approach B — ABC wrapper |
cz bump --dry-run --changelog — ✅ works |
✅ works |
Cold python -c "from ...spdx_markdown import SPDXMarkdown" — ❌ AttributeError |
✅ works |
Same cold import with import commitizen.changelog_formats first — ✅ works |
✅ works |
pytest collection — ❌ AttributeError (cold path) |
✅ works (via conftest noneeded) |
isinstance(formatter, SPDXMarkdown) — ✅ true (real inheritance) |
✅ true (virtual subclass) |
The cycle only manifests when our own plugin module is the first
thing in the process to import commitizen.changelog_formats. cz's
own CLI bootstrap always imports that package before scanning
third-party commitizen.changelog_format entry points, so production
usage was never actually at risk. pytest, run directly against the
test module, is the one cold-import path in our own toolchain.
Proposal
- Replace the
abc.ABC / __new__ / register() wrapper in
spdx_markdown.py with a plain class SPDXMarkdown(Markdown): —
same __init__, get_metadata, and get_latest_full_release
overrides as the current _SPDXMarkdownImpl, just renamed and made
the only class in the module. Remove import abc, _resolve(),
__new__, and SPDXMarkdown.register(...).
- Fix the cold-import at the one place it actually occurs — test
collection — by adding a one-line warm-up import to
tests/conftest.py:
import commitizen.changelog_formats # noqa: F401 — warm up before plugin import
- Mark
docs/plans/10-collapse-impl.md as superseded with a
one-line note linking to Plan 11.
Follow-up tasks from #4 / PR #5
Two issues were spotted in the CI and changelog after PR #5 was merged.
These are unrelated but small enough to include in this change:
.github/workflows/publish.yaml — Reorder so GitHub Release
creation happens before PyPI publish; switch from
generate_release_notes: true to body_path: release-notes.md;
quote tags glob pattern [v*] as ["v*"] for YAML compliance;
fix indentation and remove stale comments.
docs/changelog.md — Escape underscore in _impl.py so it
renders correctly as literal text.
Acceptance criteria
Restore direct
Markdownsubclass; fix cold-import at the test boundaryBackground
Plan 10 (
collapse-impl-module) replaced the two-file__new__-redirectarchitecture with a single-file
abc.ABC+register()virtual-subclasspattern, to fix a real circular import while also fixing the
isinstancebreakage the old two-file split had introduced.
The traceback that motivated Plan 10's approach:
This showed the cycle is real — but reproduction since then revealed
its scope is narrower than initially assumed.
Scope of the circular import
Two scenarios were tested with the direct-subclass version (no wrapper):
cz bump --dry-run --changelog— ✅ workspython -c "from ...spdx_markdown import SPDXMarkdown"— ❌AttributeErrorimport commitizen.changelog_formatsfirst — ✅ workspytestcollection — ❌AttributeError(cold path)isinstance(formatter, SPDXMarkdown)— ✅ true (real inheritance)The cycle only manifests when our own plugin module is the first
thing in the process to import
commitizen.changelog_formats.cz'sown CLI bootstrap always imports that package before scanning
third-party
commitizen.changelog_formatentry points, so productionusage was never actually at risk.
pytest, run directly against thetest module, is the one cold-import path in our own toolchain.
Proposal
abc.ABC/__new__/register()wrapper inspdx_markdown.pywith a plainclass SPDXMarkdown(Markdown):—same
__init__,get_metadata, andget_latest_full_releaseoverrides as the current
_SPDXMarkdownImpl, just renamed and madethe only class in the module. Remove
import abc,_resolve(),__new__, andSPDXMarkdown.register(...).collection — by adding a one-line warm-up import to
tests/conftest.py:docs/plans/10-collapse-impl.mdas superseded with aone-line note linking to Plan 11.
Follow-up tasks from #4 / PR #5
Two issues were spotted in the CI and changelog after PR #5 was merged.
These are unrelated but small enough to include in this change:
.github/workflows/publish.yaml— Reorder so GitHub Releasecreation happens before PyPI publish; switch from
generate_release_notes: truetobody_path: release-notes.md;quote tags glob pattern
[v*]as["v*"]for YAML compliance;fix indentation and remove stale comments.
docs/changelog.md— Escape underscore in_impl.pyso itrenders correctly as literal text.
Acceptance criteria
spdx_markdown.pycontains one class, no wrapper, noregister().isinstance(formatter, SPDXMarkdown)isTruevia real inheritance.tests/conftest.pycontains the warm-up import.ruff check src/— zero warnings, nonoqa: E402needed.uv run pytest -v— all tests pass unchanged.cz bump --dry-run --changelogsucceeds.docs/plans/10-collapse-impl.mdmarked superseded with link toPlan 11.