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,1017 @@
|
|
|
1
|
+
"""Closed immutable contracts for deterministic preparation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
12
|
+
from mostlyright.data_harness.local_contracts import LOGICAL_TYPES
|
|
13
|
+
from mostlyright.data_harness.preparation.errors import PreparationError
|
|
14
|
+
|
|
15
|
+
PREPARATION_CONTRACT_VERSION = "preparation.v1"
|
|
16
|
+
PROFILE_VERSION = "table-profile.v1"
|
|
17
|
+
OPERATION_AUDIT_VERSION = "operation-audit.v1"
|
|
18
|
+
JOIN_AUDIT_VERSION = "join-audit.v1"
|
|
19
|
+
TEMPORAL_REPORT_VERSION = "temporal-report.v1"
|
|
20
|
+
TARGET_RATIONALE_VERSION = "target-rationale.v1"
|
|
21
|
+
QUALITY_REPORT_VERSION = "quality-report.v1"
|
|
22
|
+
|
|
23
|
+
# Ordered because the quality report serializes checks in this policy-owned order. Repair derives
|
|
24
|
+
# its canonically sorted rerun inventory from the same immutable mapping.
|
|
25
|
+
QUALITY_CHECK_POLICY = MappingProxyType(
|
|
26
|
+
{
|
|
27
|
+
"schema": ("blocker", "schema.exact.v1"),
|
|
28
|
+
"row_bounds": ("critical", "rows.declared-bounds.v1"),
|
|
29
|
+
"missingness": ("high", "missingness.absolute.v1"),
|
|
30
|
+
"grain": ("critical", "grain.non-null-unique.v1"),
|
|
31
|
+
"duplicates": ("high", "rows.no-exact-duplicates.v1"),
|
|
32
|
+
"join_multiplier": ("critical", "join.maximum-multiplier.v1"),
|
|
33
|
+
"temporal": ("blocker", "temporal.cutoff-and-revision.v1"),
|
|
34
|
+
"outliers": ("high", "outlier.tukey-count.v1"),
|
|
35
|
+
"missing_periods": ("high", "temporal.frequency-gap-count.v1"),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MAX_FIELDS = 1_024
|
|
40
|
+
MAX_ROWS = 1_000_000
|
|
41
|
+
MAX_OPERATIONS = 256
|
|
42
|
+
MAX_JOINS = 32
|
|
43
|
+
MAX_ALIASES = 10_000
|
|
44
|
+
MAX_TEXT = 4_096
|
|
45
|
+
|
|
46
|
+
PHYSICAL_TYPES = {
|
|
47
|
+
"string": "utf8",
|
|
48
|
+
"int64": "int64",
|
|
49
|
+
"float64": "double",
|
|
50
|
+
"boolean": "bool",
|
|
51
|
+
"date": "date32",
|
|
52
|
+
"timestamp_utc": "timestamp[us, tz=UTC]",
|
|
53
|
+
}
|
|
54
|
+
SEMANTIC_TYPES = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"identifier",
|
|
57
|
+
"entity",
|
|
58
|
+
"category",
|
|
59
|
+
"measure",
|
|
60
|
+
"dimension",
|
|
61
|
+
"target",
|
|
62
|
+
"other",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
TEMPORAL_ROLES = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"none",
|
|
68
|
+
"event_time",
|
|
69
|
+
"observed_at",
|
|
70
|
+
"available_at",
|
|
71
|
+
"ingested_at",
|
|
72
|
+
"cutoff",
|
|
73
|
+
"revision_time",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
UNITS = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"none",
|
|
79
|
+
"count",
|
|
80
|
+
"ratio",
|
|
81
|
+
"percent",
|
|
82
|
+
"celsius",
|
|
83
|
+
"fahrenheit",
|
|
84
|
+
"kelvin",
|
|
85
|
+
"meter",
|
|
86
|
+
"kilometer",
|
|
87
|
+
"mile",
|
|
88
|
+
"second",
|
|
89
|
+
"minute",
|
|
90
|
+
"hour",
|
|
91
|
+
"hectopascal",
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
OPERATION_KINDS = frozenset(
|
|
95
|
+
{
|
|
96
|
+
"trim",
|
|
97
|
+
"case_normalize",
|
|
98
|
+
"null_tokens",
|
|
99
|
+
"strict_cast",
|
|
100
|
+
"unit_convert",
|
|
101
|
+
"entity_resolve",
|
|
102
|
+
"rename",
|
|
103
|
+
"project",
|
|
104
|
+
"deduplicate",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
CARDINALITIES = frozenset({"one_to_one", "one_to_many", "many_to_one", "many_to_many"})
|
|
108
|
+
TEMPORAL_FIDELITIES = frozenset({"exact", "reconstructed", "latest_only", "unknown"})
|
|
109
|
+
# The blocking severities only. Deliberately not the same set as
|
|
110
|
+
# ``repair.contracts.SEVERITIES``, which is all five levels; this is the same subset the
|
|
111
|
+
# repair package spells ``BLOCKING_SEVERITIES``. Nothing in ``src`` reads it today.
|
|
112
|
+
BLOCKING_SEVERITIES = frozenset({"blocker", "critical", "high"})
|
|
113
|
+
_IDENT = re.compile(r"\A[A-Za-z][A-Za-z0-9_.-]{0,127}\Z")
|
|
114
|
+
_DIGEST = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
115
|
+
_UTC = re.compile(r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z\Z")
|
|
116
|
+
|
|
117
|
+
_OPERATION_PARAMETERS: dict[str, tuple[str, ...]] = {
|
|
118
|
+
"trim": ("column",),
|
|
119
|
+
"case_normalize": ("column", "mode"),
|
|
120
|
+
"null_tokens": ("column", "tokens"),
|
|
121
|
+
"strict_cast": ("column", "to_type"),
|
|
122
|
+
"unit_convert": ("column", "from_unit", "to_unit"),
|
|
123
|
+
"entity_resolve": ("column", "entity_type", "aliases"),
|
|
124
|
+
"rename": ("mappings",),
|
|
125
|
+
"project": ("columns",),
|
|
126
|
+
"deduplicate": ("keys", "authority"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _text(value: Any, path: str, *, maximum: int = MAX_TEXT) -> str:
|
|
131
|
+
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
132
|
+
raise PreparationError("CONTRACT_TEXT", path, f"must be 1..{maximum} characters")
|
|
133
|
+
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
|
134
|
+
raise PreparationError("CONTRACT_UNICODE", path, "contains an unpaired surrogate")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _identifier(value: Any, path: str) -> str:
|
|
139
|
+
if not isinstance(value, str) or _IDENT.fullmatch(value) is None:
|
|
140
|
+
raise PreparationError("CONTRACT_IDENTIFIER", path, "must be a canonical identifier")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _digest(value: Any, path: str) -> str:
|
|
145
|
+
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
146
|
+
raise PreparationError("CONTRACT_DIGEST", path, "must be a lowercase SHA-256 digest")
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_utc(value: Any, path: str) -> datetime:
|
|
151
|
+
if not isinstance(value, str) or _UTC.fullmatch(value) is None:
|
|
152
|
+
raise PreparationError(
|
|
153
|
+
"CONTRACT_TIMESTAMP",
|
|
154
|
+
path,
|
|
155
|
+
"must be a canonical UTC RFC 3339 timestamp",
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
return datetime.fromisoformat(value[:-1] + "+00:00")
|
|
159
|
+
except ValueError:
|
|
160
|
+
raise PreparationError(
|
|
161
|
+
"CONTRACT_TIMESTAMP",
|
|
162
|
+
path,
|
|
163
|
+
"timestamp is not a real instant",
|
|
164
|
+
) from None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _exact_keys(value: Any, keys: set[str], path: str) -> dict[str, Any]:
|
|
168
|
+
if not isinstance(value, dict) or set(value) != keys:
|
|
169
|
+
raise PreparationError(
|
|
170
|
+
"CONTRACT_FIELDS",
|
|
171
|
+
path,
|
|
172
|
+
f"fields must be exactly {sorted(keys)}",
|
|
173
|
+
)
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _tuple(
|
|
178
|
+
value: Any,
|
|
179
|
+
path: str,
|
|
180
|
+
*,
|
|
181
|
+
minimum: int = 0,
|
|
182
|
+
maximum: int = MAX_FIELDS,
|
|
183
|
+
) -> tuple[Any, ...]:
|
|
184
|
+
if not isinstance(value, tuple) or not minimum <= len(value) <= maximum:
|
|
185
|
+
raise PreparationError(
|
|
186
|
+
"CONTRACT_COLLECTION",
|
|
187
|
+
path,
|
|
188
|
+
f"must be an immutable tuple with {minimum}..{maximum} members",
|
|
189
|
+
)
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _identifiers(
|
|
194
|
+
value: Any,
|
|
195
|
+
path: str,
|
|
196
|
+
*,
|
|
197
|
+
minimum: int = 1,
|
|
198
|
+
maximum: int = MAX_FIELDS,
|
|
199
|
+
) -> tuple[str, ...]:
|
|
200
|
+
values = _tuple(value, path, minimum=minimum, maximum=maximum)
|
|
201
|
+
result = tuple(_identifier(item, f"{path}[{index}]") for index, item in enumerate(values))
|
|
202
|
+
if len(set(result)) != len(result):
|
|
203
|
+
raise PreparationError("CONTRACT_DUPLICATE", path, "contains duplicate identifiers")
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _pairs(
|
|
208
|
+
value: Any,
|
|
209
|
+
path: str,
|
|
210
|
+
*,
|
|
211
|
+
maximum: int,
|
|
212
|
+
require_sorted: bool = True,
|
|
213
|
+
) -> tuple[tuple[str, str], ...]:
|
|
214
|
+
pairs = _tuple(value, path, minimum=1, maximum=maximum)
|
|
215
|
+
result: list[tuple[str, str]] = []
|
|
216
|
+
for index, pair in enumerate(pairs):
|
|
217
|
+
if not isinstance(pair, tuple) or len(pair) != 2:
|
|
218
|
+
raise PreparationError(
|
|
219
|
+
"CONTRACT_PAIR",
|
|
220
|
+
f"{path}[{index}]",
|
|
221
|
+
"must be an immutable two-member tuple",
|
|
222
|
+
)
|
|
223
|
+
left = _text(pair[0], f"{path}[{index}][0]", maximum=256)
|
|
224
|
+
right = _text(pair[1], f"{path}[{index}][1]", maximum=256)
|
|
225
|
+
result.append((left, right))
|
|
226
|
+
if len({left for left, _ in result}) != len(result):
|
|
227
|
+
raise PreparationError("CONTRACT_DUPLICATE", path, "contains duplicate left values")
|
|
228
|
+
if require_sorted and tuple(result) != tuple(sorted(result)):
|
|
229
|
+
raise PreparationError("CONTRACT_ORDER", path, "must be sorted canonically")
|
|
230
|
+
return tuple(result)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class FieldSpec:
|
|
235
|
+
"""Declared physical, logical, semantic, unit, entity, and temporal field state."""
|
|
236
|
+
|
|
237
|
+
name: str
|
|
238
|
+
logical_type: str
|
|
239
|
+
physical_type: str
|
|
240
|
+
semantic_type: str
|
|
241
|
+
unit: str
|
|
242
|
+
entity: str | None
|
|
243
|
+
temporal_role: str
|
|
244
|
+
nullable: bool
|
|
245
|
+
source_id: str
|
|
246
|
+
availability_column: str | None = None
|
|
247
|
+
|
|
248
|
+
def __post_init__(self) -> None:
|
|
249
|
+
_identifier(self.name, "field.name")
|
|
250
|
+
if self.logical_type not in LOGICAL_TYPES:
|
|
251
|
+
raise PreparationError("FIELD_LOGICAL_TYPE", "field.logical_type", "is unsupported")
|
|
252
|
+
if self.physical_type != PHYSICAL_TYPES[self.logical_type]:
|
|
253
|
+
raise PreparationError(
|
|
254
|
+
"FIELD_PHYSICAL_TYPE",
|
|
255
|
+
"field.physical_type",
|
|
256
|
+
"does not match the logical type",
|
|
257
|
+
)
|
|
258
|
+
if self.semantic_type not in SEMANTIC_TYPES:
|
|
259
|
+
raise PreparationError("FIELD_SEMANTIC_TYPE", "field.semantic_type", "is unsupported")
|
|
260
|
+
if self.unit not in UNITS:
|
|
261
|
+
raise PreparationError("FIELD_UNIT", "field.unit", "is unsupported")
|
|
262
|
+
if self.semantic_type == "measure" and self.unit == "none":
|
|
263
|
+
raise PreparationError("FIELD_UNIT", "field.unit", "measure fields require a unit")
|
|
264
|
+
if self.semantic_type != "measure" and self.unit not in {"none", "count"}:
|
|
265
|
+
raise PreparationError(
|
|
266
|
+
"FIELD_UNIT",
|
|
267
|
+
"field.unit",
|
|
268
|
+
"non-measure fields cannot carry a measurement unit",
|
|
269
|
+
)
|
|
270
|
+
if self.entity is not None:
|
|
271
|
+
_identifier(self.entity, "field.entity")
|
|
272
|
+
if self.semantic_type == "entity" and self.entity is None:
|
|
273
|
+
raise PreparationError("FIELD_ENTITY", "field.entity", "entity fields require a type")
|
|
274
|
+
if self.temporal_role not in TEMPORAL_ROLES:
|
|
275
|
+
raise PreparationError("FIELD_TEMPORAL_ROLE", "field.temporal_role", "is unsupported")
|
|
276
|
+
if self.temporal_role != "none" and self.logical_type not in {"date", "timestamp_utc"}:
|
|
277
|
+
raise PreparationError(
|
|
278
|
+
"FIELD_TEMPORAL_TYPE",
|
|
279
|
+
"field.logical_type",
|
|
280
|
+
"temporal roles require date or UTC timestamp values",
|
|
281
|
+
)
|
|
282
|
+
if type(self.nullable) is not bool:
|
|
283
|
+
raise PreparationError("FIELD_NULLABLE", "field.nullable", "must be Boolean")
|
|
284
|
+
_identifier(self.source_id, "field.source_id")
|
|
285
|
+
if self.availability_column is not None:
|
|
286
|
+
_identifier(self.availability_column, "field.availability_column")
|
|
287
|
+
if (
|
|
288
|
+
self.temporal_role == "none"
|
|
289
|
+
and self.semantic_type != "target"
|
|
290
|
+
and self.availability_column is None
|
|
291
|
+
):
|
|
292
|
+
raise PreparationError(
|
|
293
|
+
"FIELD_TEMPORAL_AMBIGUITY",
|
|
294
|
+
"field.availability_column",
|
|
295
|
+
"selected non-temporal fields require explicit availability semantics",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def to_dict(self) -> dict[str, Any]:
|
|
299
|
+
return {
|
|
300
|
+
"name": self.name,
|
|
301
|
+
"logical_type": self.logical_type,
|
|
302
|
+
"physical_type": self.physical_type,
|
|
303
|
+
"semantic_type": self.semantic_type,
|
|
304
|
+
"unit": self.unit,
|
|
305
|
+
"entity": self.entity,
|
|
306
|
+
"temporal_role": self.temporal_role,
|
|
307
|
+
"nullable": self.nullable,
|
|
308
|
+
"source_id": self.source_id,
|
|
309
|
+
"availability_column": self.availability_column,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True)
|
|
314
|
+
class LineageInput:
|
|
315
|
+
source_id: str
|
|
316
|
+
snapshot_digest: str
|
|
317
|
+
column: str
|
|
318
|
+
|
|
319
|
+
def __post_init__(self) -> None:
|
|
320
|
+
_identifier(self.source_id, "lineage.source_id")
|
|
321
|
+
_digest(self.snapshot_digest, "lineage.snapshot_digest")
|
|
322
|
+
_identifier(self.column, "lineage.column")
|
|
323
|
+
|
|
324
|
+
def to_dict(self) -> dict[str, str]:
|
|
325
|
+
return {
|
|
326
|
+
"source_id": self.source_id,
|
|
327
|
+
"snapshot_digest": self.snapshot_digest,
|
|
328
|
+
"column": self.column,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class ColumnLineage:
|
|
334
|
+
output_column: str
|
|
335
|
+
inputs: tuple[LineageInput, ...]
|
|
336
|
+
operation_ids: tuple[str, ...]
|
|
337
|
+
|
|
338
|
+
def __post_init__(self) -> None:
|
|
339
|
+
_identifier(self.output_column, "lineage.output_column")
|
|
340
|
+
_tuple(self.inputs, "lineage.inputs", minimum=1, maximum=MAX_FIELDS)
|
|
341
|
+
if len(set(self.inputs)) != len(self.inputs):
|
|
342
|
+
raise PreparationError("CONTRACT_DUPLICATE", "lineage.inputs", "contains duplicates")
|
|
343
|
+
_identifiers(self.operation_ids, "lineage.operation_ids", minimum=0, maximum=MAX_OPERATIONS)
|
|
344
|
+
|
|
345
|
+
def to_dict(self) -> dict[str, Any]:
|
|
346
|
+
return {
|
|
347
|
+
"output_column": self.output_column,
|
|
348
|
+
"inputs": [item.to_dict() for item in self.inputs],
|
|
349
|
+
"operation_ids": list(self.operation_ids),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass(frozen=True)
|
|
354
|
+
class OperationSpec:
|
|
355
|
+
"""One allowlisted operation with a kind-specific immutable parameter shape."""
|
|
356
|
+
|
|
357
|
+
operation_id: str
|
|
358
|
+
source_id: str
|
|
359
|
+
kind: str
|
|
360
|
+
parameters: tuple[tuple[str, Any], ...]
|
|
361
|
+
|
|
362
|
+
def __post_init__(self) -> None:
|
|
363
|
+
_identifier(self.operation_id, "operation.operation_id")
|
|
364
|
+
_identifier(self.source_id, "operation.source_id")
|
|
365
|
+
if self.kind not in OPERATION_KINDS:
|
|
366
|
+
raise PreparationError("OPERATION_KIND", "operation.kind", "is not allowlisted")
|
|
367
|
+
if not isinstance(self.parameters, tuple):
|
|
368
|
+
raise PreparationError(
|
|
369
|
+
"OPERATION_PARAMETERS",
|
|
370
|
+
"operation.parameters",
|
|
371
|
+
"must be an immutable tuple",
|
|
372
|
+
)
|
|
373
|
+
names = tuple(name for name, _ in self.parameters if isinstance(name, str))
|
|
374
|
+
if len(names) != len(self.parameters) or names != _OPERATION_PARAMETERS[self.kind]:
|
|
375
|
+
raise PreparationError(
|
|
376
|
+
"OPERATION_PARAMETERS",
|
|
377
|
+
"operation.parameters",
|
|
378
|
+
f"{self.kind} parameters must be exactly {_OPERATION_PARAMETERS[self.kind]}",
|
|
379
|
+
)
|
|
380
|
+
values = dict(self.parameters)
|
|
381
|
+
if self.kind in {"trim", "case_normalize", "null_tokens", "strict_cast", "unit_convert"}:
|
|
382
|
+
_identifier(values["column"], "operation.parameters.column")
|
|
383
|
+
if self.kind == "case_normalize" and values["mode"] not in {"lower", "upper", "casefold"}:
|
|
384
|
+
raise PreparationError("OPERATION_MODE", "operation.parameters.mode", "is unsupported")
|
|
385
|
+
if self.kind == "null_tokens":
|
|
386
|
+
tokens = _tuple(
|
|
387
|
+
values["tokens"],
|
|
388
|
+
"operation.parameters.tokens",
|
|
389
|
+
minimum=1,
|
|
390
|
+
maximum=128,
|
|
391
|
+
)
|
|
392
|
+
checked_values: list[str] = []
|
|
393
|
+
for index, item in enumerate(tokens):
|
|
394
|
+
path = f"operation.parameters.tokens[{index}]"
|
|
395
|
+
if not isinstance(item, str) or len(item) > 256:
|
|
396
|
+
raise PreparationError(
|
|
397
|
+
"CONTRACT_TEXT",
|
|
398
|
+
path,
|
|
399
|
+
"must be 0..256 characters",
|
|
400
|
+
)
|
|
401
|
+
if any(0xD800 <= ord(character) <= 0xDFFF for character in item):
|
|
402
|
+
raise PreparationError(
|
|
403
|
+
"CONTRACT_UNICODE",
|
|
404
|
+
path,
|
|
405
|
+
"contains an unpaired surrogate",
|
|
406
|
+
)
|
|
407
|
+
checked_values.append(item)
|
|
408
|
+
checked = tuple(checked_values)
|
|
409
|
+
if checked != tuple(sorted(set(checked))):
|
|
410
|
+
raise PreparationError(
|
|
411
|
+
"OPERATION_TOKENS",
|
|
412
|
+
"operation.parameters.tokens",
|
|
413
|
+
"must be unique and canonically sorted",
|
|
414
|
+
)
|
|
415
|
+
if self.kind == "strict_cast" and values["to_type"] not in LOGICAL_TYPES:
|
|
416
|
+
raise PreparationError(
|
|
417
|
+
"OPERATION_CAST_TYPE",
|
|
418
|
+
"operation.parameters.to_type",
|
|
419
|
+
"is unsupported",
|
|
420
|
+
)
|
|
421
|
+
if self.kind == "unit_convert":
|
|
422
|
+
if values["from_unit"] not in UNITS or values["to_unit"] not in UNITS:
|
|
423
|
+
raise PreparationError(
|
|
424
|
+
"OPERATION_UNIT",
|
|
425
|
+
"operation.parameters",
|
|
426
|
+
"contains an unsupported unit",
|
|
427
|
+
)
|
|
428
|
+
if values["from_unit"] == values["to_unit"]:
|
|
429
|
+
raise PreparationError(
|
|
430
|
+
"OPERATION_UNIT",
|
|
431
|
+
"operation.parameters",
|
|
432
|
+
"source and target units must differ",
|
|
433
|
+
)
|
|
434
|
+
if self.kind == "entity_resolve":
|
|
435
|
+
_identifier(values["column"], "operation.parameters.column")
|
|
436
|
+
_identifier(values["entity_type"], "operation.parameters.entity_type")
|
|
437
|
+
aliases = _pairs(
|
|
438
|
+
values["aliases"],
|
|
439
|
+
"operation.parameters.aliases",
|
|
440
|
+
maximum=MAX_ALIASES,
|
|
441
|
+
)
|
|
442
|
+
canonical_values = {canonical for _, canonical in aliases}
|
|
443
|
+
if any(
|
|
444
|
+
alias in canonical_values and alias != canonical for alias, canonical in aliases
|
|
445
|
+
):
|
|
446
|
+
raise PreparationError(
|
|
447
|
+
"ENTITY_AMBIGUOUS",
|
|
448
|
+
"operation.parameters.aliases",
|
|
449
|
+
"a canonical entity is also mapped to a different entity",
|
|
450
|
+
)
|
|
451
|
+
if self.kind == "rename":
|
|
452
|
+
mappings = _pairs(
|
|
453
|
+
values["mappings"],
|
|
454
|
+
"operation.parameters.mappings",
|
|
455
|
+
maximum=MAX_FIELDS,
|
|
456
|
+
)
|
|
457
|
+
for index, (old, new) in enumerate(mappings):
|
|
458
|
+
_identifier(old, f"operation.parameters.mappings[{index}][0]")
|
|
459
|
+
_identifier(new, f"operation.parameters.mappings[{index}][1]")
|
|
460
|
+
if len({new for _, new in mappings}) != len(mappings):
|
|
461
|
+
raise PreparationError(
|
|
462
|
+
"OPERATION_RENAME",
|
|
463
|
+
"operation.parameters.mappings",
|
|
464
|
+
"target names must be unique",
|
|
465
|
+
)
|
|
466
|
+
if self.kind == "project":
|
|
467
|
+
_identifiers(values["columns"], "operation.parameters.columns")
|
|
468
|
+
if self.kind == "deduplicate":
|
|
469
|
+
_identifiers(values["keys"], "operation.parameters.keys")
|
|
470
|
+
authority = _pairs(
|
|
471
|
+
values["authority"],
|
|
472
|
+
"operation.parameters.authority",
|
|
473
|
+
maximum=MAX_FIELDS,
|
|
474
|
+
require_sorted=False,
|
|
475
|
+
)
|
|
476
|
+
for index, (column, direction) in enumerate(authority):
|
|
477
|
+
_identifier(column, f"operation.parameters.authority[{index}][0]")
|
|
478
|
+
if direction not in {"ascending", "descending"}:
|
|
479
|
+
raise PreparationError(
|
|
480
|
+
"OPERATION_DIRECTION",
|
|
481
|
+
f"operation.parameters.authority[{index}][1]",
|
|
482
|
+
"must be ascending or descending",
|
|
483
|
+
)
|
|
484
|
+
if len({column for column, _ in authority}) != len(authority):
|
|
485
|
+
raise PreparationError(
|
|
486
|
+
"CONTRACT_DUPLICATE",
|
|
487
|
+
"operation.parameters.authority",
|
|
488
|
+
"contains duplicate columns",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def parameter(self, name: str) -> Any:
|
|
492
|
+
return dict(self.parameters)[name]
|
|
493
|
+
|
|
494
|
+
def to_dict(self) -> dict[str, Any]:
|
|
495
|
+
return {
|
|
496
|
+
"operation_id": self.operation_id,
|
|
497
|
+
"source_id": self.source_id,
|
|
498
|
+
"kind": self.kind,
|
|
499
|
+
"parameters": {name: _thaw(value) for name, value in self.parameters},
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def parameters_digest(self) -> str:
|
|
504
|
+
return canonical_sha256(self.to_dict()["parameters"])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@dataclass(frozen=True)
|
|
508
|
+
class EqualityJoinSpec:
|
|
509
|
+
join_id: str
|
|
510
|
+
right_source_id: str
|
|
511
|
+
left_keys: tuple[str, ...]
|
|
512
|
+
right_keys: tuple[str, ...]
|
|
513
|
+
cardinality: str
|
|
514
|
+
suffix: str
|
|
515
|
+
|
|
516
|
+
def __post_init__(self) -> None:
|
|
517
|
+
_identifier(self.join_id, "join.join_id")
|
|
518
|
+
_identifier(self.right_source_id, "join.right_source_id")
|
|
519
|
+
left = _identifiers(self.left_keys, "join.left_keys")
|
|
520
|
+
right = _identifiers(self.right_keys, "join.right_keys")
|
|
521
|
+
if len(left) != len(right):
|
|
522
|
+
raise PreparationError("JOIN_KEYS", "join", "left and right key counts differ")
|
|
523
|
+
if self.cardinality not in CARDINALITIES:
|
|
524
|
+
raise PreparationError("JOIN_CARDINALITY", "join.cardinality", "is unsupported")
|
|
525
|
+
_identifier(self.suffix, "join.suffix")
|
|
526
|
+
|
|
527
|
+
def to_dict(self) -> dict[str, Any]:
|
|
528
|
+
return {
|
|
529
|
+
"kind": "equality_left",
|
|
530
|
+
"join_id": self.join_id,
|
|
531
|
+
"right_source_id": self.right_source_id,
|
|
532
|
+
"left_keys": list(self.left_keys),
|
|
533
|
+
"right_keys": list(self.right_keys),
|
|
534
|
+
"cardinality": self.cardinality,
|
|
535
|
+
"suffix": self.suffix,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@dataclass(frozen=True)
|
|
540
|
+
class AsOfJoinSpec:
|
|
541
|
+
join_id: str
|
|
542
|
+
right_source_id: str
|
|
543
|
+
left_by: tuple[str, ...]
|
|
544
|
+
right_by: tuple[str, ...]
|
|
545
|
+
left_time: str
|
|
546
|
+
left_cutoff: str
|
|
547
|
+
right_time: str
|
|
548
|
+
right_available_at: str
|
|
549
|
+
right_revision_time: str
|
|
550
|
+
direction: str
|
|
551
|
+
tolerance_seconds: int
|
|
552
|
+
revision_policy: str
|
|
553
|
+
question_cutoff: str
|
|
554
|
+
cardinality: str
|
|
555
|
+
suffix: str
|
|
556
|
+
|
|
557
|
+
def __post_init__(self) -> None:
|
|
558
|
+
_identifier(self.join_id, "join.join_id")
|
|
559
|
+
_identifier(self.right_source_id, "join.right_source_id")
|
|
560
|
+
left = _identifiers(self.left_by, "join.left_by")
|
|
561
|
+
right = _identifiers(self.right_by, "join.right_by")
|
|
562
|
+
if len(left) != len(right):
|
|
563
|
+
raise PreparationError("JOIN_KEYS", "join", "partition key counts differ")
|
|
564
|
+
for name in (
|
|
565
|
+
"left_time",
|
|
566
|
+
"left_cutoff",
|
|
567
|
+
"right_time",
|
|
568
|
+
"right_available_at",
|
|
569
|
+
"right_revision_time",
|
|
570
|
+
):
|
|
571
|
+
_identifier(getattr(self, name), f"join.{name}")
|
|
572
|
+
if self.direction != "backward":
|
|
573
|
+
raise PreparationError("JOIN_DIRECTION", "join.direction", "only backward is allowed")
|
|
574
|
+
if type(self.tolerance_seconds) is not int or not 0 <= self.tolerance_seconds <= 31_536_000:
|
|
575
|
+
raise PreparationError(
|
|
576
|
+
"JOIN_TOLERANCE",
|
|
577
|
+
"join.tolerance_seconds",
|
|
578
|
+
"must be an integer between 0 and 31536000",
|
|
579
|
+
)
|
|
580
|
+
if self.revision_policy not in {"latest_available", "earliest_available"}:
|
|
581
|
+
raise PreparationError(
|
|
582
|
+
"JOIN_REVISION_POLICY",
|
|
583
|
+
"join.revision_policy",
|
|
584
|
+
"is unsupported",
|
|
585
|
+
)
|
|
586
|
+
parse_utc(self.question_cutoff, "join.question_cutoff")
|
|
587
|
+
if self.cardinality not in {"one_to_one", "many_to_one"}:
|
|
588
|
+
raise PreparationError(
|
|
589
|
+
"JOIN_CARDINALITY",
|
|
590
|
+
"join.cardinality",
|
|
591
|
+
"as-of joins require one_to_one or many_to_one",
|
|
592
|
+
)
|
|
593
|
+
_identifier(self.suffix, "join.suffix")
|
|
594
|
+
|
|
595
|
+
def to_dict(self) -> dict[str, Any]:
|
|
596
|
+
return {
|
|
597
|
+
"kind": "asof_left",
|
|
598
|
+
"join_id": self.join_id,
|
|
599
|
+
"right_source_id": self.right_source_id,
|
|
600
|
+
"left_by": list(self.left_by),
|
|
601
|
+
"right_by": list(self.right_by),
|
|
602
|
+
"left_time": self.left_time,
|
|
603
|
+
"left_cutoff": self.left_cutoff,
|
|
604
|
+
"right_time": self.right_time,
|
|
605
|
+
"right_available_at": self.right_available_at,
|
|
606
|
+
"right_revision_time": self.right_revision_time,
|
|
607
|
+
"direction": self.direction,
|
|
608
|
+
"tolerance_seconds": self.tolerance_seconds,
|
|
609
|
+
"revision_policy": self.revision_policy,
|
|
610
|
+
"question_cutoff": self.question_cutoff,
|
|
611
|
+
"cardinality": self.cardinality,
|
|
612
|
+
"suffix": self.suffix,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
JoinSpec = EqualityJoinSpec | AsOfJoinSpec
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@dataclass(frozen=True)
|
|
620
|
+
class SourceTemporalEvidence:
|
|
621
|
+
source_id: str
|
|
622
|
+
snapshot_digest: str
|
|
623
|
+
fidelity: str
|
|
624
|
+
retrieval_time: str
|
|
625
|
+
event_time_column: str | None
|
|
626
|
+
available_at_column: str
|
|
627
|
+
revision_time_column: str | None
|
|
628
|
+
|
|
629
|
+
def __post_init__(self) -> None:
|
|
630
|
+
_identifier(self.source_id, "temporal.source_id")
|
|
631
|
+
_digest(self.snapshot_digest, "temporal.snapshot_digest")
|
|
632
|
+
if self.fidelity not in TEMPORAL_FIDELITIES:
|
|
633
|
+
raise PreparationError("TEMPORAL_FIDELITY", "temporal.fidelity", "is unsupported")
|
|
634
|
+
parse_utc(self.retrieval_time, "temporal.retrieval_time")
|
|
635
|
+
if self.event_time_column is not None:
|
|
636
|
+
_identifier(self.event_time_column, "temporal.event_time_column")
|
|
637
|
+
_identifier(self.available_at_column, "temporal.available_at_column")
|
|
638
|
+
if self.revision_time_column is not None:
|
|
639
|
+
_identifier(self.revision_time_column, "temporal.revision_time_column")
|
|
640
|
+
|
|
641
|
+
def to_dict(self) -> dict[str, Any]:
|
|
642
|
+
return {
|
|
643
|
+
"source_id": self.source_id,
|
|
644
|
+
"snapshot_digest": self.snapshot_digest,
|
|
645
|
+
"fidelity": self.fidelity,
|
|
646
|
+
"retrieval_time": self.retrieval_time,
|
|
647
|
+
"event_time_column": self.event_time_column,
|
|
648
|
+
"available_at_column": self.available_at_column,
|
|
649
|
+
"revision_time_column": self.revision_time_column,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@dataclass(frozen=True)
|
|
654
|
+
class TemporalPolicy:
|
|
655
|
+
analysis_kind: str
|
|
656
|
+
question_cutoff: str
|
|
657
|
+
row_cutoff_column: str | None
|
|
658
|
+
|
|
659
|
+
def __post_init__(self) -> None:
|
|
660
|
+
if self.analysis_kind not in {"point_in_time", "current_retrospective"}:
|
|
661
|
+
raise PreparationError(
|
|
662
|
+
"TEMPORAL_ANALYSIS_KIND",
|
|
663
|
+
"temporal.analysis_kind",
|
|
664
|
+
"is unsupported",
|
|
665
|
+
)
|
|
666
|
+
parse_utc(self.question_cutoff, "temporal.question_cutoff")
|
|
667
|
+
if self.row_cutoff_column is not None:
|
|
668
|
+
_identifier(self.row_cutoff_column, "temporal.row_cutoff_column")
|
|
669
|
+
|
|
670
|
+
def to_dict(self) -> dict[str, Any]:
|
|
671
|
+
return {
|
|
672
|
+
"analysis_kind": self.analysis_kind,
|
|
673
|
+
"question_cutoff": self.question_cutoff,
|
|
674
|
+
"row_cutoff_column": self.row_cutoff_column,
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@dataclass(frozen=True)
|
|
679
|
+
class CategoryMapping:
|
|
680
|
+
input_value: str
|
|
681
|
+
output_value: str
|
|
682
|
+
|
|
683
|
+
def __post_init__(self) -> None:
|
|
684
|
+
_text(self.input_value, "target.mapping.input_value", maximum=256)
|
|
685
|
+
_text(self.output_value, "target.mapping.output_value", maximum=256)
|
|
686
|
+
|
|
687
|
+
def to_dict(self) -> dict[str, str]:
|
|
688
|
+
return {"input_value": self.input_value, "output_value": self.output_value}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@dataclass(frozen=True)
|
|
692
|
+
class TargetSpec:
|
|
693
|
+
operation: str
|
|
694
|
+
output_column: str
|
|
695
|
+
source_column: str
|
|
696
|
+
availability_time_column: str
|
|
697
|
+
cutoff_column: str
|
|
698
|
+
source_snapshot_digest: str
|
|
699
|
+
rationale: str
|
|
700
|
+
resolution_rule: str
|
|
701
|
+
horizon_seconds: int
|
|
702
|
+
mapping: tuple[CategoryMapping, ...] = ()
|
|
703
|
+
allow_null: bool = False
|
|
704
|
+
|
|
705
|
+
def __post_init__(self) -> None:
|
|
706
|
+
if self.operation not in {"exact_copy", "categorical_map"}:
|
|
707
|
+
raise PreparationError("TARGET_OPERATION", "target.operation", "is unsupported")
|
|
708
|
+
for name in (
|
|
709
|
+
"output_column",
|
|
710
|
+
"source_column",
|
|
711
|
+
"availability_time_column",
|
|
712
|
+
"cutoff_column",
|
|
713
|
+
):
|
|
714
|
+
_identifier(getattr(self, name), f"target.{name}")
|
|
715
|
+
if self.output_column == self.source_column:
|
|
716
|
+
raise PreparationError("TARGET_COLUMN", "target.output_column", "must be a new column")
|
|
717
|
+
_digest(self.source_snapshot_digest, "target.source_snapshot_digest")
|
|
718
|
+
_text(self.rationale, "target.rationale")
|
|
719
|
+
_text(self.resolution_rule, "target.resolution_rule")
|
|
720
|
+
if type(self.horizon_seconds) is not int or not 0 <= self.horizon_seconds <= 315_360_000:
|
|
721
|
+
raise PreparationError(
|
|
722
|
+
"TARGET_HORIZON",
|
|
723
|
+
"target.horizon_seconds",
|
|
724
|
+
"must be an integer between 0 and 315360000",
|
|
725
|
+
)
|
|
726
|
+
_tuple(self.mapping, "target.mapping", maximum=1_024)
|
|
727
|
+
if self.operation == "exact_copy" and self.mapping:
|
|
728
|
+
raise PreparationError(
|
|
729
|
+
"TARGET_MAPPING",
|
|
730
|
+
"target.mapping",
|
|
731
|
+
"must be empty for exact_copy",
|
|
732
|
+
)
|
|
733
|
+
if self.operation == "categorical_map":
|
|
734
|
+
if not self.mapping:
|
|
735
|
+
raise PreparationError(
|
|
736
|
+
"TARGET_MAPPING",
|
|
737
|
+
"target.mapping",
|
|
738
|
+
"must be non-empty for categorical_map",
|
|
739
|
+
)
|
|
740
|
+
inputs = tuple(item.input_value for item in self.mapping)
|
|
741
|
+
if inputs != tuple(sorted(set(inputs))):
|
|
742
|
+
raise PreparationError(
|
|
743
|
+
"TARGET_MAPPING",
|
|
744
|
+
"target.mapping",
|
|
745
|
+
"must be unique and canonically sorted",
|
|
746
|
+
)
|
|
747
|
+
if type(self.allow_null) is not bool:
|
|
748
|
+
raise PreparationError("TARGET_NULL", "target.allow_null", "must be Boolean")
|
|
749
|
+
|
|
750
|
+
def to_dict(self) -> dict[str, Any]:
|
|
751
|
+
return {
|
|
752
|
+
"operation": self.operation,
|
|
753
|
+
"output_column": self.output_column,
|
|
754
|
+
"source_column": self.source_column,
|
|
755
|
+
"availability_time_column": self.availability_time_column,
|
|
756
|
+
"cutoff_column": self.cutoff_column,
|
|
757
|
+
"source_snapshot_digest": self.source_snapshot_digest,
|
|
758
|
+
"rationale": self.rationale,
|
|
759
|
+
"resolution_rule": self.resolution_rule,
|
|
760
|
+
"horizon_seconds": self.horizon_seconds,
|
|
761
|
+
"mapping": [item.to_dict() for item in self.mapping],
|
|
762
|
+
"allow_null": self.allow_null,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@dataclass(frozen=True)
|
|
767
|
+
class MissingnessLimit:
|
|
768
|
+
column: str
|
|
769
|
+
maximum_nulls: int
|
|
770
|
+
|
|
771
|
+
def __post_init__(self) -> None:
|
|
772
|
+
_identifier(self.column, "quality.missingness.column")
|
|
773
|
+
if type(self.maximum_nulls) is not int or not 0 <= self.maximum_nulls <= MAX_ROWS:
|
|
774
|
+
raise PreparationError(
|
|
775
|
+
"QUALITY_THRESHOLD",
|
|
776
|
+
"quality.missingness.maximum_nulls",
|
|
777
|
+
"is outside the supported range",
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def to_dict(self) -> dict[str, Any]:
|
|
781
|
+
return {"column": self.column, "maximum_nulls": self.maximum_nulls}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@dataclass(frozen=True)
|
|
785
|
+
class QualitySpec:
|
|
786
|
+
expected_fields: tuple[FieldSpec, ...]
|
|
787
|
+
grain: tuple[str, ...]
|
|
788
|
+
missingness: tuple[MissingnessLimit, ...]
|
|
789
|
+
maximum_outliers: int
|
|
790
|
+
temporal_column: str | None
|
|
791
|
+
expected_frequency: str
|
|
792
|
+
maximum_missing_periods: int
|
|
793
|
+
maximum_join_multiplier_numerator: int
|
|
794
|
+
maximum_join_multiplier_denominator: int
|
|
795
|
+
minimum_rows: int
|
|
796
|
+
maximum_rows: int
|
|
797
|
+
|
|
798
|
+
def __post_init__(self) -> None:
|
|
799
|
+
_tuple(self.expected_fields, "quality.expected_fields", minimum=1, maximum=MAX_FIELDS)
|
|
800
|
+
names = tuple(field.name for field in self.expected_fields)
|
|
801
|
+
if len(set(names)) != len(names):
|
|
802
|
+
raise PreparationError(
|
|
803
|
+
"CONTRACT_DUPLICATE",
|
|
804
|
+
"quality.expected_fields",
|
|
805
|
+
"duplicates names",
|
|
806
|
+
)
|
|
807
|
+
_identifiers(self.grain, "quality.grain")
|
|
808
|
+
_tuple(self.missingness, "quality.missingness", maximum=MAX_FIELDS)
|
|
809
|
+
missing_columns = tuple(item.column for item in self.missingness)
|
|
810
|
+
if missing_columns != tuple(sorted(set(missing_columns))):
|
|
811
|
+
raise PreparationError(
|
|
812
|
+
"CONTRACT_ORDER",
|
|
813
|
+
"quality.missingness",
|
|
814
|
+
"must be unique and sorted by column",
|
|
815
|
+
)
|
|
816
|
+
if any(column not in names for column in (*self.grain, *missing_columns)):
|
|
817
|
+
raise PreparationError("QUALITY_REFERENCE", "quality", "references an unknown column")
|
|
818
|
+
if type(self.maximum_outliers) is not int or not 0 <= self.maximum_outliers <= MAX_ROWS:
|
|
819
|
+
raise PreparationError("QUALITY_THRESHOLD", "quality.maximum_outliers", "is invalid")
|
|
820
|
+
if self.temporal_column is not None and self.temporal_column not in names:
|
|
821
|
+
raise PreparationError("QUALITY_REFERENCE", "quality.temporal_column", "is unknown")
|
|
822
|
+
if self.expected_frequency not in {"none", "daily", "hourly"}:
|
|
823
|
+
raise PreparationError(
|
|
824
|
+
"QUALITY_FREQUENCY",
|
|
825
|
+
"quality.expected_frequency",
|
|
826
|
+
"is unsupported",
|
|
827
|
+
)
|
|
828
|
+
if (self.temporal_column is None) != (self.expected_frequency == "none"):
|
|
829
|
+
raise PreparationError(
|
|
830
|
+
"QUALITY_FREQUENCY",
|
|
831
|
+
"quality",
|
|
832
|
+
"temporal column and frequency must be declared together",
|
|
833
|
+
)
|
|
834
|
+
for name in (
|
|
835
|
+
"maximum_missing_periods",
|
|
836
|
+
"maximum_join_multiplier_numerator",
|
|
837
|
+
"maximum_join_multiplier_denominator",
|
|
838
|
+
"minimum_rows",
|
|
839
|
+
"maximum_rows",
|
|
840
|
+
):
|
|
841
|
+
value = getattr(self, name)
|
|
842
|
+
minimum = 1 if name == "maximum_join_multiplier_denominator" else 0
|
|
843
|
+
if type(value) is not int or not minimum <= value <= MAX_ROWS:
|
|
844
|
+
raise PreparationError("QUALITY_THRESHOLD", f"quality.{name}", "is invalid")
|
|
845
|
+
if self.minimum_rows > self.maximum_rows:
|
|
846
|
+
raise PreparationError("QUALITY_THRESHOLD", "quality", "row bounds are reversed")
|
|
847
|
+
|
|
848
|
+
def to_dict(self) -> dict[str, Any]:
|
|
849
|
+
return {
|
|
850
|
+
"expected_fields": [item.to_dict() for item in self.expected_fields],
|
|
851
|
+
"grain": list(self.grain),
|
|
852
|
+
"missingness": [item.to_dict() for item in self.missingness],
|
|
853
|
+
"maximum_outliers": self.maximum_outliers,
|
|
854
|
+
"temporal_column": self.temporal_column,
|
|
855
|
+
"expected_frequency": self.expected_frequency,
|
|
856
|
+
"maximum_missing_periods": self.maximum_missing_periods,
|
|
857
|
+
"maximum_join_multiplier_numerator": self.maximum_join_multiplier_numerator,
|
|
858
|
+
"maximum_join_multiplier_denominator": self.maximum_join_multiplier_denominator,
|
|
859
|
+
"minimum_rows": self.minimum_rows,
|
|
860
|
+
"maximum_rows": self.maximum_rows,
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@dataclass(frozen=True)
|
|
865
|
+
class PreparationPlan:
|
|
866
|
+
contract_version: str
|
|
867
|
+
plan_id: str
|
|
868
|
+
primary_source_id: str
|
|
869
|
+
operations: tuple[OperationSpec, ...]
|
|
870
|
+
joins: tuple[JoinSpec, ...]
|
|
871
|
+
output_columns: tuple[str, ...]
|
|
872
|
+
temporal_evidence: tuple[SourceTemporalEvidence, ...]
|
|
873
|
+
temporal_policy: TemporalPolicy
|
|
874
|
+
target_policy: str
|
|
875
|
+
target: TargetSpec | None
|
|
876
|
+
quality: QualitySpec
|
|
877
|
+
|
|
878
|
+
def __post_init__(self) -> None:
|
|
879
|
+
if self.contract_version != PREPARATION_CONTRACT_VERSION:
|
|
880
|
+
raise PreparationError("PLAN_VERSION", "plan.contract_version", "is unsupported")
|
|
881
|
+
_identifier(self.plan_id, "plan.plan_id")
|
|
882
|
+
_identifier(self.primary_source_id, "plan.primary_source_id")
|
|
883
|
+
_tuple(self.operations, "plan.operations", maximum=MAX_OPERATIONS)
|
|
884
|
+
operation_ids = tuple(item.operation_id for item in self.operations)
|
|
885
|
+
if len(set(operation_ids)) != len(operation_ids):
|
|
886
|
+
raise PreparationError(
|
|
887
|
+
"CONTRACT_DUPLICATE",
|
|
888
|
+
"plan.operations",
|
|
889
|
+
"duplicates operation IDs",
|
|
890
|
+
)
|
|
891
|
+
_tuple(self.joins, "plan.joins", maximum=MAX_JOINS)
|
|
892
|
+
join_ids = tuple(item.join_id for item in self.joins)
|
|
893
|
+
if len(set(join_ids)) != len(join_ids):
|
|
894
|
+
raise PreparationError("CONTRACT_DUPLICATE", "plan.joins", "duplicates join IDs")
|
|
895
|
+
_identifiers(self.output_columns, "plan.output_columns")
|
|
896
|
+
_tuple(
|
|
897
|
+
self.temporal_evidence,
|
|
898
|
+
"plan.temporal_evidence",
|
|
899
|
+
minimum=1,
|
|
900
|
+
maximum=MAX_FIELDS,
|
|
901
|
+
)
|
|
902
|
+
source_ids = tuple(item.source_id for item in self.temporal_evidence)
|
|
903
|
+
if source_ids != tuple(sorted(set(source_ids))):
|
|
904
|
+
raise PreparationError(
|
|
905
|
+
"CONTRACT_ORDER",
|
|
906
|
+
"plan.temporal_evidence",
|
|
907
|
+
"must be unique and sorted by source ID",
|
|
908
|
+
)
|
|
909
|
+
if self.target_policy not in {"none", "justified"}:
|
|
910
|
+
raise PreparationError("TARGET_POLICY", "plan.target_policy", "is unsupported")
|
|
911
|
+
if (self.target_policy == "none") != (self.target is None):
|
|
912
|
+
raise PreparationError(
|
|
913
|
+
"TARGET_POLICY",
|
|
914
|
+
"plan.target",
|
|
915
|
+
"must be absent only when target policy is none",
|
|
916
|
+
)
|
|
917
|
+
if self.target is not None:
|
|
918
|
+
forbidden = set(self.quality.grain)
|
|
919
|
+
for operation in self.operations:
|
|
920
|
+
if operation.kind == "deduplicate":
|
|
921
|
+
forbidden.update(operation.parameter("keys"))
|
|
922
|
+
elif operation.kind not in {"project", "rename"}:
|
|
923
|
+
forbidden.add(operation.parameter("column"))
|
|
924
|
+
for join in self.joins:
|
|
925
|
+
if isinstance(join, EqualityJoinSpec):
|
|
926
|
+
forbidden.update(join.left_keys)
|
|
927
|
+
forbidden.update(join.right_keys)
|
|
928
|
+
else:
|
|
929
|
+
forbidden.update(join.left_by)
|
|
930
|
+
forbidden.update(join.right_by)
|
|
931
|
+
forbidden.update(
|
|
932
|
+
{
|
|
933
|
+
join.left_time,
|
|
934
|
+
join.left_cutoff,
|
|
935
|
+
join.right_time,
|
|
936
|
+
join.right_available_at,
|
|
937
|
+
join.right_revision_time,
|
|
938
|
+
}
|
|
939
|
+
)
|
|
940
|
+
if self.target.source_column in forbidden:
|
|
941
|
+
raise PreparationError(
|
|
942
|
+
"TARGET_ISOLATION",
|
|
943
|
+
"plan.target.source_column",
|
|
944
|
+
"cannot be a grain, join, temporal, or cleaning input",
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
@property
|
|
948
|
+
def digest(self) -> str:
|
|
949
|
+
"""Hash the ``to_dict`` encoding of this plan.
|
|
950
|
+
|
|
951
|
+
Distinct from ``repair.adapters.plan_digest``, which hashes the raw dataclass
|
|
952
|
+
fields instead. ``to_dict`` adds a ``kind`` discriminator to each join and turns
|
|
953
|
+
each operation's ``parameters`` pair-tuple into a mapping, so the two digests never
|
|
954
|
+
agree for the same plan. Use this one wherever a stored digest was produced by
|
|
955
|
+
``PreparationResult`` or the Studio wire boundary.
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
return canonical_sha256(self.to_dict())
|
|
959
|
+
|
|
960
|
+
def to_dict(self) -> dict[str, Any]:
|
|
961
|
+
return {
|
|
962
|
+
"contract_version": self.contract_version,
|
|
963
|
+
"plan_id": self.plan_id,
|
|
964
|
+
"primary_source_id": self.primary_source_id,
|
|
965
|
+
"operations": [item.to_dict() for item in self.operations],
|
|
966
|
+
"joins": [item.to_dict() for item in self.joins],
|
|
967
|
+
"output_columns": list(self.output_columns),
|
|
968
|
+
"temporal_evidence": [item.to_dict() for item in self.temporal_evidence],
|
|
969
|
+
"temporal_policy": self.temporal_policy.to_dict(),
|
|
970
|
+
"target_policy": self.target_policy,
|
|
971
|
+
"target": self.target.to_dict() if self.target is not None else None,
|
|
972
|
+
"quality": self.quality.to_dict(),
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _thaw(value: Any) -> Any:
|
|
977
|
+
"""Convert frozen tuples to lists so a value can be canonically serialized.
|
|
978
|
+
|
|
979
|
+
Both branches return the same thing; the pair-tuple test is vestigial.
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
if isinstance(value, tuple):
|
|
983
|
+
if value and all(isinstance(item, tuple) and len(item) == 2 for item in value):
|
|
984
|
+
return [_thaw(item) for item in value]
|
|
985
|
+
return [_thaw(item) for item in value]
|
|
986
|
+
return value
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
__all__ = [
|
|
990
|
+
"BLOCKING_SEVERITIES",
|
|
991
|
+
"CARDINALITIES",
|
|
992
|
+
"JOIN_AUDIT_VERSION",
|
|
993
|
+
"MAX_FIELDS",
|
|
994
|
+
"MAX_ROWS",
|
|
995
|
+
"OPERATION_AUDIT_VERSION",
|
|
996
|
+
"PHYSICAL_TYPES",
|
|
997
|
+
"PREPARATION_CONTRACT_VERSION",
|
|
998
|
+
"PROFILE_VERSION",
|
|
999
|
+
"QUALITY_REPORT_VERSION",
|
|
1000
|
+
"TARGET_RATIONALE_VERSION",
|
|
1001
|
+
"TEMPORAL_REPORT_VERSION",
|
|
1002
|
+
"AsOfJoinSpec",
|
|
1003
|
+
"CategoryMapping",
|
|
1004
|
+
"ColumnLineage",
|
|
1005
|
+
"EqualityJoinSpec",
|
|
1006
|
+
"FieldSpec",
|
|
1007
|
+
"JoinSpec",
|
|
1008
|
+
"LineageInput",
|
|
1009
|
+
"MissingnessLimit",
|
|
1010
|
+
"OperationSpec",
|
|
1011
|
+
"PreparationPlan",
|
|
1012
|
+
"QualitySpec",
|
|
1013
|
+
"SourceTemporalEvidence",
|
|
1014
|
+
"TargetSpec",
|
|
1015
|
+
"TemporalPolicy",
|
|
1016
|
+
"parse_utc",
|
|
1017
|
+
]
|