-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy patharcgis_hub.py
More file actions
299 lines (241 loc) · 10.5 KB
/
Copy patharcgis_hub.py
File metadata and controls
299 lines (241 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
"""Reusable ArcGIS Hub Search API + FeatureServer client.
Used by any Canadian municipal open data module that publishes through ArcGIS Hub
(e.g., york_region, future BC modules). See 14-RESEARCH.md for API reference and
verified endpoints.
Public functions:
search_hub_datasets(portal_base_url, query, limit, offset) -> dict
query_feature_service(service_url, layer_id, where, out_fields, include_geometry, max_records) -> (list[dict], bool)
get_layer_metadata(service_url, layer_id) -> dict
get_count(service_url, layer_id, where) -> int
shape_hub_dataset(feature) -> dict
"""
from __future__ import annotations
from typing import Any
import httpx
from mcp_canada.shared.http import decode_json, decode_json_bytes
from mcp_canada.shared.parsers import _parse_geojson
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_RECORDS = 5000 # cap per tool call — prevents runaway pagination
DEFAULT_PAGE_SIZE = 1000 # safe default; actual maxRecordCount varies 1000-2000
HUB_SEARCH_PATH = "/api/search/v1/collections/all/items"
DEFAULT_TIMEOUT = 30.0
MAX_DESCRIPTION_CHARS = 500
# ---------------------------------------------------------------------------
# Public functions
# ---------------------------------------------------------------------------
async def search_hub_datasets(
portal_base_url: str | None,
query: str = "",
limit: int = 10,
offset: int = 0,
*,
httpx_client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""Search an ArcGIS Hub portal for datasets matching the given query.
Args:
portal_base_url: Base URL of the ArcGIS Hub portal (e.g.,
"https://insights-york.opendata.arcgis.com"). Pass None for
municipalities that have no public portal — raises ValueError.
query: Free-text search query. Empty string returns all items.
limit: Maximum number of results to return (default: 10).
offset: Pagination offset (default: 0). Omitted from request if 0.
httpx_client: Optional pre-built AsyncClient for dependency injection
(mainly for tests). Defaults to creating a new client per call.
Returns:
Raw JSON dict from the Hub Search API, containing:
{type, numberMatched, numberReturned, features: [...], links: [...]}
Raises:
ValueError: If portal_base_url is None (municipality has no public portal).
httpx.HTTPStatusError: On 4xx/5xx responses.
"""
if portal_base_url is None:
raise ValueError("portal has no public ArcGIS Hub open data portal")
url = portal_base_url.rstrip("/") + HUB_SEARCH_PATH
params: dict[str, Any] = {"limit": limit}
if query and query.strip():
params["q"] = query # empty q is rejected with HTTP 400 by every Hub portal, so omit it
if offset > 0:
params["startindex"] = offset # OGC API Records pagination (NOT offset); startindex=0 is invalid so omit at 0
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
return decode_json(response, url)
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
return decode_json(response, url)
async def query_feature_service(
service_url: str,
layer_id: int,
where: str | None = "1=1",
out_fields: str = "*",
include_geometry: bool = False,
max_records: int = MAX_RECORDS,
*,
httpx_client: httpx.AsyncClient | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""Query an ArcGIS FeatureServer layer and return all matching features.
Automatically paginates using resultOffset/resultRecordCount until all
records are retrieved or the max_records cap is reached.
Args:
service_url: FeatureServer base URL (without layer id).
layer_id: Layer/table index (0-based).
where: SQL-92 WHERE clause (default: "1=1" for all records).
out_fields: Comma-separated field names or "*" for all.
include_geometry: If True, include GeoJSON geometry in each dict.
max_records: Maximum total records to return (default: 5000).
httpx_client: Optional pre-built AsyncClient for dependency injection.
Returns:
(features, truncated) where features is a list of property dicts and
truncated is True if the max_records cap was hit with more data available.
Raises:
httpx.HTTPStatusError: On 4xx/5xx responses.
"""
query_url = f"{service_url.rstrip('/')}/{layer_id}/query"
all_features: list[dict[str, Any]] = []
truncated = False
offset = 0
_client_to_use = httpx_client
async def _fetch_page(client: httpx.AsyncClient) -> bytes:
page_size = min(DEFAULT_PAGE_SIZE, max_records - offset)
params: dict[str, Any] = {
# httpx drops None-valued params; ArcGIS /query rejects a request
# with no `where`, which surfaces as a bogus UPSTREAM_ERROR.
"where": where or "1=1",
"outFields": out_fields,
"f": "geojson",
"resultOffset": offset,
"resultRecordCount": page_size,
}
if not include_geometry:
params["returnGeometry"] = "false"
response = await client.get(query_url, params=params)
response.raise_for_status()
return response.content
async def _run_pagination(client: httpx.AsyncClient) -> None:
nonlocal offset, truncated
while offset < max_records:
raw_content = await _fetch_page(client)
raw_json = _parse_raw_json(raw_content)
batch = _parse_geojson(raw_content, include_geometry=include_geometry)
all_features.extend(batch)
exceeded = raw_json.get("exceededTransferLimit", False)
if not exceeded:
break
offset += len(batch)
if offset >= max_records:
truncated = True
break
if _client_to_use is not None:
await _run_pagination(_client_to_use)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
await _run_pagination(client)
return all_features, truncated
async def get_layer_metadata(
service_url: str,
layer_id: int,
*,
httpx_client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""Fetch metadata for a specific FeatureServer layer.
Args:
service_url: FeatureServer base URL (without layer id).
layer_id: Layer/table index (0-based).
httpx_client: Optional pre-built AsyncClient for dependency injection.
Returns:
Dict with keys: max_record_count (int), fields (list of {name, type}),
geometry_type (str | None), name (str).
Raises:
httpx.HTTPStatusError: On 4xx/5xx responses.
"""
url = f"{service_url.rstrip('/')}/{layer_id}"
params = {"f": "json"}
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
data = decode_json(response, url)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = decode_json(response, url)
return {
"max_record_count": int(data.get("maxRecordCount", DEFAULT_PAGE_SIZE)),
"fields": [
{"name": f.get("name"), "type": f.get("type")}
for f in data.get("fields", [])
],
"geometry_type": data.get("geometryType"),
"name": data.get("name", ""),
}
async def get_count(
service_url: str,
layer_id: int,
where: str | None = "1=1",
*,
httpx_client: httpx.AsyncClient | None = None,
) -> int:
"""Get the total record count for a FeatureServer layer matching a WHERE clause.
Args:
service_url: FeatureServer base URL (without layer id).
layer_id: Layer/table index (0-based).
where: SQL-92 WHERE clause (default: "1=1" for all records).
httpx_client: Optional pre-built AsyncClient for dependency injection.
Returns:
Integer count of matching records.
Raises:
httpx.HTTPStatusError: On 4xx/5xx responses.
"""
url = f"{service_url.rstrip('/')}/{layer_id}/query"
params: dict[str, Any] = {
# See query_feature_service: a None `where` would be dropped by httpx.
"where": where or "1=1",
"returnCountOnly": "true",
"f": "json",
}
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
data = decode_json(response, url)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = decode_json(response, url)
return int(data.get("count", 0))
def shape_hub_dataset(feature: dict[str, Any]) -> dict[str, Any]:
"""Flatten a single Hub Search features[i] entry to a flat dict.
Args:
feature: A single item from the Hub Search API features array.
Returns:
Flat dict with keys: id, title, type, description, url, owner,
tags, categories, created, modified.
"""
props = feature.get("properties") or {}
description = props.get("description")
if description and len(description) > MAX_DESCRIPTION_CHARS:
description = description[:MAX_DESCRIPTION_CHARS] + "..."
return {
"id": feature.get("id"),
"title": props.get("title", ""),
"type": props.get("type"),
"description": description,
"url": props.get("url"),
"owner": props.get("owner"),
"tags": props.get("tags") or [],
"categories": props.get("categories") or [],
"created": props.get("created"),
"modified": props.get("modified"),
}
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _parse_raw_json(content: bytes) -> dict[str, Any]:
"""Parse raw bytes as JSON and return the dict (for checking vendor extensions).
Decodes via the shared helper so a malformed body raises httpx.DecodingError
rather than a ValueError subclass — see shared/http.py:decode_json.
"""
return decode_json_bytes(content)