Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<your_pkg>` 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 `<your_pkg>.main:main` or another consumer-owned entry point.

The self-demo remains available after the rename:

```bash
python -m your_pkg
```

## Repository Structure

```text
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 | Yes |
26 changes: 21 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = [
Expand All @@ -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"]
Expand All @@ -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/*"
21 changes: 21 additions & 0 deletions src/sample_app/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
8 changes: 8 additions & 0 deletions src/sample_app/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
45 changes: 45 additions & 0 deletions src/sample_app/_contracts.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions src/sample_app/config.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/sample_app/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
45 changes: 45 additions & 0 deletions src/sample_app/main.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added src/sample_app/py.typed
Empty file.
27 changes: 27 additions & 0 deletions src/sample_app/validators.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading