refactor, split to two versions - cli and service
This commit is contained in:
@@ -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=
|
||||
@@ -15,6 +15,9 @@ dist/
|
||||
*.egg
|
||||
pip-wheel-metadata/
|
||||
|
||||
# Local environment / secrets
|
||||
.env
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@@ -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 }}"]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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() == []
|
||||
Reference in New Issue
Block a user