mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-17 14:36:29 +03:00
604 lines
21 KiB
Python
604 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Live endpoint and schema canary for the Raleigh civic-data CLI.
|
|
|
|
Runs a full catalog check against the ArcGIS Hub and probes every fixed
|
|
non-Hub adapter endpoint shipped by the CLI. Validates source-specific
|
|
minimum schemas, classifies failures, retries bounded transient errors,
|
|
and writes a machine-readable JSON report.
|
|
|
|
Token-gated imagery folders are reported as ``restricted_folder``
|
|
observations (non-failing): the CLI only reads public data, so a folder
|
|
that requires a token is not a contract failure, but it stays visible in
|
|
the report.
|
|
|
|
Exit codes:
|
|
0 all probes passed (or only empty-but-valid / restricted observations)
|
|
1 one or more durable contract failures detected
|
|
2 script-level error (bad arguments, import failure, etc.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from raleighlib import core
|
|
from raleighlib import hub
|
|
from raleighlib import arcgis
|
|
from raleighlib import imagery
|
|
from raleighlib import geocode
|
|
from raleighlib import transit
|
|
from raleighlib import development
|
|
from raleighlib import civic
|
|
from raleighlib import meetings
|
|
from raleighlib import police
|
|
|
|
MAX_RETRIES = 2
|
|
RETRY_DELAY_SECONDS = 5
|
|
|
|
FAILURE_CLASSES = (
|
|
"transport_outage",
|
|
"auth_regression",
|
|
"arcgis_error",
|
|
"schema_drift",
|
|
"parser_failure",
|
|
"empty_but_valid",
|
|
"restricted_folder",
|
|
)
|
|
|
|
|
|
def _classify_exception(exc: Exception) -> str:
|
|
if isinstance(exc, urllib.error.HTTPError):
|
|
if exc.code in (401, 403):
|
|
return "auth_regression"
|
|
if exc.code >= 500:
|
|
return "transport_outage"
|
|
return "transport_outage"
|
|
if isinstance(exc, (urllib.error.URLError, OSError, TimeoutError)):
|
|
return "transport_outage"
|
|
if isinstance(exc, core.SecurityError):
|
|
return "auth_regression"
|
|
if isinstance(exc, ValueError):
|
|
msg = str(exc).lower()
|
|
if "token required" in msg or "auth" in msg:
|
|
return "auth_regression"
|
|
if "arcgis" in msg or "error" in msg:
|
|
return "arcgis_error"
|
|
return "schema_drift"
|
|
return "parser_failure"
|
|
|
|
|
|
def _is_transient(failure_class: str) -> bool:
|
|
return failure_class == "transport_outage"
|
|
|
|
|
|
def _probe_with_retry(fn, *args, **kwargs) -> tuple[Any, None] | tuple[None, dict[str, Any]]:
|
|
first_evidence: dict[str, Any] | None = None
|
|
for attempt in range(1, MAX_RETRIES + 2):
|
|
try:
|
|
result = fn(*args, **kwargs)
|
|
return result, None
|
|
except Exception as exc:
|
|
fc = _classify_exception(exc)
|
|
evidence = {
|
|
"failure_class": fc,
|
|
"error": str(exc),
|
|
"attempt": attempt,
|
|
}
|
|
if first_evidence is None:
|
|
first_evidence = evidence
|
|
if not _is_transient(fc) or attempt > MAX_RETRIES:
|
|
return None, first_evidence
|
|
time.sleep(RETRY_DELAY_SECONDS * attempt)
|
|
return None, first_evidence
|
|
|
|
|
|
def probe_hub_catalog() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
catalog, err = _probe_with_retry(hub.fetch_catalog)
|
|
if err:
|
|
results.append({
|
|
"source": "hub-catalog",
|
|
"target": "fetch_catalog",
|
|
"status": "fail",
|
|
**err,
|
|
})
|
|
return results
|
|
|
|
supported_types = {"FeatureServer", "MapServer", "ImageServer"}
|
|
items = [i for i in catalog if i.get("type") in supported_types and i.get("url")]
|
|
total = len(items)
|
|
failures = 0
|
|
|
|
for item in items:
|
|
url = item["url"]
|
|
item_id = item.get("id", "unknown")
|
|
title = item.get("title", "untitled")
|
|
item_type = item.get("type", "")
|
|
|
|
if item_type == "ImageServer":
|
|
meta, err = _probe_with_retry(imagery.service_info, url)
|
|
else:
|
|
meta, err = _probe_with_retry(arcgis.service_metadata, url)
|
|
|
|
if err:
|
|
failures += 1
|
|
results.append({
|
|
"source": "hub-catalog",
|
|
"target": f"{item_type}/{item_id}",
|
|
"title": title,
|
|
"url": url,
|
|
"status": "fail",
|
|
**err,
|
|
})
|
|
continue
|
|
|
|
if isinstance(meta, dict) and "error" in meta:
|
|
failures += 1
|
|
results.append({
|
|
"source": "hub-catalog",
|
|
"target": f"{item_type}/{item_id}",
|
|
"title": title,
|
|
"url": url,
|
|
"status": "fail",
|
|
"failure_class": "arcgis_error",
|
|
"error": json.dumps(meta["error"]),
|
|
"attempt": 1,
|
|
})
|
|
continue
|
|
|
|
if isinstance(meta, dict) and not meta.get("layers") and not meta.get("serviceDescription") is None:
|
|
pass
|
|
|
|
results.insert(0, {
|
|
"source": "hub-catalog",
|
|
"target": "summary",
|
|
"status": "pass" if failures == 0 else "fail",
|
|
"checked": total,
|
|
"failures": failures,
|
|
})
|
|
return results
|
|
|
|
|
|
def probe_geocode() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
candidates, err = _probe_with_retry(
|
|
geocode.find_address_candidates, "1 Hargett St, Raleigh, NC", max_locations=1
|
|
)
|
|
if err:
|
|
results.append({"source": "geocode", "target": "findAddressCandidates", "status": "fail", **err})
|
|
return results
|
|
|
|
if not isinstance(candidates, list):
|
|
results.append({
|
|
"source": "geocode", "target": "findAddressCandidates", "status": "fail",
|
|
"failure_class": "schema_drift", "error": "expected list of candidates", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
if len(candidates) == 0:
|
|
results.append({
|
|
"source": "geocode", "target": "findAddressCandidates", "status": "pass",
|
|
"failure_class": "empty_but_valid", "error": "no candidates returned", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
c = candidates[0]
|
|
missing = [f for f in ("address", "location") if f not in c]
|
|
if missing:
|
|
results.append({
|
|
"source": "geocode", "target": "findAddressCandidates", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": f"missing required fields: {missing}", "attempt": 1,
|
|
})
|
|
else:
|
|
results.append({"source": "geocode", "target": "findAddressCandidates", "status": "pass"})
|
|
return results
|
|
|
|
|
|
def probe_transit_gtfs() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
data, err = _probe_with_retry(transit.download_gtfs)
|
|
if err:
|
|
results.append({"source": "transit", "target": "static-gtfs", "status": "fail", **err})
|
|
return results
|
|
|
|
try:
|
|
feed = transit._parse_gtfs_zip(data)
|
|
except Exception as exc:
|
|
results.append({
|
|
"source": "transit", "target": "static-gtfs", "status": "fail",
|
|
"failure_class": "parser_failure", "error": str(exc), "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
missing_tables = [t for t in transit.REQUIRED_GTFS_FIELDS if t not in feed]
|
|
if missing_tables:
|
|
results.append({
|
|
"source": "transit", "target": "static-gtfs", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": f"missing required tables: {missing_tables}", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
for table, required_fields in transit.REQUIRED_GTFS_FIELDS.items():
|
|
rows = feed.get(table, [])
|
|
if not rows:
|
|
results.append({
|
|
"source": "transit", "target": f"static-gtfs/{table}", "status": "pass",
|
|
"failure_class": "empty_but_valid", "error": f"table {table} is empty", "attempt": 1,
|
|
})
|
|
continue
|
|
header = set(rows[0].keys())
|
|
missing = required_fields - header
|
|
if missing:
|
|
results.append({
|
|
"source": "transit", "target": f"static-gtfs/{table}", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": f"missing fields in {table}: {sorted(missing)}", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
results.append({
|
|
"source": "transit", "target": "static-gtfs", "status": "pass",
|
|
"tables": len(feed), "rows": sum(len(v) for v in feed.values()),
|
|
})
|
|
return results
|
|
|
|
|
|
def probe_development() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
criteria, err = _probe_with_retry(development.fetch_criteria)
|
|
if err:
|
|
results.append({"source": "development", "target": "criteria", "status": "fail", **err})
|
|
return results
|
|
|
|
if not isinstance(criteria, dict):
|
|
results.append({
|
|
"source": "development", "target": "criteria", "status": "fail",
|
|
"failure_class": "schema_drift", "error": "expected dict", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
results.append({"source": "development", "target": "criteria", "status": "pass"})
|
|
return results
|
|
|
|
|
|
def probe_civic_jsonapi() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
index, err = _probe_with_retry(core.json_request, civic.JSONAPI_ROOT)
|
|
if err:
|
|
results.append({"source": "civic", "target": "jsonapi-index", "status": "fail", **err})
|
|
return results
|
|
|
|
if not isinstance(index, dict) or "links" not in index:
|
|
results.append({
|
|
"source": "civic", "target": "jsonapi-index", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "index missing 'links' key", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
results.append({"source": "civic", "target": "jsonapi-index", "status": "pass"})
|
|
return results
|
|
|
|
|
|
def probe_civic_rss() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
data, err = _probe_with_retry(core.raw_request, civic.RSS_FEED)
|
|
if err:
|
|
results.append({"source": "civic", "target": "rss-feed", "status": "fail", **err})
|
|
return results
|
|
|
|
text = data.decode("utf-8", errors="replace")
|
|
if "<rss" not in text and "<feed" not in text:
|
|
results.append({
|
|
"source": "civic", "target": "rss-feed", "status": "fail",
|
|
"failure_class": "parser_failure",
|
|
"error": "response does not look like RSS/Atom XML", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
results.append({"source": "civic", "target": "rss-feed", "status": "pass"})
|
|
return results
|
|
|
|
|
|
def probe_meetings() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
upcoming, err = _probe_with_retry(meetings.list_upcoming)
|
|
if err:
|
|
fc = err.get("failure_class", "parser_failure")
|
|
if fc == "parser_failure" and "CompatibilityError" in err.get("error", ""):
|
|
err["failure_class"] = "parser_failure"
|
|
results.append({"source": "meetings", "target": "upcoming", "status": "fail", **err})
|
|
return results
|
|
|
|
if not isinstance(upcoming, list):
|
|
results.append({
|
|
"source": "meetings", "target": "upcoming", "status": "fail",
|
|
"failure_class": "schema_drift", "error": "expected list", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
if len(upcoming) == 0:
|
|
results.append({
|
|
"source": "meetings", "target": "upcoming", "status": "pass",
|
|
"failure_class": "empty_but_valid", "error": "no upcoming meetings", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
m = upcoming[0]
|
|
missing = [f for f in ("id", "title", "date") if f not in m]
|
|
if missing:
|
|
results.append({
|
|
"source": "meetings", "target": "upcoming", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": f"missing fields: {missing}", "attempt": 1,
|
|
})
|
|
else:
|
|
results.append({"source": "meetings", "target": "upcoming", "status": "pass", "count": len(upcoming)})
|
|
return results
|
|
|
|
|
|
def probe_imagery_catalog() -> list[dict[str, Any]]:
|
|
results: list[dict[str, Any]] = []
|
|
listing, err = _probe_with_retry(imagery.list_services)
|
|
if err:
|
|
results.append({"source": "imagery", "target": "catalog", "status": "fail", **err})
|
|
return results
|
|
|
|
if not isinstance(listing, tuple) or len(listing) != 2:
|
|
results.append({
|
|
"source": "imagery", "target": "catalog", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "list_services returned an unexpected shape", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
services, restricted_folders = listing
|
|
if not isinstance(services, list) or not isinstance(restricted_folders, list):
|
|
results.append({
|
|
"source": "imagery", "target": "catalog", "status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "list_services returned non-list fields", "attempt": 1,
|
|
})
|
|
return results
|
|
|
|
if len(services) == 0:
|
|
results.append({
|
|
"source": "imagery", "target": "catalog", "status": "pass",
|
|
"failure_class": "empty_but_valid", "error": "no imagery services", "attempt": 1,
|
|
})
|
|
else:
|
|
results.append({"source": "imagery", "target": "catalog", "status": "pass", "count": len(services)})
|
|
|
|
for folder in restricted_folders:
|
|
results.append({
|
|
"source": "imagery",
|
|
"target": f"folder:{folder}",
|
|
"status": "pass",
|
|
"failure_class": "restricted_folder",
|
|
"error": "folder listing requires a token; skipped",
|
|
"attempt": 1,
|
|
})
|
|
return results
|
|
|
|
|
|
def probe_police() -> list[dict[str, Any]]:
|
|
"""Exercise bounded date-filtered queries against fixed RPD sources."""
|
|
results: list[dict[str, Any]] = []
|
|
for source_key in ("nibrs", "crimemapper-90d"):
|
|
layer_url, err = _probe_with_retry(police.resolve_layer_url, source_key)
|
|
if err:
|
|
results.append({"source": "police", "target": source_key, "status": "fail", **err})
|
|
continue
|
|
fields, err = _probe_with_retry(arcgis.layer_fields, layer_url)
|
|
if err:
|
|
results.append({"source": "police", "target": source_key, "status": "fail", **err})
|
|
continue
|
|
field_names = {
|
|
field.get("name") for field in fields if isinstance(field, dict)
|
|
}
|
|
if "reported_date" not in field_names:
|
|
results.append({
|
|
"source": "police",
|
|
"target": source_key,
|
|
"status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "missing required field: reported_date",
|
|
"attempt": 1,
|
|
})
|
|
continue
|
|
collection, err = _probe_with_retry(
|
|
police.query_incidents,
|
|
source_key,
|
|
since_ms=police.NIBRS_EPOCH_MS,
|
|
limit=1,
|
|
)
|
|
if err:
|
|
results.append({"source": "police", "target": source_key, "status": "fail", **err})
|
|
continue
|
|
if (
|
|
not isinstance(collection, dict)
|
|
or collection.get("type") != "FeatureCollection"
|
|
or not isinstance(collection.get("features"), list)
|
|
):
|
|
results.append({
|
|
"source": "police",
|
|
"target": source_key,
|
|
"status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "expected GeoJSON FeatureCollection",
|
|
"attempt": 1,
|
|
})
|
|
continue
|
|
if not collection["features"]:
|
|
results.append({
|
|
"source": "police",
|
|
"target": source_key,
|
|
"status": "fail",
|
|
"failure_class": "schema_drift",
|
|
"error": "date-filtered query returned no records",
|
|
"attempt": 1,
|
|
})
|
|
continue
|
|
results.append({
|
|
"source": "police",
|
|
"target": source_key,
|
|
"status": "pass",
|
|
"count": len(collection["features"]),
|
|
})
|
|
return results
|
|
|
|
|
|
ALL_PROBES = [
|
|
("hub-catalog", probe_hub_catalog),
|
|
("geocode", probe_geocode),
|
|
("transit-gtfs", probe_transit_gtfs),
|
|
("development", probe_development),
|
|
("civic-jsonapi", probe_civic_jsonapi),
|
|
("civic-rss", probe_civic_rss),
|
|
("meetings", probe_meetings),
|
|
("imagery", probe_imagery_catalog),
|
|
("police", probe_police),
|
|
]
|
|
|
|
|
|
def run_canary() -> dict[str, Any]:
|
|
started = datetime.now(timezone.utc).isoformat()
|
|
all_results: list[dict[str, Any]] = []
|
|
durable_failures = 0
|
|
transient_failures = 0
|
|
empty_valid = 0
|
|
restricted = 0
|
|
|
|
for name, probe_fn in ALL_PROBES:
|
|
try:
|
|
results = probe_fn()
|
|
except Exception as exc:
|
|
results = [{
|
|
"source": name,
|
|
"target": "probe-level",
|
|
"status": "fail",
|
|
"failure_class": "parser_failure",
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
"traceback": traceback.format_exc(),
|
|
"attempt": 1,
|
|
}]
|
|
all_results.extend(results)
|
|
|
|
for r in all_results:
|
|
if r.get("target") == "summary":
|
|
continue
|
|
if r.get("status") != "fail":
|
|
if r.get("failure_class") == "empty_but_valid":
|
|
empty_valid += 1
|
|
elif r.get("failure_class") == "restricted_folder":
|
|
restricted += 1
|
|
continue
|
|
fc = r.get("failure_class", "unknown")
|
|
if _is_transient(fc):
|
|
transient_failures += 1
|
|
else:
|
|
durable_failures += 1
|
|
|
|
passed = durable_failures == 0 and transient_failures == 0
|
|
report = {
|
|
"canary": "raleigh-live-endpoint",
|
|
"started_at": started,
|
|
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
"passed": passed,
|
|
"summary": {
|
|
"total_results": len(all_results),
|
|
"durable_failures": durable_failures,
|
|
"transient_failures": transient_failures,
|
|
"empty_but_valid": empty_valid,
|
|
"restricted_folders": restricted,
|
|
},
|
|
"results": all_results,
|
|
}
|
|
return report
|
|
|
|
|
|
def write_github_summary(report: dict[str, Any]) -> None:
|
|
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if not summary_path:
|
|
return
|
|
lines: list[str] = []
|
|
s = report["summary"]
|
|
status = "PASS" if report["passed"] else "FAIL"
|
|
lines.append(f"## Raleigh Live Canary: {status}")
|
|
lines.append("")
|
|
lines.append(f"| Metric | Count |")
|
|
lines.append(f"|--------|-------|")
|
|
lines.append(f"| Total probes | {s['total_results']} |")
|
|
lines.append(f"| Durable failures | {s['durable_failures']} |")
|
|
lines.append(f"| Transient failures | {s['transient_failures']} |")
|
|
lines.append(f"| Empty-but-valid | {s['empty_but_valid']} |")
|
|
lines.append(f"| Restricted folders | {s.get('restricted_folders', 0)} |")
|
|
lines.append("")
|
|
|
|
restricted = [r for r in report["results"] if r.get("failure_class") == "restricted_folder"]
|
|
if restricted:
|
|
lines.append("### Restricted folders (token required; skipped, non-failing)")
|
|
lines.append("")
|
|
lines.append("| Source | Target | Evidence |")
|
|
lines.append("|--------|--------|----------|")
|
|
for r in restricted:
|
|
lines.append(f"| {r.get('source', '?')} | {r.get('target', '?')} | {r.get('error', '')[:120]} |")
|
|
lines.append("")
|
|
|
|
failures = [r for r in report["results"] if r.get("status") == "fail"]
|
|
if failures:
|
|
lines.append("### Failures")
|
|
lines.append("")
|
|
lines.append("| Source | Target | Class | Evidence |")
|
|
lines.append("|--------|--------|-------|----------|")
|
|
for f in failures:
|
|
evidence = f.get("error", "")[:120]
|
|
lines.append(
|
|
f"| {f.get('source', '?')} | {f.get('target', '?')} "
|
|
f"| {f.get('failure_class', '?')} | {evidence} |"
|
|
)
|
|
lines.append("")
|
|
|
|
with open(summary_path, "a", encoding="utf-8") as fh:
|
|
fh.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def main() -> int:
|
|
report = run_canary()
|
|
|
|
report_path = os.environ.get("CANARY_REPORT_PATH", "canary-report.json")
|
|
with open(report_path, "w", encoding="utf-8") as fh:
|
|
json.dump(report, fh, indent=2)
|
|
|
|
write_github_summary(report)
|
|
|
|
s = report["summary"]
|
|
print(
|
|
f"Canary {'PASSED' if report['passed'] else 'FAILED'}: "
|
|
f"{s['total_results']} probes, "
|
|
f"{s['durable_failures']} durable failures, "
|
|
f"{s['transient_failures']} transient failures, "
|
|
f"{s['empty_but_valid']} empty-but-valid, "
|
|
f"{s.get('restricted_folders', 0)} restricted folders"
|
|
)
|
|
print(f"Report written to {report_path}")
|
|
|
|
return 0 if report["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|