chore: refresh maintenance tooling (#177)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Update README.md / update_readme (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / browser-tests (push) Has been cancelled
CI / lint (push) Has been cancelled
Docs / build (push) Has been cancelled
Docs / deploy (push) Has been cancelled

* chore: refresh maintenance tooling

* fix: restore cli startup budget

* fix: keep rich help in startup test
This commit is contained in:
Bas Nijholt
2026-05-08 19:14:18 -07:00
committed by GitHub
parent 77857d49ad
commit bfa1cdcea5
22 changed files with 1081 additions and 799 deletions

View File

@@ -9,17 +9,18 @@ default:
install:
uv sync --all-extras --dev
# Run all tests (parallel)
# Run all tests (parallel, with bounded browser workers)
test:
uv run pytest -n auto
uv run pytest -m "not browser" -n auto
uv run pytest -m browser -n 4
# Run CLI tests only (parallel, with coverage)
test-cli:
uv run pytest -m "not browser" -n auto
# Run web UI tests only (parallel)
# Run web UI tests only (bounded parallelism for browser server fixtures)
test-web:
uv run pytest -m browser -n auto
uv run pytest -m browser -n 4
# Lint, format, and type check
lint:

View File

@@ -98,6 +98,8 @@ ignore = [
"ANN002", # allow args without type comments in some call sites
"ANN003", # allow kwargs without type comments in some call sites
"ANN401", # allow `Any` for external library hooks
"D203", # incompatible with D211
"D213", # incompatible with D212
"D401", # short docstrings are acceptable
"D402", # allow "Return" in first line
"PLW0603", # global statements not used here

View File

@@ -17,5 +17,6 @@ from compose_farm.cli.app import app
__all__ = ["app"]
if __name__ == "__main__":
app()

View File

@@ -6,14 +6,15 @@ from typing import Annotated
import typer
from compose_farm import __version__
__all__ = ["app"]
def _version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
# Lazy import: package version lookup is not needed while rendering `cf --help`.
from compose_farm import __version__ # noqa: PLC0415
typer.echo(f"compose-farm {__version__}")
raise typer.Exit
@@ -23,6 +24,7 @@ app = typer.Typer(
help="Compose Farm - run docker compose commands across multiple hosts",
no_args_is_help=True,
context_settings={"help_option_names": ["-h", "--help"]},
suggest_commands=False,
rich_markup_mode="rich",
)

View File

@@ -8,15 +8,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Annotated, TypeVar
import typer
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TaskID,
TextColumn,
TimeElapsedColumn,
)
from compose_farm.console import (
MSG_HOST_NOT_FOUND,
@@ -31,11 +22,13 @@ from compose_farm.console import (
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Generator
from rich.progress import Progress, TaskID
from compose_farm.config import Config
from compose_farm.executor import CommandResult
_T = TypeVar("_T")
_R = TypeVar("_R")
_R = TypeVar("_R", bound=tuple[object, ...])
# --- Shared CLI Options ---
@@ -85,6 +78,16 @@ def progress_bar(
Yields (progress, task_id). Use progress.update(task_id, advance=1, description=...)
to advance.
"""
# Lazy import: Rich progress pulls in heavy rendering modules and slows `cf --help`.
from rich.progress import ( # noqa: PLC0415
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TextColumn,
TimeElapsedColumn,
)
with Progress(
SpinnerColumn(),
TextColumn(f"[bold blue]{label}[/]"),
@@ -126,7 +129,7 @@ def run_parallel_with_progress(
for coro in asyncio.as_completed(tasks):
result = await coro
results.append(result)
progress.update(task_id, advance=1, description=f"[cyan]{result[0]}[/]") # type: ignore[index]
progress.update(task_id, advance=1, description=f"[cyan]{result[0]}[/]")
return results
return asyncio.run(gather())

View File

@@ -3,10 +3,6 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING, Annotated
@@ -43,6 +39,9 @@ _RawOption = Annotated[
def _get_editor() -> str:
"""Get the user's preferred editor ($EDITOR > $VISUAL > platform default)."""
# Lazy import: editor discovery is not needed while building `cf --help`.
import shutil # noqa: PLC0415
if editor := os.environ.get("EDITOR") or os.environ.get("VISUAL"):
return editor
return next((e for e in ("nano", "vim", "vi") if shutil.which(e)), "vi")
@@ -50,6 +49,9 @@ def _get_editor() -> str:
def _generate_template() -> str:
"""Generate a config template with documented schema."""
# Lazy import: package resource lookup is only needed when generating a config template.
from importlib import resources # noqa: PLC0415
try:
template_file = resources.files("compose_farm") / "example-config.yaml"
return template_file.read_text(encoding="utf-8")
@@ -150,6 +152,10 @@ def config_edit(
editor = _get_editor()
console.print(f"[dim]Opening {config_file} with {editor}...[/dim]")
# Lazy imports: process execution helpers are only needed by `cf config edit`.
import shlex # noqa: PLC0415
import subprocess # noqa: PLC0415
try:
editor_cmd = shlex.split(editor)
except ValueError as e:

View File

@@ -6,7 +6,6 @@ import contextlib
from typing import TYPE_CHECKING, Annotated
import typer
from rich.table import Table
from compose_farm.cli.app import app
from compose_farm.cli.common import (
@@ -30,6 +29,8 @@ from compose_farm.state import get_stacks_needing_migration, group_stacks_by_hos
if TYPE_CHECKING:
from collections.abc import Callable
from rich.table import Table
from compose_farm.config import Config
from compose_farm.glances import ContainerStats
@@ -64,6 +65,9 @@ def _build_host_table(
show_containers: bool,
) -> Table:
"""Build the hosts table."""
# Lazy import: Rich table rendering is not needed while building `cf --help`.
from rich.table import Table # noqa: PLC0415
table = Table(title="Hosts", show_header=True, header_style="bold cyan")
table.add_column("Host", style="magenta")
table.add_column("Address")
@@ -106,6 +110,9 @@ def _build_summary_table(
host_filter: str | None = None,
) -> Table:
"""Build the summary table."""
# Lazy import: Rich table rendering is not needed while building `cf --help`.
from rich.table import Table # noqa: PLC0415
on_disk = cfg.discover_compose_dirs()
if host_filter:
stacks_configured = [stack for stack in cfg.stacks if host_filter in cfg.get_hosts(stack)]
@@ -183,6 +190,9 @@ def _build_containers_table(
host_filter: str | None = None,
) -> Table:
"""Build Rich table for container stats."""
# Lazy import: Rich table rendering is not needed while building `cf --help`.
from rich.table import Table # noqa: PLC0415
from compose_farm.glances import format_bytes # noqa: PLC0415
table = Table(title="Containers", show_header=True, header_style="bold cyan")
@@ -372,6 +382,9 @@ def list_(
for stack, _ in sorted(stacks):
console.print(stack)
else:
# Lazy import: Rich table rendering is not needed while building `cf --help`.
from rich.table import Table # noqa: PLC0415
# Assign colors to hosts for visual grouping
host_colors = ["magenta", "cyan", "green", "yellow", "blue", "red"]
unique_hosts = sorted({str(h) for _, h in stacks})

View File

@@ -12,9 +12,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
from dotenv import dotenv_values
if TYPE_CHECKING:
from .config import Config
@@ -41,6 +38,9 @@ def _load_env(compose_path: Path) -> dict[str, str]:
Reads from .env file in the same directory as compose file,
then overlays current environment variables.
"""
# Lazy import: dotenv parsing is only needed when reading compose files, not for CLI help.
from dotenv import dotenv_values # noqa: PLC0415
env_path = compose_path.parent / ".env"
env: dict[str, str] = {k: v for k, v in dotenv_values(env_path).items() if v is not None}
env.update({k: v for k, v in os.environ.items() if isinstance(v, str)})
@@ -49,6 +49,9 @@ def _load_env(compose_path: Path) -> dict[str, str]:
def parse_compose_data(content: str) -> dict[str, Any]:
"""Parse compose YAML content into a dict."""
# Lazy import: PyYAML is relatively expensive and not needed during command registration.
import yaml # noqa: PLC0415
compose_data = yaml.safe_load(content) or {}
return compose_data if isinstance(compose_data, dict) else {}

View File

@@ -1,9 +1,38 @@
"""Shared console instances for consistent output styling."""
from rich.console import Console
from __future__ import annotations
console = Console(highlight=False)
err_console = Console(stderr=True, highlight=False)
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from rich.console import Console
class _LazyConsole:
"""Create a Rich console only when command output actually needs one."""
def __init__(self, *, stderr: bool = False) -> None:
self._stderr = stderr
self._console: Console | None = None
def _get(self) -> Console:
if self._console is None:
# Lazy import: Rich console setup is not needed while building `cf --help`.
from rich.console import Console # noqa: PLC0415
self._console = Console(stderr=self._stderr, highlight=False)
return self._console
def print(self, *args: Any, **kwargs: Any) -> None:
"""Proxy print calls to the underlying Rich console."""
self._get().print(*args, **kwargs)
def __getattr__(self, name: str) -> Any:
return getattr(self._get(), name)
console: Any = _LazyConsole()
err_console: Any = _LazyConsole(stderr=True)
# --- Message Constants ---

View File

@@ -10,8 +10,6 @@ from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from rich.markup import escape
from .console import console, err_console
from .ssh_keys import get_key_path, get_ssh_auth_sock, get_ssh_env
@@ -79,6 +77,9 @@ async def _stream_output_lines(
If prefix is empty, output is printed without a prefix.
"""
out = err_console if is_stderr else console
# Lazy import: Rich markup escaping is only needed when streaming command output.
from rich.markup import escape # noqa: PLC0415
async for line in reader:
text = line.decode() if isinstance(line, bytes) else line
if text.strip():

View File

@@ -68,7 +68,8 @@ class ImageRef:
"""Parse image string into components."""
match = IMAGE_PATTERN.match(image)
if not match:
return cls("docker.io", "library", image.split(":")[0].split("@")[0], "latest")
name = image.split(":", maxsplit=1)[0].split("@", maxsplit=1)[0]
return cls("docker.io", "library", name, "latest")
groups = match.groupdict()
registry = groups.get("registry") or "docker.io"

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
@@ -52,6 +50,9 @@ def load_state(config: Config) -> dict[str, str | list[str]]:
Returns a dict mapping stack names to host name(s).
Multi-host stacks store a list of hosts.
"""
# Lazy import: PyYAML is only needed when state files are read or written.
import yaml # noqa: PLC0415
state_path = config.get_state_path()
if not state_path.exists():
return {}
@@ -73,6 +74,9 @@ def _sorted_dict(d: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
def save_state(config: Config, deployed: dict[str, str | list[str]]) -> None:
"""Save the deployment state."""
# Lazy import: PyYAML is only needed when state files are read or written.
import yaml # noqa: PLC0415
state_path = config.get_state_path()
with state_path.open("w") as f:
yaml.safe_dump({"deployed": _sorted_dict(deployed)}, f, sort_keys=False)

View File

@@ -285,8 +285,7 @@ async def save_config(
async def _read_file_local(path: str) -> str:
"""Read a file from the local filesystem."""
expanded = Path(path).expanduser()
return await asyncio.to_thread(expanded.read_text, encoding="utf-8")
return await asyncio.to_thread(_read_text_expanded, path)
async def _write_file_local(path: str, content: str) -> bool:
@@ -294,8 +293,17 @@ async def _write_file_local(path: str, content: str) -> bool:
Returns True if file was saved, False if content was unchanged.
"""
expanded = Path(path).expanduser()
return await asyncio.to_thread(_save_with_backup, expanded, content)
return await asyncio.to_thread(_save_expanded, path, content)
def _read_text_expanded(path: str) -> str:
"""Expand and read a local text file."""
return Path(path).expanduser().read_text(encoding="utf-8")
def _save_expanded(path: str, content: str) -> bool:
"""Expand and save a local text file with backup handling."""
return _save_with_backup(Path(path).expanduser(), content)
async def _read_file_remote(host: Any, path: str) -> str:

View File

@@ -76,6 +76,7 @@ async def containers_page(request: Request) -> HTMLResponse:
glances_enabled = config.glances_stack is not None
return templates.TemplateResponse(
request,
"containers.html",
{
"request": request,
@@ -131,8 +132,9 @@ def _image_web_url(image: str) -> str | None:
def _render_row(c: ContainerStats, idx: int | str) -> str:
"""Render a single container as an HTML table row."""
image_name, tag = _parse_image(c.image)
stack = c.stack if c.stack else _infer_stack_service(c.name)[0]
service = c.service if c.service else _infer_stack_service(c.name)[1]
inferred_stack, inferred_service = _infer_stack_service(c.name)
stack = c.stack or inferred_stack
service = c.service or inferred_service
cpu = c.cpu_percent
mem = c.memory_percent

View File

@@ -44,6 +44,7 @@ async def console(request: Request) -> HTMLResponse:
config_path = str(config.config_path) if config.config_path else ""
return templates.TemplateResponse(
request,
"console.html",
{
"request": request,
@@ -71,6 +72,7 @@ async def index(request: Request) -> HTMLResponse:
config_content = config_path.read_text() if config_path else ""
return templates.TemplateResponse(
request,
"index.html",
{
"request": request,
@@ -112,6 +114,7 @@ async def index(request: Request) -> HTMLResponse:
state_content = yaml.dump({"deployed": deployed}, default_flow_style=False, sort_keys=False)
return templates.TemplateResponse(
request,
"index.html",
{
"request": request,
@@ -185,6 +188,7 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
website_urls = extract_website_urls(config, name)
return templates.TemplateResponse(
request,
"stack.html",
{
"request": request,
@@ -217,6 +221,7 @@ async def sidebar_partial(request: Request) -> HTMLResponse:
}
return templates.TemplateResponse(
request,
"partials/sidebar.html",
{
"request": request,
@@ -239,7 +244,7 @@ async def config_error_partial(request: Request) -> HTMLResponse:
except (ValidationError, FileNotFoundError) as e:
error = extract_config_error(e)
return templates.TemplateResponse(
"partials/config_error.html", {"request": request, "config_error": error}
request, "partials/config_error.html", {"request": request, "config_error": error}
)
@@ -255,6 +260,7 @@ async def stats_partial(request: Request) -> HTMLResponse:
stopped_count = len(config.stacks) - running_count
return templates.TemplateResponse(
request,
"partials/stats.html",
{
"request": request,
@@ -277,6 +283,7 @@ async def pending_partial(request: Request, expanded: bool = True) -> HTMLRespon
not_started = get_stacks_not_in_state(config)
return templates.TemplateResponse(
request,
"partials/pending.html",
{
"request": request,
@@ -298,6 +305,7 @@ async def stacks_by_host_partial(request: Request, expanded: bool = True) -> HTM
stacks_by_host = group_running_stacks_by_host(deployed, config.hosts)
return templates.TemplateResponse(
request,
"partials/stacks_by_host.html",
{
"request": request,

View File

@@ -1,6 +1,8 @@
"""Tests for CLI lifecycle commands (apply, down --orphaned)."""
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
@@ -46,6 +48,18 @@ def _make_result(stack: str, success: bool = True) -> CommandResult:
)
def _run_async_returns(
results: list[CommandResult],
) -> Callable[[Coroutine[Any, Any, Any]], list[CommandResult]]:
"""Create a run_async stub that closes the intercepted coroutine."""
def mock_run_async(coro: Coroutine[Any, Any, Any]) -> list[CommandResult]:
coro.close()
return results
return mock_run_async
class TestApplyCommand:
"""Tests for the apply command."""
@@ -117,7 +131,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -145,7 +159,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
patch("compose_farm.cli.lifecycle.report_results"),
@@ -176,7 +190,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
@@ -230,7 +244,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -278,7 +292,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -332,7 +346,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -384,7 +398,7 @@ class TestDownOrphaned:
),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
side_effect=_run_async_returns(mock_results),
),
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
patch("compose_farm.cli.lifecycle.report_results"),
@@ -480,7 +494,7 @@ class TestHostFilterMultiHost:
patch("compose_farm.cli.lifecycle.run_on_stacks") as mock_run,
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[_make_result("multi-host@host1")],
side_effect=_run_async_returns([_make_result("multi-host@host1")]),
),
patch("compose_farm.cli.lifecycle.remove_stack"),
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -515,7 +529,7 @@ class TestHostFilterMultiHost:
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[_make_result("multi-host@host1")],
side_effect=_run_async_returns([_make_result("multi-host@host1")]),
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
@@ -544,11 +558,13 @@ class TestHostFilterMultiHost:
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[
_make_result("multi-host@host1"),
_make_result("multi-host@host2"),
_make_result("multi-host@host3"),
],
side_effect=_run_async_returns(
[
_make_result("multi-host@host1"),
_make_result("multi-host@host2"),
_make_result("multi-host@host3"),
]
),
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),

View File

@@ -62,7 +62,8 @@ def _mock_run_async_factory(
"""Create a mock run_async that returns results for given stacks."""
results = [_make_result(s) for s in stacks]
def mock_run_async(_coro: Coroutine[Any, Any, Any]) -> list[CommandResult]:
def mock_run_async(coro: Coroutine[Any, Any, Any]) -> list[CommandResult]:
coro.close()
return results
return mock_run_async, results

View File

@@ -14,7 +14,9 @@ import pytest
if sys.platform == "darwin":
CLI_STARTUP_THRESHOLD = 0.35
else: # Linux
CLI_STARTUP_THRESHOLD = 0.25
# Top-level help intentionally uses Typer/Rich rendering. The threshold should catch
# slow import regressions while allowing normal variance on slower virtualenv filesystems.
CLI_STARTUP_THRESHOLD = 0.35
@pytest.mark.skipif(

View File

@@ -25,7 +25,7 @@ class TestExtractConfigError:
Config(
hosts={"server": Host(address="192.168.1.1")},
stacks={"app": "server"},
unknown_field="bad", # type: ignore[call-arg]
unknown_field="bad", # type: ignore[call-arg] # ty: ignore[unknown-argument]
)
msg = extract_config_error(exc_info.value)
@@ -38,7 +38,7 @@ class TestExtractConfigError:
# Trigger a validation error with a nested extra field
with pytest.raises(ValidationError) as exc_info:
Host(address="192.168.1.1", bad_key="value") # type: ignore[call-arg]
Host(address="192.168.1.1", bad_key="value") # type: ignore[call-arg] # ty: ignore[unknown-argument]
msg = extract_config_error(exc_info.value)
assert "bad_key" in msg

View File

@@ -199,16 +199,18 @@ def server_url(
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
# Wait for startup with proper error handling
# Wait for startup with proper error handling. This can be slow after
# a highly parallel non-browser test phase has just saturated the machine.
url = f"http://127.0.0.1:{port}"
server_ready = False
for _ in range(100): # 2 seconds max
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
try:
urllib.request.urlopen(url, timeout=0.1) # noqa: S310
server_ready = True
break
except Exception:
time.sleep(0.02) # 20ms between checks
time.sleep(0.02)
if not server_ready:
msg = f"Test server failed to start on {url}"

1651
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@ code = "JetBrains Mono"
[project.theme.icon]
logo = "lucide/server"
repo = "lucide/github"
repo = "fontawesome/brands/github"
[project.extra]
generator = false