diff --git a/docs/docs/cli/api-serve.md b/docs/docs/cli/api-serve.md index 575792d..a677d0c 100644 --- a/docs/docs/cli/api-serve.md +++ b/docs/docs/cli/api-serve.md @@ -1,6 +1,6 @@ --- title: stack api serve -sidebar_position: 66 +sidebar_position: 77 --- # `stack api serve` diff --git a/docs/docs/cli/api.md b/docs/docs/cli/api.md index 28c81c8..8def8b9 100644 --- a/docs/docs/cli/api.md +++ b/docs/docs/cli/api.md @@ -1,6 +1,6 @@ --- title: stack api -sidebar_position: 65 +sidebar_position: 76 --- # `stack api` diff --git a/docs/docs/cli/bcda-adopt.md b/docs/docs/cli/bcda-adopt.md new file mode 100644 index 0000000..6141d2e --- /dev/null +++ b/docs/docs/cli/bcda-adopt.md @@ -0,0 +1,23 @@ +--- +title: stack bcda adopt +sidebar_position: 15 +--- + +# `stack bcda adopt` + +``` +Usage: stack bcda adopt [OPTIONS] JOB_ID + + Download a completed job this store did not start. + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +│ * job_id TEXT Server job id (see `stack bcda jobs`). [required] │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --type -t TEXT Resource type(s) the job holds, for the store │ +│ record. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-auth.md b/docs/docs/cli/bcda-auth.md new file mode 100644 index 0000000..491c7cb --- /dev/null +++ b/docs/docs/cli/bcda-auth.md @@ -0,0 +1,18 @@ +--- +title: stack bcda auth +sidebar_position: 12 +--- + +# `stack bcda auth` + +``` +Usage: stack bcda auth [OPTIONS] + + Issue a token and show attribution status. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-cancel.md b/docs/docs/cli/bcda-cancel.md new file mode 100644 index 0000000..e4c35be --- /dev/null +++ b/docs/docs/cli/bcda-cancel.md @@ -0,0 +1,21 @@ +--- +title: stack bcda cancel +sidebar_position: 16 +--- + +# `stack bcda cancel` + +``` +Usage: stack bcda cancel [OPTIONS] JOB_ID + + Cancel an in-progress job. + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +│ * job_id TEXT Server job id. [required] │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-export.md b/docs/docs/cli/bcda-export.md new file mode 100644 index 0000000..6a894f8 --- /dev/null +++ b/docs/docs/cli/bcda-export.md @@ -0,0 +1,40 @@ +--- +title: stack bcda export +sidebar_position: 14 +--- + +# `stack bcda export` + +``` +Usage: stack bcda export [OPTIONS] + + Run an export through the store (blocks until downloaded). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when │ +│ BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --type -t TEXT Resource type(s); default all │ +│ three. │ +│ --endpoint TEXT Group/all, Group/runout or │ +│ Patient. │ +│ [default: Group/all] │ +│ --since TEXT FHIR instant, e.g. │ +│ 2026-01-01T00:00:00+00:00 │ +│ --system-type TEXT _typeFilter System-Type │ +│ tag(s). │ +│ --outcome TEXT _typeFilter outcome: partial │ +│ or complete. │ +│ --service-date-from TEXT _typeFilter service-date > │ +│ YYYY-MM-DD. │ +│ --per-type --one-job One job per type (survives │ +│ sandbox sweeps). │ +│ [default: per-type] │ +│ --cancel-retries INTEGER Restarts after server-side │ +│ cancellation. │ +│ [default: 6] │ +│ --poll-interval FLOAT Seconds between status polls. │ +│ [default: 15.0] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-jobs.md b/docs/docs/cli/bcda-jobs.md new file mode 100644 index 0000000..28ff632 --- /dev/null +++ b/docs/docs/cli/bcda-jobs.md @@ -0,0 +1,19 @@ +--- +title: stack bcda jobs +sidebar_position: 13 +--- + +# `stack bcda jobs` + +``` +Usage: stack bcda jobs [OPTIONS] + + Server job history for the entity (shared on the sandbox). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --limit INTEGER Newest N jobs. [default: 25] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-load.md b/docs/docs/cli/bcda-load.md new file mode 100644 index 0000000..951a8a0 --- /dev/null +++ b/docs/docs/cli/bcda-load.md @@ -0,0 +1,20 @@ +--- +title: stack bcda load +sidebar_position: 18 +--- + +# `stack bcda load` + +``` +Usage: stack bcda load [OPTIONS] + + Flatten + load the newest export per type, run the connector, publish the + replica. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --pipeline --no-pipeline Run the connector into input_layer.* │ +│ [default: pipeline] │ +│ --skip-flatten Reuse existing Parquet. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-preflight.md b/docs/docs/cli/bcda-preflight.md new file mode 100644 index 0000000..50ac8ef --- /dev/null +++ b/docs/docs/cli/bcda-preflight.md @@ -0,0 +1,18 @@ +--- +title: stack bcda preflight +sidebar_position: 11 +--- + +# `stack bcda preflight` + +``` +Usage: stack bcda preflight [OPTIONS] + + /_version, /_health and metadata — no token needed. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --preset TEXT Public sandbox entity when BCDA_CLIENT_ID is unset. │ +│ [default: Large Model Entity] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-store.md b/docs/docs/cli/bcda-store.md new file mode 100644 index 0000000..464f127 --- /dev/null +++ b/docs/docs/cli/bcda-store.md @@ -0,0 +1,16 @@ +--- +title: stack bcda store +sidebar_position: 17 +--- + +# `stack bcda store` + +``` +Usage: stack bcda store [OPTIONS] + + Local store: jobs, files, bytes. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-ui.md b/docs/docs/cli/bcda-ui.md new file mode 100644 index 0000000..70af438 --- /dev/null +++ b/docs/docs/cli/bcda-ui.md @@ -0,0 +1,20 @@ +--- +title: stack bcda ui +sidebar_position: 10 +--- + +# `stack bcda ui` + +``` +Usage: stack bcda ui [OPTIONS] + + Launch the BCDA manager (Streamlit). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --port INTEGER Port for the Streamlit server. │ +│ [default: 8501] │ +│ --headless --no-headless Do not open a browser. │ +│ [default: headless] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda-vendor.md b/docs/docs/cli/bcda-vendor.md new file mode 100644 index 0000000..ab9f99a --- /dev/null +++ b/docs/docs/cli/bcda-vendor.md @@ -0,0 +1,18 @@ +--- +title: stack bcda vendor +sidebar_position: 19 +--- + +# `stack bcda vendor` + +``` +Usage: stack bcda vendor [OPTIONS] + + Vendored upstream (CMSgov/bcda-app, bcda.cms.gov): manifest, verification, + drift. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --check Also query GitHub / bcda.cms.gov for drift. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bcda.md b/docs/docs/cli/bcda.md new file mode 100644 index 0000000..31a65b0 --- /dev/null +++ b/docs/docs/cli/bcda.md @@ -0,0 +1,30 @@ +--- +title: stack bcda +sidebar_position: 9 +--- + +# `stack bcda` + +``` +Usage: stack bcda [OPTIONS] COMMAND [ARGS]... + + BCDA client and manager (exports, store, load, UI). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ ui Launch the BCDA manager (Streamlit). │ +│ preflight /_version, /_health and metadata — no token needed. │ +│ auth Issue a token and show attribution status. │ +│ jobs Server job history for the entity (shared on the sandbox). │ +│ export Run an export through the store (blocks until downloaded). │ +│ adopt Download a completed job this store did not start. │ +│ cancel Cancel an in-progress job. │ +│ store Local store: jobs, files, bytes. │ +│ load Flatten + load the newest export per type, run the connector, │ +│ publish the replica. │ +│ vendor Vendored upstream (CMSgov/bcda-app, bcda.cms.gov): manifest, │ +│ verification, drift. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/bib-attach-iom.md b/docs/docs/cli/bib-attach-iom.md index bfd85de..d85e063 100644 --- a/docs/docs/cli/bib-attach-iom.md +++ b/docs/docs/cli/bib-attach-iom.md @@ -1,6 +1,6 @@ --- title: stack bib attach-iom -sidebar_position: 21 +sidebar_position: 32 --- # `stack bib attach-iom` diff --git a/docs/docs/cli/bib-attach-oig.md b/docs/docs/cli/bib-attach-oig.md index b2a2481..abb17c9 100644 --- a/docs/docs/cli/bib-attach-oig.md +++ b/docs/docs/cli/bib-attach-oig.md @@ -1,6 +1,6 @@ --- title: stack bib attach-oig -sidebar_position: 26 +sidebar_position: 37 --- # `stack bib attach-oig` diff --git a/docs/docs/cli/bib-backfill-comments.md b/docs/docs/cli/bib-backfill-comments.md index a1956ba..b098014 100644 --- a/docs/docs/cli/bib-backfill-comments.md +++ b/docs/docs/cli/bib-backfill-comments.md @@ -1,6 +1,6 @@ --- title: stack bib backfill-comments -sidebar_position: 19 +sidebar_position: 30 --- # `stack bib backfill-comments` diff --git a/docs/docs/cli/bib-cfr-jump.md b/docs/docs/cli/bib-cfr-jump.md index fe3507d..3346665 100644 --- a/docs/docs/cli/bib-cfr-jump.md +++ b/docs/docs/cli/bib-cfr-jump.md @@ -1,6 +1,6 @@ --- title: stack bib cfr-jump -sidebar_position: 31 +sidebar_position: 42 --- # `stack bib cfr-jump` diff --git a/docs/docs/cli/bib-code-tags.md b/docs/docs/cli/bib-code-tags.md index 9efc2e4..a576d1d 100644 --- a/docs/docs/cli/bib-code-tags.md +++ b/docs/docs/cli/bib-code-tags.md @@ -1,6 +1,6 @@ --- title: stack bib code-tags -sidebar_position: 32 +sidebar_position: 43 --- # `stack bib code-tags` diff --git a/docs/docs/cli/bib-discover-pfs-rules.md b/docs/docs/cli/bib-discover-pfs-rules.md index eef1980..c98b2d8 100644 --- a/docs/docs/cli/bib-discover-pfs-rules.md +++ b/docs/docs/cli/bib-discover-pfs-rules.md @@ -1,6 +1,6 @@ --- title: stack bib discover-pfs-rules -sidebar_position: 15 +sidebar_position: 26 --- # `stack bib discover-pfs-rules` diff --git a/docs/docs/cli/bib-fetch-docket-comments.md b/docs/docs/cli/bib-fetch-docket-comments.md index 427b366..b2ef056 100644 --- a/docs/docs/cli/bib-fetch-docket-comments.md +++ b/docs/docs/cli/bib-fetch-docket-comments.md @@ -1,6 +1,6 @@ --- title: stack bib fetch-docket-comments -sidebar_position: 16 +sidebar_position: 27 --- # `stack bib fetch-docket-comments` diff --git a/docs/docs/cli/bib-fetch-pfs-comments.md b/docs/docs/cli/bib-fetch-pfs-comments.md index 26e3235..93d989a 100644 --- a/docs/docs/cli/bib-fetch-pfs-comments.md +++ b/docs/docs/cli/bib-fetch-pfs-comments.md @@ -1,6 +1,6 @@ --- title: stack bib fetch-pfs-comments -sidebar_position: 17 +sidebar_position: 28 --- # `stack bib fetch-pfs-comments` diff --git a/docs/docs/cli/bib-fr-grab.md b/docs/docs/cli/bib-fr-grab.md index 4d2d3e9..cbf06df 100644 --- a/docs/docs/cli/bib-fr-grab.md +++ b/docs/docs/cli/bib-fr-grab.md @@ -1,6 +1,6 @@ --- title: stack bib fr-grab -sidebar_position: 29 +sidebar_position: 40 --- # `stack bib fr-grab` diff --git a/docs/docs/cli/bib-fr-jump.md b/docs/docs/cli/bib-fr-jump.md index 2b24b37..33aef6d 100644 --- a/docs/docs/cli/bib-fr-jump.md +++ b/docs/docs/cli/bib-fr-jump.md @@ -1,6 +1,6 @@ --- title: stack bib fr-jump -sidebar_position: 30 +sidebar_position: 41 --- # `stack bib fr-jump` diff --git a/docs/docs/cli/bib-import-zotero.md b/docs/docs/cli/bib-import-zotero.md index 7c80bd3..6fcdfdd 100644 --- a/docs/docs/cli/bib-import-zotero.md +++ b/docs/docs/cli/bib-import-zotero.md @@ -1,6 +1,6 @@ --- title: stack bib import-zotero -sidebar_position: 23 +sidebar_position: 34 --- # `stack bib import-zotero` diff --git a/docs/docs/cli/bib-ingest-iom.md b/docs/docs/cli/bib-ingest-iom.md index fdc15af..03c8e22 100644 --- a/docs/docs/cli/bib-ingest-iom.md +++ b/docs/docs/cli/bib-ingest-iom.md @@ -1,6 +1,6 @@ --- title: stack bib ingest-iom -sidebar_position: 20 +sidebar_position: 31 --- # `stack bib ingest-iom` diff --git a/docs/docs/cli/bib-ingest-mail.md b/docs/docs/cli/bib-ingest-mail.md index c03922e..ab80fa9 100644 --- a/docs/docs/cli/bib-ingest-mail.md +++ b/docs/docs/cli/bib-ingest-mail.md @@ -1,6 +1,6 @@ --- title: stack bib ingest-mail -sidebar_position: 18 +sidebar_position: 29 --- # `stack bib ingest-mail` diff --git a/docs/docs/cli/bib-ingest-oig.md b/docs/docs/cli/bib-ingest-oig.md index 3f028e9..3c8cb2c 100644 --- a/docs/docs/cli/bib-ingest-oig.md +++ b/docs/docs/cli/bib-ingest-oig.md @@ -1,6 +1,6 @@ --- title: stack bib ingest-oig -sidebar_position: 25 +sidebar_position: 36 --- # `stack bib ingest-oig` diff --git a/docs/docs/cli/bib-query.md b/docs/docs/cli/bib-query.md index 2edf700..4311440 100644 --- a/docs/docs/cli/bib-query.md +++ b/docs/docs/cli/bib-query.md @@ -1,6 +1,6 @@ --- title: stack bib query -sidebar_position: 14 +sidebar_position: 25 --- # `stack bib query` diff --git a/docs/docs/cli/bib-refresh-iom.md b/docs/docs/cli/bib-refresh-iom.md index 754800a..cc0c7be 100644 --- a/docs/docs/cli/bib-refresh-iom.md +++ b/docs/docs/cli/bib-refresh-iom.md @@ -1,6 +1,6 @@ --- title: stack bib refresh-iom -sidebar_position: 28 +sidebar_position: 39 --- # `stack bib refresh-iom` diff --git a/docs/docs/cli/bib-refresh-oig.md b/docs/docs/cli/bib-refresh-oig.md index 04d4ca9..32b3cd2 100644 --- a/docs/docs/cli/bib-refresh-oig.md +++ b/docs/docs/cli/bib-refresh-oig.md @@ -1,6 +1,6 @@ --- title: stack bib refresh-oig -sidebar_position: 27 +sidebar_position: 38 --- # `stack bib refresh-oig` diff --git a/docs/docs/cli/bib-sync-zotero.md b/docs/docs/cli/bib-sync-zotero.md index b4d0fd4..ec7af77 100644 --- a/docs/docs/cli/bib-sync-zotero.md +++ b/docs/docs/cli/bib-sync-zotero.md @@ -1,6 +1,6 @@ --- title: stack bib sync-zotero -sidebar_position: 24 +sidebar_position: 35 --- # `stack bib sync-zotero` diff --git a/docs/docs/cli/bib-sync.md b/docs/docs/cli/bib-sync.md index ce21e6d..8802c80 100644 --- a/docs/docs/cli/bib-sync.md +++ b/docs/docs/cli/bib-sync.md @@ -1,6 +1,6 @@ --- title: stack bib sync -sidebar_position: 12 +sidebar_position: 23 --- # `stack bib sync` diff --git a/docs/docs/cli/bib-tag.md b/docs/docs/cli/bib-tag.md index 6768524..5fe79df 100644 --- a/docs/docs/cli/bib-tag.md +++ b/docs/docs/cli/bib-tag.md @@ -1,6 +1,6 @@ --- title: stack bib tag -sidebar_position: 13 +sidebar_position: 24 --- # `stack bib tag` diff --git a/docs/docs/cli/bib-watch-iom.md b/docs/docs/cli/bib-watch-iom.md index c0e9dc6..44d1d1c 100644 --- a/docs/docs/cli/bib-watch-iom.md +++ b/docs/docs/cli/bib-watch-iom.md @@ -1,6 +1,6 @@ --- title: stack bib watch-iom -sidebar_position: 22 +sidebar_position: 33 --- # `stack bib watch-iom` diff --git a/docs/docs/cli/bib.md b/docs/docs/cli/bib.md index d501774..44d9f33 100644 --- a/docs/docs/cli/bib.md +++ b/docs/docs/cli/bib.md @@ -1,6 +1,6 @@ --- title: stack bib -sidebar_position: 11 +sidebar_position: 22 --- # `stack bib` diff --git a/docs/docs/cli/bls-ces.md b/docs/docs/cli/bls-ces.md index a78e3bc..ddbe3c4 100644 --- a/docs/docs/cli/bls-ces.md +++ b/docs/docs/cli/bls-ces.md @@ -1,6 +1,6 @@ --- title: stack bls ces -sidebar_position: 35 +sidebar_position: 46 --- # `stack bls ces` diff --git a/docs/docs/cli/bls-oews.md b/docs/docs/cli/bls-oews.md index db2d8f2..ec8a01a 100644 --- a/docs/docs/cli/bls-oews.md +++ b/docs/docs/cli/bls-oews.md @@ -1,6 +1,6 @@ --- title: stack bls oews -sidebar_position: 34 +sidebar_position: 45 --- # `stack bls oews` diff --git a/docs/docs/cli/bls.md b/docs/docs/cli/bls.md index 5c835f0..678e9f7 100644 --- a/docs/docs/cli/bls.md +++ b/docs/docs/cli/bls.md @@ -1,6 +1,6 @@ --- title: stack bls -sidebar_position: 33 +sidebar_position: 44 --- # `stack bls` diff --git a/docs/docs/cli/comments-dockets.md b/docs/docs/cli/comments-dockets.md index 633ee06..20aec18 100644 --- a/docs/docs/cli/comments-dockets.md +++ b/docs/docs/cli/comments-dockets.md @@ -1,6 +1,6 @@ --- title: stack comments dockets -sidebar_position: 40 +sidebar_position: 51 --- # `stack comments dockets` diff --git a/docs/docs/cli/comments-extract-ocr.md b/docs/docs/cli/comments-extract-ocr.md index 52738ea..00d87e5 100644 --- a/docs/docs/cli/comments-extract-ocr.md +++ b/docs/docs/cli/comments-extract-ocr.md @@ -1,6 +1,6 @@ --- title: stack comments extract-ocr -sidebar_position: 38 +sidebar_position: 49 --- # `stack comments extract-ocr` diff --git a/docs/docs/cli/comments-extract.md b/docs/docs/cli/comments-extract.md index f0049f0..4591e9e 100644 --- a/docs/docs/cli/comments-extract.md +++ b/docs/docs/cli/comments-extract.md @@ -1,6 +1,6 @@ --- title: stack comments extract -sidebar_position: 37 +sidebar_position: 48 --- # `stack comments extract` diff --git a/docs/docs/cli/comments-seal.md b/docs/docs/cli/comments-seal.md index c8618bd..e86617b 100644 --- a/docs/docs/cli/comments-seal.md +++ b/docs/docs/cli/comments-seal.md @@ -1,6 +1,6 @@ --- title: stack comments seal -sidebar_position: 41 +sidebar_position: 52 --- # `stack comments seal` diff --git a/docs/docs/cli/comments-stats.md b/docs/docs/cli/comments-stats.md index 26819d9..0c435a4 100644 --- a/docs/docs/cli/comments-stats.md +++ b/docs/docs/cli/comments-stats.md @@ -1,6 +1,6 @@ --- title: stack comments stats -sidebar_position: 39 +sidebar_position: 50 --- # `stack comments stats` diff --git a/docs/docs/cli/comments-unseal.md b/docs/docs/cli/comments-unseal.md index e147684..df5d68c 100644 --- a/docs/docs/cli/comments-unseal.md +++ b/docs/docs/cli/comments-unseal.md @@ -1,6 +1,6 @@ --- title: stack comments unseal -sidebar_position: 42 +sidebar_position: 53 --- # `stack comments unseal` diff --git a/docs/docs/cli/comments.md b/docs/docs/cli/comments.md index d1c5e7a..2a61708 100644 --- a/docs/docs/cli/comments.md +++ b/docs/docs/cli/comments.md @@ -1,6 +1,6 @@ --- title: stack comments -sidebar_position: 36 +sidebar_position: 47 --- # `stack comments` diff --git a/docs/docs/cli/db-comment.md b/docs/docs/cli/db-comment.md index 03fed18..4ea6adc 100644 --- a/docs/docs/cli/db-comment.md +++ b/docs/docs/cli/db-comment.md @@ -1,6 +1,6 @@ --- title: stack db comment -sidebar_position: 59 +sidebar_position: 70 --- # `stack db comment` diff --git a/docs/docs/cli/db-inspect.md b/docs/docs/cli/db-inspect.md index 172a47b..5d33428 100644 --- a/docs/docs/cli/db-inspect.md +++ b/docs/docs/cli/db-inspect.md @@ -1,6 +1,6 @@ --- title: stack db inspect -sidebar_position: 60 +sidebar_position: 71 --- # `stack db inspect` diff --git a/docs/docs/cli/db.md b/docs/docs/cli/db.md index 7967d3c..6b223e5 100644 --- a/docs/docs/cli/db.md +++ b/docs/docs/cli/db.md @@ -1,6 +1,6 @@ --- title: stack db -sidebar_position: 58 +sidebar_position: 69 --- # `stack db` diff --git a/docs/docs/cli/docs-build.md b/docs/docs/cli/docs-build.md index f96d6b5..dd909e8 100644 --- a/docs/docs/cli/docs-build.md +++ b/docs/docs/cli/docs-build.md @@ -1,6 +1,6 @@ --- title: stack docs build -sidebar_position: 62 +sidebar_position: 73 --- # `stack docs build` diff --git a/docs/docs/cli/docs-generate.md b/docs/docs/cli/docs-generate.md index 2b49447..9cda6d0 100644 --- a/docs/docs/cli/docs-generate.md +++ b/docs/docs/cli/docs-generate.md @@ -1,6 +1,6 @@ --- title: stack docs generate -sidebar_position: 64 +sidebar_position: 75 --- # `stack docs generate` diff --git a/docs/docs/cli/docs-serve.md b/docs/docs/cli/docs-serve.md index a7a743b..19f3262 100644 --- a/docs/docs/cli/docs-serve.md +++ b/docs/docs/cli/docs-serve.md @@ -1,6 +1,6 @@ --- title: stack docs serve -sidebar_position: 63 +sidebar_position: 74 --- # `stack docs serve` diff --git a/docs/docs/cli/docs.md b/docs/docs/cli/docs.md index 5feaef7..7dccf7d 100644 --- a/docs/docs/cli/docs.md +++ b/docs/docs/cli/docs.md @@ -1,6 +1,6 @@ --- title: stack docs -sidebar_position: 61 +sidebar_position: 72 --- # `stack docs` diff --git a/docs/docs/cli/generate-models.md b/docs/docs/cli/generate-models.md index 17d4983..bb68333 100644 --- a/docs/docs/cli/generate-models.md +++ b/docs/docs/cli/generate-models.md @@ -1,6 +1,6 @@ --- title: stack generate models -sidebar_position: 10 +sidebar_position: 21 --- # `stack generate models` diff --git a/docs/docs/cli/generate.md b/docs/docs/cli/generate.md index 347e6c4..e532df1 100644 --- a/docs/docs/cli/generate.md +++ b/docs/docs/cli/generate.md @@ -1,6 +1,6 @@ --- title: stack generate -sidebar_position: 9 +sidebar_position: 20 --- # `stack generate` diff --git a/docs/docs/cli/lake-deploy.md b/docs/docs/cli/lake-deploy.md index 3ab6e76..13ce235 100644 --- a/docs/docs/cli/lake-deploy.md +++ b/docs/docs/cli/lake-deploy.md @@ -1,6 +1,6 @@ --- title: stack lake deploy -sidebar_position: 44 +sidebar_position: 55 --- # `stack lake deploy` diff --git a/docs/docs/cli/lake-load.md b/docs/docs/cli/lake-load.md index 547a8e3..1f6c998 100644 --- a/docs/docs/cli/lake-load.md +++ b/docs/docs/cli/lake-load.md @@ -1,6 +1,6 @@ --- title: stack lake load -sidebar_position: 45 +sidebar_position: 56 --- # `stack lake load` diff --git a/docs/docs/cli/lake-parity.md b/docs/docs/cli/lake-parity.md index 8ca1ced..74481e5 100644 --- a/docs/docs/cli/lake-parity.md +++ b/docs/docs/cli/lake-parity.md @@ -1,6 +1,6 @@ --- title: stack lake parity -sidebar_position: 47 +sidebar_position: 58 --- # `stack lake parity` diff --git a/docs/docs/cli/lake-validate.md b/docs/docs/cli/lake-validate.md index 676fe33..de003f3 100644 --- a/docs/docs/cli/lake-validate.md +++ b/docs/docs/cli/lake-validate.md @@ -1,6 +1,6 @@ --- title: stack lake validate -sidebar_position: 46 +sidebar_position: 57 --- # `stack lake validate` diff --git a/docs/docs/cli/lake.md b/docs/docs/cli/lake.md index 9d89d37..0960e99 100644 --- a/docs/docs/cli/lake.md +++ b/docs/docs/cli/lake.md @@ -1,6 +1,6 @@ --- title: stack lake -sidebar_position: 43 +sidebar_position: 54 --- # `stack lake` diff --git a/docs/docs/cli/llm-eval-tags.md b/docs/docs/cli/llm-eval-tags.md index 9192922..3c6b253 100644 --- a/docs/docs/cli/llm-eval-tags.md +++ b/docs/docs/cli/llm-eval-tags.md @@ -1,6 +1,6 @@ --- title: stack llm eval-tags -sidebar_position: 55 +sidebar_position: 66 --- # `stack llm eval-tags` diff --git a/docs/docs/cli/llm-hosts.md b/docs/docs/cli/llm-hosts.md index 14e4326..ad11335 100644 --- a/docs/docs/cli/llm-hosts.md +++ b/docs/docs/cli/llm-hosts.md @@ -1,6 +1,6 @@ --- title: stack llm hosts -sidebar_position: 51 +sidebar_position: 62 --- # `stack llm hosts` diff --git a/docs/docs/cli/llm-index.md b/docs/docs/cli/llm-index.md index 0fb721c..d91c929 100644 --- a/docs/docs/cli/llm-index.md +++ b/docs/docs/cli/llm-index.md @@ -1,6 +1,6 @@ --- title: stack llm index -sidebar_position: 49 +sidebar_position: 60 --- # `stack llm index` diff --git a/docs/docs/cli/llm-prune-rules.md b/docs/docs/cli/llm-prune-rules.md index 5cad852..970e32e 100644 --- a/docs/docs/cli/llm-prune-rules.md +++ b/docs/docs/cli/llm-prune-rules.md @@ -1,6 +1,6 @@ --- title: stack llm prune-rules -sidebar_position: 56 +sidebar_position: 67 --- # `stack llm prune-rules` diff --git a/docs/docs/cli/llm-restamp.md b/docs/docs/cli/llm-restamp.md index 14f189f..31818f8 100644 --- a/docs/docs/cli/llm-restamp.md +++ b/docs/docs/cli/llm-restamp.md @@ -1,6 +1,6 @@ --- title: stack llm restamp -sidebar_position: 50 +sidebar_position: 61 --- # `stack llm restamp` diff --git a/docs/docs/cli/llm-serve.md b/docs/docs/cli/llm-serve.md index f6bbb79..c4a93f3 100644 --- a/docs/docs/cli/llm-serve.md +++ b/docs/docs/cli/llm-serve.md @@ -1,6 +1,6 @@ --- title: stack llm serve -sidebar_position: 52 +sidebar_position: 63 --- # `stack llm serve` diff --git a/docs/docs/cli/llm-stamp-themes.md b/docs/docs/cli/llm-stamp-themes.md index a8ca76c..5764031 100644 --- a/docs/docs/cli/llm-stamp-themes.md +++ b/docs/docs/cli/llm-stamp-themes.md @@ -1,6 +1,6 @@ --- title: stack llm stamp-themes -sidebar_position: 57 +sidebar_position: 68 --- # `stack llm stamp-themes` diff --git a/docs/docs/cli/llm-tag.md b/docs/docs/cli/llm-tag.md index e4a41f3..7361ac2 100644 --- a/docs/docs/cli/llm-tag.md +++ b/docs/docs/cli/llm-tag.md @@ -1,6 +1,6 @@ --- title: stack llm tag -sidebar_position: 54 +sidebar_position: 65 --- # `stack llm tag` diff --git a/docs/docs/cli/llm-vocab.md b/docs/docs/cli/llm-vocab.md index a337cf7..923c8e4 100644 --- a/docs/docs/cli/llm-vocab.md +++ b/docs/docs/cli/llm-vocab.md @@ -1,6 +1,6 @@ --- title: stack llm vocab -sidebar_position: 53 +sidebar_position: 64 --- # `stack llm vocab` diff --git a/docs/docs/cli/llm.md b/docs/docs/cli/llm.md index bc86b9c..3a2687a 100644 --- a/docs/docs/cli/llm.md +++ b/docs/docs/cli/llm.md @@ -1,6 +1,6 @@ --- title: stack llm -sidebar_position: 48 +sidebar_position: 59 --- # `stack llm` diff --git a/docs/docs/cli/mail-attach-smarthost.md b/docs/docs/cli/mail-attach-smarthost.md index 01ca05d..81f4327 100644 --- a/docs/docs/cli/mail-attach-smarthost.md +++ b/docs/docs/cli/mail-attach-smarthost.md @@ -1,6 +1,6 @@ --- title: stack mail attach-smarthost -sidebar_position: 107 +sidebar_position: 118 --- # `stack mail attach-smarthost` diff --git a/docs/docs/cli/mail-dkim-export.md b/docs/docs/cli/mail-dkim-export.md index 5386221..fb64359 100644 --- a/docs/docs/cli/mail-dkim-export.md +++ b/docs/docs/cli/mail-dkim-export.md @@ -1,6 +1,6 @@ --- title: stack mail dkim-export -sidebar_position: 106 +sidebar_position: 117 --- # `stack mail dkim-export` diff --git a/docs/docs/cli/mail-dns.md b/docs/docs/cli/mail-dns.md index 1175da6..20985f0 100644 --- a/docs/docs/cli/mail-dns.md +++ b/docs/docs/cli/mail-dns.md @@ -1,6 +1,6 @@ --- title: stack mail dns -sidebar_position: 105 +sidebar_position: 116 --- # `stack mail dns` diff --git a/docs/docs/cli/mail-down.md b/docs/docs/cli/mail-down.md index 218756c..2eb00b7 100644 --- a/docs/docs/cli/mail-down.md +++ b/docs/docs/cli/mail-down.md @@ -1,6 +1,6 @@ --- title: stack mail down -sidebar_position: 103 +sidebar_position: 114 --- # `stack mail down` diff --git a/docs/docs/cli/mail-provision.md b/docs/docs/cli/mail-provision.md index 3692501..4c7cc6d 100644 --- a/docs/docs/cli/mail-provision.md +++ b/docs/docs/cli/mail-provision.md @@ -1,6 +1,6 @@ --- title: stack mail provision -sidebar_position: 101 +sidebar_position: 112 --- # `stack mail provision` diff --git a/docs/docs/cli/mail-rotate-creds.md b/docs/docs/cli/mail-rotate-creds.md index 57e1df9..af6b9ed 100644 --- a/docs/docs/cli/mail-rotate-creds.md +++ b/docs/docs/cli/mail-rotate-creds.md @@ -1,6 +1,6 @@ --- title: stack mail rotate-creds -sidebar_position: 108 +sidebar_position: 119 --- # `stack mail rotate-creds` diff --git a/docs/docs/cli/mail-seed-mailboxes.md b/docs/docs/cli/mail-seed-mailboxes.md index ae2f65c..3ac98d3 100644 --- a/docs/docs/cli/mail-seed-mailboxes.md +++ b/docs/docs/cli/mail-seed-mailboxes.md @@ -1,6 +1,6 @@ --- title: stack mail seed-mailboxes -sidebar_position: 109 +sidebar_position: 120 --- # `stack mail seed-mailboxes` diff --git a/docs/docs/cli/mail-status.md b/docs/docs/cli/mail-status.md index 1bb60da..fc89ac4 100644 --- a/docs/docs/cli/mail-status.md +++ b/docs/docs/cli/mail-status.md @@ -1,6 +1,6 @@ --- title: stack mail status -sidebar_position: 104 +sidebar_position: 115 --- # `stack mail status` diff --git a/docs/docs/cli/mail-up.md b/docs/docs/cli/mail-up.md index 360dcf4..6988c32 100644 --- a/docs/docs/cli/mail-up.md +++ b/docs/docs/cli/mail-up.md @@ -1,6 +1,6 @@ --- title: stack mail up -sidebar_position: 102 +sidebar_position: 113 --- # `stack mail up` diff --git a/docs/docs/cli/mail-wire-git.md b/docs/docs/cli/mail-wire-git.md index 927ff50..c68a92a 100644 --- a/docs/docs/cli/mail-wire-git.md +++ b/docs/docs/cli/mail-wire-git.md @@ -1,6 +1,6 @@ --- title: stack mail wire-git -sidebar_position: 110 +sidebar_position: 121 --- # `stack mail wire-git` diff --git a/docs/docs/cli/mail.md b/docs/docs/cli/mail.md index 52c072c..626ddd9 100644 --- a/docs/docs/cli/mail.md +++ b/docs/docs/cli/mail.md @@ -1,6 +1,6 @@ --- title: stack mail -sidebar_position: 100 +sidebar_position: 111 --- # `stack mail` diff --git a/docs/docs/cli/perf-show.md b/docs/docs/cli/perf-show.md index 12233a7..4d03297 100644 --- a/docs/docs/cli/perf-show.md +++ b/docs/docs/cli/perf-show.md @@ -1,6 +1,6 @@ --- title: stack perf show -sidebar_position: 68 +sidebar_position: 79 --- # `stack perf show` diff --git a/docs/docs/cli/perf.md b/docs/docs/cli/perf.md index 0f2ca12..5a3296e 100644 --- a/docs/docs/cli/perf.md +++ b/docs/docs/cli/perf.md @@ -1,6 +1,6 @@ --- title: stack perf -sidebar_position: 67 +sidebar_position: 78 --- # `stack perf` diff --git a/docs/docs/cli/pfs-cpt-ingest.md b/docs/docs/cli/pfs-cpt-ingest.md index ffe4599..5095ed8 100644 --- a/docs/docs/cli/pfs-cpt-ingest.md +++ b/docs/docs/cli/pfs-cpt-ingest.md @@ -1,6 +1,6 @@ --- title: stack pfs cpt-ingest -sidebar_position: 82 +sidebar_position: 93 --- # `stack pfs cpt-ingest` diff --git a/docs/docs/cli/pfs-elements.md b/docs/docs/cli/pfs-elements.md index 045814c..d249534 100644 --- a/docs/docs/cli/pfs-elements.md +++ b/docs/docs/cli/pfs-elements.md @@ -1,6 +1,6 @@ --- title: stack pfs elements -sidebar_position: 74 +sidebar_position: 85 --- # `stack pfs elements` diff --git a/docs/docs/cli/pfs-exposure.md b/docs/docs/cli/pfs-exposure.md index 89c53cf..1f4cf05 100644 --- a/docs/docs/cli/pfs-exposure.md +++ b/docs/docs/cli/pfs-exposure.md @@ -1,6 +1,6 @@ --- title: stack pfs exposure -sidebar_position: 79 +sidebar_position: 90 --- # `stack pfs exposure` diff --git a/docs/docs/cli/pfs-families.md b/docs/docs/cli/pfs-families.md index ea0548d..666708b 100644 --- a/docs/docs/cli/pfs-families.md +++ b/docs/docs/cli/pfs-families.md @@ -1,6 +1,6 @@ --- title: stack pfs families -sidebar_position: 76 +sidebar_position: 87 --- # `stack pfs families` diff --git a/docs/docs/cli/pfs-guidance.md b/docs/docs/cli/pfs-guidance.md index 32dc08d..08c4e1d 100644 --- a/docs/docs/cli/pfs-guidance.md +++ b/docs/docs/cli/pfs-guidance.md @@ -1,6 +1,6 @@ --- title: stack pfs guidance -sidebar_position: 77 +sidebar_position: 88 --- # `stack pfs guidance` diff --git a/docs/docs/cli/pfs-lineage.md b/docs/docs/cli/pfs-lineage.md index 7a11271..bed5e38 100644 --- a/docs/docs/cli/pfs-lineage.md +++ b/docs/docs/cli/pfs-lineage.md @@ -1,6 +1,6 @@ --- title: stack pfs lineage -sidebar_position: 75 +sidebar_position: 86 --- # `stack pfs lineage` diff --git a/docs/docs/cli/pfs-reaction.md b/docs/docs/cli/pfs-reaction.md index a58537c..7ff6842 100644 --- a/docs/docs/cli/pfs-reaction.md +++ b/docs/docs/cli/pfs-reaction.md @@ -1,6 +1,6 @@ --- title: stack pfs reaction -sidebar_position: 78 +sidebar_position: 89 --- # `stack pfs reaction` diff --git a/docs/docs/cli/pfs-review.md b/docs/docs/cli/pfs-review.md index 79dae3f..b088cf0 100644 --- a/docs/docs/cli/pfs-review.md +++ b/docs/docs/cli/pfs-review.md @@ -1,6 +1,6 @@ --- title: stack pfs review -sidebar_position: 81 +sidebar_position: 92 --- # `stack pfs review` diff --git a/docs/docs/cli/pfs-utilization.md b/docs/docs/cli/pfs-utilization.md index 0dfb7ec..12bbb79 100644 --- a/docs/docs/cli/pfs-utilization.md +++ b/docs/docs/cli/pfs-utilization.md @@ -1,6 +1,6 @@ --- title: stack pfs utilization -sidebar_position: 80 +sidebar_position: 91 --- # `stack pfs utilization` diff --git a/docs/docs/cli/pfs.md b/docs/docs/cli/pfs.md index 28edc75..ea57562 100644 --- a/docs/docs/cli/pfs.md +++ b/docs/docs/cli/pfs.md @@ -1,6 +1,6 @@ --- title: stack pfs -sidebar_position: 73 +sidebar_position: 84 --- # `stack pfs` diff --git a/docs/docs/cli/prisma-eligible.md b/docs/docs/cli/prisma-eligible.md index 9bbcef2..727026e 100644 --- a/docs/docs/cli/prisma-eligible.md +++ b/docs/docs/cli/prisma-eligible.md @@ -1,6 +1,6 @@ --- title: stack prisma eligible -sidebar_position: 94 +sidebar_position: 105 --- # `stack prisma eligible` diff --git a/docs/docs/cli/prisma-export.md b/docs/docs/cli/prisma-export.md index b8afd39..88bb40e 100644 --- a/docs/docs/cli/prisma-export.md +++ b/docs/docs/cli/prisma-export.md @@ -1,6 +1,6 @@ --- title: stack prisma export -sidebar_position: 91 +sidebar_position: 102 --- # `stack prisma export` diff --git a/docs/docs/cli/prisma-extract.md b/docs/docs/cli/prisma-extract.md index 5d32690..7a89a1b 100644 --- a/docs/docs/cli/prisma-extract.md +++ b/docs/docs/cli/prisma-extract.md @@ -1,6 +1,6 @@ --- title: stack prisma extract -sidebar_position: 95 +sidebar_position: 106 --- # `stack prisma extract` diff --git a/docs/docs/cli/prisma-fetch.md b/docs/docs/cli/prisma-fetch.md index a557f55..7d93b0f 100644 --- a/docs/docs/cli/prisma-fetch.md +++ b/docs/docs/cli/prisma-fetch.md @@ -1,6 +1,6 @@ --- title: stack prisma fetch -sidebar_position: 97 +sidebar_position: 108 --- # `stack prisma fetch` diff --git a/docs/docs/cli/prisma-flow.md b/docs/docs/cli/prisma-flow.md index 382697e..580f223 100644 --- a/docs/docs/cli/prisma-flow.md +++ b/docs/docs/cli/prisma-flow.md @@ -1,6 +1,6 @@ --- title: stack prisma flow -sidebar_position: 96 +sidebar_position: 107 --- # `stack prisma flow` diff --git a/docs/docs/cli/prisma-init.md b/docs/docs/cli/prisma-init.md index 88f734b..c44d303 100644 --- a/docs/docs/cli/prisma-init.md +++ b/docs/docs/cli/prisma-init.md @@ -1,6 +1,6 @@ --- title: stack prisma init -sidebar_position: 90 +sidebar_position: 101 --- # `stack prisma init` diff --git a/docs/docs/cli/prisma-ping-llm.md b/docs/docs/cli/prisma-ping-llm.md index 5118139..839c6b0 100644 --- a/docs/docs/cli/prisma-ping-llm.md +++ b/docs/docs/cli/prisma-ping-llm.md @@ -1,6 +1,6 @@ --- title: stack prisma ping-llm -sidebar_position: 92 +sidebar_position: 103 --- # `stack prisma ping-llm` diff --git a/docs/docs/cli/prisma-run.md b/docs/docs/cli/prisma-run.md index 8a9e670..f501c04 100644 --- a/docs/docs/cli/prisma-run.md +++ b/docs/docs/cli/prisma-run.md @@ -1,6 +1,6 @@ --- title: stack prisma run -sidebar_position: 98 +sidebar_position: 109 --- # `stack prisma run` diff --git a/docs/docs/cli/prisma-screen.md b/docs/docs/cli/prisma-screen.md index e073eb1..791436f 100644 --- a/docs/docs/cli/prisma-screen.md +++ b/docs/docs/cli/prisma-screen.md @@ -1,6 +1,6 @@ --- title: stack prisma screen -sidebar_position: 93 +sidebar_position: 104 --- # `stack prisma screen` diff --git a/docs/docs/cli/prisma-vpn.md b/docs/docs/cli/prisma-vpn.md index c376dc7..17d6180 100644 --- a/docs/docs/cli/prisma-vpn.md +++ b/docs/docs/cli/prisma-vpn.md @@ -1,6 +1,6 @@ --- title: stack prisma vpn -sidebar_position: 99 +sidebar_position: 110 --- # `stack prisma vpn` diff --git a/docs/docs/cli/prisma.md b/docs/docs/cli/prisma.md index 7e5d0a9..c8e2360 100644 --- a/docs/docs/cli/prisma.md +++ b/docs/docs/cli/prisma.md @@ -1,6 +1,6 @@ --- title: stack prisma -sidebar_position: 89 +sidebar_position: 100 --- # `stack prisma` diff --git a/docs/docs/cli/rec-list.md b/docs/docs/cli/rec-list.md index 6c38a79..43fc0d9 100644 --- a/docs/docs/cli/rec-list.md +++ b/docs/docs/cli/rec-list.md @@ -1,6 +1,6 @@ --- title: stack rec list -sidebar_position: 70 +sidebar_position: 81 --- # `stack rec list` diff --git a/docs/docs/cli/rec-opps.md b/docs/docs/cli/rec-opps.md index ece31c0..76f4380 100644 --- a/docs/docs/cli/rec-opps.md +++ b/docs/docs/cli/rec-opps.md @@ -1,6 +1,6 @@ --- title: stack rec opps -sidebar_position: 72 +sidebar_position: 83 --- # `stack rec opps` diff --git a/docs/docs/cli/rec-pfs.md b/docs/docs/cli/rec-pfs.md index 22dbbdd..d1b5bdd 100644 --- a/docs/docs/cli/rec-pfs.md +++ b/docs/docs/cli/rec-pfs.md @@ -1,6 +1,6 @@ --- title: stack rec pfs -sidebar_position: 71 +sidebar_position: 82 --- # `stack rec pfs` diff --git a/docs/docs/cli/rec.md b/docs/docs/cli/rec.md index 2b212c0..a866e70 100644 --- a/docs/docs/cli/rec.md +++ b/docs/docs/cli/rec.md @@ -1,6 +1,6 @@ --- title: stack rec -sidebar_position: 69 +sidebar_position: 80 --- # `stack rec` diff --git a/docs/docs/cli/stack.md b/docs/docs/cli/stack.md index 26c1486..addf063 100644 --- a/docs/docs/cli/stack.md +++ b/docs/docs/cli/stack.md @@ -21,6 +21,7 @@ Usage: stack [OPTIONS] COMMAND [ARGS]... │ validate Verify pipeline output contracts against SQLTable models. │ │ health Run health checks on all platform services. │ │ load Ingest data (CCLF, BCDA, seeds). │ +│ bcda BCDA client and manager (exports, store, load, UI). │ │ generate Regenerate code artefacts. │ │ bib Bibliography operations. │ │ bls BLS occupation wages (OEWS) and health-care employment (CES). │ diff --git a/docs/docs/cli/zot-dump-schema.md b/docs/docs/cli/zot-dump-schema.md index 5791fa2..59b7dd7 100644 --- a/docs/docs/cli/zot-dump-schema.md +++ b/docs/docs/cli/zot-dump-schema.md @@ -1,6 +1,6 @@ --- title: stack zot dump-schema -sidebar_position: 84 +sidebar_position: 95 --- # `stack zot dump-schema` diff --git a/docs/docs/cli/zot-fix-dates.md b/docs/docs/cli/zot-fix-dates.md index f19b368..579f7ff 100644 --- a/docs/docs/cli/zot-fix-dates.md +++ b/docs/docs/cli/zot-fix-dates.md @@ -1,6 +1,6 @@ --- title: stack zot fix-dates -sidebar_position: 85 +sidebar_position: 96 --- # `stack zot fix-dates` diff --git a/docs/docs/cli/zot-fix-fields.md b/docs/docs/cli/zot-fix-fields.md index efa3aed..f34bb5d 100644 --- a/docs/docs/cli/zot-fix-fields.md +++ b/docs/docs/cli/zot-fix-fields.md @@ -1,6 +1,6 @@ --- title: stack zot fix-fields -sidebar_position: 87 +sidebar_position: 98 --- # `stack zot fix-fields` diff --git a/docs/docs/cli/zot-fix-keys.md b/docs/docs/cli/zot-fix-keys.md index 080f462..29c4791 100644 --- a/docs/docs/cli/zot-fix-keys.md +++ b/docs/docs/cli/zot-fix-keys.md @@ -1,6 +1,6 @@ --- title: stack zot fix-keys -sidebar_position: 86 +sidebar_position: 97 --- # `stack zot fix-keys` diff --git a/docs/docs/cli/zot-verify-parity.md b/docs/docs/cli/zot-verify-parity.md index 1b8a31d..7010ffa 100644 --- a/docs/docs/cli/zot-verify-parity.md +++ b/docs/docs/cli/zot-verify-parity.md @@ -1,6 +1,6 @@ --- title: stack zot verify-parity -sidebar_position: 88 +sidebar_position: 99 --- # `stack zot verify-parity` diff --git a/docs/docs/cli/zot.md b/docs/docs/cli/zot.md index a20331f..316e411 100644 --- a/docs/docs/cli/zot.md +++ b/docs/docs/cli/zot.md @@ -1,6 +1,6 @@ --- title: stack zot -sidebar_position: 83 +sidebar_position: 94 --- # `stack zot` diff --git a/docs/superpowers/specs/2026-10-25-bcda-v3-design.md b/docs/superpowers/specs/2026-10-25-bcda-v3-design.md index dde53e0..6c8d7d0 100644 --- a/docs/superpowers/specs/2026-10-25-bcda-v3-design.md +++ b/docs/superpowers/specs/2026-10-25-bcda-v3-design.md @@ -64,3 +64,17 @@ The input_layer connector was still written for v2 and had never run end to end 5. **Contracts inherit.** The header stages subclass `ExplanationOfBenefit`; the outputs subclass the canonical `InputLayer*` models, so the same downstream pipelines run on BCDA output as on CCLF output. `load_bcda(run_pipeline=True)` / `stack load bcda --run-pipeline` and `stack run bcda` land `input_layer.medical_claim`, `pharmacy_claim`, `eligibility`. Run on the Large-entity pull (94 s including re-flattening): 221,021 medical lines on 28,282 claims (16,468 institutional, 11,814 professional — equal to the FinalAction non-PDE EOB counts by type; 4,111 NotFinalAction excluded), 11,968 pharmacy rows (= FinalAction PDEs), 9,582 eligibility rows (7,290 with a Part A/B row, 4,868 with dual status). Every institutional line has a 4-character type of bill, admission date, paid, charge, deductible and coinsurance; every professional line has paid, rendering NPI and (99 %) place of service. + +## BCDA manager (`bcda.ui`, `stack bcda`, 2026-10-25) + +A Streamlit app and a CLI group over the same service layer, mirroring the surface of the CMS server (`CMSgov/bcda-app`, vendored): `/_version`, `/_health`, `metadata`, `/auth/token`, `attribution_status`, `$export` on `Group/all` · `Group/runout` · `Patient` with `_type` / `_since` / `_typeFilter`, job history, status, cancel, `/data` download — plus the local side: the Store's job and file history, flatten + load into `bcda.*`, the connector into `input_layer.*`, the replica for browsing, the v3 data dictionary and CCLF crosswalk, and the vendored-upstream manifest. + +Decisions: +1. **`bcda.ui.service` has no Streamlit in it.** Every operation takes explicit objects (a `Connection`, a `Client`, a `Store`), so the CLI reuses it verbatim and tests hand in mock transports and temp directories. Views are thin. +2. **Connections are presets, environment or custom.** The four public sandbox entities from bcda.cms.gov are built in (the legacy REACH pair pinned to v2); `BCDA_CLIENT_ID` / `BCDA_CLIENT_SECRET` (+ `BCDA_BASE_URL`, `BCDA_API_VERSION`) win when set; production is a selectable environment. +3. **Long work runs on background threads** (`bcda.ui.runner`): a process-level registry that survives Streamlit reruns, captures the `bcda.*` log lines emitted on each run's own thread (job started, progress, files stored) and flips `state` last so readers never see a finished run with an incomplete log. A `st.fragment(run_every="5s")` panel shows runs on every page that starts them. +4. **Exports default to one job per type** with cancel retries, because that is the only shape that survives the shared sandbox's sweeps; the `_typeFilter` builder composes the System-Type `_tag`, `outcome` and `service-date` subqueries from the transition deck; completed jobs under 24 h are offered for *adopt*, in-progress ones for *cancel*. +5. **Data pages read the read-only replica only**, so the manager never holds the primary's single-writer lock; the load page republishes the replica after `load_bcda(run_pipeline=…)`. +6. **Launch:** `stack bcda ui` (port, headless flags) runs `streamlit run src/bcda/ui/app.py`; Streamlit lives in the `bcda` extra. Containerising it (image + traefik route + dashboard tile) is deliberately left out of this slice. + +Tests: service with `httpx.MockTransport`, runner for thread-routed logs and state ordering, every page through `AppTest.from_string` with the service stubbed (buttons, forms, error paths), the app shell through `AppTest.from_file`, and the CLI through `CliRunner` — all at 100 % line coverage. diff --git a/pyproject.toml b/pyproject.toml index b05f3a9..19da8d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ bcda = [ "fsspec>=2024.1.0", "narwhals>=2.17.0", "pyarrow>=23.0.1", + "streamlit>=1.40", ] bib = [ "stack[conf]", diff --git a/src/bcda/ui/__init__.py b/src/bcda/ui/__init__.py new file mode 100644 index 0000000..8d5dc13 --- /dev/null +++ b/src/bcda/ui/__init__.py @@ -0,0 +1,10 @@ +"""bcda.ui — the BCDA manager: a Streamlit app over the client, store, +loader, connector pipeline, crosswalk and vendored-upstream tooling. + + stack bcda ui # launches streamlit on src/bcda/ui/app.py + +``service`` holds every operation with no Streamlit in it (the +``stack bcda`` CLI uses the same functions); ``runner`` runs the long +ones on background threads; ``views`` are the pages; ``app`` is the +navigation shell. +""" diff --git a/src/bcda/ui/app.py b/src/bcda/ui/app.py new file mode 100644 index 0000000..3c755d5 --- /dev/null +++ b/src/bcda/ui/app.py @@ -0,0 +1,40 @@ +"""BCDA manager — Streamlit entry point. + +stack bcda ui +# or: uv run streamlit run src/bcda/ui/app.py +""" + +from __future__ import annotations + +import streamlit as st + +from bcda.ui import views + +PAGES = [ + st.Page(views.connect, title="Connect", icon="🔌", default=True), + st.Page(views.exports, title="Exports", icon="📦"), + st.Page(views.store, title="Store", icon="🗄️"), + st.Page(views.load, title="Flatten + load", icon="⚙️"), + st.Page(views.data, title="Data", icon="📊"), + st.Page(views.dictionary, title="Dictionary + crosswalk", icon="📖"), + st.Page(views.upstream, title="Upstream", icon="🛰️"), +] + + +def main() -> None: + st.set_page_config(page_title="BCDA manager", page_icon="🏥", layout="wide") + conn = st.session_state.get("conn") + with st.sidebar: + st.markdown("### BCDA manager") + st.caption( + f"**{conn.label}** · {conn.environment} · {conn.version}" + if conn + else "not connected" + ) + st.navigation(PAGES).run() + + +if ( + __name__ == "__main__" +): # pragma: no cover — ``streamlit run`` executes this file as __main__ + main() diff --git a/src/bcda/ui/runner.py b/src/bcda/ui/runner.py new file mode 100644 index 0000000..4153159 --- /dev/null +++ b/src/bcda/ui/runner.py @@ -0,0 +1,156 @@ +"""Background runs for the BCDA manager UI. + +Streamlit re-executes the script on every interaction, so anything that +takes longer than a click (a sandbox export, a 2 GB download, a flatten ++ load) runs in a daemon thread and is tracked here, in a process-level +registry that survives reruns. Each run captures the ``bcda`` logger +lines emitted on its own thread (job started, progress, files stored), +so the page can show what the client is doing without the client +knowing about the UI. + + run = runner.submit("export Patient", store.export, client, types=["Patient"]) + ... + runner.get(run.id).state # queued · running · done · failed + runner.get(run.id).log # ["Export ABC started", "Job in progress: 35%", ...] +""" + +from __future__ import annotations + +import logging +import threading +import time +import traceback +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +_LOG_LIMIT = 400 + + +@dataclass +class Run: + """One background job and what it has said so far.""" + + id: str + name: str + state: str = "queued" + started: float = field(default_factory=time.time) + finished: float | None = None + result: Any = None + error: str | None = None + log: list[str] = field(default_factory=list) + thread_id: int | None = None + + @property + def elapsed(self) -> float: + return (self.finished or time.time()) - self.started + + @property + def active(self) -> bool: + return self.state in ("queued", "running") + + def append(self, line: str) -> None: + self.log.append(line) + if len(self.log) > _LOG_LIMIT: + del self.log[: len(self.log) - _LOG_LIMIT] + + +class _ThreadLogHandler(logging.Handler): + """Route ``bcda.*`` log records to the Run owning the emitting thread.""" + + def __init__(self, registry: Runner) -> None: + super().__init__(level=logging.DEBUG) + self._registry = registry + + def emit(self, record: logging.LogRecord) -> None: + run = self._registry._by_thread.get(record.thread) + if run is not None: + run.append(record.getMessage()) + + +class Runner: + """Process-level registry of background runs.""" + + def __init__(self) -> None: + self._runs: dict[str, Run] = {} + self._by_thread: dict[int, Run] = {} + self._lock = threading.Lock() + self._handler: _ThreadLogHandler | None = None + + # ── logging capture ────────────────────────────────────── + + def _ensure_handler(self) -> None: + if self._handler is None: + self._handler = _ThreadLogHandler(self) + logger = logging.getLogger("bcda") + logger.addHandler(self._handler) + if logger.level == logging.NOTSET or logger.level > logging.DEBUG: + logger.setLevel(logging.DEBUG) + + # ── API ────────────────────────────────────────────────── + + def submit( + self, name: str, fn: Callable[..., Any], *args: Any, **kwargs: Any + ) -> Run: + """Start *fn* on a daemon thread; return its Run immediately.""" + self._ensure_handler() + run = Run(id=uuid.uuid4().hex[:8], name=name) + with self._lock: + self._runs[run.id] = run + + def _target() -> None: + run.thread_id = threading.get_ident() + with self._lock: + self._by_thread[run.thread_id] = run + run.state = "running" + run.append(f"started: {name}") + # The state flips last: ``wait()`` and the page read ``state`` + # without a lock, so result, log and finish time must already be + # in place when they see done/failed. + try: + run.result = fn(*args, **kwargs) + run.append("finished") + run.finished = time.time() + run.state = "done" + except Exception as exc: # noqa: BLE001 — surfaced to the page + run.error = f"{type(exc).__name__}: {exc}" + run.append(run.error) + run.append(traceback.format_exc(limit=3)) + run.finished = time.time() + run.state = "failed" + finally: + with self._lock: + self._by_thread.pop(run.thread_id, None) + + threading.Thread(target=_target, name=f"bcda-ui-{run.id}", daemon=True).start() + return run + + def get(self, run_id: str) -> Run | None: + return self._runs.get(run_id) + + def all(self) -> list[Run]: + """Newest first.""" + return sorted(self._runs.values(), key=lambda r: r.started, reverse=True) + + def active(self) -> list[Run]: + return [r for r in self.all() if r.active] + + def wait(self, run_id: str, timeout: float = 30.0) -> Run: + """Block until the run leaves queued/running (tests and the CLI).""" + run = self._runs[run_id] + deadline = time.time() + timeout + while run.active and time.time() < deadline: + time.sleep(0.02) + return run + + def clear_finished(self) -> int: + with self._lock: + gone = [k for k, r in self._runs.items() if not r.active] + for k in gone: + del self._runs[k] + return len(gone) + + +runner = Runner() +"""The registry the UI and CLI share.""" diff --git a/src/bcda/ui/service.py b/src/bcda/ui/service.py new file mode 100644 index 0000000..cd7643b --- /dev/null +++ b/src/bcda/ui/service.py @@ -0,0 +1,537 @@ +"""Service layer for the BCDA manager — every operation the UI and the +``stack bcda`` CLI expose, with no Streamlit in it. + +Mirrors the surface of the CMS server (``CMSgov/bcda-app``, vendored +under ``dev/vendor/bcda-app``): ``/_version``, ``/_health``, +``/api/v3/metadata``, ``/auth/token``, ``/api/v3/attribution_status``, +``$export`` on ``Group/all`` · ``Group/runout`` · ``Patient`` with +``_type`` / ``_since`` / ``_typeFilter``, ``/api/v3/jobs`` (list, status, +cancel) and ``/data`` downloads — plus the local side: the Store's job +and file history, flatten + load into ``bcda.*``, the connector pipeline +into ``input_layer.*``, the replica for browsing, the v3 data dictionary +and CCLF crosswalk, and the vendored-upstream manifest. + +Everything takes explicit objects (a :class:`Connection`, a +:class:`bcda.client.Client`, a :class:`bcda.store.Store`) so tests can +hand in mock transports and temp directories. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from bcda.client import ( + PRODUCTION, + SANDBOX, + SYSTEM_TYPES, + BcdaError, + Client, + type_filter_tag, +) +from bcda.store import Store + +RESOURCE_TYPES = ("Patient", "Coverage", "ExplanationOfBenefit") +ENDPOINTS = ("Group/all", "Group/runout", "Patient") +ENVIRONMENTS = {"sandbox": SANDBOX, "production": PRODUCTION} + +# ── Connections ────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Connection: + """Where and as whom to talk to BCDA.""" + + label: str + base_url: str + client_id: str + client_secret: str + version: str = "v3" + note: str = "" + + @property + def environment(self) -> str: + return "sandbox" if self.base_url == SANDBOX else "production" + + def masked_secret(self) -> str: + s = self.client_secret + return f"{s[:4]}…{s[-4:]}" if len(s) > 8 else "…" + + +# Public sandbox credentials published at +# https://bcda.cms.gov/api-documentation/get-a-bearer-token.html — synthetic +# entities anyone may use; the legacy REACH pair only speaks v2. +PRESETS: dict[str, Connection] = { + "Large Model Entity": Connection( + "Large Model Entity", + SANDBOX, + "f5b6a686-0d7b-4080-873a-e60534fd2995", + "96ac1483449c652f363396737937f23cdae47a56a116c8a4405dbd54b31d2686f005df502c5c86ab", + note="10,000 synthetic enrollees · adjudicated claims only · shared by every sandbox user", + ), + "Small Model Entity": Connection( + "Small Model Entity", + SANDBOX, + "6dc59a4c-ef93-46c0-9c00-02e500e13731", + "48fe3a3731ee5b68cf4ebeea6d93fa2d9db3396e94f6830f17d7d7c0fa1fb61f413fcde6f6322f84", + note="100 synthetic enrollees · fastest end-to-end run", + ), + "Legacy Large REACH ACO (v2)": Connection( + "Legacy Large REACH ACO (v2)", + SANDBOX, + "1f4575c9-80ea-425c-9d3e-c2b79e9159da", + "78389f5e9a9148cfd14832b4dfa89d8ebf3d09e178763ec3cdfee1373068e3b60f66c1bed2c14025", + version="v2", + note="11,000 synthetic enrollees · v2 only · partially adjudicated Claim/ClaimResponse", + ), + "Legacy Small REACH ACO (v2)": Connection( + "Legacy Small REACH ACO (v2)", + SANDBOX, + "7e57394f-eddb-46c7-a87b-a23f14ded95d", + "2c5130031150cd43afdc24659de2210086f6115fc8d897d7547faaa26c827b53bb70c14089bdeb17", + version="v2", + note="110 synthetic enrollees · v2 only", + ), +} + + +def connection_from_env(env: dict[str, str] | None = None) -> Connection | None: + """A Connection from ``BCDA_CLIENT_ID`` / ``BCDA_CLIENT_SECRET`` (+ ``BCDA_BASE_URL``, + ``BCDA_API_VERSION``), or None when the id or secret is unset.""" + e = os.environ if env is None else env + cid, sec = e.get("BCDA_CLIENT_ID", ""), e.get("BCDA_CLIENT_SECRET", "") + if not cid or not sec: + return None + base = e.get("BCDA_BASE_URL", SANDBOX) + return Connection( + label="environment credentials", + base_url=base, + client_id=cid, + client_secret=sec, + version=e.get("BCDA_API_VERSION", "v3"), + note="from BCDA_CLIENT_ID / BCDA_CLIENT_SECRET", + ) + + +def store_path() -> Path: + from conf import path + + return Path(path("storage.bcda")) + + +def make_client(conn: Connection, *, output_dir: Path | None = None) -> Client: + return Client( + conn.client_id, + conn.client_secret, + base_url=conn.base_url, + version=conn.version, + output_dir=output_dir or store_path(), + ) + + +def make_store(path: Path | None = None) -> Store: + return Store(path=str(path or store_path())) + + +# ── Server side ────────────────────────────────────────────── + + +def preflight(client: Client) -> dict[str, Any]: + """No-token checks: ``/_version``, ``/_health``, ``metadata``.""" + out: dict[str, Any] = {"base_url": client.base_url, "version": client.version} + try: + md = client.metadata() + out["fhir_version"] = md.get("fhirVersion") + sw = md.get("software") or {} + out["software"] = sw.get("version") + out["release_date"] = sw.get("releaseDate") + out["resources"] = [ + r.get("type") for r in (md.get("rest") or [{}])[0].get("resource", []) + ] + out["ok"] = True + except Exception as exc: # noqa: BLE001 — shown on the page + out["ok"] = False + out["error"] = str(exc) + return out + for key, fn in ( + ("server_version", client.server_version), + ("health", client.health), + ): + try: + out[key] = fn() + except BcdaError as exc: + out[key] = {"error": str(exc)} + return out + + +def auth_check(client: Client) -> dict[str, Any]: + """Token + attribution status; raises on bad credentials.""" + token = client.authenticate() + attribution = client.attribution_status() + ingest = {d["type"]: d["timestamp"] for d in attribution.get("ingestion_dates", [])} + expire = { + d["type"]: d["timestamp"] for d in attribution.get("expiration_dates", []) + } + return { + "token_chars": len(token), + "last_attribution_update": ingest.get("last_attribution_update"), + "last_runout_update": ingest.get("last_runout_update"), + "attribution_access_expiration": expire.get("attribution_access_expiration"), + "runout_access_expiration": expire.get("runout_access_expiration"), + } + + +def _parse_instant(value: str) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def server_jobs( + client: Client, *, limit: int = 100, now: datetime | None = None +) -> list[dict]: + """The entity's job history, newest first, with a ``reusable`` flag. + + A completed job younger than 24 h still serves its files; the flag + marks those so the page can offer *adopt* instead of a new export. + Only this client's API version is listed (``/data`` payloads differ). + """ + now = now or datetime.now(timezone.utc) + cutoff = now - timedelta(hours=23) + rows = [] + for task in client.jobs(): + ident = (task.get("identifier") or [{}])[0] + request = ((task.get("input") or [{}])[0]).get("valueString", "") + if f"/api/{client.version}/" not in request: + continue + period = task.get("executionPeriod") or {} + end = _parse_instant(period.get("end", "")) + status = str(task.get("status", "")) + rows.append( + { + "job_id": ident.get("value"), + "status": status, + "types": ",".join(_types_from_request(request)) or "(all)", + "endpoint": _endpoint_from_request(request), + "started": period.get("start", ""), + "ended": period.get("end", ""), + "reusable": status.lower() == "completed" + and end is not None + and end >= cutoff, + "job_url": f"{client.base_url}/api/{client.version}/jobs/{ident.get('value')}", + } + ) + rows.sort(key=lambda r: r["started"], reverse=True) + return rows[:limit] + + +def _types_from_request(request: str) -> list[str]: + from bcda.client import _requested_types + + return _requested_types(request) + + +def _endpoint_from_request(request: str) -> str: + url = request.split(" ", 1)[-1] + for ep in ENDPOINTS: + if f"/{ep}/$export" in url: + return ep + return "?" + + +def build_type_filter( + *, + system_types: list[str] | None = None, + outcome: str | None = None, + service_date_from: str | None = None, +) -> str | None: + """Compose a v3 ``_typeFilter`` subquery from the three filters the + transition deck documents: System-Type ``_tag``, ``outcome`` + (partial / complete) and ``service-date``. None when nothing is set.""" + params: list[str] = [] + if system_types: + bad = [s for s in system_types if s not in SYSTEM_TYPES] + if bad: + raise ValueError(f"unknown System-Type {bad}; choose from {SYSTEM_TYPES}") + params.append(type_filter_tag(*system_types).split("?", 1)[1]) + if outcome: + if outcome not in ("partial", "complete"): + raise ValueError("outcome must be 'partial' or 'complete'") + params.append(f"outcome={outcome}") + if service_date_from: + params.append(f"service-date=gt{service_date_from}") + return "ExplanationOfBenefit?" + "&".join(params) if params else None + + +def start_export( + store: Store, + client: Client, + *, + endpoint: str = "Group/all", + types: list[str] | None = None, + since: str | None = None, + type_filter: str | None = None, + cancel_retries: int = 6, + poll_interval: float = 15.0, +) -> str: + """Run one export through the Store (blocking; the UI runs it on the Runner).""" + if endpoint not in ENDPOINTS: + raise ValueError(f"endpoint must be one of {ENDPOINTS}") + return store.export( + client, + endpoint=endpoint, + types=types or None, + since=since or None, + type_filter=type_filter or None, + poll_interval=poll_interval, + cancel_retries=cancel_retries, + ) + + +def export_per_type( + store: Store, + client: Client, + *, + types: list[str] | None = None, + endpoint: str = "Group/all", + since: str | None = None, + type_filter: str | None = None, + cancel_retries: int = 6, + poll_interval: float = 15.0, +) -> dict[str, str]: + """One job per resource type — the only shape that survives the shared + sandbox's cancellation sweeps. Returns ``{type: job_key}``.""" + keys: dict[str, str] = {} + for rtype in types or RESOURCE_TYPES: + keys[rtype] = start_export( + store, + client, + endpoint=endpoint, + types=[rtype], + since=since, + type_filter=type_filter if rtype == "ExplanationOfBenefit" else None, + cancel_retries=cancel_retries, + poll_interval=poll_interval, + ) + return keys + + +def adopt_job( + store: Store, client: Client, job_url: str, *, types: list[str] | None = None +) -> str: + return store.adopt(client, job_url, types=types, poll_interval=10) + + +def cancel_job(client: Client, job_url: str) -> None: + client.cancel(job_url) + + +# ── Local side: store ──────────────────────────────────────── + + +def store_jobs(store: Store) -> list[dict]: + """Current state of every store job, newest first, with file counts and bytes.""" + rows = [] + for job in store.list_jobs(): + files = store.get_files(job["key"]) + rows.append( + { + "key": job["key"], + "status": job["status"], + "endpoint": job["endpoint"], + "types": job["types"], + "since": job["since"], + "job_url": job["job_url"], + "ts": job["ts"], + "files": len(files), + "bytes": sum(int(f.get("file_size") or 0) for f in files), + "error": job.get("error", ""), + } + ) + rows.sort(key=lambda r: str(r["ts"]), reverse=True) + return rows + + +def store_files(store: Store, key: str) -> list[dict]: + return store.get_files(key) + + +def export_dirs(path: Path | None = None) -> list[dict]: + """``exports//`` directories on disk with the resource files they hold.""" + root = (path or store_path()) / "exports" + out = [] + if not root.exists(): + return out + for d in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + if not d.is_dir(): + continue + files = list(d.glob("*.ndjson")) + out.append( + { + "key": d.name, + "modified": datetime.fromtimestamp(d.stat().st_mtime).isoformat( + timespec="seconds" + ), + "types": sorted({f.name.split(".")[0] for f in files}), + "files": len(files), + "bytes": sum(f.stat().st_size for f in files), + } + ) + return out + + +# ── Local side: load, pipeline, replica ────────────────────── + + +def load_and_publish( + *, run_pipeline: bool = True, skip_flatten: bool = False +) -> dict[str, int]: + """Newest export per type → ``bcda.*`` (→ ``input_layer.*``) → replica.""" + from aco.load.bcda import load_bcda + from conf import connect + + stats = load_bcda(skip_flatten=skip_flatten, run_pipeline=run_pipeline) + connect.publish_replica("aco") + return stats + + +def replica_connect() -> Any: + from conf import connect + + return connect.duckdb("aco", read_only=True) + + +def table_counts(con: Any) -> dict[str, int]: + """Row counts of the ``bcda.*`` and ``input_layer.*`` tables present.""" + rows = con.execute( + "select table_schema, table_name from information_schema.tables " + "where table_schema in ('bcda', 'input_layer') order by 1, 2" + ).fetchall() + out: dict[str, int] = {} + for schema, table in rows: + out[f"{schema}.{table}"] = con.execute( + f'select count(*) from "{schema}"."{table}"' + ).fetchone()[0] + return out + + +SUMMARY_QUERIES: dict[str, str] = { + "Claims by System-Type × EOB-TYPE": ( + "select system_type, eob_type, final_action, count(*) claims, round(sum(clm_pmt_amt)) paid " + "from bcda.explanation_of_benefit group by 1, 2, 3 order by 1, 2, 3" + ), + "Coverage rows by plan": ( + "select class_plan, status, count(*) rows_, count(distinct subscriber_id) beneficiaries " + "from bcda.coverage group by 1, 2 order by 1, 2" + ), + "input_layer.medical_claim by claim_type": ( + "select claim_type, count(*) lines, count(distinct claim_id) claims, " + "round(sum(paid_amount)) paid, round(sum(charge_amount)) charge " + "from input_layer.medical_claim group by 1 order by 1" + ), + "Patients": ( + "select count(*) patients, count(bene_mbi_id) with_mbi, count(bene_death_dt) deceased, " + "count(distinct geo_usps_state_cd) states from bcda.patient" + ), +} + + +def run_query(con: Any, sql: str, *, limit: int = 1000) -> Any: + """Read-only query → polars frame (the replica connection is read-only).""" + return con.execute(f"select * from ({sql}) q limit {int(limit)}").pl() + + +# ── Dictionary + crosswalk ─────────────────────────────────── + + +def dictionary_frames(con: Any | None = None) -> dict[str, Any]: + """Data dictionary, CCLF crosswalk and absent-field frames. + + From ``reference_data`` when ``stack load seed`` has run, else from + the committed ``dev/seeds`` CSVs; empty dict when neither exists. + """ + import polars as pl + + names = ("bcda_v3_data_dictionary", "bcda_v3_cclf_crosswalk", "bcda_v3_cclf_absent") + if con is not None: + present = { + r[0] + for r in con.execute( + "select table_name from information_schema.tables where table_schema = 'reference_data'" + ).fetchall() + } + if set(names) <= present: + return { + n: con.execute(f"select * from reference_data.{n}").pl() for n in names + } + from conf import ROOT + + seeds = Path(ROOT) / "dev" / "seeds" + if all((seeds / f"{n}.csv").exists() for n in names): + return { + n: pl.read_csv(seeds / f"{n}.csv", infer_schema_length=10000) for n in names + } + return {} + + +# ── Vendored upstream ──────────────────────────────────────── + + +def _vendor_module() -> Any | None: + from conf import ROOT + + script = Path(ROOT) / "dev" / "scripts" / "vendor_bcda_app.py" + if not script.exists(): + return None + spec = importlib.util.spec_from_file_location("vendor_bcda_app", script) + mod = importlib.util.module_from_spec(spec) + sys.modules.setdefault("vendor_bcda_app", mod) + spec.loader.exec_module(mod) + return mod + + +def vendor_status() -> dict[str, Any]: + """Manifest sources + offline verification of the vendored tree.""" + mod = _vendor_module() + if mod is None: + return {"available": False} + m = mod.load_manifest() + problems = mod.verify(m) + return { + "available": True, + "sources": [ + { + "name": s["name"], + "kind": s["kind"], + "origin": s.get("repo") or s.get("base"), + "commit": s.get("commit", ""), + "files": len(s["files"]), + } + for s in m["source"] + ], + "files": [ + { + "path": f"{s['name']}/{f}", + "sha256": m["sha256"].get(f"{s['name']}/{f}", "")[:12], + } + for s in m["source"] + for f in s["files"] + ], + "problems": problems, + } + + +def vendor_check() -> list[str]: + """Online drift check against GitHub and bcda.cms.gov (empty = no drift).""" + mod = _vendor_module() + if mod is None: + return ["vendor script not available in this checkout"] + return mod.check(mod.load_manifest()) diff --git a/src/bcda/ui/views.py b/src/bcda/ui/views.py new file mode 100644 index 0000000..8f05adf --- /dev/null +++ b/src/bcda/ui/views.py @@ -0,0 +1,543 @@ +"""Pages of the BCDA manager. Thin: every operation is in ``service``. + +Session state keys: ``conn`` (the active :class:`service.Connection`), +``last_preflight``, ``last_auth``, ``server_jobs``, ``last_query``. +Long operations go through :mod:`bcda.ui.runner` and are shown by the +``runs_panel`` fragment, which re-renders itself every few seconds. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import polars as pl +import streamlit as st + +from bcda.ui import service as svc +from bcda.ui.runner import runner + +# ── shared ─────────────────────────────────────────────────── + + +def _conn() -> svc.Connection | None: + return st.session_state.get("conn") + + +def _require_conn() -> svc.Connection | None: + conn = _conn() + if conn is None: + st.info("No connection yet — pick one on the **Connect** page.") + return conn + + +def _client() -> Any: + return svc.make_client(_conn()) + + +def _store() -> Any: + return svc.make_store() + + +def _fmt_bytes(n: int) -> str: + size = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:,.0f} {unit}" if unit == "B" else f"{size:,.1f} {unit}" + size /= 1024 + return f"{n}" # pragma: no cover — loop always returns + + +@st.fragment(run_every="5s") +def runs_panel() -> None: + """Background runs: state, elapsed, last log lines.""" + runs = runner.all() + if not runs: + return + st.subheader("Background runs") + for run in runs[:8]: + icon = {"queued": "⏳", "running": "🔄", "done": "✅", "failed": "❌"}[ + run.state + ] + with st.expander( + f"{icon} {run.name} — {run.state}, {run.elapsed:,.0f}s", expanded=run.active + ): + if run.error: + st.error(run.error) + if run.result is not None and not run.active: + st.json( + run.result + if isinstance(run.result, (dict, list)) + else {"result": str(run.result)} + ) + if run.log: + st.code("\n".join(run.log[-12:]), language="text") + if st.button("Clear finished runs", key="clear_runs"): + runner.clear_finished() + st.rerun() + + +# ── Connect ────────────────────────────────────────────────── + + +def connect() -> None: + st.title("Connect") + st.caption( + "BCDA v3 (v1/v2 end 2027-07-30). `/auth` and `/data` are shared by every version; " + "only the `/api//` segment differs." + ) + env_conn = svc.connection_from_env() + sources = ( + (["Environment credentials"] if env_conn else []) + + list(svc.PRESETS) + + ["Custom"] + ) + choice = st.radio("Credentials", sources, horizontal=True, key="conn_source") + + if choice == "Environment credentials": + candidate = env_conn + elif choice == "Custom": + c1, c2 = st.columns(2) + env_name = c1.selectbox("Environment", list(svc.ENVIRONMENTS), key="custom_env") + version = c2.selectbox("API version", ["v3", "v2"], key="custom_version") + cid = st.text_input("Client ID", key="custom_id") + sec = st.text_input("Client secret", type="password", key="custom_secret") + candidate = ( + svc.Connection( + "custom", svc.ENVIRONMENTS[env_name], cid, sec, version=version + ) + if cid and sec + else None + ) + else: + candidate = svc.PRESETS[choice] + + if candidate is not None: + st.write( + f"**{candidate.label}** · {candidate.environment} · `{candidate.version}` · " + f"client `{candidate.client_id}` · secret `{candidate.masked_secret()}`" + ) + if candidate.note: + st.caption(candidate.note) + if st.button("Use this connection", type="primary", key="use_conn"): + st.session_state["conn"] = candidate + st.session_state.pop("last_preflight", None) + st.session_state.pop("last_auth", None) + st.session_state.pop("server_jobs", None) + st.toast(f"Connected to {candidate.label}") + + conn = _conn() + if conn is None: + return + st.divider() + st.subheader(f"Active: {conn.label}") + c1, c2 = st.columns(2) + if c1.button("Preflight (no token)", key="btn_preflight"): + st.session_state["last_preflight"] = svc.preflight(_client()) + if c2.button("Authenticate + attribution", key="btn_auth"): + try: + st.session_state["last_auth"] = svc.auth_check(_client()) + except Exception as exc: # noqa: BLE001 — bad credentials are the common case + st.session_state["last_auth"] = {"error": str(exc)} + + pre = st.session_state.get("last_preflight") + if pre: + if pre.get("ok"): + m1, m2, m3 = st.columns(3) + m1.metric("FHIR", pre.get("fhir_version") or "?") + m2.metric("bcda-app", pre.get("software") or "?") + health = pre.get("health") or {} + m3.metric("database", health.get("database") or health.get("error") or "?") + st.write("Resources: " + ", ".join(pre.get("resources") or [])) + st.caption( + f"release {pre.get('release_date')} · /_version {pre.get('server_version')}" + ) + else: + st.error(f"Preflight failed: {pre.get('error')}") + auth = st.session_state.get("last_auth") + if auth: + if "error" in auth: + st.error(f"Authentication failed: {auth['error']}") + else: + st.success( + f"Token issued ({auth['token_chars']} chars, 20-minute lifetime)" + ) + st.json(auth) + + +# ── Exports ────────────────────────────────────────────────── + + +def exports() -> None: + st.title("Exports") + conn = _require_conn() + if conn is None: + return + + with st.form("export_form"): + c1, c2, c3 = st.columns(3) + endpoint = c1.selectbox("Endpoint", list(svc.ENDPOINTS), key="exp_endpoint") + types = c2.multiselect( + "Resource types", + list(svc.RESOURCE_TYPES), + default=list(svc.RESOURCE_TYPES), + key="exp_types", + ) + per_type = c3.checkbox( + "One job per type", + value=True, + key="exp_per_type", + help="A combined job on the shared sandbox rarely survives its cancellation sweeps; EOB-only finishes in ~6 min.", + ) + c4, c5, c6 = st.columns(3) + use_since = c4.checkbox("_since", key="exp_use_since") + since_date = c5.date_input( + "updated after", value=date.today().replace(day=1), key="exp_since" + ) + retries = c6.number_input( + "cancel retries", min_value=0, max_value=20, value=6, key="exp_retries" + ) + st.markdown("**`_typeFilter`** (v3 — EOB only; default omits SharedSystem)") + f1, f2, f3 = st.columns(3) + system_types = f1.multiselect( + "System-Type _tag", list(svc.SYSTEM_TYPES), key="exp_system_types" + ) + outcome = f2.selectbox( + "outcome", ["", "complete", "partial"], key="exp_outcome" + ) + use_svc = f3.checkbox("service-date after", key="exp_use_svc_date") + svc_date = f3.date_input( + "service date", + value=date(date.today().year, 1, 1), + key="exp_svc_date", + label_visibility="collapsed", + ) + submitted = st.form_submit_button("Start export", type="primary") + + if submitted: + since = f"{since_date.isoformat()}T00:00:00+00:00" if use_since else None + type_filter = svc.build_type_filter( + system_types=system_types or None, + outcome=outcome or None, + service_date_from=svc_date.isoformat() if use_svc else None, + ) + store, client = _store(), _client() + if per_type: + run = runner.submit( + f"export {', '.join(types)} ({endpoint})", + svc.export_per_type, + store, + client, + types=types, + endpoint=endpoint, + since=since, + type_filter=type_filter, + cancel_retries=int(retries), + ) + else: + run = runner.submit( + f"export {endpoint} {','.join(types)}", + svc.start_export, + store, + client, + endpoint=endpoint, + types=types, + since=since, + type_filter=type_filter, + cancel_retries=int(retries), + ) + st.toast(f"Started run {run.id}") + if type_filter: + st.caption(f"`_typeFilter={type_filter}`") + + st.divider() + st.subheader("Server job history") + st.caption( + "Shared entity: other sandbox users' jobs appear here too. Completed jobs serve their files for 24 h." + ) + if st.button("Refresh jobs", key="btn_jobs"): + try: + st.session_state["server_jobs"] = svc.server_jobs(_client()) + except Exception as exc: # noqa: BLE001 + st.error(f"Could not list jobs: {exc}") + jobs = st.session_state.get("server_jobs") + if jobs: + st.dataframe( + pl.DataFrame(jobs).drop("job_url"), hide_index=True, width="stretch" + ) + reusable = [j for j in jobs if j["reusable"]] + active = [ + j + for j in jobs + if j["status"].lower() in ("in-progress", "pending", "in progress") + ] + a1, a2 = st.columns(2) + if reusable: + pick = a1.selectbox( + "Adopt a completed job (download its files)", + [f"{j['job_id']} · {j['types']} · {j['ended']}" for j in reusable], + key="adopt_pick", + ) + if a1.button("Adopt", key="btn_adopt"): + job = reusable[ + [ + f"{j['job_id']} · {j['types']} · {j['ended']}" for j in reusable + ].index(pick) + ] + run = runner.submit( + f"adopt job {job['job_id']}", + svc.adopt_job, + _store(), + _client(), + job["job_url"], + types=[t for t in job["types"].split(",") if t and t != "(all)"] + or None, + ) + st.toast(f"Adopting {job['job_id']} as run {run.id}") + if active: + pick = a2.selectbox( + "Cancel an in-progress job", + [f"{j['job_id']} · {j['types']}" for j in active], + key="cancel_pick", + ) + if a2.button("Cancel", key="btn_cancel"): + job = active[ + [f"{j['job_id']} · {j['types']}" for j in active].index(pick) + ] + try: + svc.cancel_job(_client(), job["job_url"]) + st.toast(f"Cancelled {job['job_id']}") + st.session_state.pop("server_jobs", None) + except Exception as exc: # noqa: BLE001 + st.error(f"Cancel failed: {exc}") + runs_panel() + + +# ── Store ──────────────────────────────────────────────────── + + +def store() -> None: + st.title("Store") + path = svc.store_path() + st.caption( + f"`{path}` — append-only `jobs.parquet` / `files.parquet`; the last row per key is the current state." + ) + s = _store() + jobs = svc.store_jobs(s) + if not jobs: + st.info("No store jobs yet.") + else: + df = pl.DataFrame(jobs).with_columns( + pl.col("bytes") + .map_elements(_fmt_bytes, return_dtype=pl.String) + .alias("size") + ) + st.dataframe(df.drop("bytes", "job_url"), hide_index=True, width="stretch") + key = st.selectbox("Files for job", [j["key"] for j in jobs], key="store_key") + files = svc.store_files(s, key) + if files: + fdf = pl.DataFrame(files).with_columns( + pl.col("file_size") + .map_elements(_fmt_bytes, return_dtype=pl.String) + .alias("size") + ) + st.dataframe( + fdf.select("resource_type", "status", "size", "storage_path", "ts"), + hide_index=True, + width="stretch", + ) + st.subheader("Export directories on disk") + dirs = svc.export_dirs(path) + if dirs: + ddf = pl.DataFrame(dirs).with_columns( + pl.col("bytes") + .map_elements(_fmt_bytes, return_dtype=pl.String) + .alias("size"), + pl.col("types").list.join(", "), + ) + st.dataframe(ddf.drop("bytes"), hide_index=True, width="stretch") + else: + st.caption("none") + runs_panel() + + +# ── Load ───────────────────────────────────────────────────── + + +def load() -> None: + st.title("Flatten + load") + st.caption( + "Newest export per resource type → `bcda.*` in aco.duckdb (v2 or v3 dialect per resource, " + "streamed from multi-part ndjson) → connector pipeline → `input_layer.*` → replica republished." + ) + c1, c2 = st.columns(2) + run_pipeline = c1.checkbox( + "Run connector pipeline into input_layer.*", value=True, key="load_pipeline" + ) + skip_flatten = c2.checkbox( + "Skip flatten (reuse existing Parquet)", value=False, key="load_skip" + ) + if st.button("Load now", type="primary", key="btn_load"): + run = runner.submit( + "flatten + load", + svc.load_and_publish, + run_pipeline=run_pipeline, + skip_flatten=skip_flatten, + ) + st.toast(f"Load started as run {run.id}") + runs_panel() + + +# ── Data ───────────────────────────────────────────────────── + + +def data() -> None: + st.title("Data") + try: + con = svc.replica_connect() + except Exception as exc: # noqa: BLE001 — no replica yet + st.warning(f"Replica not available: {exc}") + return + counts = svc.table_counts(con) + if not counts: + st.info("No `bcda.*` / `input_layer.*` tables in the replica yet — run a load.") + return + cols = st.columns(min(4, len(counts))) + for i, (name, n) in enumerate(counts.items()): + cols[i % len(cols)].metric(name, f"{n:,}") + st.subheader("Summaries") + pick = st.selectbox("Summary", list(svc.SUMMARY_QUERIES), key="summary_pick") + try: + st.dataframe( + svc.run_query(con, svc.SUMMARY_QUERIES[pick]), + hide_index=True, + width="stretch", + ) + except Exception as exc: # noqa: BLE001 — table may be absent + st.warning(str(exc)) + st.subheader("SQL (read-only replica)") + sql = st.text_area( + "query", + value="select eob_type, count(*) n from bcda.explanation_of_benefit group by 1 order by 2 desc", + key="sql", + label_visibility="collapsed", + ) + if st.button("Run", key="btn_sql"): + try: + st.session_state["last_query"] = svc.run_query(con, sql) + except Exception as exc: # noqa: BLE001 + st.session_state["last_query"] = str(exc) + res = st.session_state.get("last_query") + if isinstance(res, pl.DataFrame): + st.dataframe(res, hide_index=True, width="stretch") + st.caption(f"{len(res):,} rows (capped at 1,000)") + elif isinstance(res, str): + st.error(res) + + +# ── Dictionary ─────────────────────────────────────────────── + + +def dictionary() -> None: + st.title("Data dictionary + CCLF crosswalk") + try: + con = svc.replica_connect() + except Exception: # noqa: BLE001 + con = None + frames = svc.dictionary_frames(con) + if not frames: + st.info( + "Crosswalk seeds not found — run `uv run python dev/scripts/gen_bcda_v3_crosswalk.py`." + ) + return + needle = ( + st.text_input( + "Search field, source column, fhirPath, CCW or CCLF mapping", + key="dict_search", + ) + .strip() + .lower() + ) + dd = frames["bcda_v3_data_dictionary"] + if needle: + mask = pl.lit(False) + for col in ( + "field_name", + "source_column", + "fhir_path", + "ccw_mapping", + "cclf_mapping", + ): + if col in dd.columns: + mask = mask | pl.col(col).cast( + pl.String + ).str.to_lowercase().str.contains(needle, literal=True).fill_null(False) + dd = dd.filter(mask) + t1, t2, t3 = st.tabs( + [f"Dictionary ({len(dd):,})", "CCLF → BCDA v3", "CCLF fields absent from v3"] + ) + with t1: + st.dataframe( + dd.select( + [ + c + for c in ( + "fhir_resource", + "claim_type", + "field_name", + "source_column", + "ccw_mapping", + "cclf_mapping", + "fhir_path", + ) + if c in dd.columns + ] + ), + hide_index=True, + width="stretch", + ) + with t2: + xw = frames["bcda_v3_cclf_crosswalk"] + only_mapped = st.checkbox( + "Only elements with a v3 field", value=True, key="xw_mapped" + ) + if only_mapped and "bcda_v3_field" in xw.columns: + xw = xw.filter( + pl.col("bcda_v3_field").is_not_null() + & (pl.col("bcda_v3_field").cast(pl.String) != "") + ) + st.dataframe(xw, hide_index=True, width="stretch") + with t3: + st.dataframe(frames["bcda_v3_cclf_absent"], hide_index=True, width="stretch") + + +# ── Upstream ───────────────────────────────────────────────── + + +def upstream() -> None: + st.title("Upstream: CMSgov/bcda-app + bcda.cms.gov") + status = svc.vendor_status() + if not status.get("available"): + st.info( + "Vendor manifest not available in this checkout (`dev/vendor/MANIFEST.toml`)." + ) + return + st.dataframe(pl.DataFrame(status["sources"]), hide_index=True, width="stretch") + if status["problems"]: + st.error( + "Vendored tree does not match the manifest:\n\n" + + "\n".join(f"- {p}" for p in status["problems"]) + ) + else: + st.success( + f"Vendored tree matches the manifest ({len(status['files'])} files)." + ) + with st.expander("Files"): + st.dataframe(pl.DataFrame(status["files"]), hide_index=True, width="stretch") + if st.button("Check upstream for drift (network)", key="btn_vendor_check"): + run = runner.submit("vendor drift check", svc.vendor_check) + st.toast(f"Checking upstream as run {run.id}") + st.caption( + "A weekly workflow runs the same check and files an issue on drift; refresh with `vendor_bcda_app.py --sync` then `gen_bcda_v3_crosswalk.py`." + ) + runs_panel() diff --git a/src/cli/__init__.py b/src/cli/__init__.py index b1cd7af..49219c6 100644 --- a/src/cli/__init__.py +++ b/src/cli/__init__.py @@ -13,6 +13,7 @@ from __future__ import annotations import typer from cli.api import app as api_app +from cli.bcda import app as bcda_app from cli.bib import app as bib_app from cli.bls import app as bls_app from cli.comments import app as comments_app @@ -42,6 +43,9 @@ app.command()(run) app.command()(validate) app.command()(health) app.add_typer(load_app, name="load", help="Ingest data (CCLF, BCDA, seeds).") +app.add_typer( + bcda_app, name="bcda", help="BCDA client and manager (exports, store, load, UI)." +) app.add_typer(generate_app, name="generate", help="Regenerate code artefacts.") app.add_typer(bib_app, name="bib", help="Bibliography operations.") app.add_typer( diff --git a/src/cli/bcda.py b/src/cli/bcda.py new file mode 100644 index 0000000..33a7dda --- /dev/null +++ b/src/cli/bcda.py @@ -0,0 +1,280 @@ +"""stack bcda — the BCDA client and manager from the terminal. + +The same operations the Streamlit manager exposes (``stack bcda ui``): +preflight, authentication, job history, exports with ``_typeFilter``, +adopting and cancelling jobs, the local store, flatten + load + connector +pipeline, and the vendored-upstream drift check. + +Credentials: ``BCDA_CLIENT_ID`` / ``BCDA_CLIENT_SECRET`` (+ ``BCDA_BASE_URL``, +``BCDA_API_VERSION``) win; otherwise ``--preset`` picks one of the public +sandbox entities (default *Large Model Entity*). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer + +app = typer.Typer(no_args_is_help=True) + +_PRESET_OPT = typer.Option( + "Large Model Entity", + "--preset", + help="Public sandbox entity when BCDA_CLIENT_ID is unset.", +) + + +def _connection(preset: str): # noqa: ANN202 + from bcda.ui import service as svc + + conn = svc.connection_from_env() + if conn is not None: + return conn + if preset not in svc.PRESETS: + typer.echo( + f"Unknown preset '{preset}'. Choose from: {', '.join(svc.PRESETS)}", + err=True, + ) + raise typer.Exit(1) + return svc.PRESETS[preset] + + +def _echo_json(obj) -> None: # noqa: ANN001 + typer.echo(json.dumps(obj, indent=2, default=str)) + + +@app.command() +def ui( + port: int = typer.Option(8501, help="Port for the Streamlit server."), + headless: bool = typer.Option(True, help="Do not open a browser."), +) -> None: + """Launch the BCDA manager (Streamlit).""" + import subprocess + import sys + + from bcda.ui import app as ui_app + + cmd = [ + sys.executable, + "-m", + "streamlit", + "run", + str(Path(ui_app.__file__)), + "--server.port", + str(port), + "--server.headless", + "true" if headless else "false", + ] + typer.echo(f"BCDA manager on http://localhost:{port}") + raise typer.Exit(subprocess.call(cmd)) + + +@app.command() +def preflight(preset: str = _PRESET_OPT) -> None: + """/_version, /_health and metadata — no token needed.""" + from bcda.ui import service as svc + + conn = _connection(preset) + with svc.make_client(conn) as client: + out = svc.preflight(client) + _echo_json(out) + if not out.get("ok"): + raise typer.Exit(1) + + +@app.command() +def auth(preset: str = _PRESET_OPT) -> None: + """Issue a token and show attribution status.""" + from bcda.ui import service as svc + + conn = _connection(preset) + with svc.make_client(conn) as client: + _echo_json(svc.auth_check(client)) + + +@app.command() +def jobs( + preset: str = _PRESET_OPT, + limit: int = typer.Option(25, help="Newest N jobs."), +) -> None: + """Server job history for the entity (shared on the sandbox).""" + from bcda.ui import service as svc + + conn = _connection(preset) + with svc.make_client(conn) as client: + rows = svc.server_jobs(client, limit=limit) + for r in rows: + flag = " reusable" if r["reusable"] else "" + typer.echo( + f"{r['job_id']:>7} {r['status']:<12} {r['endpoint']:<13} {r['types']:<40} {r['started']} → {r['ended']}{flag}" + ) + typer.echo(f"{len(rows)} job(s)") + + +@app.command() +def export( + preset: str = _PRESET_OPT, + types: list[str] = typer.Option( + None, "--type", "-t", help="Resource type(s); default all three." + ), + endpoint: str = typer.Option( + "Group/all", help="Group/all, Group/runout or Patient." + ), + since: str = typer.Option("", help="FHIR instant, e.g. 2026-01-01T00:00:00+00:00"), + system_type: list[str] = typer.Option( + None, "--system-type", help="_typeFilter System-Type tag(s)." + ), + outcome: str = typer.Option("", help="_typeFilter outcome: partial or complete."), + service_date_from: str = typer.Option( + "", help="_typeFilter service-date > YYYY-MM-DD." + ), + per_type: bool = typer.Option( + True, "--per-type/--one-job", help="One job per type (survives sandbox sweeps)." + ), + cancel_retries: int = typer.Option( + 6, help="Restarts after server-side cancellation." + ), + poll_interval: float = typer.Option(15.0, help="Seconds between status polls."), +) -> None: + """Run an export through the store (blocks until downloaded).""" + from bcda.ui import service as svc + + conn = _connection(preset) + type_filter = svc.build_type_filter( + system_types=system_type or None, + outcome=outcome or None, + service_date_from=service_date_from or None, + ) + if type_filter: + typer.echo(f"_typeFilter={type_filter}") + store = svc.make_store() + with svc.make_client(conn) as client: + if per_type: + keys = svc.export_per_type( + store, + client, + types=types or None, + endpoint=endpoint, + since=since or None, + type_filter=type_filter, + cancel_retries=cancel_retries, + poll_interval=poll_interval, + ) + else: + keys = { + ",".join(types or svc.RESOURCE_TYPES): svc.start_export( + store, + client, + endpoint=endpoint, + types=types or None, + since=since or None, + type_filter=type_filter, + cancel_retries=cancel_retries, + poll_interval=poll_interval, + ) + } + for rtype, key in keys.items(): + files = store.get_files(key) + typer.echo(f"{rtype}: job {key}, {len(files)} file(s)") + + +@app.command() +def adopt( + job_id: str = typer.Argument(help="Server job id (see `stack bcda jobs`)."), + preset: str = _PRESET_OPT, + types: list[str] = typer.Option( + None, + "--type", + "-t", + help="Resource type(s) the job holds, for the store record.", + ), +) -> None: + """Download a completed job this store did not start.""" + from bcda.ui import service as svc + + conn = _connection(preset) + store = svc.make_store() + with svc.make_client(conn) as client: + job_url = f"{client.base_url}/api/{client.version}/jobs/{job_id}" + key = svc.adopt_job(store, client, job_url, types=types or None) + typer.echo(f"adopted job {job_id} as {key}, {len(store.get_files(key))} file(s)") + + +@app.command() +def cancel( + job_id: str = typer.Argument(help="Server job id."), + preset: str = _PRESET_OPT, +) -> None: + """Cancel an in-progress job.""" + from bcda.ui import service as svc + + conn = _connection(preset) + with svc.make_client(conn) as client: + svc.cancel_job(client, f"{client.base_url}/api/{client.version}/jobs/{job_id}") + typer.echo(f"cancelled {job_id}") + + +@app.command() +def store() -> None: + """Local store: jobs, files, bytes.""" + from bcda.ui import service as svc + + rows = svc.store_jobs(svc.make_store()) + for r in rows: + typer.echo( + f"{r['key']} {r['status']:<10} {r['types']:<40} {r['files']:>4} file(s) {r['bytes']:>14,} B {r['ts']}" + ) + typer.echo(f"{len(rows)} job(s) in {svc.store_path()}") + + +@app.command() +def load( + pipeline: bool = typer.Option( + True, "--pipeline/--no-pipeline", help="Run the connector into input_layer.*" + ), + skip_flatten: bool = typer.Option( + False, "--skip-flatten", help="Reuse existing Parquet." + ), +) -> None: + """Flatten + load the newest export per type, run the connector, publish the replica.""" + from bcda.ui import service as svc + + stats = svc.load_and_publish(run_pipeline=pipeline, skip_flatten=skip_flatten) + for table, n in sorted(stats.items()): + typer.echo(f" {table}: {n:,} rows") + typer.echo("Replica published.") + + +@app.command() +def vendor( + check: bool = typer.Option( + False, "--check", help="Also query GitHub / bcda.cms.gov for drift." + ), +) -> None: + """Vendored upstream (CMSgov/bcda-app, bcda.cms.gov): manifest, verification, drift.""" + from bcda.ui import service as svc + + status = svc.vendor_status() + if not status.get("available"): + typer.echo("vendor manifest not available", err=True) + raise typer.Exit(1) + for s in status["sources"]: + typer.echo( + f"{s['name']:<10} {s['kind']:<7} {s['origin']} {s['commit'][:12]} ({s['files']} files)" + ) + if status["problems"]: + for p in status["problems"]: + typer.echo(f" ! {p}") + raise typer.Exit(1) + typer.echo("vendored tree matches the manifest") + if check: + drift = svc.vendor_check() + for d in drift: + typer.echo(f" ~ {d}") + typer.echo( + "no upstream drift" if not drift else f"{len(drift)} upstream change(s)" + ) + if drift: + raise typer.Exit(1) diff --git a/tests/bcda/test_ui_runner.py b/tests/bcda/test_ui_runner.py new file mode 100644 index 0000000..cceb68a --- /dev/null +++ b/tests/bcda/test_ui_runner.py @@ -0,0 +1,91 @@ +"""bcda.ui.runner — background runs with per-thread log capture.""" + +from __future__ import annotations + +import logging +import threading +import time + +from bcda.ui.runner import Runner + + +def test_run_success_captures_bcda_logs() -> None: + r = Runner() + log = logging.getLogger("bcda.test") + + def work(x: int) -> int: + log.info("working on %d", x) + return x * 2 + + run = r.submit("double", work, 21) + done = r.wait(run.id) + assert done.state == "done" and done.result == 42 + assert ( + "working on 21" in done.log + and done.log[0] == "started: double" + and done.log[-1] == "finished" + ) + assert not done.active and done.elapsed >= 0 and done.finished is not None + assert r.get(run.id) is done and r.get("nope") is None + assert r.all() == [done] and r.active() == [] + assert r.clear_finished() == 1 and r.all() == [] + + +def test_run_failure_records_error_and_traceback() -> None: + r = Runner() + + def boom() -> None: + raise ValueError("bad input") + + run = r.wait(r.submit("boom", boom).id) + assert run.state == "failed" and run.error == "ValueError: bad input" + assert any("Traceback" in line for line in run.log) + + +def test_logs_are_routed_by_thread() -> None: + r = Runner() + log = logging.getLogger("bcda.routing") + gate = threading.Event() + + def slow() -> str: + log.info("from slow") + gate.wait(5) + return "ok" + + def quick() -> str: + log.info("from quick") + return "ok" + + slow_run = r.submit("slow", slow) + quick_run = r.wait(r.submit("quick", quick).id) + log.info("from main thread") # no run owns the main thread → dropped + gate.set() + r.wait(slow_run.id) + assert "from quick" in quick_run.log and "from slow" not in quick_run.log + assert "from slow" in slow_run.log and "from quick" not in slow_run.log + assert "from main thread" not in slow_run.log + quick_run.log + assert r.active() == [] + + +def test_log_is_bounded() -> None: + r = Runner() + log = logging.getLogger("bcda.flood") + + def flood() -> None: + for i in range(500): + log.info("line %d", i) + + run = r.wait(r.submit("flood", flood).id) + assert len(run.log) == 400 and run.log[-1] == "finished" + + +def test_wait_times_out_on_active_run() -> None: + r = Runner() + gate = threading.Event() + run = r.submit("hang", gate.wait, 5) + t0 = time.time() + waited = r.wait(run.id, timeout=0.1) + assert waited.active and time.time() - t0 < 2 + assert r.active() == [waited] and r.clear_finished() == 0 + gate.set() + r.wait(run.id) diff --git a/tests/bcda/test_ui_service.py b/tests/bcda/test_ui_service.py new file mode 100644 index 0000000..d20126a --- /dev/null +++ b/tests/bcda/test_ui_service.py @@ -0,0 +1,485 @@ +"""bcda.ui.service — the manager's operations, with mock transports.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import duckdb +import httpx +import polars as pl +import pytest + +from bcda.client import SANDBOX, Client +from bcda.store import Store +from bcda.ui import service as svc + +ROOT = Path(__file__).resolve().parents[2] + + +def _token() -> httpx.Response: + return httpx.Response(200, json={"access_token": "t" * 40, "expires_in": 1200}) + + +def _client(handler, version: str = "v3", tmp_path: Path | None = None) -> Client: + c = Client( + "cid", "csec", version=version, output_dir=tmp_path or Path("/tmp/bcda-ui-test") + ) + c._http = httpx.Client(transport=httpx.MockTransport(handler)) + return c + + +def _task(job_id: str, status: str, request: str, start: str, end: str) -> dict: + return { + "resourceType": "Task", + "identifier": [{"value": job_id}], + "status": status, + "input": [{"valueString": request}], + "executionPeriod": {"start": start, "end": end}, + } + + +# ── connections ────────────────────────────────────────────── + + +class TestConnections: + def test_presets_are_public_sandbox_entities(self) -> None: + assert set(svc.PRESETS) >= {"Large Model Entity", "Small Model Entity"} + large = svc.PRESETS["Large Model Entity"] + assert ( + large.base_url == SANDBOX + and large.environment == "sandbox" + and large.version == "v3" + ) + assert svc.PRESETS["Legacy Large REACH ACO (v2)"].version == "v2" + assert large.masked_secret().startswith("96ac") and "…" in large.masked_secret() + assert svc.Connection("x", SANDBOX, "a", "short").masked_secret() == "…" + assert svc.Connection("x", svc.PRODUCTION, "a", "b").environment == "production" + + def test_connection_from_env(self) -> None: + assert svc.connection_from_env({}) is None + assert svc.connection_from_env({"BCDA_CLIENT_ID": "a"}) is None + c = svc.connection_from_env({"BCDA_CLIENT_ID": "a", "BCDA_CLIENT_SECRET": "b"}) + assert c is not None and c.base_url == SANDBOX and c.version == "v3" + c = svc.connection_from_env( + { + "BCDA_CLIENT_ID": "a", + "BCDA_CLIENT_SECRET": "b", + "BCDA_BASE_URL": svc.PRODUCTION, + "BCDA_API_VERSION": "v2", + } + ) + assert c.environment == "production" and c.version == "v2" + + def test_connection_from_real_env(self, monkeypatch) -> None: + monkeypatch.setenv("BCDA_CLIENT_ID", "id") + monkeypatch.setenv("BCDA_CLIENT_SECRET", "secret") + assert svc.connection_from_env().client_id == "id" + + def test_make_client_and_store(self, tmp_path) -> None: + c = svc.make_client(svc.PRESETS["Small Model Entity"], output_dir=tmp_path) + assert c.version == "v3" and c.base_url == SANDBOX and c.output_dir == tmp_path + c.close() + s = svc.make_store(tmp_path) + assert str(tmp_path) in s._root + assert svc.store_path().name == "bcda" + + +# ── server side ────────────────────────────────────────────── + + +class TestServerSide: + def test_preflight_ok(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + url = str(req.url) + if url.endswith("/metadata"): + return httpx.Response( + 200, + json={ + "fhirVersion": "4.0.1", + "software": {"version": "r302", "releaseDate": "2026-10-25"}, + "rest": [ + { + "resource": [ + {"type": "Patient"}, + {"type": "ExplanationOfBenefit"}, + ] + } + ], + }, + ) + if url.endswith("/_version"): + return httpx.Response(200, json={"version": "r302"}) + return httpx.Response(200, json={"database": "ok"}) + + out = svc.preflight(_client(handler)) + assert ( + out["ok"] and out["fhir_version"] == "4.0.1" and out["software"] == "r302" + ) + assert out["resources"] == ["Patient", "ExplanationOfBenefit"] + assert out["server_version"] == {"version": "r302"} and out["health"] == { + "database": "ok" + } + + def test_preflight_degrades(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + url = str(req.url) + if url.endswith("/metadata"): + return httpx.Response(200, json={"fhirVersion": "4.0.1"}) + return httpx.Response( + 404, + json={ + "resourceType": "OperationOutcome", + "issue": [{"diagnostics": "no"}], + }, + ) + + out = svc.preflight(_client(handler)) + assert ( + out["ok"] and "error" in out["server_version"] and "error" in out["health"] + ) + + def down(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom") + + out = svc.preflight(_client(down)) + assert out["ok"] is False and "boom" in out["error"] + + def test_auth_check(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + if "/auth/token" in str(req.url): + return _token() + return httpx.Response( + 200, + json={ + "ingestion_dates": [ + {"type": "last_attribution_update", "timestamp": "A"} + ], + "expiration_dates": [ + {"type": "attribution_access_expiration", "timestamp": "E"} + ], + }, + ) + + out = svc.auth_check(_client(handler)) + assert out["token_chars"] == 40 and out["last_attribution_update"] == "A" + assert ( + out["attribution_access_expiration"] == "E" + and out["last_runout_update"] is None + ) + + def test_server_jobs(self) -> None: + tasks = [ + _task( + "1", + "completed", + f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Patient", + "2026-10-25T10:00:00Z", + "2026-10-25T10:30:00Z", + ), + _task( + "2", + "completed", + f"GET {SANDBOX}/api/v3/Group/runout/$export", + "2026-10-20T10:00:00Z", + "2026-10-20T11:00:00Z", + ), + _task( + "3", + "in-progress", + f"GET {SANDBOX}/api/v3/Patient/$export?_type=Coverage", + "2026-10-25T11:00:00Z", + "", + ), + _task( + "4", + "completed", + f"GET {SANDBOX}/api/v2/Group/all/$export", + "2026-10-25T12:00:00Z", + "2026-10-25T12:30:00Z", + ), + _task( + "5", + "cancelled", + f"GET {SANDBOX}/api/v3/Group/all/$export", + "2026-10-25T09:00:00Z", + "bad-instant", + ), + ] + + def handler(req: httpx.Request) -> httpx.Response: + if "/auth/token" in str(req.url): + return _token() + return httpx.Response( + 200, + json={ + "resourceType": "Bundle", + "entry": [{"resource": t} for t in tasks], + }, + ) + + now = datetime(2026, 10, 25, 12, 0, tzinfo=timezone.utc) + rows = svc.server_jobs(_client(handler), now=now) + assert [r["job_id"] for r in rows] == [ + "3", + "1", + "5", + "2", + ] # newest first, v2 dropped + by = {r["job_id"]: r for r in rows} + assert ( + by["1"]["reusable"] is True + and by["1"]["types"] == "Patient" + and by["1"]["endpoint"] == "Group/all" + ) + assert ( + by["2"]["reusable"] is False + and by["2"]["types"] == "(all)" + and by["2"]["endpoint"] == "Group/runout" + ) + assert by["3"]["endpoint"] == "Patient" and by["3"]["reusable"] is False + assert by["5"]["reusable"] is False + assert by["1"]["job_url"].endswith("/api/v3/jobs/1") + assert len(svc.server_jobs(_client(handler), limit=2, now=now)) == 2 + assert svc._endpoint_from_request("GET https://x/api/v3/Other/$export") == "?" + + def test_build_type_filter(self) -> None: + assert svc.build_type_filter() is None + assert ( + svc.build_type_filter(system_types=["DDPS"]) + == "ExplanationOfBenefit?_tag=https://bluebutton.cms.gov/fhir/CodeSystem/System-Type|DDPS" + ) + tf = svc.build_type_filter( + system_types=["SharedSystem", "NationalClaimsHistory"], + outcome="partial", + service_date_from="2026-01-01", + ) + assert ( + tf.startswith("ExplanationOfBenefit?_tag=") + and "&outcome=partial&service-date=gt2026-01-01" in tf + ) + with pytest.raises(ValueError, match="System-Type"): + svc.build_type_filter(system_types=["Nope"]) + with pytest.raises(ValueError, match="outcome"): + svc.build_type_filter(outcome="maybe") + + def _export_handler(self, calls: list[str]): # noqa: ANN202 + def handler(req: httpx.Request) -> httpx.Response: + url = str(req.url) + calls.append(url) + if "/auth/token" in url: + return _token() + if "$export" in url: + n = sum(1 for c in calls if "$export" in c) + return httpx.Response( + 202, headers={"Content-Location": f"{SANDBOX}/api/v3/jobs/{n}"} + ) + if "/jobs/" in url and req.method == "GET": + jid = url.rsplit("/", 1)[1] + return httpx.Response( + 200, + json={ + "output": [ + {"type": "Patient", "url": f"{SANDBOX}/data/{jid}/a.ndjson"} + ] + }, + ) + if req.method == "DELETE": + return httpx.Response(202) + return httpx.Response(200, content=b'{"resourceType":"Patient"}\n') + + return handler + + def test_start_export_and_per_type(self, tmp_path) -> None: + calls: list[str] = [] + client = _client(self._export_handler(calls), tmp_path=tmp_path) + store = Store(path=str(tmp_path)) + key = svc.start_export( + store, + client, + endpoint="Patient", + types=["Patient"], + since="2026-01-01T00:00:00+00:00", + type_filter="ExplanationOfBenefit?outcome=complete", + poll_interval=0, + ) + assert store.get_job(key)["status"] == "complete" + assert any( + "/api/v3/Patient/$export" in c and "_since=" in c and "_typeFilter=" in c + for c in calls + ) + with pytest.raises(ValueError, match="endpoint"): + svc.start_export(store, client, endpoint="Nope") + keys = svc.export_per_type( + store, + client, + type_filter="ExplanationOfBenefit?outcome=partial", + poll_interval=0, + ) + assert set(keys) == set(svc.RESOURCE_TYPES) + exports = [c for c in calls if "$export" in c] + # the EOB job carries the _typeFilter, the Patient/Coverage jobs do not + assert sum(1 for c in exports if "_typeFilter" in c) == 2 # first call + EOB + assert all(store.get_job(k)["status"] == "complete" for k in keys.values()) + client.close() + + def test_adopt_and_cancel(self, tmp_path) -> None: + calls: list[str] = [] + client = _client(self._export_handler(calls), tmp_path=tmp_path) + store = Store(path=str(tmp_path)) + key = svc.adopt_job( + store, client, f"{SANDBOX}/api/v3/jobs/77", types=["Patient"] + ) + assert store.get_job(key)["job_url"].endswith("/jobs/77") + svc.cancel_job(client, f"{SANDBOX}/api/v3/jobs/77") + assert any(c.endswith("/jobs/77") for c in calls) + client.close() + + +# ── local side ─────────────────────────────────────────────── + + +class TestLocalSide: + def test_store_jobs_files_and_dirs(self, tmp_path) -> None: + store = Store(path=str(tmp_path)) + assert svc.store_jobs(store) == [] + store._append_job( + key="K1", + status="complete", + endpoint="Group/all", + types="Patient", + job_url="u", + ) + store._append_file( + job_key="K1", + resource_type="Patient", + url="u1", + storage_path="p", + file_size=10, + status="complete", + ) + store._append_job(key="K2", status="failed", error="boom") + rows = svc.store_jobs(store) + assert [r["key"] for r in rows] == ["K2", "K1"] + k1 = next(r for r in rows if r["key"] == "K1") + assert k1["files"] == 1 and k1["bytes"] == 10 + assert next(r for r in rows if r["key"] == "K2")["error"] == "boom" + assert svc.store_files(store, "K1")[0]["storage_path"] == "p" + assert svc.export_dirs(tmp_path) == [] + d = tmp_path / "exports" / "K1" + d.mkdir(parents=True) + (d / "Patient.ndjson").write_text("{}\n") + (d / "Patient.1.ndjson").write_text("{}\n") + (tmp_path / "exports" / "stray.txt").write_text("x") + dirs = svc.export_dirs(tmp_path) + assert ( + dirs[0]["key"] == "K1" + and dirs[0]["types"] == ["Patient"] + and dirs[0]["files"] == 2 + and dirs[0]["bytes"] == 6 + ) + + def test_load_and_publish(self, monkeypatch) -> None: + calls = {} + + def fake_load(**kw): # noqa: ANN003, ANN202 + calls["load"] = kw + return {"bcda.patient": 1} + + monkeypatch.setattr("aco.load.bcda.load_bcda", fake_load) + monkeypatch.setattr( + "conf.connect.publish_replica", + lambda name: calls.setdefault("publish", name), + ) + assert svc.load_and_publish(run_pipeline=False, skip_flatten=True) == { + "bcda.patient": 1 + } + assert ( + calls["load"] == {"skip_flatten": True, "run_pipeline": False} + and calls["publish"] == "aco" + ) + + def test_replica_queries(self, tmp_path) -> None: + db = tmp_path / "r.duckdb" + con = duckdb.connect(str(db)) + con.execute("create schema bcda; create schema input_layer") + con.execute( + "create table bcda.patient as select 'A' bene_mbi_id, null::date bene_death_dt, 'TX' geo_usps_state_cd" + ) + con.execute( + "create table bcda.explanation_of_benefit as select 'DDPS' system_type, 'PDE' eob_type, 'FinalAction' final_action, 1.5 clm_pmt_amt" + ) + con.execute( + "create table bcda.coverage as select 'Part A' class_plan, 'active' status, 'A' subscriber_id" + ) + con.execute( + "create table input_layer.medical_claim as select 'institutional' claim_type, 'c' claim_id, 1.0 paid_amount, 2.0 charge_amount" + ) + con.close() + ro = duckdb.connect(str(db), read_only=True) + counts = svc.table_counts(ro) + assert counts == { + "bcda.coverage": 1, + "bcda.explanation_of_benefit": 1, + "bcda.patient": 1, + "input_layer.medical_claim": 1, + } + for sql in svc.SUMMARY_QUERIES.values(): + assert isinstance(svc.run_query(ro, sql), pl.DataFrame) + assert len(svc.run_query(ro, "select * from bcda.patient", limit=5)) == 1 + ro.close() + + def test_replica_connect_uses_conf(self, monkeypatch) -> None: + monkeypatch.setattr( + "conf.connect.duckdb", lambda name, read_only: (name, read_only) + ) + assert svc.replica_connect() == ("aco", True) + + def test_dictionary_frames_from_seeds_and_reference_data(self, tmp_path) -> None: + frames = svc.dictionary_frames(None) + assert set(frames) == { + "bcda_v3_data_dictionary", + "bcda_v3_cclf_crosswalk", + "bcda_v3_cclf_absent", + } + assert len(frames["bcda_v3_data_dictionary"]) > 300 + con = duckdb.connect(str(tmp_path / "ref.duckdb")) + con.execute("create schema reference_data") + for n, df in frames.items(): + con.register("df", df.to_arrow()) + con.execute(f"create table reference_data.{n} as select * from df") + again = svc.dictionary_frames(con) + assert len(again["bcda_v3_cclf_absent"]) == len(frames["bcda_v3_cclf_absent"]) + con.close() + # a connection without the tables falls back to the seeds + con = duckdb.connect() + assert len(svc.dictionary_frames(con)) == 3 + con.close() + + def test_dictionary_frames_missing(self, monkeypatch, tmp_path) -> None: + monkeypatch.setattr("conf.ROOT", tmp_path) + assert svc.dictionary_frames(None) == {} + + def test_vendor_status_and_check(self, monkeypatch) -> None: + status = svc.vendor_status() + assert status["available"] and status["problems"] == [] + names = {s["name"] for s in status["sources"]} + assert names == {"bcda-app", "bcda-site"} + assert any(f["path"].endswith("swagger.json") for f in status["files"]) + mod = svc._vendor_module() + monkeypatch.setattr(mod, "check", lambda m: ["bcda-app: swagger.json changed"]) + monkeypatch.setattr(svc, "_vendor_module", lambda: mod) + assert svc.vendor_check() == ["bcda-app: swagger.json changed"] + + def test_vendor_unavailable(self, monkeypatch, tmp_path) -> None: + monkeypatch.setattr("conf.ROOT", tmp_path) + assert svc.vendor_status() == {"available": False} + assert svc.vendor_check() == ["vendor script not available in this checkout"] + + def test_types_from_request(self) -> None: + assert svc._types_from_request("GET x?_type=Patient%2CCoverage") == [ + "Patient", + "Coverage", + ] + json.dumps(svc.SUMMARY_QUERIES) # plain strings diff --git a/tests/bcda/test_ui_views.py b/tests/bcda/test_ui_views.py new file mode 100644 index 0000000..3433289 --- /dev/null +++ b/tests/bcda/test_ui_views.py @@ -0,0 +1,438 @@ +"""bcda.ui.views — every page through Streamlit's AppTest with the +service layer stubbed, so no network, no replica, no real store.""" + +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path + +import duckdb +import pytest +from streamlit.testing.v1 import AppTest + +from bcda.store import Store +from bcda.ui import service as svc +from bcda.ui import views +from bcda.ui.runner import Runner + +SMALL = svc.PRESETS["Small Model Entity"] + + +def _page( + name: str, *, conn: svc.Connection | None = SMALL, timeout: float = 60 +) -> AppTest: + at = AppTest.from_string( + f"from bcda.ui import views\nviews.{name}()\n", default_timeout=timeout + ) + if conn is not None: + at.session_state["conn"] = conn + return at + + +def _button(at: AppTest, label: str): # noqa: ANN202 + return next(b for b in at.button if b.label == label) + + +@pytest.fixture(autouse=True) +def fresh_runner(monkeypatch): + """Each test gets its own Runner so background runs never leak across tests.""" + r = Runner() + monkeypatch.setattr(views, "runner", r) + return r + + +@pytest.fixture +def fake_client(monkeypatch): + """A client stand-in; service functions are stubbed to accept it.""" + + class FakeClient: + base_url = svc.SANDBOX + version = "v3" + + def close(self) -> None: + pass + + client = FakeClient() + monkeypatch.setattr(svc, "make_client", lambda conn, output_dir=None: client) + return client + + +@pytest.fixture +def tmp_store(monkeypatch, tmp_path): + monkeypatch.setattr(svc, "store_path", lambda: tmp_path) + return Store(path=str(tmp_path)) + + +# ── Connect ────────────────────────────────────────────────── + + +class TestConnect: + def test_preset_then_preflight_and_auth(self, monkeypatch, fake_client) -> None: + monkeypatch.setattr( + svc, + "preflight", + lambda c: { + "ok": True, + "fhir_version": "4.0.1", + "software": "r302", + "release_date": "2026", + "resources": ["Patient"], + "health": {"database": "ok"}, + "server_version": {"version": "r302"}, + }, + ) + monkeypatch.setattr( + svc, + "auth_check", + lambda c: {"token_chars": 40, "last_attribution_update": "x"}, + ) + at = _page("connect", conn=None).run() + assert at.session_state.get("conn") is None + _button(at, "Use this connection").click().run() + assert at.session_state["conn"].label == "Large Model Entity" + _button(at, "Preflight (no token)").click().run() + assert any(m.value == "4.0.1" for m in at.metric) + _button(at, "Authenticate + attribution").click().run() + assert any("Token issued" in s.value for s in at.success) + + def test_preflight_failure_and_auth_error(self, monkeypatch, fake_client) -> None: + monkeypatch.setattr( + svc, "preflight", lambda c: {"ok": False, "error": "unreachable"} + ) + + def bad_auth(c): # noqa: ANN001, ANN202 + raise RuntimeError("401 invalid client") + + monkeypatch.setattr(svc, "auth_check", bad_auth) + at = _page("connect").run() + _button(at, "Preflight (no token)").click().run() + assert any("unreachable" in e.value for e in at.error) + _button(at, "Authenticate + attribution").click().run() + assert any("invalid client" in e.value for e in at.error) + + def test_environment_and_custom_sources(self, monkeypatch) -> None: + monkeypatch.setenv("BCDA_CLIENT_ID", "env-id") + monkeypatch.setenv("BCDA_CLIENT_SECRET", "env-secret-long-enough") + at = _page("connect", conn=None).run() + radio = at.radio[0] + assert radio.options[0] == "Environment credentials" + _button(at, "Use this connection").click().run() + assert at.session_state["conn"].client_id == "env-id" + radio.set_value("Custom").run() + assert not [ + b for b in at.button if b.label == "Use this connection" + ] # needs id + secret first + at.text_input(key="custom_id").input("abc").run() + at.text_input(key="custom_secret").input("def").run() + at.selectbox(key="custom_version").set_value("v2").run() + _button(at, "Use this connection").click().run() + conn = at.session_state["conn"] + assert (conn.client_id, conn.client_secret, conn.version, conn.label) == ( + "abc", + "def", + "v2", + "custom", + ) + + +# ── Exports ────────────────────────────────────────────────── + + +class TestExports: + def test_requires_connection(self) -> None: + at = _page("exports", conn=None).run() + assert any("Connect" in i.value for i in at.info) + + def test_start_export_per_type_and_one_job( + self, monkeypatch, fake_client, tmp_store, fresh_runner + ) -> None: + calls: list[dict] = [] + monkeypatch.setattr( + svc, + "export_per_type", + lambda store, client, **kw: ( + calls.append({"per_type": True, **kw}) or {"Patient": "K1"} + ), + ) + monkeypatch.setattr( + svc, + "start_export", + lambda store, client, **kw: calls.append({"per_type": False, **kw}) or "K2", + ) + at = _page("exports").run() + at.multiselect(key="exp_types").set_value(["Patient"]) + at.multiselect(key="exp_system_types").set_value(["DDPS"]) + at.selectbox(key="exp_outcome").set_value("complete") + at.checkbox(key="exp_use_since").check() + _button(at, "Start export").click().run() + run = fresh_runner.wait(fresh_runner.all()[0].id) + assert run.state == "done" and run.result == {"Patient": "K1"} + assert calls[0]["per_type"] and calls[0]["types"] == ["Patient"] + assert calls[0]["type_filter"].endswith("|DDPS&outcome=complete") and calls[0][ + "since" + ].endswith("T00:00:00+00:00") + assert any("_typeFilter=" in c.value for c in at.caption) + at.checkbox(key="exp_per_type").uncheck() + at.checkbox(key="exp_use_svc_date").check() + _button(at, "Start export").click().run() + fresh_runner.wait(fresh_runner.all()[0].id) + assert ( + calls[-1]["per_type"] is False + and "service-date=gt" in calls[-1]["type_filter"] + ) + + def test_jobs_adopt_and_cancel( + self, monkeypatch, fake_client, tmp_store, fresh_runner + ) -> None: + jobs = [ + { + "job_id": "11", + "status": "completed", + "types": "Patient,Coverage", + "endpoint": "Group/all", + "started": "s", + "ended": "e", + "reusable": True, + "job_url": "u11", + }, + { + "job_id": "12", + "status": "in-progress", + "types": "(all)", + "endpoint": "Group/all", + "started": "s", + "ended": "", + "reusable": False, + "job_url": "u12", + }, + ] + monkeypatch.setattr(svc, "server_jobs", lambda c: jobs) + adopted: list = [] + monkeypatch.setattr( + svc, + "adopt_job", + lambda store, client, url, types=None: adopted.append((url, types)) or "KA", + ) + cancelled: list = [] + monkeypatch.setattr(svc, "cancel_job", lambda c, url: cancelled.append(url)) + at = _page("exports").run() + _button(at, "Refresh jobs").click().run() + assert len(at.dataframe) == 1 + _button(at, "Adopt").click().run() + run = fresh_runner.wait(fresh_runner.all()[0].id) + assert run.result == "KA" and adopted == [("u11", ["Patient", "Coverage"])] + _button(at, "Cancel").click().run() + assert cancelled == ["u12"] and "server_jobs" not in at.session_state + + def test_jobs_errors(self, monkeypatch, fake_client, tmp_store) -> None: + def boom(*a, **k): # noqa: ANN002, ANN003, ANN202 + raise RuntimeError("nope") + + monkeypatch.setattr(svc, "server_jobs", boom) + at = _page("exports").run() + _button(at, "Refresh jobs").click().run() + assert any("Could not list jobs" in e.value for e in at.error) + jobs = [ + { + "job_id": "12", + "status": "in-progress", + "types": "(all)", + "endpoint": "Group/all", + "started": "s", + "ended": "", + "reusable": False, + "job_url": "u12", + } + ] + monkeypatch.setattr(svc, "server_jobs", lambda c: jobs) + monkeypatch.setattr(svc, "cancel_job", boom) + _button(at, "Refresh jobs").click().run() + _button(at, "Cancel").click().run() + assert any("Cancel failed" in e.value for e in at.error) + + +# ── Store / Load ───────────────────────────────────────────── + + +class TestStoreAndLoad: + def test_store_empty_and_populated(self, tmp_store, tmp_path) -> None: + at = _page("store").run() + assert any("No store jobs" in i.value for i in at.info) + tmp_store._append_job( + key="K1", + status="complete", + endpoint="Group/all", + types="Patient", + job_url="u", + ) + tmp_store._append_file( + job_key="K1", + resource_type="Patient", + url="u1", + storage_path="p", + file_size=2048, + status="complete", + ) + d = tmp_path / "exports" / "K1" + d.mkdir(parents=True) + (d / "Patient.ndjson").write_text("{}\n") + at = _page("store").run() + assert at.selectbox(key="store_key").value == "K1" + assert len(at.dataframe) == 3 # jobs, files, dirs + assert ( + views._fmt_bytes(2048) == "2.0 KB" + and views._fmt_bytes(5) == "5 B" + and views._fmt_bytes(3 * 1024**4) == "3.0 TB" + ) + + def test_load_runs_in_background(self, monkeypatch, fresh_runner) -> None: + seen = {} + monkeypatch.setattr( + svc, "load_and_publish", lambda **kw: seen.update(kw) or {"bcda.patient": 3} + ) + at = _page("load").run() + at.checkbox(key="load_skip").check() + _button(at, "Load now").click().run() + run = fresh_runner.wait(fresh_runner.all()[0].id) + assert run.result == {"bcda.patient": 3} and seen == { + "run_pipeline": True, + "skip_flatten": True, + } + at.run() # the runs panel now shows the finished run + assert any("flatten + load" in e.label for e in at.expander) + _button(at, "Clear finished runs").click().run() + assert fresh_runner.all() == [] + + +# ── Data ───────────────────────────────────────────────────── + + +class TestData: + def test_no_replica(self, monkeypatch) -> None: + def boom(): # noqa: ANN202 + raise OSError("no replica") + + monkeypatch.setattr(svc, "replica_connect", boom) + at = _page("data").run() + assert any("Replica not available" in w.value for w in at.warning) + + def test_empty_replica(self, monkeypatch) -> None: + con = duckdb.connect() + monkeypatch.setattr(svc, "replica_connect", lambda: con) + at = _page("data").run() + assert any("No `bcda.*`" in i.value for i in at.info) + + def test_tables_summaries_and_sql(self, monkeypatch, tmp_path) -> None: + db = tmp_path / "r.duckdb" + con = duckdb.connect(str(db)) + con.execute("create schema bcda") + con.execute( + "create table bcda.explanation_of_benefit as select 'DDPS' system_type, 'PDE' eob_type, 'FinalAction' final_action, 1.0 clm_pmt_amt" + ) + con.close() + ro = duckdb.connect(str(db), read_only=True) + monkeypatch.setattr(svc, "replica_connect", lambda: ro) + at = _page("data").run() + assert ( + at.metric[0].label == "bcda.explanation_of_benefit" + and at.metric[0].value == "1" + ) + assert len(at.dataframe) == 1 # the default summary + at.selectbox(key="summary_pick").set_value("Coverage rows by plan").run() + assert any( + "coverage" in w.value.lower() for w in at.warning + ) # table absent → warning + _button(at, "Run").click().run() + assert any("1 rows" in c.value for c in at.caption) + at.text_area(key="sql").input("select * from nope").run() + _button(at, "Run").click().run() + assert at.error + + +# ── Dictionary / Upstream ──────────────────────────────────── + + +class TestDictionaryAndUpstream: + def test_dictionary_search(self, monkeypatch) -> None: + monkeypatch.setattr(svc, "replica_connect", lambda: duckdb.connect()) + at = _page("dictionary").run() + assert len(at.dataframe) == 3 + at.text_input(key="dict_search").input("CLM_CNTL_NUM").run() + assert ( + "Dictionary (" in at.tabs[0].label and at.tabs[0].label != "Dictionary (0)" + ) + at.checkbox(key="xw_mapped").uncheck().run() + assert len(at.dataframe) == 3 + + def test_dictionary_missing(self, monkeypatch) -> None: + def boom(): # noqa: ANN202 + raise OSError("no replica") + + monkeypatch.setattr(svc, "replica_connect", boom) + monkeypatch.setattr(svc, "dictionary_frames", lambda con: {}) + at = _page("dictionary").run() + assert any("Crosswalk seeds not found" in i.value for i in at.info) + + def test_upstream(self, monkeypatch, fresh_runner) -> None: + monkeypatch.setattr(svc, "vendor_status", lambda: {"available": False}) + at = _page("upstream").run() + assert any("not available" in i.value for i in at.info) + status = { + "available": True, + "sources": [ + { + "name": "bcda-app", + "kind": "github", + "origin": "CMSgov/bcda-app", + "commit": "abc", + "files": 1, + } + ], + "files": [{"path": "bcda-app/LICENSE", "sha256": "0" * 12}], + "problems": ["missing bcda-app/LICENSE"], + } + monkeypatch.setattr(svc, "vendor_status", lambda: status) + monkeypatch.setattr(svc, "vendor_check", lambda: []) + at = _page("upstream").run() + assert any("does not match" in e.value for e in at.error) + _button(at, "Check upstream for drift (network)").click().run() + assert fresh_runner.wait(fresh_runner.all()[0].id).result == [] + status["problems"] = [] + at = _page("upstream").run() + assert any("matches the manifest" in s.value for s in at.success) + + +# ── runs panel states ──────────────────────────────────────── + + +class TestRunsPanel: + def test_failed_and_active_runs_render(self, fresh_runner) -> None: + gate = threading.Event() + logging.getLogger("bcda.ui.test") + + def fail() -> None: + raise RuntimeError("kaput") + + fresh_runner.wait(fresh_runner.submit("will fail", fail).id) + fresh_runner.submit("hangs", gate.wait, 10) + time.sleep(0.05) + at = _page("load").run() + labels = [e.label for e in at.expander] + assert any("will fail" in lbl and "failed" in lbl for lbl in labels) + assert any("hangs" in lbl and "running" in lbl for lbl in labels) + assert any("kaput" in e.value for e in at.error) + gate.set() + fresh_runner.wait(fresh_runner.all()[0].id) + + +def test_app_shell_renders() -> None: + at = AppTest.from_file( + str(Path(views.__file__).with_name("app.py")), default_timeout=60 + ).run() + assert not at.exception + assert any("not connected" in c.value for c in at.sidebar.caption) + at.session_state["conn"] = SMALL + at.run() + assert any("Small Model Entity" in c.value for c in at.sidebar.caption) diff --git a/tests/cli/test_cli_bcda.py b/tests/cli/test_cli_bcda.py new file mode 100644 index 0000000..940d77d --- /dev/null +++ b/tests/cli/test_cli_bcda.py @@ -0,0 +1,251 @@ +"""stack bcda — the manager's CLI face, with the service layer stubbed.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from bcda.ui import service as svc +from cli import app + +runner = CliRunner() + + +class _FakeClient: + base_url = svc.SANDBOX + version = "v3" + + def __enter__(self): # noqa: ANN204 + return self + + def __exit__(self, *exc): # noqa: ANN002, ANN204 + return False + + +@pytest.fixture(autouse=True) +def _no_env(monkeypatch): + monkeypatch.delenv("BCDA_CLIENT_ID", raising=False) + monkeypatch.delenv("BCDA_CLIENT_SECRET", raising=False) + monkeypatch.setattr(svc, "make_client", lambda conn, output_dir=None: _FakeClient()) + + +def test_help_lists_commands() -> None: + result = runner.invoke(app, ["bcda", "--help"]) + assert result.exit_code == 0 + for cmd in ( + "ui", + "preflight", + "auth", + "jobs", + "export", + "adopt", + "cancel", + "store", + "load", + "vendor", + ): + assert cmd in result.output + + +def test_unknown_preset() -> None: + result = runner.invoke(app, ["bcda", "preflight", "--preset", "Nope"]) + assert result.exit_code == 1 and "Unknown preset" in result.output + + +def test_env_credentials_win(monkeypatch) -> None: + monkeypatch.setenv("BCDA_CLIENT_ID", "env") + monkeypatch.setenv("BCDA_CLIENT_SECRET", "secret") + seen = {} + + def remember(conn, output_dir=None): # noqa: ANN001, ANN202 + seen["conn"] = conn + return _FakeClient() + + monkeypatch.setattr(svc, "make_client", remember) + monkeypatch.setattr(svc, "preflight", lambda c: {"ok": True}) + assert runner.invoke(app, ["bcda", "preflight", "--preset", "Nope"]).exit_code == 0 + assert seen["conn"].client_id == "env" + + +def test_preflight_and_auth(monkeypatch) -> None: + monkeypatch.setattr( + svc, "preflight", lambda c: {"ok": True, "fhir_version": "4.0.1"} + ) + result = runner.invoke(app, ["bcda", "preflight"]) + assert result.exit_code == 0 and '"fhir_version": "4.0.1"' in result.output + monkeypatch.setattr(svc, "preflight", lambda c: {"ok": False, "error": "down"}) + assert runner.invoke(app, ["bcda", "preflight"]).exit_code == 1 + monkeypatch.setattr(svc, "auth_check", lambda c: {"token_chars": 40}) + result = runner.invoke(app, ["bcda", "auth", "--preset", "Small Model Entity"]) + assert result.exit_code == 0 and '"token_chars": 40' in result.output + + +def test_jobs(monkeypatch) -> None: + rows = [ + { + "job_id": "1", + "status": "completed", + "endpoint": "Group/all", + "types": "Patient", + "started": "s", + "ended": "e", + "reusable": True, + "job_url": "u", + } + ] + monkeypatch.setattr(svc, "server_jobs", lambda c, limit: rows[:limit]) + result = runner.invoke(app, ["bcda", "jobs", "--limit", "1"]) + assert ( + result.exit_code == 0 + and "reusable" in result.output + and "1 job(s)" in result.output + ) + + +class _FakeStore: + def get_files(self, key): # noqa: ANN001, ANN202 + return [{"storage_path": f"{key}/a"}] + + +def test_export_per_type_and_one_job(monkeypatch) -> None: + monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore()) + calls = [] + monkeypatch.setattr( + svc, + "export_per_type", + lambda store, client, **kw: calls.append(("per", kw)) or {"Patient": "K1"}, + ) + monkeypatch.setattr( + svc, + "start_export", + lambda store, client, **kw: calls.append(("one", kw)) or "K2", + ) + result = runner.invoke( + app, + [ + "bcda", + "export", + "-t", + "Patient", + "--system-type", + "DDPS", + "--outcome", + "complete", + "--since", + "2026-01-01T00:00:00+00:00", + ], + ) + assert result.exit_code == 0, result.output + assert ( + "_typeFilter=ExplanationOfBenefit?_tag=" in result.output + and "Patient: job K1, 1 file(s)" in result.output + ) + assert ( + calls[0][0] == "per" + and calls[0][1]["types"] == ["Patient"] + and calls[0][1]["since"].startswith("2026") + ) + result = runner.invoke( + app, ["bcda", "export", "--one-job", "--endpoint", "Group/runout"] + ) + assert ( + result.exit_code == 0 + and "Patient,Coverage,ExplanationOfBenefit: job K2" in result.output + ) + assert ( + calls[-1][0] == "one" + and calls[-1][1]["endpoint"] == "Group/runout" + and calls[-1][1]["type_filter"] is None + ) + + +def test_adopt_and_cancel(monkeypatch) -> None: + monkeypatch.setattr(svc, "make_store", lambda path=None: _FakeStore()) + seen = {} + monkeypatch.setattr( + svc, + "adopt_job", + lambda store, client, url, types=None: ( + seen.update(url=url, types=types) or "KA" + ), + ) + result = runner.invoke(app, ["bcda", "adopt", "77", "-t", "Patient"]) + assert result.exit_code == 0 and "adopted job 77 as KA" in result.output + assert seen["url"].endswith("/api/v3/jobs/77") and seen["types"] == ["Patient"] + monkeypatch.setattr(svc, "cancel_job", lambda client, url: seen.update(cancel=url)) + result = runner.invoke(app, ["bcda", "cancel", "78"]) + assert result.exit_code == 0 and seen["cancel"].endswith("/jobs/78") + + +def test_store_load_vendor(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(svc, "make_store", lambda path=None: object()) + monkeypatch.setattr(svc, "store_path", lambda: tmp_path) + monkeypatch.setattr( + svc, + "store_jobs", + lambda s: [ + { + "key": "K1", + "status": "complete", + "types": "Patient", + "files": 2, + "bytes": 1234, + "ts": "t", + } + ], + ) + result = runner.invoke(app, ["bcda", "store"]) + assert ( + result.exit_code == 0 and "K1" in result.output and "1 job(s)" in result.output + ) + monkeypatch.setattr( + svc, "load_and_publish", lambda **kw: {"bcda.patient": 5, **{"kw": len(kw)}} + ) + result = runner.invoke(app, ["bcda", "load", "--no-pipeline", "--skip-flatten"]) + assert ( + result.exit_code == 0 + and "bcda.patient: 5 rows" in result.output + and "Replica published" in result.output + ) + + monkeypatch.setattr(svc, "vendor_status", lambda: {"available": False}) + assert runner.invoke(app, ["bcda", "vendor"]).exit_code == 1 + status = { + "available": True, + "sources": [ + { + "name": "bcda-app", + "kind": "github", + "origin": "CMSgov/bcda-app", + "commit": "abcdef123456789", + "files": 3, + } + ], + "files": [], + "problems": [], + } + monkeypatch.setattr(svc, "vendor_status", lambda: status) + monkeypatch.setattr(svc, "vendor_check", lambda: []) + result = runner.invoke(app, ["bcda", "vendor", "--check"]) + assert ( + result.exit_code == 0 + and "matches the manifest" in result.output + and "no upstream drift" in result.output + ) + monkeypatch.setattr(svc, "vendor_check", lambda: ["bcda-app: swagger.json changed"]) + result = runner.invoke(app, ["bcda", "vendor", "--check"]) + assert result.exit_code == 1 and "1 upstream change(s)" in result.output + status["problems"] = ["missing x"] + result = runner.invoke(app, ["bcda", "vendor"]) + assert result.exit_code == 1 and "! missing x" in result.output + + +def test_ui_launches_streamlit(monkeypatch) -> None: + with patch("subprocess.call", return_value=0) as call: + result = runner.invoke(app, ["bcda", "ui", "--port", "9999", "--no-headless"]) + assert result.exit_code == 0 and "http://localhost:9999" in result.output + cmd = call.call_args.args[0] + assert cmd[1:4] == ["-m", "streamlit", "run"] and cmd[4].endswith("bcda/ui/app.py") + assert cmd[cmd.index("--server.headless") + 1] == "false" diff --git a/uv.lock b/uv.lock index 9c2e4a6..4919f68 100644 --- a/uv.lock +++ b/uv.lock @@ -3112,38 +3112,52 @@ wheels = [ [[package]] name = "pyarrow" -version = "25.0.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, - { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, - { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, - { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, - { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, - { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, - { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, - { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, - { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, - { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" }, - { url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" }, - { url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" }, - { url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" }, - { url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" }, - { url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" }, + { url = "https://files.pythonhosted.org/packages/b3/60/6793778f2617cce469383dac0ba08c4f2401cf342df0c7b9ca53939d9b46/pyarrow-26.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:90ddaf7c625307ad52f31a9b25c34fe5e4897c7529ee3481135822b2b6842ff1", size = 36333953, upload-time = "2026-10-09T08:14:00.387Z" }, + { url = "https://files.pythonhosted.org/packages/db/81/f944cc63ce8a753e5fbff25de6d1d475ebd7fffdf9cf98c65130294fc896/pyarrow-26.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee341973f78a0b46e073d065e88e75026a9c584051e97f98a0d05d96c6bac7dd", size = 38688456, upload-time = "2026-10-09T08:14:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7e5c722fa5d5d9f3b75e62fe11694b34217664d4f05ac88031197166b277/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01c863a18bd9c8412453dd0d92de6d0ee7b2b3d6fb079d9734a4b2a3c8bd4453", size = 50867603, upload-time = "2026-10-09T08:14:09.115Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/9cd356d906e71bd79b0c3fc5c9a54e01a0020dcf14c152ccfbcb503c7298/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a628922ba20705fa964ca73e4ef959c2fb2f14b9bbec5589a6a1e68e6257c85", size = 53931932, upload-time = "2026-10-09T08:14:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e4/5bae3133b7fe04c24907a20f3bc1fba388cbbde659199e7b76445982047a/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:954d971b363b16ee41f89389a4053315dc71265f2ce5c2468eb0a910b1166268", size = 54444720, upload-time = "2026-10-09T08:14:31.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/ee422493bb6dafdbef776cfe2c2a73106a1063a79bf4e78d1e5f51176885/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d5768d03426abe6526d5274adefa00abf00a7f81118c46e98b5a46390f5549e", size = 57388949, upload-time = "2026-10-09T08:14:38.964Z" }, + { url = "https://files.pythonhosted.org/packages/54/3c/1783aab1dac28e175dcf26dfc7123725efc474caecaed91e8a34cb89cad0/pyarrow-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cc903e1069e9dd5e9dcf780324c0112e27e051e422ecfaff574fb33ed65d9160", size = 28567581, upload-time = "2026-10-09T08:14:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2", size = 36336700, upload-time = "2026-10-09T08:14:51.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2", size = 38698502, upload-time = "2026-10-09T08:14:57.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7c/cea852a832a327a8de797b3a68e5c25ce0f5aa1d20503807671bd90ec642/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e3b190ba1d3d22a5a8758597f797111b77d433473744352a184a5ee0a42d672e", size = 50865064, upload-time = "2026-10-09T08:20:01.614Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:240bd18a7487f8767616a948a69dd4e740a8bc36a1c9da49e4dc9a32c5c2faed", size = 53926722, upload-time = "2026-10-09T08:23:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/98257444e2aea2e1fddceee3af3bd2077236d550428413f80393bd1f888d/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b5fcd69c0e1107b79e55839877db5a6ed04651b73fd6fec581d09e230bed5e4", size = 54443093, upload-time = "2026-10-09T08:23:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/dac99cfb25cfa62bf7194600cc99abc14a6bd2af50d7fdb7f15eeaf6e202/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7444ea6975c49a857c68f9bd8fa11acae96dede63d120ffb3bf0a603ea82516", size = 57381937, upload-time = "2026-10-09T08:23:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ed/138d29fddaf803b90f4527e124bb6aaddc18aaf4a6c50fd0a5f577c94989/pyarrow-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3de30a7432b48b98b9decbd9e25a53bb9251d202c2e6c5a29a50869592ccb117", size = 28478571, upload-time = "2026-10-09T08:23:30.535Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/01858422a37f083911c2bb4d15cc32c5eeaa9d9b2bf5ddedee995a7146a6/pyarrow-26.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5780d487ff6c6ed7b42298609680d87fe0036e529a9dc2e1105364bce9697f50", size = 36378402, upload-time = "2026-10-09T08:23:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/00/85/f6b5976c2878b752d0804d371684e0495a71de296b6dc6559e6fbaa4311a/pyarrow-26.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:a0e4e92eeb088f1d7c2c04d6c7de8434c75abb4b4ccf0bbcd045aa7164c68d93", size = 38733074, upload-time = "2026-10-09T08:23:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/81/bc/c90fcbbcf893631e23dab1b0fb3fa29a508a8614326571b03c0894eda00b/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:eaf9e7cc7ab59f6c760232bbde18f64d559bbc50544841303bfb32be53533297", size = 50929201, upload-time = "2026-10-09T08:23:50.507Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/0c1ff38ab7df1b2cf54cf0ad9f19a516c4e416c6c9b4c966cc2c9d587f77/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab6914db225d7f399652ae1f08588dfbc9efe617612715701e3d9d5cfa5ca19f", size = 53951865, upload-time = "2026-10-09T08:23:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/6a6b170496925472adad45a32528770fc8632db35fc60d4edd1e9ce1be0b/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41dd3661ef40790a78870052ad7a58ad827b27c67a4511f06962eb9e9b74d19b", size = 54496388, upload-time = "2026-10-09T08:24:05.23Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/033ef9dba80976820190e292a10a5a23e9406572b76bbeb4d685d90e5c8d/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e949744dcfc2d379808f7013c5f9cafaf0f817656dff7d46c6931528dd1784b", size = 57411588, upload-time = "2026-10-09T08:24:12.043Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ff/a74892c50aaf1f9f744a84493e08a2f99221e77c39d2d4a926de21a99edf/pyarrow-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a5fa8dc70dd50808990ff36faf44088e357b353d86c7682dd92d4b78d4c97d5", size = 29237858, upload-time = "2026-10-09T08:24:58.106Z" }, + { url = "https://files.pythonhosted.org/packages/03/10/f0ee0976ef08a851a743c57608917ac9a47623f688b9ee0efe5429975ba1/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:e2a1856e9565fe2679863b372478c681806aebbf7d0a6e72f33e77f804e647d6", size = 36495870, upload-time = "2026-10-09T08:24:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/0bc431a509bf10b4472dbb94f4184752ecbbddeb7f467152dac0fdaed469/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:4bcba83299cb2b8f8e443d36c6ba6269a5034431879015fb0719495df8a14de2", size = 38819754, upload-time = "2026-10-09T08:24:20.875Z" }, + { url = "https://files.pythonhosted.org/packages/61/59/2be41d26af7a07fb71581fb753cae396403ba1a2978355fd553929d44a9a/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a4d235876f14b4136b4d616ec42eb469ea0d6ead336cae631aa1dd29b21c962", size = 50933671, upload-time = "2026-10-09T08:24:27.199Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/b6d5048cf3178be9678f5c9c60040199894b2f69c3439c87ced91fd24da9/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:210cc9b83888b87cdc8f793eebb264f22b20d0dedbedefc73b9687a7047b4747", size = 53906419, upload-time = "2026-10-09T08:24:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/23e30fbd776c81d18d134d2592eb60daca13e8a57ab087d0fa042f9d9f3d/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca77c43ca55bfc9a4eeb1f0cd5f093f08731b77c24cdba0829035f084959b0bb", size = 54527960, upload-time = "2026-10-09T08:24:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/fce251cd6b0546dfc181b00d5c8ef1c95a8c4cae83266bc3dfd5f719c62c/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:290a74c48e9491b436fd5edacfadf357943f82aa45c81110bd83a69aab33d1cf", size = 57388010, upload-time = "2026-10-09T08:24:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/44/a5/0126fb0ef8d59bf257bdd68bb41623b72afc6e81790a0b4ac863a0f58861/pyarrow-26.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:515a10dae2a1d236bc9c9209d0317acb6746ea63cd4f98704904af7156d90ed1", size = 29406123, upload-time = "2026-10-09T08:24:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/ed/66/8ada1b5165359d84b4b9b5384742304d1081da670f77d458fd9c9b8a2161/pyarrow-26.0.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e890816e5ee89c74a0f8b9379fe8b5ba83f46132b2a0bbb9b1c21359ec30dfda", size = 36373215, upload-time = "2026-10-09T08:25:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/74f10c3d803a6834b2acab21847724d4bdbc74d246eb17321432844707f3/pyarrow-26.0.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:9db18a9dc0af52135c9eac549d80a7a882696efbe5406cf882b044525d4ecc2e", size = 38730866, upload-time = "2026-10-09T08:25:07.924Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/ea2fa2163b1bd8ff73efd39c4060be63fd6ddec03e7887a471acd1e042a4/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:734312d3d99088d9ec28c5b17bad40389bd8373a1afc10acb60b83fd217af087", size = 50924443, upload-time = "2026-10-09T08:25:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/78/80/8c47b6cf8cfd42826df65193eff026c1cc81fa6cb213a3c3f5d203e6f67a/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:24f892fdf1ae1942d69d3f7742e2f49960ec95277cfb1a70b8a1d91f4a96d935", size = 53948540, upload-time = "2026-10-09T08:25:19.305Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/3a506a76d944ec5c5e4b7f01d8d0446b392a6fb384de627a12e503f616b4/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:879331ddea2a26479fa18fade71e6facf684a6cf19f67daec3775c871569e8e5", size = 54494863, upload-time = "2026-10-09T08:25:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/08c4bb04d651788d2eaca78065743f4f6ded974d4ef96ae3c473993e9d0c/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5b827650e874f1f9f9392524ea3e9e3e8a245de5ba64acca1f81ab188090afb9", size = 57409877, upload-time = "2026-10-09T08:25:31.157Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f3/c64781fbd7b6d3c07993b698c14944d0d195f07e800fa931c486ae6ab36a/pyarrow-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:8e8e28c464552b5ca03e30d4504168c4425ce383884f8611b00e972f9fd933fc", size = 29236658, upload-time = "2026-10-09T08:26:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/06/55/2ee3729daea999f19f061f03898d4895a242c4cd94f26e1324e5fdfbfe10/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:ce28748cbeb0f29c3ce9603782979c7117580fc76f16aa3ca448b38a22281adb", size = 36489011, upload-time = "2026-10-09T08:25:37.64Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7d/3eb17f601f2bf13eda5f2ed28956379ca628b4dda97619cbb1cb1721622d/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_x86_64.whl", hash = "sha256:106bb9290fc6fd9a84138a9440038ef184bac86463543c5ff099229cb30d996c", size = 38808480, upload-time = "2026-10-09T08:25:43.579Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/f0047360b0f4bfc031b256dc0aec3837a61f245b2fb70f8363438e2db665/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a413046eba9896e632925066c74095182200ba32e19ff0166bf64d2f936ac", size = 50923273, upload-time = "2026-10-09T08:25:51.445Z" }, + { url = "https://files.pythonhosted.org/packages/38/d9/56d9fb91210407df31cbeb9b91138601c88c7c8fb5f6bf773b20d65509bf/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:d58798c4d8d629700058e9afc1e16b9801023f3ce4dc1c92d945e79b5ffe4e98", size = 53900905, upload-time = "2026-10-09T08:25:59.554Z" }, + { url = "https://files.pythonhosted.org/packages/cf/40/8e8a7e9e027c731520c7eb179dd00a153b76ebf0bc11d213c6c8f8502851/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:645917e976671debabf854abab6e2b75c571ca4f82adc33a2d338697f7c27d93", size = 54518345, upload-time = "2026-10-09T08:26:07.125Z" }, + { url = "https://files.pythonhosted.org/packages/be/89/1e768a3fdb88d34e708ad2dc00dbf8e4e30290784eb84198d59308963bea/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7c3fda041e7078802589cf257750323ee3d0cd1e56e53a9b20ec845697fb3d28", size = 57379403, upload-time = "2026-10-09T08:26:13.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/be/7b81a44d6a8e70581dcc1d6f01541f9000a973b1e5d75394aec91e7b179a/pyarrow-26.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:68cd662e9e2b00876a131950cf32336ace2d0865e1f9418763e3d3be8481dfa4", size = 29389953, upload-time = "2026-10-09T08:26:18.277Z" }, ] [[package]] @@ -3262,6 +3276,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + [[package]] name = "pydo" version = "0.30.0" @@ -3942,6 +3969,7 @@ all = [ { name = "resend" }, { name = "sqlalchemy" }, { name = "sqlglot" }, + { name = "streamlit" }, { name = "typer" }, { name = "uvicorn" }, ] @@ -3969,6 +3997,7 @@ bcda = [ { name = "narwhals" }, { name = "pyarrow" }, { name = "pydantic" }, + { name = "streamlit" }, ] bib = [ { name = "duckdb" }, @@ -4241,6 +4270,7 @@ requires-dist = [ { name = "stack", extras = ["rex"], marker = "extra == 'all'" }, { name = "stack", extras = ["rex"], marker = "extra == 'opps'" }, { name = "stack", extras = ["rex"], marker = "extra == 'pfs'" }, + { name = "streamlit", marker = "extra == 'bcda'", specifier = ">=1.40" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.24.1" }, { name = "typer", marker = "extra == 'rec'", specifier = ">=0.24.1" }, { name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.41.0" }, @@ -4285,6 +4315,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "streamlit" +version = "1.66.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/20/d6490a27b4985907364112f40511c8e575efe042fb05983d060c753daf1c/streamlit-1.66.1.tar.gz", hash = "sha256:7710a8b67469da7af7bf3503c9b68afca5676f832f62f9bda1221e0ce11941d1", size = 9826774, upload-time = "2026-10-21T17:02:58.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/eb/f12286b0b5c30e3fd8e45ca47f05a6cd14cb6e583d04ef4409974a17d371/streamlit-1.66.1-py3-none-any.whl", hash = "sha256:29607190a589fe1fa9bd7e154635d335d47dc09b4010c6cc273daa6e4430ee31", size = 10364948, upload-time = "2026-10-21T17:02:55.973Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -4445,6 +4504,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "websockets" version = "16.0"