diff --git a/README.md b/README.md index 2bfb83f..9a35eca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# Python CLI Template +# Python Project Template -Minimal Cookiecutter template for python CLI tools. Python 3.12+, uv, Typer, ruff, mypy, pytest. +Minimal Cookiecutter template for Python 3.12+ projects using uv, ruff, mypy, +and pytest. Pick a variant when generating: + +- **`cli`** — a Typer command-line tool (distributed as a wheel / `uv tool install`). +- **`service`** — a long-running, containerized script (e.g. an email poller): + poll loop with graceful SIGTERM shutdown, a self-contained Docker image, and a + registry-free `docker save | ssh` deploy task. + +Versioning is git-tag based (`hatch-vcs`): the tag is the single source of +truth and `inv bump ` creates the next `vX.Y.Z` tag. ## Usage @@ -8,6 +17,15 @@ Minimal Cookiecutter template for python CLI tools. Python 3.12+, uv, Typer, ruf cruft create https://git.roxautomation.com/sjev/python-cli-template.git ``` +You'll be prompted for `project_type` (`cli` or `service`). The generated +project is git-initialized, tagged `v0.1.0`, and ready to `source init.sh`. + +## Development (this template) + +```bash +bash test.sh # renders + lint/tests both variants +``` + ## License MIT diff --git a/cookiecutter.json b/cookiecutter.json index 9718594..0d7cb7b 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -3,6 +3,7 @@ "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}", "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}", "description": "A modern CLI tool", + "project_type": ["cli", "service"], "author_name": "Your Name", "author_email": "your.email@example.com", "version": "0.1.0", diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 546943c..aadb2e7 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -6,25 +6,59 @@ from __future__ import annotations import shutil import subprocess import sys +from pathlib import Path + +PROJECT_TYPE = "{{ cookiecutter.project_type }}" +PACKAGE = "{{ cookiecutter.package_name }}" +VERSION = "{{ cookiecutter.version }}" + +# Files to remove for the variant we did NOT generate. +VARIANT_FILES = { + "cli": [ + f"src/{PACKAGE}/service.py", + f"src/{PACKAGE}/__main__.py", + "tests/test_service.py", + "Dockerfile", + ".dockerignore", + "docker-compose.yml", + ".env.example", + ], + "service": [ + f"src/{PACKAGE}/cli.py", + "tests/test_cli.py", + ], +} + + +def run(*cmd: str) -> None: + print("Running:", " ".join(cmd)) + subprocess.run(cmd, check=True) def main() -> None: - """Create a lockfile so generated projects are reproducible by default.""" if shutil.which("uv") is None: print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr) sys.exit(1) - print("Running: uv lock") + for rel in VARIANT_FILES[PROJECT_TYPE]: + Path(rel).unlink(missing_ok=True) + + run("git", "init", "-q") try: - subprocess.run(["uv", "lock"], check=True) + run("uv", "lock") # before committing, so the lockfile lands in the tagged commit except subprocess.CalledProcessError as exc: print(f"Error: uv lock failed with exit code {exc.returncode}.", file=sys.stderr) sys.exit(exc.returncode) - print("\nProject lockfile generated.") + # Commit + tag so HEAD == tag and hatch-vcs resolves a clean v{VERSION}. + run("git", "add", "-A") + run("git", "commit", "-q", "-m", "chore: initial commit from template") + run("git", "tag", f"v{VERSION}") + + print("\nProject ready (git initialized, tagged v" + VERSION + ").") print("Next steps:") - print("cd ") - print("source init.sh") + print(" cd ") + print(" source init.sh") if __name__ == "__main__": diff --git a/test.sh b/test.sh index 5a1ad74..6f5d612 100755 --- a/test.sh +++ b/test.sh @@ -9,28 +9,40 @@ echo "Removing old build dir..." rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" -echo "Generating project from template..." -cd "$BUILD_DIR" -cookiecutter "$TEMPLATE_DIR" \ - --no-input \ - project_name="Test Project" \ - project_slug="test-project" \ - package_name="test_project" \ - description="A test project" \ - author_name="Test Author" \ - author_email="test@example.com" \ - version="0.1.0" +run_variant() { + local type="$1" + local slug="demo-$type" -cd "$BUILD_DIR/test-project" -echo "Generated project at: $(pwd)" + echo + echo "=== Generating '$type' variant ===" + cd "$BUILD_DIR" + cookiecutter "$TEMPLATE_DIR" \ + --no-input \ + project_name="Demo $type" \ + project_slug="$slug" \ + package_name="demo_$type" \ + description="A demo $type project" \ + project_type="$type" \ + author_name="Test Author" \ + author_email="test@example.com" \ + version="0.1.0" -echo "Running init.sh..." -bash init.sh + cd "$BUILD_DIR/$slug" + echo "Generated at: $(pwd)" -echo "Running lint..." -uv run invoke lint + echo "Running init.sh..." + # shellcheck disable=SC1091 + source init.sh -echo "Running tests..." -uv run invoke test + echo "Running lint..." + uv run invoke lint -echo "All checks passed." + echo "Running tests..." + uv run invoke test +} + +run_variant cli +run_variant service + +echo +echo "All checks passed for both variants." diff --git a/{{cookiecutter.project_slug}}/.env.example b/{{cookiecutter.project_slug}}/.env.example new file mode 100644 index 0000000..c64efc3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.env.example @@ -0,0 +1,8 @@ +# Copy to .env and adjust. .env is gitignored. +LOG_LEVEL=INFO +POLL_INTERVAL=60 + +# Example email-poller settings (uncomment and use in service.py): +# IMAP_HOST=imap.example.com +# IMAP_USER= +# IMAP_PASSWORD= diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore index c0ae060..788d13e 100644 --- a/{{cookiecutter.project_slug}}/.gitignore +++ b/{{cookiecutter.project_slug}}/.gitignore @@ -15,6 +15,9 @@ dist/ *.egg pip-wheel-metadata/ +# Local environment / secrets +.env + # Virtual environments .venv/ venv/ diff --git a/{{cookiecutter.project_slug}}/Dockerfile b/{{cookiecutter.project_slug}}/Dockerfile index 6b1ad3b..ee38ec0 100644 --- a/{{cookiecutter.project_slug}}/Dockerfile +++ b/{{cookiecutter.project_slug}}/Dockerfile @@ -1,9 +1,18 @@ -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder +# git lets hatch-vcs resolve the version from the tag in the copied .git. +RUN apt-get update && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app - -COPY pyproject.toml uv.lock* ./ -RUN uv sync --group dev --no-install-project - COPY . . -RUN uv sync --group dev +RUN uv build --wheel \ + && uv export --no-dev --frozen --no-emit-project --no-hashes -o requirements.txt + +FROM python:3.12-slim AS runtime +ENV PYTHONUNBUFFERED=1 +WORKDIR /app +COPY --from=builder /app/requirements.txt /app/dist/*.whl ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir --no-deps *.whl \ + && rm -f *.whl requirements.txt +USER 1000:1000 +ENTRYPOINT ["python", "-m", "{{ cookiecutter.package_name }}"] diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index def641d..d2de0bd 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -10,10 +10,21 @@ source init.sh ## Usage +{%- if cookiecutter.project_type == "cli" %} ```bash {{ cookiecutter.project_slug }} --help {{ cookiecutter.project_slug }} hello ``` +{%- else %} +```bash +python -m {{ cookiecutter.package_name }} # run the poller loop +python -m {{ cookiecutter.package_name }} --once # single cycle (cron/tests) +``` + +Configuration is read from the environment (`LOG_LEVEL`, `POLL_INTERVAL`); see +`.env.example`. Put the polling/handling logic in `poll_once` / `handle` in +`src/{{ cookiecutter.package_name }}/service.py`. +{%- endif %} ## Development @@ -21,5 +32,30 @@ source init.sh uv run invoke lint uv run invoke test uv run invoke format -uv run invoke ci # lint + test in Docker +uv run invoke ci # lint + test ``` + +## Versioning + +The git tag is the single source of truth (via `hatch-vcs`); the version is +derived at build time — never edited in `pyproject.toml`. + +```bash +uv run invoke bump patch # creates tag vX.Y.Z +git push origin vX.Y.Z +``` +{%- if cookiecutter.project_type == "service" %} + +## Deployment + +Ships a self-contained image; no registry required — the image is copied to the +target over SSH. + +```bash +uv run invoke build-image # build {{ cookiecutter.project_slug }}: +uv run invoke deploy # build + docker save | ssh + restart compose +``` + +Edit `VPS`, `REMOTE_DIR`, and `SERVICE` constants in `tasks.py` and the +`docker-compose.yml` on the target host to match your setup. +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/docker-compose.yml b/{{cookiecutter.project_slug}}/docker-compose.yml new file mode 100644 index 0000000..7bc5487 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docker-compose.yml @@ -0,0 +1,9 @@ +services: + {{ cookiecutter.project_slug }}: + image: {{ cookiecutter.project_slug }}:latest # `inv deploy` rewrites the tag + container_name: {{ cookiecutter.project_slug }} + env_file: .env + user: "1000:1000" + restart: unless-stopped + security_opt: + - no-new-privileges:true diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index a1e6454..248690b 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -1,22 +1,36 @@ [project] name = "{{ cookiecutter.project_slug }}" -version = "{{ cookiecutter.version }}" +dynamic = ["version"] description = "{{ cookiecutter.description }}" readme = "README.md" authors = [ { name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" } ] requires-python = ">=3.12" +{%- if cookiecutter.project_type == "cli" %} dependencies = [ "typer>=0.12", ] +{%- else %} +dependencies = [] # stdlib-only skeleton; add email/poller libs as needed +{%- endif %} [project.scripts] +{%- if cookiecutter.project_type == "cli" %} {{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli:app" +{%- else %} +{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.service:main" +{%- endif %} [build-system] -requires = ["uv_build>=0.8.8,<0.9.0"] -build-backend = "uv_build" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" [dependency-groups] dev = [ diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__main__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__main__.py new file mode 100644 index 0000000..5a66845 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .service import main + +if __name__ == "__main__": + main() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/service.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/service.py new file mode 100644 index 0000000..7db5ce8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/service.py @@ -0,0 +1,66 @@ +"""Long-running poller skeleton with graceful shutdown. + +Replace `poll_once` and `handle` with real work (e.g. fetch + process email). +""" + +from __future__ import annotations + +import argparse +import logging +import os +import signal +import threading +from importlib.metadata import version + +log = logging.getLogger("{{ cookiecutter.package_name }}") + + +def setup_logging(level: str) -> None: + logging.basicConfig( + level=level.upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + +def poll_once() -> list[object]: + """Fetch pending items. Return an empty list when there is nothing to do.""" + return [] + + +def handle(item: object) -> None: + """Process a single item.""" + log.info("handling %r", item) + + +def run(stop: threading.Event, interval: float) -> None: + """Poll-handle-sleep loop that exits cleanly when `stop` is set.""" + log.info("started (interval=%ss)", interval) + while not stop.is_set(): + for item in poll_once(): + handle(item) + stop.wait(interval) + log.info("stopped") + + +def main() -> None: + parser = argparse.ArgumentParser(description="{{ cookiecutter.description }}") + parser.add_argument("--once", action="store_true", help="Run a single cycle and exit.") + parser.add_argument( + "--version", + action="version", + version=version("{{ cookiecutter.project_slug }}"), + ) + args = parser.parse_args() + + setup_logging(os.getenv("LOG_LEVEL", "INFO")) + interval = float(os.getenv("POLL_INTERVAL", "60")) + + stop = threading.Event() + if args.once: + for item in poll_once(): + handle(item) + return + + signal.signal(signal.SIGTERM, lambda *_: stop.set()) + signal.signal(signal.SIGINT, lambda *_: stop.set()) + run(stop, interval) diff --git a/{{cookiecutter.project_slug}}/tasks.py b/{{cookiecutter.project_slug}}/tasks.py index 1ad7083..7844df8 100644 --- a/{{cookiecutter.project_slug}}/tasks.py +++ b/{{cookiecutter.project_slug}}/tasks.py @@ -1,10 +1,13 @@ # type: ignore -import re -from pathlib import Path - from invoke import task -PYPROJECT = Path(__file__).parent / "pyproject.toml" +{%- if cookiecutter.project_type == "service" %} + +IMAGE = "{{ cookiecutter.project_slug }}" +SERVICE = "{{ cookiecutter.project_slug }}" +VPS = "vps" # ssh alias; edit to match your ~/.ssh/config +REMOTE_DIR = "~/stack" # dir on the VPS holding docker-compose.yml +{%- endif %} @task @@ -35,34 +38,27 @@ def test(c): @task def ci(c): - """Run lint and tests in a clean Docker container.""" - image = "{{ cookiecutter.project_slug }}-ci" - c.run(f"docker build --network=host -t {image} .") - cmd = ( - "uv run ruff check src tests && " - "uv run ruff format --check src tests && " - "uv run mypy src && " - "uv run pytest --cov=src --cov-report=term-missing" - ) - c.run(f"docker run --rm {image} sh -c '{cmd}'") + """Run lint and tests.""" + lint(c) + test(c) + print("All checks passed!") -@task +def _latest_tag(c) -> str: + result = c.run("git describe --tags --abbrev=0", hide=True, warn=True) + return result.stdout.strip() if result.ok else "v0.0.0" + + +@task(help={"part": "patch, minor, or major"}) def bump(c, part): - """Bump version (patch/minor/major), commit, and tag.""" + """Tag a new semver release (git tag is the source of truth).""" if part not in ("patch", "minor", "major"): raise SystemExit("Usage: inv bump ") - result = c.run("git status --porcelain", hide=True) - if result.stdout.strip(): + if c.run("git status --porcelain", hide=True).stdout.strip(): raise SystemExit("Working tree is dirty. Commit or stash changes first.") - text = PYPROJECT.read_text() - m = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', text) - if not m: - raise SystemExit("Could not find version in pyproject.toml") - - major, minor, patch = int(m[1]), int(m[2]), int(m[3]) + major, minor, patch = (int(x) for x in _latest_tag(c).lstrip("v").split(".")) if part == "major": major, minor, patch = major + 1, 0, 0 elif part == "minor": @@ -70,13 +66,37 @@ def bump(c, part): else: patch += 1 - new_version = f"{major}.{minor}.{patch}" - new_text = re.sub(r'(version = ")\d+\.\d+\.\d+(")', rf"\g<1>{new_version}\2", text) - PYPROJECT.write_text(new_text) + tag = f"v{major}.{minor}.{patch}" + c.run(f"git tag {tag}") + print(f"Tagged {tag}. Push it with: git push origin {tag}") +{%- if cookiecutter.project_type == "service" %} - c.run(f'git add pyproject.toml && git commit -m "bump version to {new_version}"') - c.run(f"git tag v{new_version}") - print(f"Bumped to {new_version}") + +def _version(c) -> str: + return _latest_tag(c).lstrip("v") + + +@task +def build_image(c): + """Build the runtime image, tagged with the current version.""" + c.run(f"docker build --network=host -t {IMAGE}:{_version(c)} .") + + +@task +def deploy(c): + """Build image, copy to VPS over SSH, restart the compose service.""" + version = _version(c) + build_image(c) + print(f"Transferring {IMAGE}:{version} to {VPS}...") + c.run(f"docker save {IMAGE}:{version} | ssh {VPS} docker load", pty=True) + print("Updating docker-compose and restarting service...") + c.run( + f'ssh {VPS} "cd {REMOTE_DIR} && ' + f"sed -i 's|image: {IMAGE}:.*|image: {IMAGE}:{version}|' docker-compose.yml && " + f'docker compose up -d {SERVICE}"' + ) + print(f"Deployed {IMAGE}:{version}") +{%- endif %} @task diff --git a/{{cookiecutter.project_slug}}/tests/test_service.py b/{{cookiecutter.project_slug}}/tests/test_service.py new file mode 100644 index 0000000..ef8a34b --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/test_service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import threading + +from {{ cookiecutter.package_name }} import service + + +def test_run_stops_when_event_set() -> None: + stop = threading.Event() + stop.set() # already requested to stop + service.run(stop, interval=0.01) # returns immediately, no hang + + +def test_run_handles_polled_items(monkeypatch) -> None: + handled: list[object] = [] + stop = threading.Event() + + def fake_handle(item: object) -> None: + handled.append(item) + stop.set() # stop after the first batch + + monkeypatch.setattr(service, "poll_once", lambda: ["a", "b"]) + monkeypatch.setattr(service, "handle", fake_handle) + + service.run(stop, interval=0.01) + assert handled == ["a", "b"] + + +def test_poll_once_default_empty() -> None: + assert service.poll_once() == []