From 4f5027434ac97af0641f0010450e1ccbbd4424fa Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 9 May 2026 13:07:51 +0100 Subject: [PATCH 1/7] Sync Python template runtime fallback Move generated package initialisation to a re-export-only `__init__` and place the optional Rust-extension fallback in a private runtime module. Add the pure module docstring and generated public API test so new projects satisfy the current lint and test gates. Refresh Ruff-formatted rule and scripting guidance, and update the generated packaging settings so Python-only and Rust-backed projects install under custom package names on current Python versions. --- template/.rules/python-00.md | 2 ++ template/.rules/python-context-managers.md | 3 +++ ...tion-design-raising-handling-and-logging.md | 13 ++++++++----- template/.rules/python-generators.md | 6 +++--- template/.rules/python-return.md | 4 ++++ template/.rules/python-typing.md | 11 +++++++++-- template/docs/scripting-standards.md | 17 +++++++++++++---- template/tests/test_public_api.py.jinja | 10 ++++++++++ template/{{ package_name }}/__init__.py.jinja | 14 +------------- template/{{ package_name }}/_runtime.py.jinja | 18 ++++++++++++++++++ tests/test_template.py | 3 +++ 11 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 template/tests/test_public_api.py.jinja create mode 100644 template/{{ package_name }}/_runtime.py.jinja diff --git a/template/.rules/python-00.md b/template/.rules/python-00.md index 6ff106d..279399d 100644 --- a/template/.rules/python-00.md +++ b/template/.rules/python-00.md @@ -116,10 +116,12 @@ def login_user(username: str, password: str) -> bool: """Return True if the user is authenticated.""" ... + # login_flow_test.py def test_login_success(): assert login_user("alice", "correct-password") is True + def test_login_failure(): assert not login_user("alice", "wrong-password") ``` diff --git a/template/.rules/python-context-managers.md b/template/.rules/python-context-managers.md index cd9600f..303aca9 100644 --- a/template/.rules/python-context-managers.md +++ b/template/.rules/python-context-managers.md @@ -23,6 +23,7 @@ Use this for straightforward procedural setup/teardown: ```python from contextlib import contextmanager + @contextmanager def managed_file(path: str, mode: str): f = open(path, mode) @@ -31,6 +32,7 @@ def managed_file(path: str, mode: str): finally: f.close() + # Usage: with managed_file("/tmp/data.txt", "w") as f: f.write("hello") @@ -53,6 +55,7 @@ class Resource: def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() + # Usage: with Resource() as conn: conn.send("ping") diff --git a/template/.rules/python-exception-design-raising-handling-and-logging.md b/template/.rules/python-exception-design-raising-handling-and-logging.md index e33d50a..baf1a94 100644 --- a/template/.rules/python-exception-design-raising-handling-and-logging.md +++ b/template/.rules/python-exception-design-raising-handling-and-logging.md @@ -14,6 +14,7 @@ class enables callers to catch all domain failures without vendor leakage. class PaymentsError(Exception): """All payment-layer errors.""" + class CardDeclinedError(PaymentsError): # ✅ ends with Error (N818) def __init__(self, code: str, *, retry_after: int | None = None): super().__init__(f"Card declined ({code})") @@ -114,13 +115,14 @@ clarifies intent. ```python import logging + logger = logging.getLogger(__name__) # ❌ LOG issues -logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) -logging.warning("failed for %s" % user_id) # %-formatting (LOG007) -logging.warn("deprecated") # warn() (LOG009) -logging.error("bad root logger") # root logger usage (LOG015) +logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) +logging.warning("failed for %s" % user_id) # %-formatting (LOG007) +logging.warn("deprecated") # warn() (LOG009) +logging.error("bad root logger") # root logger usage (LOG015) # ✅ Correct logger.warning("Failed for user_id=%s", user_id) # lazy interpolation @@ -203,7 +205,7 @@ def charge(amount_pennies: int, card_token: str) -> str: try: return gateway.charge(amount_pennies, card_token) except gateway.Timeout as exc: - raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 + raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 except gateway.CardDeclined as exc: raise CardDeclinedError(exc.code, retry_after=60) from exc ``` @@ -227,6 +229,7 @@ def must_have_key(d: dict, key: str) -> None: msg = f"Missing required key: {key!r}" raise KeyError(msg) + logger.info("Dispatching order_id=%s to shop_id=%s", order_id, shop_id) # structured ``` diff --git a/template/.rules/python-generators.md b/template/.rules/python-generators.md index 8d91cd6..36f11a1 100644 --- a/template/.rules/python-generators.md +++ b/template/.rules/python-generators.md @@ -34,6 +34,7 @@ def iter_user_names(users): if user.active and user.name: yield user.name.upper() + def get_names(users): return list(iter_user_names(users)) ``` @@ -50,11 +51,10 @@ def get_names(users): ```python from itertools import islice + def top_active_emails(users): emails = ( - user.email.lower() - for user in users - if user.active and user.email is not None + user.email.lower() for user in users if user.active and user.email is not None ) return list(islice(emails, 10)) ``` diff --git a/template/.rules/python-return.md b/template/.rules/python-return.md index cdd6513..fb26977 100644 --- a/template/.rules/python-return.md +++ b/template/.rules/python-return.md @@ -11,6 +11,7 @@ flow. Follow these rules: def func(): return None + # GOOD: def func(): return @@ -30,6 +31,7 @@ def func(x): return x # implicitly returns None (bad) + # GOOD: def func(x): if x > 0: @@ -50,6 +52,7 @@ def func(x): return x # no return (bad) + # GOOD: def func(x): if x > 0: @@ -69,6 +72,7 @@ def func(): result = compute() return result + # GOOD: def func(): return compute() diff --git a/template/.rules/python-typing.md b/template/.rules/python-typing.md index 7962287..f8b90b7 100644 --- a/template/.rules/python-typing.md +++ b/template/.rules/python-typing.md @@ -13,14 +13,17 @@ with integers or strings is required (e.g. for database or JSON serialisation). ```python import enum + class Status(enum.Enum): PENDING = enum.auto() COMPLETE = enum.auto() + class ErrorCode(enum.IntEnum): OK = 0 NOT_FOUND = 404 + class Role(enum.StrEnum): ADMIN = enum.auto() GUEST = enum.auto() @@ -65,6 +68,7 @@ returns the same instance. ```python import typing + class Builder: def add(self, value: int) -> typing.Self: self.values.append(value) @@ -81,9 +85,10 @@ enables static analysis tools to detect typos and signature mismatches. ```python import typing + class Base: - def run(self) -> None: - ... + def run(self) -> None: ... + class Child(Base): @typing.override @@ -101,6 +106,7 @@ checkers. ```python import typing + def is_str_list(val: list[object]) -> typing.TypeIs[list[str]]: return all(isinstance(x, str) for x in val) ``` @@ -116,6 +122,7 @@ type is provided. ```python T = typing.TypeVar("T", default=int) + class Box[T]: def __init__(self, value: T | None = None): # Fallback to the TypeVar default (int in this example) diff --git a/template/docs/scripting-standards.md b/template/docs/scripting-standards.md index d8ea71c..f99730e 100644 --- a/template/docs/scripting-standards.md +++ b/template/docs/scripting-standards.md @@ -66,16 +66,16 @@ def default( # Required parameters bin_name: Annotated[str, Parameter(required=True)], version: Annotated[str, Parameter(required=True)], - # Optional scalars package_name: Optional[str] = None, target: Optional[str] = None, outdir: Optional[Path] = None, dry_run: bool = False, - # Lists (whitespace/newline separated by default) formats: list[str] | None = None, - man_paths: Annotated[list[Path] | None, Parameter(env_var="INPUT_MAN_PATHS")] = None, + man_paths: Annotated[ + list[Path] | None, Parameter(env_var="INPUT_MAN_PATHS") + ] = None, deb_depends: list[str] | None = None, rpm_depends: list[str] | None = None, ): @@ -407,7 +407,9 @@ f.write_text("1.2.3\n", encoding="utf-8") version = f.read_text(encoding="utf-8").strip() # Atomic write pattern (tmp → replace) -with tempfile.NamedTemporaryFile("w", delete=False, dir=f.parent, encoding="utf-8") as tmp: +with tempfile.NamedTemporaryFile( + "w", delete=False, dir=f.parent, encoding="utf-8" +) as tmp: tmp.write("new-contents\n") tmp_path = Path(tmp.name) @@ -432,12 +434,19 @@ except FileNotFoundError: pass ``` + +## Cyclopts + cuprum + pathlib together (reference script) + +```python +#!/usr/bin/env -S uv run python ## Cyclopts + cuprum + pathlib together (reference script) ```python #!/usr/bin/env -S uv run python # /// script # requires-python = ">=3.13" + +# dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"] # dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"] # /// diff --git a/template/tests/test_public_api.py.jinja b/template/tests/test_public_api.py.jinja new file mode 100644 index 0000000..03166e1 --- /dev/null +++ b/template/tests/test_public_api.py.jinja @@ -0,0 +1,10 @@ +"""Tests for the public package API.""" + +from __future__ import annotations + +import {{ package_name }} + + +def test_hello_uses_configured_backend() -> None: + """Return the expected greeting for this generated project.""" + assert {{ package_name }}.hello() == "{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" diff --git a/template/{{ package_name }}/__init__.py.jinja b/template/{{ package_name }}/__init__.py.jinja index 83fbf8d..4b661de 100644 --- a/template/{{ package_name }}/__init__.py.jinja +++ b/template/{{ package_name }}/__init__.py.jinja @@ -2,18 +2,6 @@ from __future__ import annotations -import importlib -import typing as typ - -if typ.TYPE_CHECKING: - import collections.abc as cabc - -PACKAGE_NAME = "{{ package_name }}" - -try: # pragma: no cover - Rust optional - rust = importlib.import_module(f"._{PACKAGE_NAME}_rs", package=__name__) - hello = typ.cast("cabc.Callable[[], str]", rust.hello) -except ModuleNotFoundError: # pragma: no cover - Python fallback - from .pure import hello +from ._runtime import hello __all__ = ["hello"] diff --git a/template/{{ package_name }}/_runtime.py.jinja b/template/{{ package_name }}/_runtime.py.jinja new file mode 100644 index 0000000..7f55f8d --- /dev/null +++ b/template/{{ package_name }}/_runtime.py.jinja @@ -0,0 +1,18 @@ +from __future__ import annotations +{% if use_rust %} +import importlib +import typing as typ + +if typ.TYPE_CHECKING: + import collections.abc as cabc + +PACKAGE_NAME = "{{ package_name }}" + +try: # pragma: no cover - Rust optional + rust = importlib.import_module(f"._{PACKAGE_NAME}_rs", package=__package__) + hello = typ.cast("cabc.Callable[[], str]", rust.hello) +except ModuleNotFoundError: # pragma: no cover - Python fallback + from .pure import hello as hello +{%- else %} +from .pure import hello as hello +{%- endif %} diff --git a/tests/test_template.py b/tests/test_template.py index a266f11..f6edbe6 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -190,6 +190,9 @@ def test_python_only_template(copier: CopierFixture, tmp_path: Path) -> None: assert not (proj / "docs" / "rust-extension.md").exists(), ( "Rust documentation should not be generated for Python-only template" ) + assert (proj / "docs" / "documentation-style-guide.md").exists(), ( + "Python-only template should include shared documentation guidance" + ) assert "maturin" not in (proj / "pyproject.toml").read_text(encoding="utf-8"), ( "maturin should not be in pyproject.toml for Python-only template" ) From c78ef55e1ca84d4b0982b6259ea03a28d5311553 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Wed, 13 May 2026 20:59:24 +0100 Subject: [PATCH 2/7] Add complexity-antipatterns-and-refactoring-strategies.md --- .../docs/complexity-antipatterns-and-refactoring-strategies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/docs/complexity-antipatterns-and-refactoring-strategies.md b/template/docs/complexity-antipatterns-and-refactoring-strategies.md index 7c0d207..503a4f5 100644 --- a/template/docs/complexity-antipatterns-and-refactoring-strategies.md +++ b/template/docs/complexity-antipatterns-and-refactoring-strategies.md @@ -653,7 +653,7 @@ behaviour—common culprits for bugs and increased cognitive load in imperative code.[^26] Examples include using Structured Query Language for database queries— -specifying the desired dataset rather than the retrieval algorithm[^26]—or +specifying the desired dataset rather than the retrieval algorithm[^34]—or employing functional programming constructs like `map`, `filter`, and `reduce` on collections instead of writing explicit loops. Refactoring imperative code to a declarative style can start small, perhaps by converting a loop that From 0259872fcaada4e032a61a17ecfc0f291a3aedb1 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 14 Jun 2026 05:16:24 +0100 Subject: [PATCH 3/7] Clean up branch documentation formatting Add the root `markdownlint-cli2` config so parent-level Markdown checks use the same rules as rendered projects. Remove the incidental `.rules` formatting churn from the branch and repair the duplicated fenced block in `scripting-standards.md`. Keep the scoped Markdown formatter output for the docs that remain changed by this branch. --- .markdownlint-cli2.jsonc | 22 ++++++++++++ template/.rules/python-00.md | 2 -- template/.rules/python-context-managers.md | 3 -- ...ion-design-raising-handling-and-logging.md | 13 +++---- template/.rules/python-generators.md | 6 ++-- template/.rules/python-return.md | 4 --- template/.rules/python-typing.md | 11 ++---- ...antipatterns-and-refactoring-strategies.md | 36 +++++++++---------- template/docs/scripting-standards.md | 19 ++++------ 9 files changed, 56 insertions(+), 60 deletions(-) create mode 100644 .markdownlint-cli2.jsonc diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..deee43f --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,22 @@ +{ + "config": { + "MD004": { "style": "dash" }, + "MD010": { "code_blocks": false }, + "MD013": { + "line_length": 80, + "code_block_line_length": 120, + "tables": false, + "headings": false + }, + "MD029": { "style": "ordered" } + }, + "ignores": [ + "**/.venv/**", + ".node_modules/**", + "**/node_modules/**", + "**/target/**", + ".terraform/**", + ".uv-cache/**", + "CRUSH.md" + ] +} diff --git a/template/.rules/python-00.md b/template/.rules/python-00.md index 279399d..6ff106d 100644 --- a/template/.rules/python-00.md +++ b/template/.rules/python-00.md @@ -116,12 +116,10 @@ def login_user(username: str, password: str) -> bool: """Return True if the user is authenticated.""" ... - # login_flow_test.py def test_login_success(): assert login_user("alice", "correct-password") is True - def test_login_failure(): assert not login_user("alice", "wrong-password") ``` diff --git a/template/.rules/python-context-managers.md b/template/.rules/python-context-managers.md index 303aca9..cd9600f 100644 --- a/template/.rules/python-context-managers.md +++ b/template/.rules/python-context-managers.md @@ -23,7 +23,6 @@ Use this for straightforward procedural setup/teardown: ```python from contextlib import contextmanager - @contextmanager def managed_file(path: str, mode: str): f = open(path, mode) @@ -32,7 +31,6 @@ def managed_file(path: str, mode: str): finally: f.close() - # Usage: with managed_file("/tmp/data.txt", "w") as f: f.write("hello") @@ -55,7 +53,6 @@ class Resource: def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() - # Usage: with Resource() as conn: conn.send("ping") diff --git a/template/.rules/python-exception-design-raising-handling-and-logging.md b/template/.rules/python-exception-design-raising-handling-and-logging.md index baf1a94..e33d50a 100644 --- a/template/.rules/python-exception-design-raising-handling-and-logging.md +++ b/template/.rules/python-exception-design-raising-handling-and-logging.md @@ -14,7 +14,6 @@ class enables callers to catch all domain failures without vendor leakage. class PaymentsError(Exception): """All payment-layer errors.""" - class CardDeclinedError(PaymentsError): # ✅ ends with Error (N818) def __init__(self, code: str, *, retry_after: int | None = None): super().__init__(f"Card declined ({code})") @@ -115,14 +114,13 @@ clarifies intent. ```python import logging - logger = logging.getLogger(__name__) # ❌ LOG issues -logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) -logging.warning("failed for %s" % user_id) # %-formatting (LOG007) -logging.warn("deprecated") # warn() (LOG009) -logging.error("bad root logger") # root logger usage (LOG015) +logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) +logging.warning("failed for %s" % user_id) # %-formatting (LOG007) +logging.warn("deprecated") # warn() (LOG009) +logging.error("bad root logger") # root logger usage (LOG015) # ✅ Correct logger.warning("Failed for user_id=%s", user_id) # lazy interpolation @@ -205,7 +203,7 @@ def charge(amount_pennies: int, card_token: str) -> str: try: return gateway.charge(amount_pennies, card_token) except gateway.Timeout as exc: - raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 + raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 except gateway.CardDeclined as exc: raise CardDeclinedError(exc.code, retry_after=60) from exc ``` @@ -229,7 +227,6 @@ def must_have_key(d: dict, key: str) -> None: msg = f"Missing required key: {key!r}" raise KeyError(msg) - logger.info("Dispatching order_id=%s to shop_id=%s", order_id, shop_id) # structured ``` diff --git a/template/.rules/python-generators.md b/template/.rules/python-generators.md index 36f11a1..8d91cd6 100644 --- a/template/.rules/python-generators.md +++ b/template/.rules/python-generators.md @@ -34,7 +34,6 @@ def iter_user_names(users): if user.active and user.name: yield user.name.upper() - def get_names(users): return list(iter_user_names(users)) ``` @@ -51,10 +50,11 @@ def get_names(users): ```python from itertools import islice - def top_active_emails(users): emails = ( - user.email.lower() for user in users if user.active and user.email is not None + user.email.lower() + for user in users + if user.active and user.email is not None ) return list(islice(emails, 10)) ``` diff --git a/template/.rules/python-return.md b/template/.rules/python-return.md index fb26977..cdd6513 100644 --- a/template/.rules/python-return.md +++ b/template/.rules/python-return.md @@ -11,7 +11,6 @@ flow. Follow these rules: def func(): return None - # GOOD: def func(): return @@ -31,7 +30,6 @@ def func(x): return x # implicitly returns None (bad) - # GOOD: def func(x): if x > 0: @@ -52,7 +50,6 @@ def func(x): return x # no return (bad) - # GOOD: def func(x): if x > 0: @@ -72,7 +69,6 @@ def func(): result = compute() return result - # GOOD: def func(): return compute() diff --git a/template/.rules/python-typing.md b/template/.rules/python-typing.md index f8b90b7..7962287 100644 --- a/template/.rules/python-typing.md +++ b/template/.rules/python-typing.md @@ -13,17 +13,14 @@ with integers or strings is required (e.g. for database or JSON serialisation). ```python import enum - class Status(enum.Enum): PENDING = enum.auto() COMPLETE = enum.auto() - class ErrorCode(enum.IntEnum): OK = 0 NOT_FOUND = 404 - class Role(enum.StrEnum): ADMIN = enum.auto() GUEST = enum.auto() @@ -68,7 +65,6 @@ returns the same instance. ```python import typing - class Builder: def add(self, value: int) -> typing.Self: self.values.append(value) @@ -85,10 +81,9 @@ enables static analysis tools to detect typos and signature mismatches. ```python import typing - class Base: - def run(self) -> None: ... - + def run(self) -> None: + ... class Child(Base): @typing.override @@ -106,7 +101,6 @@ checkers. ```python import typing - def is_str_list(val: list[object]) -> typing.TypeIs[list[str]]: return all(isinstance(x, str) for x in val) ``` @@ -122,7 +116,6 @@ type is provided. ```python T = typing.TypeVar("T", default=int) - class Box[T]: def __init__(self, value: T | None = None): # Fallback to the TypeVar default (int in this example) diff --git a/template/docs/complexity-antipatterns-and-refactoring-strategies.md b/template/docs/complexity-antipatterns-and-refactoring-strategies.md index 503a4f5..3c60eda 100644 --- a/template/docs/complexity-antipatterns-and-refactoring-strategies.md +++ b/template/docs/complexity-antipatterns-and-refactoring-strategies.md @@ -46,8 +46,8 @@ the number of edges, N is the number of nodes, and P is the number of connected components (typically 1 for a single program or method).[^3] A simpler formulation applies to a single subroutine: -M = number of decision points + 1, where decision points include constructs -like `if` statements and conditional loops.[^3] +M = number of decision points + 1, where decision points include constructs like +`if` statements and conditional loops.[^3] Thresholds and Implications: @@ -198,14 +198,14 @@ attention, much like a physical bumpy road slows down driving.[^9] ### B. How it forms and its impact The Bumpy Road antipattern, like many software antipatterns, often emerges from -development practices that prioritize short-term speed over long-term -structural integrity.[^2] Rushed development cycles, lack of clear design, or -cutting corners on maintenance can lead to the gradual accumulation of -conditional logic within a single function.[^2] As new requirements emerge -alongside additional edge cases, developers might add conditional branches to -an existing method. Examples include an `if` statement, a loop, or a deeply -nested match added in haste when a team could instead step back to refactor and -create appropriate abstractions. +development practices that prioritize short-term speed over long-term structural +integrity.[^2] Rushed development cycles, lack of clear design, or cutting +corners on maintenance can lead to the gradual accumulation of conditional +logic within a single function.[^2] As new requirements emerge alongside +additional edge cases, developers might add conditional branches to an existing +method. Examples include an `if` statement, a loop, or a deeply nested match +added in haste when a team could instead step back to refactor and create +appropriate abstractions. The impact of this antipattern is significant: @@ -393,10 +393,10 @@ maintainable systems. 1\. Separation of Concerns (SoC) Separation of Concerns is a design principle that advocates for dividing a -computer program into distinct sections, where each section addresses a -separate concern.[^13] A "concern" is a set of information that affects the -code of a computer program. Modularity is achieved by encapsulating information -within a section of code that has a well-defined interface.[^13] +computer program into distinct sections, where each section addresses a separate +concern.[^13] A "concern" is a set of information that affects the code of a +computer program. Modularity is achieved by encapsulating information within a +section of code that has a well-defined interface.[^13] The Bumpy Road antipattern is a direct violation of SoC. Each "bump" in the code often represents a distinct concern, or responsibility, that has been @@ -471,10 +471,10 @@ Command Query Responsibility Segregation promotes a clear separation that can prevent the kind of tangled logic that forms Bumpy Roads. By isolating write operations (commands) from read operations (queries), and by encouraging task-based commands, the system naturally tends towards smaller, more cohesive -units of behaviour, thus reducing overall cognitive complexity within -individual components.[^14] The separation allows for independent optimization -and scaling of read and write sides, but more importantly for this discussion, -it enforces a structural discipline that discourages methods from accumulating +units of behaviour, thus reducing overall cognitive complexity within individual +components.[^14] The separation allows for independent optimization and +scaling of read and write sides, but more importantly for this discussion, it +enforces a structural discipline that discourages methods from accumulating diverse responsibilities.[^14] ### B. Avoiding spaghetti code turning into ravioli code diff --git a/template/docs/scripting-standards.md b/template/docs/scripting-standards.md index f99730e..40a44ab 100644 --- a/template/docs/scripting-standards.md +++ b/template/docs/scripting-standards.md @@ -338,15 +338,15 @@ The exceptions raised are those from the Python event loop itself, such as A `Catalogue` instance is safe to share across concurrent tasks because it is read-only after construction. `sh.scoped(CATALOGUE)` is a context manager that -binds the catalogue for the current execution scope. Authors must not mutate the -catalogue inside a concurrent task. Construct the catalogue once at module level -and re-use it. +binds the catalogue for the current execution scope. Authors must not mutate +the catalogue inside a concurrent task. Construct the catalogue once at module +level and re-use it. #### Concurrent testing patterns with cmd-mox -Concurrent async script paths use the same catalogue and scoped context in tests -as they do in production code. `cmd-mox` intercepts at the catalogue boundary -regardless of whether `run()` or `run_sync()` is used. +Concurrent async script paths use the same catalogue and scoped context in +tests as they do in production code. `cmd-mox` intercepts at the catalogue +boundary regardless of whether `run()` or `run_sync()` is used. ```python import pytest @@ -434,19 +434,12 @@ except FileNotFoundError: pass ``` - -## Cyclopts + cuprum + pathlib together (reference script) - -```python -#!/usr/bin/env -S uv run python ## Cyclopts + cuprum + pathlib together (reference script) ```python #!/usr/bin/env -S uv run python # /// script # requires-python = ">=3.13" - -# dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"] # dependencies = ["cyclopts>=2.9", "cuprum", "cmd-mox"] # /// From f844906f29d94ec94f6f8f29a4a56b068decdd07 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 14 Jun 2026 06:22:30 +0100 Subject: [PATCH 4/7] Address runtime fallback review findings Document the generated runtime selector and narrow the optional Rust fallback so only the missing extension module falls back to the pure Python backend. Other missing-module failures now propagate. Add generated-template coverage for the fallback and re-raise paths, define the dangling declarative-programming citation, and add context to the public API assertion. --- ...antipatterns-and-refactoring-strategies.md | 1 + template/tests/test_public_api.py.jinja | 5 +- template/{{ package_name }}/_runtime.py.jinja | 29 ++++++-- tests/test_template.py | 74 +++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/template/docs/complexity-antipatterns-and-refactoring-strategies.md b/template/docs/complexity-antipatterns-and-refactoring-strategies.md index 3c60eda..044f3bc 100644 --- a/template/docs/complexity-antipatterns-and-refactoring-strategies.md +++ b/template/docs/complexity-antipatterns-and-refactoring-strategies.md @@ -885,3 +885,4 @@ maintain. [^32]: Refactor `if-else` Statements to `match-case` for Improved Readability and Maintainability in Python 3.10+ · Issue #453 — GitHub, +[^34]: SQL — Wikipedia, diff --git a/template/tests/test_public_api.py.jinja b/template/tests/test_public_api.py.jinja index 03166e1..83c726d 100644 --- a/template/tests/test_public_api.py.jinja +++ b/template/tests/test_public_api.py.jinja @@ -7,4 +7,7 @@ import {{ package_name }} def test_hello_uses_configured_backend() -> None: """Return the expected greeting for this generated project.""" - assert {{ package_name }}.hello() == "{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" + expected_greeting = "{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" + assert {{ package_name }}.hello() == expected_greeting, ( + "expected public hello() to return the configured backend greeting" + ) diff --git a/template/{{ package_name }}/_runtime.py.jinja b/template/{{ package_name }}/_runtime.py.jinja index 7f55f8d..122d3ec 100644 --- a/template/{{ package_name }}/_runtime.py.jinja +++ b/template/{{ package_name }}/_runtime.py.jinja @@ -1,18 +1,37 @@ from __future__ import annotations + +"""Select the generated package implementation backend. + +This module keeps `__init__.py` as a stable public re-export while isolating +the logic that chooses between the optional Rust extension and the pure Python +implementation. External callers should import the package root and call +`{{ package_name }}.hello()`. Internal generated code may import `hello` from +`._runtime` when it needs the already-selected backend function. + +Examples +-------- +>>> import {{ package_name }} +>>> {{ package_name }}.hello() +"{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" +""" {% if use_rust %} -import importlib -import typing as typ +import importlib # noqa: E402 +import typing as typ # noqa: E402 if typ.TYPE_CHECKING: import collections.abc as cabc PACKAGE_NAME = "{{ package_name }}" +RUST_MODULE_NAME = f"_{PACKAGE_NAME}_rs" +EXPECTED_RUST_MODULE_NAME = f"{PACKAGE_NAME}.{RUST_MODULE_NAME}" try: # pragma: no cover - Rust optional - rust = importlib.import_module(f"._{PACKAGE_NAME}_rs", package=__package__) + rust = importlib.import_module(f".{RUST_MODULE_NAME}", package=__package__) hello = typ.cast("cabc.Callable[[], str]", rust.hello) -except ModuleNotFoundError: # pragma: no cover - Python fallback +except ModuleNotFoundError as exc: # pragma: no cover - Python fallback + if exc.name != EXPECTED_RUST_MODULE_NAME: + raise from .pure import hello as hello {%- else %} -from .pure import hello as hello +from .pure import hello as hello # noqa: E402 {%- endif %} diff --git a/tests/test_template.py b/tests/test_template.py index f6edbe6..ed90691 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -14,8 +14,10 @@ from __future__ import annotations import ast +import os import shutil import subprocess +import sys from pathlib import Path import pytest @@ -157,6 +159,78 @@ def test_pure_module_snapshot( assert pure_module == snapshot +def test_runtime_module_documents_and_limits_fallback( + copier: CopierFixture, tmp_path: Path +) -> None: + """Validate generated runtime backend-selection safeguards. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project is created. + + Returns + ------- + None + The test passes when the generated runtime module documents its role, + falls back only for the missing Rust extension, and re-raises unrelated + missing-module failures. + """ + project = copier.copy( + tmp_path / "runtime", + project_name="Runtime", + package_name="runtime_pkg", + use_rust=True, + ) + + runtime_module = read_generated_file(project, "runtime_pkg/_runtime.py") + + assert '"""Select the generated package implementation backend.' in runtime_module + assert "except ModuleNotFoundError as exc:" in runtime_module + assert 'EXPECTED_RUST_MODULE_NAME = f"{PACKAGE_NAME}.{RUST_MODULE_NAME}"' in ( + runtime_module + ) + assert "if exc.name != EXPECTED_RUST_MODULE_NAME:" in runtime_module + assert "raise" in runtime_module + + python_env = os.environ | {"PYTHONPATH": str(project.path)} + fallback = subprocess.run( + [ + sys.executable, + "-c", + "import runtime_pkg; assert runtime_pkg.hello() == 'hello from Python'", + ], + cwd=project.path, + env=python_env, + check=False, + capture_output=True, + text=True, + ) + assert fallback.returncode == 0, fallback.stderr + + (project / "runtime_pkg" / "_runtime_pkg_rs.py").write_text( + 'raise ModuleNotFoundError("missing dependency", name="transitive_dependency")\n', + encoding="utf-8", + ) + unexpected_missing_module = subprocess.run( + [sys.executable, "-c", "import runtime_pkg"], + cwd=project.path, + env=python_env, + check=False, + capture_output=True, + text=True, + ) + + assert unexpected_missing_module.returncode != 0, ( + "expected runtime import to re-raise unrelated ModuleNotFoundError" + ) + assert "transitive_dependency" in unexpected_missing_module.stderr, ( + "expected runtime import failure to preserve the original missing module" + ) + + def test_python_only_template(copier: CopierFixture, tmp_path: Path) -> None: """Validate the Python-only rendered project variant. From 95eb3ae9bed65cf44a0c7e87c832915773dec83e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 14 Jun 2026 15:37:10 +0100 Subject: [PATCH 5/7] Address public API documentation review findings Expand the generated package entry-point documentation so users can see the public import path and a concrete `hello()` example. Keep generated tests easier to diagnose by describing the public API check and adding assertion messages to the runtime source-shape checks. Renumber the SQL citation to keep the Markdown references sequential. --- ...ty-antipatterns-and-refactoring-strategies.md | 4 ++-- template/tests/test_public_api.py.jinja | 2 +- template/{{ package_name }}/__init__.py.jinja | 14 +++++++++++++- tests/test_template.py | 16 ++++++++++++---- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/template/docs/complexity-antipatterns-and-refactoring-strategies.md b/template/docs/complexity-antipatterns-and-refactoring-strategies.md index 044f3bc..5c48516 100644 --- a/template/docs/complexity-antipatterns-and-refactoring-strategies.md +++ b/template/docs/complexity-antipatterns-and-refactoring-strategies.md @@ -653,7 +653,7 @@ behaviour—common culprits for bugs and increased cognitive load in imperative code.[^26] Examples include using Structured Query Language for database queries— -specifying the desired dataset rather than the retrieval algorithm[^34]—or +specifying the desired dataset rather than the retrieval algorithm[^33]—or employing functional programming constructs like `map`, `filter`, and `reduce` on collections instead of writing explicit loops. Refactoring imperative code to a declarative style can start small, perhaps by converting a loop that @@ -885,4 +885,4 @@ maintain. [^32]: Refactor `if-else` Statements to `match-case` for Improved Readability and Maintainability in Python 3.10+ · Issue #453 — GitHub, -[^34]: SQL — Wikipedia, +[^33]: SQL — Wikipedia, diff --git a/template/tests/test_public_api.py.jinja b/template/tests/test_public_api.py.jinja index 83c726d..9d5feb1 100644 --- a/template/tests/test_public_api.py.jinja +++ b/template/tests/test_public_api.py.jinja @@ -6,7 +6,7 @@ import {{ package_name }} def test_hello_uses_configured_backend() -> None: - """Return the expected greeting for this generated project.""" + """Verify the expected greeting for this generated project.""" expected_greeting = "{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" assert {{ package_name }}.hello() == expected_greeting, ( "expected public hello() to return the configured backend greeting" diff --git a/template/{{ package_name }}/__init__.py.jinja b/template/{{ package_name }}/__init__.py.jinja index 4b661de..4a33b7c 100644 --- a/template/{{ package_name }}/__init__.py.jinja +++ b/template/{{ package_name }}/__init__.py.jinja @@ -1,4 +1,16 @@ -"""{{ project_name }} package.""" +"""Public package entry point for {{ project_name }}. + +The package exposes the supported public API for generated projects. Import +from this module when application code needs the package's main functionality; +the package selects the configured backend internally and exports `hello` as +the stable greeting function. + +Examples +-------- +>>> import {{ package_name }} +>>> {{ package_name }}.hello() +"{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" +""" from __future__ import annotations diff --git a/tests/test_template.py b/tests/test_template.py index ed90691..81967e6 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -187,13 +187,21 @@ def test_runtime_module_documents_and_limits_fallback( runtime_module = read_generated_file(project, "runtime_pkg/_runtime.py") - assert '"""Select the generated package implementation backend.' in runtime_module - assert "except ModuleNotFoundError as exc:" in runtime_module + assert '"""Select the generated package implementation backend.' in ( + runtime_module + ), "expected _runtime.py to document backend selection" + assert "except ModuleNotFoundError as exc:" in runtime_module, ( + "expected _runtime.py to inspect ModuleNotFoundError details" + ) assert 'EXPECTED_RUST_MODULE_NAME = f"{PACKAGE_NAME}.{RUST_MODULE_NAME}"' in ( runtime_module + ), "expected _runtime.py to define the fully qualified Rust module name" + assert "if exc.name != EXPECTED_RUST_MODULE_NAME:" in runtime_module, ( + "expected _runtime.py to fall back only for the missing Rust module" + ) + assert "raise" in runtime_module, ( + "expected _runtime.py to re-raise unrelated ModuleNotFoundError cases" ) - assert "if exc.name != EXPECTED_RUST_MODULE_NAME:" in runtime_module - assert "raise" in runtime_module python_env = os.environ | {"PYTHONPATH": str(project.path)} fallback = subprocess.run( From e6109039a4d0289646d8e8419209cb03140a28a7 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 14 Jun 2026 18:01:12 +0100 Subject: [PATCH 6/7] Make generated runtime documentation discoverable Move the generated `_runtime.py` documentation string before the future import so Python exposes it through `module.__doc__`. Extend the rendered-template regression check to require the generated file to start with the documentation string and to prove the imported runtime module keeps a non-empty `__doc__` value. --- template/{{ package_name }}/_runtime.py.jinja | 10 +++++----- tests/test_template.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/template/{{ package_name }}/_runtime.py.jinja b/template/{{ package_name }}/_runtime.py.jinja index 122d3ec..2122363 100644 --- a/template/{{ package_name }}/_runtime.py.jinja +++ b/template/{{ package_name }}/_runtime.py.jinja @@ -1,5 +1,3 @@ -from __future__ import annotations - """Select the generated package implementation backend. This module keeps `__init__.py` as a stable public re-export while isolating @@ -14,9 +12,11 @@ Examples >>> {{ package_name }}.hello() "{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" """ + +from __future__ import annotations {% if use_rust %} -import importlib # noqa: E402 -import typing as typ # noqa: E402 +import importlib +import typing as typ if typ.TYPE_CHECKING: import collections.abc as cabc @@ -33,5 +33,5 @@ except ModuleNotFoundError as exc: # pragma: no cover - Python fallback raise from .pure import hello as hello {%- else %} -from .pure import hello as hello # noqa: E402 +from .pure import hello as hello {%- endif %} diff --git a/tests/test_template.py b/tests/test_template.py index 81967e6..3780703 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -187,9 +187,9 @@ def test_runtime_module_documents_and_limits_fallback( runtime_module = read_generated_file(project, "runtime_pkg/_runtime.py") - assert '"""Select the generated package implementation backend.' in ( - runtime_module - ), "expected _runtime.py to document backend selection" + assert runtime_module.startswith( + '"""Select the generated package implementation backend.' + ), "expected rendered _runtime.py to start with its module docstring" assert "except ModuleNotFoundError as exc:" in runtime_module, ( "expected _runtime.py to inspect ModuleNotFoundError details" ) @@ -208,7 +208,12 @@ def test_runtime_module_documents_and_limits_fallback( [ sys.executable, "-c", - "import runtime_pkg; assert runtime_pkg.hello() == 'hello from Python'", + ( + "import runtime_pkg; " + "import runtime_pkg._runtime as runtime; " + "assert runtime_pkg.hello() == 'hello from Python'; " + "assert runtime.__doc__ is not None" + ), ], cwd=project.path, env=python_env, From 6be8939db720a8942f502e3c99f8170bee3944db Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 14 Jun 2026 18:39:34 +0100 Subject: [PATCH 7/7] Update generated PyO3 dependency for audit safety Bump the Rust-extension template to PyO3 0.29.0 so generated Rust-enabled projects no longer fail `cargo audit` before the shared coverage action can run. --- .../{% if use_rust %}rust_extension{% endif %}/Cargo.toml.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/{% if use_rust %}rust_extension{% endif %}/Cargo.toml.jinja b/template/{% if use_rust %}rust_extension{% endif %}/Cargo.toml.jinja index a3d8e7d..588c21e 100644 --- a/template/{% if use_rust %}rust_extension{% endif %}/Cargo.toml.jinja +++ b/template/{% if use_rust %}rust_extension{% endif %}/Cargo.toml.jinja @@ -8,7 +8,7 @@ name = "_{{ package_name }}_rs" crate-type = ["cdylib", "rlib"] [dependencies] -pyo3 = { version = "0.28.3", features = ["extension-module"] } +pyo3 = { version = "0.29.0", features = ["extension-module"] } [lints.clippy] pedantic = { level = "warn", priority = -1 }