refactor, split to two versions - cli and service
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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"
|
||||||
--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"
|
|
||||||
|
|
||||||
cd "$BUILD_DIR/test-project"
|
echo
|
||||||
echo "Generated project at: $(pwd)"
|
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..."
|
cd "$BUILD_DIR/$slug"
|
||||||
bash init.sh
|
echo "Generated at: $(pwd)"
|
||||||
|
|
||||||
echo "Running lint..."
|
echo "Running init.sh..."
|
||||||
uv run invoke lint
|
# shellcheck disable=SC1091
|
||||||
|
source init.sh
|
||||||
|
|
||||||
echo "Running tests..."
|
echo "Running lint..."
|
||||||
uv run invoke test
|
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=
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -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 }}"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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() == []
|
||||||
Reference in New Issue
Block a user