mostlyright-data 0.9.0__py3-none-any.whl
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_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""The probe vocabulary a research session speaks (ADR 0021).
|
|
2
|
+
|
|
3
|
+
Studio owns the probe *kind* enum and nothing else about a probe. Its contract says so in as many
|
|
4
|
+
words: a probe request and a probe result are "opaque to Studio", size-bounded, digested, and
|
|
5
|
+
stored uninterpreted, "so the harness can add a probe parameter or a result field without a Studio
|
|
6
|
+
contract change". This module is the other half of that arrangement -- the place where the bodies
|
|
7
|
+
under those four kinds are actually specified, so that "opaque to Studio" does not degrade into
|
|
8
|
+
"unspecified anywhere".
|
|
9
|
+
|
|
10
|
+
**Why the kind enum is not restated as a choice.** ``research-session.schema.json`` closes
|
|
11
|
+
``probe_kind`` to four values. Restating them here as a *harness* decision would invite the two
|
|
12
|
+
spellings to drift; :data:`PROBE_KINDS` is therefore the same four, and
|
|
13
|
+
``tests/test_session_probes.py`` asserts it equals the generated ``ContractProbeKind`` enum. What
|
|
14
|
+
the harness decides is the body, and only the body.
|
|
15
|
+
|
|
16
|
+
**Why the schemas are data and the validators are code.** Every schema below is a JSON Schema
|
|
17
|
+
object, and every one of them is enforced at runtime by a hand-written validator rather than by a
|
|
18
|
+
schema library. That is not duplication for its own sake: ``jsonschema`` is a development and
|
|
19
|
+
release dependency and is deliberately absent from ``[local]``, which is the only extra the worker
|
|
20
|
+
image installs, so a runtime schema check would be a runtime import the image cannot satisfy. The
|
|
21
|
+
schemas are the statement, the validators are the enforcement, and
|
|
22
|
+
``test_session_probes.py`` runs both over the same corpus and refuses any input the two disagree
|
|
23
|
+
about. A drifted validator is a red test rather than a silently widened probe.
|
|
24
|
+
|
|
25
|
+
**Why every request names a source.** All four kinds carry ``source_id``, including
|
|
26
|
+
``evaluate_expression``. It costs one field and buys two things. Probe progress can then be emitted
|
|
27
|
+
as the ``source_probe_started`` / ``source_probe_settled`` pair -- which travel as Studio's
|
|
28
|
+
``attempt_progress`` and ``source_probe_completed`` event types, exactly the two the
|
|
29
|
+
research-session design names -- without mislabelling a probe id as a source id. And the session's
|
|
30
|
+
``context.source_ids`` becomes an enforceable allowlist: a probe that names a source the session
|
|
31
|
+
was not opened against is refused before anything is read, which is the whole of a probe's
|
|
32
|
+
authority check.
|
|
33
|
+
|
|
34
|
+
**Why expressions are an AST and never a string.** ``evaluate_expression`` is the one kind a caller
|
|
35
|
+
could reasonably expect to accept a little language. It does not. The request carries a closed,
|
|
36
|
+
bounded, non-recursive-by-construction operator tree; there is no parser, no ``eval``, no
|
|
37
|
+
comprehension over caller text, and no operator whose cost is superlinear in anything the caller
|
|
38
|
+
controls. A probe body arrives from Studio having been stored uninterpreted, which means it arrives
|
|
39
|
+
with exactly as much trust as the client that submitted it -- that is to say, none.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import math
|
|
45
|
+
from collections.abc import Mapping, Sequence
|
|
46
|
+
from typing import Any, Final
|
|
47
|
+
|
|
48
|
+
from mostlyright.data_harness.canonical import canonical_json_bytes
|
|
49
|
+
|
|
50
|
+
#: The vocabulary's own version, carried on every result body. It is not Studio's
|
|
51
|
+
#: ``schema_version``: Studio versions the envelope, this versions what the harness puts inside it,
|
|
52
|
+
#: and the point of the arrangement is that this number can move without Studio moving.
|
|
53
|
+
PROBE_SCHEMA_VERSION: Final = "mr-session-probes.v1"
|
|
54
|
+
|
|
55
|
+
#: Studio's closed enum, restated so this module loads without the generated client (the
|
|
56
|
+
#: ``progress_events`` precedent) and asserted equal to it by the test suite.
|
|
57
|
+
PROBE_KINDS: Final[tuple[str, ...]] = (
|
|
58
|
+
"source_inspect",
|
|
59
|
+
"sample_rows",
|
|
60
|
+
"profile_columns",
|
|
61
|
+
"evaluate_expression",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Studio's own bounds on the two opaque bodies, restated because the worker must refuse an
|
|
65
|
+
#: oversized result *before* spending a request on it. Studio enforces these too; a worker that
|
|
66
|
+
#: relied on that alone would learn about the ceiling by being rejected, after the work.
|
|
67
|
+
PROBE_REQUEST_MAX_BYTES: Final = 64 * 1024
|
|
68
|
+
PROBE_RESULT_MAX_BYTES: Final = 256 * 1024
|
|
69
|
+
|
|
70
|
+
#: How many rows any probe may pull back, and how many any probe may look at. The first bounds the
|
|
71
|
+
#: answer, the second bounds the work: a profile over a million rows is not an interactive probe.
|
|
72
|
+
MAX_SAMPLE_ROWS: Final = 200
|
|
73
|
+
MAX_SCAN_ROWS: Final = 50_000
|
|
74
|
+
DEFAULT_SCAN_ROWS: Final = 10_000
|
|
75
|
+
#: A cell is rendered as a short token or a number. A probe answer is a preview, not an export, so
|
|
76
|
+
#: a long string is truncated in the answer rather than the answer being refused.
|
|
77
|
+
MAX_CELL_CHARS: Final = 512
|
|
78
|
+
MAX_PROFILE_COLUMNS: Final = 256
|
|
79
|
+
#: Expression trees are bounded twice: by depth, so nesting cannot be used to build a large tree
|
|
80
|
+
#: from a small body, and by operand count at each node.
|
|
81
|
+
MAX_EXPRESSION_DEPTH: Final = 8
|
|
82
|
+
MAX_EXPRESSION_OPERANDS: Final = 16
|
|
83
|
+
|
|
84
|
+
#: The aggregate an ``evaluate_expression`` probe may compute. Closed, and every member is one
|
|
85
|
+
#: pass over at most ``MAX_SCAN_ROWS`` values.
|
|
86
|
+
AGGREGATES: Final[tuple[str, ...]] = ("count", "sum", "min", "max", "avg")
|
|
87
|
+
|
|
88
|
+
#: The predicate operators a filter may use. Closed, and every member is one comparison between one
|
|
89
|
+
#: cell and one caller literal. Nothing here matches, searches, or concatenates.
|
|
90
|
+
COMPARISONS: Final[tuple[str, ...]] = ("eq", "ne", "lt", "le", "gt", "ge")
|
|
91
|
+
UNARY_TESTS: Final[tuple[str, ...]] = ("is_null", "is_not_null")
|
|
92
|
+
CONNECTIVES: Final[tuple[str, ...]] = ("and", "or", "not")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ProbeVocabularyError(ValueError):
|
|
96
|
+
"""A probe body that is not in the vocabulary. Carries the code the probe fails with."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
99
|
+
super().__init__(f"{code}: {detail}")
|
|
100
|
+
self.code = code
|
|
101
|
+
self.detail = detail
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------------------------------
|
|
105
|
+
# The declared schemas. Documentation and test oracle; never the runtime check -- see the module
|
|
106
|
+
# docstring for why the worker image cannot afford a schema library.
|
|
107
|
+
# ------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
_UUID_PATTERN: Final = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
110
|
+
_COLUMN: Final = {"type": "string", "minLength": 1, "maxLength": 256}
|
|
111
|
+
_SCALAR: Final = {
|
|
112
|
+
"description": "One caller literal. Numbers, booleans, null and short strings only.",
|
|
113
|
+
"type": ["string", "number", "boolean", "null"],
|
|
114
|
+
"maxLength": MAX_CELL_CHARS,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _predicate_schema(depth: int) -> dict[str, Any]:
|
|
119
|
+
"""The predicate grammar, unrolled to :data:`MAX_EXPRESSION_DEPTH`.
|
|
120
|
+
|
|
121
|
+
Unrolled rather than expressed with ``$ref`` back to itself, because a self-referential schema
|
|
122
|
+
states a grammar with no depth bound and this grammar's depth bound is a security property.
|
|
123
|
+
The unrolled form makes the ceiling visible in the schema instead of only in the validator.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
branches: list[dict[str, Any]] = [
|
|
127
|
+
{
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": False,
|
|
130
|
+
"required": ["op", "column", "value"],
|
|
131
|
+
"properties": {
|
|
132
|
+
"op": {"enum": list(COMPARISONS)},
|
|
133
|
+
"column": _COLUMN,
|
|
134
|
+
"value": _SCALAR,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"type": "object",
|
|
139
|
+
"additionalProperties": False,
|
|
140
|
+
"required": ["op", "column"],
|
|
141
|
+
"properties": {"op": {"enum": list(UNARY_TESTS)}, "column": _COLUMN},
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
if depth > 1:
|
|
145
|
+
inner = _predicate_schema(depth - 1)
|
|
146
|
+
branches.append(
|
|
147
|
+
{
|
|
148
|
+
"type": "object",
|
|
149
|
+
"additionalProperties": False,
|
|
150
|
+
"required": ["op", "operands"],
|
|
151
|
+
"properties": {
|
|
152
|
+
"op": {"enum": ["and", "or"]},
|
|
153
|
+
"operands": {
|
|
154
|
+
"type": "array",
|
|
155
|
+
"minItems": 1,
|
|
156
|
+
"maxItems": MAX_EXPRESSION_OPERANDS,
|
|
157
|
+
"items": inner,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
branches.append(
|
|
163
|
+
{
|
|
164
|
+
"type": "object",
|
|
165
|
+
"additionalProperties": False,
|
|
166
|
+
"required": ["op", "operand"],
|
|
167
|
+
"properties": {"op": {"const": "not"}, "operand": inner},
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
return {"oneOf": branches}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
PREDICATE_SCHEMA: Final[dict[str, Any]] = _predicate_schema(MAX_EXPRESSION_DEPTH)
|
|
174
|
+
|
|
175
|
+
PROBE_REQUEST_SCHEMA: Final[Mapping[str, dict[str, Any]]] = {
|
|
176
|
+
"source_inspect": {
|
|
177
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
178
|
+
"title": "source_inspect request",
|
|
179
|
+
"description": (
|
|
180
|
+
"What shape is this source? Reports the detected format, the column names and their "
|
|
181
|
+
"inferred types, and the schema digest -- and no cell values at all, which is what "
|
|
182
|
+
"separates it from sample_rows."
|
|
183
|
+
),
|
|
184
|
+
"type": "object",
|
|
185
|
+
"additionalProperties": False,
|
|
186
|
+
"required": ["source_id"],
|
|
187
|
+
"properties": {
|
|
188
|
+
"source_id": {"type": "string", "pattern": _UUID_PATTERN},
|
|
189
|
+
"scan_rows": {"type": "integer", "minimum": 1, "maximum": MAX_SCAN_ROWS},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
"sample_rows": {
|
|
193
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
194
|
+
"title": "sample_rows request",
|
|
195
|
+
"description": "The first `limit` rows after `offset`, rendered as preview cells.",
|
|
196
|
+
"type": "object",
|
|
197
|
+
"additionalProperties": False,
|
|
198
|
+
"required": ["source_id", "limit"],
|
|
199
|
+
"properties": {
|
|
200
|
+
"source_id": {"type": "string", "pattern": _UUID_PATTERN},
|
|
201
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_SAMPLE_ROWS},
|
|
202
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": MAX_SCAN_ROWS},
|
|
203
|
+
"columns": {
|
|
204
|
+
"description": "Project to these columns. Absent means every column.",
|
|
205
|
+
"type": "array",
|
|
206
|
+
"minItems": 1,
|
|
207
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
208
|
+
"uniqueItems": True,
|
|
209
|
+
"items": _COLUMN,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
"profile_columns": {
|
|
214
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
215
|
+
"title": "profile_columns request",
|
|
216
|
+
"description": (
|
|
217
|
+
"Per-column null counts, distinct counts and extremes over a bounded scan. Distinct "
|
|
218
|
+
"is exact over the scanned rows and is reported with the number scanned, because a "
|
|
219
|
+
"distinct count without its denominator is a number nobody can use."
|
|
220
|
+
),
|
|
221
|
+
"type": "object",
|
|
222
|
+
"additionalProperties": False,
|
|
223
|
+
"required": ["source_id"],
|
|
224
|
+
"properties": {
|
|
225
|
+
"source_id": {"type": "string", "pattern": _UUID_PATTERN},
|
|
226
|
+
"scan_rows": {"type": "integer", "minimum": 1, "maximum": MAX_SCAN_ROWS},
|
|
227
|
+
"columns": {
|
|
228
|
+
"type": "array",
|
|
229
|
+
"minItems": 1,
|
|
230
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
231
|
+
"uniqueItems": True,
|
|
232
|
+
"items": _COLUMN,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
"evaluate_expression": {
|
|
237
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
238
|
+
"title": "evaluate_expression request",
|
|
239
|
+
"description": (
|
|
240
|
+
"One aggregate over an optionally filtered bounded scan. The expression is an "
|
|
241
|
+
"operator tree, never a string: there is no parser here and nothing a caller sends "
|
|
242
|
+
"is ever compiled, evaluated, or matched as a pattern."
|
|
243
|
+
),
|
|
244
|
+
"type": "object",
|
|
245
|
+
"additionalProperties": False,
|
|
246
|
+
"required": ["source_id", "expression"],
|
|
247
|
+
"properties": {
|
|
248
|
+
"source_id": {"type": "string", "pattern": _UUID_PATTERN},
|
|
249
|
+
"scan_rows": {"type": "integer", "minimum": 1, "maximum": MAX_SCAN_ROWS},
|
|
250
|
+
# Two arms, not one with an optional column: `count` counts matched ROWS and takes no
|
|
251
|
+
# column, because `count(column)` would silently mean either rows or non-null values
|
|
252
|
+
# and an exploratory caller has no way to tell which they got. Every other aggregate
|
|
253
|
+
# needs a column and says so here rather than only in the validator.
|
|
254
|
+
"expression": {
|
|
255
|
+
"oneOf": [
|
|
256
|
+
{
|
|
257
|
+
"type": "object",
|
|
258
|
+
"additionalProperties": False,
|
|
259
|
+
"required": ["aggregate"],
|
|
260
|
+
"properties": {
|
|
261
|
+
"aggregate": {"const": "count"},
|
|
262
|
+
"where": PREDICATE_SCHEMA,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"type": "object",
|
|
267
|
+
"additionalProperties": False,
|
|
268
|
+
"required": ["aggregate", "column"],
|
|
269
|
+
"properties": {
|
|
270
|
+
"aggregate": {"enum": [name for name in AGGREGATES if name != "count"]},
|
|
271
|
+
"column": _COLUMN,
|
|
272
|
+
"where": PREDICATE_SCHEMA,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_RESULT_ENVELOPE: Final[dict[str, Any]] = {
|
|
282
|
+
"schema_version": {"const": PROBE_SCHEMA_VERSION},
|
|
283
|
+
"probe_kind": {"enum": list(PROBE_KINDS)},
|
|
284
|
+
"source_id": {"type": "string", "pattern": _UUID_PATTERN},
|
|
285
|
+
"content_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
286
|
+
"content_bytes": {"type": "integer", "minimum": 0},
|
|
287
|
+
"data_format": {"type": "string", "minLength": 1, "maxLength": 64},
|
|
288
|
+
"sandbox_policy_digest": {"type": "string", "minLength": 1, "maxLength": 256},
|
|
289
|
+
"rows_scanned": {"type": "integer", "minimum": 0},
|
|
290
|
+
"truncated": {"type": "boolean"},
|
|
291
|
+
}
|
|
292
|
+
_ENVELOPE_REQUIRED: Final = (
|
|
293
|
+
"schema_version",
|
|
294
|
+
"probe_kind",
|
|
295
|
+
"source_id",
|
|
296
|
+
"content_sha256",
|
|
297
|
+
"content_bytes",
|
|
298
|
+
"data_format",
|
|
299
|
+
"sandbox_policy_digest",
|
|
300
|
+
"rows_scanned",
|
|
301
|
+
"truncated",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
_COLUMN_SHAPE: Final[dict[str, Any]] = {
|
|
305
|
+
"type": "object",
|
|
306
|
+
"additionalProperties": False,
|
|
307
|
+
"required": ["name", "inferred_type"],
|
|
308
|
+
"properties": {
|
|
309
|
+
"name": _COLUMN,
|
|
310
|
+
"inferred_type": {"enum": ["string", "integer", "number", "boolean", "null", "mixed"]},
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
PROBE_RESULT_SCHEMA: Final[Mapping[str, dict[str, Any]]] = {
|
|
315
|
+
"source_inspect": {
|
|
316
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
317
|
+
"type": "object",
|
|
318
|
+
"additionalProperties": False,
|
|
319
|
+
"required": [*_ENVELOPE_REQUIRED, "columns", "schema_digest"],
|
|
320
|
+
"properties": {
|
|
321
|
+
**_RESULT_ENVELOPE,
|
|
322
|
+
"media_type": {"type": "string", "minLength": 1, "maxLength": 256},
|
|
323
|
+
"schema_digest": {"type": "string", "minLength": 1, "maxLength": 256},
|
|
324
|
+
"columns": {
|
|
325
|
+
"type": "array",
|
|
326
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
327
|
+
"items": _COLUMN_SHAPE,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
"sample_rows": {
|
|
332
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
333
|
+
"type": "object",
|
|
334
|
+
"additionalProperties": False,
|
|
335
|
+
"required": [*_ENVELOPE_REQUIRED, "columns", "rows"],
|
|
336
|
+
"properties": {
|
|
337
|
+
**_RESULT_ENVELOPE,
|
|
338
|
+
"columns": {
|
|
339
|
+
"type": "array",
|
|
340
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
341
|
+
"items": _COLUMN,
|
|
342
|
+
},
|
|
343
|
+
"rows": {
|
|
344
|
+
"type": "array",
|
|
345
|
+
"maxItems": MAX_SAMPLE_ROWS,
|
|
346
|
+
"items": {
|
|
347
|
+
"type": "array",
|
|
348
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
349
|
+
"items": {"type": ["string", "number", "boolean", "null"]},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
"profile_columns": {
|
|
355
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
356
|
+
"type": "object",
|
|
357
|
+
"additionalProperties": False,
|
|
358
|
+
"required": [*_ENVELOPE_REQUIRED, "columns"],
|
|
359
|
+
"properties": {
|
|
360
|
+
**_RESULT_ENVELOPE,
|
|
361
|
+
"columns": {
|
|
362
|
+
"type": "array",
|
|
363
|
+
"maxItems": MAX_PROFILE_COLUMNS,
|
|
364
|
+
"items": {
|
|
365
|
+
"type": "object",
|
|
366
|
+
"additionalProperties": False,
|
|
367
|
+
"required": ["name", "inferred_type", "non_null", "nulls", "distinct"],
|
|
368
|
+
"properties": {
|
|
369
|
+
**_COLUMN_SHAPE["properties"],
|
|
370
|
+
"non_null": {"type": "integer", "minimum": 0},
|
|
371
|
+
"nulls": {"type": "integer", "minimum": 0},
|
|
372
|
+
"distinct": {"type": "integer", "minimum": 0},
|
|
373
|
+
"minimum": {"type": ["string", "number", "boolean", "null"]},
|
|
374
|
+
"maximum": {"type": ["string", "number", "boolean", "null"]},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
"evaluate_expression": {
|
|
381
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
382
|
+
"type": "object",
|
|
383
|
+
"additionalProperties": False,
|
|
384
|
+
"required": [*_ENVELOPE_REQUIRED, "aggregate", "value", "rows_matched"],
|
|
385
|
+
"properties": {
|
|
386
|
+
**_RESULT_ENVELOPE,
|
|
387
|
+
"aggregate": {"enum": list(AGGREGATES)},
|
|
388
|
+
"column": _COLUMN,
|
|
389
|
+
"value": {"type": ["string", "number", "boolean", "null"]},
|
|
390
|
+
"rows_matched": {"type": "integer", "minimum": 0},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------------------------------
|
|
397
|
+
# Runtime validation. Hand written, total, and refusing rather than coercing.
|
|
398
|
+
# ------------------------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _require(condition: bool, code: str, detail: str) -> None:
|
|
402
|
+
if not condition:
|
|
403
|
+
raise ProbeVocabularyError(code, detail)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _object(value: Any, label: str) -> dict[str, Any]:
|
|
407
|
+
_require(
|
|
408
|
+
isinstance(value, dict) and all(isinstance(key, str) for key in value),
|
|
409
|
+
"PROBE_REQUEST_INVALID",
|
|
410
|
+
f"{label} must be an object with string keys",
|
|
411
|
+
)
|
|
412
|
+
return dict(value)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _exact_keys(value: Mapping[str, Any], required: set[str], optional: set[str]) -> None:
|
|
416
|
+
present = set(value)
|
|
417
|
+
missing = sorted(required - present)
|
|
418
|
+
_require(not missing, "PROBE_REQUEST_INVALID", f"missing field(s): {', '.join(missing)}")
|
|
419
|
+
unknown = sorted(present - required - optional)
|
|
420
|
+
_require(not unknown, "PROBE_REQUEST_INVALID", f"unknown field(s): {', '.join(unknown)}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _uuid_text(value: Any, label: str) -> str:
|
|
424
|
+
_require(isinstance(value, str), "PROBE_REQUEST_INVALID", f"{label} must be a string")
|
|
425
|
+
text = str(value)
|
|
426
|
+
parts = text.split("-")
|
|
427
|
+
shape = [8, 4, 4, 4, 12]
|
|
428
|
+
ok = len(parts) == 5 and all(
|
|
429
|
+
len(part) == width and all(character in "0123456789abcdef" for character in part)
|
|
430
|
+
for part, width in zip(parts, shape, strict=True)
|
|
431
|
+
)
|
|
432
|
+
_require(ok, "PROBE_REQUEST_INVALID", f"{label} must be a lowercase UUID")
|
|
433
|
+
return text
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _bounded_int(value: Any, label: str, *, minimum: int, maximum: int) -> int:
|
|
437
|
+
# `type(...) is int` rather than isinstance: bool is a subclass of int, and a caller that sent
|
|
438
|
+
# `true` for a row limit made a mistake that must surface as one.
|
|
439
|
+
_require(type(value) is int, "PROBE_REQUEST_INVALID", f"{label} must be an integer")
|
|
440
|
+
_require(
|
|
441
|
+
minimum <= value <= maximum,
|
|
442
|
+
"PROBE_REQUEST_INVALID",
|
|
443
|
+
f"{label} must be in [{minimum}, {maximum}]",
|
|
444
|
+
)
|
|
445
|
+
return int(value)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _column_name(value: Any, label: str) -> str:
|
|
449
|
+
_require(
|
|
450
|
+
isinstance(value, str) and 1 <= len(value) <= 256,
|
|
451
|
+
"PROBE_REQUEST_INVALID",
|
|
452
|
+
f"{label} must be a string of 1..256 characters",
|
|
453
|
+
)
|
|
454
|
+
return str(value)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _column_list(value: Any, label: str) -> tuple[str, ...]:
|
|
458
|
+
_require(isinstance(value, list), "PROBE_REQUEST_INVALID", f"{label} must be an array")
|
|
459
|
+
_require(
|
|
460
|
+
1 <= len(value) <= MAX_PROFILE_COLUMNS,
|
|
461
|
+
"PROBE_REQUEST_INVALID",
|
|
462
|
+
f"{label} must name between 1 and {MAX_PROFILE_COLUMNS} columns",
|
|
463
|
+
)
|
|
464
|
+
names = tuple(_column_name(item, f"{label} entry") for item in value)
|
|
465
|
+
_require(len(set(names)) == len(names), "PROBE_REQUEST_INVALID", f"{label} repeats a column")
|
|
466
|
+
return names
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _scalar(value: Any, label: str) -> Any:
|
|
470
|
+
if value is None or isinstance(value, bool):
|
|
471
|
+
return value
|
|
472
|
+
if isinstance(value, str):
|
|
473
|
+
_require(
|
|
474
|
+
len(value) <= MAX_CELL_CHARS,
|
|
475
|
+
"PROBE_REQUEST_INVALID",
|
|
476
|
+
f"{label} exceeds {MAX_CELL_CHARS} characters",
|
|
477
|
+
)
|
|
478
|
+
return value
|
|
479
|
+
if type(value) in {int, float}:
|
|
480
|
+
_require(
|
|
481
|
+
not isinstance(value, float) or math.isfinite(value),
|
|
482
|
+
"PROBE_REQUEST_INVALID",
|
|
483
|
+
f"{label} must be a finite number",
|
|
484
|
+
)
|
|
485
|
+
return value
|
|
486
|
+
raise ProbeVocabularyError("PROBE_REQUEST_INVALID", f"{label} is not an admitted literal")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _predicate(value: Any, *, depth: int) -> dict[str, Any]:
|
|
490
|
+
_require(
|
|
491
|
+
depth <= MAX_EXPRESSION_DEPTH,
|
|
492
|
+
"PROBE_EXPRESSION_TOO_DEEP",
|
|
493
|
+
f"predicate nests deeper than {MAX_EXPRESSION_DEPTH}",
|
|
494
|
+
)
|
|
495
|
+
node = _object(value, "predicate")
|
|
496
|
+
operator = node.get("op")
|
|
497
|
+
_require(isinstance(operator, str), "PROBE_REQUEST_INVALID", "predicate op must be a string")
|
|
498
|
+
if operator in COMPARISONS:
|
|
499
|
+
_exact_keys(node, {"op", "column", "value"}, set())
|
|
500
|
+
return {
|
|
501
|
+
"op": operator,
|
|
502
|
+
"column": _column_name(node["column"], "predicate column"),
|
|
503
|
+
"value": _scalar(node["value"], "predicate value"),
|
|
504
|
+
}
|
|
505
|
+
if operator in UNARY_TESTS:
|
|
506
|
+
_exact_keys(node, {"op", "column"}, set())
|
|
507
|
+
return {"op": operator, "column": _column_name(node["column"], "predicate column")}
|
|
508
|
+
if operator in {"and", "or"}:
|
|
509
|
+
_exact_keys(node, {"op", "operands"}, set())
|
|
510
|
+
operands = node["operands"]
|
|
511
|
+
_require(
|
|
512
|
+
isinstance(operands, list) and 1 <= len(operands) <= MAX_EXPRESSION_OPERANDS,
|
|
513
|
+
"PROBE_REQUEST_INVALID",
|
|
514
|
+
f"{operator} takes 1..{MAX_EXPRESSION_OPERANDS} operands",
|
|
515
|
+
)
|
|
516
|
+
return {
|
|
517
|
+
"op": operator,
|
|
518
|
+
"operands": [_predicate(item, depth=depth + 1) for item in operands],
|
|
519
|
+
}
|
|
520
|
+
if operator == "not":
|
|
521
|
+
_exact_keys(node, {"op", "operand"}, set())
|
|
522
|
+
return {"op": "not", "operand": _predicate(node["operand"], depth=depth + 1)}
|
|
523
|
+
raise ProbeVocabularyError(
|
|
524
|
+
"PROBE_REQUEST_INVALID", f"predicate op {operator!r} is not admitted"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def probe_kind(value: Any) -> str:
|
|
529
|
+
"""Admit one Studio probe kind, or refuse with the code the probe fails under."""
|
|
530
|
+
|
|
531
|
+
_require(
|
|
532
|
+
isinstance(value, str) and value in PROBE_KINDS,
|
|
533
|
+
"PROBE_KIND_UNSUPPORTED",
|
|
534
|
+
f"probe kind {value!r} is not in the vocabulary",
|
|
535
|
+
)
|
|
536
|
+
return str(value)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def validate_probe_request(kind: str, request: Any) -> dict[str, Any]:
|
|
540
|
+
"""Admit one probe request body and return it normalized, with defaults filled in.
|
|
541
|
+
|
|
542
|
+
Normalizing here rather than at each use is deliberate: the executor should never see an
|
|
543
|
+
absent ``scan_rows`` and choose a default of its own, because two executors choosing two
|
|
544
|
+
defaults is how a probe stops being reproducible.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
kind = probe_kind(kind)
|
|
548
|
+
body = _object(request, "probe request")
|
|
549
|
+
encoded = canonical_json_bytes(body)
|
|
550
|
+
_require(
|
|
551
|
+
len(encoded) <= PROBE_REQUEST_MAX_BYTES,
|
|
552
|
+
"PROBE_REQUEST_TOO_LARGE",
|
|
553
|
+
f"probe request is {len(encoded)} canonical bytes, over {PROBE_REQUEST_MAX_BYTES}",
|
|
554
|
+
)
|
|
555
|
+
if kind == "source_inspect":
|
|
556
|
+
_exact_keys(body, {"source_id"}, {"scan_rows"})
|
|
557
|
+
return {
|
|
558
|
+
"source_id": _uuid_text(body["source_id"], "source_id"),
|
|
559
|
+
"scan_rows": _bounded_int(
|
|
560
|
+
body.get("scan_rows", DEFAULT_SCAN_ROWS),
|
|
561
|
+
"scan_rows",
|
|
562
|
+
minimum=1,
|
|
563
|
+
maximum=MAX_SCAN_ROWS,
|
|
564
|
+
),
|
|
565
|
+
}
|
|
566
|
+
if kind == "sample_rows":
|
|
567
|
+
_exact_keys(body, {"source_id", "limit"}, {"offset", "columns"})
|
|
568
|
+
normalized: dict[str, Any] = {
|
|
569
|
+
"source_id": _uuid_text(body["source_id"], "source_id"),
|
|
570
|
+
"limit": _bounded_int(body["limit"], "limit", minimum=1, maximum=MAX_SAMPLE_ROWS),
|
|
571
|
+
"offset": _bounded_int(
|
|
572
|
+
body.get("offset", 0), "offset", minimum=0, maximum=MAX_SCAN_ROWS
|
|
573
|
+
),
|
|
574
|
+
}
|
|
575
|
+
if "columns" in body:
|
|
576
|
+
normalized["columns"] = list(_column_list(body["columns"], "columns"))
|
|
577
|
+
return normalized
|
|
578
|
+
if kind == "profile_columns":
|
|
579
|
+
_exact_keys(body, {"source_id"}, {"scan_rows", "columns"})
|
|
580
|
+
profiled: dict[str, Any] = {
|
|
581
|
+
"source_id": _uuid_text(body["source_id"], "source_id"),
|
|
582
|
+
"scan_rows": _bounded_int(
|
|
583
|
+
body.get("scan_rows", DEFAULT_SCAN_ROWS),
|
|
584
|
+
"scan_rows",
|
|
585
|
+
minimum=1,
|
|
586
|
+
maximum=MAX_SCAN_ROWS,
|
|
587
|
+
),
|
|
588
|
+
}
|
|
589
|
+
if "columns" in body:
|
|
590
|
+
profiled["columns"] = list(_column_list(body["columns"], "columns"))
|
|
591
|
+
return profiled
|
|
592
|
+
_exact_keys(body, {"source_id", "expression"}, {"scan_rows"})
|
|
593
|
+
expression = _object(body["expression"], "expression")
|
|
594
|
+
_exact_keys(expression, {"aggregate"}, {"column", "where"})
|
|
595
|
+
aggregate = expression["aggregate"]
|
|
596
|
+
_require(
|
|
597
|
+
isinstance(aggregate, str) and aggregate in AGGREGATES,
|
|
598
|
+
"PROBE_REQUEST_INVALID",
|
|
599
|
+
f"aggregate {aggregate!r} is not admitted",
|
|
600
|
+
)
|
|
601
|
+
normalized_expression: dict[str, Any] = {"aggregate": str(aggregate)}
|
|
602
|
+
if aggregate == "count":
|
|
603
|
+
# `count` over a column would silently mean one of two different things -- rows, or
|
|
604
|
+
# non-null values in that column -- so it takes no column at all and always means rows.
|
|
605
|
+
_require(
|
|
606
|
+
"column" not in expression,
|
|
607
|
+
"PROBE_REQUEST_INVALID",
|
|
608
|
+
"count takes no column; it counts matched rows",
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
_require(
|
|
612
|
+
"column" in expression,
|
|
613
|
+
"PROBE_REQUEST_INVALID",
|
|
614
|
+
f"{aggregate} requires a column",
|
|
615
|
+
)
|
|
616
|
+
normalized_expression["column"] = _column_name(expression["column"], "expression column")
|
|
617
|
+
if "where" in expression:
|
|
618
|
+
normalized_expression["where"] = _predicate(expression["where"], depth=1)
|
|
619
|
+
return {
|
|
620
|
+
"source_id": _uuid_text(body["source_id"], "source_id"),
|
|
621
|
+
"scan_rows": _bounded_int(
|
|
622
|
+
body.get("scan_rows", DEFAULT_SCAN_ROWS), "scan_rows", minimum=1, maximum=MAX_SCAN_ROWS
|
|
623
|
+
),
|
|
624
|
+
"expression": normalized_expression,
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def validate_probe_result(kind: str, result: Any) -> dict[str, Any]:
|
|
629
|
+
"""Admit one probe result body before it is sent, so an over-budget answer is ours to refuse.
|
|
630
|
+
|
|
631
|
+
Studio bounds the result too. Checking here as well is not belt-and-braces: an answer refused
|
|
632
|
+
by Studio has already cost the probe its one settlement, and the probe cannot be answered
|
|
633
|
+
again. Refusing locally converts that into an ordinary ``failed`` outcome with a code.
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
kind = probe_kind(kind)
|
|
637
|
+
body = _object(result, "probe result")
|
|
638
|
+
encoded = canonical_json_bytes(body)
|
|
639
|
+
_require(
|
|
640
|
+
len(encoded) <= PROBE_RESULT_MAX_BYTES,
|
|
641
|
+
"PROBE_RESULT_TOO_LARGE",
|
|
642
|
+
f"probe result is {len(encoded)} canonical bytes, over {PROBE_RESULT_MAX_BYTES}",
|
|
643
|
+
)
|
|
644
|
+
schema = PROBE_RESULT_SCHEMA[kind]
|
|
645
|
+
required = set(schema["required"])
|
|
646
|
+
optional = set(schema["properties"]) - required
|
|
647
|
+
_exact_keys(body, required, optional)
|
|
648
|
+
_require(
|
|
649
|
+
body["schema_version"] == PROBE_SCHEMA_VERSION,
|
|
650
|
+
"PROBE_RESULT_INVALID",
|
|
651
|
+
"probe result schema_version is not this vocabulary",
|
|
652
|
+
)
|
|
653
|
+
_require(
|
|
654
|
+
body["probe_kind"] == kind,
|
|
655
|
+
"PROBE_RESULT_INVALID",
|
|
656
|
+
"probe result names a different kind than the probe",
|
|
657
|
+
)
|
|
658
|
+
return body
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def preview_cell(value: Any) -> Any:
|
|
662
|
+
"""Render one parsed cell as a JSON-safe preview value.
|
|
663
|
+
|
|
664
|
+
A parsed cell can be a decimal, a date, or a very long string; a probe answer is a preview and
|
|
665
|
+
must be small, canonical-JSON encodable, and finite. Anything that is not already an admitted
|
|
666
|
+
literal is rendered as its ``str`` and truncated, and truncation is visible in the string
|
|
667
|
+
rather than silent, because a preview that lies about being complete is worse than no preview.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
if value is None or isinstance(value, bool):
|
|
671
|
+
return value
|
|
672
|
+
if type(value) is int:
|
|
673
|
+
# Canonical JSON refuses integers outside the exactly representable range; a count that
|
|
674
|
+
# cannot survive the wire is reported as text rather than rounded.
|
|
675
|
+
return value if abs(value) <= 2**53 - 1 else f"{value}"[:MAX_CELL_CHARS]
|
|
676
|
+
if type(value) is float:
|
|
677
|
+
return value if math.isfinite(value) else None
|
|
678
|
+
text = value if isinstance(value, str) else str(value)
|
|
679
|
+
if len(text) > MAX_CELL_CHARS:
|
|
680
|
+
return text[: MAX_CELL_CHARS - 1] + "…"
|
|
681
|
+
return text
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def inferred_type(values: Sequence[Any]) -> str:
|
|
685
|
+
"""Name the one type a column's scanned values share, or ``mixed``/``null``."""
|
|
686
|
+
|
|
687
|
+
seen: set[str] = set()
|
|
688
|
+
for value in values:
|
|
689
|
+
if value is None:
|
|
690
|
+
continue
|
|
691
|
+
if isinstance(value, bool):
|
|
692
|
+
seen.add("boolean")
|
|
693
|
+
elif type(value) is int:
|
|
694
|
+
seen.add("integer")
|
|
695
|
+
elif type(value) is float:
|
|
696
|
+
seen.add("number")
|
|
697
|
+
elif isinstance(value, str):
|
|
698
|
+
seen.add("string")
|
|
699
|
+
else:
|
|
700
|
+
seen.add("string")
|
|
701
|
+
if len(seen) > 1:
|
|
702
|
+
return "mixed"
|
|
703
|
+
if not seen:
|
|
704
|
+
return "null"
|
|
705
|
+
return seen.pop()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _compare(cell: Any, literal: Any) -> int | None:
|
|
709
|
+
"""Order one cell against one literal, or ``None`` when the two are not comparable.
|
|
710
|
+
|
|
711
|
+
Incomparable is a third answer rather than an exception or a coerced ``False``: a predicate
|
|
712
|
+
over a mixed column must exclude the rows it cannot judge, and must not decide that a string
|
|
713
|
+
is less than a number on the strength of Python's willingness to say so (it is not, and in
|
|
714
|
+
Python 3 it raises).
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
if cell is None or literal is None:
|
|
718
|
+
return None
|
|
719
|
+
if isinstance(cell, bool) != isinstance(literal, bool):
|
|
720
|
+
return None
|
|
721
|
+
if isinstance(cell, bool):
|
|
722
|
+
return (cell > literal) - (cell < literal)
|
|
723
|
+
numeric = (int, float)
|
|
724
|
+
if type(cell) in numeric and type(literal) in numeric:
|
|
725
|
+
return (cell > literal) - (cell < literal)
|
|
726
|
+
if isinstance(cell, str) and isinstance(literal, str):
|
|
727
|
+
return (cell > literal) - (cell < literal)
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def evaluate_predicate(predicate: Mapping[str, Any], row: Mapping[str, Any]) -> bool:
|
|
732
|
+
"""Decide one validated predicate against one row. Total, and never raises on data."""
|
|
733
|
+
|
|
734
|
+
operator = predicate["op"]
|
|
735
|
+
if operator in {"and", "or"}:
|
|
736
|
+
results = (evaluate_predicate(item, row) for item in predicate["operands"])
|
|
737
|
+
return all(results) if operator == "and" else any(results)
|
|
738
|
+
if operator == "not":
|
|
739
|
+
return not evaluate_predicate(predicate["operand"], row)
|
|
740
|
+
# A predicate naming a column the source does not have is False for every row rather than an
|
|
741
|
+
# error. The column list is already in the source_inspect answer, so a caller that wants the
|
|
742
|
+
# difference between "no such column" and "no matching row" can see it there; failing the
|
|
743
|
+
# whole probe instead would make an exploratory typo cost a round trip.
|
|
744
|
+
cell = row.get(predicate["column"])
|
|
745
|
+
if operator == "is_null":
|
|
746
|
+
return cell is None
|
|
747
|
+
if operator == "is_not_null":
|
|
748
|
+
return cell is not None
|
|
749
|
+
order = _compare(cell, predicate["value"])
|
|
750
|
+
if order is None:
|
|
751
|
+
return False
|
|
752
|
+
return {
|
|
753
|
+
"eq": order == 0,
|
|
754
|
+
"ne": order != 0,
|
|
755
|
+
"lt": order < 0,
|
|
756
|
+
"le": order <= 0,
|
|
757
|
+
"gt": order > 0,
|
|
758
|
+
"ge": order >= 0,
|
|
759
|
+
}[operator]
|