mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-29 05:21:48 +00:00
With ruff target-version py310 and ty python-version 3.10, both tools reject an unconditional `import tomllib`: ty reports it as unresolved (exit 1) and ruff's isort classifies it as third-party. Raise the targets in the projects that now import tomllib directly, and in the root ruff.toml that governs dev_install.py. - ruff target-version = "py311" and ty python-version = "3.11" in cli, core, mcp_server, platform_api, and cookiecutter; root ruff.toml target-version = "py311" - UP042: OutputMode, ChartLifecycle, MCPType, and HTTPMethod become StrEnum; every consumer uses .value or == comparisons, so only str() and format() output change - UP017: datetime.timezone.utc -> datetime.UTC (ruff autofix) in the cli dispatchers, the cli test generator, and a core test
1074 lines
34 KiB
Python
1074 lines
34 KiB
Python
"""Generate a ``.spec`` file from a Socrata story JSON."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
import json
|
||
import re
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
_DISCOVERY_MAX_WORKERS = 20
|
||
|
||
from openbb_cli.dispatchers.spec import SPEC_VERSION, _generator_identifier
|
||
|
||
_CACHED_TOP_CAP = 20
|
||
|
||
_MAX_RESOLVED_CHOICES = 1000
|
||
|
||
_NUMERIC_CHOICE_CAP = 50
|
||
|
||
_NUMERIC_TYPES: frozenset[str] = frozenset({"number", "money", "percent", "double"})
|
||
|
||
|
||
def _coerce_choices_to_type(values: list[Any], data_type: str) -> list[Any]:
|
||
"""Cast distinct-value strings to match the column's declared type."""
|
||
if data_type in _NUMERIC_TYPES:
|
||
coerced: list[Any] = []
|
||
for v in values:
|
||
try:
|
||
num = float(v)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if num.is_integer():
|
||
coerced.append(int(num))
|
||
else:
|
||
coerced.append(num)
|
||
return coerced
|
||
if data_type == "checkbox":
|
||
coerced_bool: list[Any] = []
|
||
for v in values:
|
||
if v is None:
|
||
continue
|
||
if isinstance(v, bool):
|
||
coerced_bool.append(v)
|
||
elif isinstance(v, str) and v.lower() in {"true", "false"}:
|
||
coerced_bool.append(v.lower() == "true")
|
||
return coerced_bool
|
||
return values
|
||
|
||
|
||
def _cached_top_values(column: dict[str, Any]) -> list[Any]:
|
||
"""Pull the items out of a column's ``cachedContents.top`` list."""
|
||
cached = column.get("cachedContents") or {}
|
||
top = cached.get("top") or []
|
||
values: list[Any] = []
|
||
for entry in top:
|
||
if isinstance(entry, dict):
|
||
item = entry.get("item")
|
||
if item is not None:
|
||
values.append(item)
|
||
elif entry is not None:
|
||
values.append(entry)
|
||
return values
|
||
|
||
|
||
def _is_categorical_column(column: dict[str, Any]) -> bool:
|
||
"""Return True when querying for distinct values is worthwhile."""
|
||
return (column.get("dataTypeName") or "text") in ("text", "html")
|
||
|
||
|
||
async def afetch_column_distinct_values(
|
||
client: httpx.AsyncClient,
|
||
host: str,
|
||
uid: str,
|
||
field_name: str,
|
||
*,
|
||
limit: int = _MAX_RESOLVED_CHOICES,
|
||
) -> list[Any] | None:
|
||
"""Resolve a column's full distinct-value set via SoQL (async)."""
|
||
query = urllib.parse.urlencode(
|
||
{
|
||
"$select": field_name,
|
||
"$group": field_name,
|
||
"$order": field_name,
|
||
"$limit": str(limit + 1),
|
||
}
|
||
)
|
||
url = f"{host.rstrip('/')}/resource/{uid}.json?{query}"
|
||
try:
|
||
response = await client.get(url)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
except (httpx.HTTPError, ValueError, OSError):
|
||
return None
|
||
if not isinstance(payload, list) or len(payload) > limit:
|
||
return None
|
||
out: list[Any] = []
|
||
for row in payload:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
value = row.get(field_name)
|
||
if value is not None:
|
||
out.append(value)
|
||
return out
|
||
|
||
|
||
def _column_choices(
|
||
column: dict[str, Any],
|
||
*,
|
||
host: str | None = None,
|
||
uid: str | None = None,
|
||
resolver: Any = None,
|
||
choices_cache: dict[tuple[str, str], list[Any] | None] | None = None,
|
||
) -> list[Any]:
|
||
"""Return the closed ``choices`` set for a column, or ``[]`` if unbounded."""
|
||
field_name = column.get("fieldName") or column.get("name")
|
||
if (
|
||
choices_cache is not None
|
||
and host
|
||
and uid
|
||
and field_name
|
||
and (uid, field_name) in choices_cache
|
||
):
|
||
cached = choices_cache[(uid, field_name)]
|
||
if cached is None:
|
||
return []
|
||
type_name = column.get("dataTypeName") or ""
|
||
return _coerce_choices_to_type(cached, type_name)
|
||
if not host or not uid:
|
||
return _cached_top_values(column)
|
||
if not field_name:
|
||
return []
|
||
if resolver is None:
|
||
return _cached_top_values(column)
|
||
fetch = resolver
|
||
type_name = column.get("dataTypeName") or ""
|
||
cap = _NUMERIC_CHOICE_CAP if type_name in _NUMERIC_TYPES else _MAX_RESOLVED_CHOICES
|
||
resolved = fetch(host, uid, field_name, limit=cap)
|
||
if resolved is not None:
|
||
return _coerce_choices_to_type(resolved, type_name)
|
||
cached = column.get("cachedContents") or {}
|
||
if 0 < len(cached.get("top") or []) < _CACHED_TOP_CAP:
|
||
return _cached_top_values(column)
|
||
return []
|
||
|
||
|
||
def _column_help(column: dict[str, Any]) -> str:
|
||
"""Compose the per-column help text shown under ``--<col>=<value>``."""
|
||
parts: list[str] = []
|
||
description = column.get("description")
|
||
if description:
|
||
parts.append(description.strip().rstrip(".") + ".")
|
||
cached = column.get("cachedContents") or {}
|
||
top = cached.get("top") or []
|
||
if top and len(top) >= _CACHED_TOP_CAP:
|
||
sample = []
|
||
for entry in top[:5]:
|
||
item = entry.get("item") if isinstance(entry, dict) else entry
|
||
if item is not None:
|
||
sample.append(str(item))
|
||
if sample:
|
||
parts.append(f"Examples: {', '.join(sample)}.")
|
||
smallest = cached.get("smallest")
|
||
largest = cached.get("largest")
|
||
if smallest is not None and largest is not None and len(top) >= _CACHED_TOP_CAP:
|
||
parts.append(f"Range: {smallest} – {largest}.")
|
||
return " ".join(parts)
|
||
|
||
|
||
def _column_param_type(column: dict[str, Any]) -> str:
|
||
"""Map a Socrata ``dataTypeName`` to a spec ``type`` value."""
|
||
type_name = column.get("dataTypeName") or "text"
|
||
if type_name in ("number", "money", "double", "percent"):
|
||
return "number"
|
||
if type_name == "checkbox":
|
||
return "boolean"
|
||
return "string"
|
||
|
||
|
||
_DATE_COLUMN_TYPES: frozenset[str] = frozenset({"calendar_date", "date"})
|
||
|
||
_TIME_AXIS_NAME_PATTERNS: tuple[str, ...] = (
|
||
"month_year",
|
||
"year_month",
|
||
"yearmonth",
|
||
"monthyear",
|
||
"as_of_date",
|
||
"as_of",
|
||
"asof",
|
||
"observation_date",
|
||
"report_date",
|
||
"release_date",
|
||
"effective_date",
|
||
"date",
|
||
"datetime",
|
||
"timestamp",
|
||
"time",
|
||
"year",
|
||
"quarter",
|
||
"month",
|
||
"week",
|
||
"period",
|
||
)
|
||
|
||
|
||
def _is_date_column(column: dict[str, Any]) -> bool:
|
||
"""Return True when the column's data type calls for range-style filtering."""
|
||
return (column.get("dataTypeName") or "") in _DATE_COLUMN_TYPES
|
||
|
||
|
||
def _is_time_axis_named(field_name: str) -> bool:
|
||
"""Return True when the column's name suggests it carries a time value."""
|
||
name = (field_name or "").lower()
|
||
return name in _TIME_AXIS_NAME_PATTERNS
|
||
|
||
|
||
def _detect_time_axis_column(meta: dict[str, Any]) -> str | None:
|
||
"""Pick the column to sort by descending for most-recent semantics."""
|
||
for col in meta.get("columns") or []:
|
||
if not isinstance(col, dict):
|
||
continue
|
||
if _is_date_column(col):
|
||
field_name = col.get("fieldName") or col.get("name")
|
||
if field_name:
|
||
return field_name
|
||
candidates: list[str] = []
|
||
for col in meta.get("columns") or []:
|
||
if not isinstance(col, dict):
|
||
continue
|
||
field_name = col.get("fieldName") or col.get("name")
|
||
if field_name and _is_time_axis_named(field_name):
|
||
candidates.append(field_name)
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=len, reverse=True)
|
||
return candidates[0]
|
||
|
||
|
||
def _to_plain_date(value: Any) -> Any:
|
||
"""Trim the time component off a Socrata datetime string."""
|
||
if not isinstance(value, str):
|
||
return value
|
||
if "T" in value and len(value) >= 10 and value[4] == "-" and value[7] == "-":
|
||
return value.split("T", 1)[0]
|
||
return value
|
||
|
||
|
||
def _date_range_parameters(column: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""Emit a uniform ``start_date`` / ``end_date`` pair for a date column."""
|
||
field_name = column.get("fieldName") or column.get("name")
|
||
cached = column.get("cachedContents") or {}
|
||
smallest = _to_plain_date(cached.get("smallest"))
|
||
largest = _to_plain_date(cached.get("largest"))
|
||
description = column.get("description") or ""
|
||
base_help = description.strip().rstrip(".")
|
||
range_text = (
|
||
f" Dataset spans {smallest} to {largest}." if smallest and largest else ""
|
||
)
|
||
return [
|
||
{
|
||
"name": "start_date",
|
||
"in": "query",
|
||
"type": "string",
|
||
"is_list": False,
|
||
"required": False,
|
||
"default": None,
|
||
"choices": [],
|
||
"example": smallest,
|
||
"help": (
|
||
f"Earliest date (YYYY-MM-DD) to include — filters on ``{field_name}``."
|
||
f" Omit to fetch from the dataset's earliest record."
|
||
f"{(' ' + base_help + '.') if base_help else ''}"
|
||
f"{range_text}"
|
||
).strip(),
|
||
"providers": [],
|
||
"_socrata_op": "date_min",
|
||
"_socrata_column": field_name,
|
||
},
|
||
{
|
||
"name": "end_date",
|
||
"in": "query",
|
||
"type": "string",
|
||
"is_list": False,
|
||
"required": False,
|
||
"default": None,
|
||
"choices": [],
|
||
"example": largest,
|
||
"help": (
|
||
f"Latest date (YYYY-MM-DD) to include — filters on ``{field_name}``."
|
||
f" Omit to fetch through the dataset's latest record."
|
||
f"{(' ' + base_help + '.') if base_help else ''}"
|
||
f"{range_text}"
|
||
).strip(),
|
||
"providers": [],
|
||
"_socrata_op": "date_max",
|
||
"_socrata_column": field_name,
|
||
},
|
||
]
|
||
|
||
|
||
def _build_query_parameters(
|
||
meta: dict[str, Any],
|
||
*,
|
||
host: str | None = None,
|
||
uid: str | None = None,
|
||
resolver: Any = None,
|
||
choices_cache: dict[tuple[str, str], list[Any] | None] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Build the per-dataset query parameter list."""
|
||
parameters: list[dict[str, Any]] = []
|
||
date_range_emitted = False
|
||
for column in meta.get("columns") or []:
|
||
if not isinstance(column, dict):
|
||
continue
|
||
field_name = column.get("fieldName") or column.get("name")
|
||
if not field_name:
|
||
continue
|
||
if _is_date_column(column):
|
||
if not date_range_emitted:
|
||
parameters.extend(_date_range_parameters(column))
|
||
date_range_emitted = True
|
||
continue
|
||
parameters.append(
|
||
{
|
||
"name": field_name,
|
||
"in": "query",
|
||
"type": _column_param_type(column),
|
||
"is_list": False,
|
||
"required": False,
|
||
"default": None,
|
||
"choices": _column_choices(
|
||
column,
|
||
host=host,
|
||
uid=uid,
|
||
resolver=resolver,
|
||
choices_cache=choices_cache,
|
||
),
|
||
"example": None,
|
||
"help": _column_help(column),
|
||
"providers": [],
|
||
}
|
||
)
|
||
parameters.extend(
|
||
[
|
||
{
|
||
"name": "limit",
|
||
"in": "query",
|
||
"type": "integer",
|
||
"is_list": False,
|
||
"required": False,
|
||
"default": 1000,
|
||
"choices": [],
|
||
"example": None,
|
||
"help": (
|
||
"Max rows returned. Socrata's API default is 1000; up to "
|
||
"50000 per request without pagination."
|
||
),
|
||
"providers": [],
|
||
"wire_name": "$limit",
|
||
},
|
||
{
|
||
"name": "offset",
|
||
"in": "query",
|
||
"type": "integer",
|
||
"is_list": False,
|
||
"required": False,
|
||
"default": None,
|
||
"choices": [],
|
||
"example": None,
|
||
"help": "Row offset for pagination — pair with limit.",
|
||
"providers": [],
|
||
"wire_name": "$offset",
|
||
},
|
||
]
|
||
)
|
||
return parameters
|
||
|
||
|
||
_SOCRATA_TYPE_MAP: dict[str, dict[str, Any]] = {
|
||
"text": {"type": "string"},
|
||
"html": {"type": "string"},
|
||
"number": {"type": "number"},
|
||
"money": {"type": "number"},
|
||
"percent": {"type": "number"},
|
||
"double": {"type": "number"},
|
||
"checkbox": {"type": "boolean"},
|
||
"calendar_date": {"type": "string", "format": "date"},
|
||
"date": {"type": "string", "format": "date"},
|
||
"url": {
|
||
"type": "object",
|
||
"properties": {
|
||
"url": {"type": "string"},
|
||
"description": {"type": "string"},
|
||
},
|
||
},
|
||
"email": {"type": "string"},
|
||
"phone": {"type": "object"},
|
||
"point": {"type": "object"},
|
||
"location": {"type": "object"},
|
||
"multipoint": {"type": "object"},
|
||
"line": {"type": "object"},
|
||
"multiline": {"type": "object"},
|
||
"polygon": {"type": "object"},
|
||
"multipolygon": {"type": "object"},
|
||
"photo": {"type": "object"},
|
||
"document": {"type": "object"},
|
||
}
|
||
|
||
|
||
def _slugify(name: str) -> str:
|
||
"""Translate a free-form dataset name to a snake_case identifier."""
|
||
cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_").lower()
|
||
if not cleaned:
|
||
cleaned = "dataset"
|
||
if cleaned[0].isdigit():
|
||
cleaned = f"_{cleaned}"
|
||
return cleaned
|
||
|
||
|
||
_PREFIX_STOP_TOKENS: frozenset[str] = frozenset(
|
||
{"by", "for", "of", "in", "at", "to", "with", "and", "the"}
|
||
)
|
||
|
||
|
||
def _common_token_prefix(token_lists: list[list[str]]) -> list[str]:
|
||
"""Return the longest leading token sequence shared by every input."""
|
||
if not token_lists:
|
||
return []
|
||
shortest = min(len(tl) for tl in token_lists)
|
||
common: list[str] = []
|
||
for i in range(shortest):
|
||
token = token_lists[0][i]
|
||
if all(tl[i] == token for tl in token_lists):
|
||
common.append(token)
|
||
else:
|
||
break
|
||
return common
|
||
|
||
|
||
def _longest_common_token_run(token_lists: list[list[str]]) -> list[str]:
|
||
"""Return the longest contiguous token run shared by every input list."""
|
||
if not token_lists:
|
||
return []
|
||
first = token_lists[0]
|
||
n = len(first)
|
||
others = token_lists[1:]
|
||
for length in range(n, 0, -1):
|
||
for start in range(n - length + 1):
|
||
candidate = first[start : start + length]
|
||
if all(
|
||
any(
|
||
tl[i : i + length] == candidate for i in range(len(tl) - length + 1)
|
||
)
|
||
for tl in others
|
||
):
|
||
return candidate
|
||
return []
|
||
|
||
|
||
def _strip_stop_tokens(tokens: list[str]) -> list[str]:
|
||
"""Trim leading and trailing connector words from a token list."""
|
||
out = list(tokens)
|
||
while out and out[-1] in _PREFIX_STOP_TOKENS:
|
||
out.pop()
|
||
while out and out[0] in _PREFIX_STOP_TOKENS:
|
||
out.pop(0)
|
||
return out
|
||
|
||
|
||
_MIN_SHARED_RUN = 2
|
||
|
||
|
||
def _assign_router_namespaces(
|
||
slugs: dict[str, str],
|
||
) -> dict[str, tuple[str | None, str]]:
|
||
"""Group dataset slugs into ``(router, command)`` pairs.
|
||
|
||
Parameters
|
||
----------
|
||
slugs : dict
|
||
Mapping of dataset UID to slugified dataset name.
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
Mapping of dataset UID to ``(router_or_None, command)`` tuple.
|
||
"""
|
||
if not slugs:
|
||
return {}
|
||
token_lists: dict[str, list[str]] = {
|
||
uid: slug.split("_") for uid, slug in slugs.items()
|
||
}
|
||
uids = list(slugs.keys())
|
||
|
||
parent: dict[str, str] = {uid: uid for uid in uids}
|
||
|
||
def find(u: str) -> str:
|
||
while parent[u] != u:
|
||
parent[u] = parent[parent[u]]
|
||
u = parent[u]
|
||
return u
|
||
|
||
def union(u: str, v: str) -> None:
|
||
ru, rv = find(u), find(v)
|
||
if ru != rv:
|
||
parent[ru] = rv
|
||
|
||
for i in range(len(uids)):
|
||
for j in range(i + 1, len(uids)):
|
||
run = _longest_common_token_run(
|
||
[token_lists[uids[i]], token_lists[uids[j]]]
|
||
)
|
||
if len(run) >= _MIN_SHARED_RUN:
|
||
union(uids[i], uids[j])
|
||
|
||
groups: dict[str, list[str]] = {}
|
||
for uid in uids:
|
||
groups.setdefault(find(uid), []).append(uid)
|
||
|
||
out: dict[str, tuple[str | None, str]] = {}
|
||
for group_uids in groups.values():
|
||
if len(group_uids) == 1:
|
||
uid = group_uids[0]
|
||
out[uid] = (None, slugs[uid])
|
||
continue
|
||
group_tokens = [token_lists[u] for u in group_uids]
|
||
router_tokens = _strip_stop_tokens(_longest_common_token_run(group_tokens))
|
||
if not router_tokens:
|
||
for uid in group_uids:
|
||
out[uid] = (None, slugs[uid])
|
||
continue
|
||
router = "_".join(router_tokens)
|
||
run_len = len(router_tokens)
|
||
member_commands = {
|
||
uid: _command_tokens_around_run(token_lists[uid], router_tokens, run_len)
|
||
for uid in group_uids
|
||
}
|
||
if all(not c for c in member_commands.values()):
|
||
for uid in group_uids:
|
||
out[uid] = (None, slugs[uid])
|
||
continue
|
||
for uid in group_uids:
|
||
tokens = member_commands[uid]
|
||
command = "_".join(tokens) if tokens else "query"
|
||
out[uid] = (router, command)
|
||
return out
|
||
|
||
|
||
def _command_tokens_around_run(
|
||
tokens: list[str], router_tokens: list[str], run_len: int
|
||
) -> list[str]:
|
||
"""Return ``tokens`` minus the first occurrence of ``router_tokens``."""
|
||
for i in range(len(tokens) - run_len + 1):
|
||
if tokens[i : i + run_len] == router_tokens:
|
||
return tokens[:i] + tokens[i + run_len :]
|
||
return list(tokens)
|
||
|
||
|
||
def _walk_for_dataset_uids(node: Any, sink: set[str]) -> None:
|
||
"""Recursively collect every ``datasetUid`` value in a JSON tree."""
|
||
if isinstance(node, dict):
|
||
for key, value in node.items():
|
||
if key == "datasetUid" and isinstance(value, str):
|
||
sink.add(value)
|
||
else:
|
||
_walk_for_dataset_uids(value, sink)
|
||
elif isinstance(node, list):
|
||
for item in node:
|
||
_walk_for_dataset_uids(item, sink)
|
||
|
||
|
||
def extract_dataset_uids(story: dict[str, Any]) -> list[str]:
|
||
"""Return the unique dataset UIDs referenced by a story, in stable order."""
|
||
sink: set[str] = set()
|
||
_walk_for_dataset_uids(story, sink)
|
||
return sorted(sink)
|
||
|
||
|
||
_STORY_URL_RE = re.compile(
|
||
r"^(?P<base>https?://[^/]+/stories/s/[a-z0-9]{4}-[a-z0-9]{4})(?P<rest>.*)$"
|
||
)
|
||
|
||
_SINGLE_VIEW_URL_RE = re.compile(
|
||
r"^(?P<host>https?://[^/]+)"
|
||
r"/(?:resource|api/views)/(?P<uid>[a-z0-9]{4}-[a-z0-9]{4})"
|
||
r"(?:\.json)?(?:\?.*)?$"
|
||
)
|
||
|
||
|
||
def _direct_dataset_seed(source: str) -> tuple[str, str] | None:
|
||
"""Detect ``/resource/`` or ``/api/views/`` URLs and return ``(host, uid)``."""
|
||
match = _SINGLE_VIEW_URL_RE.match(source)
|
||
if match:
|
||
return match.group("host"), match.group("uid")
|
||
return None
|
||
|
||
|
||
def _normalize_story_url(source: str) -> str:
|
||
"""Append ``.json`` when the user pastes the browser-facing story URL."""
|
||
match = _STORY_URL_RE.match(source)
|
||
if not match:
|
||
return source
|
||
rest = match.group("rest")
|
||
if rest.startswith(".json") or rest.startswith("?") and ".json" in rest:
|
||
return source
|
||
if not rest:
|
||
return f"{match.group('base')}.json"
|
||
if rest.startswith("?"):
|
||
return f"{match.group('base')}.json{rest}"
|
||
return source
|
||
|
||
|
||
async def _aread_json_url_or_path(source: str, *, timeout: float = 15.0) -> Any:
|
||
"""Load JSON from an HTTP URL (async via httpx) or local file path (sync)."""
|
||
if source.startswith(("http://", "https://")):
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
|
||
response = await client.get(source)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
return json.loads(Path(source).read_text())
|
||
|
||
|
||
def _story_host(story_source: str, story: dict[str, Any]) -> str:
|
||
"""Pick the portal base URL the story's datasets live on."""
|
||
domain = (story.get("dataSource") or {}).get("domainCName")
|
||
if isinstance(domain, str) and domain:
|
||
return f"https://{domain}".rstrip("/")
|
||
if story_source.startswith(("http://", "https://")):
|
||
parsed = urllib.parse.urlparse(story_source)
|
||
return f"{parsed.scheme}://{parsed.netloc}"
|
||
raise ValueError(
|
||
"story has no ``dataSource.domainCName`` and the source is a local "
|
||
"file — pass an HTTP URL or add the host to the story document."
|
||
)
|
||
|
||
|
||
async def _afetch_json(client: httpx.AsyncClient, url: str) -> Any:
|
||
"""Async GET + JSON-decode, raising on HTTP errors."""
|
||
response = await client.get(url)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
|
||
async def _amaybe_call(callable_: Any, *args: Any, **kwargs: Any) -> Any:
|
||
"""Invoke ``callable_`` and ``await`` the result if it's a coroutine."""
|
||
result = callable_(*args, **kwargs)
|
||
if inspect.isawaitable(result):
|
||
return await result
|
||
return result
|
||
|
||
|
||
async def _aone_metadata(
|
||
client: httpx.AsyncClient,
|
||
host: str,
|
||
uid: str,
|
||
fetcher: Any,
|
||
) -> dict[str, Any] | None:
|
||
"""Fetch one view's metadata, returning ``None`` on any failure."""
|
||
if fetcher is None or fetcher is afetch_dataset_metadata:
|
||
try:
|
||
return await afetch_dataset_metadata(client, host, uid)
|
||
except (httpx.HTTPError, ValueError, OSError):
|
||
return None
|
||
try:
|
||
return await _amaybe_call(fetcher, host, uid)
|
||
except (httpx.HTTPError, OSError, ValueError):
|
||
return None
|
||
|
||
|
||
async def _afetch_metadata_batch(
|
||
client: httpx.AsyncClient,
|
||
host: str,
|
||
uids: list[str],
|
||
fetcher: Any,
|
||
) -> dict[str, dict[str, Any] | None]:
|
||
"""Concurrently fetch metadata for many UIDs."""
|
||
if not uids:
|
||
return {}
|
||
sem = asyncio.Semaphore(_DISCOVERY_MAX_WORKERS)
|
||
|
||
async def _one(uid: str) -> dict[str, Any] | None:
|
||
async with sem:
|
||
return await _aone_metadata(client, host, uid, fetcher)
|
||
|
||
results = await asyncio.gather(*(_one(uid) for uid in uids))
|
||
return dict(zip(uids, results))
|
||
|
||
|
||
async def _aone_choice(
|
||
client: httpx.AsyncClient,
|
||
host: str,
|
||
uid: str,
|
||
field: str,
|
||
cap: int,
|
||
resolver: Any,
|
||
) -> list[Any] | None:
|
||
"""Fetch one column's distinct values, ``None`` on overflow / error."""
|
||
if resolver is None or resolver is afetch_column_distinct_values:
|
||
return await afetch_column_distinct_values(client, host, uid, field, limit=cap)
|
||
try:
|
||
return await _amaybe_call(resolver, host, uid, field, limit=cap)
|
||
except (httpx.HTTPError, OSError, ValueError):
|
||
return None
|
||
|
||
|
||
async def _abuild_discovery(
|
||
story_source: str,
|
||
host_override: str | None,
|
||
fetcher: Any,
|
||
choice_resolver: Any,
|
||
direct_seed: tuple[str, str] | None = None,
|
||
) -> tuple[
|
||
dict[str, Any],
|
||
str,
|
||
dict[str, dict[str, Any]],
|
||
dict[str, str],
|
||
list[tuple[str, str]],
|
||
dict[tuple[str, str], list[Any] | None],
|
||
]:
|
||
"""Run the full async discovery pipeline end-to-end."""
|
||
async with httpx.AsyncClient(
|
||
timeout=httpx.Timeout(30.0),
|
||
limits=httpx.Limits(
|
||
max_connections=_DISCOVERY_MAX_WORKERS * 2,
|
||
max_keepalive_connections=_DISCOVERY_MAX_WORKERS,
|
||
),
|
||
) as client:
|
||
if direct_seed is not None:
|
||
seed_host, seed_uid = direct_seed
|
||
story = {"_synthetic_single_view": True, "uid": seed_uid}
|
||
host = host_override or seed_host
|
||
uids = [seed_uid]
|
||
else:
|
||
story = await _aload_story(client, story_source)
|
||
host = host_override or _story_host(story_source, story)
|
||
uids = extract_dataset_uids(story)
|
||
|
||
metas: dict[str, dict[str, Any]] = {}
|
||
slugs: dict[str, str] = {}
|
||
skipped: list[tuple[str, str]] = []
|
||
pending: list[str] = list(dict.fromkeys(uids))
|
||
processed: set[str] = set()
|
||
|
||
while pending:
|
||
wave = [uid for uid in pending if uid not in processed]
|
||
pending = []
|
||
if not wave: # pragma: no cover
|
||
break
|
||
processed.update(wave)
|
||
wave_metas = await _afetch_metadata_batch(client, host, wave, fetcher)
|
||
for uid, meta in wave_metas.items():
|
||
if meta is None:
|
||
skipped.append((uid, "unreachable"))
|
||
continue
|
||
asset_type = meta.get("assetType")
|
||
if asset_type in {"dataset", "filter"}:
|
||
metas[uid] = meta
|
||
slugs[uid] = _slugify(meta.get("name") or uid)
|
||
else:
|
||
skipped.append((uid, asset_type or "unknown"))
|
||
backing = _backing_dataset_uid(meta)
|
||
if backing and backing not in processed:
|
||
pending.append(backing)
|
||
choices_cache = await _aprobe_choices_batch(
|
||
client, host, metas, choice_resolver
|
||
)
|
||
return story, host, metas, slugs, skipped, choices_cache
|
||
|
||
|
||
async def _aload_story(client: httpx.AsyncClient, story_source: str) -> dict[str, Any]:
|
||
"""Async version of ``_aread_json_url_or_path`` that reuses the client."""
|
||
if story_source.startswith(("http://", "https://")):
|
||
response = await client.get(story_source)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
return json.loads(Path(story_source).read_text())
|
||
|
||
|
||
async def _aprobe_choices_batch(
|
||
client: httpx.AsyncClient,
|
||
host: str,
|
||
metas: dict[str, dict[str, Any]],
|
||
resolver: Any,
|
||
) -> dict[tuple[str, str], list[Any] | None]:
|
||
"""Concurrently probe distinct-value choices for every column on every dataset."""
|
||
tasks: list[tuple[str, str, int]] = []
|
||
for uid, meta in metas.items():
|
||
for col in meta.get("columns") or []:
|
||
if not isinstance(col, dict):
|
||
continue
|
||
field = col.get("fieldName") or col.get("name")
|
||
if not field:
|
||
continue
|
||
if _is_date_column(col):
|
||
continue
|
||
type_name = col.get("dataTypeName") or "text"
|
||
if type_name not in _NUMERIC_TYPES and type_name not in {
|
||
"text",
|
||
"html",
|
||
"checkbox",
|
||
}:
|
||
continue
|
||
cap = (
|
||
_NUMERIC_CHOICE_CAP
|
||
if type_name in _NUMERIC_TYPES
|
||
else _MAX_RESOLVED_CHOICES
|
||
)
|
||
tasks.append((uid, field, cap))
|
||
|
||
if not tasks:
|
||
return {}
|
||
|
||
sem = asyncio.Semaphore(_DISCOVERY_MAX_WORKERS)
|
||
|
||
async def _one(
|
||
uid: str, field: str, cap: int
|
||
) -> tuple[tuple[str, str], list[Any] | None]:
|
||
async with sem:
|
||
value = await _aone_choice(client, host, uid, field, cap, resolver)
|
||
return (uid, field), value
|
||
|
||
results = await asyncio.gather(
|
||
*(_one(uid, field, cap) for uid, field, cap in tasks)
|
||
)
|
||
return dict(results)
|
||
|
||
|
||
async def afetch_dataset_metadata(
|
||
client: httpx.AsyncClient, host: str, uid: str
|
||
) -> dict[str, Any]:
|
||
"""Fetch a Socrata view's metadata JSON (async)."""
|
||
url = f"{host.rstrip('/')}/api/views/{uid}.json"
|
||
response = await client.get(url)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
|
||
def _backing_dataset_uid(meta: dict[str, Any]) -> str | None:
|
||
"""Return the UID of a non-dataset view's backing dataset, or ``None``."""
|
||
backing = meta.get("modifyingViewUid")
|
||
if isinstance(backing, str) and backing:
|
||
return backing
|
||
query = meta.get("query")
|
||
if isinstance(query, dict):
|
||
source = query.get("viewSourceId")
|
||
if isinstance(source, str) and source:
|
||
return source
|
||
return None
|
||
|
||
|
||
def _column_to_schema(column: dict[str, Any]) -> dict[str, Any]:
|
||
"""Translate one Socrata column descriptor into a JSON-schema fragment."""
|
||
type_name = column.get("dataTypeName") or "text"
|
||
schema = dict(_SOCRATA_TYPE_MAP.get(type_name, {"type": "string"}))
|
||
description = column.get("description")
|
||
if description:
|
||
cleaned = description.strip()
|
||
if cleaned:
|
||
schema["description"] = cleaned
|
||
fmt = column.get("format")
|
||
if isinstance(fmt, dict) and fmt:
|
||
rendering_hints = {
|
||
k: v
|
||
for k, v in fmt.items()
|
||
if k
|
||
in {
|
||
"precisionStyle",
|
||
"currency",
|
||
"decimalSeparator",
|
||
"groupSeparator",
|
||
"noCommas",
|
||
"precision",
|
||
"view",
|
||
"mask",
|
||
}
|
||
}
|
||
if rendering_hints:
|
||
schema["socrata_format"] = rendering_hints
|
||
return schema
|
||
|
||
|
||
def _build_response_schema(meta: dict[str, Any]) -> dict[str, Any]:
|
||
"""Build a row-shape schema from a dataset's column metadata."""
|
||
columns = meta.get("columns") or []
|
||
properties: dict[str, dict[str, Any]] = {}
|
||
for column in columns:
|
||
if not isinstance(column, dict):
|
||
continue
|
||
field_name = column.get("fieldName") or column.get("name")
|
||
if not field_name:
|
||
continue
|
||
properties[field_name] = _column_to_schema(column)
|
||
return {
|
||
"type": "object",
|
||
"properties": {
|
||
"results": {
|
||
"type": "array",
|
||
"items": {"type": "object", "properties": properties},
|
||
}
|
||
},
|
||
}
|
||
|
||
|
||
def _build_command(
|
||
host: str,
|
||
uid: str,
|
||
meta: dict[str, Any],
|
||
*,
|
||
choice_resolver: Any = None,
|
||
choices_cache: dict[tuple[str, str], list[Any] | None] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Synthesize one spec command entry for a dataset."""
|
||
name = (meta.get("name") or uid).strip()
|
||
description = (meta.get("description") or "").strip()
|
||
summary = description or name
|
||
parameters = _build_query_parameters(
|
||
meta,
|
||
host=host,
|
||
uid=uid,
|
||
resolver=choice_resolver,
|
||
choices_cache=choices_cache,
|
||
)
|
||
time_axis = _detect_time_axis_column(meta)
|
||
return {
|
||
"url_path": f"/resource/{uid}.json",
|
||
"method": "get",
|
||
"description": summary,
|
||
"parameters": parameters,
|
||
"providers": [],
|
||
"request_body_schema": None,
|
||
"request_body_schemas": {},
|
||
"response_schema": _build_response_schema(meta),
|
||
"response_schemas": {},
|
||
"_socrata_time_axis": time_axis,
|
||
"_socrata_primary_item": _primary_item_column(parameters, time_axis),
|
||
}
|
||
|
||
|
||
def _primary_item_column(
|
||
parameters: list[dict[str, Any]], time_axis: str | None
|
||
) -> str | None:
|
||
"""Pick the column to use as the per-item key for ``limit`` semantics."""
|
||
excluded: set[str] = {"limit", "offset"}
|
||
if time_axis:
|
||
excluded.add(time_axis)
|
||
candidates: list[tuple[int, str]] = []
|
||
for p in parameters:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
if p.get("_socrata_op") in {"date_min", "date_max"}:
|
||
continue
|
||
name = p.get("name")
|
||
if not name or name in excluded:
|
||
continue
|
||
if _is_time_axis_named(name):
|
||
continue
|
||
if p.get("type") != "string":
|
||
continue
|
||
choices = p.get("choices") or []
|
||
if len(choices) < 3:
|
||
continue
|
||
candidates.append((len(choices), name))
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda pair: -pair[0])
|
||
return candidates[0][1]
|
||
|
||
|
||
def build_socrata_spec(
|
||
story_source: str,
|
||
*,
|
||
host_override: str | None = None,
|
||
fetch: Any = None,
|
||
choice_resolver: Any = None,
|
||
) -> dict[str, Any]:
|
||
"""Build a complete spec document from a Socrata story URL or local path.
|
||
|
||
Parameters
|
||
----------
|
||
story_source : str
|
||
A story URL or a path to a previously-downloaded story JSON file.
|
||
host_override : str, optional
|
||
Portal base URL to use instead of the one inferred from the story.
|
||
fetch : callable, optional
|
||
Override the metadata fetcher — signature ``(host, uid) -> dict``.
|
||
choice_resolver : callable, optional
|
||
Override the per-column distinct-value resolver.
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
A spec document conforming to ``SpecDocument``.
|
||
"""
|
||
story_source = _normalize_story_url(story_source)
|
||
direct_seed = _direct_dataset_seed(story_source)
|
||
story, host, metas, slugs, skipped, choices_cache = asyncio.run(
|
||
_abuild_discovery(
|
||
story_source,
|
||
host_override,
|
||
fetch,
|
||
choice_resolver,
|
||
direct_seed=direct_seed,
|
||
)
|
||
)
|
||
|
||
assignments = _assign_router_namespaces(slugs)
|
||
|
||
commands: dict[str, dict[str, Any]] = {}
|
||
seen_paths: set[str] = set()
|
||
for uid, (router, command) in assignments.items():
|
||
path = command if router is None else f"{router}.{command}"
|
||
if path in seen_paths:
|
||
path = f"{path}_{uid.replace('-', '')}"
|
||
seen_paths.add(path)
|
||
commands[path] = _build_command(
|
||
host,
|
||
uid,
|
||
metas[uid],
|
||
choice_resolver=choice_resolver,
|
||
choices_cache=choices_cache,
|
||
)
|
||
|
||
return {
|
||
"version": SPEC_VERSION,
|
||
"generated_at": datetime.now(UTC).isoformat(),
|
||
"generator": _generator_identifier(),
|
||
"source_url": story_source,
|
||
"api_version": "socrata-soda-2.1",
|
||
"base_url": host,
|
||
"api_prefix": "",
|
||
"commands": commands,
|
||
"routers": _build_routers(commands),
|
||
"reference": _build_reference(commands),
|
||
"_socrata": {
|
||
"story_uid": story.get("uid"),
|
||
"dataset_count": len(commands),
|
||
"skipped": [{"uid": u, "asset_type": t} for u, t in skipped],
|
||
},
|
||
}
|
||
|
||
|
||
def _build_routers(commands: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||
"""Mark every namespace prefix as ``menu`` and every leaf as ``command``."""
|
||
out: dict[str, str] = {}
|
||
for cmd_path in commands:
|
||
parts = cmd_path.split(".")
|
||
for i in range(1, len(parts)):
|
||
out[".".join(parts[:i])] = "menu"
|
||
out[cmd_path] = "command"
|
||
return out
|
||
|
||
|
||
def _build_reference(
|
||
commands: dict[str, dict[str, Any]],
|
||
) -> dict[str, dict[str, dict[str, str]]]:
|
||
"""Build the slash-style reference index the REPL renders menu help from."""
|
||
paths: dict[str, dict[str, str]] = {}
|
||
routers: dict[str, dict[str, str]] = {}
|
||
for cmd_path, cmd_spec in commands.items():
|
||
parts = cmd_path.split(".")
|
||
slash_path = "/" + "/".join(parts)
|
||
paths[slash_path] = {"description": cmd_spec.get("description", "")}
|
||
for i in range(1, len(parts)):
|
||
slash_router = "/" + "/".join(parts[:i]) + "/"
|
||
routers.setdefault(slash_router, {"description": ""})
|
||
return {"paths": paths, "routers": routers}
|