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.
@@ -0,0 +1,5 @@
1
+ """Agent-friendly MCP server for Seattle Open Data."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("seattle-open-data")
@@ -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
+ )