cli: Add --containers flag to stats command (#159)

* fix: Ignore _version.py in type checkers

The _version.py file is generated at build time by hatchling,
so mypy and ty can't resolve it during development.

* Update README.md

* cli: Respect --host flag in stats summary and add tests

- Fix --host filter to work in non-containers mode (was ignored)
- Filter hosts table, pending migrations, and --live queries by host
- Add tests for stats --containers functionality

* refactor: Remove redundant _format_bytes wrappers

Use format_bytes directly from glances module instead of wrapper
functions that add no value.

* Fix stats --host filtering

* refactor: Move validate_hosts to top-level imports
This commit is contained in:
Bas Nijholt
2026-01-08 00:05:30 +01:00
committed by GitHub
parent 7ce2067fcb
commit d65f4cf7f4
7 changed files with 376 additions and 44 deletions

View File

@@ -1179,13 +1179,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Show overview statistics for hosts and stacks. Show overview statistics for hosts and stacks.
Without --live: Shows config/state info (hosts, stacks, pending migrations). Without flags: Shows config/state info (hosts, stacks, pending migrations).
With --live: Also queries Docker on each host for container counts. With --live: Also queries Docker on each host for container counts.
With --containers: Shows per-container resource stats (requires Glances).
╭─ Options ────────────────────────────────────────────────────────────────────╮ ╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --live -l Query Docker for live container stats │ --live -l Query Docker for live container stats │
│ --config -c PATH Path to config file │ --containers -C Show per-container resource stats (requires
--help -h Show this message and exit. Glances)
│ --host -H TEXT Filter to stacks on this host │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
``` ```

View File

@@ -124,6 +124,10 @@ python_version = "3.11"
strict = true strict = true
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "compose_farm._version"
ignore_missing_imports = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "asyncssh.*" module = "asyncssh.*"
ignore_missing_imports = true ignore_missing_imports = true
@@ -174,8 +178,12 @@ python-version = "3.11"
exclude = [ exclude = [
"hatch_build.py", # Build-time only, hatchling not in dev deps "hatch_build.py", # Build-time only, hatchling not in dev deps
"docs/demos/**", # Demo scripts with local conftest imports "docs/demos/**", # Demo scripts with local conftest imports
"src/compose_farm/_version.py", # Generated at build time
] ]
[tool.ty.rules]
unresolved-import = "ignore" # _version.py is generated at build time
[dependency-groups] [dependency-groups]
dev = [ dev = [
"mypy>=1.19.0", "mypy>=1.19.0",

View File

@@ -21,17 +21,22 @@ from compose_farm.cli.common import (
report_results, report_results,
run_async, run_async,
run_parallel_with_progress, run_parallel_with_progress,
validate_hosts,
) )
from compose_farm.console import console, print_error from compose_farm.console import console, print_error, print_warning
from compose_farm.executor import run_command, run_on_stacks from compose_farm.executor import run_command, run_on_stacks
from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from compose_farm.config import Config from compose_farm.config import Config
from compose_farm.glances import ContainerStats
def _get_container_counts(cfg: Config) -> dict[str, int]: def _get_container_counts(cfg: Config, hosts: list[str] | None = None) -> dict[str, int]:
"""Get container counts from all hosts with a progress bar.""" """Get container counts from hosts with a progress bar."""
host_list = hosts if hosts is not None else list(cfg.hosts.keys())
async def get_count(host_name: str) -> tuple[str, int]: async def get_count(host_name: str) -> tuple[str, int]:
host = cfg.hosts[host_name] host = cfg.hosts[host_name]
@@ -44,7 +49,7 @@ def _get_container_counts(cfg: Config) -> dict[str, int]:
results = run_parallel_with_progress( results = run_parallel_with_progress(
"Querying hosts", "Querying hosts",
list(cfg.hosts.keys()), host_list,
get_count, get_count,
) )
return dict(results) return dict(results)
@@ -67,7 +72,7 @@ def _build_host_table(
if show_containers: if show_containers:
table.add_column("Containers", justify="right") table.add_column("Containers", justify="right")
for host_name in sorted(cfg.hosts.keys()): for host_name in sorted(stacks_by_host.keys()):
host = cfg.hosts[host_name] host = cfg.hosts[host_name]
configured = len(stacks_by_host[host_name]) configured = len(stacks_by_host[host_name])
running = len(running_by_host[host_name]) running = len(running_by_host[host_name])
@@ -86,19 +91,46 @@ def _build_host_table(
return table return table
def _state_includes_host(host_value: str | list[str], host_name: str) -> bool:
"""Check whether a state entry includes the given host."""
if isinstance(host_value, list):
return host_name in host_value
return host_value == host_name
def _build_summary_table( def _build_summary_table(
cfg: Config, state: dict[str, str | list[str]], pending: list[str] cfg: Config,
state: dict[str, str | list[str]],
pending: list[str],
*,
host_filter: str | None = None,
) -> Table: ) -> Table:
"""Build the summary table.""" """Build the summary table."""
on_disk = cfg.discover_compose_dirs() 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)]
stacks_configured_set = set(stacks_configured)
state = {
stack: hosts
for stack, hosts in state.items()
if _state_includes_host(hosts, host_filter)
}
on_disk = {stack for stack in on_disk if stack in stacks_configured_set}
total_hosts = 1
stacks_configured_count = len(stacks_configured)
stacks_tracked_count = len(state)
else:
total_hosts = len(cfg.hosts)
stacks_configured_count = len(cfg.stacks)
stacks_tracked_count = len(state)
table = Table(title="Summary", show_header=False) table = Table(title="Summary", show_header=False)
table.add_column("Label", style="dim") table.add_column("Label", style="dim")
table.add_column("Value", style="bold") table.add_column("Value", style="bold")
table.add_row("Total hosts", str(len(cfg.hosts))) table.add_row("Total hosts", str(total_hosts))
table.add_row("Stacks (configured)", str(len(cfg.stacks))) table.add_row("Stacks (configured)", str(stacks_configured_count))
table.add_row("Stacks (tracked)", str(len(state))) table.add_row("Stacks (tracked)", str(stacks_tracked_count))
table.add_row("Compose files on disk", str(len(on_disk))) table.add_row("Compose files on disk", str(len(on_disk)))
if pending: if pending:
@@ -111,6 +143,81 @@ def _build_summary_table(
return table return table
def _format_network(rx: int, tx: int, fmt: Callable[[int], str]) -> str:
"""Format network I/O."""
return f"[dim]↓[/]{fmt(rx)} [dim]↑[/]{fmt(tx)}"
def _cpu_style(percent: float) -> str:
"""Rich style for CPU percentage."""
if percent > 80: # noqa: PLR2004
return "red"
if percent > 50: # noqa: PLR2004
return "yellow"
return "green"
def _mem_style(percent: float) -> str:
"""Rich style for memory percentage."""
if percent > 90: # noqa: PLR2004
return "red"
if percent > 70: # noqa: PLR2004
return "yellow"
return "green"
def _status_style(status: str) -> str:
"""Rich style for container status."""
s = status.lower()
if s == "running":
return "green"
if s == "exited":
return "red"
if s == "paused":
return "yellow"
return "dim"
def _build_containers_table(
containers: list[ContainerStats],
host_filter: str | None = None,
) -> Table:
"""Build Rich table for container stats."""
from compose_farm.glances import format_bytes # noqa: PLC0415
table = Table(title="Containers", show_header=True, header_style="bold cyan")
table.add_column("Stack", style="cyan")
table.add_column("Service", style="dim")
table.add_column("Host", style="magenta")
table.add_column("Image")
table.add_column("Status")
table.add_column("Uptime", justify="right")
table.add_column("CPU%", justify="right")
table.add_column("Memory", justify="right")
table.add_column("Net I/O", justify="right")
if host_filter:
containers = [c for c in containers if c.host == host_filter]
# Sort by stack, then service
containers = sorted(containers, key=lambda c: (c.stack.lower(), c.service.lower()))
for c in containers:
table.add_row(
c.stack or c.name,
c.service or c.name,
c.host,
c.image,
f"[{_status_style(c.status)}]{c.status}[/]",
c.uptime or "[dim]-[/]",
f"[{_cpu_style(c.cpu_percent)}]{c.cpu_percent:.1f}%[/]",
f"[{_mem_style(c.memory_percent)}]{format_bytes(c.memory_usage)}[/]",
_format_network(c.network_rx, c.network_tx, format_bytes),
)
return table
# --- Command functions --- # --- Command functions ---
@@ -175,24 +282,66 @@ def stats(
bool, bool,
typer.Option("--live", "-l", help="Query Docker for live container stats"), typer.Option("--live", "-l", help="Query Docker for live container stats"),
] = False, ] = False,
containers: Annotated[
bool,
typer.Option(
"--containers", "-C", help="Show per-container resource stats (requires Glances)"
),
] = False,
host: HostOption = None,
config: ConfigOption = None, config: ConfigOption = None,
) -> None: ) -> None:
"""Show overview statistics for hosts and stacks. """Show overview statistics for hosts and stacks.
Without --live: Shows config/state info (hosts, stacks, pending migrations). Without flags: Shows config/state info (hosts, stacks, pending migrations).
With --live: Also queries Docker on each host for container counts. With --live: Also queries Docker on each host for container counts.
With --containers: Shows per-container resource stats (requires Glances).
""" """
cfg = load_config_or_exit(config) cfg = load_config_or_exit(config)
host_filter = None
if host:
validate_hosts(cfg, host)
host_filter = host
# Handle --containers mode
if containers:
if not cfg.glances_stack:
print_error("Glances not configured")
console.print("[dim]Add 'glances_stack: glances' to compose-farm.yaml[/]")
raise typer.Exit(1)
from compose_farm.glances import fetch_all_container_stats # noqa: PLC0415
host_list = [host_filter] if host_filter else None
container_list = run_async(fetch_all_container_stats(cfg, hosts=host_list))
if not container_list:
print_warning("No containers found")
raise typer.Exit(0)
console.print(_build_containers_table(container_list, host_filter=host_filter))
return
# Validate and filter by host if specified
if host_filter:
all_hosts = [host_filter]
selected_hosts = {host_filter: cfg.hosts[host_filter]}
else:
all_hosts = list(cfg.hosts.keys())
selected_hosts = cfg.hosts
state = load_state(cfg) state = load_state(cfg)
pending = get_stacks_needing_migration(cfg) pending = get_stacks_needing_migration(cfg)
# Filter pending migrations to selected host(s)
all_hosts = list(cfg.hosts.keys()) if host_filter:
stacks_by_host = group_stacks_by_host(cfg.stacks, cfg.hosts, all_hosts) pending = [stack for stack in pending if host_filter in cfg.get_hosts(stack)]
running_by_host = group_stacks_by_host(state, cfg.hosts, all_hosts) stacks_by_host = group_stacks_by_host(cfg.stacks, selected_hosts, all_hosts)
running_by_host = group_stacks_by_host(state, selected_hosts, all_hosts)
container_counts: dict[str, int] = {} container_counts: dict[str, int] = {}
if live: if live:
container_counts = _get_container_counts(cfg) container_counts = _get_container_counts(cfg, all_hosts)
host_table = _build_host_table( host_table = _build_host_table(
cfg, stacks_by_host, running_by_host, container_counts, show_containers=live cfg, stacks_by_host, running_by_host, container_counts, show_containers=live
@@ -200,7 +349,7 @@ def stats(
console.print(host_table) console.print(host_table)
console.print() console.print()
console.print(_build_summary_table(cfg, state, pending)) console.print(_build_summary_table(cfg, state, pending, host_filter=host_filter))
@app.command("list", rich_help_panel="Monitoring") @app.command("list", rich_help_panel="Monitoring")

View File

@@ -16,6 +16,13 @@ if TYPE_CHECKING:
DEFAULT_GLANCES_PORT = 61208 DEFAULT_GLANCES_PORT = 61208
def format_bytes(bytes_val: int) -> str:
"""Format bytes to human readable string (e.g., 1.5 GiB)."""
import humanize # noqa: PLC0415
return humanize.naturalsize(bytes_val, binary=True, format="%.1f")
def _get_glances_address( def _get_glances_address(
host_name: str, host_name: str,
host: Host, host: Host,
@@ -244,11 +251,13 @@ async def fetch_container_stats(
async def fetch_all_container_stats( async def fetch_all_container_stats(
config: Config, config: Config,
port: int = DEFAULT_GLANCES_PORT, port: int = DEFAULT_GLANCES_PORT,
hosts: list[str] | None = None,
) -> list[ContainerStats]: ) -> list[ContainerStats]:
"""Fetch container stats from all hosts in parallel, enriched with compose labels.""" """Fetch container stats from all hosts in parallel, enriched with compose labels."""
from .executor import get_container_compose_labels # noqa: PLC0415 from .executor import get_container_compose_labels # noqa: PLC0415
glances_container = config.glances_stack glances_container = config.glances_stack
host_names = hosts if hosts is not None else list(config.hosts.keys())
async def fetch_host_data( async def fetch_host_data(
host_name: str, host_name: str,
@@ -269,8 +278,9 @@ async def fetch_all_container_stats(
return containers return containers
tasks = [ tasks = [
fetch_host_data(name, _get_glances_address(name, host, glances_container)) fetch_host_data(name, _get_glances_address(name, config.hosts[name], glances_container))
for name, host in config.hosts.items() for name in host_names
if name in config.hosts
] ]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
# Flatten list of lists # Flatten list of lists

View File

@@ -7,12 +7,11 @@ import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import quote from urllib.parse import quote
import humanize
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from compose_farm.executor import TTLCache from compose_farm.executor import TTLCache
from compose_farm.glances import ContainerStats, fetch_all_container_stats from compose_farm.glances import ContainerStats, fetch_all_container_stats, format_bytes
from compose_farm.registry import DOCKER_HUB_ALIASES, ImageRef from compose_farm.registry import DOCKER_HUB_ALIASES, ImageRef
from compose_farm.web.deps import get_config, get_templates from compose_farm.web.deps import get_config, get_templates
@@ -32,11 +31,6 @@ MIN_NAME_PARTS = 2
_DASH_HTML = '<span class="text-xs opacity-50">-</span>' _DASH_HTML = '<span class="text-xs opacity-50">-</span>'
def _format_bytes(bytes_val: int) -> str:
"""Format bytes to human readable string."""
return humanize.naturalsize(bytes_val, binary=True, format="%.1f")
def _parse_image(image: str) -> tuple[str, str]: def _parse_image(image: str) -> tuple[str, str]:
"""Parse image string into (name, tag).""" """Parse image string into (name, tag)."""
# Handle registry prefix (e.g., ghcr.io/user/repo:tag) # Handle registry prefix (e.g., ghcr.io/user/repo:tag)
@@ -177,8 +171,8 @@ def _render_row(c: ContainerStats, idx: int | str) -> str:
f'<td data-sort="{c.status.lower()}"><span class="{_status_class(c.status)}">{c.status}</span></td>' f'<td data-sort="{c.status.lower()}"><span class="{_status_class(c.status)}">{c.status}</span></td>'
f'<td data-sort="{uptime_sec}" class="text-xs text-right font-mono">{c.uptime or "-"}</td>' f'<td data-sort="{uptime_sec}" class="text-xs text-right font-mono">{c.uptime or "-"}</td>'
f'<td data-sort="{cpu}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {cpu_class}" style="width: {min(cpu, 100)}%"></div></div><span class="text-xs">{cpu:.0f}%</span></div></td>' f'<td data-sort="{cpu}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {cpu_class}" style="width: {min(cpu, 100)}%"></div></div><span class="text-xs">{cpu:.0f}%</span></div></td>'
f'<td data-sort="{c.memory_usage}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {mem_class}" style="width: {min(mem, 100)}%"></div></div><span class="text-xs">{_format_bytes(c.memory_usage)}</span></div></td>' f'<td data-sort="{c.memory_usage}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {mem_class}" style="width: {min(mem, 100)}%"></div></div><span class="text-xs">{format_bytes(c.memory_usage)}</span></div></td>'
f'<td data-sort="{c.network_rx + c.network_tx}" class="text-xs text-right font-mono">↓{_format_bytes(c.network_rx)}{_format_bytes(c.network_tx)}</td>' f'<td data-sort="{c.network_rx + c.network_tx}" class="text-xs text-right font-mono">↓{format_bytes(c.network_rx)}{format_bytes(c.network_tx)}</td>'
"</tr>" "</tr>"
) )

View File

@@ -0,0 +1,168 @@
"""Tests for CLI monitoring commands (stats)."""
from pathlib import Path
from unittest.mock import patch
import pytest
import typer
from compose_farm.cli.monitoring import _build_summary_table, stats
from compose_farm.config import Config, Host
from compose_farm.glances import ContainerStats
def _make_config(tmp_path: Path, glances_stack: str | None = None) -> Config:
"""Create a minimal config for testing."""
config_path = tmp_path / "compose-farm.yaml"
config_path.write_text("")
return Config(
compose_dir=tmp_path / "compose",
hosts={"host1": Host(address="localhost")},
stacks={"svc1": "host1"},
config_path=config_path,
glances_stack=glances_stack,
)
class TestStatsCommand:
"""Tests for the stats command."""
def test_stats_containers_requires_glances_config(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""--containers fails if glances_stack is not configured."""
cfg = _make_config(tmp_path, glances_stack=None)
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
pytest.raises(typer.Exit) as exc_info,
):
stats(live=False, containers=True, host=None, config=None)
assert exc_info.value.exit_code == 1
captured = capsys.readouterr()
assert "Glances not configured" in captured.err
def test_stats_containers_success(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""--containers fetches and displays container stats."""
cfg = _make_config(tmp_path, glances_stack="glances")
mock_containers = [
ContainerStats(
name="nginx",
host="host1",
status="running",
image="nginx:latest",
cpu_percent=10.5,
memory_usage=100 * 1024 * 1024,
memory_limit=1024 * 1024 * 1024,
memory_percent=10.0,
network_rx=1000,
network_tx=2000,
uptime="1h",
ports="80->80",
engine="docker",
stack="web",
service="nginx",
)
]
async def mock_fetch_async(
cfg: Config, hosts: list[str] | None = None
) -> list[ContainerStats]:
return mock_containers
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch(
"compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_async
) as mock_fetch,
):
stats(live=False, containers=True, host=None, config=None)
mock_fetch.assert_called_once_with(cfg, hosts=None)
captured = capsys.readouterr()
# Verify table output
assert "nginx" in captured.out
assert "host1" in captured.out
assert "runni" in captured.out
assert "10.5%" in captured.out
def test_stats_containers_empty(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""--containers handles empty result gracefully."""
cfg = _make_config(tmp_path, glances_stack="glances")
async def mock_fetch_empty(
cfg: Config, hosts: list[str] | None = None
) -> list[ContainerStats]:
return []
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_empty),
):
with pytest.raises(typer.Exit) as exc_info:
stats(live=False, containers=True, host=None, config=None)
assert exc_info.value.exit_code == 0
captured = capsys.readouterr()
assert "No containers found" in captured.err
def test_stats_containers_host_filter(self, tmp_path: Path) -> None:
"""--host limits container queries in --containers mode."""
cfg = _make_config(tmp_path, glances_stack="glances")
async def mock_fetch_async(
cfg: Config, hosts: list[str] | None = None
) -> list[ContainerStats]:
return []
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch(
"compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_async
) as mock_fetch,
pytest.raises(typer.Exit),
):
stats(live=False, containers=True, host="host1", config=None)
mock_fetch.assert_called_once_with(cfg, hosts=["host1"])
def test_stats_summary_respects_host_filter(self, tmp_path: Path) -> None:
"""--host filters summary counts to the selected host."""
compose_dir = tmp_path / "compose"
for name in ("svc1", "svc2", "svc3"):
stack_dir = compose_dir / name
stack_dir.mkdir(parents=True)
(stack_dir / "compose.yaml").write_text("services: {}\n")
config_path = tmp_path / "compose-farm.yaml"
config_path.write_text("")
cfg = Config(
compose_dir=compose_dir,
hosts={
"host1": Host(address="localhost"),
"host2": Host(address="127.0.0.2"),
},
stacks={"svc1": "host1", "svc2": "host2", "svc3": "host1"},
config_path=config_path,
)
state: dict[str, str | list[str]] = {"svc1": "host1", "svc2": "host2"}
table = _build_summary_table(cfg, state, pending=[], host_filter="host1")
labels = table.columns[0]._cells
values = table.columns[1]._cells
summary = dict(zip(labels, values, strict=True))
assert summary["Total hosts"] == "1"
assert summary["Stacks (configured)"] == "2"
assert summary["Stacks (tracked)"] == "1"
assert summary["Compose files on disk"] == "2"

View File

@@ -7,10 +7,9 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from compose_farm.config import Config, Host from compose_farm.config import Config, Host
from compose_farm.glances import ContainerStats from compose_farm.glances import ContainerStats, format_bytes
from compose_farm.web.app import create_app from compose_farm.web.app import create_app
from compose_farm.web.routes.containers import ( from compose_farm.web.routes.containers import (
_format_bytes,
_infer_stack_service, _infer_stack_service,
_parse_image, _parse_image,
_parse_uptime_seconds, _parse_uptime_seconds,
@@ -23,25 +22,25 @@ GB = MB * 1024
class TestFormatBytes: class TestFormatBytes:
"""Tests for _format_bytes function (uses humanize library).""" """Tests for format_bytes function (uses humanize library)."""
def test_bytes(self) -> None: def test_bytes(self) -> None:
assert _format_bytes(500) == "500 Bytes" assert format_bytes(500) == "500 Bytes"
assert _format_bytes(0) == "0 Bytes" assert format_bytes(0) == "0 Bytes"
def test_kilobytes(self) -> None: def test_kilobytes(self) -> None:
assert _format_bytes(KB) == "1.0 KiB" assert format_bytes(KB) == "1.0 KiB"
assert _format_bytes(KB * 5) == "5.0 KiB" assert format_bytes(KB * 5) == "5.0 KiB"
assert _format_bytes(KB + 512) == "1.5 KiB" assert format_bytes(KB + 512) == "1.5 KiB"
def test_megabytes(self) -> None: def test_megabytes(self) -> None:
assert _format_bytes(MB) == "1.0 MiB" assert format_bytes(MB) == "1.0 MiB"
assert _format_bytes(MB * 100) == "100.0 MiB" assert format_bytes(MB * 100) == "100.0 MiB"
assert _format_bytes(MB * 512) == "512.0 MiB" assert format_bytes(MB * 512) == "512.0 MiB"
def test_gigabytes(self) -> None: def test_gigabytes(self) -> None:
assert _format_bytes(GB) == "1.0 GiB" assert format_bytes(GB) == "1.0 GiB"
assert _format_bytes(GB * 2) == "2.0 GiB" assert format_bytes(GB * 2) == "2.0 GiB"
class TestParseImage: class TestParseImage: