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
@@ -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() == []