seattle-open-data 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.
- seattle_open_data/__init__.py +5 -0
- seattle_open_data/constants.py +129 -0
- seattle_open_data/core/__init__.py +0 -0
- seattle_open_data/core/arguments.py +96 -0
- seattle_open_data/core/catalog.py +217 -0
- seattle_open_data/core/context.py +33 -0
- seattle_open_data/core/cursor.py +91 -0
- seattle_open_data/core/describe.py +510 -0
- seattle_open_data/core/errors.py +320 -0
- seattle_open_data/core/guides.py +353 -0
- seattle_open_data/core/http.py +309 -0
- seattle_open_data/core/models.py +212 -0
- seattle_open_data/core/query.py +501 -0
- seattle_open_data/core/reference.py +543 -0
- seattle_open_data/core/reference_service.py +86 -0
- seattle_open_data/core/text.py +42 -0
- seattle_open_data/mcp/__init__.py +0 -0
- seattle_open_data/mcp/resources.py +108 -0
- seattle_open_data/mcp/result.py +34 -0
- seattle_open_data/mcp/schema.py +42 -0
- seattle_open_data/mcp/server.py +161 -0
- seattle_open_data/mcp/tools.py +318 -0
- seattle_open_data-0.1.0.dist-info/METADATA +82 -0
- seattle_open_data-0.1.0.dist-info/RECORD +27 -0
- seattle_open_data-0.1.0.dist-info/WHEEL +4 -0
- seattle_open_data-0.1.0.dist-info/entry_points.txt +3 -0
- seattle_open_data-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Seattle portal facts (docs/design.md: Scope).
|
|
2
|
+
|
|
3
|
+
Extract a `Portal` type only when a second portal arrives.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
SERVER_NAME = "seattle-open-data"
|
|
11
|
+
TOOL_PREFIX = "seattle_data_"
|
|
12
|
+
URI_SCHEME = "seattle-data"
|
|
13
|
+
|
|
14
|
+
PUBLIC_HOST = "data.seattle.gov"
|
|
15
|
+
#: The Discovery API indexes Seattle under this name; `domains=data.seattle.gov` returns nothing.
|
|
16
|
+
CATALOG_DOMAIN = "cos-data.seattle.gov"
|
|
17
|
+
DISCOVERY_API = "https://api.us.socrata.com/api/catalog/v1"
|
|
18
|
+
|
|
19
|
+
SEATTLE_TZ = ZoneInfo("America/Los_Angeles")
|
|
20
|
+
|
|
21
|
+
DATASET_ID_PATTERN = r"^[a-z0-9]{4}-[a-z0-9]{4}$"
|
|
22
|
+
|
|
23
|
+
SEARCH_PAGE_SIZE_MAX = 50
|
|
24
|
+
QUERY_PAGE_SIZE_MAX = 500
|
|
25
|
+
QUERY_LOGICAL_CAP = 10_000
|
|
26
|
+
QUERY_TEXT_MAX_BYTES = 4096
|
|
27
|
+
SAMPLE_ROWS_MAX = 5
|
|
28
|
+
|
|
29
|
+
RESULT_BYTE_BUDGET = 24 * 1024
|
|
30
|
+
TOOLS_LIST_BYTE_BUDGET = 24 * 1024
|
|
31
|
+
|
|
32
|
+
CACHE_TTL_SECONDS = 3600
|
|
33
|
+
|
|
34
|
+
INSTRUCTIONS_SENTINEL = "END SEATTLE OPEN DATA INSTRUCTIONS"
|
|
35
|
+
|
|
36
|
+
UPSTREAM_TIMEOUT_SECONDS = 30.0
|
|
37
|
+
DESCRIBE_AGGREGATE_TIMEOUT_SECONDS = 10.0
|
|
38
|
+
UPSTREAM_MAX_BYTES = 8 * 1024 * 1024
|
|
39
|
+
CURSOR_TTL_SECONDS = 3600
|
|
40
|
+
|
|
41
|
+
#: Values publishers use instead of null in text place fields (docs/upstream-facts.md).
|
|
42
|
+
PLACEHOLDER_VALUES = frozenset({"-", "UNKNOWN", "NULL", "OOJ", "FK ERROR", "X", "0", ""})
|
|
43
|
+
|
|
44
|
+
#: Computed-region layers seen on Seattle datasets; label columns and dates verified 2026-10-15.
|
|
45
|
+
REGION_LAYERS: dict[str, dict[str, str]] = {
|
|
46
|
+
"ru88-fbhk": {
|
|
47
|
+
"kind": "neighborhood",
|
|
48
|
+
"label_column": "name",
|
|
49
|
+
"vintage": "SPD Micro Community Policing Plan Areas, updated 2016-03-03",
|
|
50
|
+
"reliability": "ok",
|
|
51
|
+
},
|
|
52
|
+
"kuhn-3gp2": {
|
|
53
|
+
"kind": "beat",
|
|
54
|
+
"label_column": "name",
|
|
55
|
+
"vintage": "SPD Beats, updated 2015-07-17 (51 beats; current SPD beats differ)",
|
|
56
|
+
"reliability": "partial",
|
|
57
|
+
"reason": "2015 beat boundaries; current SPD beats differ.",
|
|
58
|
+
},
|
|
59
|
+
"q256-3sug": {
|
|
60
|
+
"kind": "zip",
|
|
61
|
+
"label_column": "zcta5ce10",
|
|
62
|
+
"vintage": "2010 ZIP Code Tabulation Areas (national), updated 2015-02-19",
|
|
63
|
+
"reliability": "ok",
|
|
64
|
+
},
|
|
65
|
+
"cyqu-gs94": {
|
|
66
|
+
"kind": "council_district",
|
|
67
|
+
"label_column": "c_district",
|
|
68
|
+
"vintage": "City Council Districts, updated 2015-07-20 (predates 2022 redistricting)",
|
|
69
|
+
"reliability": "unreliable",
|
|
70
|
+
"reason": "Mostly null, only districts 1-3 appear, and the boundaries predate 2022.",
|
|
71
|
+
},
|
|
72
|
+
"2day-rhn5": {
|
|
73
|
+
"kind": "census_tract",
|
|
74
|
+
"label_column": "name10",
|
|
75
|
+
"vintage": "2010 Census Tracts, updated 2015-05-16",
|
|
76
|
+
"reliability": "unreliable",
|
|
77
|
+
"reason": "Mostly null on Seattle datasets; 2010 tracts.",
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#: Text place fields per dataset, with reliability from the 2026-10-15 probe
|
|
82
|
+
#: (docs/upstream-facts.md).
|
|
83
|
+
PLACE_FIELDS: dict[str, dict[str, dict[str, str]]] = {
|
|
84
|
+
"33kz-ixgy": {
|
|
85
|
+
"dispatch_neighborhood": {"kind": "neighborhood", "reliability": "ok"},
|
|
86
|
+
"dispatch_precinct": {"kind": "precinct", "reliability": "ok"},
|
|
87
|
+
"dispatch_sector": {"kind": "sector", "reliability": "ok"},
|
|
88
|
+
"dispatch_beat": {"kind": "beat", "reliability": "ok"},
|
|
89
|
+
},
|
|
90
|
+
"tazs-3rd5": {
|
|
91
|
+
"neighborhood": {
|
|
92
|
+
"kind": "neighborhood",
|
|
93
|
+
"reliability": "partial",
|
|
94
|
+
"reason": "Placeholder ('-') for nearly all offenses before 2017; 1-4% missing since. "
|
|
95
|
+
"Do not use for 2008-2016.",
|
|
96
|
+
},
|
|
97
|
+
"precinct": {"kind": "precinct", "reliability": "ok"},
|
|
98
|
+
"sector": {"kind": "sector", "reliability": "ok"},
|
|
99
|
+
"beat": {"kind": "beat", "reliability": "ok"},
|
|
100
|
+
},
|
|
101
|
+
"ppi5-g2bj": {
|
|
102
|
+
"precinct": {"kind": "precinct", "reliability": "ok"},
|
|
103
|
+
"sector": {"kind": "sector", "reliability": "ok"},
|
|
104
|
+
"beat": {"kind": "beat", "reliability": "ok"},
|
|
105
|
+
},
|
|
106
|
+
"76t5-zqzr": {
|
|
107
|
+
"originalzip": {"kind": "zip", "reliability": "ok"},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#: Timezone evidence per dataset column; absent means unknown, never assumed.
|
|
112
|
+
TIME_EVIDENCE: dict[str, dict[str, dict[str, str]]] = {
|
|
113
|
+
"kzjm-xkqj": {
|
|
114
|
+
"datetime": {
|
|
115
|
+
"timezone": "America/Los_Angeles",
|
|
116
|
+
"evidence": "max(datetime) tracked Pacific wall-clock time when probed 2026-10-15.",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
GEO_TYPES = frozenset({"point", "location"})
|
|
122
|
+
TIME_TYPES = frozenset({"floating_timestamp", "calendar_date", "fixed_timestamp", "date"})
|
|
123
|
+
|
|
124
|
+
#: Upper bound written into download_url; SODA2 accepted it when probed 2026-10-15.
|
|
125
|
+
DOWNLOAD_LIMIT = 1_000_000_000
|
|
126
|
+
URL_MAX_CHARS = 2048
|
|
127
|
+
#: Whole-call deadlines, metadata fetch included; tool descriptions state these.
|
|
128
|
+
QUERY_DEADLINE_SECONDS = 30.0
|
|
129
|
+
DESCRIBE_DEADLINE_SECONDS = 30.0
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Cross-argument rules the JSON Schema cannot express portably (docs/design.md: Primitives).
|
|
2
|
+
|
|
3
|
+
Single-field constraints (types, enums, ranges, patterns, empty strings) live in
|
|
4
|
+
the published inputSchema; only combinations are checked here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from seattle_open_data.constants import QUERY_TEXT_MAX_BYTES
|
|
12
|
+
from seattle_open_data.core import cursor as cursors
|
|
13
|
+
from seattle_open_data.core.errors import ErrorCode, ErrorDetails, Repair, SeattleDataError
|
|
14
|
+
|
|
15
|
+
CLAUSE_ARGS = ("select", "where", "group_by", "having", "order_by", "search_text", "near")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _provided(args: dict[str, Any]) -> list[str]:
|
|
19
|
+
return [name for name, value in args.items() if value is not None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reject(fields: list[str], reason: str, *, tool: str, repair: Repair | None = None) -> None:
|
|
23
|
+
details = (
|
|
24
|
+
ErrorDetails(field=fields[0], reason=reason)
|
|
25
|
+
if len(fields) == 1
|
|
26
|
+
else ErrorDetails(fields=fields, reason=reason)
|
|
27
|
+
)
|
|
28
|
+
raise SeattleDataError(
|
|
29
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
30
|
+
f"Invalid argument combination for {tool}.",
|
|
31
|
+
details=details,
|
|
32
|
+
repair=repair,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_continuation(args: dict[str, Any], *, allowed: set[str], tool: str) -> None:
|
|
37
|
+
if args.get("cursor") is None:
|
|
38
|
+
return
|
|
39
|
+
extra = sorted(set(_provided(args)) - allowed - {"cursor"})
|
|
40
|
+
if extra:
|
|
41
|
+
_reject(
|
|
42
|
+
extra,
|
|
43
|
+
"A cursor continues the original query; send only "
|
|
44
|
+
+ ", ".join(sorted(allowed | {"cursor"}))
|
|
45
|
+
+ ".",
|
|
46
|
+
tool=tool,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_search(args: dict[str, Any]) -> None:
|
|
51
|
+
check_continuation(args, allowed={"detail", "page_size"}, tool="seattle_data_search_datasets")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_query(args: dict[str, Any]) -> None:
|
|
55
|
+
tool = "seattle_data_query_dataset"
|
|
56
|
+
check_continuation(args, allowed={"dataset_id", "detail", "page_size"}, tool=tool)
|
|
57
|
+
if args.get("cursor") is not None:
|
|
58
|
+
return
|
|
59
|
+
mode = args.get("mode") or "clauses"
|
|
60
|
+
if mode == "soql":
|
|
61
|
+
if args.get("soql") is None:
|
|
62
|
+
_reject(["soql"], "mode='soql' requires soql.", tool=tool)
|
|
63
|
+
clauses = [name for name in CLAUSE_ARGS if args.get(name) is not None]
|
|
64
|
+
if clauses:
|
|
65
|
+
_reject(
|
|
66
|
+
clauses,
|
|
67
|
+
"mode='soql' runs soql verbatim; clause arguments are not allowed.",
|
|
68
|
+
tool=tool,
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
if args.get("soql") is not None:
|
|
72
|
+
_reject(["soql"], "soql requires mode='soql'.", tool=tool)
|
|
73
|
+
# Measured as a cursor stores the clauses, so the cap also bounds the cursor (≤ 6 KiB).
|
|
74
|
+
present = {n: _plain(args[n]) for n in CLAUSE_ARGS if args.get(n) is not None}
|
|
75
|
+
if len(cursors.dump(present)) > QUERY_TEXT_MAX_BYTES:
|
|
76
|
+
_reject(
|
|
77
|
+
list(present),
|
|
78
|
+
f"Clause arguments exceed {QUERY_TEXT_MAX_BYTES} bytes in total, measured as JSON.",
|
|
79
|
+
tool=tool,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_reference(args: dict[str, Any]) -> None:
|
|
84
|
+
tool = "seattle_data_get_reference"
|
|
85
|
+
topic = args.get("topic") or "index"
|
|
86
|
+
if args.get("item_id") is not None and topic in ("index", "capabilities"):
|
|
87
|
+
_reject(
|
|
88
|
+
["topic", "item_id"],
|
|
89
|
+
f"Topic '{topic}' has no items; item_id needs soql, errors, or guides.",
|
|
90
|
+
tool=tool,
|
|
91
|
+
repair=Repair(next_step="list_topics", tool=tool, arguments={}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _plain(value: Any) -> Any:
|
|
96
|
+
return value.model_dump() if hasattr(value, "model_dump") else value
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Catalog search over the Socrata Discovery API (docs/design.md: tool 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from seattle_open_data.constants import (
|
|
9
|
+
CATALOG_DOMAIN,
|
|
10
|
+
DISCOVERY_API,
|
|
11
|
+
PUBLIC_HOST,
|
|
12
|
+
)
|
|
13
|
+
from seattle_open_data.core import cursor as cursors
|
|
14
|
+
from seattle_open_data.core.context import make_source
|
|
15
|
+
from seattle_open_data.core.errors import Repair
|
|
16
|
+
from seattle_open_data.core.guides import BY_DATASET
|
|
17
|
+
from seattle_open_data.core.http import shape_error
|
|
18
|
+
from seattle_open_data.core.models import DatasetSummary, Detail, Queryability, SearchResult
|
|
19
|
+
from seattle_open_data.core.reference import topic_uri
|
|
20
|
+
from seattle_open_data.core.text import clean, html_to_text
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from seattle_open_data.core.http import SodaClient
|
|
24
|
+
|
|
25
|
+
AssetScope = Literal["tabular", "all"]
|
|
26
|
+
|
|
27
|
+
_SUPPORTED = frozenset({"dataset", "filter"})
|
|
28
|
+
_EXTERNAL = frozenset({"federated_href", "href"})
|
|
29
|
+
#: Views that render a parent dataset; search folds them into that parent.
|
|
30
|
+
_FOLDED = frozenset({"map", "chart", "visualization", "calendar", "form"})
|
|
31
|
+
|
|
32
|
+
_DEFAULT_PAGE_SIZE = 10
|
|
33
|
+
#: Bounds cursor size; a duplicate can reappear only after this many results.
|
|
34
|
+
_SEEN_CAP = 200
|
|
35
|
+
_RESTART = Repair(next_step="restart_search", tool="seattle_data_search_datasets", arguments={})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _catalog_body(body: Any) -> dict[str, Any]:
|
|
39
|
+
"""An unexpected catalog shape must not read as "no datasets match"."""
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(body, dict)
|
|
42
|
+
or not isinstance(body.get("results"), list)
|
|
43
|
+
or not str(body.get("resultSetSize", "")).isdigit()
|
|
44
|
+
):
|
|
45
|
+
raise shape_error("Expected a Discovery API result object.")
|
|
46
|
+
return body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def queryability(asset_type: str) -> Queryability:
|
|
50
|
+
if asset_type in _SUPPORTED:
|
|
51
|
+
return "supported"
|
|
52
|
+
if asset_type in _EXTERNAL:
|
|
53
|
+
return "external"
|
|
54
|
+
if asset_type in {"file", "story", "measure"}:
|
|
55
|
+
return "unsupported"
|
|
56
|
+
return "unknown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def public_url(url: str | None) -> str | None:
|
|
60
|
+
"""Catalog links can use the alias host; agents should see data.seattle.gov."""
|
|
61
|
+
if not url:
|
|
62
|
+
return None
|
|
63
|
+
parts = urlsplit(url)
|
|
64
|
+
if parts.netloc == CATALOG_DOMAIN:
|
|
65
|
+
parts = parts._replace(netloc=PUBLIC_HOST)
|
|
66
|
+
return urlunsplit(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def search(
|
|
70
|
+
client: SodaClient,
|
|
71
|
+
*,
|
|
72
|
+
query: str | None,
|
|
73
|
+
asset_scope: AssetScope,
|
|
74
|
+
category: str | None,
|
|
75
|
+
page_size: int | None,
|
|
76
|
+
cursor: str | None,
|
|
77
|
+
detail: Detail,
|
|
78
|
+
) -> SearchResult:
|
|
79
|
+
if cursor is not None:
|
|
80
|
+
state = cursors.decode(cursor, "search", restart=_RESTART)
|
|
81
|
+
query, asset_scope, category = state["q"], state["scope"], state["cat"]
|
|
82
|
+
offset = int(state["off"])
|
|
83
|
+
page_size = page_size or int(state.get("ps", _DEFAULT_PAGE_SIZE))
|
|
84
|
+
earlier = [str(i) for i in state.get("seen", [])]
|
|
85
|
+
else:
|
|
86
|
+
offset = 0
|
|
87
|
+
earlier = []
|
|
88
|
+
page_size = page_size or _DEFAULT_PAGE_SIZE
|
|
89
|
+
|
|
90
|
+
params: dict[str, Any] = {
|
|
91
|
+
"domains": CATALOG_DOMAIN,
|
|
92
|
+
"search_context": PUBLIC_HOST,
|
|
93
|
+
"limit": page_size,
|
|
94
|
+
"offset": offset,
|
|
95
|
+
}
|
|
96
|
+
if query:
|
|
97
|
+
params["q"] = query
|
|
98
|
+
else:
|
|
99
|
+
params["order"] = "page_views_total"
|
|
100
|
+
if category:
|
|
101
|
+
params["categories"] = category
|
|
102
|
+
if asset_scope == "tabular":
|
|
103
|
+
# Maps and charts are requested only to be folded into their parent datasets.
|
|
104
|
+
params["only"] = "dataset,filter,map,chart"
|
|
105
|
+
|
|
106
|
+
body = _catalog_body(await client.get_json(DISCOVERY_API, params))
|
|
107
|
+
results = body["results"]
|
|
108
|
+
total = int(body["resultSetSize"])
|
|
109
|
+
|
|
110
|
+
datasets = await _fold(client, results, detail, already=set(earlier))
|
|
111
|
+
next_offset = offset + len(results)
|
|
112
|
+
has_more = bool(results) and next_offset < total
|
|
113
|
+
next_cursor = (
|
|
114
|
+
cursors.encode(
|
|
115
|
+
"search",
|
|
116
|
+
{
|
|
117
|
+
"q": query,
|
|
118
|
+
"scope": asset_scope,
|
|
119
|
+
"cat": category,
|
|
120
|
+
"off": next_offset,
|
|
121
|
+
"ps": page_size,
|
|
122
|
+
# Folded map views can repeat a dataset from an earlier page; remember recent ids.
|
|
123
|
+
"seen": [*earlier, *(d.dataset_id for d in datasets)][-_SEEN_CAP:],
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
if has_more
|
|
127
|
+
else None
|
|
128
|
+
)
|
|
129
|
+
return SearchResult(
|
|
130
|
+
datasets=datasets,
|
|
131
|
+
has_more=has_more,
|
|
132
|
+
next_cursor=next_cursor,
|
|
133
|
+
source=make_source(retrieved=True),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _access_point(item: dict[str, Any]) -> str | None:
|
|
138
|
+
points = (item.get("metadata") or {}).get("access_points") or {}
|
|
139
|
+
url = next((str(u) for u in points.values() if str(u).startswith("https://")), None)
|
|
140
|
+
return url or public_url(item.get("link"))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _fold(
|
|
144
|
+
client: SodaClient, results: list[Any], detail: Detail, *, already: set[str]
|
|
145
|
+
) -> list[DatasetSummary]:
|
|
146
|
+
"""Replace each map or chart match with its parent dataset, once, at its first rank.
|
|
147
|
+
|
|
148
|
+
`already` holds datasets emitted on earlier pages, which are not repeated.
|
|
149
|
+
"""
|
|
150
|
+
slots: list[DatasetSummary | str] = []
|
|
151
|
+
for item in results:
|
|
152
|
+
resource = item.get("resource") or {}
|
|
153
|
+
if str(resource.get("type")) in _FOLDED:
|
|
154
|
+
parents = resource.get("parent_fxf") or []
|
|
155
|
+
if parents:
|
|
156
|
+
slots.append(str(parents[0]))
|
|
157
|
+
else:
|
|
158
|
+
slots.append(_summarize(item, detail))
|
|
159
|
+
# Seeding with this page's summaries lets a map ranked above its own parent keep that rank.
|
|
160
|
+
parents: dict[str, DatasetSummary] = {
|
|
161
|
+
s.dataset_id: s for s in slots if isinstance(s, DatasetSummary)
|
|
162
|
+
}
|
|
163
|
+
missing = sorted(
|
|
164
|
+
{s for s in slots if isinstance(s, str) and s not in parents and s not in already}
|
|
165
|
+
)
|
|
166
|
+
if missing:
|
|
167
|
+
body = await client.get_json(
|
|
168
|
+
DISCOVERY_API,
|
|
169
|
+
{
|
|
170
|
+
"domains": CATALOG_DOMAIN,
|
|
171
|
+
"search_context": PUBLIC_HOST,
|
|
172
|
+
"ids": missing,
|
|
173
|
+
"limit": len(missing),
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
for item in _catalog_body(body)["results"]:
|
|
177
|
+
summary = _summarize(item, detail)
|
|
178
|
+
parents[summary.dataset_id] = summary
|
|
179
|
+
out: list[DatasetSummary] = []
|
|
180
|
+
seen = set(already)
|
|
181
|
+
for slot in slots:
|
|
182
|
+
summary = parents.get(slot) if isinstance(slot, str) else slot
|
|
183
|
+
if summary is not None and summary.dataset_id not in seen:
|
|
184
|
+
seen.add(summary.dataset_id)
|
|
185
|
+
out.append(summary)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _guide_uri(dataset_id: str, parents: list[str]) -> str | None:
|
|
190
|
+
guide = BY_DATASET.get(dataset_id) or next(
|
|
191
|
+
(BY_DATASET[p] for p in parents if p in BY_DATASET), None
|
|
192
|
+
)
|
|
193
|
+
return topic_uri("guides", guide.guide_id) if guide else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _summarize(item: dict[str, Any], detail: Detail) -> DatasetSummary:
|
|
197
|
+
resource = item.get("resource") or {}
|
|
198
|
+
asset_type = str(resource.get("type") or "unknown")
|
|
199
|
+
dataset_id = str(resource.get("id", ""))
|
|
200
|
+
reach = queryability(asset_type)
|
|
201
|
+
parents = resource.get("parent_fxf") or []
|
|
202
|
+
classification = item.get("classification") or {}
|
|
203
|
+
return DatasetSummary(
|
|
204
|
+
dataset_id=dataset_id,
|
|
205
|
+
title=clean(resource.get("name"), limit=200) or dataset_id,
|
|
206
|
+
asset_type=asset_type,
|
|
207
|
+
queryability=reach,
|
|
208
|
+
portal_url=public_url(item.get("permalink")) or f"https://{PUBLIC_HOST}/d/{dataset_id}",
|
|
209
|
+
updated_at=resource.get("data_updated_at") or resource.get("updatedAt"),
|
|
210
|
+
derived_from=parents[0] if asset_type == "filter" and parents else None,
|
|
211
|
+
guide_uri=_guide_uri(dataset_id, parents),
|
|
212
|
+
external_url=_access_point(item) if reach == "external" else None,
|
|
213
|
+
category=clean(classification.get("domain_category")),
|
|
214
|
+
description=html_to_text(resource.get("description"), limit=1000)
|
|
215
|
+
if detail == "full"
|
|
216
|
+
else None,
|
|
217
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Per-call provenance: request id and Seattle wall-clock time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from seattle_open_data.constants import SEATTLE_TZ
|
|
10
|
+
from seattle_open_data.core.models import Source
|
|
11
|
+
|
|
12
|
+
_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def new_request_id() -> str:
|
|
16
|
+
request_id = uuid.uuid4().hex[:16]
|
|
17
|
+
_request_id.set(request_id)
|
|
18
|
+
return request_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_request_id() -> str:
|
|
22
|
+
return _request_id.get() or new_request_id()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_source(*, retrieved: bool = False) -> Source:
|
|
26
|
+
now = datetime.now(UTC)
|
|
27
|
+
return Source(
|
|
28
|
+
request_id=current_request_id(),
|
|
29
|
+
now_local=now.astimezone(SEATTLE_TZ).isoformat(timespec="seconds"),
|
|
30
|
+
retrieved_at=now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
31
|
+
if retrieved
|
|
32
|
+
else None,
|
|
33
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Stateless, HMAC-signed cursors (docs/design.md: Query engine).
|
|
2
|
+
|
|
3
|
+
The key lives in process memory only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from seattle_open_data.constants import CURSOR_TTL_SECONDS
|
|
17
|
+
from seattle_open_data.core.errors import ErrorCode, ErrorDetails, Repair, SeattleDataError
|
|
18
|
+
|
|
19
|
+
_KEY = secrets.token_bytes(32)
|
|
20
|
+
_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _b64(data: bytes) -> str:
|
|
24
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unb64(text: str) -> bytes:
|
|
28
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dump(data: Any) -> bytes:
|
|
32
|
+
"""Compact UTF-8 JSON; non-ASCII stays raw so it costs its UTF-8 size, not `\\uXXXX`."""
|
|
33
|
+
return json.dumps(data, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode(kind: str, state: dict[str, Any]) -> str:
|
|
37
|
+
payload = dump({"v": _VERSION, "k": kind, "t": int(time.time()), "s": state})
|
|
38
|
+
signature = hmac.new(_KEY, payload, hashlib.sha256).digest()[:16]
|
|
39
|
+
return f"{_b64(payload)}.{_b64(signature)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode(cursor: str, kind: str, *, restart: Repair) -> dict[str, Any]:
|
|
43
|
+
"""Verify and unpack.
|
|
44
|
+
|
|
45
|
+
A bad signature is `cursor_expired`: a server restart rotates the in-memory key.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
payload_part, signature_part = cursor.split(".", 1)
|
|
49
|
+
payload = _unb64(payload_part)
|
|
50
|
+
signature = _unb64(signature_part)
|
|
51
|
+
data = json.loads(payload)
|
|
52
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
53
|
+
raise _error(
|
|
54
|
+
ErrorCode.INVALID_CURSOR,
|
|
55
|
+
"The cursor is malformed.",
|
|
56
|
+
"It could not be decoded.",
|
|
57
|
+
restart,
|
|
58
|
+
) from exc
|
|
59
|
+
expected = hmac.new(_KEY, payload, hashlib.sha256).digest()[:16]
|
|
60
|
+
if not hmac.compare_digest(signature, expected):
|
|
61
|
+
raise _error(
|
|
62
|
+
ErrorCode.CURSOR_EXPIRED,
|
|
63
|
+
"The cursor has expired or the server restarted.",
|
|
64
|
+
"Its signature does not match: cursors do not survive a server restart.",
|
|
65
|
+
restart,
|
|
66
|
+
)
|
|
67
|
+
if data.get("v") != _VERSION:
|
|
68
|
+
raise _error(
|
|
69
|
+
ErrorCode.INVALID_CURSOR,
|
|
70
|
+
"The cursor uses an unsupported format.",
|
|
71
|
+
"It was issued by a different server version.",
|
|
72
|
+
restart,
|
|
73
|
+
)
|
|
74
|
+
if data.get("k") != kind:
|
|
75
|
+
raise _error(
|
|
76
|
+
ErrorCode.INVALID_CURSOR,
|
|
77
|
+
"The cursor belongs to a different tool.",
|
|
78
|
+
"Use a cursor only with the tool that returned it.",
|
|
79
|
+
restart,
|
|
80
|
+
)
|
|
81
|
+
if time.time() - int(data.get("t", 0)) > CURSOR_TTL_SECONDS:
|
|
82
|
+
raise _error(
|
|
83
|
+
ErrorCode.CURSOR_EXPIRED, "The cursor has expired.", "Cursors last 1 hour.", restart
|
|
84
|
+
)
|
|
85
|
+
return data["s"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _error(code: ErrorCode, message: str, reason: str, restart: Repair) -> SeattleDataError:
|
|
89
|
+
return SeattleDataError(
|
|
90
|
+
code, message, details=ErrorDetails(field="cursor", reason=reason), repair=restart
|
|
91
|
+
)
|