mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-13 14:10:28 +02:00
Merge branch 'BigBodyCobain:main' into feature/locate-auto-zoom
This commit is contained in:
@@ -10,6 +10,10 @@ OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
AIS_API_KEY=
|
||||
|
||||
# Optional AISHub REST backup when AISStream is silent/offline (same ships layer, ~20 min cadence).
|
||||
# Free registration at https://www.aishub.net/api — paste the account username (not a password).
|
||||
# AISHUB_USERNAME=
|
||||
|
||||
# Global Fishing Watch — fishing vessel activity events (Fishing Activity map layer).
|
||||
# Free API token from https://globalfishingwatch.org/our-apis/tokens
|
||||
# Without this the fishing_activity layer stays empty.
|
||||
|
||||
@@ -168,6 +168,7 @@ def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
_SECRET_VARS = [
|
||||
"AIS_API_KEY",
|
||||
"AISHUB_USERNAME",
|
||||
"OPENSKY_CLIENT_ID",
|
||||
"OPENSKY_CLIENT_SECRET",
|
||||
"LTA_ACCOUNT_KEY",
|
||||
|
||||
@@ -111,6 +111,12 @@ async def health_check(request: Request):
|
||||
ais_status = ais_proxy_status() or {}
|
||||
except Exception:
|
||||
ais_status = {}
|
||||
try:
|
||||
from services.fetchers.aishub_fallback import aishub_fallback_enabled
|
||||
|
||||
ais_status["aishub_configured"] = bool(aishub_fallback_enabled())
|
||||
except Exception:
|
||||
ais_status["aishub_configured"] = bool(str(os.environ.get("AISHUB_USERNAME", "") or "").strip())
|
||||
if ais_status.get("degraded_tls") and top_status == "ok":
|
||||
# Don't override a worse top-level status if SLOs already failed,
|
||||
# but escalate ok -> degraded so the field surfaces in dashboards.
|
||||
|
||||
@@ -137,15 +137,16 @@ def inject_layer_data(
|
||||
tagged.append(entry)
|
||||
|
||||
with _data_lock:
|
||||
existing = latest_data.get(layer)
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
current = latest_data.get(layer)
|
||||
existing = list(current) if isinstance(current, list) else []
|
||||
|
||||
if mode == "replace":
|
||||
existing = [e for e in existing if not e.get("_injected")]
|
||||
|
||||
existing.extend(tagged)
|
||||
latest_data[layer] = existing
|
||||
# Readers can hold references to published layer lists after releasing
|
||||
# _data_lock. Build a fresh list and swap it atomically rather than
|
||||
# mutating the published object in place with list.extend().
|
||||
latest_data[layer] = [*existing, *tagged]
|
||||
|
||||
bump_data_version()
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ API_REGISTRY = [
|
||||
"url": "https://aisstream.io/",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"id": "aishub_username",
|
||||
"env_key": "AISHUB_USERNAME",
|
||||
"name": "AISHub Username (backup)",
|
||||
"description": "Free AISHub account username used as a slow REST backup when AISStream is silent or offline. Does not replace live AIS — polls about every 20 minutes into the same ships layer. Register at aishub.net/api.",
|
||||
"category": "Maritime",
|
||||
"url": "https://www.aishub.net/api",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"id": "gfw_api_token",
|
||||
"env_key": "GFW_API_TOKEN",
|
||||
|
||||
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# Data sources
|
||||
AIS_API_KEY: str = ""
|
||||
AISHUB_USERNAME: str = "" # Optional AISHub REST backup when AISStream is silent
|
||||
OPENSKY_CLIENT_ID: str = ""
|
||||
OPENSKY_CLIENT_SECRET: str = ""
|
||||
LTA_ACCOUNT_KEY: str = ""
|
||||
|
||||
@@ -46,6 +46,7 @@ _CRITICAL_WARN = {
|
||||
|
||||
_OPTIONAL = {
|
||||
"AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)",
|
||||
"AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)",
|
||||
"GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)",
|
||||
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
|
||||
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Regression coverage for copy-on-write OpenClaw layer injection."""
|
||||
|
||||
from services import ai_intel_store
|
||||
from services.fetchers import _store
|
||||
|
||||
|
||||
def _publish_test_layer(monkeypatch, items):
|
||||
published = list(items)
|
||||
monkeypatch.setitem(_store.latest_data, "air_quality", published)
|
||||
monkeypatch.setattr(_store, "bump_data_version", lambda: None)
|
||||
return published
|
||||
|
||||
|
||||
def test_append_does_not_mutate_previously_published_list(monkeypatch):
|
||||
before = _publish_test_layer(monkeypatch, [{"id": "existing"}])
|
||||
|
||||
result = ai_intel_store.inject_layer_data(
|
||||
"air_quality",
|
||||
[{"id": "injected"}],
|
||||
mode="append",
|
||||
)
|
||||
|
||||
after = _store.latest_data["air_quality"]
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"layer": "air_quality",
|
||||
"injected": 1,
|
||||
"mode": "append",
|
||||
}
|
||||
assert after is not before
|
||||
assert before == [{"id": "existing"}]
|
||||
assert [item["id"] for item in after] == ["existing", "injected"]
|
||||
assert after[-1]["_injected"] is True
|
||||
assert after[-1]["_source"] == "user:openclaw"
|
||||
|
||||
|
||||
def test_replace_does_not_mutate_previously_published_list(monkeypatch):
|
||||
before = _publish_test_layer(
|
||||
monkeypatch,
|
||||
[
|
||||
{"id": "native"},
|
||||
{"id": "old-injected", "_injected": True},
|
||||
],
|
||||
)
|
||||
|
||||
result = ai_intel_store.inject_layer_data(
|
||||
"air_quality",
|
||||
[{"id": "new-injected"}],
|
||||
mode="replace",
|
||||
)
|
||||
|
||||
after = _store.latest_data["air_quality"]
|
||||
assert result["ok"] is True
|
||||
assert after is not before
|
||||
assert before == [
|
||||
{"id": "native"},
|
||||
{"id": "old-injected", "_injected": True},
|
||||
]
|
||||
assert [item["id"] for item in after] == ["native", "new-injected"]
|
||||
@@ -130,6 +130,7 @@ class TestHealthEndpointEscalation:
|
||||
body = res.json()
|
||||
assert body["ais_proxy"]["connected"] is False
|
||||
assert body["ais_proxy"]["proxy_spawn_count"] == 5
|
||||
assert "aishub_configured" in body["ais_proxy"]
|
||||
# Without API_KEY this would stay "ok"; with it set + connected=false,
|
||||
# we expect at least "degraded" (could be "error" if an SLO is also
|
||||
# red, but never "ok").
|
||||
@@ -138,6 +139,18 @@ class TestHealthEndpointEscalation:
|
||||
f"got {body['status']!r}"
|
||||
)
|
||||
|
||||
def test_health_reports_aishub_configured_flag(self, client, monkeypatch):
|
||||
_reset_ais_module()
|
||||
monkeypatch.setenv("AISHUB_USERNAME", "shadowbroker-test")
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ais_proxy"]["aishub_configured"] is True
|
||||
|
||||
monkeypatch.delenv("AISHUB_USERNAME", raising=False)
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ais_proxy"]["aishub_configured"] is False
|
||||
|
||||
def test_no_api_key_does_not_escalate(self, client, monkeypatch):
|
||||
"""When AIS_API_KEY isn't set, the operator hasn't opted in. Don't
|
||||
flag the system as degraded just because AIS isn't running — that's
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from services.api_settings import ALLOWED_ENV_KEYS, API_REGISTRY
|
||||
|
||||
|
||||
def test_aishub_username_is_in_api_registry():
|
||||
entry = next((item for item in API_REGISTRY if item.get("env_key") == "AISHUB_USERNAME"), None)
|
||||
assert entry is not None
|
||||
assert entry["category"] == "Maritime"
|
||||
assert entry["required"] is False
|
||||
assert "AISHUB_USERNAME" in ALLOWED_ENV_KEYS
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scripts.regen_duplicate_routes_baseline import (
|
||||
build_baseline_payload,
|
||||
collect_duplicate_routes,
|
||||
write_baseline,
|
||||
)
|
||||
|
||||
|
||||
def _route(path: str, methods: set[str], module: str):
|
||||
def endpoint():
|
||||
return None
|
||||
|
||||
endpoint.__module__ = module
|
||||
return SimpleNamespace(path=path, methods=methods, endpoint=endpoint)
|
||||
|
||||
|
||||
def test_collect_duplicate_routes_matches_ci_guard_shape():
|
||||
routes = [
|
||||
_route("/api/layers", {"POST"}, "routers.data"),
|
||||
_route("/api/layers", {"POST"}, "main"),
|
||||
_route("/api/health", {"GET", "HEAD"}, "routers.health"),
|
||||
_route("/api/health", {"GET", "HEAD"}, "main"),
|
||||
_route("/api/only-once", {"GET"}, "routers.example"),
|
||||
SimpleNamespace(path=None, methods={"GET"}, endpoint=lambda: None),
|
||||
]
|
||||
|
||||
assert collect_duplicate_routes(routes) == {
|
||||
"GET /api/health": ["main", "routers.health"],
|
||||
"POST /api/layers": ["main", "routers.data"],
|
||||
}
|
||||
|
||||
|
||||
def test_build_baseline_payload_is_deterministic():
|
||||
payload = build_baseline_payload(
|
||||
{
|
||||
"POST /z": ["routers.z", "main"],
|
||||
"GET /a": ["routers.a", "main"],
|
||||
}
|
||||
)
|
||||
|
||||
assert list(payload["duplicates"]) == ["GET /a", "POST /z"]
|
||||
assert payload["duplicates"]["GET /a"] == ["main", "routers.a"]
|
||||
assert payload["_meta"]["issue"] == "#239"
|
||||
assert payload["_meta"]["generated_with"] == (
|
||||
"python -m scripts.regen_duplicate_routes_baseline"
|
||||
)
|
||||
|
||||
|
||||
def test_write_baseline_emits_stable_json(tmp_path):
|
||||
output = tmp_path / "duplicate_routes_baseline.json"
|
||||
duplicates = {"POST /api/layers": ["routers.data", "main"]}
|
||||
|
||||
payload = write_baseline(output, duplicates=duplicates)
|
||||
|
||||
assert json.loads(output.read_text(encoding="utf-8")) == payload
|
||||
assert output.read_text(encoding="utf-8").endswith("\n")
|
||||
assert payload["duplicates"] == {
|
||||
"POST /api/layers": ["main", "routers.data"]
|
||||
}
|
||||
@@ -994,7 +994,7 @@ export default function Dashboard() {
|
||||
{/* AIS UPSTREAM OUTAGE BANNER — renders only when AIS is configured
|
||||
but the WebSocket upstream is unreachable. Tells users the empty
|
||||
ocean isn't their fault. */}
|
||||
<AisUpstreamBanner />
|
||||
<AisUpstreamBanner onOpenApiKeys={() => setSettingsOpen(true)} />
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* AisUpstreamBanner — visible notice that AIS ship data is unavailable
|
||||
* because the upstream provider (AISStream) is offline.
|
||||
* AisUpstreamBanner — visible notice that AISStream ship data is unavailable.
|
||||
*
|
||||
* Renders nothing when AIS is healthy or when AIS isn't configured at all.
|
||||
* Mounted at the app shell level so users see it before they wonder why
|
||||
* the ocean looks empty.
|
||||
* When AISStream is silent, nudge operators toward the existing AISHub REST
|
||||
* backup (Settings → API Keys) or confirm that backup is already active.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
|
||||
|
||||
export function AisUpstreamBanner() {
|
||||
type AisUpstreamBannerProps = {
|
||||
onOpenApiKeys?: () => void;
|
||||
};
|
||||
|
||||
export function AisUpstreamBanner({ onOpenApiKeys }: AisUpstreamBannerProps = {}) {
|
||||
const health = useAisUpstreamHealth();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
@@ -29,6 +32,10 @@ export function AisUpstreamBanner() {
|
||||
}
|
||||
}
|
||||
|
||||
const detail = health.aishubConfigured
|
||||
? `AISStream is silent (${stalenessLabel}). AISHub backup polling stays active on a slower cadence (~20 min) — live WebSocket traffic will resume when AISStream recovers.`
|
||||
: `AISStream is silent (${stalenessLabel}). Add a free AISHub username under Settings → API Keys → Maritime for slow backup ship coverage while AISStream is down.`;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
@@ -38,12 +45,21 @@ export function AisUpstreamBanner() {
|
||||
<div className="flex items-start gap-3">
|
||||
<span aria-hidden className="mt-0.5 text-amber-300">⚠</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">Ship data temporarily unavailable</div>
|
||||
<div className="text-xs opacity-90">
|
||||
AISStream upstream is offline ({stalenessLabel}). The map will
|
||||
refill once their service comes back online — nothing is wrong
|
||||
with your install.
|
||||
<div className="font-semibold">
|
||||
{health.aishubConfigured
|
||||
? 'Live AIS offline — AISHub backup active'
|
||||
: 'Ship data temporarily unavailable'}
|
||||
</div>
|
||||
<div className="text-xs opacity-90">{detail}</div>
|
||||
{!health.aishubConfigured && onOpenApiKeys ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenApiKeys}
|
||||
className="mt-2 text-[11px] font-mono tracking-wide text-amber-100 underline underline-offset-2 hover:text-white"
|
||||
>
|
||||
Open API Keys
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -38,6 +38,20 @@ const API_GUIDES = [
|
||||
url: 'https://aisstream.io/authenticate',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
name: 'AISHub (backup)',
|
||||
icon: <Ship size={14} className="text-blue-300" />,
|
||||
required: false,
|
||||
description:
|
||||
'Slow REST backup for the ships layer when AISStream is silent or offline. Uses the same map layer on a ~20 minute cadence.',
|
||||
steps: [
|
||||
'Create a free account at aishub.net',
|
||||
'Open the API page and note your username',
|
||||
'Paste the username into Quick Local Setup above or Settings → API Keys → Maritime',
|
||||
],
|
||||
url: 'https://www.aishub.net/api',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
name: 'Global Fishing Watch',
|
||||
icon: <Ship size={14} className="text-teal-400" />,
|
||||
@@ -79,6 +93,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CLIENT_SECRET: '',
|
||||
AIS_API_KEY: '',
|
||||
AISHUB_USERNAME: '',
|
||||
GFW_API_TOKEN: '',
|
||||
});
|
||||
const [setupSaving, setSetupSaving] = useState(false);
|
||||
@@ -129,6 +144,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
OPENSKY_CLIENT_ID: '',
|
||||
OPENSKY_CLIENT_SECRET: '',
|
||||
AIS_API_KEY: '',
|
||||
AISHUB_USERNAME: '',
|
||||
GFW_API_TOKEN: '',
|
||||
});
|
||||
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
|
||||
@@ -577,9 +593,10 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
|
||||
useful immediately: live aircraft and vessel tracking. Global Fishing Watch
|
||||
unlocks the fishing-activity layer. Paste them below or use Settings later;
|
||||
secrets stay on the local backend.
|
||||
useful immediately: live aircraft and vessel tracking. Optionally add an
|
||||
AISHub username as a slow ships-layer backup when AISStream is silent.
|
||||
Global Fishing Watch unlocks the fishing-activity layer. Paste them below
|
||||
or use Settings later; secrets stay on the local backend.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,6 +616,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
|
||||
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
|
||||
['AIS_API_KEY', 'AIS Stream API Key'],
|
||||
['AISHUB_USERNAME', 'AISHub Username (optional backup)'],
|
||||
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
|
||||
].map(([key, label]) => (
|
||||
<input
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* banner can explain "AIS upstream is offline" instead of letting users
|
||||
* wonder.
|
||||
*
|
||||
* When AISStream is silent, the backend can still fill ships via AISHub REST
|
||||
* (`AISHUB_USERNAME`) on a slow cadence. ``aishubConfigured`` tells the banner
|
||||
* whether to nudge the operator to add that backup or confirm it is active.
|
||||
*
|
||||
* The poll interval is intentionally relaxed (30s) — this is a low-urgency UX
|
||||
* signal, not a real-time data feed. Backend already escalates top_status to
|
||||
* "degraded" when AIS is configured-but-disconnected.
|
||||
@@ -35,6 +39,8 @@ export interface AisUpstreamHealth {
|
||||
* seen — we approximate it by requiring at least one spawn before
|
||||
* declaring an outage. */
|
||||
aisEnabled: boolean;
|
||||
/** True when ``AISHUB_USERNAME`` is set so the REST backup can run. */
|
||||
aishubConfigured: boolean;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
@@ -67,6 +73,7 @@ export function useAisUpstreamHealth(): AisUpstreamHealth | null {
|
||||
degradedTls: Boolean(proxy.degraded_tls),
|
||||
proxySpawnCount: spawns,
|
||||
aisEnabled: spawns > 0,
|
||||
aishubConfigured: Boolean(proxy.aishub_configured),
|
||||
});
|
||||
} catch {
|
||||
// Backend unreachable — separate problem. Banner not relevant.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Regenerate the tolerated duplicate-route baseline used by issue #239 tests.
|
||||
|
||||
Run from the repository root with::
|
||||
|
||||
python -m scripts.regen_duplicate_routes_baseline
|
||||
|
||||
The command imports the backend application, inspects FastAPI's registered
|
||||
routes, and rewrites ``backend/tests/data/duplicate_routes_baseline.json`` in a
|
||||
deterministic order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
DEFAULT_BASELINE_PATH = BACKEND_DIR / "tests" / "data" / "duplicate_routes_baseline.json"
|
||||
|
||||
_BASELINE_NOTE = (
|
||||
"Snapshot of currently-tolerated duplicate route registrations. The test in "
|
||||
"test_no_new_duplicate_routes.py fails if any NEW (method, path) duplicate "
|
||||
"appears outside this list. Removing entries (by actually deduping) is fine "
|
||||
"and the test stays green. New entries here require explicit, reviewed updates."
|
||||
)
|
||||
|
||||
|
||||
def collect_duplicate_routes(routes: Iterable[Any]) -> dict[str, list[str]]:
|
||||
"""Return duplicate ``METHOD /path`` registrations and their modules."""
|
||||
by_key: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
for route in routes:
|
||||
path = getattr(route, "path", None)
|
||||
methods = getattr(route, "methods", None)
|
||||
endpoint = getattr(route, "endpoint", None)
|
||||
if not path or not methods or endpoint is None:
|
||||
continue
|
||||
|
||||
module = str(getattr(endpoint, "__module__", "") or "")
|
||||
for method in sorted(methods):
|
||||
if method in {"HEAD", "OPTIONS"}:
|
||||
continue
|
||||
by_key[f"{method} {path}"].append(module)
|
||||
|
||||
return {
|
||||
key: sorted(modules)
|
||||
for key, modules in sorted(by_key.items())
|
||||
if len(modules) > 1
|
||||
}
|
||||
|
||||
|
||||
def current_duplicates() -> dict[str, list[str]]:
|
||||
"""Import the backend application and inspect its live route table."""
|
||||
backend = str(BACKEND_DIR)
|
||||
if backend not in sys.path:
|
||||
sys.path.insert(0, backend)
|
||||
|
||||
import main
|
||||
|
||||
return collect_duplicate_routes(main.app.routes)
|
||||
|
||||
|
||||
def build_baseline_payload(duplicates: dict[str, list[str]]) -> dict[str, Any]:
|
||||
"""Build the canonical JSON payload written by the regeneration command."""
|
||||
return {
|
||||
"_meta": {
|
||||
"issue": "#239",
|
||||
"note": _BASELINE_NOTE,
|
||||
"generated_with": "python -m scripts.regen_duplicate_routes_baseline",
|
||||
},
|
||||
"duplicates": {
|
||||
key: sorted(modules)
|
||||
for key, modules in sorted(duplicates.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_baseline(
|
||||
path: Path = DEFAULT_BASELINE_PATH,
|
||||
*,
|
||||
duplicates: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write the baseline and return the payload for callers and tests."""
|
||||
payload = build_baseline_payload(
|
||||
current_duplicates() if duplicates is None else duplicates
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
payload = write_baseline()
|
||||
print(
|
||||
f"Wrote {len(payload['duplicates'])} duplicate route entries to "
|
||||
f"{DEFAULT_BASELINE_PATH.relative_to(REPO_ROOT)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user