mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-06-09 19:40:23 +01:00
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
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:
9
justfile
9
justfile
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,5 +17,6 @@ from compose_farm.cli.app import app
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -77,7 +77,7 @@ code = "JetBrains Mono"
|
||||
|
||||
[project.theme.icon]
|
||||
logo = "lucide/server"
|
||||
repo = "lucide/github"
|
||||
repo = "fontawesome/brands/github"
|
||||
|
||||
[project.extra]
|
||||
generator = false
|
||||
|
||||
Reference in New Issue
Block a user