statusdoc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
statusdoc/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Open Status Document: read, discover and validate service status pages."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .adapters import detect, from_betterstack, load
6
+ from .discover import Discovery, DiscoveryError, discover
7
+ from .model import Component, Document, FormatError, Incident, Update, parse
8
+ from .validate import Issue, validate_document, validate_http
9
+
10
+ __all__ = [
11
+ "Component",
12
+ "Discovery",
13
+ "DiscoveryError",
14
+ "Document",
15
+ "FormatError",
16
+ "Incident",
17
+ "Issue",
18
+ "Update",
19
+ "detect",
20
+ "discover",
21
+ "from_betterstack",
22
+ "load",
23
+ "parse",
24
+ "validate_document",
25
+ "validate_http",
26
+ ]
statusdoc/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
statusdoc/adapters.py ADDED
@@ -0,0 +1,237 @@
1
+ """Readers for status formats that are not OSD yet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .model import Component, Document, FormatError, Incident, Update, parse
8
+
9
+ # Better Stack resource states → OSD component statuses.
10
+ _BETTERSTACK_STATUS = {
11
+ "operational": "operational",
12
+ "maintenance": "under_maintenance",
13
+ "degraded": "degraded_performance",
14
+ "downtime": "major_outage",
15
+ }
16
+ _BETTERSTACK_SEVERITY = {"operational": 0, "under_maintenance": 1, "degraded_performance": 2, "partial_outage": 3, "major_outage": 4}
17
+ _IMPACT_OF = {"degraded_performance": "minor", "partial_outage": "major", "major_outage": "critical"}
18
+ _INDICATOR_OF = {0: "none", 1: "maintenance", 2: "minor", 3: "major", 4: "critical"}
19
+ _DESCRIPTION_OF = {
20
+ 0: "All Systems Operational",
21
+ 1: "Service Under Maintenance",
22
+ 2: "Partially Degraded Service",
23
+ 3: "Partial System Outage",
24
+ 4: "Major System Outage",
25
+ }
26
+
27
+
28
+ def detect(data: Any) -> str | None:
29
+ """Returns the format of a parsed JSON document, or None if unknown."""
30
+ if not isinstance(data, dict):
31
+ return None
32
+ if isinstance(data.get("page"), dict) and isinstance(data.get("status"), dict) and isinstance(data.get("components"), list):
33
+ return "osd"
34
+ d = data.get("data")
35
+ if isinstance(d, dict) and d.get("type") == "status_page" and isinstance(data.get("included"), list):
36
+ return "betterstack"
37
+ if isinstance(data.get("status"), str) and isinstance(data.get("active_incidents"), list):
38
+ return "slack"
39
+ return None
40
+
41
+
42
+ def load(data: Any) -> Document:
43
+ """Reads a status document in any supported format."""
44
+ fmt = detect(data)
45
+ if fmt == "osd":
46
+ return parse(data)
47
+ if fmt == "betterstack":
48
+ return from_betterstack(data)
49
+ if fmt == "slack":
50
+ return from_slack(data)
51
+ raise FormatError("not a status document in a known format")
52
+
53
+
54
+ def from_betterstack(data: dict[str, Any]) -> Document:
55
+ """Converts a Better Stack status page (`/index.json`, JSON:API) to OSD."""
56
+ page = data["data"].get("attributes", {})
57
+ included = [x for x in data["included"] if isinstance(x, dict)]
58
+
59
+ def of_type(t: str) -> list[dict[str, Any]]:
60
+ return [x for x in included if x.get("type") == t]
61
+
62
+ components: dict[str, Component] = {}
63
+ sections = {str(s.get("id")): s.get("attributes", {}) for s in of_type("status_page_section")}
64
+ for s_id, s in sections.items():
65
+ components["section-" + s_id] = Component(
66
+ id="section-" + s_id, name=str(s.get("name", "")).strip(), status="operational", group=True, position=s.get("position")
67
+ )
68
+ for r in of_type("status_page_resource"):
69
+ a = r.get("attributes", {})
70
+ if a.get("status") == "not_monitored":
71
+ # No data, so no claim either way: leave it out.
72
+ continue
73
+ section = a.get("status_page_section_id")
74
+ c = Component(
75
+ id=str(r.get("id")),
76
+ name=str(a.get("public_name", "")),
77
+ status=_BETTERSTACK_STATUS.get(str(a.get("status")), "degraded_performance"),
78
+ description=a.get("explanation") or None,
79
+ group_id=f"section-{section}" if section is not None and str(section) in sections else None,
80
+ position=a.get("position"),
81
+ )
82
+ components[c.id] = c
83
+ # A section is as bad as its worst resource.
84
+ for c in components.values():
85
+ if c.group_id:
86
+ group = components[c.group_id]
87
+ if _BETTERSTACK_SEVERITY[c.status] > _BETTERSTACK_SEVERITY[group.status]:
88
+ group.status = c.status
89
+
90
+ updates = {str(u.get("id")): u.get("attributes", {}) for u in of_type("status_update")}
91
+ incidents: list[Incident] = []
92
+ maintenances: list[Incident] = []
93
+ for report in of_type("status_report"):
94
+ a = report.get("attributes", {})
95
+ resolved = a.get("aggregate_state") == "resolved" or a.get("ends_at") is not None
96
+ affected = [
97
+ components[str(x.get("status_page_resource_id"))] for x in a.get("affected_resources", []) if str(x.get("status_page_resource_id")) in components
98
+ ]
99
+ worst = max((_BETTERSTACK_SEVERITY[c.status] for c in affected), default=0)
100
+ rel = report.get("relationships", {}).get("status_updates", {}).get("data", [])
101
+ report_updates = [updates[str(x.get("id"))] for x in rel if str(x.get("id")) in updates]
102
+ report_updates.sort(key=lambda u: str(u.get("published_at", "")), reverse=True)
103
+ is_maintenance = a.get("report_type") == "maintenance"
104
+ item = Incident(
105
+ id=str(report.get("id")),
106
+ name=str(a.get("title", "")),
107
+ status=("completed" if resolved else "in_progress") if is_maintenance else ("resolved" if resolved else "investigating"),
108
+ impact="none" if is_maintenance else _IMPACT_OF.get(_status_of(worst), "minor"),
109
+ created_at=str(a.get("starts_at", "")),
110
+ updates=[
111
+ Update(id=str(i), status="resolved" if resolved else "investigating", body=str(u.get("message", "")), created_at=str(u.get("published_at", "")))
112
+ for i, u in enumerate(report_updates)
113
+ ],
114
+ components=affected,
115
+ scheduled_for=a.get("starts_at") if is_maintenance else None,
116
+ scheduled_until=a.get("ends_at") if is_maintenance else None,
117
+ )
118
+ if is_maintenance:
119
+ if not resolved:
120
+ maintenances.append(item)
121
+ elif not resolved:
122
+ incidents.append(item)
123
+
124
+ # The page's own aggregate state is the publisher's judgement (spec section 4.3);
125
+ # fall back to the worst resource when it is missing.
126
+ aggregate = _BETTERSTACK_STATUS.get(str(page.get("aggregate_state")))
127
+ worst = _BETTERSTACK_SEVERITY[aggregate] if aggregate else max((_BETTERSTACK_SEVERITY[c.status] for c in components.values() if not c.group), default=0)
128
+ domain = page.get("custom_domain") or f"{page.get('subdomain', '')}.betteruptime.com"
129
+ return Document(
130
+ name=str(page.get("company_name", "")),
131
+ url=f"https://{domain}",
132
+ updated_at=str(page.get("updated_at", "")),
133
+ indicator=_INDICATOR_OF[worst],
134
+ description=_DESCRIPTION_OF[worst],
135
+ components=sorted(components.values(), key=lambda c: (c.group_id or c.id, not c.group, c.position or 0)),
136
+ incidents=incidents,
137
+ maintenances=maintenances,
138
+ source_format="betterstack",
139
+ )
140
+
141
+
142
+ def _status_of(severity: int) -> str:
143
+ for status, s in _BETTERSTACK_SEVERITY.items():
144
+ if s == severity:
145
+ return status
146
+ return "operational"
147
+
148
+
149
+ # Services listed by the Slack Status API documentation for /api/v2.0.0/current.
150
+ SLACK_SERVICES = (
151
+ "Login/SSO",
152
+ "Messaging",
153
+ "Notifications",
154
+ "Search",
155
+ "Workspace/Org Administration",
156
+ "Canvases",
157
+ "Connectivity",
158
+ "Files",
159
+ "Huddles",
160
+ "Apps/Integrations/APIs",
161
+ "Workflows",
162
+ )
163
+ # Slack issue types → (OSD incident impact, status of the affected services).
164
+ _SLACK_TYPES = {"outage": ("major", "major_outage"), "incident": ("minor", "degraded_performance"), "notice": ("none", "operational")}
165
+
166
+
167
+ def from_slack(data: dict[str, Any]) -> Document:
168
+ """Converts the Slack Status API (`https://slack-status.com/api/v2.0.0/current`) to OSD.
169
+
170
+ Slack reports issues, not component states: affected services take the
171
+ status implied by the issue type (an outage makes them major_outage, an
172
+ incident degraded_performance, a notice leaves them operational).
173
+ """
174
+ services: dict[str, Component] = {}
175
+
176
+ def service(name: str) -> Component:
177
+ if name not in services:
178
+ slug = "".join(ch if ch.isalnum() else "-" for ch in name.lower()).strip("-")
179
+ services[name] = Component(id=slug, name=name, status="operational", position=len(services))
180
+ return services[name]
181
+
182
+ for name in SLACK_SERVICES:
183
+ service(name)
184
+
185
+ incidents: list[Incident] = []
186
+ maintenances: list[Incident] = []
187
+ worst = 0
188
+ for item in data.get("active_incidents", []):
189
+ if not isinstance(item, dict):
190
+ continue
191
+ status = str(item.get("status", ""))
192
+ kind = str(item.get("type", ""))
193
+ impact, service_status = _SLACK_TYPES.get(kind, ("minor", "degraded_performance"))
194
+ notes = [n for n in item.get("notes", []) if isinstance(n, dict)]
195
+ notes.sort(key=lambda n: str(n.get("date_created", "")), reverse=True)
196
+ affected = [service(str(s)) for s in item.get("services", []) if isinstance(s, str)]
197
+ entry = Incident(
198
+ id=str(item.get("id", "")),
199
+ name=str(item.get("title", "")),
200
+ status="",
201
+ impact=impact,
202
+ created_at=str(item.get("date_created", "")),
203
+ updated_at=item.get("date_updated"),
204
+ shortlink=item.get("url"),
205
+ updates=[
206
+ Update(id=f"{item.get('id', '')}-{n}", status="", body=str(note.get("body", "")), created_at=str(note.get("date_created", "")))
207
+ for n, note in enumerate(notes)
208
+ ],
209
+ )
210
+ if status in ("scheduled", "completed", "cancelled"):
211
+ if status == "scheduled":
212
+ entry.status, entry.impact = "scheduled", "none"
213
+ entry.scheduled_for = entry.created_at
214
+ maintenances.append(entry)
215
+ continue
216
+ entry.status = "resolved" if status == "resolved" else "investigating"
217
+ for u in entry.updates:
218
+ u.status = entry.status
219
+ if not entry.resolved:
220
+ for c in affected:
221
+ if _BETTERSTACK_SEVERITY[service_status] > _BETTERSTACK_SEVERITY[c.status]:
222
+ c.status = service_status
223
+ worst = max(worst, {"none": 0, "minor": 2, "major": 3}[impact])
224
+ entry.components = [Component(id=c.id, name=c.name, status=c.status) for c in affected]
225
+ incidents.append(entry)
226
+
227
+ return Document(
228
+ name="Slack",
229
+ url="https://slack-status.com",
230
+ updated_at=str(data.get("date_updated", "")),
231
+ indicator=_INDICATOR_OF[worst],
232
+ description=_DESCRIPTION_OF[worst],
233
+ components=list(services.values()),
234
+ incidents=incidents,
235
+ maintenances=maintenances,
236
+ source_format="slack",
237
+ )
statusdoc/cli.py ADDED
@@ -0,0 +1,221 @@
1
+ """Command line: check, discover, validate and convert status documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from typing import Any, TextIO
10
+
11
+ from . import __version__
12
+ from .discover import Discovery, DiscoveryError, discover
13
+ from .fetch import Fetcher, FetchError, get
14
+ from .model import LEVELS, Document
15
+ from .validate import Issue, has_errors, validate_document, validate_http
16
+
17
+ # Exit codes of `statusdoc check`.
18
+ EXIT_OK, EXIT_DEGRADED, EXIT_USAGE, EXIT_UNREACHABLE = 0, 1, 2, 3
19
+
20
+ _SYMBOL = {"operational": "✓", "maintenance": "◷", "minor": "!", "major": "✗", "critical": "✗"}
21
+
22
+
23
+ def main(argv: list[str] | None = None, *, out: TextIO = sys.stdout, fetch: Fetcher = get, registry: dict[str, dict[str, str]] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(prog="statusdoc", description="Read service status pages in the Open Status Document format.")
25
+ parser.add_argument("--version", action="version", version=f"statusdoc {__version__}")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+
28
+ p_check = sub.add_parser("check", help="show the status of services (exit 1 if one is degraded)")
29
+ p_check.add_argument("targets", nargs="+", metavar="DOMAIN|URL")
30
+ p_check.add_argument("--json", action="store_true", help="print JSON")
31
+ p_check.add_argument("--github", action="store_true", help="in GitHub Actions: write a job summary, step outputs and annotations")
32
+ p_check.add_argument("-v", "--verbose", action="store_true", help="list components that are not operational")
33
+ p_check.add_argument(
34
+ "--fail-on", choices=("minor", "major", "critical", "never"), default="minor", help="lowest level that makes the exit code 1 (default: minor)"
35
+ )
36
+
37
+ p_disc = sub.add_parser("discover", help="find the status document of services")
38
+ p_disc.add_argument("targets", nargs="+", metavar="DOMAIN|URL")
39
+
40
+ p_val = sub.add_parser("validate", help="check a status document against the spec")
41
+ p_val.add_argument("target", metavar="DOMAIN|URL")
42
+
43
+ p_conv = sub.add_parser("convert", help="print a status document in OSD form")
44
+ p_conv.add_argument("target", metavar="DOMAIN|URL")
45
+
46
+ args = parser.parse_args(argv)
47
+ if out is sys.stdout and hasattr(out, "reconfigure"):
48
+ # Windows consoles and pipes may not encode the status symbols.
49
+ out.reconfigure(errors="replace")
50
+ find = lambda t: discover(t, fetch=fetch, registry=registry)
51
+
52
+ if args.command == "check":
53
+ return _check(args, find, out)
54
+ if args.command == "discover":
55
+ status = EXIT_OK
56
+ for target, result in _all(args.targets, find):
57
+ if isinstance(result, Exception):
58
+ print(f"{target}: {result}", file=out)
59
+ status = EXIT_UNREACHABLE
60
+ else:
61
+ print(f"{target}: {result.document_url} (via {result.method}, {result.document.source_format})", file=out)
62
+ return status
63
+ if args.command == "validate":
64
+ return _validate(args.target, find, fetch, out)
65
+ if args.command == "convert":
66
+ try:
67
+ d = find(args.target)
68
+ except (DiscoveryError, FetchError, ValueError) as e:
69
+ print(f"statusdoc: {e}", file=sys.stderr)
70
+ return EXIT_UNREACHABLE
71
+ json.dump(d.document.to_dict(), out, indent=2, ensure_ascii=False)
72
+ print(file=out)
73
+ return EXIT_OK
74
+ return EXIT_USAGE
75
+
76
+
77
+ def _all(targets: list[str], find: Any) -> list[tuple[str, Discovery | Exception]]:
78
+ def one(t: str) -> Discovery | Exception:
79
+ try:
80
+ return find(t)
81
+ except (DiscoveryError, FetchError, ValueError) as e:
82
+ return e
83
+
84
+ with ThreadPoolExecutor(max_workers=8) as pool:
85
+ return list(zip(targets, pool.map(one, targets)))
86
+
87
+
88
+ def _check(args: argparse.Namespace, find: Any, out: TextIO) -> int:
89
+ results = _all(args.targets, find)
90
+ threshold = len(LEVELS) if args.fail_on == "never" else LEVELS.index(args.fail_on)
91
+ degraded = unreachable = False
92
+ report = []
93
+ for target, r in results:
94
+ if isinstance(r, Exception):
95
+ unreachable = True
96
+ report.append({"target": target, "error": str(r)})
97
+ continue
98
+ d = r.document
99
+ degraded |= d.severity >= threshold
100
+ report.append(_summary(target, r, d))
101
+
102
+ if args.json:
103
+ json.dump(report, out, indent=2, ensure_ascii=False)
104
+ print(file=out)
105
+ else:
106
+ width = max(len(t) for t in args.targets)
107
+ for item in report:
108
+ if "error" in item:
109
+ print(f"? {item['target']:<{width}} unreachable: {item['error']}", file=out)
110
+ continue
111
+ print(f"{_SYMBOL[item['level']]} {item['target']:<{width}} {item['level']:<11} {item['description']}", file=out)
112
+ for inc in item["incidents"]:
113
+ print(f" {'':<{width}} • {inc['name']} ({inc['impact']}, {inc['status']})", file=out)
114
+ for m in item["maintenance"]:
115
+ print(f" {'':<{width}} ◷ {m['name']} (until {m['until'] or 'unknown'})", file=out)
116
+ if args.verbose:
117
+ for comp in item["components"]:
118
+ print(f" {'':<{width}} - {comp['name']}: {comp['status']}", file=out)
119
+ if args.github:
120
+ _github(report, threshold, out)
121
+ if degraded:
122
+ return EXIT_DEGRADED
123
+ return EXIT_UNREACHABLE if unreachable else EXIT_OK
124
+
125
+
126
+ def _short(error: str) -> str:
127
+ """Shortens a discovery error that lists every address tried."""
128
+ head, sep, tried = error.partition(": tried ")
129
+ return f"{head} (tried {tried.count('; ') + 1} addresses)" if sep else error
130
+
131
+
132
+ _EMOJI = {"operational": "✅", "maintenance": "🛠️", "minor": "⚠️", "major": "❌", "critical": "🔥"}
133
+
134
+
135
+ def markdown(report: list[dict[str, Any]]) -> str:
136
+ """Renders a check report as a Markdown table."""
137
+ lines = ["### Upstream status", "", "| | Service | Status | Details |", "|---|---|---|---|"]
138
+
139
+ def cell(text: str) -> str:
140
+ return text.replace("|", "\\|").replace("\n", " ")
141
+
142
+ for item in report:
143
+ if "error" in item:
144
+ lines.append(f"| ❓ | {cell(item['target'])} | unreachable | {cell(_short(item['error']))} |")
145
+ continue
146
+ details = [cell(item["description"])]
147
+ for inc in item["incidents"]:
148
+ name = cell(inc["name"])
149
+ details.append(f"[{name}]({inc['url']})" if inc.get("url") else name)
150
+ for m in item["maintenance"]:
151
+ details.append("Maintenance: " + cell(m["name"]))
152
+ service = f"[{cell(item['target'])}]({item['page']})" if item.get("page") else cell(item["target"])
153
+ lines.append(f"| {_EMOJI[item['level']]} | {service} | {item['level']} | {'<br>'.join(details)} |")
154
+ return "\n".join(lines) + "\n"
155
+
156
+
157
+ def _github(report: list[dict[str, Any]], threshold: int, out: TextIO) -> None:
158
+ """Job summary, step outputs and annotations for GitHub Actions."""
159
+ import os
160
+
161
+ if path := os.environ.get("GITHUB_STEP_SUMMARY"):
162
+ with open(path, "a", encoding="utf-8") as f:
163
+ f.write(markdown(report))
164
+ levels = [LEVELS.index(i["level"]) for i in report if "error" not in i]
165
+ worst = LEVELS[max(levels)] if levels else "unknown"
166
+ if path := os.environ.get("GITHUB_OUTPUT"):
167
+ with open(path, "a", encoding="utf-8") as f:
168
+ f.write(f"worst-level={worst}\n")
169
+ f.write(f"degraded={'true' if levels and max(levels) >= threshold else 'false'}\n")
170
+ f.write(f"unreachable={'true' if any('error' in i for i in report) else 'false'}\n")
171
+ f.write("report=" + json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n")
172
+ for item in report:
173
+ if "error" in item:
174
+ print(f"::warning title={item['target']} unreachable::{_short(item['error'])}", file=out)
175
+ elif item["level"] != "operational":
176
+ kind = "error" if LEVELS.index(item["level"]) >= threshold else "warning"
177
+ incidents = "; ".join(i["name"] for i in item["incidents"])
178
+ print(f"::{kind} title={item['target']} is {item['level']}::{item['description']}{': ' + incidents if incidents else ''}", file=out)
179
+
180
+
181
+ def _summary(target: str, r: Discovery, d: Document) -> dict[str, Any]:
182
+ return {
183
+ "target": target,
184
+ "name": d.name,
185
+ "level": d.level,
186
+ "indicator": d.indicator,
187
+ "description": d.description,
188
+ "page": d.url,
189
+ "document": r.document_url,
190
+ "discovered_via": r.method,
191
+ "incidents": [{"name": i.name, "status": i.status, "impact": i.impact, "url": i.shortlink} for i in d.open_incidents],
192
+ "maintenance": [{"name": m.name, "status": m.status, "until": m.scheduled_until, "url": m.shortlink} for m in d.active_maintenances],
193
+ "components": [{"name": c.name, "status": c.status} for c in d.components if not c.group and c.status != "operational"],
194
+ }
195
+
196
+
197
+ def _validate(target: str, find: Any, fetch: Fetcher, out: TextIO) -> int:
198
+ try:
199
+ r = find(target)
200
+ except (DiscoveryError, FetchError, ValueError) as e:
201
+ print(f"statusdoc: {e}", file=out)
202
+ return EXIT_UNREACHABLE
203
+ print(f"document: {r.document_url} (found via {r.method})", file=out)
204
+ if r.document.source_format != "osd":
205
+ print(f"MUST $: the document is in the {r.document.source_format} format, not OSD; `statusdoc convert` shows the OSD equivalent", file=out)
206
+ return EXIT_DEGRADED
207
+ issues: list[Issue] = []
208
+ if r.method != "well-known" and "://" not in target:
209
+ issues.append(Issue("SHOULD", "discovery", f"https://{target}/.well-known/status does not point to the document (section 3.1)"))
210
+ resp = r.response or fetch(r.document_url)
211
+ issues += validate_http(resp) + validate_document(resp.json())
212
+ for issue in issues:
213
+ print(issue, file=out)
214
+ errors = sum(i.level == "MUST" for i in issues)
215
+ warnings = len(issues) - errors
216
+ print(f"\n{'not conforming' if has_errors(issues) else 'conforming'} to OSD 0.1: {errors} errors, {warnings} recommendations", file=out)
217
+ return EXIT_DEGRADED if has_errors(issues) else EXIT_OK
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())
statusdoc/discover.py ADDED
@@ -0,0 +1,129 @@
1
+ """Finding the status document of a service (SPEC.md, section 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from importlib import resources
9
+ from typing import Any
10
+ from urllib.parse import urlsplit
11
+
12
+ from .adapters import detect, load
13
+ from .fetch import Fetcher, FetchError, Response, get
14
+ from .model import Document
15
+
16
+
17
+ @dataclass
18
+ class Discovery:
19
+ target: str
20
+ document_url: str
21
+ #: How the document was found: well-known, registry, convention or url.
22
+ method: str
23
+ document: Document
24
+ page_url: str | None = None
25
+ response: Response | None = None
26
+
27
+
28
+ class DiscoveryError(Exception):
29
+ def __init__(self, target: str, attempts: list[tuple[str, str]]):
30
+ self.target = target
31
+ self.attempts = attempts
32
+ tried = "; ".join(f"{url} ({why})" for url, why in attempts)
33
+ super().__init__(f"no status document found for {target}: tried {tried}")
34
+
35
+
36
+ def load_registry() -> dict[str, dict[str, str]]:
37
+ data = json.loads(resources.files("statusdoc").joinpath("registry.json").read_text("utf-8"))
38
+ return data["services"]
39
+
40
+
41
+ _DOMAIN = re.compile(r"^[A-Za-z0-9.-]+(:\d+)?$")
42
+
43
+
44
+ def discover(target: str, *, fetch: Fetcher = get, registry: dict[str, dict[str, str]] | None = None, scheme: str = "https") -> Discovery:
45
+ """Finds and reads the status document of a domain, a status page URL or a document URL."""
46
+ if "://" in target:
47
+ return _from_url(target, fetch)
48
+ domain = target.strip().lower().rstrip(".")
49
+ if not _DOMAIN.match(domain):
50
+ raise ValueError(f"not a domain or URL: {target!r}")
51
+ domain = domain.removeprefix("www.")
52
+ attempts: list[tuple[str, str]] = []
53
+
54
+ # 1. The service's own well-known URI.
55
+ well_known = f"{scheme}://{domain}/.well-known/status"
56
+ data, resp = _try_json(well_known, fetch, attempts)
57
+ if isinstance(data, dict):
58
+ if detect(data):
59
+ return Discovery(target, resp.url, "well-known", load(data), response=resp)
60
+ doc_url = data.get("document")
61
+ if isinstance(doc_url, str) and doc_url.startswith(("https://", f"{scheme}://")):
62
+ found = _try_document(doc_url, fetch, attempts)
63
+ if found:
64
+ page = data.get("page") if isinstance(data.get("page"), str) else None
65
+ return Discovery(target, found[1].url, "well-known", found[0], page_url=page, response=found[1])
66
+ else:
67
+ attempts.append((well_known, "no document URL"))
68
+
69
+ # 2. The list of known services, for the domain and its parents.
70
+ reg = load_registry() if registry is None else registry
71
+ parts = domain.split(".")
72
+ for i in range(len(parts) - 1):
73
+ entry = reg.get(".".join(parts[i:]))
74
+ if entry:
75
+ found = _try_document(entry["document"], fetch, attempts)
76
+ if found:
77
+ return Discovery(target, found[1].url, "registry", found[0], page_url=entry.get("page"), response=found[1])
78
+ break
79
+
80
+ # 3. Common conventions.
81
+ host = domain.split(":")[0]
82
+ label = host.split(".")[-2] if host.count(".") >= 1 else host
83
+ for page in (f"{scheme}://status.{domain}", f"{scheme}://{label}status.com", f"{scheme}://www.{label}status.com"):
84
+ for path in ("/api/v2/summary.json", "/index.json"):
85
+ found = _try_document(page + path, fetch, attempts)
86
+ if found:
87
+ return Discovery(target, found[1].url, "convention", found[0], page_url=page, response=found[1])
88
+ raise DiscoveryError(target, attempts)
89
+
90
+
91
+ def _from_url(url: str, fetch: Fetcher) -> Discovery:
92
+ attempts: list[tuple[str, str]] = []
93
+ found = _try_document(url, fetch, attempts)
94
+ if found:
95
+ return Discovery(url, found[1].url, "url", found[0], response=found[1])
96
+ # A status page URL: try the usual document paths.
97
+ parts = urlsplit(url)
98
+ base = f"{parts.scheme}://{parts.netloc}"
99
+ for path in ("/api/v2/summary.json", "/index.json"):
100
+ found = _try_document(base + path, fetch, attempts)
101
+ if found:
102
+ return Discovery(url, found[1].url, "convention", found[0], page_url=url, response=found[1])
103
+ raise DiscoveryError(url, attempts)
104
+
105
+
106
+ def _try_json(url: str, fetch: Fetcher, attempts: list[tuple[str, str]]) -> tuple[Any, Response]:
107
+ try:
108
+ resp = fetch(url)
109
+ except FetchError as e:
110
+ attempts.append((url, str(e).removeprefix(url + ": ")))
111
+ return None, Response(url, 0)
112
+ if resp.status != 200:
113
+ attempts.append((url, f"HTTP {resp.status}"))
114
+ return None, resp
115
+ try:
116
+ return resp.json(), resp
117
+ except (ValueError, UnicodeDecodeError):
118
+ attempts.append((url, "not JSON"))
119
+ return None, resp
120
+
121
+
122
+ def _try_document(url: str, fetch: Fetcher, attempts: list[tuple[str, str]]) -> tuple[Document, Response] | None:
123
+ data, resp = _try_json(url, fetch, attempts)
124
+ if data is None:
125
+ return None
126
+ if not detect(data):
127
+ attempts.append((url, "JSON, but not a status document"))
128
+ return None
129
+ return load(data), resp