refactor, split to two versions - cli and service

This commit is contained in:
Jev
2026-05-25 23:28:24 +02:00
parent 79d86961e3
commit 7188b20797
14 changed files with 335 additions and 69 deletions
+20 -2
View File
@@ -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 <part>` creates the next `vX.Y.Z` tag.
## Usage ## 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 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 ## License
MIT MIT
+1
View File
@@ -3,6 +3,7 @@
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}", "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}",
"package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}", "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
"description": "A modern CLI tool", "description": "A modern CLI tool",
"project_type": ["cli", "service"],
"author_name": "Your Name", "author_name": "Your Name",
"author_email": "your.email@example.com", "author_email": "your.email@example.com",
"version": "0.1.0", "version": "0.1.0",
+40 -6
View File
@@ -6,25 +6,59 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
import sys 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: def main() -> None:
"""Create a lockfile so generated projects are reproducible by default."""
if shutil.which("uv") is None: if shutil.which("uv") is None:
print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr) print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr)
sys.exit(1) 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: 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: except subprocess.CalledProcessError as exc:
print(f"Error: uv lock failed with exit code {exc.returncode}.", file=sys.stderr) print(f"Error: uv lock failed with exit code {exc.returncode}.", file=sys.stderr)
sys.exit(exc.returncode) 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("Next steps:")
print("cd <project_name>") print(" cd <project_name>")
print("source init.sh") print(" source init.sh")
if __name__ == "__main__": if __name__ == "__main__":
+28 -16
View File
@@ -9,28 +9,40 @@ echo "Removing old build dir..."
rm -rf "$BUILD_DIR" rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
echo "Generating project from template..." run_variant() {
cd "$BUILD_DIR" local type="$1"
cookiecutter "$TEMPLATE_DIR" \ local slug="demo-$type"
echo
echo "=== Generating '$type' variant ==="
cd "$BUILD_DIR"
cookiecutter "$TEMPLATE_DIR" \
--no-input \ --no-input \
project_name="Test Project" \ project_name="Demo $type" \
project_slug="test-project" \ project_slug="$slug" \
package_name="test_project" \ package_name="demo_$type" \
description="A test project" \ description="A demo $type project" \
project_type="$type" \
author_name="Test Author" \ author_name="Test Author" \
author_email="test@example.com" \ author_email="test@example.com" \
version="0.1.0" version="0.1.0"
cd "$BUILD_DIR/test-project" cd "$BUILD_DIR/$slug"
echo "Generated project at: $(pwd)" echo "Generated at: $(pwd)"
echo "Running init.sh..." echo "Running init.sh..."
bash init.sh # shellcheck disable=SC1091
source init.sh
echo "Running lint..." echo "Running lint..."
uv run invoke lint uv run invoke lint
echo "Running tests..." echo "Running tests..."
uv run invoke test uv run invoke test
}
echo "All checks passed." run_variant cli
run_variant service
echo
echo "All checks passed for both variants."
@@ -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=
+3
View File
@@ -15,6 +15,9 @@ dist/
*.egg *.egg
pip-wheel-metadata/ pip-wheel-metadata/
# Local environment / secrets
.env
# Virtual environments # Virtual environments
.venv/ .venv/
venv/ venv/
+16 -7
View File
@@ -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 WORKDIR /app
COPY pyproject.toml uv.lock* ./
RUN uv sync --group dev --no-install-project
COPY . . 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 }}"]
+37 -1
View File
@@ -10,10 +10,21 @@ source init.sh
## Usage ## Usage
{%- if cookiecutter.project_type == "cli" %}
```bash ```bash
{{ cookiecutter.project_slug }} --help {{ cookiecutter.project_slug }} --help
{{ cookiecutter.project_slug }} hello {{ 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 ## Development
@@ -21,5 +32,30 @@ source init.sh
uv run invoke lint uv run invoke lint
uv run invoke test uv run invoke test
uv run invoke format 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 }}:<version>
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 %}
@@ -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
+17 -3
View File
@@ -1,22 +1,36 @@
[project] [project]
name = "{{ cookiecutter.project_slug }}" name = "{{ cookiecutter.project_slug }}"
version = "{{ cookiecutter.version }}" dynamic = ["version"]
description = "{{ cookiecutter.description }}" description = "{{ cookiecutter.description }}"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" } { name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }
] ]
requires-python = ">=3.12" requires-python = ">=3.12"
{%- if cookiecutter.project_type == "cli" %}
dependencies = [ dependencies = [
"typer>=0.12", "typer>=0.12",
] ]
{%- else %}
dependencies = [] # stdlib-only skeleton; add email/poller libs as needed
{%- endif %}
[project.scripts] [project.scripts]
{%- if cookiecutter.project_type == "cli" %}
{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli:app" {{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli:app"
{%- else %}
{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.service:main"
{%- endif %}
[build-system] [build-system]
requires = ["uv_build>=0.8.8,<0.9.0"] requires = ["hatchling", "hatch-vcs"]
build-backend = "uv_build" build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
local_scheme = "no-local-version"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -0,0 +1,6 @@
from __future__ import annotations
from .service import main
if __name__ == "__main__":
main()
@@ -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)
+50 -30
View File
@@ -1,10 +1,13 @@
# type: ignore # type: ignore
import re
from pathlib import Path
from invoke import task 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 @task
@@ -35,34 +38,27 @@ def test(c):
@task @task
def ci(c): def ci(c):
"""Run lint and tests in a clean Docker container.""" """Run lint and tests."""
image = "{{ cookiecutter.project_slug }}-ci" lint(c)
c.run(f"docker build --network=host -t {image} .") test(c)
cmd = ( print("All checks passed!")
"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}'")
@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): 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"): if part not in ("patch", "minor", "major"):
raise SystemExit("Usage: inv bump <patch|minor|major>") raise SystemExit("Usage: inv bump <patch|minor|major>")
result = c.run("git status --porcelain", hide=True) if c.run("git status --porcelain", hide=True).stdout.strip():
if result.stdout.strip():
raise SystemExit("Working tree is dirty. Commit or stash changes first.") raise SystemExit("Working tree is dirty. Commit or stash changes first.")
text = PYPROJECT.read_text() major, minor, patch = (int(x) for x in _latest_tag(c).lstrip("v").split("."))
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])
if part == "major": if part == "major":
major, minor, patch = major + 1, 0, 0 major, minor, patch = major + 1, 0, 0
elif part == "minor": elif part == "minor":
@@ -70,13 +66,37 @@ def bump(c, part):
else: else:
patch += 1 patch += 1
new_version = f"{major}.{minor}.{patch}" tag = f"v{major}.{minor}.{patch}"
new_text = re.sub(r'(version = ")\d+\.\d+\.\d+(")', rf"\g<1>{new_version}\2", text) c.run(f"git tag {tag}")
PYPROJECT.write_text(new_text) 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}") def _version(c) -> str:
print(f"Bumped to {new_version}") 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 @task
@@ -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() == []