Skip to content

feat: add --explain to show the planned requests before they are made - #84

Merged
aborruso merged 3 commits into
ondata:mainfrom
paoValle:fix/explain
Oct 7, 2026
Merged

aborruso merged 3 commits into
ondata:mainfrom
paoValle:fix/explain

Conversation

@paoValle

@paoValle paoValle commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes #74.

I implemented the dry-run variant: --explain prints the plan to stderr and stops before the first request, so the request is never spent. That is the variant the rate-limit problem needs — a trace still spends the request you are trying to sanity-check — and it covers provenance too, since the plan names the exact URL, method and POST body.

What changes

  • One hook, in the shared request path. base.sdmx_request() reports the call and stops before the lock and the rate-limit timer. hub._hub_get_json() is hooked as well, because the StatKit hub builds its own httpx client and would otherwise really call it during a dry run. No per-command, per-provider or per-endpoint branching.
  • Cache hits report the request they replaced, with the same path and params their miss branch uses: dataflow catalog, datastructure, codelist (description and values), categoryscheme + categorisation. That is what makes cached vs network steps distinguishable, and it is why get can show the data URL at all — make_url_key() can read the dimension order from the cached structure.
  • No output and no product of a run. stdout stays empty in table/json/csv (the command console, _emit and _write_output are guarded), plot writes no chart (also for a local CSV input, which has no request to plan), get --query-file writes no YAML, and no provider data is written to the cache. Reading the local cache stays a read that can initialise it, as any command does — see the last note below.
  • ExplainStop derives from BaseException rather than Exception, so the callers' own except Exception handlers cannot report the plan's stop as Error: .... main() catches it and exits 0, and says explain: no provider request would be made. when the whole plan came from the cache.
  • Applies to the nine network commands listed in the issue. search --semantic is a no-op under --explain, since the Ollama query is not a provider request.
  • The flag is declared per command, next to --provider, so it is written after the command — opensdmx get ... --explain, as in the acceptance criteria — not opensdmx --explain get ....

Deliberately not done

  • No trace variant. The issue asks to pick one explicitly; a trace does not address the ISTAT IP block, which is the first problem it lists.
  • No invented URL for a cached step whose URL is not knowable. For hub-only providers (INPS) the same Parquet/SQLite caches are written by middleware calls whose URLs are not built where the cache is read, and a constraint entry in SQLite does not record whether the hub or availableconstraint produced it. Those steps get no [cached] line rather than a wrong one; a plan with no [would fetch] line and the closing explain: no provider request would be made. means everything needed was already local.

Verification

$ .venv/bin/python -m pytest tests/ -q
418 passed in 9.54s          # 404 before, 14 new in tests/test_explain.py (suite command from CI)
$ uv run ruff check src/
All checks passed!
$ .venv/bin/python -m mypy
Success: no issues found in 16 source files

Cold cache, fresh OPENSDMX_CACHE_DIR: the plan is printed, stdout is 0 bytes, exit 0, and no cache or rate-limit file is created — i.e. no request was made, including against the rate-limited provider, which is the whole point (ISTAT, rate_limit: 15.0):

$ opensdmx get NAMA_10_GDP --explain -p eurostat
[would fetch] GET https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/dataflow/ESTAT?detail=allstubs&references=none
$ opensdmx get 22_289 --explain -p istat
[would fetch] GET https://esploradati.istat.it/SDMXWS/rest/dataflow/IT1
$ opensdmx values PENSIONI X --explain -p inps
[would fetch] GET https://opendata.inps.it/databrowser/api/core/nodes/2/catalog
                                                  # files written after all three: 0

Warm cache — the per-line cached/network shape from the issue, with the data URL built from the cached structure:

$ opensdmx get NAMA_10_GDP --FREQ A --start-period 2020 --last-n 1 --explain -p eurostat
[cached]      GET .../dataflow/ESTAT?detail=allstubs&references=none
[cached]      GET .../datastructure/ESTAT/NAMA_10_GDP
[would fetch] GET .../data/NAMA_10_GDP/A...?format=SDMX-CSV&startPeriod=2020&lastNObservations=1
$ opensdmx search population --n 1 --explain -p eurostat
[cached]      GET .../dataflow/ESTAT?detail=allstubs&references=none
explain: no provider request would be made.

The tests pin "spends nothing" with httpx patched and assert_not_called() — the funnel, the hub client and every CLI case - plus stdout == "" in all three output modes and no file written by plot, _write_output or --query-file. I mutation-checked the guards: removing the console/write guards or the per-invocation reset makes the suite fail.

Three things worth knowing:

  • get without --last-n/--first-n/--yes spends a size probe (lastNObservations=1) before the real download; the plan shows that request, so it is finally visible for what it is.
  • On a POST-based hub (INPS) the cold-cache plan and the hub-client hook are live-verified, and the POST line with its body is unit-tested; a fully warm INPS run end to end was not exercised, because it needs a hub cache I could not build offline.
  • A dry run can still initialise an empty cache.db. With a warm dataflows.parquet and no cache.db, one catalogue read creates the empty schema file (45 KB) — the same thing any command does on first use, no provider data in it. Making the cache strictly read-only is not part of this PR: _db_conn also runs the in-place code_parent/code_order migration that older databases need before they can be read, so skipping the writes would either break reading an old cache.db or require the readers to tolerate missing columns. Worth a follow-up if the maintainer wants that guarantee; the size probe note below is the same kind of honesty.
  • The reachability GET is skipped by reading --explain off sys.argv, the way the existing --help check does it: the value arrives with the command, and a plan has to be printable while the provider is unreachable. A command line where --explain is a value (--grep --explain) only loses the friendly "API unreachable" hint.

Implements ondata#74 as a dry run: the plan goes to stderr, stdout stays empty, and
the run stops before the first request, so nothing is spent.

- The hook sits in `sdmx_request`, the shared path every SDMX call funnels
  through, plus the StatKit hub's own httpx client in `hub.py`, which bypasses
  it. Plan lines show method, URL (httpx's encoder, query string included) and
  the POST body.
- Not fetching means the planned URLs have to be *known*: cache hits report the
  request they replaced (catalog, datastructure, codelist, categoryscheme and
  categorisation). For hub-only providers those URLs are built elsewhere, so no
  line is printed rather than a wrong one.
- `ExplainStop` derives from `BaseException` so the callers' `except Exception`
  handlers cannot report the plan's stop as an error; `main()` exits 0 and says
  "no provider request would be made" when the whole plan came from the cache.
- Applies to the nine network commands; `search --semantic` is a no-op, since
  Ollama is not a provider request.
- Nothing is written in a dry run: the console, `_emit`, `_write_output`, the
  chart and the query file are all guarded.

Verified: 418 tests (14 new), ruff clean, mypy strict clean on 16 files.
@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Retrigger

[Medium risk] Adds a dry-run mode to preview requests without executing them.

The PR appears safe to merge; the catalog-outage finding is fixed, and no new actionable issues were found.

Summary

Adds --explain to nine commands. It prints cached steps and the first planned provider request to stderr, then stops before sending that request.

  • Guards command output and file writes during dry runs.
  • Fixes the previously reported extra catalog loads in siblings.
  • Adds a test for catalog outages with cached category results.
  • paoValle explicitly deferred strictly read-only cache access: existing readers may initialise or upgrade cache.db, and skipping those writes could break older caches.

Reviews (3) · Last reviewed commit: "cli: on a siblings catalog outage, skip ..." · Reviewed by Greptile

Comment thread src/opensdmx/discovery.py
@paoValle

paoValle commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Reproduced, thanks — it is real. With a warm dataflows.parquet and no cache.db, one catalogue read creates the empty schema file, because the read path goes through _catalog_view → _filter_invalid → get_invalid_dataset_ids:

$ OPENSDMX_CACHE_DIR=/tmp/greptile-check opensdmx search gdp --n 1 --explain -p eurostat
[cached]      GET .../dataflow/ESTAT?detail=allstubs&references=none
explain: no provider request would be made.
$ ls /tmp/greptile-check/eurostat/          # before: dataflows.parquet only
cache.db  dataflows.parquet

I am deliberately not making cache reads read-only in this PR (the PR body now says so instead of claiming "nothing is written"):

  • #74's contract for the dry run is make no data request: no request is sent, no provider data is written, stdout, the chart and the --query-file YAML are untouched. Initialising the local cache on a read is what every other command does, and the cache directory is created by get_cache_dir() either way — so "the cache directory stays unchanged" is not a guarantee this code can make.
  • A read-only path is not a one-liner: _db_conn also runs the in-place code_parent/code_order migration, and existing databases need it before they can be read (SELECT * followed by row["code_parent"]). Skipping all writes would either break reads of an older cache.db or force the four readers to tolerate missing columns.

The useful half of your suggestion — never create a missing database during a plan — is a clean follow-up; I would keep the migration and skip only the creation. Say the word and I will do it as a separate PR.

@aborruso aborruso left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paoValle thanks. I checked 734abcd: 418 passed, ruff and mypy clean. I ran every dry run with the network pointed at a dead proxy, so no request could leave. With a cold cache (ISTAT, Eurostat, INPS, OECD): exit 0, empty stdout, no file written. With a copy of my warm cache the [cached] lines are right and the cache directory is byte-identical before and after; with the structure cached, get shows the full data URL.

Two points inline. On cache.db being initialised by a read, the reasoning in the PR body is fine for me.

Comment thread src/opensdmx/cli.py
Comment thread src/opensdmx/cli.py
The plan named the same request twice or read like a real download:

- `get` without --last-n/--first-n/--yes stops at the size probe, whose line
  was byte-identical to the request of a real `--last-n 1` run. The funnel
  now takes a probe marker and prints `[would fetch] (size probe) GET ...`,
  with the exact URL the non-probe call builds (format param included).
- `siblings` read the dataflow catalog once for id resolution and once for
  the description table, so a warm-cache plan listed the same `[cached]`
  dataflow line twice. The CLI now loads the catalog once and passes it to
  both callers; the fallback paths in resolution and in `siblings_of`
  keep their old behaviour when the catalog is unreachable.

Tests: the get plan asserts the (size probe) marker and the absence of a
plain probe line; a new test pins one catalog line in the siblings plan.
Both fail without their fix (mutation-checked). 419 passed.
@paoValle

paoValle commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Both points fixed in c6696bb, and checked on the same warm-cache paths you measured:

$ opensdmx get EDAT_LFS_9901 --freq A --geo IT --explain -p eurostat
[cached]      GET …/dataflow/ESTAT?detail=allstubs&references=none
[cached]      GET …/datastructure/ESTAT/EDAT_LFS_9901
[would fetch] (size probe) GET …/data/EDAT_LFS_9901/A.....IT?format=SDMX-CSV&lastNObservations=1

The probe marker threads through the request funnel (explain_would_fetch(probe=True) → sdmx_request/sdmx_request_csv take _explain_probe), so the line keeps the exact URL the real --last-n 1 call builds — only the label changes.

And siblings now loads the catalog once and passes it to both the id resolution and the description table (resolve_dataflow(_dataflows=…), siblings_of(dataflows=…)); the fallback paths keep reading it themselves only when the catalog is unreachable. The plan lists the dataflow [cached] line once:

$ opensdmx siblings NAMA_10_GDP --explain -p eurostat
[cached]      GET …/dataflow/ESTAT?detail=allstubs&references=none
[cached]      GET …/categoryscheme/ESTAT/ALL/latest
[cached]      GET …/categorisation/ESTAT/ALL/latest

Tests: the get plan test now asserts the (size probe) marker (and the absence of a plain probe line); a new test pins exactly one catalog line in the siblings plan. Both fail without their fix (mutation-checked). Full suite 419 passed, ruff and mypy clean.

Comment thread src/opensdmx/cli.py Outdated
…descriptions

Greptile: when the first all_available() fails, the previous fallback
(dataflows=None) made both resolve_dataflow and siblings_of load the
catalog again, so an outage cost three attempts instead of two, each with
its own retries. On failure the command now carries on with the raw id
and an empty descriptions table, so neither call retries; the category
cache still answers. A test pins exactly one load attempt during an
outage (fails without the change). 420 passed.
@paoValle

paoValle commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Done — 11cf791. When the first all_available() fails, the command now carries on with the raw id (dataset_id.upper()) and passes siblings_of an empty descriptions frame, so neither resolution nor the description table retries the load — one attempt total on an outage, and the cached category tree still answers. A test pins exactly one _load_cached_dataflows call during an outage (it fails with the previous behaviour). Full suite 420 passed, ruff and mypy clean.

And thanks for verifying the probe label and the single catalog line on your warm-cache run.

@aborruso
aborruso merged commit 14f783b into ondata:main Oct 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add --explain to show the planned requests before they are made

2 participants