mostlyright-data 0.31.10__tar.gz → 0.31.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/PKG-INFO +1 -1
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/preparing-table-search.md +75 -12
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recipe-test.md +4 -3
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/activity.py +6 -5
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_test.py +46 -11
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/runs.py +96 -7
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/session.py +83 -29
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/table_search_contract.json +44 -1
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4.py +2 -2
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_runs.py +10 -1
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_search.py +31 -1
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_search_contract.py +54 -3
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/.gitignore +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/README.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/pyproject.toml +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/scripts/hatch_build.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/SKILL.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/agents/openai.yaml +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/agent-protocol.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/boundaries.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/commands.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/enriching-with-small-models.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/installation-parity.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/live-run.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/narrating-the-run.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/one-install.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/patterns.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/prediction-labels.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/promote.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/readers.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/receipts.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/reference-pages.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/required-protocol.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/source-credentials.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/sources.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/transforms.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/user-communication-contract.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/__init__.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/agent_protocol.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/canonical.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/formats.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/key_seam.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/page_coverage.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/session_probes.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/skill_assets.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/table_manifest.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/__init__.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/acquire.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/approvals.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/backoff.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/categories.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/commands.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/delivery.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/description_shape.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/download.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/enrichment.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/matching_benchmark.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/narrative.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/parity.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/probe.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/progress_rate.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/propose.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/release_check.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/research.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/router.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/stream.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/support.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/table_view.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/transport.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_matching.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/__init__.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/attendance.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/clarification.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credentials.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/login.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/remediation.py +0 -0
- {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/render.py +0 -0
|
@@ -146,7 +146,10 @@ column description.
|
|
|
146
146
|
|
|
147
147
|
## 6. Write the vocabulary column by column
|
|
148
148
|
|
|
149
|
-
An alias maps a phrase to an exact filter, and a sort alias maps a phrase to a sort.
|
|
149
|
+
An alias maps a phrase to an exact filter, and a sort alias maps a phrase to a sort. Once
|
|
150
|
+
planning#1842 lands, the live page's search node applies the aliases and merges them with the
|
|
151
|
+
planner's plan. Until then aliases are honoured only by `search explain`, and only while the
|
|
152
|
+
planner is unavailable for that table. The live page applies none. List every
|
|
150
153
|
declared column before you write the spec. Then generate its phrases from its type:
|
|
151
154
|
|
|
152
155
|
| Column | Phrases | Spec entry |
|
|
@@ -191,7 +194,46 @@ column names, the descriptions and the planner. Never bind a word that two colum
|
|
|
191
194
|
such as "growth", "change" or "best", unless the brief names the metric. "Highest growth"
|
|
192
195
|
pointed at a revenue column turns "highest growth last 30 days" into a revenue sort.
|
|
193
196
|
|
|
194
|
-
|
|
197
|
+
### Declare a floor for every rate
|
|
198
|
+
|
|
199
|
+
A growth, change, ratio or per-unit column ranks nonsense first when its base is tiny. Revenue
|
|
200
|
+
that grew from $5,000 to $80 million is 1,600,000% growth, and a film with three votes has a
|
|
201
|
+
perfect rating. Sorting by such a column shows shells and placeholders, not the rows the reader
|
|
202
|
+
asked for. For each sortable rate column, declare the column it is a rate of, and the smallest
|
|
203
|
+
base that makes the rate meaningful, in `order_floors` (contract 4.54.0, up to 32 entries):
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
"order_floors": [
|
|
207
|
+
{"column": "revenue_growth_pct", "base_column": "revenue_prior_year_usd", "min": 10000000},
|
|
208
|
+
{"column": "revenue_cagr_5y_pct", "base_column": "revenue_5y_ago_usd", "min": 10000000},
|
|
209
|
+
{"column": "vote_average", "base_column": "vote_count", "min": 100}
|
|
210
|
+
]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `column` is in `sortable_columns` or is a sort alias's column. `base_column` is another numeric
|
|
214
|
+
column in `filterable_columns`. List each base early in `filterable_columns`: a package keeps 64
|
|
215
|
+
filters, role and alias columns first and the rest in spec order, and a base past that cut
|
|
216
|
+
loses its floor. `search validate TABLE --spec` names such a floor in `warnings` under
|
|
217
|
+
`/order_floors/`; treat that warning as a floor that will not apply.
|
|
218
|
+
- `min` is a whole number in the base column's own unit, written without a decimal point or
|
|
219
|
+
exponent: `10000000` on a column in dollars, `10` on a column in millions.
|
|
220
|
+
- Floors are declared on a search revision with `mr-data search configure --spec`, not in the
|
|
221
|
+
recipe's `table.search`, which refuses the member. A revision configured from the recipe's own
|
|
222
|
+
spec carries none, and a new version of a private table keeps them only when you configure the
|
|
223
|
+
revision again. A plan made without the planner, such as a keystroke search, applies no floor.
|
|
224
|
+
- A Studio older than contract 4.54.0 refuses the member as unknown. Configure without it there.
|
|
225
|
+
- When a plan orders by `column`, in either direction, Studio adds `base_column >= min` as a
|
|
226
|
+
filter. The reader sees it in the search summary and can remove it. A query that already filters
|
|
227
|
+
`base_column` keeps its own filter.
|
|
228
|
+
- Prefer the true base (the earlier period's revenue, the vote count) over a size proxy such as
|
|
229
|
+
market cap. When the table has no base column, add one in the transform: it is cheaper than a
|
|
230
|
+
wrong ranking.
|
|
231
|
+
- Choose `min` from the built table with `mr-data query`: the smallest base at which the rate stops
|
|
232
|
+
being dominated by placeholders. State the floor at the start of the base column's
|
|
233
|
+
description, since the planner reads only the first 160 characters of a description.
|
|
234
|
+
|
|
235
|
+
A recipe's spec takes 128 filter aliases and 64 sort aliases, and a search revision 512 and 256.
|
|
236
|
+
`search validate` refuses more.
|
|
195
237
|
Rank the phrases and keep the top of the list. Put first the words that name the kind of row, the
|
|
196
238
|
facets users type most, the popularity sorts and the product's own vocabulary. A phrase you leave
|
|
197
239
|
out maps to nothing. The planner may still recover it from the column names and values, and
|
|
@@ -200,15 +242,35 @@ nothing guarantees that.
|
|
|
200
242
|
## 8. Turn the planner on
|
|
201
243
|
|
|
202
244
|
The planner turns a sentence into filters, sorts and preferences. Turn it on for every table with
|
|
203
|
-
search
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
245
|
+
search with `"enabled": true`. A query whose every word an alias resolved skips it. Queries with
|
|
246
|
+
negation or "or" stay unresolved rather than becoming a guessed filter.
|
|
247
|
+
|
|
248
|
+
The planner model sees the query text, the column names and their descriptions, value
|
|
249
|
+
dictionaries of at most 16 columns of at most 256 short values each, the aliases and the field
|
|
250
|
+
roles. It never sees rows.
|
|
251
|
+
|
|
252
|
+
`external_context_allowed` is the owner's consent to send that context to the planner model:
|
|
253
|
+
|
|
254
|
+
- A public table whose sources are all public gets the planner on its live page whatever the flag
|
|
255
|
+
says. Set the flag to `true` on it, so `search explain` and `capabilities.planner` match the page.
|
|
256
|
+
- A private or link-shared table gets the planner only when the recipe sets the flag to `true`.
|
|
257
|
+
That is a deliberate yes from the user or the workspace policy. Data-read permission is not that
|
|
258
|
+
consent. Without that yes, keep the flag `false`.
|
|
259
|
+
- Until the Studio change in planning#1844 is live, the flag changes only `search explain` and the
|
|
260
|
+
`capabilities.planner` readout. The live page plans a public table with public sources and no
|
|
261
|
+
other table.
|
|
262
|
+
|
|
263
|
+
`search inspect`, `search status` and `search explain` say why the planner is unavailable in
|
|
264
|
+
`planner_detail.reason`. `external_context_not_allowed` means the recipe withholds consent.
|
|
265
|
+
`private_source` means a source is not public.
|
|
266
|
+
|
|
267
|
+
Write each column description as if the planner reads it, because it does, and the page shows it
|
|
268
|
+
to the reader beside results ordered by that column. Say what the column holds, the period it
|
|
269
|
+
covers ("trailing twelve months", "latest fiscal year against the year before", "over the last
|
|
270
|
+
five fiscal years"), its unit, and the words people use for it, abbreviations included
|
|
271
|
+
("compound annual growth rate, CAGR"). The planner picks the column a query asks about, named
|
|
272
|
+
bare ("revenue") or in a question ("what is the P/E ratio?"), from these descriptions. It reads
|
|
273
|
+
the first 160 characters of each, so lead with what the column measures and its period.
|
|
212
274
|
|
|
213
275
|
### Name and describe columns for search
|
|
214
276
|
|
|
@@ -314,7 +376,8 @@ mr-data search explain TABLE_ID --version VERSION_ID --query "stocks under 10" -
|
|
|
314
376
|
|
|
315
377
|
Read `planner_used`, `filters` and `order_by`. A query that asks for a filter or a sort and plans
|
|
316
378
|
text only is a gap in the spec. Add the alias or sharpen the column description, then check again.
|
|
317
|
-
`planner_used: false` on a settled query is a fault to report, unless aliases resolved every word
|
|
379
|
+
`planner_used: false` on a settled query is a fault to report, unless aliases resolved every word
|
|
380
|
+
or `planner_detail.reason` says the planner is withheld.
|
|
318
381
|
|
|
319
382
|
Run real queries against the live search once `package_state` is `ready`:
|
|
320
383
|
|
{mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recipe-test.md
RENAMED
|
@@ -43,7 +43,7 @@ Read `status`:
|
|
|
43
43
|
| `recipe_test_cancelled` | Cancelled before it reported. | 2 |
|
|
44
44
|
| `recipe_test_awaiting_confirmation` | Held for a size confirmation. Run `confirm_command`, then ask again. The hold is a size check, not a charge. | 2 |
|
|
45
45
|
| `recipe_test_started` | `--no-wait` returned before the report. | 0 |
|
|
46
|
-
| `recipe_test_still_running` | Not reported within the wait. Ask again with the same bounds. | 2 |
|
|
46
|
+
| `recipe_test_still_running` | Not reported within the wait. `test_status: queued` means no worker holds the test yet, so nothing is wrong with the recipe. Ask again with the same bounds. | 2 |
|
|
47
47
|
| `recipe_test_skipped` | Nothing was tested. `test.reason` says why: `not_served` (Studio predates the test), `engine_unsupported` (the recipe runs on the Keel engine) or `worker_unsupported` (the worker predates the test and refused it `JOB_INVALID`). | 0 |
|
|
48
48
|
|
|
49
49
|
Asking again with the same bounds reads a running test back, and a completed test replays its
|
|
@@ -66,8 +66,9 @@ page is fetched twice, and the receipt's `recipe_test` block names the test run.
|
|
|
66
66
|
When the test finds a blocking problem, fails, is held or is still running after those five
|
|
67
67
|
minutes, the full is not started. The status is `run_stopped_by_test`,
|
|
68
68
|
`run_test_awaiting_confirmation` or `run_test_still_running`, `run_id` names the test, and the
|
|
69
|
-
command exits 2.
|
|
70
|
-
|
|
69
|
+
command exits 2. `test.test_status: queued` on `run_test_still_running` means no worker had
|
|
70
|
+
picked the test up; `mr-data status RUN_ID` says when one has. Running the same command again
|
|
71
|
+
picks a running test back up, replays a finished one, and tests again after a failure. `next` ends with the command that starts the
|
|
71
72
|
same full without the test. Repair the recipe first. Skip the test only when the user decides the
|
|
72
73
|
problem does not matter:
|
|
73
74
|
|
{mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/activity.py
RENAMED
|
@@ -50,7 +50,7 @@ from time import monotonic
|
|
|
50
50
|
from typing import Any
|
|
51
51
|
|
|
52
52
|
from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
|
|
53
|
-
from mostlyright.data_harness.thin.session import
|
|
53
|
+
from mostlyright.data_harness.thin.session import StudioSessionProvider, dataset_dashboard_url
|
|
54
54
|
from mostlyright.data_harness.thin.transport import (
|
|
55
55
|
REQUEST_TIMEOUT_SECONDS,
|
|
56
56
|
ThinLaneError,
|
|
@@ -931,8 +931,9 @@ def _document_counts(document: Mapping[str, Any]) -> tuple[int, int]:
|
|
|
931
931
|
#: One session per process, because two reports in one command -- ``recipe`` says one thing before
|
|
932
932
|
#: it registers and another after -- must not buy two token exchanges. It is never shared with a
|
|
933
933
|
#: command's own client: those open their own, and a cache reaching across the two would make a
|
|
934
|
-
#: progress report able to break a command by holding an expired token.
|
|
935
|
-
|
|
934
|
+
#: progress report able to break a command by holding an expired token. It is a provider, so the
|
|
935
|
+
#: clients built on it share one renewal when the token nears its expiry rather than one each.
|
|
936
|
+
_SESSION: StudioSessionProvider | None = None
|
|
936
937
|
|
|
937
938
|
#: The verb's transport, which is cached because its clock is a constant. The automatic half's is
|
|
938
939
|
#: not cached and cannot be: its clock is a deadline, and a deadline belongs to one report.
|
|
@@ -993,11 +994,11 @@ def _reporting_client(budget: _Budget) -> StudioV4ActivityClient:
|
|
|
993
994
|
|
|
994
995
|
def _client_on(transport: Transport) -> StudioV4ActivityClient:
|
|
995
996
|
global _SESSION
|
|
996
|
-
from mostlyright.data_harness.thin.session import
|
|
997
|
+
from mostlyright.data_harness.thin.session import open_renewable_session
|
|
997
998
|
from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
|
|
998
999
|
|
|
999
1000
|
if _SESSION is None:
|
|
1000
|
-
_SESSION =
|
|
1001
|
+
_SESSION = open_renewable_session(resolve_cloud_credentials(), transport=transport)
|
|
1001
1002
|
return StudioV4ActivityClient(_SESSION, transport=transport)
|
|
1002
1003
|
|
|
1003
1004
|
|
|
@@ -49,6 +49,7 @@ from mostlyright.data_harness.thin.v4_runs import (
|
|
|
49
49
|
CONFIRM_REQUIRED_CODE,
|
|
50
50
|
TERMINAL_RUN_STATUSES,
|
|
51
51
|
StudioV4RunClient,
|
|
52
|
+
attempt_progress_members,
|
|
52
53
|
confirm_held_command,
|
|
53
54
|
failure_sentence,
|
|
54
55
|
failure_triple,
|
|
@@ -515,6 +516,12 @@ def reading(run: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
|
515
516
|
#: under. It is not a status word: the status is still :data:`FAILED`, with ``room_fault: true``.
|
|
516
517
|
ROOM_FAULT = "room_fault"
|
|
517
518
|
|
|
519
|
+
#: The sentence key for a test still ``queued`` when the wait ended: no worker holds it. It is not
|
|
520
|
+
#: a status word -- the status stays ``recipe_test_still_running`` / ``run_test_still_running`` --
|
|
521
|
+
#: it is which ``next`` and headline that status gets, because "running" was untrue of a test no
|
|
522
|
+
#: worker had picked up and sent people to look for a slow source rather than a cold queue.
|
|
523
|
+
QUEUED = "queued"
|
|
524
|
+
|
|
518
525
|
#: What a person does next, by what the test came to.
|
|
519
526
|
_NEXT = {
|
|
520
527
|
PASSED: (
|
|
@@ -662,11 +669,29 @@ def _settled(
|
|
|
662
669
|
"confirm_command": confirm_held_command(str(run["run_id"])),
|
|
663
670
|
}
|
|
664
671
|
if state not in TERMINAL_RUN_STATUSES:
|
|
672
|
+
minutes = int(ceiling // 60)
|
|
673
|
+
if state == QUEUED:
|
|
674
|
+
ledger = attempt_progress_members(client, str(run["run_id"]))
|
|
675
|
+
waiting = (
|
|
676
|
+
"it is back in the queue after an earlier attempt and no worker holds it now"
|
|
677
|
+
if "progress" in ledger
|
|
678
|
+
else "no worker has picked it up yet"
|
|
679
|
+
)
|
|
680
|
+
return {
|
|
681
|
+
"status": STILL_RUNNING,
|
|
682
|
+
"headline": f"The recipe test is still queued: {waiting}, so its report is not in.",
|
|
683
|
+
**members,
|
|
684
|
+
"test_status": state,
|
|
685
|
+
**ledger,
|
|
686
|
+
"next": f"The test was still queued after {minutes} minutes: {waiting}. Ask again "
|
|
687
|
+
"with the same bounds to pick the same test back up, or check whether a worker "
|
|
688
|
+
f"has claimed it with mr-data status {run['run_id']}.",
|
|
689
|
+
}
|
|
665
690
|
return {
|
|
666
691
|
"status": STILL_RUNNING,
|
|
667
692
|
**members,
|
|
668
693
|
"test_status": state,
|
|
669
|
-
"next": f"The test had not reported after {
|
|
694
|
+
"next": f"The test had not reported after {minutes} minutes. Ask "
|
|
670
695
|
"again with the same bounds to pick it back up, or follow it with "
|
|
671
696
|
f"mr-data watch {run['run_id']}.",
|
|
672
697
|
}
|
|
@@ -786,18 +811,24 @@ def before_full(
|
|
|
786
811
|
block = {"status": "passed", **members}
|
|
787
812
|
block.pop("preflight_report", None)
|
|
788
813
|
return BeforeFull(block=block, resume_capture_run_id=str(settled["run_id"]))
|
|
814
|
+
headline = members.pop("headline", None)
|
|
789
815
|
test_block = {"status": status.removeprefix("recipe_test_"), **members}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
816
|
+
if members.get("room_fault") is True:
|
|
817
|
+
said = ROOM_FAULT
|
|
818
|
+
elif status == STILL_RUNNING and members.get("test_status") == QUEUED:
|
|
819
|
+
said = QUEUED
|
|
820
|
+
else:
|
|
821
|
+
said = status
|
|
822
|
+
sentence = _STOPPED_NEXT[said]
|
|
823
|
+
stopped: dict[str, Any] = {"run_id": str(settled["run_id"]), "test": test_block}
|
|
824
|
+
if said == QUEUED:
|
|
825
|
+
# The test's own headline, said about the full it was standing in front of.
|
|
826
|
+
sentence = sentence.format(run=settled["run_id"])
|
|
827
|
+
stopped["headline"] = str(headline).replace(
|
|
828
|
+
"so its report is not in", "so the full run was not started"
|
|
799
829
|
)
|
|
800
|
-
|
|
830
|
+
stopped["next"] = sentence + _HELD_TAIL + rerun
|
|
831
|
+
return BeforeFull(stop=_stopped(status, stopped))
|
|
801
832
|
|
|
802
833
|
|
|
803
834
|
_STOPPED_NEXT = {
|
|
@@ -814,6 +845,9 @@ _STOPPED_NEXT = {
|
|
|
814
845
|
STILL_RUNNING: f"The recipe test had not reported after {int(FULL_WAIT_SECONDS // 60)} "
|
|
815
846
|
"minutes, so the full was not started. Run this command again to pick the same test back "
|
|
816
847
|
"up.",
|
|
848
|
+
QUEUED: f"The recipe test was still queued after {int(FULL_WAIT_SECONDS // 60)} minutes, "
|
|
849
|
+
"waiting for a worker, so the full was not started. Run this command again to pick the same "
|
|
850
|
+
"test back up; mr-data status {run} says whether a worker has claimed it.",
|
|
817
851
|
}
|
|
818
852
|
|
|
819
853
|
|
|
@@ -847,6 +881,7 @@ __all__ = [
|
|
|
847
881
|
"MAX_WAIT_SECONDS",
|
|
848
882
|
"PASSED",
|
|
849
883
|
"PREFLIGHT_COMPLETE",
|
|
884
|
+
"QUEUED",
|
|
850
885
|
"ROOM_FAULT",
|
|
851
886
|
"RUN_STOPPED_STATUS",
|
|
852
887
|
"SKIPPED",
|
{mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/runs.py
RENAMED
|
@@ -34,7 +34,11 @@ from mostlyright.data_harness.canonical import (
|
|
|
34
34
|
canonical_json_bytes,
|
|
35
35
|
parse_foreign_json,
|
|
36
36
|
)
|
|
37
|
-
from mostlyright.data_harness.thin.session import
|
|
37
|
+
from mostlyright.data_harness.thin.session import (
|
|
38
|
+
StudioSession,
|
|
39
|
+
StudioSessionProvider,
|
|
40
|
+
sessions_for,
|
|
41
|
+
)
|
|
38
42
|
from mostlyright.data_harness.thin.transport import (
|
|
39
43
|
MAX_JSON_RESPONSE_BYTES,
|
|
40
44
|
ThinLaneError,
|
|
@@ -211,15 +215,39 @@ class StudioApiClient:
|
|
|
211
215
|
hardening, and a second ``_call`` would be a second place for the byte ceiling, the JSON
|
|
212
216
|
hardening and the Studio-code translation to drift apart. Subclasses add routes; none of them
|
|
213
217
|
adds a transport.
|
|
218
|
+
|
|
219
|
+
⚠ AND EVERY CALL ASKS FOR ITS TOKEN FRESH (planning#1845). The client holds a
|
|
220
|
+
:class:`~mostlyright.data_harness.thin.session.StudioSessionProvider` rather than one token, so
|
|
221
|
+
a call made after a long wait -- the recipe test's report, a search poll, a closing read --
|
|
222
|
+
goes out on a token renewed when it had under a minute left, and a 401 on a token that was not
|
|
223
|
+
just renewed renews it once and sends the same call again, once. A session built by hand has
|
|
224
|
+
no renewal, and its 401 is final as it always was.
|
|
214
225
|
"""
|
|
215
226
|
|
|
216
|
-
def __init__(
|
|
217
|
-
self
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
session: StudioSession | StudioSessionProvider,
|
|
230
|
+
*,
|
|
231
|
+
transport: Transport | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
self._sessions = sessions_for(session)
|
|
218
234
|
self._transport = transport or UrlLibTransport()
|
|
219
235
|
|
|
220
236
|
@property
|
|
221
237
|
def session(self) -> StudioSession:
|
|
222
|
-
|
|
238
|
+
"""The session held right now; a call renews it first when it is nearly spent."""
|
|
239
|
+
|
|
240
|
+
return self._sessions.session
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def _session(self) -> StudioSession:
|
|
244
|
+
return self._sessions.session
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def sessions(self) -> StudioSessionProvider:
|
|
248
|
+
"""The provider this client's calls renew through, for a stream that should share it."""
|
|
249
|
+
|
|
250
|
+
return self._sessions
|
|
223
251
|
|
|
224
252
|
@property
|
|
225
253
|
def transport(self) -> Transport:
|
|
@@ -251,7 +279,6 @@ class StudioApiClient:
|
|
|
251
279
|
headers = {
|
|
252
280
|
"Accept": "application/json",
|
|
253
281
|
CLIENT_HEADER: CLIENT_NAME,
|
|
254
|
-
**self._session.authorization(),
|
|
255
282
|
}
|
|
256
283
|
if extra_headers:
|
|
257
284
|
headers.update(extra_headers)
|
|
@@ -282,10 +309,72 @@ class StudioApiClient:
|
|
|
282
309
|
and len(raw_body) > max_request_bytes
|
|
283
310
|
):
|
|
284
311
|
raise ThinLaneError("THIN_REQUEST_INVALID", "request exceeds its byte limit")
|
|
285
|
-
|
|
312
|
+
# A renewal made for THIS call -- ahead of expiry -- means a 401 on it is not an expiry.
|
|
313
|
+
renewals_before = self._sessions.renewals
|
|
314
|
+
selected = self._sessions.current()
|
|
315
|
+
try:
|
|
316
|
+
return self._send(
|
|
317
|
+
selected,
|
|
318
|
+
method,
|
|
319
|
+
path,
|
|
320
|
+
query=query,
|
|
321
|
+
headers=headers,
|
|
322
|
+
raw_body=raw_body,
|
|
323
|
+
expected=expected,
|
|
324
|
+
shape=shape,
|
|
325
|
+
response_headers=response_headers,
|
|
326
|
+
maximum=maximum,
|
|
327
|
+
max_members=max_members,
|
|
328
|
+
)
|
|
329
|
+
except ThinLaneError as refusal:
|
|
330
|
+
if (
|
|
331
|
+
getattr(refusal, "http_status", None) != 401
|
|
332
|
+
or not self._sessions.renewable
|
|
333
|
+
or self._sessions.renewals != renewals_before
|
|
334
|
+
# A call that carried its own bearer was refused for THAT credential.
|
|
335
|
+
or "Authorization" in headers
|
|
336
|
+
):
|
|
337
|
+
raise
|
|
338
|
+
# The token lapsed between the check and Studio reading it, or Studio holds a shorter
|
|
339
|
+
# clock than the expiry Cloud stated. Renew once and send the same call once more; a 401
|
|
340
|
+
# never reached a handler, so the call was not applied. A second 401 is final.
|
|
341
|
+
return self._send(
|
|
342
|
+
self._sessions.renew(),
|
|
343
|
+
method,
|
|
344
|
+
path,
|
|
345
|
+
query=query,
|
|
346
|
+
headers=headers,
|
|
347
|
+
raw_body=raw_body,
|
|
348
|
+
expected=expected,
|
|
349
|
+
shape=shape,
|
|
350
|
+
response_headers=response_headers,
|
|
351
|
+
maximum=maximum,
|
|
352
|
+
max_members=max_members,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _send(
|
|
356
|
+
self,
|
|
357
|
+
session: StudioSession,
|
|
358
|
+
method: str,
|
|
359
|
+
path: str,
|
|
360
|
+
*,
|
|
361
|
+
query: Mapping[str, Any] | None,
|
|
362
|
+
headers: Mapping[str, str],
|
|
363
|
+
raw_body: bytes | None,
|
|
364
|
+
expected: tuple[int, ...],
|
|
365
|
+
shape: type,
|
|
366
|
+
response_headers: dict[str, str] | None,
|
|
367
|
+
maximum: int,
|
|
368
|
+
max_members: int,
|
|
369
|
+
) -> Any:
|
|
370
|
+
"""One request on one session's token, and the refusal it becomes."""
|
|
371
|
+
|
|
372
|
+
url = f"{session.studio_base_url}{path}{_query_string(query)}"
|
|
373
|
+
# The session's bearer first, so a call that states its own (a deciding credential) wins.
|
|
374
|
+
sent = {**session.authorization(), **headers}
|
|
286
375
|
try:
|
|
287
376
|
status, raw, answered_headers = self._transport.request(
|
|
288
|
-
method, url,
|
|
377
|
+
method, url, sent, raw_body, maximum
|
|
289
378
|
)
|
|
290
379
|
if response_headers is not None:
|
|
291
380
|
response_headers.update(answered_headers)
|
{mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/session.py
RENAMED
|
@@ -22,6 +22,7 @@ import re
|
|
|
22
22
|
import time
|
|
23
23
|
from collections.abc import Callable, Mapping
|
|
24
24
|
from dataclasses import dataclass
|
|
25
|
+
from dataclasses import field as dataclass_field
|
|
25
26
|
from datetime import UTC, datetime, timedelta
|
|
26
27
|
from typing import Any
|
|
27
28
|
from urllib.parse import quote, urlencode
|
|
@@ -169,22 +170,43 @@ TOKEN_EXCHANGE_MAX_RETRY_SECONDS = 30.0
|
|
|
169
170
|
#: The spread is never more than the wait itself, and never subtracted from it.
|
|
170
171
|
TOKEN_EXCHANGE_MAX_JITTER_SECONDS = 5.0
|
|
171
172
|
|
|
172
|
-
#: How close to its expiry a Studio token is traded for a new one before
|
|
173
|
-
#:
|
|
173
|
+
#: How close to its expiry a Studio token is traded for a new one before the next call or the next
|
|
174
|
+
#: stream connection goes out. A token that would expire mid-call is not worth sending.
|
|
174
175
|
SESSION_RENEWAL_MARGIN_SECONDS = 60.0
|
|
175
176
|
|
|
176
177
|
_CLI_KEY = re.compile(r"^mr_cli_[A-Za-z0-9_-]{64}$")
|
|
177
178
|
|
|
178
179
|
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class SessionRenewal:
|
|
182
|
+
"""How one session is traded for a fresh one: the device-key exchange, and the clock it reads.
|
|
183
|
+
|
|
184
|
+
Held by a session :func:`open_studio_session` minted, so every client built on that session can
|
|
185
|
+
renew it without each command threading the credential through by hand. It closes over the
|
|
186
|
+
device key already in this process's memory for the first exchange; nothing here writes it.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
exchange: Callable[[], StudioSession]
|
|
190
|
+
now: Callable[[], datetime]
|
|
191
|
+
|
|
192
|
+
def __repr__(self) -> str:
|
|
193
|
+
return "SessionRenewal(<device-key exchange>)"
|
|
194
|
+
|
|
195
|
+
|
|
179
196
|
@dataclass(frozen=True)
|
|
180
197
|
class StudioSession:
|
|
181
|
-
"""One short-lived Studio authority: where to call, as whom, and until when.
|
|
198
|
+
"""One short-lived Studio authority: where to call, as whom, and until when.
|
|
199
|
+
|
|
200
|
+
``renewal`` is how to trade it for a fresh one, and ``None`` for a session that can never be
|
|
201
|
+
renewed -- one a test or a caller built by hand. It takes no part in equality or the repr.
|
|
202
|
+
"""
|
|
182
203
|
|
|
183
204
|
studio_base_url: str
|
|
184
205
|
workspace_id: UUID
|
|
185
206
|
token: str
|
|
186
207
|
expires_at: datetime
|
|
187
208
|
cloud_url: str
|
|
209
|
+
renewal: SessionRenewal | None = dataclass_field(default=None, compare=False, repr=False)
|
|
188
210
|
|
|
189
211
|
def __repr__(self) -> str:
|
|
190
212
|
return (
|
|
@@ -358,7 +380,12 @@ def open_studio_session(
|
|
|
358
380
|
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
359
381
|
sleep: Callable[[float], None] = time.sleep,
|
|
360
382
|
) -> StudioSession:
|
|
361
|
-
"""Exchange the stored device key for a short-lived Studio token.
|
|
383
|
+
"""Exchange the stored device key for a short-lived Studio token.
|
|
384
|
+
|
|
385
|
+
The session that comes back carries its own :class:`SessionRenewal` -- this same exchange,
|
|
386
|
+
made again -- so a client built on it renews the token ahead of its expiry and once on a 401
|
|
387
|
+
(see :class:`StudioSessionProvider`) rather than outliving it.
|
|
388
|
+
"""
|
|
362
389
|
|
|
363
390
|
require_device_key(credentials)
|
|
364
391
|
cloud_url = service_origin(credentials.cloud_url, "Cloud URL", allow_loopback_http=False)
|
|
@@ -412,6 +439,10 @@ def open_studio_session(
|
|
|
412
439
|
expires_at = _timestamp(_text(parsed, "expires_at"), "expires_at")
|
|
413
440
|
if expires_at <= now():
|
|
414
441
|
raise ThinLaneError("THIN_TOKEN_EXCHANGE_FAILED", "Cloud returned an expired Studio token")
|
|
442
|
+
|
|
443
|
+
def _again() -> StudioSession:
|
|
444
|
+
return open_studio_session(credentials, transport=transport, now=now, sleep=sleep)
|
|
445
|
+
|
|
415
446
|
return StudioSession(
|
|
416
447
|
studio_base_url=service_origin(
|
|
417
448
|
_text(parsed, "studio_base_url"), "Studio base URL", allow_loopback_http=True
|
|
@@ -420,28 +451,33 @@ def open_studio_session(
|
|
|
420
451
|
token=_text(parsed, "token"),
|
|
421
452
|
expires_at=expires_at,
|
|
422
453
|
cloud_url=cloud_url,
|
|
454
|
+
renewal=SessionRenewal(exchange=_again, now=now),
|
|
423
455
|
)
|
|
424
456
|
|
|
425
457
|
|
|
426
458
|
class StudioSessionProvider:
|
|
427
|
-
"""The Studio session a
|
|
428
|
-
|
|
429
|
-
⚠
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
459
|
+
"""The Studio session a command calls through, renewed rather than outlived.
|
|
460
|
+
|
|
461
|
+
⚠ A STUDIO TOKEN LIVES FOR MINUTES AND A WAIT CAN LAST LONGER. A ``watch`` streams for hours;
|
|
462
|
+
``run --full`` waits up to five minutes for the recipe test in front of it, and ``recipe
|
|
463
|
+
test`` twenty; the search and query polls wait on a node that is still settling. Each of those
|
|
464
|
+
used to make its follow-up call on the token minted at the start, and Studio refused it with
|
|
465
|
+
``401 AUTHENTICATION_REQUIRED`` -- which surfaced as an authorization refusal in the middle of
|
|
466
|
+
a wait that was going fine (planning#1845). So every Studio call
|
|
467
|
+
(:class:`~mostlyright.data_harness.thin.runs.StudioApiClient`) and every stream connection asks
|
|
468
|
+
a provider for its session first, and the provider re-exchanges the device key when the token
|
|
469
|
+
has less than :data:`SESSION_RENEWAL_MARGIN_SECONDS` left. A 401 on a token that was not just
|
|
470
|
+
renewed renews once more and the caller sends the same call again, once; a second 401 is final.
|
|
471
|
+
|
|
472
|
+
⚠ A PROVIDER WITH NO ``exchange`` NEVER RENEWS. A test, or a caller handed only a session it
|
|
473
|
+
built by hand, gets exactly the behaviour it had: the session it was given, and a 401 that is
|
|
474
|
+
final. A session :func:`open_studio_session` minted carries its exchange, and
|
|
475
|
+
:func:`sessions_for` hands it to the provider.
|
|
440
476
|
|
|
441
477
|
⚠ AND A RENEWAL MUST NAME THE SAME STUDIO AND THE SAME WORKSPACE. A stream resumes from a
|
|
442
|
-
``Last-Event-ID`` in one Studio's cursor space
|
|
443
|
-
|
|
444
|
-
rather than followed.
|
|
478
|
+
``Last-Event-ID`` in one Studio's cursor space, and a wait reads back a run in one workspace; a
|
|
479
|
+
fresh token for another origin or another workspace would carry that cursor or that run
|
|
480
|
+
somewhere it means nothing, so the renewal is refused by name rather than followed.
|
|
445
481
|
|
|
446
482
|
The device key the exchange closes over is the one already in this process's memory for the
|
|
447
483
|
first exchange. Nothing here writes a token anywhere.
|
|
@@ -472,10 +508,22 @@ class StudioSessionProvider:
|
|
|
472
508
|
return self._exchange is not None
|
|
473
509
|
|
|
474
510
|
def current(self) -> StudioSession:
|
|
475
|
-
"""The session to open the next connection with, renewed first when it is nearly spent.
|
|
511
|
+
"""The session to open the next connection with, renewed first when it is nearly spent.
|
|
512
|
+
|
|
513
|
+
⚠ A RENEWAL AHEAD OF EXPIRY IS A COURTESY, NOT A GATE. When Cloud cannot mint a new token
|
|
514
|
+
right now (a cold-start 503, a 429, a 500) and the token held has not expired yet, the
|
|
515
|
+
call goes out on the token held -- which is what it did before renewal existed. Only a
|
|
516
|
+
token that has already lapsed makes the failed exchange the answer. A renewal that names
|
|
517
|
+
another Studio or workspace is refused either way.
|
|
518
|
+
"""
|
|
476
519
|
|
|
477
520
|
if self._exchange is not None and self._session.expires_at - self._now() < self._margin:
|
|
478
|
-
|
|
521
|
+
try:
|
|
522
|
+
return self.renew()
|
|
523
|
+
except ThinLaneError as refusal:
|
|
524
|
+
lapsed = self._session.expires_at <= self._now()
|
|
525
|
+
if refusal.code == "THIN_SESSION_BINDING" or lapsed:
|
|
526
|
+
raise
|
|
479
527
|
return self._session
|
|
480
528
|
|
|
481
529
|
def renew(self) -> StudioSession:
|
|
@@ -508,20 +556,25 @@ def open_renewable_session(
|
|
|
508
556
|
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
509
557
|
sleep: Callable[[float], None] = time.sleep,
|
|
510
558
|
) -> StudioSessionProvider:
|
|
511
|
-
"""Exchange the device key once now, and keep the means to exchange it again
|
|
512
|
-
|
|
513
|
-
def _exchange() -> StudioSession:
|
|
514
|
-
return open_studio_session(credentials, transport=transport, now=now, sleep=sleep)
|
|
559
|
+
"""Exchange the device key once now, and keep the means to exchange it again."""
|
|
515
560
|
|
|
516
|
-
return
|
|
561
|
+
return sessions_for(open_studio_session(credentials, transport=transport, now=now, sleep=sleep))
|
|
517
562
|
|
|
518
563
|
|
|
519
564
|
def sessions_for(session: StudioSession | StudioSessionProvider) -> StudioSessionProvider:
|
|
520
|
-
"""A provider for what a caller holds: itself, or
|
|
565
|
+
"""A provider for what a caller holds: itself, or one over the session and its own renewal.
|
|
566
|
+
|
|
567
|
+
A session :func:`open_studio_session` minted renews through the exchange it carries; one built
|
|
568
|
+
by hand carries none and is never renewed.
|
|
569
|
+
"""
|
|
521
570
|
|
|
522
571
|
if isinstance(session, StudioSessionProvider):
|
|
523
572
|
return session
|
|
524
|
-
|
|
573
|
+
if session.renewal is None:
|
|
574
|
+
return StudioSessionProvider(session)
|
|
575
|
+
return StudioSessionProvider(
|
|
576
|
+
session, exchange=session.renewal.exchange, now=session.renewal.now
|
|
577
|
+
)
|
|
525
578
|
|
|
526
579
|
|
|
527
580
|
#: What a stated refusal code has to look like before it is interpolated into one.
|
|
@@ -960,6 +1013,7 @@ __all__ = [
|
|
|
960
1013
|
"USAGE_LIMIT_SCHEMA",
|
|
961
1014
|
"CloudClient",
|
|
962
1015
|
"CloudRouteAbsent",
|
|
1016
|
+
"SessionRenewal",
|
|
963
1017
|
"StudioSession",
|
|
964
1018
|
"StudioSessionProvider",
|
|
965
1019
|
"approval_dashboard_url",
|