- feat(discovery): capture
GEO_IDinto a newdf_geo_dimcatalog column — the territorial dimension ISTAT names on a dataflow when it is not the defaultITTER107/REF_AREA(e.g.RESIDENCE_TERR). - feat(constraints_archive): the nightly territorial classifier now treats as
territorial the default dimensions plus the distinct
df_geo_dimnames discovered across the catalog, applying each name wherever it occurs. Gated on the dimension name only — never on code shape (ECOICOP_2has 6-digit codes but is not geography). Recovers 77 previously-invisible dataflows (incl. births25_74) while correctly excluding the 31ECOICOPdataflows. - Verified in a dry run over
data/constraints/istat.parquet: classifiable dataflows 1,749 → 1,826;RESIDENCE_TERRdiscovered fromGEO_ID;ECOICOPnot included.
- feat(discovery): three new nullable catalog columns read from ISTAT's
annotationsblock at zero extra network cost —df_last_update(LAST_UPDATE, 4,511 dataflows),df_notes(DATAFLOW_NOTES, 144),df_bulk_files(ATTACHED_DATA_FILES, 365). Stored as published (LAST_UPDATE mixes ISO 8601 and MM/dd/yyyy; bulk files areURL|LABEL). - feat(embed): fold
df_notesinto the embedded document text so semantic search can match a dataset by its methodological caveats (e.g. province-boundary changes that break time-series comparability).df_last_update/df_bulk_filesare captured, not embedded. - infra: optional catalog columns backfilled via one
_ensure_catalog_columns()helper (legacy cache + hub-only INPS), replacing the growing per-column if-chain. - Verified: coverage 4,511 / 144 / 365; a notes-only query now reaches the dataflow.
Follow-ups (out of scope): normalise
df_last_update, surfacedf_bulk_filesininfo/get, and the GEO_ID territorial change.
- refactor(discovery): replace the text-only
_keyword_annotationwalker with_annotations(node, language)→dict[type, Annotation(title, text, url)], one pass over the node. Fixes the latent bug that the old reader saw onlyAnnotationText, while most ISTAT annotations (LAST_UPDATE, LAYOUT_*, GEO_ID, …) carry their value inAnnotationTitle— pointing the old reader at them returnedNonesilently. Presence-only annotations (e.g. READY_FOR_PRODUCTION) map to an all-NoneAnnotationso membership tests work; type strings match literally (LINkEDDATAFLOWNODE, lowercase k)._keyword_annotationkept as a thin wrapper. - refactor(portals): declare consumed annotations in one
annotationsblock ({stable_key: {type, value: text|title|presence}}) instead of per-annotation top-level keys; ISTAT migrated (keywords,metadata_url). The METADATA_API service keys stay separate. Noif provider ==. - refactor(scripts): the descriptions harvester drops its
_annotation_textcopy and reads METADATA_URL through the shared reader. - Enabling change only: no new annotation is consumed and behaviour is unchanged —
verified end-to-end (ISTAT
df_keywordsstill 144/4899, identical) and by the untouched keyword tests. Unlocks LAST_UPDATE, DATAFLOW_NOTES, ATTACHED_DATA_FILES, hidden-flag labelling (seedocs/istat/databrowser-annotations.md) at zero net cost.
- docs: add
docs/istat/databrowser-annotations.md— census of the SDMX annotations ISTAT publishes on its dataflows, from exploring the SDMX Istat Toolkit site. One catalog call: 4,899 dataflows, 29 distinct annotation types in use, of which opensdmx reads 2 (LAYOUT_DATAFLOW_KEYWORDS,METADATA_URL). - finding: esploradati runs the StatKit Data Browser, not
.Stat Suite— the manual (§3.7, p. 36) namesapp/databrowserhub/api/core, ourhub_base_url.docs/istat/hub-api.mdanddocs/inps/middleware-api.mdcarry the wrong platform. - finding: 643 dataflows (13.1%) are flagged non-production or hidden by ISTAT and we
expose them all — 14.1% of real search hits. Probed
CPI(flagged, returns current data): the flag means catalog visibility, not data availability, so the remedy is to label and deprioritize, never to filter. - unused signals worth a pass:
LAST_UPDATE(92.1%, mixed ISO /dd/mm/yyyy),LAYOUT_ROW/COLUMN/FILTER(~80%, the publisher's own pivot),DATAFLOW_NOTES(145 methodological caveats),ATTACHED_DATA_FILES(366 bulk CSV/zip downloads). - no code change in this pass.
- feat(descriptions): harvest the authentic dataflow descriptions ISTAT shows on
EsploraDati — never present in the SDMX structure — via the
METADATA_URLannotation →METADATA_API(DATA_SOURCE). Newscripts/descriptions_archive.pyreads the annotation for the whole catalog in one call, de-duplicates by the sharedreportId(3,974 linked dataflows → 622 unique reports, 6.3×), fetches each report once, HTML-cleans the prose, and writes a bundled resourcesrc/opensdmx/data/descriptions/istat.parquet(3,949 dataflows described, ~81%). - feat(embed): fold the harvested description into the embedded document text when the provider resource is present; guarded, so providers/checkouts without it are byte-identical to before. Read locally — no metadata request at embedding time.
- feat(portals): ISTAT declares the channel (
metadata_annotation,metadata_api_path,metadata_description_attribute); generic, no per-providerif.metadataSetId/reportId/BaseUrlMDAare derived from the live annotation. - infra: monthly GitHub Action refreshes the resource; the metadata API is a service distinct from the rate-limited SDMX endpoint.
- Context: ISTAT declined to add
<common:Description>to the DSD (2026-07-23), but the prose was already reachable through this separate channel. This is the primary description lever (81%); deterministic DSD composition (the ~19% tail + other providers) and optional LLM prose are declared follow-ups. No runtime LLM.
- feat(discovery): parse an optional per-dataflow keyword annotation into a new
nullable
df_keywordscolumn on the dataflows cache. Providers opt in by declaringkeyword_annotationinportals.json(only ISTAT:LAYOUT_DATAFLOW_KEYWORDS); others never call the parser. New helper_keyword_annotation()matches on theAnnotationTypechild text (namespace-agnostic), returns the text for the provider language with en fallback. Zero extra network cost — sameGET /dataflow/IT1already made. - feat(embed): append
df_keywordsto the embedded document text when present (guarded on column existence for backward-compat with older caches). - Where present, the keyword layer is rich — expanded dimensions, periodicity,
and territorial granularity (
provincia, comune…) — so it also helps the REF_AREA granularity question. But coverage is thin: 144/4899 ISTAT dataflows (2.9%). Deliberately not wired into keyword search (low impact). - Context: ISTAT declined (2026-07-23) to populate
<common:Description>on the DSD, so descriptive text must come from within opensdmx.cat_descriptionis effectively empty everywhere (0.1% ISTAT, 0% Eurostat); the real broad lever remains offline synthetic descriptions (separate task). - Verified: full suite green (307), ruff clean, live refresh confirmed the column populates (2.9%) and the embedded text carries the keywords.
- feat(provider): added INPS — the first hub-only provider. Its classic SDMX-REST endpoint is WAF-blocked, so opensdmx talks exclusively to the
.Stat SuiteDataBrowser middleware (https://opendata.inps.it/databrowser/api/core, JSON over GET+POST). All INPS logic lives in a dedicatedinps.pyadapter; core functions delegate via a singleif provider.get("hub_only")branch — no scatteredif provider ==checks. - The middleware is split into four nodes (observatories: pensioni, dipendenti, imprese, politiche occupazionali);
hub_nodesinportals.jsonholds thecode→nodeIdmap, and adf_id→nodeindex is built once from the four catalogs and cached as Parquet. providers/tree/search/info/constraints/getall work. Discovery reads catalog+structure+PartialCodelists;getdownloads the full dataflow as SDMX-CSV (the middleware has no server-side filter) and filters client-side (dimensions + a by-year period window), mirroring Derzhstat'sdata_key_format:"empty".last_n/first_nunavailable.- infra:
base.sdmx_requestgained_method/_json_body/_base_urlso hub POST calls inherit rate-limit/retry/file-lock.dimensions_infonow prefers a description carried on the dimension (the hub's per-dimension label), falling back to the codelist description for the other providers. - Verified live end-to-end: RAL media Lombardia 2021 = 27,285 € (matches a manual cross-check),
--labelsresolves Italian names. Full test suite green, ruff clean, zero changes to any other provider's path. - docs:
docs/inps/middleware-api.md(endpoint reference),docs/providers.md+ skillreferences/providers.mdupdated.
- feat(search): keyword search now also matches the category name a dataflow belongs to. ISTAT titles are often leaf labels —
Sesso,Età,Lazio— and 1,622 of 4,879 dataflows (33%) share a title with another.search "disoccupati mensili" --provider istatwent from 2 results to 6, the four new ones being the dataflows that actually hold the data. - feat(search): results print the category as context (
Disoccupati - dati mensili › Sesso, età), suppressed when the title already contains it.infogained aCategory:line.-o json|csvcarry a separatecategoryfield, so a machine gets the parts and a human the composed line. - Not ISTAT-specific. The context comes from the category cache, and
categories_supportedholds for 9 of 14 providers. Eurostatsearch "consumer prices"went from 33 to 66 results. Providers without categories, and users who never ranopensdmx tree, keep the previous behaviour exactly — verified on IMF. - refactor:
_category_context_for_embed()moved tocategories.category_context(), gaining aninclude_schemeflag and acat_primarycolumn. Embeddings keep scheme+category as before; search uses the category alone, since a scheme like "Lavoro e retribuzioni" would match nearly everything under it. New sharedutils.compose_title(). - Rejected on measurement. The issue proposed deriving a parent title from the ISTAT id pattern
{parent}_DF_{dsd}_{n}. Measured first: on the 3,460 dataflows having both, the parent title is the category name in 51% of cases, and contained in it in another 4% — an ISTAT-specific id parser would have rebuilt data the catalogue already carries and already feeds to the embeddings. The 45% where the parent says something else, typically the vintage, stays uncovered and is documented in #52. --grepdeliberately still applies to title and ID only, so documented recipes return the same rows.treeandsiblingsare untouched: both already group by category, so a per-row prefix would be pure duplication.- test: 4 new cases, including a regression guard that an absent category cache leaves results byte-identical. Suite 276 → 280.
- fix:
str.concat→str.join, silencing a polars DeprecationWarning that the wider use of this code path made frequent. - Review follow-up:
compose_titleused a substring test, so the categoryAgewas silently dropped from the titleAverage earnings; now a casefolded prefix match, pinned by tests. CSV output regained its typed frame — without itscorewas stringified and the column set depended on whether the category cache existed, making the schema a function of local state. - Filed while planning this: #57 (the embeddings index records neither the model nor the text that built it, so it cannot report staleness) and #58 (the 45% of ISTAT dataflows whose parent title carries a vintage the category does not).
- BREAKING fix(cli):
tree --category Xandtree --show-dataflowswithout--schemenow exit 1 instead of silently printing the scheme list with exit 0. Thescheme is Nonebranch returned before any tree-shaping flag was read, so three flags were accepted and discarded —--category's own help text already claimed "requires --scheme", an intent never enforced. - feat(cli):
--depthnow acts without--scheme. The provider is the root of the tree and the scheme list is level 1, so--depth Nuniformly means "N levels below the current root" and the special case disappears.--depth 2renders one tree per scheme with its top-level categories; ISTAT goes from 45 to 314 lines.--depth 1and the no-flag default keep printing the same scheme table — deliberate, sinceskills/sdmx-explorerdocumentstree --depth 1as the entry point of the guided workflow. - refactor(cli): the ASCII render block moved into
_render_tree_block()so the no-scheme path reuses it per scheme rather than reimplementing it. - Rendering shifts with depth: level 1 is the
n_dftable, level 2+ the ASCII tree. Accepted, no row-count warning — whoever types--depth 3asked for the big tree. - test: 5 new cases in
tests/test_cli.py, including a regression guard that no-scheme--depth 1output stays identical to the no-flag output, and a dedicated two-scheme fixture covering the one-tree-per-scheme loop the single-scheme fixture could not reach. Suite 271 → 276. - docs:
treedocstring andskills/sdmx-explorer/SKILL.mdstate the root/level model; plan intasks/todo-tree-depth.md.
- fix(discovery):
set_filtersnow treats-and_as equivalent when matching dimension names, so--na-itemreaches theNA_ITEMdimension. CLI convention is dashes, SDMX ids use underscores; matching was already case-insensitive, this extends the same leniency. - BREAKING fix(discovery): an unknown filter name now raises
ValueErrorinstead of being dropped with a log line. The old behaviour ran the query unfiltered and put the only signal on stderr — for a tool whose primary consumer reads stdout, that returned wrong data with no indication. Verified:opensdmx get NAMA_10_GDP --geo IT --na-item B1GQ ...used to emit 20+na_itemvalues when one was asked for. The error carries adifflibsuggestion and the available dimension list. - Correction to the Phase 2 record. That refactor claimed
plothad degraded by not capturingset_filterswarnings. It had not.catch_warningsgenuinely appeared inget/runand notplot, but nothing in the package callswarnings.warn— the messages come fromlogger.warningvia logging, independently. Confirmed by runningplotat the v0.14.1 tag: same output. The claim was verified only in the after state, never the before. - refactor(cli): removed the dead
catch_warningsblocks from_load_and_filterandrun. They captured nothing, and Phase 2 had preserved them faithfully rather than deleting them — the opposite of that phase's purpose. - test: replaced
test_load_and_filter_surfaces_set_filters_warnings, which passed only because its own mock raised a warning the real code never raises. 5 new tests on dash matching, the raise, the suggestion and the available list. Suite 264 → 268. - docs:
docs/evaluation-v0.14.0.mdfinding 6 carries the correction inline; new finding 20 documents the filter bug.
- refactor(retrieval, cli):
opensdmx runreimplementedretrieval.run_query()— public API, exported in__all__— and the copies had diverged. The CLI honouredOPENSDMX_PROVIDERand accepted--provider; the library did neither, so the same YAML file behaved differently depending on the caller.run_query()gains aproviderargument and the env fallback; the CLI now calls it. Behaviour change for library users: a query file with noproviderfield now respectsOPENSDMX_PROVIDERinstead of silently staying on the default. - feat(base):
set_provider_from_env()— resolvesOPENSDMX_PROVIDERincluding aliases, so library entry points and the CLI agree.set_provider()alone never resolved aliases. - refactor(cli): extracted
_load_and_filter()and_fetch_frame(). Theload_dataset → set_filters → get_data → enrich_with_labelspipeline was written three times, and theplotcopy had already degraded — it did not captureset_filterswarnings, so a suspicious filter value was reported bygetand silently ignored byplot. Verified:plotnow surfaces it. - refactor(cli): extracted
_write_output(), duplicated betweengetandrundown to a one-word difference in the error string (unsupported output formatvsunsupported format).runnow uses the former wording. - perf(discovery):
parse_bulk_constraints()builds the tree once._extract_bulk_long_idsand_parse_bulk_constraint_xmleach calledxml_parse()on the same bulk contentconstraint bytes — the largest XML the library fetches — for two full tree builds and two namespace scans. - chore(db_cache): deleted
bulk_constraint_fetchandbulk_constraint_index. Written on every catalog rebuild, never read: their only reader had no callers. The signal travels in thehas_constraintcolumn ofdataflows.parquet. - test: 15 regressions in
tests/test_phase2_dedup.py. Suite 243 → 258, ruff and mypy strict green, real-CLI smoke onget --query-file,runandplot.
- release: patch shipping the four Phase 1 bug fixes below. Most user-visible is
-o csv, which emitted corrupt CSV whenever a field contained a comma —whichandprovidersboth do. No API change.
- docs: full architecture review in
docs/evaluation-v0.14.0.md— data flow, 19 findings ranked by severity, refactoring strategy in 4 phases, plus an explicit "verified correct, do not change" list. Plan intasks/todo-architecture-review.md. - fix(cli):
-o csvemitted corrupt CSV —_emitjoined fields with","and no quoting, so any description containing a comma split into extra columns (which,providers). Now delegates to Polarswrite_csv();str()semantics preserved. - fix(cli):
-o csvon a nested payload (info,constraintssummary,siblings) fell back to JSON silently. Still JSON — there is no faithful flat form — but now says so on stderr. Two deliberate behaviour additions, not restorations: that stderr warning is new (an empty list is exempt — it is empty, not formless), and a present-but-Nonevalue now renders as an empty cell instead of the literalNone. stdout is byte-identical to before in every other case. - fix(cli): the large-dataset guard raised
typer.Exit(1)inside atrywhoseexcept Exceptioncaught it (typer.ExitsubclassesRuntimeError), appending a spuriousError: 1after a correct warning. Handler now re-raisestyper.Exit. - fix(cli): that warning said "no filters set" unconditionally, even when filters were passed.
- fix(utils, ai): codelist values are cached under
{codelist_id}:{lang}but were read with the bare id, so_get_code_labeland the AI label map returned nothing on every provider — verified against the live cache (zero rows under a bare key). The AI context was listing raw codes with no labels. - chore(deps): declared
pandas(imported atcli.py:1456,1473, previously resolved only transitively via plotnine); removedduckdb, declared since v0.2.6 and never imported. - test: 14 regressions in
tests/test_phase1_regressions.py. Suite 229 → 243, ruff and mypy strict green, real-CLI smoke onsearchandget.
- release: export types to consumers via the
py.typedmarker (PEP 561) and fullmypy --strictcoverage across the package. No runtime or behaviour change.
- ci:
cliconverted to strict and the gradual opt-out override removed —strict = truenow covers all 14 modules with no exemptions. Typer commands get-> None, helpers typed (_status_ctx -> Iterator[None],_filter_by_grep/_emitoverpl.DataFrame), dicts parametrised. - plotnine ships
py.typedbut leaves its public API (aes,geom_*,theme) unannotated → dedicatedfollow_imports = "skip"override so its calls aren't flagged;chatlaskept typed (used for the_ai_structuredTypeVar). - feat: added the
py.typedmarker (PEP 561), verified present in the built wheel — downstream users importingopensdmxnow get the exported types. - Verified: mypy strict green (14 files), ruff clean, 229 tests, real CLI smoke (
providers,get --output csv).
- ci: switched
[tool.mypy]tostrict = truewith a per-module opt-out list so strict is adopted one module at a time. Converted the small/pure modules now —ai,embed,which,hub,retrieval(plus the already-cleancache_config,guide,__init__) are strict-clean;categories,utils,db_cache,base,discovery,clistay exempted with the specific strict flags relaxed (tracked TODO in the config comment). - fix(types): precise generics (
dict[str, Any],list[dict[str, str]]), missing param/return annotations, and Any-return coercions via typed locals; no new# type: ignore. mypy,ruff, and the 229-test suite all green; runtime smoke (which, package import) ok.
- ci: added mypy to the dev tooling.
[tool.mypy]inpyproject.toml(pragmatic baseline: default checks +warn_unused_ignores/warn_redundant_casts), dev depsmypy,types-PyYAML,lxml-stubs,pandas-stubs;uv run mypystep inci.ymlnext to ruff. - fix(types): resolved the real type errors surfaced —
ai._ai_structurednow generic (TypeVarbound toBaseModel), dropping 11 stale# type: ignore;discoverydataflow records typed via aTypedDict(df_id alwaysstr);params/ns/constraint_timeoutannotations;which.rank_whichscored list typed. mypy,ruff, and the 229-test suite all green.
- feat(cli):
searchgains--grep, the case-insensitive regex post-filter already available onvaluesandconstraints. Closes the gap that forced a shell pipe for whole-word matching (searchingcomunion ISTAT also matched "comunitari", "comunicazione"). - fix(cli): a malformed
--greppattern now reports a readable error instead of raising a regex traceback — applies tosearch,valuesandconstraintsvia the shared_filter_by_grephelper. - fix(cli):
searchhelp said "dataset descriptions"; it matches titles and IDs, and dataflows expose a title (SDMXName), not a description. - docs: README command table + example,
skills/sdmx-explorersearch step. - test: 4 regressions covering whole-word filtering, ID matching, invalid pattern, no-match exit.
- feat(data): public per-provider constraints archive in
data/constraints/(parquet + status CSV), grown incrementally byscripts/constraints_archive.py— budgeted daily probes instead of bulk sweeps. - ISTAT probed via the databrowser hub bulk endpoint (~1 s/dataflow, no DSD call, outside the 15 s SDMX rate limiter); other providers via
load_dataset()+get_available_values()with built-in pacing. - Derived view
istat_territorial.csv: territorial granularity per dataflow (nazionale → comune) classified from ITTER107/REF_AREA codes — answers "which ISTAT datasets have municipal detail?" offline. - ci:
constraints-archive.ymldaily workflow (istat + eurostat budgets, opensdmx cache restore, commits on change). - chore: deleted unused bulk scaffolding (
schema_export.py,update_istat_schema.py,update_all_schemas.py,cache_maintenance.py,rate_limit_helper.py,example_cron.sh); rewrotescripts/README.md.
- fix(search): OR fallback coverage treats a null description token match as false, preserving coverage from tokens that match the dataset ID.
- test: added a null-description regression to ensure a two-token ID match ranks above a one-token match.
- ci: removed duplicate automatic Claude Code PR review; Greptile and Copilot remain the automatic reviewers.
- ci: Claude on-demand now triggers only from issue and normal PR conversation comments containing
@claude, avoiding skipped runs for every inline review reply or review submission.
- fix(search): treat search tokens as literal text, preventing malformed or broad regular-expression matches from user input.
- fix(search): in OR fallback results, rank datasets by the number of distinct matched tokens before the existing relevance score; repeated occurrences of one token no longer outrank broader matches.
- test: added regression coverage for literal regex characters, helper case normalization, and token-coverage ranking.
224 passed; ruff clean.
- fix:
search_dataset()(discovery.py) combined all keyword tokens with AND and returned nothing when a single token was unmatched; now falls back to OR (any token) when AND is empty, so one stray token no longer wipes the result set. Relevance scoring (_score_results) keeps full-token matches on top. Extracted_token_match_expr()helper. - test: added AND/OR fallback cases to
tests/test_discovery.py(mocked catalog, no network). Full suite 221 passed, ruff clean. - verified on real Eurostat cache:
"unemployment youth aardvark"went from 0 → 123 results, top hits still the youth-unemployment dataflows.
- docs: exported the requested Google Doc to Markdown with
gwsb drive files export; saved astmp/google-doc.md - docs: reviewed the ISTAT conference abstract against the local
istat_mcp_serverimplementation; drafted a minimally edited 1,109-character revision intmp/abstract-revised.md
- fix: dependency
httpx>=0.28.1→httpx[socks]>=0.28.1, bundlingsocksio. opensdmx now works out of the box inside sandboxes that route all traffic through a SOCKS proxy (e.g. the Claude Cowork sandbox); previously any network call raisedImportError - docs: README Installation note +
sdmx-explorerSKILL.md setup note documenting the SOCKS/sandbox case and the one-shot manual patch (uv pip install ... socksio "httpx[socks]") for installs older than 0.12.1 - skill: bumped
sdmx-explorerto v1.3
- feat: the
codelist_valuescache now stores each code'scode_parent(<Parent>ref) andcode_order(ORDERannotation); populated at parse time, no extra request - feat: new
get_codelist_hierarchy(ds, dim)accessor returns(id, name, parent, order), exported from the package;get_dimension_valuesoutput is unchanged (still(id, name)) - cache: in-place migration adds the two columns to pre-existing
codelist_valuestables (no wipe);save_codelist_valuesnow inserts by explicit columns. Note: codelists cached before this upgrade keep null parent/order until re-fetched on TTL expiry — deletecache.dbto refresh immediately - cross-provider: providers with flat/unordered codelists (e.g. Eurostat) yield null parent/order; verified ISTAT
CL_ITTER107(12,471 codes → 12,465 parents, 12,471 orders) and Eurostat geo (4,292 codes, all null) - test: 4 new tests in
test_codelist_hierarchy.py(parsing, regression guard, save/read round-trip, in-place migration)
- feat:
opensdmx get --labelsappends a<dim>_labelcolumn with the human-readable name for each dimension code (resolved from the codelist cache, in the provider's language); codes are preserved, label column mirrors the data column case +_label. Inspired by rsdmx'sas.data.frame(labels=TRUE) - feat: the
--labelschoice is serialized to the YAML query file and honored byopensdmx run - core: new
enrich_with_labels(dataset, data)helper inretrieval.py, exported from the package - docs: document
--labelsin README and thesdmx-explorerskill (SKILL.md, visualization.md Rule 5, duckdb-setup.md) - test: 5 new tests in
test_labels.py(enrichment, no-codelist, unmapped→null, query-file round-trip)
- fix: SQLite schema is now initialized per DB file path instead of via a single module-level
_DB_INITIALIZEDflag (#42); switching provider (e.g.eurostat→oecd) no longer crashes withno such table: invalid_datasets - fix: harden the schema guard — key on the resolved absolute path (avoids relative-path/CWD aliasing) and re-create the schema when the DB file did not exist before connecting (covers
cache.dbdeleted during a long-lived process) - test: add
test_schema_init_per_db_pathregression covering the two-provider scenario
- docs: align operational documentation with current code — provider count/list, ISTAT 15s rate limit, 7d/30d cache TTLs, provider-key cache paths, rate-limit lock behavior, and
guideextra dependency split - docs: add current project evaluation for v0.10.3 in
docs/evaluation-v0.10.3.md; verification:207 passed,ruff check src/passed, full-repo ruff has minor test/script lint issues - note: strongest next priorities are documentation drift cleanup, dependency slimming, full-repo lint hygiene, and closing stale OpenSpec state
- feat: add
whichcommand — natural-language capability lookup; resolves a query to the best matching command from a curated index; exit code 2 on no confident match (agent-friendly); supports-o jsonfor pipeline use - feat: add ILO (ILOSTAT) and UNICEF portals — ILO uses standard
Accept: text/csv, UNICEF usesformat=csvdata; both support lastN and categories; constraints disabled for ILO (endpoint returns 500/413)
- fix: patch
_check_api_reachablein all 9test_tree_*tests — missing mock caused real HTTP requests to ISTAT in CI, where the provider is unreachable, making every tree test fail before the data mock could take effect
- feat: add
has_constraintboolean column to dataflow catalog (dataflows.parquet) — populated at catalog-build time via a single bulkcontentconstraintcall for providers that support it (currently ISTAT);get_available_values()skips the per-dataflow CC_ call when the catalog confirms no static constraint exists, avoiding repeated 60s+ timeout attempts on large dataflows - fix: pre-existing key-mismatch bug in bulk constraint parser —
_DF_-truncation produced short ids (e.g.41_269) that didn't match full-form catalog entries (e.g.41_269_DF_DCIS_INCIDENTISTR1_1); new_extract_bulk_long_idshelper and_match_catalog_idresolver handle both catalog id formats correctly - fix: constraint values for full-form catalog ids now correctly cached under the right key
- chore: document Eurostat bulk CC 405 via
constraint_bulk_supported: falseinportals.json
- fix: bulk CSV download no longer crashes when REF_AREA contains string codes like
IT—infer_schema_length=0andnull_values=[]prevent Polars from misinterpreting dimension codes as numeric or null; closes #32 - fix:
"Could not find dataset"error now names the active provider and suggests--provideralternatives; closes #33 - fix: large-dataset warning now explains that
--yestriggers a single wildcard REST request, not chunked calls; closes #35 - fix:
Saved:andQuery saved:messages moved from stdout to stderr for clean pipeline use; partial #34
- fix:
opensdmx valuesnow returns labels in the provider's configured language (was always returning English); closes #31 - fix: codelist cache is now language-aware — same codelist cached separately per language to prevent cross-language contamination
- feat:
--provider estatnow accepted as alias foreurostat— aliases are declared inportals.jsonunder the"aliases"field and resolved automatically at CLI startup
Empirical verification on 2026-05-10 against esploradati.istat.it shows that the
"hub fast path" introduced in v0.9.0 does not solve the territorial-dimension
discovery problem on ISTAT. The release notes implied a generic fix; the fix is real
only for small coded dimensions.
What was tested (two datasets: 22_289_DF_DCIS_POPRES1_24 "Tutti i comuni per
singola età" — 6 dimensions, and 41_269_DF_DCIS_INCIDENTISTR1_1 "Incidenti
stradali" — 10 dimensions):
- SDMX REST
availableconstraint/{df}— HTTP 000, 60 s timeout (as documented). - SDMX REST
contentconstraint/IT1/BL_…— HTTP 404 in 1.2 s. - SDMX REST
dataflow/?references=actualconstraint— HTTP 200 in ~5 s, butREF_AREAis absent from theCubeRegion(explicit<NOT_DISPLAYED title="NOTE_REF_AREA">annotation on the population dataflow; silently absent on the incidents one). - SDMX REST
dataflow/?references=all&detail=referencepartial— HTTP 200 in 34 s, 9.4 MB. The returnedCL_ITTER107codelist contains 12,471 codes — identical to the full codelist (codelist/IT1/CL_ITTER107/?references=none→ 12,471 codes too). - Hub
column/REF_AREA/partial/values— HTTP 200 in ~6 s, 12,471 codes, response body byte-identical between the population and incidents datasets (1,209,463 B). TheisSelectable: trueandisDefault: falseflags are uniform across all 12,471 codes and carry no discovery information.
Implications:
- For dimensions where ISTAT populates an actual content constraint (FREQ, DATA_TYPE, SEX, AGE, MARITAL_STATUS, MONTH, …) the hub fast path remains correct: it returns the codes actually used.
- For
REF_AREA(and likely for any large coded dimension) the hub returns the full codelist regardless of which territorial granularity the dataset actually exposes.opensdmx constraints <df> REF_AREA --provider istattherefore reports values that are not all queryable, while presenting them as if they were. - There is currently no documented or undocumented endpoint on ISTAT that returns
"the territorial codes actually used in dataset X". Discovery must rely on
out-of-band signals (dataflow naming, category tree) plus a probe
GET /data/{key}?lastNObservations=1at the candidate granularity.
Documentation: docs/istat/hub-api.md updated today — caveats added to the bulk
and per-dimension endpoint sections, two new sections added: "Officially
documented SDMX REST constraint endpoints" (references=actualconstraint,
references=all&detail=referencepartial) and "Critical limitation: REF_AREA
discovery on ISTAT" with the full comparison table.
Follow-ups (not yet done):
- Print a runtime warning from
opensdmx constraints <df> REF_AREAon ISTAT when the response is the full codelist, so users do not mistake it for filtered data. - Open a tracking issue mirroring this errata.
- Consider amending the v0.9.0 release notes on GitHub with a link back to this entry.
- fix(hub): replace 10 sequential
column/{dim}/partial/valuescalls with a singlecolumns/partial/values(plural) request that returns all dimensions at once (~2 s vs 10–15 s). Discovered via Playwright network inspection: the Data Browser UI has always used the bulk endpoint; opensdmx was using the slower per-dimension variant. - fix(hub): add
_get_all_dimension_values_via_hub_bulk()— falls back to sequential per-dimension calls only when the bulk response is incomplete. - test(hub): 7 new tests covering bulk parsing, TIME_PERIOD exclusion, error paths, and the bulk-first / sequential-fallback dispatch logic. Suite: 195 tests, all pass.
- docs(istat):
docs/istat/hub-api.mdupdated — bulk endpoint documented as preferred path, single-dim endpoint moved to fallback section.
- feat(hub): new
src/opensdmx/hub.pymodule wrapping the.Stat Suitedatabrowser hub API (/databrowserhub/api/core/nodes/{node}/datasets/{id}/column/{dim}/partial/values). Per-dimension JSON lookups in ~500 ms, never go through the slowavailableconstraint/serieskeysonlycross-join. Reference:docs/istat/hub-api.md. - feat(discovery):
get_available_values()tries the hub right after the SQLite cache and before the existing bulk + availableconstraint + serieskeysonly chain. Hub usage is opt-in per provider viahub_base_urlinportals.json; on any hub failure (HTTP error, parse error, partial result) the original chain runs unchanged. - feat(portals): ISTAT gains
hub_base_url,hub_node_id,hub_dataset_agency,hub_timeout. No other provider is touched — Eurostat, OECD, ECB, ABS, BIS, IMF, Bundesbank, INSEE, World Bank, Comext, Derzhstat all keep their existing path byte-for-byte.OPENSDMX_DISABLE_HUB=1opts out at runtime. - fix(istat):
41_270_DF_DCIS_MORTIFERITISTR1_1(11 dimensions, ~573 k observations) now resolves all dimension values in ~7 s end-to-end. Previously: timeout onavailableconstraintafter 30 s, timeout onserieskeysonlyafter 30 s, no usable output. Verified live on 2026-05-10. - test: 21 new tests in
tests/test_hub.pycovering enable/disable, URL/identifier build, JSON parsing, error fallback (HTTP error, timeout, malformed JSON, partial failure), TIME_PERIOD exclusion, graceful contract for direct calls on non-hub providers, and discovery integration on both ISTAT-like and non-hub providers. Two existing ISTAT regression tests intest_http.pyopt out of the hub explicitly so they keep guarding the SDMX-REST availableconstraint and bulk contentconstraint codepaths. Total: 186 tests pass.
- feat(base):
_use_split_rate_limit(is_data)— returns True only whenis_data=Trueand the provider definesdata_rate_limit. Keeps the unified timer for all providers without that key, preserving identical behavior for ISTAT, Eurostat, Derzhstat, and all others. - feat(base):
_rate_limit_file/_rate_limit_lock_file/_rate_limit_checkacceptis_data=False. When split is active, data calls use{provider}.data.log+{provider}.data.lock; structure calls keep{provider}.log+{provider}.lock— fully independent clocks and locks. - feat(base):
sdmx_requestgains_is_data=Falsekwarg, propagated to the rate-limit machinery inside_do_request. - feat(base): all three data paths in
sdmx_request_csvpass_is_data=True(World Bank JSON, Eurostat SDMX-CSV, generic CSV). - feat(portals): OECD gains
"data_rate_limit": 60(1 data download/min, per official OECD docs). Structure calls (search, info, constraints) are unthrottled. - test: 6 new tests in
TestSplitRateLimitcovering split/no-split detection, lock file naming, and correct lock selection per call type. Updated two existing_rate_limit_checkmocks to accept the newis_datakwarg. Total: 165 tests pass.
- fix(portals): added
constraint_params: {}to ABS entry — the server returns 500 whenreferences=noneis sent as query parameter (default for all providers); omitting all params gives 200 and correct constraint data. - probe(issue #27): BIS, Derzhstat, IMF already work via
availableconstraintwithout any portals.json change; the original probe only testedcontentconstraint, which those providers don't implement. ABS was the only broken case. - Updated
tmp/contentconstraint-probe.mdwith current findings.
- feat(discovery):
_parse_serieskeys_xml()parses GenericDatadetail=serieskeysonlyresponses into{dim_id: [unique sorted values]}. - feat(discovery):
_fallback_serieskeysonly()sends an all-wildcard data request withdetail=serieskeysonlywhenavailableconstrainttimes out. Results are cached in SQLite identically to normal constraint data. - feat(discovery): both
ConstraintsTimeoutcatch-points inget_available_values()now chain to_fallback_serieskeysonlybefore re-raising — covers both the bulk path and the contentconstraint-404 path. - feat(cli): updated
ConstraintsTimeouterror message to mention that both fallbacks were attempted. - test: added
test_parse_serieskeys_xml,test_get_available_values_serieskeysonly_fallback, updated existing timeout test to reflect double-fallback chain. All 159 tests pass.
- feat(discovery):
_fetch_and_cache_bulk_constraintsfetchescontentconstraint/{agency_id}in one call, parses all 73 constraints (43 unique short df_ids), and populatesavailable_constraintsfor each covered dataflow. Multiple constraints for the same df_id are merged by union per dimension. - feat(discovery):
get_available_valuesnow uses a bulk index for providers withconstraint_bulk_supported: true. For covered df_ids: returns cached bulk data instantly. For uncovered df_ids (~99 % of ISTAT catalog): skips the 404 roundtrip entirely and goes straight to_fallback_availableconstraint. - feat(db_cache): two new SQLite tables —
bulk_constraint_fetch(records last bulk fetch per agency, TTL 7 days) andbulk_constraint_index(set of short df_ids covered per agency). - feat(portals): added
constraint_bulk_supported: trueto ISTAT entry. - docs: added P-ISTAT-02 to
docs/provider-proposals.md— request to extendcontentconstraintcoverage from 43 to all 4,836 ISTAT dataflows. - Bonus: bulk merge reveals
REF_AREA(139 regional/provincial codes) forDCIS_POPRES1— previously reported as absent. Now cached automatically.
- fix(discovery):
get_available_valuesnow falls back toavailableconstraintwhencontentconstraintreturns 404. Previously, a 404 was silently swallowed (logged as a warning, returned{}), leaving the user with no constraint data and no recovery path. Now: if the provider usescontentconstraintand gets 404,_fallback_availableconstraintconstructs the all-wildcard SDMX key (N-1dots forNdimensions) and retries against theavailableconstraintendpoint. - feat(portals): added
constraint_fallback_timeout: 30to the ISTAT entry. The fallback uses a shorter 30 s timeout (vs. the normal constraint timeout) to fail fast on datasets whereavailableconstraintis also slow (e.g. large municipal datasets like road accident data). On timeout,ConstraintsTimeoutis raised — the same exception already handled by the CLI. - refactor(discovery): extracted
_parse_constraint_xml(content)helper, reused by both the primary path and the fallback. Eliminates duplicated KeyValue parsing logic. - test: 3 new unit tests in
tests/test_discovery.pycovering contentconstraint-200, contentconstraint-404→fallback-200, and contentconstraint-404→fallback-timeout — all mocked, no network calls.
Note: the road-accident dataset (41_270_DF_DCIS_MORTIFERITISTR1_1) that motivated this fix will still raise ConstraintsTimeout on the fallback (its availableconstraint endpoint is slow regardless of timeout). For that dataset the only reliable path remains Fallback B: probe GET with a narrow time range + duckdb DISTINCT REF_AREA (documented in skills/sdmx-explorer/references/istat-flow.md).
-
feat(istat): switch ISTAT to
contentconstraint—opensdmx constraintson ISTAT goes from always timing out on large municipal datasets (e.g.22_289_DF_DCIS_POPRES1_24~77 s before timeout) to sub-second response. Probed 6 providers (Eurostat baseline + ISTAT + ABS + BIS + IMF + Derzhstat) and only ISTAT gains from the switch in this iteration; the other 4 either return 404/401/204 oncontentconstraint. Probe details intmp/contentconstraint-probe.md. Removed from the ISTAT entry the obsoleteconstraint_path_suffix,constraint_params,constraint_timeoutandconstraint_max_retries(those were specific toavailableconstraint). -
feat(cli): inline hint for dimensions absent from
contentconstraint. ISTAT'scontentconstraintdoes not exposeREF_AREA(8k+ comuni would inflate the payload). Theconstraintssummary table now shows missing dimensions with–and a hint pointing at the codelist endpoint:REF_AREA – not in contentconstraint — use: opensdmx values 22_289_DF_DCIS_POPRES1_24 REF_AREAIn
--output jsonmode each dimension entry carries asourcefield ("constraint"or"missing") and missing entries include ahintfield with the exact follow-up command. Single-dim mode (opensdmx constraints <df> REF_AREA) emits the same hint instead of a hard error and exits 0. -
docs(skill): rewrite
sdmx-explorer/references/istat-flow.mdfor thecontentconstraintflow (no more 30 s timeout warnings). UpdateSKILL.mdconstraints section with the missing-dim pattern,source/hintJSON fields, and ajqrecipe to list dims that need avaluesfollow-up.
- refactor(discovery): per-provider
constraint_timeout/constraint_max_retriesinportals.jsoninstead of hardcoded values indiscovery.py. Previously the 30 s + 1-attempt fast-fail was applied to every provider withconstraints_supported: true(Eurostat, ABS, BIS, IMF, Derzhstat), silently disabling the 3-retry policy for transient failures (5xx, NetworkError, TimeoutException) on backends that aren't slow. Now only ISTAT carries the override (constraint_timeout: 30,constraint_max_retries: 1); all other providers keep the module timeout (300 s) and 3 retries. Precedence:OPENSDMX_AVAILCONSTRAINT_TIMEOUTenv var > provider config > module default. CLI advisory generalized: gating onprovider.constraint_timeoutinstead ofagency_id == "IT1". - feat(discovery): dedicated short timeout for the
availableconstraintendpoint, configurable viaOPENSDMX_AVAILCONSTRAINT_TIMEOUT. When the provider's availability backend is slow or unresponsive (documented case: ISTAT, TTFB > 180 s with zero bytes received),opensdmx constraints/valuesnow fail fast instead of waiting up to 3 × 300 s on the global timeout. NewConstraintsTimeoutexception parallel toConstraintsUnavailable, exported from the package root. CLI prints a yellow advisory with the exact env var override. - feat(http):
sdmx_request/sdmx_request_xmlaccept optional_timeoutand_max_retrieskeyword overrides for per-call control of the HTTP timeout and retry attempts. - ux: ISTAT pre-warning before the first uncached constraints call now reflects the actual per-call timeout and the no-retry behavior (was a stale "30–120 s" estimate inherited from the old global-timeout regime).
- docs: add
docs/provider-proposals.md— empirically verified provider-side issues (P-ISTAT-01:availableconstraintkey filter accepted but ignored,contentconstraintavailable at 0.66 s; see issue #24). - docs(skill): update
sdmx-explorer/references/istat-flow.md— expand Step 3 with timeout behavior,OPENSDMX_AVAILCONSTRAINT_TIMEOUTenv var, empirical findings on key filter, and concrete fallback flow for municipal-level datasets.
- fix(http): retry only on transient failures in
sdmx_request— the previous@retrydecorator had noretry=predicate, so tenacity retried on any exception, includinghttpx.HTTPStatusErrorfor 4xx codes. On rate-limit-by-IP providers (e.g. ISTAT) this could turn a single 429 into three back-to-back hits and amplify the risk of a multi-day ban. Now retries are restricted tohttpx.TimeoutException,httpx.NetworkError,httpx.RemoteProtocolError, and 5xx server errors (excluding 501 Not Implemented). Predicate exposed as_is_retryable_exceptionfor testing.
- ux:
constraintssummary table now shows a tip to use--output jsonfor the full list of values
- fix: force
OBS_VALUEtopl.Float64in CSV parsing — Polars inferredi64from the first 10 000 rows and crashed on the first float value past the inference window (reported on DerzhstatDF_POPULATION_BIRTH) - fix: apply dimension filters client-side for portals with
data_key_format: "empty"(e.g. Derzhstat) — previously--DIM VALUEflags were silently ignored, causing full-dataset downloads instead of filtered results - fix: force UTF-8 on stdout/stderr on Windows (
sys.platform == "win32") to preventUnicodeEncodeErrorin PowerShell/cmd when Rich tables emit non-ASCII characters (Cyrillic labels, box-drawing) - fix(portals): update Derzhstat description — wildcard dots in key-filter path return 404; dimension filters are now applied client-side after download
- feat: add
--header "Name: Value"option (repeatable) toget,constraints,values,info,treecommands — allows passing custom HTTP headers per invocation (e.g. API keys required in headers, not query string) - feat: expose
set_extra_headers()/get_extra_headers()in Python public API (opensdmx.base, re-exported fromopensdmx) - Extra headers merge over defaults;
User-Agentis protected and cannot be overridden via--header
- fix: always parse
OBS_FLAG,OBS_STATUS,CONF_STATUSas strings in CSV responses — Polars was inferring these SDMX observation attribute columns asi64, crashing when a provider returned flag codes such asc(confidential); confirmed on DerzhstatDF_SALARY_PAYMENT_STATUS
- feat: add
OPENSDMX_USER_AGENTenv var to override the HTTPUser-Agentheader globally - feat: add
user_agentfield to portal entries inportals.json— per-portal default User-Agent - feat: add
data_key_formatfield to portal entries ("dots"default |"empty") — controls whether the SDMX dimension key segment is appended to data URLs; providers that reject the dot notation (e.g. Derzhstat) use"empty" - feat: add
derzhstatpreset portal (State Statistics Service of Ukraine, agency SSSU) — 112+ dataflows including consumer prices and wages, accessible without authentication
Named after Nadiia Babynska-Virna, whose work on building a local MCP server for Ukrainian statistics identified these blockers and inspired this release.
- fix(tree):
opensdmx treeno longer downloads the full dataflow catalog during the stale-categorisation warning path on first run; the warning now uses cached dataflows only, soLoading category tree...does not stay active for unrelated network work - docs: add an explicit subrelease section to
docs/release.md - tests: add regression coverage to ensure
load_categories()does not callall_available()when no dataflow cache is present
- feat: parse
Category.descriptionalongsideCategory.nameand expose it ascat_descriptionin both the cache schema and--output json|csv - feat: ASCII tree appends the description dimmed+italic after the cat_id marker when populated — helps disambiguate opaque cat_ids when the provider fills the field
- note: ISTAT populates
Descriptionon ~1/1200 categories; Eurostat and OECD usage varies by scheme. For categories withoutDescription, rendering is unchanged - cache: the loader invalidates caches missing the new
cat_descriptioncolumn, forcing a fresh fetch on first use after upgrade - tests: 1 new pytest; 21/21 passing
- feat:
opensdmx tree --show-dataflows(alias-l) inlines dataflow leaves under each category in the ASCII rendering, labelled[df:ID]. Usesall_available()for dataflow descriptions — all data come from cache, no extra HTTP fetch. Stderr warning if output exceeds 200 dfs. - feat: the flag shortcuts the
tree --category X+search "" --category Xsequence when the user has picked a terminal branch — one command instead of two. JSON/CSV outputs remain categories-only (warning to stderr if--show-dataflowsis combined with--output json/csv). - skill: documents the shortcut and advises against using it on broad branches where it floods the output
- tests: 2 new pytest cases (df leaf rendering + regression safety); 20/20 passing
- feat: ASCII
treerendering now prefixes category IDs as[cat:ID](instead of[ID]) to disambiguate them from dataflow IDs - feat:
tree --categoryerror messages label dataflow IDs as[df:ID]and category IDs as[cat:ID], so the kind travels with the identifier when copied between terminals - skill:
sdmx-explorerdocuments the[cat:]/[df:]convention - tests: 1 new pytest verifies the
[cat:...]prefix in ASCII output; existing tests unchanged (18/18 passing) - CSV/JSON outputs unchanged — raw
cat_id/df_idcolumns preserved; the prefix lives only in ASCII rendering and error messages
- fix:
opensdmx tree --categorynow resolves categories bycat_path(previously only matched root-level cat_ids whosecat_id == cat_path; nested categories silently returned an empty subtree) - fix:
--depthis relative to--categorywhen set —--depth 1always means "node + direct children", fixing theOutOfBoundsErrorcrash when the chosen category sat deeper than the absolute depth limit - fix: passing a dataflow ID to
--categorynow yields a clear message ("is a dataflow, not a category") with suggestedinfo,search --category, andtree --categorycommands using the parent category. When the dataflow is categorised only under a different scheme, the hint surfaces the scheme mismatch instead of pointing at an unrelated cat_id. - fix:
[cat_id]labels in tree rendering now survive Rich markup — escaping the opening bracket makes them visible on Eurostat (where cat_ids liket_bop_6were previously consumed as style tags) - fix: empty subtree guard prints a descriptive message instead of crashing at
.row(0)on an empty polars frame - skill:
sdmx-explorerpromotestreeas primary discovery path (Step 1b); keywordsearchdemoted to Step 1c fallback; new hierarchical drill-down protocol mandates--depth 1at every level to avoid dumping large trees; new "cat_id vs df_id" guidance - tests: 4 new pytest cases on
treeerror paths and relative-depth semantics
- fix: semantic search now uses
search_document:/search_query:prefixes required bynomic-embed-text-v2-moe(without prefixes the model returned essentially random rankings on short queries) - feat: embedding text now includes
df_idand (when the category cache is populated) the dataflow's scheme/category names — dramatically improves recall on short titles (e.g.Prezzi al consumo→CPI Prezzi al consumo Prezzi) - docs: README documents richer embeddings via
opensdmx treeand adds tips on writing better semantic queries
- feat:
tree --category <cat_id>— browse subtree rooted at a category (requires--scheme) - fix:
tree --schemenow shows hint when a category ID is passed instead of a scheme ID (suggests correct--scheme --category) - fix:
load_datasetnow case-insensitive fordf_idanddf_structure_id—opensdmx info cpiandopensdmx info CPIboth work - docs: add
### Thematic treesection in README with real Eurostat 3-step tutorial (schemes → tree browse → category filter); shows cat_id retrieval via CSV output andsearch --categorydelta (502 → 1 result)
- fix: use SDMX 2.1 structure Accept header in
sdmx_request_xml; fixes OECD returning JSON on/dataflow(closes #15); unblocksall_available/searchon OECD and fillsdf_descriptioninsiblingsoutput - feat: add thematic category tree (SDMX categoryscheme + categorisation) via new
opensdmx treecommand (ASCII in table mode, flat JSON/CSV otherwise);--schemerenders tree,--depthlimits nesting - feat: add
--categoryfilter toopensdmx search(leaf id or dotted path); matches dataflow through categorisation - feat: add
opensdmx siblings <df_id>— shows dataflow siblings in each category (one group per membership); surfaces related variants that text search misses - feat: new
src/opensdmx/categories.pywith two-parquet cache (categories.parquet,categorisation.parquet), lazy fetch on firsttreecall, 7-day TTL viaCATEGORIES_CACHE_TTL, stale-df warning to stderr - feat: add
categories_supportedflag toportals.json(true: eurostat, istat, ecb, oecd, insee, abs, bis; false: comext, bundesbank, worldbank, imf) + new column inopensdmx providers - chore: add read-only MCP tool allowlist to
.claude/settings.local.json(mempalace search/get/list, playwright network/snapshot/screenshot, chrome-devtools network tools)
- chore: bump version to v0.3.34
- perf: memoize
_resolve_cache_base()viafunctools.lru_cache, keyed onOPENSDMX_CACHE_DIR, so the write-probe runs at most once per process instead of on every HTTP request - test: isolate
TestRateLimitLockviamonkeypatch+tmp_path(OPENSDMX_CACHE_DIRoverride) so it no longer writes under the real user cache dir - docs: fix
_rate_limit_check()docstring to reference the per-user rate-limit dir instead of/tmp - chore: bump version to v0.3.33
- fix: move rate-limit lock and timestamp files from the shared system tempdir to the per-user cache base (via
platformdirs), avoiding cross-user interference on multi-tenant hosts; cross-OS by construction - fix: derive provider cache/lock key from base URL hash when custom provider has no
agency_id, so two such providers don't share the same lock and get serialized together - test: cover cross-process flock — assert
portalocker.Lockis called with the per-provider lock path - chore: bump version to v0.3.32
- fix: serialize HTTP calls per provider with
portalockerflock, so the rate limit holds across concurrent processes (parallel timestamp-file readers could previously defeat it) - chore: bump ISTAT rate_limit 13s → 15s for a safer margin
- chore: bump version to v0.3.31
- fix(istat): use
/all/all?mode=availablefor constraints endpoint, returning only codes actually present in each dataflow (removes ambiguity between multiple "total" codes likeTvs9for SEX) - feat(cli): warn before first (slow) ISTAT constraints call; warning suppressed on cache hits
- fix: rate limiter records timestamp at HTTP call start (not end), so the 13s interval is measured from request sent, not response received
- feat: add Eurostat Comext as named provider (
--provider comext) for DS-prefixed datasets - fix: cache and rate-limit keys now use provider alias instead of agency_id, preventing collision between eurostat and comext (both ESTAT)
- chore: bump version to v0.3.27
- fix: replace warnings.warn with logging.warning in discovery.py and base.py
- chore: bump version to v0.3.26
- feat(search): add spinner during semantic search
- feat(plot): add xkcd theme support via --theme xkcd
- chore: bump version to v0.3.25
- feat: expose
__version__viaimportlib.metadata - fix: replace
assertwith proper error handling in guide.py - chore: add PyPI classifiers and keywords to pyproject.toml
- chore: bump version to v0.3.24
- docs(skill): update sdmx-explorer with providers capability columns
- chore: bump version to v0.3.23
- feat(providers): fill all capability columns from probe run; fix probe_providers.sh (IMF filters, OECD dataset)
- chore: bump version to v0.3.22
- feat(scripts): add probe_providers.sh — tests constraints and last_n for all built-in providers, saves report to tmp/
- feat(providers): add constraints and last_n capability columns; mark known support in portals.json
- chore: bump version to v0.3.21
- fix(discovery): resolve codelist from concept scheme when DSD lacks LocalRepresentation (fixes
valuesfor IMF provider)
- chore: bump version to v0.3.20
- feat(cli): add --grep flag to
valuesandconstraintscommands — filters codelist/constraint results by regex (case-insensitive, matches id or name)
- docs(validation): update validation-statgpt.md — add Round 2 (IMF WEO, 3 agents, 42/42 match); now covers both OECD and WEO convergence tests
- chore: bump version to v0.3.19
- feat(portals): add IMF as built-in provider (
--provider imf) — base URLhttps://api.imf.org/external/sdmx/2.1, agencyIMF.RES,catalog_agency=all; WEO dataflow confirmed working (3 dimensions: COUNTRY, INDICATOR, FREQUENCY) - docs(readme): add framing paragraph on AI+statistics accuracy problem, citing IMF StatGPT paper (2026)
- docs(validation): add
docs/validation-statgpt.md— public validation doc inspired by StatGPT paper; 3-agent convergence test (42/42 identical values), cross-source accuracy, repeatability with provenance metadata; linked from README - docs(validation): add
tmp/statgpt-tests/REPORT.md— full technical report with all 8 tests
- fix(worldbank): handle missing observation values (
[,0]→[null,0]) in SDMX-JSON responses — World Bank API returns invalid JSON for null observations
- feat(plot): add
--themeoption — supportsminimal(default),bw,classic,538,tufte,void,dark,light,gray - docs(skill): add
--themeto visualization.md options table - chore: bump version to v0.3.17
- feat(plot): add
--geom heatmapusinggeom_tile—--x= columns,--color= rows,--y= fill intensity - docs(skill): add heatmap to visualization.md geom table
- chore: bump version to v0.3.16
- feat(plot): add
--x-allflag to force all x-axis tick labels on discrete axes (e.g. quarterly labels) — usesscale_x_discrete(limits=...), works with--facetand--color - docs(skill): update visualization.md with
--x-all,--rotate-x,--colorsoptions and "missing x-axis labels" fix - chore: bump version to v0.3.15
- fix(cache): switch SQLite journal mode from WAL to DELETE — WAL creates
-wal/-shmfiles that cause "database is locked" errors when accessing the cache across WSL/Windows filesystem boundaries (closes #7) - chore: bump version to v0.3.14
- fix(cache): SQLite connections were never closed — replaced raw
sqlite3.connectcontext manager with proper open/commit/close pattern, added WAL mode and timeout to prevent lock contention - chore: bump version to v0.3.13
- fix(plot):
--geom barhnow renders correctly — fixed axis swap (--x= value,--y= category), prevented numeric value column from being cast to string, and corrected axis labels aftercoord_flip - docs(skill): update visualization.md — document
barhaxis convention (--x= value,--y= category)
- docs(skill): human-readable labels required in charts (Rule 5 in visualization.md)
- docs(skill): generalize Rule 3 — never mix individual units with aggregates
- docs(skill): add filter coherence principle to SKILL.md (individual vs. aggregate codes)
- chore: bump version to v0.3.10
- feat(cache): flexible cache directory —
OPENSDMX_CACHE_DIRenv var,platformdirs(XDG on Linux, OS-native on macOS/Windows),/tmp/opensdmx-{user}fallback if nothing is writable (closes #6)
- fix(cache): all SQLite
save_*calls wrapped in isolated try/except — cache failures no longer discard successfully fetched API data - fix(values):
opensdmx valuesno longer fails with "readonly database" when cache write fails; returns data anyway - fix(constraints):
opensdmx constraintswarning message now distinguishes cache errors from API errors
- feat(plot): add
--rotate-x Nflag to rotate x-axis labels by N degrees - feat(plot): add
--colors 'hex1,hex2,...'flag for custom categorical color palettes - fix(plot): bar/barh charts now display integer years (e.g. 2022) correctly instead of as dates (2022-01-01)
- docs(skill): update sdmx-explorer skill — fix facet note in SKILL.md, add x-axis label overlap fix in visualization.md
- docs(skill): add
references/color-guide.mdwith Okabe-Ito and Set2 palettes, colorblind rules, and plotnine snippets - docs(skill): add
references/cli-templates.mdwith ready-to-use parameter combinations for common chart scenarios
- feat(info): show Eurostat dataflow page URL in
opensdmx infooutput (table and JSON mode) - feat(portals): add
dataflow_page_urlfield to Eurostat entry in portals.json
- feat(plot):
--facet/--ncoloptions forfacet_wrap(small multiples) - feat(plot):
--timealias for--x(more intuitive column name override) - fix(plot):
schema_overridesnow uses the actual--xcolumn name instead of hardcodedTIME_PERIOD - fix(plot): error message now lists only the missing column(s) instead of both
- docs(skill): update visualization.md with facet options and small-multiples guidance
- feat: machine-to-machine output — global
--output table|json|csvflag on all metadata commands (search,info,values,constraints,providers); stdout = pure structured data, stderr = errors/warnings; spinners suppressed in non-table mode - feat: relevance ranking for standard search — multi-token AND filter on
df_description+df_id, synthetic score (id match ×3, start-of-desc ×2, occurrence count ×1), results sorted by score - feat: search default page size 20→50
- feat: search table now shows
scorecolumn
- docs: expand semantic search section in README with comparison table, 3 real examples, and sample output
- feat: add --all and --page to search command (paginate cache results)
- chore: bump version to v0.3.2
- feat: expose
run_query(),semantic_search(),build_embeddings()in public Python API - docs: add
sdmx-explorerskill installation guide (docs/skill/README.md) with screenshots - docs: add missing functions to Python API table in README
- docs: improve docstrings for
semantic_searchandbuild_embeddings
- test: add test_cli.py with 12 tests for _parse_extra_filters, _apply_provider, CLI commands
- fix: World Bank provider now works for data requests (closes #5)
- add
data_acceptfield in portals.json → sends correct Accept header for SDMX-JSON - add
data_path_suffix: "/"→ trailing slash required by WB API to avoid 307 redirect - add
follow_redirects=Trueto httpx.Client (global fix) - add
_parse_sdmx_json()parser for SDMX-JSON 1.0 responses - handle World Bank non-standard key order (dimensions sorted by keyPosition descending)
- add
- chore: bump version to v0.3.0
- feat: add
--query-fileoption togetcommand — saves query as YAML for later reuse - feat: add
runcommand — executes a query from a YAML file, output to stdout or--out - feat: YAML captures provider alias, provider_url, agency_id, filters with labels from cache
- feat:
runresolves provider via alias → URL+agency_id fallback chain - feat: add
build_query_dict()helper inutils.py - dep: add
pyyamldependency - docs: update README with query file workflow and
runcommand - docs: update sdmx-explorer skill to suggest saving queries as YAML templates
- chore: bump version to v0.2.9
- feat: add BIS (Bank for International Settlements) as built-in provider
- chore: bump version to v0.2.8
- feat: add
providerscommand listing curated SDMX providers with alias, name, description, agency - feat: add
descriptionfield to all 8 providers in portals.json
- chore: bump version to v0.2.7
- refactor: extract guide() logic to guide.py; cli.py -358 lines
- feat: add [guide] optional extras (chatlas, questionary); remove dead deps (duckdb, inquirerpy)
- test: add 11 mocked HTTP tests (sdmx_request_csv, all_available, get_data)
- fix: replace silent except Exception with specific exception types in discovery, embed, cli, guide
- chore: bump version to v0.2.6
- fix(bundesbank): add datastructure_agency=BBK to portals.json (ALL not accepted by Bundesbank API)
- fix(utils): handle default xmlns (prefix=None) in xml_parse() for known canonical namespaces — fixes World Bank WDI discovery
- fix(utils): xml_parse() now collects namespaces from all elements (not just root) — fixes World Bank support where
structurens is declared on a child element — closes #4
- fix(bundesbank): add
metadata_prefix: "metadata"to portals.json; add_struct_path()helper in discovery.py to prepend prefix to dataflow/datastructure/codelist paths — fixes #3
- Test: full workflow tested for
oecd,bundesbank,worldbankproviders - OECD: all 5 steps work (search, info, values, constraints, get)
- OECD: official rate limit is 60 data downloads/hour — applies to
getonly, not structure/metadata calls (search, info, values, constraints); currentrate_limit: 0.5in portals.json is fine - ECB, Eurostat: no rate limit officially documented
- Bundesbank: all steps fail —
/rest/dataflow/BBKreturns 404 ("Unknown path"); API routing broken - World Bank: all steps fail —
xml_parse()misses namespaces declared on child elements (not root);WDIdataflow exists but never discovered
- Feat:
getwarns when dataset has >5,000 series and no filters/limits are set - Feat:
get --yes/-yflag to bypass large-dataset confirmation - Probe request (
lastNObservations=1) used to count series before full download
- Fix: Polars dtype crash on mixed TIME_PERIOD (e.g. ECB ICP) — force Utf8 in CSV read
- Fix:
--outwith unsupported extension (e.g..xlsx) now raises clear error instead of silent wrong write - Fix:
--geom scatteraccepted as alias forpointinplot - Fix: invalid
--providername shows clean error with list of valid providers - Fix: dimension ID in
valuesandgetis now case-insensitive (e.g.FREQ=freq) - Fix:
UserWarningfor unknown dimension replaced with rich console warning - Fix:
blacklist --removereports "Not found" when entry doesn't exist (was always "Removed") - Fix:
search --nnow works in keyword mode too (was semantic-only) - Fix: default plot filename now uses dataset ID/stem instead of
chart.png - Fix: local CSV plot: TIME_PERIOD parsed as date so
scale_x_dateapplies correctly - Feat:
OPENSDMX_PROVIDERandOPENSDMX_AGENCYenv vars for session-wide provider config - Feat: custom SDMX URL accepted as
--providervalue (agency_idnow optional) - Feat:
searchshows total match count in table title (e.g. "10 of 8037") - Feat:
search --ndefault raised from 10 to 20 - Tests: 57 → 64 tests (new:
test_base.py, extendedtest_db_cache,test_discovery) - Docs: README updated — providers, env vars, output formats, examples
- ECB deep test: P1 URL bug (get without filters) NOT reproduced — resolved or was dataset-specific
- ECB deep test: P1 duplicates in
valuesNOT reproduced — resolved - New bug found:
get ICP --provider ecbcrashes with Polars dtype inference error on YYYY-MM TIME_PERIOD - Results documented in
tmp/ecb-test-results.md, evaluation updated intmp/cli-evaluation.md
- Fix: CI workflow — add ruff to dev dependencies, fix f-string and F401 lint errors
- Fix: configure ruff to ignore E402 (intentional import patterns)
- Feat:
plotcommand accepts local files (.csv, .tsv, .parquet) as input
- Translate all remaining Italian UI strings in
ai.pyandcli.pyto English - Remove Gemini-based query expansion from semantic search — now Ollama-only
- Remove
--no-expandand--verboseCLI flags fromsearch --semantic - Add synonym tip to
searchhelp and README - Document
GOOGLE_API_KEYrequirement in.env.example(forguideonly) - Add 22 tests:
test_discovery.py(set/reset_filters),test_db_cache.py(all SQLite cache ops)
- Fix: removed production debug log (
/tmp/guide_debug.log) fromai.py - Fix:
get_name_by_langcrashed on XML without "common" namespace - Feat: test suite from 4 to 35 tests — coverage on
utils.py,parse_time_period(),make_url_key() - Feat: CI with GitHub Actions (pytest + ruff on push/PR)
- Production-readiness evaluation in
docs/evaluation.md
- Feat: cache TTL configurabile via env vars (
OPENSDMX_DATAFLOWS_CACHE_TTL,OPENSDMX_METADATA_CACHE_TTL,OPENSDMX_CONSTRAINTS_CACHE_TTL); default sensati,.env.exampleaggiunto - Feat: setup testing con pytest, primi test su
cache_config - Feat: CLI agent-friendly —
guide --yes --dataset(non-interactive, auto-download),blacklist --remove, fail-fast for missing embeddings, examples with default provider note in all--help
- Fix
guide: removedlookup_dimension_valuesfrom AI model — now uses only constraint values - Fix
guide: "tutti"/"all" → selects TOTAL if available, otherwise no filter (never all individual codes) - Feat
guide: new flow — AI reads all constraints on first turn and proposes 2-3 tested scenarios; user chooses or customises dimension by dimension
- Fix:
_rate_limit_checknow writes tosys.stderrinstead ofprint()— avoids polluting stdout in pipes - Fix: HTTP errors now show status code and URL; suggest
opensdmx constraintson 400/404; usereraise=Truein tenacity to avoid wrapping inRetryError - Refactor: extracted
_parse_extra_filters()— removes duplication betweengetandplot - Feat:
get/plotsupport multiple values per dimension:--geo IT --geo FR→IT+FRin URL - Fix: removed hardcoded
curlfromguideresults panel
-
opensdmxwith no arguments shows version + help (instead of doing nothing) -
Fix OECD provider support:
search,info,constraints,getnow workportals.json: addedcatalog_agency: "all"(endpoint/dataflow/all) andconstraint_params: {}(noreferences=nonewhich returned 500)discovery.py: usescatalog_agencyfor catalog path; savesdf_idas{agencyID},{id}(e.g.OECD.SDD.STES,DSD_STES@DF_CLI) to correctly build data URLs- Eurostat and ISTAT providers unchanged
- New
opensdmx constraints <dataflow_id> [dimension]CLI command- No dimension: summary table (
dimension_id,n_values,samplefirst 3 codes) - With dimension: full
id/nametable of codes actually present, labels from codelist --providerflag supported; empty result surfaces as explicit error- Reuses existing
get_available_values()+ 7-day SQLite cache — zero new infrastructure
- No dimension: summary table (
- ISTAT CLI tests completed:
search,info,values,get,get --out,get --last-n,getmultiple values,plot,plotwith options — all ok - Reference dataset:
168_2withDATA_TYPE=13,MEASURE=4,REF_AREA=IT,COICOP_REV_ISTAT=00 - Fix bug:
plotignored--start-period/--end-period(treated as dimensions); added as typer options and passed toget_data()— #1 - Updated
docs/cli-test-examples.mdwith correct ISTAT example
portals.json: addeddata_format_paramfor Eurostat (SDMX-CSV); fixesget/plotcommands that returned 406base.py:sdmx_request_csvnow usesformat=query param when provider hasdata_format_param, elseAccept: text/csv(ISTAT and others)embed.py: query expansion via Gemini before embedding (_expand_query); translates to English + adds synonyms; default on,--no-expandto skip,--verboseto show expanded query- Tried replacing Ollama with
fastembed(nomic-ai/nomic-embed-text-v1.5-Q) — reverted: quality inferior especially for Italian queries;nomic-embed-text-v2-moevia Ollama remains the embedding backend - Added
docs/cli-test-examples.mdwith all non-AI CLI examples (Eurostat tested, ISTAT pending)
- New
portals.jsonbundled with 8 SDMX portals (eurostat, istat, ecb, oecd, insee, bundesbank, worldbank, abs) base.py:PROVIDERSloaded from JSON with_DEFAULTSmerge; custom providers get defaults toodiscovery.py:dataflow_params,constraint_endpoint,datastructure_agencyread from provider config- Fixed XML namespace normalization (
s/c/m→structure/common/message) for cross-portal compat - Fixed
_check_api_reachable: catches all exceptions, uses GET not HEAD - Added spinner to CLI commands (
search,info,values,get) guide/search --semantic: prompts to build embeddings if cache missingembed.py: guards against empty catalog and corrupted cache- Renamed package
istatpy→opensdmx; CLI entry pointistatpy→opensdmx - New provider system:
set_provider("eurostat"|"istat"|url),get_provider(); default is Eurostat (ESTAT) - Cache namespaced per provider:
~/.cache/opensdmx/{AGENCY_ID}/dataflows.parquet+cache.db - Rate limit per-provider (Eurostat: 0.5s, ISTAT: 13s); temp file
/tmp/opensdmx_{agency_id}_rate_limit.log istat_dataset→load_dataset;istat_get→fetch;istat_timeout→set_timeout- Removed
df_description_itcolumn; description language driven by provider config - All CLI commands accept
--provider/-pflag ai.pysystem prompt is now provider-aware (referencesagency_id, language-agnostic rules)embed.pyupdated: cache path dynamic per provider, removeddf_description_itREADME.mdrewritten foropensdmxwith Eurostat as default
cli.py guide: final result also showsistatpy getcommand alongside URL and curldb_cache.py: addeddescriptioncolumn toinvalid_datasets(auto migration);save_invalid_datasetnow accepts description; addedlist_invalid_datasets()anddelete_invalid_dataset()cli.py: newblacklistcommand — lists blacklisted datasets and allows removal via interactive checkboxdocs/database.md: new file with schema of all cache files (parquet + SQLite) and Mermaid ER diagramREADME.md: link todocs/database.mdin Caching section; addedblacklistto commands table
db_cache.py: newavailable_constraintstable; get/save cached for 7 daysdiscovery.py:get_available_values()now uses SQLite cache; simplified endpoint (references=none)ai.py:lookup_actual_valuesusesget_available_values()(cached) instead of raw sample; removed initial sample fetchcli.py guidestep 6b: code validation againstavailableconstraint(more reliable than codelist)cli.py guidestep 6c: filter combination validation with real sample (lastNObservations=1+ active filters); warn if 404
ai.py: addedlookup_actual_values(dimension_id)tool — samples real data withlastNObservations=1at session start; returns actual values (e.g.UNEMP,1,2) instead of theoretical codelist codes (e.g.UNEM_TI,M,F)lookup_dimension_valueskept for text descriptions; system prompt specifies filters use onlylookup_actual_values- Sample downloaded once at session start (single extra API call)
db_cache.py: addedinvalid_datasetstable;save_invalid_dataset(),get_invalid_dataset_ids()discovery.py:all_available()automatically filters invalid datasetscli.pyguide: API availability check moved BEFORE AI session (after dataset confirmation); if unavailable → mark as invalid, return to selection without wasting AI time
get_data/istat_get: addedfirst_n_observations(firstNObservationsSDMX) alongsidelast_n_observations- CLI
get: added--start-period,--end-period,--last-n,--first-noptions ai.py: system prompt hardened — AI MUST calllookup_dimension_valuesbefore proposing any code, no invented codes
- Fix
make_url_key: "." values now mapped to empty string → correct SDMX URL (A....instead ofA........) guide_session: chatlas warnings suppressed;_chat()helper handlesget_last_turn()None-safeFilterItem.codes: list[str]: multi-value support for dimensions (e.g. SEX = M+F)- Filter validation in
guide: checks each code individually against actual values lookup_dimension_valuestool: AI verifies codes autonomously without delegating to user- Cache moved to
~/.cache/istatpy/;df_description_itadded to catalog
- Added
df_description_itto dataflows (Italian name from SDMXName xml:lang="it") embed.py: embedding text ="en / it"for better semantic search in Italiansemantic_search(): also returnsdf_description_itguide: shows Italian description in dataset selection list
- Cache moved from
/tmp/to~/.cache/istatpy/(persistent across reboots) base.py: addedCACHE_DIR = Path.home() / ".cache" / "istatpy"withmkdirdb_cache.py,discovery.py,embed.py: useCACHE_DIRinstead oftempfile.gettempdir()
- Replaced
wizardandaskwithguide: semantic search + multi-turn AI conversation for filters ai.py: removedfind_dataset/find_filters; addedguide_session(ds, objective)— chatlas multi-turn with Gemini, extracts filters with user confirmationcli.py: removedwizardandaskcommands; addedguide [query]— paginated dataset selection + interactive AI session + final SDMX URLdocs/PRD.md: created PRD for guide flow
istatpy ask [objective]: full AI flow — finds dataset via semantic search, then filters; step-by-step confirmation (dataset → filters → download);--outoptionai.py:find_dataset()(usessearch_datasetstool) +find_filters()(usesget_values_for_dimensiontool); two separate calls for progressive confirmation- deps: added
chatlas[google]>=0.7topyproject.toml
-
embed.py: vector embeddings via ollamanomic-embed-text-v2-moe(768 dim), cached in/tmp/istatpy_embeddings.parquet(~11MB) -
istatpy embed: builds embeddings cache;istatpy search --semantic: cross-language semantic search -
db_cache.py: SQLite cache/tmp/istatpy_cache.db(TTL 7d) for dimensions and codelist values — cold 52s → cached 0.0s -
Fix: use
ALLinstead ofIT1as agency ondatastructureandcodelistendpoints (fixes 404 on cross-agency datasets) -
istatpy wizard: interactive dataset discovery, paginated results, fuzzy value filtering (InquirerPy), auto-select DATA_DOMAIN, SDMX URL output -
Rate limit: countdown timer (updates every 0.2s), interval raised to 13s
- Translated all user-facing messages to English (CLI + rate limiter)
- Translated
tasks/todo.mdto English
- CLI
istatpywith 4 commands:search,info,values,get(Typer + Rich) getaccepts dynamic filters--DIM VALUEviatyper.Contextgetoutput: CSV to stdout or file (csv/parquet/json) with--out- API reachability check at startup: lightweight HEAD request if no rate-limit log exists
- Rate limiter: minimum 12s between API calls, log in OS temp dir
- Dataflow cache:
istatpy_dataflows.parquetin OS temp dir, TTL 24h (avoids repeated heavy call inistat_dataset()) - Added
pyarrowdependency (required forpolars.to_pandas())
- Created
istatpyproject withuv init --package - Added
httpx,tenacity,lxml,polars,duckdb,plotnine - Implemented modules:
base.py,utils.py,discovery.py,retrieval.py - Public API exported in
__init__.py - All functions mirror
istatR: same names, same signaturesall_available(),search_dataset(),istat_dataset()dimensions_info(),get_dimension_values(),get_available_values()set_filters(),reset_filters()get_data(),istat_get(),istat_timeout()
- DataFrame: Polars (not pandas)
- Charts: plotnine (unemployment example in README)