From 34362fe1ab6b143f0393559a02500588eee84e95 Mon Sep 17 00:00:00 2001 From: Smarter Harder <33955773+NWarila@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:04:07 -0400 Subject: [PATCH 1/2] feat: add generic package skeleton --- README.md | 54 ++++++++ ...4-python-package-layout-and-app-anatomy.md | 3 +- pyproject.toml | 26 +++- src/sample_app/__init__.py | 21 ++++ src/sample_app/__main__.py | 8 ++ src/sample_app/_contracts.py | 45 +++++++ src/sample_app/config.py | 36 ++++++ src/sample_app/exceptions.py | 21 ++++ src/sample_app/main.py | 45 +++++++ src/sample_app/py.typed | 0 src/sample_app/validators.py | 27 ++++ tests/test_sample_app.py | 117 ++++++++++++++++++ 12 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 src/sample_app/__init__.py create mode 100644 src/sample_app/__main__.py create mode 100644 src/sample_app/_contracts.py create mode 100644 src/sample_app/config.py create mode 100644 src/sample_app/exceptions.py create mode 100644 src/sample_app/main.py create mode 100644 src/sample_app/py.typed create mode 100644 src/sample_app/validators.py create mode 100644 tests/test_sample_app.py diff --git a/README.md b/README.md index d8d41b0..b277eec 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Downstream Python repos consume both layers through different mechanisms. The `. - **Canonical QA scripts** in `scripts/` — thin Python wrappers around standard tools (ruff, mypy, pytest, pip-audit, codespell, build/twine). Each script is the source implementation for its check. - **Local orchestrator** (`qa.py`) — runs all checks in sequence with `--fix` and `--skip` flags, so developers get the same quality bar locally that CI enforces remotely. +- **Generic package skeleton** in `src/sample_app/` with typed contracts, settings, validation, exceptions, and a self-demo CLI. - **Sync manifest** (`sync-manifest.json`) defining source-to-destination file mappings for downstream repos. - **Reusable sync workflow** (`self-update.yml`) that downstream repos call via `uses:` to pull updates automatically. - **Composite setup action** in `.github/actions/setup-python/` for Python plus dependency bootstrap. @@ -42,6 +43,49 @@ Downstream Python repos consume both layers through different mechanisms. The `. | Spelling | codespell | `[tool.codespell]` in `pyproject.toml` | | Packaging | build + twine | `[build-system]` in `pyproject.toml` | +## Use This Template + +The starter package is intentionally generic. Rename `sample_app` before adding product-specific code: + +```powershell +$env:NEW_PKG = "your_pkg" +git mv src/sample_app "src/$env:NEW_PKG" +git mv tests/test_sample_app.py "tests/test_$($env:NEW_PKG).py" +@' +import os +from pathlib import Path + +old = "sample_app" +new = os.environ["NEW_PKG"] +dist = new.replace("_", "-") +paths = [ + Path("pyproject.toml"), + Path("README.md"), + Path(f"tests/test_{new}.py"), + *sorted((Path("src") / new).rglob("*.py")), +] + +for path in paths: + text = path.read_text(encoding="utf-8") + text = text.replace(old, new) + text = text.replace('name = "python-template"', f'name = "{dist}"') + path.write_text(text, encoding="utf-8") +'@ | Set-Content -Encoding UTF8 rename_template_package.py +python rename_template_package.py +Remove-Item rename_template_package.py +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +python scripts/qa.py +``` + +That flow performs the required `git mv src/sample_app src/` rename, updates imports and module strings, changes `[tool.setuptools.packages.find].include` to the new package name, changes `[project].name` to the hyphenated distribution name, refreshes the editable install, and runs the QA gates. If a consumer adds `[project.scripts]`, point each entry at `.main:main` or another consumer-owned entry point. + +The self-demo remains available after the rename: + +```bash +python -m your_pkg +``` + ## Repository Structure ```text @@ -79,6 +123,16 @@ python-template/ | |-- sync.py # Manifest-driven file sync for template updates | |-- setup.sh # Unix venv bootstrap | `-- setup.ps1 # Windows venv bootstrap +|-- src/ +| `-- sample_app/ +| |-- __init__.py # Curated public API and __all__ +| |-- __main__.py # python -m sample_app self-demo +| |-- py.typed # PEP 561 typing marker +| |-- config.py # Frozen pydantic-settings API +| |-- exceptions.py # Typed What/Why/Fix exceptions +| |-- _contracts.py # Frozen Pydantic contracts and Result shape +| |-- validators.py # Pure validation helpers +| `-- main.py # CLI entry point and worked sample function |-- sync-manifest.json # Source-to-dest file mappings for downstream sync |-- pyproject.toml # Config for this repo `-- README.md diff --git a/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md b/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md index d127f83..f8d950f 100644 --- a/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md +++ b/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md @@ -130,7 +130,7 @@ None (current). ## Implementing PRs -None yet. The follow-on package skeleton implementation should be listed here when it lands. +- PT-M3: Ship the minimal generic `src/sample_app` package skeleton matching this ADR's eight-file anatomy. ## Related ADRs @@ -145,3 +145,4 @@ None. | Date | Change | Reason | Author/Role | Body-diff? | | ---------- | ------------------------------------------ | ------------------------------------------------- | ----------------------------------- | ---------- | | 2026-06-03 | Accepted the template package layout ADR. | Document the package anatomy before implementation. | Portfolio maintainer / template ADR | Yes | +| 2026-06-03 | Landed the minimal generic package skeleton. | Record the implementing package anatomy landing. | Implementation agent / template PR | No | diff --git a/pyproject.toml b/pyproject.toml index 5b4bb64..679df03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,14 @@ requires = ["setuptools>=75.0"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = [] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] +include = ["sample_app*"] + +[tool.setuptools.package-data] +"*" = ["py.typed"] [project] name = "python-template" @@ -12,6 +19,10 @@ description = "Reusable Python quality-gate scripts, workflows, and reference co readme = "README.md" requires-python = ">=3.11" license = "MIT" +dependencies = [ + "pydantic>=2.0,<3", + "pydantic-settings>=2.0,<3", +] [project.optional-dependencies] dev = [ @@ -30,7 +41,7 @@ dev = [ [tool.ruff] target-version = "py311" line-length = 120 -src = ["scripts", "tests"] +src = ["scripts", "src", "tests"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "S", "SIM", "C4", "PT", "T20", "RUF"] @@ -43,12 +54,17 @@ select = ["E", "F", "W", "I", "UP", "B", "S", "SIM", "C4", "PT", "T20", "RUF"] python_version = "3.11" strict = true explicit_package_bases = true -files = ["scripts", "tests"] +mypy_path = "src" +files = ["scripts", "src", "tests"] +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_typed = true [tool.pytest.ini_options] -addopts = "-ra --import-mode=importlib --cov=scripts --cov-report=term-missing --cov-fail-under=90" +addopts = "-ra --import-mode=importlib --cov=scripts --cov=src --cov-report=term-missing --cov-fail-under=90" testpaths = ["tests"] -pythonpath = ["."] +pythonpath = [".", "src"] [tool.codespell] skip = ".venv,dist,.git,.mypy_cache,.ruff_cache,.pytest_cache,reference,.\\docs\\decision-records\\org\\*,./docs/decision-records/org/*" diff --git a/src/sample_app/__init__.py b/src/sample_app/__init__.py new file mode 100644 index 0000000..8d2e312 --- /dev/null +++ b/src/sample_app/__init__.py @@ -0,0 +1,21 @@ +"""Curated public API for the generic sample package.""" + +from __future__ import annotations + +from ._contracts import Result, ResultCode, TextRequest +from .config import Settings, load, save_defaults +from .exceptions import InputValidationError, SampleAppError +from .main import build_message, main + +__all__ = [ + "InputValidationError", + "Result", + "ResultCode", + "SampleAppError", + "Settings", + "TextRequest", + "build_message", + "load", + "main", + "save_defaults", +] diff --git a/src/sample_app/__main__.py b/src/sample_app/__main__.py new file mode 100644 index 0000000..df4b193 --- /dev/null +++ b/src/sample_app/__main__.py @@ -0,0 +1,8 @@ +"""Self-demo entry point for ``python -m sample_app``.""" + +from __future__ import annotations + +from .main import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sample_app/_contracts.py b/src/sample_app/_contracts.py new file mode 100644 index 0000000..32a6fa5 --- /dev/null +++ b/src/sample_app/_contracts.py @@ -0,0 +1,45 @@ +"""Frozen Pydantic contracts used by the sample package.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Generic, Self, TypeVar + +from pydantic import BaseModel, ConfigDict, model_validator + +from .validators import validate_repeat, validate_text + +T = TypeVar("T") + + +class ResultCode(StrEnum): + """Stable result codes for sample operations.""" + + OK = "ok" + INVALID = "invalid" + + +class TextRequest(BaseModel): + """Input contract for the worked sample function.""" + + model_config = ConfigDict(frozen=True) + + text: str + repeat: int = 1 + + @model_validator(mode="after") + def validate_request(self) -> Self: + """Validate the full request with pure validation helpers.""" + validate_text(self.text) + validate_repeat(self.repeat) + return self + + +class Result(BaseModel, Generic[T]): + """Small Result-style response shape.""" + + model_config = ConfigDict(frozen=True) + + code: ResultCode + value: T | None = None + error: str | None = None diff --git a/src/sample_app/config.py b/src/sample_app/config.py new file mode 100644 index 0000000..bdc6213 --- /dev/null +++ b/src/sample_app/config.py @@ -0,0 +1,36 @@ +"""Frozen pydantic-settings configuration API.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +_DEFAULT_GREETING = "Hello" +_DEFAULT_NAME = "template" +_ENV_PREFIX = "SAMPLE_APP_" + + +class Settings(BaseSettings): + """Runtime settings for the generic sample package.""" + + model_config = SettingsConfigDict(env_prefix=_ENV_PREFIX, frozen=True) + + greeting: str = _DEFAULT_GREETING + default_name: str = _DEFAULT_NAME + + +def load() -> Settings: + """Load settings from the process environment.""" + return Settings() + + +def save_defaults(path: str | Path = "sample_app.defaults.env") -> Path: + """Write a dotenv-style file containing the package defaults.""" + destination = Path(path) + defaults = Settings(greeting=_DEFAULT_GREETING, default_name=_DEFAULT_NAME) + destination.write_text( + f"{_ENV_PREFIX}GREETING={defaults.greeting}\n{_ENV_PREFIX}DEFAULT_NAME={defaults.default_name}\n", + encoding="utf-8", + ) + return destination diff --git a/src/sample_app/exceptions.py b/src/sample_app/exceptions.py new file mode 100644 index 0000000..e57b53d --- /dev/null +++ b/src/sample_app/exceptions.py @@ -0,0 +1,21 @@ +"""Typed exception hierarchy for the sample package.""" + +from __future__ import annotations + + +class SampleAppError(Exception): + """Base exception carrying What, Why, and Fix details.""" + + what: str + why: str + fix: str + + def __init__(self, *, what: str, why: str, fix: str) -> None: + self.what = what + self.why = why + self.fix = fix + super().__init__(f"What: {what} Why: {why} Fix: {fix}") + + +class InputValidationError(SampleAppError, ValueError): + """Raised when a sample input contract is invalid.""" diff --git a/src/sample_app/main.py b/src/sample_app/main.py new file mode 100644 index 0000000..4a31c38 --- /dev/null +++ b/src/sample_app/main.py @@ -0,0 +1,45 @@ +"""CLI and worked sample function for the generic package.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence + +from pydantic import ValidationError + +from ._contracts import Result, ResultCode, TextRequest +from .config import load + + +def build_message(text: str, repeat: int = 1) -> Result[str]: + """Validate input and render a configured sample greeting.""" + try: + request = TextRequest(text=text, repeat=repeat) + except ValidationError as exc: + return Result[str](code=ResultCode.INVALID, error=str(exc)) + + settings = load() + message = " ".join(f"{settings.greeting}, {request.text.strip()}!" for _ in range(request.repeat)) + return Result[str](code=ResultCode.OK, value=message) + + +def _parser(default_text: str) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the sample package self-demo.") + parser.add_argument("text", nargs="?", default=default_text, help="Text to include in the demo output.") + parser.add_argument("--repeat", type=int, default=1, help="Number of times to render the message.") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the sample CLI.""" + settings = load() + args = _parser(settings.default_name).parse_args(argv) + result = build_message(args.text, args.repeat) + + if result.code is ResultCode.OK and result.value is not None: + sys.stdout.write(f"{result.value}\n") + return 0 + + sys.stderr.write(f"{result.error or 'Unknown sample_app error'}\n") + return 2 diff --git a/src/sample_app/py.typed b/src/sample_app/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sample_app/validators.py b/src/sample_app/validators.py new file mode 100644 index 0000000..c76569c --- /dev/null +++ b/src/sample_app/validators.py @@ -0,0 +1,27 @@ +"""Pure validation helpers for sample contracts.""" + +from __future__ import annotations + +from .exceptions import InputValidationError + + +def validate_text(value: str) -> str: + """Validate that text contains visible content.""" + if not value.strip(): + raise InputValidationError( + what="Input text is blank.", + why="The sample message needs visible text to render.", + fix="Pass a non-blank text value.", + ) + return value + + +def validate_repeat(value: int) -> int: + """Validate that repeat is a positive count.""" + if value < 1: + raise InputValidationError( + what="Repeat count is not positive.", + why="The sample message cannot be rendered zero or negative times.", + fix="Pass a repeat value of 1 or greater.", + ) + return value diff --git a/tests/test_sample_app.py b/tests/test_sample_app.py new file mode 100644 index 0000000..97bcf2b --- /dev/null +++ b/tests/test_sample_app.py @@ -0,0 +1,117 @@ +"""Tests for the generic sample package skeleton.""" + +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from sample_app import ( + InputValidationError, + ResultCode, + Settings, + TextRequest, + build_message, + load, + main, + save_defaults, +) +from sample_app.validators import validate_repeat, validate_text + + +def test_build_message_returns_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SAMPLE_APP_GREETING", "Hi") + + result = build_message("Ada", repeat=2) + + assert result.code is ResultCode.OK + assert result.value == "Hi, Ada! Hi, Ada!" + assert result.error is None + + +def test_build_message_returns_invalid_result() -> None: + result = build_message(" ", repeat=1) + + assert result.code is ResultCode.INVALID + assert result.value is None + assert result.error is not None + assert "Input text is blank" in result.error + + +def test_validators_accept_valid_values() -> None: + assert validate_text("template") == "template" + assert validate_repeat(1) == 1 + + +def test_validators_reject_invalid_values() -> None: + with pytest.raises(InputValidationError) as text_error: + validate_text("") + assert text_error.value.fix == "Pass a non-blank text value." + + with pytest.raises(InputValidationError) as repeat_error: + validate_repeat(0) + assert repeat_error.value.why == "The sample message cannot be rendered zero or negative times." + + +def test_contract_models_are_frozen() -> None: + request = TextRequest(text="fixed") + field = "text" + + with pytest.raises(ValidationError): + setattr(request, field, "changed") + + +def test_settings_are_frozen() -> None: + settings = Settings() + field = "greeting" + + with pytest.raises(ValidationError): + setattr(settings, field, "Changed") + + +def test_typed_exception_fields() -> None: + error = InputValidationError(what="What happened.", why="Why it happened.", fix="How to fix it.") + + assert error.what == "What happened." + assert error.why == "Why it happened." + assert error.fix == "How to fix it." + assert "What happened" in str(error) + + +def test_load_reads_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SAMPLE_APP_DEFAULT_NAME", "env-user") + + assert load().default_name == "env-user" + + +def test_save_defaults_writes_env_file(tmp_path: Path) -> None: + destination = save_defaults(tmp_path / "defaults.env") + + assert destination.read_text(encoding="utf-8") == "SAMPLE_APP_GREETING=Hello\nSAMPLE_APP_DEFAULT_NAME=template\n" + + +def test_main_prints_demo_output(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = main(["Grace", "--repeat", "1"]) + + assert exit_code == 0 + assert capsys.readouterr().out == "Hello, Grace!\n" + + +def test_main_returns_nonzero_for_invalid_input(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = main(["", "--repeat", "1"]) + + assert exit_code == 2 + assert "Input text is blank" in capsys.readouterr().err + + +def test_python_m_self_demo(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr(sys, "argv", ["sample_app"]) + + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("sample_app", run_name="__main__") + + assert exit_info.value.code == 0 + assert capsys.readouterr().out == "Hello, template!\n" From 62b0a1fbb164d7876dda2350fba705182fa47b2a Mon Sep 17 00:00:00 2001 From: Smarter Harder <33955773+NWarila@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:33:30 -0400 Subject: [PATCH 2/2] test: cover invalid repeat through sample flow --- .../0004-python-package-layout-and-app-anatomy.md | 2 +- tests/test_sample_app.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md b/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md index f8d950f..9422c7b 100644 --- a/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md +++ b/docs/decision-records/template/0004-python-package-layout-and-app-anatomy.md @@ -145,4 +145,4 @@ None. | Date | Change | Reason | Author/Role | Body-diff? | | ---------- | ------------------------------------------ | ------------------------------------------------- | ----------------------------------- | ---------- | | 2026-06-03 | Accepted the template package layout ADR. | Document the package anatomy before implementation. | Portfolio maintainer / template ADR | Yes | -| 2026-06-03 | Landed the minimal generic package skeleton. | Record the implementing package anatomy landing. | Implementation agent / template PR | No | +| 2026-06-03 | Landed the minimal generic package skeleton. | Record the implementing package anatomy landing. | Implementation agent / template PR | Yes | diff --git a/tests/test_sample_app.py b/tests/test_sample_app.py index 97bcf2b..1aa635d 100644 --- a/tests/test_sample_app.py +++ b/tests/test_sample_app.py @@ -41,6 +41,15 @@ def test_build_message_returns_invalid_result() -> None: assert "Input text is blank" in result.error +def test_build_message_rejects_invalid_repeat() -> None: + result = build_message("Ada", repeat=0) + + assert result.code is ResultCode.INVALID + assert result.value is None + assert result.error is not None + assert "Repeat count is not positive" in result.error + + def test_validators_accept_valid_values() -> None: assert validate_text("template") == "template" assert validate_repeat(1) == 1