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
@@ -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
+1
View File
@@ -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",
+40 -6
View File
@@ -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 <project_name>")
print("source init.sh")
print(" cd <project_name>")
print(" source init.sh")
if __name__ == "__main__":
+32 -20
View File
@@ -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."
@@ -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
pip-wheel-metadata/
# Local environment / secrets
.env
# Virtual environments
.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
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 }}"]
+37 -1
View File
@@ -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 }}:<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]
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 = [
@@ -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
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 <patch|minor|major>")
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
@@ -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() == []