From 02a68a1f830013a40e49012272a9d1fe7bf6b426 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 01:59:56 +0300 Subject: [PATCH 1/4] chore(cli): add typer[all] optional dependency and hyp entry point (#08ba2d) Adds cli extra to project.optional-dependencies, pins typer[all]>=0.12,<1.0, adds hyp = "hyperping.cli._app:app" script entry, and includes typer in dev dependencies for testing. --- pyproject.toml | 6 ++++++ uv.lock | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 47d1ff4..9e9540c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", "Typing :: Typed", + "Environment :: Console", ] dependencies = [ "httpx>=0.27,<1.0", @@ -30,6 +31,7 @@ dependencies = [ ] [project.optional-dependencies] +cli = ["typer[all]>=0.12,<1.0"] dev = [ "pytest>=9.0.3", "pytest-cov", @@ -38,8 +40,12 @@ dev = [ "mypy>=1.10", "pydantic", "pip-audit>=2.7", + "typer[all]>=0.12,<1.0", ] +[project.scripts] +hyp = "hyperping.cli._app:app" + [project.urls] Homepage = "https://github.com/develeap/hyperping-python" Documentation = "https://github.com/develeap/hyperping-python#readme" diff --git a/uv.lock b/uv.lock index f7589d6..a9774e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -335,7 +344,7 @@ wheels = [ [[package]] name = "hyperping" -version = "1.7.0" +version = "1.8.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -343,6 +352,9 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "typer" }, +] dev = [ { name = "mypy" }, { name = "pip-audit" }, @@ -351,6 +363,7 @@ dev = [ { name = "pytest-cov" }, { name = "respx" }, { name = "ruff" }, + { name = "typer" }, ] [package.dev-dependencies] @@ -369,8 +382,10 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "typer", extras = ["all"], marker = "extra == 'cli'", specifier = ">=0.12,<1.0" }, + { name = "typer", extras = ["all"], marker = "extra == 'dev'", specifier = ">=0.12,<1.0" }, ] -provides-extras = ["dev"] +provides-extras = ["cli", "dev"] [package.metadata.requires-dev] dev = [{ name = "pytest-asyncio", specifier = ">=0.23.0" }] @@ -950,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1022,6 +1046,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 3aebb0375f0104ac428d2852c4d2d2404cdcbfc0 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 02:00:02 +0300 Subject: [PATCH 2/4] feat(cli): implement hyp CLI with monitor, incident, statuspage, and tenant subcommands (#08ba2d) Adds src/hyperping/cli/ subpackage with: - _config.py: API key resolution (flag > env var) - _output.py: rich table/panel formatters and JSON mode - _app.py: root hyp app with --api-key, --json, --version global options - _monitors.py: hyp monitor list/get/pause/resume - _incidents.py: hyp incident list/create/resolve - _statuspages.py: hyp statuspage show/subscribers - _tenant.py: hyp tenant onboard [--monitor-url URL]... All commands reuse HyperpingClient. The --json flag works on all list/show commands for machine-readable output. --- src/hyperping/cli/__init__.py | 0 src/hyperping/cli/_app.py | 52 ++++++++++++++++++ src/hyperping/cli/_config.py | 24 +++++++++ src/hyperping/cli/_incidents.py | 87 ++++++++++++++++++++++++++++++ src/hyperping/cli/_monitors.py | 90 +++++++++++++++++++++++++++++++ src/hyperping/cli/_output.py | 58 ++++++++++++++++++++ src/hyperping/cli/_statuspages.py | 57 ++++++++++++++++++++ src/hyperping/cli/_tenant.py | 75 ++++++++++++++++++++++++++ 8 files changed, 443 insertions(+) create mode 100644 src/hyperping/cli/__init__.py create mode 100644 src/hyperping/cli/_app.py create mode 100644 src/hyperping/cli/_config.py create mode 100644 src/hyperping/cli/_incidents.py create mode 100644 src/hyperping/cli/_monitors.py create mode 100644 src/hyperping/cli/_output.py create mode 100644 src/hyperping/cli/_statuspages.py create mode 100644 src/hyperping/cli/_tenant.py diff --git a/src/hyperping/cli/__init__.py b/src/hyperping/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hyperping/cli/_app.py b/src/hyperping/cli/_app.py new file mode 100644 index 0000000..9aef55b --- /dev/null +++ b/src/hyperping/cli/_app.py @@ -0,0 +1,52 @@ +"""Main hyp CLI application.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from hyperping._version import __version__ +from hyperping.cli._incidents import incident_app +from hyperping.cli._monitors import monitor_app +from hyperping.cli._statuspages import statuspage_app +from hyperping.cli._tenant import tenant_app + +app = typer.Typer( + name="hyp", + help="Hyperping CLI: manage monitors, incidents, and status pages.", + no_args_is_help=True, +) + +app.add_typer(monitor_app, name="monitor") +app.add_typer(incident_app, name="incident") +app.add_typer(statuspage_app, name="statuspage") +app.add_typer(tenant_app, name="tenant") + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"hyp {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + ctx: typer.Context, + api_key: Annotated[ + str | None, + typer.Option("--api-key", envvar="HYPERPING_API_KEY", help="Hyperping API key."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Output as JSON."), + ] = False, + version: Annotated[ + bool | None, + typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."), + ] = None, +) -> None: + """Hyperping CLI.""" + ctx.ensure_object(dict) + ctx.obj["api_key"] = api_key + ctx.obj["json"] = json_output diff --git a/src/hyperping/cli/_config.py b/src/hyperping/cli/_config.py new file mode 100644 index 0000000..6462791 --- /dev/null +++ b/src/hyperping/cli/_config.py @@ -0,0 +1,24 @@ +"""CLI client factory and API key resolution.""" + +from __future__ import annotations + +import os + +import typer + +from hyperping.client import HyperpingClient + + +def get_client(api_key: str | None) -> HyperpingClient: + """Resolve API key and return a configured client. + + Resolution order: explicit flag value, then HYPERPING_API_KEY env var. + Raises typer.BadParameter if no key is available. + """ + key = api_key or os.environ.get("HYPERPING_API_KEY") + if not key: + raise typer.BadParameter( + "API key required. Pass --api-key or set HYPERPING_API_KEY.", + param_hint="--api-key", + ) + return HyperpingClient(api_key=key) diff --git a/src/hyperping/cli/_incidents.py b/src/hyperping/cli/_incidents.py new file mode 100644 index 0000000..6bcf09e --- /dev/null +++ b/src/hyperping/cli/_incidents.py @@ -0,0 +1,87 @@ +"""hyp incident subcommands.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from hyperping.cli._config import get_client +from hyperping.cli._output import print_detail, print_error, print_success, print_table +from hyperping.exceptions import HyperpingAPIError +from hyperping.models import Incident, IncidentCreate, IncidentType, LocalizedText + +incident_app = typer.Typer(name="incident", help="Manage incidents.") + + +def _incident_row(i: Incident) -> list[object]: + return [i.uuid, i.title_en, i.type, str(i.is_resolved), i.date or ""] + + +@incident_app.command("list") +def incident_list( + ctx: typer.Context, + status: Annotated[str | None, typer.Option("--status", help="Filter by status")] = None, +) -> None: + """List incidents.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + incidents = client.list_incidents(status=status) + columns = ["uuid", "title", "type", "resolved", "date"] + rows = [_incident_row(i) for i in incidents] + print_table("Incidents", columns, rows, json_mode) + + +@incident_app.command("create") +def incident_create( + ctx: typer.Context, + title: Annotated[str, typer.Option("--title", help="Incident title (English)")], + text: Annotated[str, typer.Option("--text", help="Incident message (English)")], + statuspage: Annotated[str, typer.Option("--statuspage", help="Status page UUID")], + incident_type: Annotated[ + str | None, typer.Option("--type", help="Incident type: incident or outage") + ] = None, +) -> None: + """Create a new incident.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + payload = IncidentCreate( + title=LocalizedText.from_string(title), + text=LocalizedText.from_string(text), + type=IncidentType(incident_type) if incident_type else IncidentType.INCIDENT, + statuspages=[statuspage], + ) + try: + incident = client.create_incident(payload) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + fields = { + "uuid": incident.uuid, + "title": incident.title_en, + "type": incident.type, + "resolved": incident.is_resolved, + "date": incident.date or "", + } + print_detail("Incident", fields, json_mode) + + +@incident_app.command("resolve") +def incident_resolve( + ctx: typer.Context, + incident_id: Annotated[str, typer.Argument(help="Incident UUID")], + message: Annotated[ + str | None, typer.Option("--message", help="Resolution message") + ] = None, +) -> None: + """Resolve an incident.""" + api_key: str | None = ctx.obj.get("api_key") + client = get_client(api_key) + try: + incident = client.resolve_incident(incident_id, message=message) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + print_success(f"Incident {incident.uuid} resolved.") diff --git a/src/hyperping/cli/_monitors.py b/src/hyperping/cli/_monitors.py new file mode 100644 index 0000000..e11e711 --- /dev/null +++ b/src/hyperping/cli/_monitors.py @@ -0,0 +1,90 @@ +"""hyp monitor subcommands.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from hyperping.cli._config import get_client +from hyperping.cli._output import print_detail, print_error, print_success, print_table +from hyperping.exceptions import HyperpingAPIError +from hyperping.models import Monitor + +monitor_app = typer.Typer(name="monitor", help="Manage monitors.") + + +def _monitor_status(m: Monitor) -> str: + if m.paused: + return "paused" + return "down" if m.down else "up" + + +@monitor_app.command("list") +def monitor_list(ctx: typer.Context) -> None: + """List all monitors.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + monitors = client.list_monitors() + columns = ["uuid", "name", "url", "protocol", "status"] + rows = [[m.uuid, m.name, m.url, m.protocol, _monitor_status(m)] for m in monitors] + print_table("Monitors", columns, rows, json_mode) + + +@monitor_app.command("get") +def monitor_get( + ctx: typer.Context, + monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")], +) -> None: + """Show a single monitor.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + try: + m = client.get_monitor(monitor_id) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + fields = { + "uuid": m.uuid, + "name": m.name, + "url": m.url, + "protocol": m.protocol, + "status": _monitor_status(m), + "check_frequency": m.check_frequency, + "regions": ", ".join(m.regions), + } + print_detail("Monitor", fields, json_mode) + + +@monitor_app.command("pause") +def monitor_pause( + ctx: typer.Context, + monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")], +) -> None: + """Pause a monitor.""" + api_key: str | None = ctx.obj.get("api_key") + client = get_client(api_key) + try: + m = client.pause_monitor(monitor_id) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + print_success(f"Monitor {m.uuid} paused (status: {_monitor_status(m)})") + + +@monitor_app.command("resume") +def monitor_resume( + ctx: typer.Context, + monitor_id: Annotated[str, typer.Argument(help="Monitor UUID")], +) -> None: + """Resume a paused monitor.""" + api_key: str | None = ctx.obj.get("api_key") + client = get_client(api_key) + try: + m = client.resume_monitor(monitor_id) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + print_success(f"Monitor {m.uuid} resumed (status: {_monitor_status(m)})") diff --git a/src/hyperping/cli/_output.py b/src/hyperping/cli/_output.py new file mode 100644 index 0000000..aa068f4 --- /dev/null +++ b/src/hyperping/cli/_output.py @@ -0,0 +1,58 @@ +"""CLI output formatters: rich tables, panels, and JSON.""" + +from __future__ import annotations + +import json +from typing import Any + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +_console = Console() +_err_console = Console(stderr=True) + + +def print_table( + title: str, + columns: list[str], + rows: list[list[Any]], + json_mode: bool, +) -> None: + """Render a list of rows as a rich table or a JSON array.""" + if json_mode: + data = [dict(zip(columns, row)) for row in rows] + typer.echo(json.dumps(data, indent=2, default=str)) + return + + table = Table(title=title, show_header=True, header_style="bold") + for col in columns: + table.add_column(col) + for row in rows: + table.add_row(*[str(v) if v is not None else "" for v in row]) + _console.print(table) + + +def print_detail( + title: str, + fields: dict[str, Any], + json_mode: bool, +) -> None: + """Render a single record as a rich panel or a JSON object.""" + if json_mode: + typer.echo(json.dumps(fields, indent=2, default=str)) + return + + lines = "\n".join(f"[bold]{k}[/bold]: {v}" for k, v in fields.items()) + _console.print(Panel(lines, title=title)) + + +def print_success(message: str) -> None: + """Print a success message.""" + _console.print(f"[green]{message}[/green]") + + +def print_error(message: str) -> None: + """Print an error message to stderr.""" + _err_console.print(f"[red]{message}[/red]") diff --git a/src/hyperping/cli/_statuspages.py b/src/hyperping/cli/_statuspages.py new file mode 100644 index 0000000..12c9f78 --- /dev/null +++ b/src/hyperping/cli/_statuspages.py @@ -0,0 +1,57 @@ +"""hyp statuspage subcommands.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from hyperping.cli._config import get_client +from hyperping.cli._output import print_detail, print_error, print_table +from hyperping.exceptions import HyperpingAPIError + +statuspage_app = typer.Typer(name="statuspage", help="Manage status pages.") + + +@statuspage_app.command("show") +def statuspage_show( + ctx: typer.Context, + statuspage_id: Annotated[str, typer.Argument(help="Status page UUID")], +) -> None: + """Show a status page.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + try: + page = client.get_status_page(statuspage_id) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + fields = { + "uuid": page.uuid, + "name": page.name, + "subdomain": page.subdomain, + "custom_domain": page.custom_domain or "", + "public": page.public, + "monitors": ", ".join(page.monitors), + } + print_detail("Status Page", fields, json_mode) + + +@statuspage_app.command("subscribers") +def statuspage_subscribers( + ctx: typer.Context, + statuspage_id: Annotated[str, typer.Argument(help="Status page UUID")], +) -> None: + """List subscribers for a status page.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + try: + subs = client.list_subscribers(statuspage_id) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + columns = ["id", "email"] + rows = [[s.id, s.email] for s in subs] + print_table("Subscribers", columns, rows, json_mode) diff --git a/src/hyperping/cli/_tenant.py b/src/hyperping/cli/_tenant.py new file mode 100644 index 0000000..958a6f8 --- /dev/null +++ b/src/hyperping/cli/_tenant.py @@ -0,0 +1,75 @@ +"""hyp tenant subcommands.""" + +from __future__ import annotations + +import re +from typing import Annotated + +import typer + +from hyperping.cli._config import get_client +from hyperping.cli._output import print_detail, print_error +from hyperping.exceptions import HyperpingAPIError +from hyperping.models import MonitorCreate, StatusPageCreate, StatusPageUpdate + +tenant_app = typer.Typer(name="tenant", help="Tenant onboarding operations.") + + +def _slugify(name: str) -> str: + """Convert a name to a URL-safe subdomain slug.""" + slug = name.lower() + slug = re.sub(r"[^a-z0-9\s-]", "", slug) + slug = re.sub(r"[\s]+", "-", slug.strip()) + slug = re.sub(r"-+", "-", slug) + return slug[:63] + + +@tenant_app.command("onboard") +def tenant_onboard( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Tenant name")], + monitor_url: Annotated[ + list[str] | None, + typer.Option("--monitor-url", help="URL to monitor (repeatable)"), + ] = None, +) -> None: + """Onboard a new tenant: create a status page and optional monitors.""" + api_key: str | None = ctx.obj.get("api_key") + json_mode: bool = ctx.obj.get("json", False) + client = get_client(api_key) + + page_create = StatusPageCreate(name=name, subdomain=_slugify(name)) + try: + page = client.create_status_page(page_create) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + + monitor_uuids: list[str] = [] + for url in monitor_url or []: + mon_create = MonitorCreate(name=f"{name} - {url}", url=url) + try: + mon = client.create_monitor(mon_create) + except HyperpingAPIError as exc: + print_error(f"Failed to create monitor for {url}: {exc}") + raise typer.Exit(code=1) from exc + monitor_uuids.append(mon.uuid) + + if monitor_uuids: + existing = list(page.monitors) + try: + page = client.update_status_page( + page.uuid, + StatusPageUpdate(monitors=existing + monitor_uuids), + ) + except HyperpingAPIError as exc: + print_error(str(exc)) + raise typer.Exit(code=1) from exc + + fields: dict[str, object] = { + "status_page_uuid": page.uuid, + "name": page.name, + "subdomain": page.subdomain, + "monitors": ", ".join(monitor_uuids) if monitor_uuids else "(none)", + } + print_detail("Tenant Onboarded", fields, json_mode) From d2784b541ec6183a2f7d42186d23022b44e3bcc1 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 02:00:06 +0300 Subject: [PATCH 3/4] test(cli): add unit tests for all hyp subcommands (#08ba2d) 25 tests covering config resolution, monitor list/get/pause/resume, incident list/create/resolve, statuspage show/subscribers, and tenant onboard. Uses typer.testing.CliRunner with patched get_client; no real HTTP calls. Full suite at 95% coverage. --- tests/unit/test_cli_config.py | 28 +++++++ tests/unit/test_cli_incidents.py | 116 +++++++++++++++++++++++++++++ tests/unit/test_cli_monitors.py | 96 ++++++++++++++++++++++++ tests/unit/test_cli_statuspages.py | 68 +++++++++++++++++ tests/unit/test_cli_tenant.py | 91 ++++++++++++++++++++++ 5 files changed, 399 insertions(+) create mode 100644 tests/unit/test_cli_config.py create mode 100644 tests/unit/test_cli_incidents.py create mode 100644 tests/unit/test_cli_monitors.py create mode 100644 tests/unit/test_cli_statuspages.py create mode 100644 tests/unit/test_cli_tenant.py diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py new file mode 100644 index 0000000..fb1cc92 --- /dev/null +++ b/tests/unit/test_cli_config.py @@ -0,0 +1,28 @@ +"""Tests for CLI client factory and API key resolution.""" + +import pytest +import typer + +from hyperping.cli._config import get_client +from hyperping.client import HyperpingClient + + +class TestGetClient: + def test_get_client_from_flag(self) -> None: + client = get_client("sk_test_flag") + assert isinstance(client, HyperpingClient) + + def test_get_client_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERPING_API_KEY", "sk_test_env") + client = get_client(None) + assert isinstance(client, HyperpingClient) + + def test_get_client_missing_key_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERPING_API_KEY", raising=False) + with pytest.raises(typer.BadParameter): + get_client(None) + + def test_flag_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERPING_API_KEY", "sk_env") + client = get_client("sk_flag") + assert client._api_key.get_secret_value() == "sk_flag" diff --git a/tests/unit/test_cli_incidents.py b/tests/unit/test_cli_incidents.py new file mode 100644 index 0000000..1224b30 --- /dev/null +++ b/tests/unit/test_cli_incidents.py @@ -0,0 +1,116 @@ +"""Tests for hyp incident subcommands.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from hyperping.cli._app import app +from hyperping.models import Incident, IncidentUpdate, LocalizedText + +runner = CliRunner() + +FAKE_INCIDENT = Incident.model_validate( + { + "uuid": "inci_123", + "title": {"en": "Test Incident"}, + "text": {"en": "Something broke"}, + "type": "incident", + "statuspages": ["sp_abc"], + "updates": [], + } +) + +FAKE_INCIDENT_RESOLVED = Incident.model_validate( + { + "uuid": "inci_123", + "title": {"en": "Test Incident"}, + "text": {"en": "Something broke"}, + "type": "incident", + "statuspages": ["sp_abc"], + "updates": [ + { + "uuid": "upd_1", + "date": "2024-01-01T00:00:00Z", + "text": {"en": "Resolved"}, + "type": "resolved", + } + ], + } +) + + +def _make_client() -> MagicMock: + client = MagicMock() + client.list_incidents.return_value = [FAKE_INCIDENT] + client.create_incident.return_value = FAKE_INCIDENT + client.resolve_incident.return_value = FAKE_INCIDENT_RESOLVED + return client + + +class TestIncidentList: + def test_incident_list(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "incident", "list"]) + assert result.exit_code == 0, result.output + assert "Test Incident" in result.output + + def test_incident_list_filter_status(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke( + app, ["--api-key", "sk_test", "incident", "list", "--status", "investigating"] + ) + assert result.exit_code == 0, result.output + + def test_incident_list_json(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "--json", "incident", "list"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["uuid"] == "inci_123" + + def test_incident_create(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke( + app, + [ + "--api-key", + "sk_test", + "incident", + "create", + "--title", + "Outage", + "--text", + "DB down", + "--statuspage", + "sp_abc", + ], + ) + assert result.exit_code == 0, result.output + assert "inci_123" in result.output + + def test_incident_create_missing_title_exits(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke( + app, + ["--api-key", "sk_test", "incident", "create", "--text", "DB down", "--statuspage", "sp_abc"], + ) + assert result.exit_code != 0 + + def test_incident_resolve(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke( + app, ["--api-key", "sk_test", "incident", "resolve", "inci_123"] + ) + assert result.exit_code == 0, result.output + assert "inci_123" in result.output or "resolved" in result.output.lower() + + def test_incident_resolve_with_message(self) -> None: + with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): + result = runner.invoke( + app, + ["--api-key", "sk_test", "incident", "resolve", "inci_123", "--message", "Fixed it"], + ) + assert result.exit_code == 0, result.output diff --git a/tests/unit/test_cli_monitors.py b/tests/unit/test_cli_monitors.py new file mode 100644 index 0000000..8ef118d --- /dev/null +++ b/tests/unit/test_cli_monitors.py @@ -0,0 +1,96 @@ +"""Tests for hyp monitor subcommands.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from hyperping.cli._app import app +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models import Monitor + +runner = CliRunner() + +FAKE_MONITOR = Monitor.model_validate( + { + "uuid": "mon_123", + "name": "Test Monitor", + "url": "https://example.com", + "protocol": "http", + "http_method": "GET", + "check_frequency": 30, + "regions": ["paris"], + "down": False, + "paused": False, + } +) + +FAKE_MONITOR_PAUSED = Monitor.model_validate( + { + "uuid": "mon_123", + "name": "Test Monitor", + "url": "https://example.com", + "protocol": "http", + "http_method": "GET", + "check_frequency": 30, + "regions": ["paris"], + "down": False, + "paused": True, + } +) + + +def _make_client(monitors: list[Monitor] | None = None) -> MagicMock: + client = MagicMock() + client.list_monitors.return_value = monitors if monitors is not None else [FAKE_MONITOR] + client.get_monitor.return_value = FAKE_MONITOR + client.pause_monitor.return_value = FAKE_MONITOR_PAUSED + client.resume_monitor.return_value = FAKE_MONITOR + return client + + +class TestMonitorList: + def test_monitor_list_table(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "list"]) + assert result.exit_code == 0, result.output + assert "Test Monitor" in result.output + + def test_monitor_list_json(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "--json", "monitor", "list"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["uuid"] == "mon_123" + + def test_monitor_list_empty(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client(monitors=[])): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "list"]) + assert result.exit_code == 0, result.output + + def test_monitor_get(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "get", "mon_123"]) + assert result.exit_code == 0, result.output + assert "mon_123" in result.output + + def test_monitor_get_not_found(self) -> None: + client = _make_client() + client.get_monitor.side_effect = HyperpingNotFoundError("not found") + with patch("hyperping.cli._monitors.get_client", return_value=client): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "get", "mon_999"]) + assert result.exit_code != 0 + + def test_monitor_pause(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "pause", "mon_123"]) + assert result.exit_code == 0, result.output + assert "pause" in result.output.lower() or "mon_123" in result.output + + def test_monitor_resume(self) -> None: + with patch("hyperping.cli._monitors.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "monitor", "resume", "mon_123"]) + assert result.exit_code == 0, result.output + assert "resume" in result.output.lower() or "mon_123" in result.output diff --git a/tests/unit/test_cli_statuspages.py b/tests/unit/test_cli_statuspages.py new file mode 100644 index 0000000..6f60c59 --- /dev/null +++ b/tests/unit/test_cli_statuspages.py @@ -0,0 +1,68 @@ +"""Tests for hyp statuspage subcommands.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from hyperping.cli._app import app +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models import StatusPage, StatusPageSubscriber + +runner = CliRunner() + +FAKE_PAGE = StatusPage.model_validate( + { + "uuid": "sp_abc", + "name": "My Status Page", + "subdomain": "my-status", + "customDomain": None, + "public": True, + "monitors": ["mon_123"], + } +) + +FAKE_SUBSCRIBER = StatusPageSubscriber.model_validate( + {"id": "sub_1", "email": "user@example.com"} +) + + +def _make_client() -> MagicMock: + client = MagicMock() + client.get_status_page.return_value = FAKE_PAGE + client.list_subscribers.return_value = [FAKE_SUBSCRIBER] + return client + + +class TestStatusPageShow: + def test_statuspage_show(self) -> None: + with patch("hyperping.cli._statuspages.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "statuspage", "show", "sp_abc"]) + assert result.exit_code == 0, result.output + assert "My Status Page" in result.output + + def test_statuspage_show_not_found(self) -> None: + client = _make_client() + client.get_status_page.side_effect = HyperpingNotFoundError("not found") + with patch("hyperping.cli._statuspages.get_client", return_value=client): + result = runner.invoke(app, ["--api-key", "sk_test", "statuspage", "show", "sp_999"]) + assert result.exit_code != 0 + + def test_statuspage_subscribers(self) -> None: + with patch("hyperping.cli._statuspages.get_client", return_value=_make_client()): + result = runner.invoke( + app, ["--api-key", "sk_test", "statuspage", "subscribers", "sp_abc"] + ) + assert result.exit_code == 0, result.output + assert "user@example.com" in result.output + + def test_statuspage_subscribers_json(self) -> None: + with patch("hyperping.cli._statuspages.get_client", return_value=_make_client()): + result = runner.invoke( + app, ["--api-key", "sk_test", "--json", "statuspage", "subscribers", "sp_abc"] + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["email"] == "user@example.com" diff --git a/tests/unit/test_cli_tenant.py b/tests/unit/test_cli_tenant.py new file mode 100644 index 0000000..8175114 --- /dev/null +++ b/tests/unit/test_cli_tenant.py @@ -0,0 +1,91 @@ +"""Tests for hyp tenant onboard subcommand.""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from typer.testing import CliRunner + +from hyperping.cli._app import app +from hyperping.exceptions import HyperpingValidationError +from hyperping.models import Monitor, StatusPage + +runner = CliRunner() + +FAKE_PAGE = StatusPage.model_validate( + { + "uuid": "sp_new", + "name": "Acme Corp", + "subdomain": "acme-corp", + "public": True, + "monitors": [], + } +) + +FAKE_PAGE_WITH_MONITORS = StatusPage.model_validate( + { + "uuid": "sp_new", + "name": "Acme Corp", + "subdomain": "acme-corp", + "public": True, + "monitors": ["mon_m1"], + } +) + +FAKE_MONITOR = Monitor.model_validate( + { + "uuid": "mon_m1", + "name": "Acme Corp - https://acme.example.com", + "url": "https://acme.example.com", + "protocol": "http", + "http_method": "GET", + "check_frequency": 30, + "regions": ["paris"], + "down": False, + "paused": False, + } +) + + +def _make_client() -> MagicMock: + client = MagicMock() + client.create_status_page.return_value = FAKE_PAGE + client.create_monitor.return_value = FAKE_MONITOR + client.update_status_page.return_value = FAKE_PAGE_WITH_MONITORS + return client + + +class TestTenantOnboard: + def test_tenant_onboard_page_only(self) -> None: + with patch("hyperping.cli._tenant.get_client", return_value=_make_client()): + result = runner.invoke(app, ["--api-key", "sk_test", "tenant", "onboard", "Acme Corp"]) + assert result.exit_code == 0, result.output + assert "sp_new" in result.output + + def test_tenant_onboard_with_monitors(self) -> None: + client = _make_client() + with patch("hyperping.cli._tenant.get_client", return_value=client): + result = runner.invoke( + app, + [ + "--api-key", + "sk_test", + "tenant", + "onboard", + "Acme Corp", + "--monitor-url", + "https://acme.example.com", + ], + ) + assert result.exit_code == 0, result.output + assert "sp_new" in result.output + client.create_monitor.assert_called_once() + client.update_status_page.assert_called_once() + + def test_tenant_onboard_duplicate_name(self) -> None: + client = _make_client() + client.create_status_page.side_effect = HyperpingValidationError( + "subdomain already taken", status_code=422 + ) + with patch("hyperping.cli._tenant.get_client", return_value=client): + result = runner.invoke(app, ["--api-key", "sk_test", "tenant", "onboard", "Acme Corp"]) + assert result.exit_code != 0 From 2313a16acb28c5ae1f19bc7c9b3a17b5dfb3b176 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 10:14:12 +0300 Subject: [PATCH 4/4] fix(cli): remove unused imports and wrap long lines in test_cli_* files Ruff F401 (unused imports: pytest, IncidentUpdate, LocalizedText, call) and E501 (lines >100 chars) flagged by CI on all three Python versions. --- tests/unit/test_cli_incidents.py | 13 +++++++++---- tests/unit/test_cli_monitors.py | 1 - tests/unit/test_cli_statuspages.py | 1 - tests/unit/test_cli_tenant.py | 3 +-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_cli_incidents.py b/tests/unit/test_cli_incidents.py index 1224b30..be2b60f 100644 --- a/tests/unit/test_cli_incidents.py +++ b/tests/unit/test_cli_incidents.py @@ -3,11 +3,10 @@ import json from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from hyperping.cli._app import app -from hyperping.models import Incident, IncidentUpdate, LocalizedText +from hyperping.models import Incident runner = CliRunner() @@ -95,7 +94,10 @@ def test_incident_create_missing_title_exits(self) -> None: with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): result = runner.invoke( app, - ["--api-key", "sk_test", "incident", "create", "--text", "DB down", "--statuspage", "sp_abc"], + [ + "--api-key", "sk_test", "incident", "create", + "--text", "DB down", "--statuspage", "sp_abc", + ], ) assert result.exit_code != 0 @@ -111,6 +113,9 @@ def test_incident_resolve_with_message(self) -> None: with patch("hyperping.cli._incidents.get_client", return_value=_make_client()): result = runner.invoke( app, - ["--api-key", "sk_test", "incident", "resolve", "inci_123", "--message", "Fixed it"], + [ + "--api-key", "sk_test", "incident", "resolve", + "inci_123", "--message", "Fixed it", + ], ) assert result.exit_code == 0, result.output diff --git a/tests/unit/test_cli_monitors.py b/tests/unit/test_cli_monitors.py index 8ef118d..4ede32a 100644 --- a/tests/unit/test_cli_monitors.py +++ b/tests/unit/test_cli_monitors.py @@ -3,7 +3,6 @@ import json from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from hyperping.cli._app import app diff --git a/tests/unit/test_cli_statuspages.py b/tests/unit/test_cli_statuspages.py index 6f60c59..71f7b7f 100644 --- a/tests/unit/test_cli_statuspages.py +++ b/tests/unit/test_cli_statuspages.py @@ -3,7 +3,6 @@ import json from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from hyperping.cli._app import app diff --git a/tests/unit/test_cli_tenant.py b/tests/unit/test_cli_tenant.py index 8175114..720c794 100644 --- a/tests/unit/test_cli_tenant.py +++ b/tests/unit/test_cli_tenant.py @@ -1,8 +1,7 @@ """Tests for hyp tenant onboard subcommand.""" -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from hyperping.cli._app import app