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/docs/complexity-antipatterns-and-refactoring-strategies.md b/template/docs/complexity-antipatterns-and-refactoring-strategies.md index 7c0d207..5c48516 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 @@ -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[^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,3 +885,4 @@ maintain. [^32]: Refactor `if-else` Statements to `match-case` for Improved Readability and Maintainability in Python 3.10+ · Issue #453 — GitHub, +[^33]: SQL — Wikipedia, diff --git a/template/docs/scripting-standards.md b/template/docs/scripting-standards.md index d8ea71c..40a44ab 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, ): @@ -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 @@ -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) diff --git a/template/tests/test_public_api.py.jinja b/template/tests/test_public_api.py.jinja new file mode 100644 index 0000000..9d5feb1 --- /dev/null +++ b/template/tests/test_public_api.py.jinja @@ -0,0 +1,13 @@ +"""Tests for the public package API.""" + +from __future__ import annotations + +import {{ package_name }} + + +def test_hello_uses_configured_backend() -> None: + """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/{% 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 } diff --git a/template/{{ package_name }}/__init__.py.jinja b/template/{{ package_name }}/__init__.py.jinja index 83fbf8d..4a33b7c 100644 --- a/template/{{ package_name }}/__init__.py.jinja +++ b/template/{{ package_name }}/__init__.py.jinja @@ -1,19 +1,19 @@ -"""{{ project_name }} package.""" +"""Public package entry point for {{ project_name }}. -from __future__ import annotations - -import importlib -import typing as typ +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. -if typ.TYPE_CHECKING: - import collections.abc as cabc +Examples +-------- +>>> import {{ package_name }} +>>> {{ package_name }}.hello() +"{% if use_rust %}hello from Rust{% else %}hello from Python{% endif %}" +""" -PACKAGE_NAME = "{{ package_name }}" +from __future__ import annotations -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..2122363 --- /dev/null +++ b/template/{{ package_name }}/_runtime.py.jinja @@ -0,0 +1,37 @@ +"""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 %}" +""" + +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 }}" +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".{RUST_MODULE_NAME}", package=__package__) + hello = typ.cast("cabc.Callable[[], str]", rust.hello) +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 +{%- endif %} diff --git a/tests/test_template.py b/tests/test_template.py index a266f11..3780703 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,91 @@ 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 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" + ) + 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" + ) + + python_env = os.environ | {"PYTHONPATH": str(project.path)} + fallback = subprocess.run( + [ + sys.executable, + "-c", + ( + "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, + 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. @@ -190,6 +277,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" )