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,384 @@
|
|
|
1
|
+
"""Exact bounded before/after table profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
13
|
+
from mostlyright.data_harness.preparation.contracts import PROFILE_VERSION
|
|
14
|
+
from mostlyright.data_harness.preparation.errors import PreparationError
|
|
15
|
+
from mostlyright.data_harness.preparation.table import TableState, validate_columns
|
|
16
|
+
|
|
17
|
+
MAX_DISTRIBUTION_VALUES = 32
|
|
18
|
+
_DIGEST = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ValueCount:
|
|
23
|
+
value: str
|
|
24
|
+
count: int
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if not isinstance(self.value, str) or len(self.value) > 1_000_000:
|
|
28
|
+
raise PreparationError("PROFILE_VALUE", "profile.distribution.value", "is invalid")
|
|
29
|
+
if type(self.count) is not int or self.count < 1:
|
|
30
|
+
raise PreparationError(
|
|
31
|
+
"PROFILE_COUNT", "profile.distribution.count", "must be positive"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {"value": self.value, "count": self.count}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FieldProfile:
|
|
40
|
+
name: str
|
|
41
|
+
logical_type: str
|
|
42
|
+
physical_type: str
|
|
43
|
+
null_count: int
|
|
44
|
+
non_null_count: int
|
|
45
|
+
distinct_count: int
|
|
46
|
+
minimum: str | None
|
|
47
|
+
maximum: str | None
|
|
48
|
+
minimum_utf8_bytes: int | None
|
|
49
|
+
maximum_utf8_bytes: int | None
|
|
50
|
+
quantiles: tuple[tuple[str, str], ...]
|
|
51
|
+
distribution: tuple[ValueCount, ...]
|
|
52
|
+
other_value_count: int
|
|
53
|
+
outlier_count: int
|
|
54
|
+
outlier_lower_bound: str | None
|
|
55
|
+
outlier_upper_bound: str | None
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
counts = (
|
|
59
|
+
self.null_count,
|
|
60
|
+
self.non_null_count,
|
|
61
|
+
self.distinct_count,
|
|
62
|
+
self.other_value_count,
|
|
63
|
+
self.outlier_count,
|
|
64
|
+
)
|
|
65
|
+
if any(type(value) is not int or value < 0 for value in counts):
|
|
66
|
+
raise PreparationError(
|
|
67
|
+
"PROFILE_COUNT", f"profile.fields.{self.name}", "has invalid counts"
|
|
68
|
+
)
|
|
69
|
+
if self.distinct_count > self.non_null_count:
|
|
70
|
+
raise PreparationError(
|
|
71
|
+
"PROFILE_COUNT",
|
|
72
|
+
f"profile.fields.{self.name}",
|
|
73
|
+
"distinct count exceeds non-null count",
|
|
74
|
+
)
|
|
75
|
+
if (self.minimum is None) != (self.maximum is None):
|
|
76
|
+
raise PreparationError(
|
|
77
|
+
"PROFILE_RANGE",
|
|
78
|
+
f"profile.fields.{self.name}",
|
|
79
|
+
"minimum and maximum must be paired",
|
|
80
|
+
)
|
|
81
|
+
if self.logical_type == "string":
|
|
82
|
+
if (self.minimum_utf8_bytes is None) != (self.maximum_utf8_bytes is None):
|
|
83
|
+
raise PreparationError(
|
|
84
|
+
"PROFILE_STRING_RANGE",
|
|
85
|
+
f"profile.fields.{self.name}",
|
|
86
|
+
"string byte bounds must be paired",
|
|
87
|
+
)
|
|
88
|
+
elif self.minimum_utf8_bytes is not None or self.maximum_utf8_bytes is not None:
|
|
89
|
+
raise PreparationError(
|
|
90
|
+
"PROFILE_STRING_RANGE",
|
|
91
|
+
f"profile.fields.{self.name}",
|
|
92
|
+
"non-string profile cannot carry string byte bounds",
|
|
93
|
+
)
|
|
94
|
+
if tuple(key for key, _ in self.quantiles) not in {
|
|
95
|
+
(),
|
|
96
|
+
("0.25", "0.50", "0.75"),
|
|
97
|
+
}:
|
|
98
|
+
raise PreparationError(
|
|
99
|
+
"PROFILE_QUANTILES",
|
|
100
|
+
f"profile.fields.{self.name}",
|
|
101
|
+
"quantile set/order is not exact",
|
|
102
|
+
)
|
|
103
|
+
if len(self.distribution) > MAX_DISTRIBUTION_VALUES:
|
|
104
|
+
raise PreparationError(
|
|
105
|
+
"PROFILE_DISTRIBUTION",
|
|
106
|
+
f"profile.fields.{self.name}",
|
|
107
|
+
"distribution exceeds its bound",
|
|
108
|
+
)
|
|
109
|
+
distribution_values = tuple(item.value for item in self.distribution)
|
|
110
|
+
if len(set(distribution_values)) != len(distribution_values):
|
|
111
|
+
raise PreparationError(
|
|
112
|
+
"PROFILE_DISTRIBUTION",
|
|
113
|
+
f"profile.fields.{self.name}",
|
|
114
|
+
"distribution contains duplicate values",
|
|
115
|
+
)
|
|
116
|
+
if (self.outlier_lower_bound is None) != (self.outlier_upper_bound is None):
|
|
117
|
+
raise PreparationError(
|
|
118
|
+
"PROFILE_OUTLIER",
|
|
119
|
+
f"profile.fields.{self.name}",
|
|
120
|
+
"outlier bounds must be paired",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"name": self.name,
|
|
126
|
+
"logical_type": self.logical_type,
|
|
127
|
+
"physical_type": self.physical_type,
|
|
128
|
+
"null_count": self.null_count,
|
|
129
|
+
"non_null_count": self.non_null_count,
|
|
130
|
+
"distinct_count": self.distinct_count,
|
|
131
|
+
"minimum": self.minimum,
|
|
132
|
+
"maximum": self.maximum,
|
|
133
|
+
"minimum_utf8_bytes": self.minimum_utf8_bytes,
|
|
134
|
+
"maximum_utf8_bytes": self.maximum_utf8_bytes,
|
|
135
|
+
"quantiles": [{"quantile": key, "value": value} for key, value in self.quantiles],
|
|
136
|
+
"distribution": [item.to_dict() for item in self.distribution],
|
|
137
|
+
"other_value_count": self.other_value_count,
|
|
138
|
+
"outlier_count": self.outlier_count,
|
|
139
|
+
"outlier_lower_bound": self.outlier_lower_bound,
|
|
140
|
+
"outlier_upper_bound": self.outlier_upper_bound,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class TableProfile:
|
|
146
|
+
schema_version: str
|
|
147
|
+
table_id: str
|
|
148
|
+
schema_digest: str
|
|
149
|
+
row_count: int
|
|
150
|
+
column_count: int
|
|
151
|
+
duplicate_row_count: int
|
|
152
|
+
key_columns: tuple[str, ...]
|
|
153
|
+
duplicate_key_count: int
|
|
154
|
+
temporal_coverage: tuple[tuple[str, str | None, str | None], ...]
|
|
155
|
+
fields: tuple[FieldProfile, ...]
|
|
156
|
+
|
|
157
|
+
def __post_init__(self) -> None:
|
|
158
|
+
if self.schema_version != PROFILE_VERSION:
|
|
159
|
+
raise PreparationError("PROFILE_VERSION", "profile.schema_version", "is unsupported")
|
|
160
|
+
if not isinstance(self.table_id, str) or not self.table_id:
|
|
161
|
+
raise PreparationError("PROFILE_TABLE", "profile.table_id", "must be non-empty")
|
|
162
|
+
if not isinstance(self.schema_digest, str) or _DIGEST.fullmatch(self.schema_digest) is None:
|
|
163
|
+
raise PreparationError("PROFILE_DIGEST", "profile.schema_digest", "is invalid")
|
|
164
|
+
counts = (
|
|
165
|
+
self.row_count,
|
|
166
|
+
self.column_count,
|
|
167
|
+
self.duplicate_row_count,
|
|
168
|
+
self.duplicate_key_count,
|
|
169
|
+
)
|
|
170
|
+
if any(type(value) is not int or value < 0 for value in counts):
|
|
171
|
+
raise PreparationError("PROFILE_COUNT", "profile", "contains invalid counts")
|
|
172
|
+
if self.column_count != len(self.fields):
|
|
173
|
+
raise PreparationError(
|
|
174
|
+
"PROFILE_COUNT",
|
|
175
|
+
"profile.column_count",
|
|
176
|
+
"does not equal the field count",
|
|
177
|
+
)
|
|
178
|
+
if self.duplicate_row_count > self.row_count or self.duplicate_key_count > self.row_count:
|
|
179
|
+
raise PreparationError("PROFILE_COUNT", "profile", "duplicate count exceeds row count")
|
|
180
|
+
if any(item.null_count + item.non_null_count != self.row_count for item in self.fields):
|
|
181
|
+
raise PreparationError(
|
|
182
|
+
"PROFILE_COUNT",
|
|
183
|
+
"profile.fields",
|
|
184
|
+
"field counts do not reconcile the table row count",
|
|
185
|
+
)
|
|
186
|
+
field_names = tuple(item.name for item in self.fields)
|
|
187
|
+
if len(set(field_names)) != len(field_names):
|
|
188
|
+
raise PreparationError("PROFILE_FIELDS", "profile.fields", "contains duplicate names")
|
|
189
|
+
if any(column not in field_names for column in self.key_columns):
|
|
190
|
+
raise PreparationError(
|
|
191
|
+
"PROFILE_KEYS", "profile.key_columns", "references unknown fields"
|
|
192
|
+
)
|
|
193
|
+
temporal_names = tuple(item[0] for item in self.temporal_coverage)
|
|
194
|
+
if len(set(temporal_names)) != len(temporal_names) or any(
|
|
195
|
+
column not in field_names for column in temporal_names
|
|
196
|
+
):
|
|
197
|
+
raise PreparationError(
|
|
198
|
+
"PROFILE_TEMPORAL",
|
|
199
|
+
"profile.temporal_coverage",
|
|
200
|
+
"is duplicate or references an unknown field",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def digest(self) -> str:
|
|
205
|
+
return canonical_sha256(self.to_dict())
|
|
206
|
+
|
|
207
|
+
def to_dict(self) -> dict[str, Any]:
|
|
208
|
+
return {
|
|
209
|
+
"schema_version": self.schema_version,
|
|
210
|
+
"table_id": self.table_id,
|
|
211
|
+
"schema_digest": self.schema_digest,
|
|
212
|
+
"row_count": self.row_count,
|
|
213
|
+
"column_count": self.column_count,
|
|
214
|
+
"duplicate_row_count": self.duplicate_row_count,
|
|
215
|
+
"key_columns": list(self.key_columns),
|
|
216
|
+
"duplicate_key_count": self.duplicate_key_count,
|
|
217
|
+
"temporal_coverage": [
|
|
218
|
+
{"column": column, "minimum": minimum, "maximum": maximum}
|
|
219
|
+
for column, minimum, maximum in self.temporal_coverage
|
|
220
|
+
],
|
|
221
|
+
"fields": [field.to_dict() for field in self.fields],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def profile_table(state: TableState, *, key_columns: tuple[str, ...] = ()) -> TableProfile:
|
|
226
|
+
"""Calculate an exact deterministic bounded profile."""
|
|
227
|
+
|
|
228
|
+
key_indexes: tuple[int, ...] = ()
|
|
229
|
+
if key_columns:
|
|
230
|
+
key_indexes = validate_columns(state, key_columns, "profile.key_columns")
|
|
231
|
+
duplicate_rows = sum(
|
|
232
|
+
count - 1 for count in Counter(_row_key(row) for row in state.rows).values()
|
|
233
|
+
)
|
|
234
|
+
duplicate_keys = 0
|
|
235
|
+
if key_indexes:
|
|
236
|
+
duplicate_keys = sum(
|
|
237
|
+
count - 1
|
|
238
|
+
for count in Counter(
|
|
239
|
+
tuple(_value_key(row[index]) for index in key_indexes) for row in state.rows
|
|
240
|
+
).values()
|
|
241
|
+
)
|
|
242
|
+
fields = tuple(
|
|
243
|
+
_profile_field(field.name, field.logical_type, field.physical_type, values)
|
|
244
|
+
for field, values in (
|
|
245
|
+
(field, tuple(row[index] for row in state.rows))
|
|
246
|
+
for index, field in enumerate(state.fields)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
temporal = tuple(
|
|
250
|
+
(profile.name, profile.minimum, profile.maximum)
|
|
251
|
+
for field, profile in zip(state.fields, fields, strict=True)
|
|
252
|
+
if field.temporal_role != "none"
|
|
253
|
+
)
|
|
254
|
+
schema_digest = canonical_sha256([field.to_dict() for field in state.fields])
|
|
255
|
+
return TableProfile(
|
|
256
|
+
schema_version=PROFILE_VERSION,
|
|
257
|
+
table_id=state.table_id,
|
|
258
|
+
schema_digest=schema_digest,
|
|
259
|
+
row_count=len(state.rows),
|
|
260
|
+
column_count=len(state.fields),
|
|
261
|
+
duplicate_row_count=duplicate_rows,
|
|
262
|
+
key_columns=key_columns,
|
|
263
|
+
duplicate_key_count=duplicate_keys,
|
|
264
|
+
temporal_coverage=temporal,
|
|
265
|
+
fields=fields,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _profile_field(
|
|
270
|
+
name: str,
|
|
271
|
+
logical_type: str,
|
|
272
|
+
physical_type: str,
|
|
273
|
+
values: tuple[Any, ...],
|
|
274
|
+
) -> FieldProfile:
|
|
275
|
+
non_null = tuple(value for value in values if value is not None)
|
|
276
|
+
keyed = Counter(_value_key(value) for value in non_null)
|
|
277
|
+
distribution_items = sorted(
|
|
278
|
+
keyed.items(),
|
|
279
|
+
key=lambda item: (-item[1], item[0]),
|
|
280
|
+
)
|
|
281
|
+
distribution = tuple(
|
|
282
|
+
ValueCount(value=key, count=count)
|
|
283
|
+
for key, count in distribution_items[:MAX_DISTRIBUTION_VALUES]
|
|
284
|
+
)
|
|
285
|
+
other = sum(count for _, count in distribution_items[MAX_DISTRIBUTION_VALUES:])
|
|
286
|
+
ordered = sorted(non_null, key=_ordered_key)
|
|
287
|
+
minimum = _evidence_scalar(ordered[0]) if ordered else None
|
|
288
|
+
maximum = _evidence_scalar(ordered[-1]) if ordered else None
|
|
289
|
+
minimum_bytes: int | None = None
|
|
290
|
+
maximum_bytes: int | None = None
|
|
291
|
+
if logical_type == "string" and non_null:
|
|
292
|
+
sizes = tuple(len(value.encode("utf-8")) for value in non_null)
|
|
293
|
+
minimum_bytes = min(sizes)
|
|
294
|
+
maximum_bytes = max(sizes)
|
|
295
|
+
quantiles: tuple[tuple[str, str], ...] = ()
|
|
296
|
+
outlier_count = 0
|
|
297
|
+
lower: str | None = None
|
|
298
|
+
upper: str | None = None
|
|
299
|
+
if logical_type in {"int64", "float64"} and non_null:
|
|
300
|
+
numeric = sorted(_decimal(value) for value in non_null)
|
|
301
|
+
q1 = _nearest_rank(numeric, 1, 4)
|
|
302
|
+
q2 = _nearest_rank(numeric, 1, 2)
|
|
303
|
+
q3 = _nearest_rank(numeric, 3, 4)
|
|
304
|
+
quantiles = (
|
|
305
|
+
("0.25", _decimal_text(q1)),
|
|
306
|
+
("0.50", _decimal_text(q2)),
|
|
307
|
+
("0.75", _decimal_text(q3)),
|
|
308
|
+
)
|
|
309
|
+
iqr = q3 - q1
|
|
310
|
+
lower_value = q1 - (iqr * Decimal(3) / Decimal(2))
|
|
311
|
+
upper_value = q3 + (iqr * Decimal(3) / Decimal(2))
|
|
312
|
+
lower = _decimal_text(lower_value)
|
|
313
|
+
upper = _decimal_text(upper_value)
|
|
314
|
+
outlier_count = sum(value < lower_value or value > upper_value for value in numeric)
|
|
315
|
+
return FieldProfile(
|
|
316
|
+
name=name,
|
|
317
|
+
logical_type=logical_type,
|
|
318
|
+
physical_type=physical_type,
|
|
319
|
+
null_count=len(values) - len(non_null),
|
|
320
|
+
non_null_count=len(non_null),
|
|
321
|
+
distinct_count=len(keyed),
|
|
322
|
+
minimum=minimum,
|
|
323
|
+
maximum=maximum,
|
|
324
|
+
minimum_utf8_bytes=minimum_bytes,
|
|
325
|
+
maximum_utf8_bytes=maximum_bytes,
|
|
326
|
+
quantiles=quantiles,
|
|
327
|
+
distribution=distribution,
|
|
328
|
+
other_value_count=other,
|
|
329
|
+
outlier_count=outlier_count,
|
|
330
|
+
outlier_lower_bound=lower,
|
|
331
|
+
outlier_upper_bound=upper,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _nearest_rank(values: list[Decimal], numerator: int, denominator: int) -> Decimal:
|
|
336
|
+
index = ((len(values) - 1) * numerator) // denominator
|
|
337
|
+
return values[index]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _decimal(value: Any) -> Decimal:
|
|
341
|
+
if type(value) is int:
|
|
342
|
+
return Decimal(value)
|
|
343
|
+
if type(value) is float and math.isfinite(value):
|
|
344
|
+
return Decimal(str(value))
|
|
345
|
+
raise PreparationError("PROFILE_NUMERIC", "profile", "encountered a non-finite numeric value")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _decimal_text(value: Decimal) -> str:
|
|
349
|
+
normalized = value.normalize()
|
|
350
|
+
text = format(normalized, "f")
|
|
351
|
+
if "." in text:
|
|
352
|
+
text = text.rstrip("0").rstrip(".")
|
|
353
|
+
return "0" if text in {"", "-0"} else text
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _evidence_scalar(value: Any) -> str:
|
|
357
|
+
if value is None:
|
|
358
|
+
return "null"
|
|
359
|
+
if type(value) is bool:
|
|
360
|
+
return "true" if value else "false"
|
|
361
|
+
if type(value) is int:
|
|
362
|
+
return str(value)
|
|
363
|
+
if type(value) is float:
|
|
364
|
+
return _decimal_text(_decimal(value))
|
|
365
|
+
return value
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _value_key(value: Any) -> str:
|
|
369
|
+
return f"{type(value).__name__}:{_evidence_scalar(value)}"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _ordered_key(value: Any) -> tuple[int, Any]:
|
|
373
|
+
if type(value) is bool:
|
|
374
|
+
return (0, value)
|
|
375
|
+
if type(value) in {int, float}:
|
|
376
|
+
return (1, _decimal(value))
|
|
377
|
+
return (2, value)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _row_key(row: tuple[Any, ...]) -> tuple[str, ...]:
|
|
381
|
+
return tuple(_value_key(value) for value in row)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
__all__ = ["FieldProfile", "TableProfile", "ValueCount", "profile_table"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Immutable in-memory tables at the acquisition-to-preparation boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mostlyright.data_harness.acquisition import ParsedTable
|
|
12
|
+
from mostlyright.data_harness.preparation.contracts import (
|
|
13
|
+
MAX_FIELDS,
|
|
14
|
+
MAX_ROWS,
|
|
15
|
+
ColumnLineage,
|
|
16
|
+
FieldSpec,
|
|
17
|
+
LineageInput,
|
|
18
|
+
parse_utc,
|
|
19
|
+
)
|
|
20
|
+
from mostlyright.data_harness.preparation.errors import PreparationError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TableState:
|
|
25
|
+
"""An immutable typed table with complete source-to-current lineage."""
|
|
26
|
+
|
|
27
|
+
table_id: str
|
|
28
|
+
snapshot_digests: tuple[str, ...]
|
|
29
|
+
fields: tuple[FieldSpec, ...]
|
|
30
|
+
rows: tuple[tuple[Any, ...], ...]
|
|
31
|
+
lineage: tuple[ColumnLineage, ...]
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not isinstance(self.table_id, str) or not self.table_id:
|
|
35
|
+
raise PreparationError("TABLE_ID", "table.table_id", "must be non-empty")
|
|
36
|
+
if (
|
|
37
|
+
not isinstance(self.snapshot_digests, tuple)
|
|
38
|
+
or not self.snapshot_digests
|
|
39
|
+
or tuple(sorted(set(self.snapshot_digests))) != self.snapshot_digests
|
|
40
|
+
):
|
|
41
|
+
raise PreparationError(
|
|
42
|
+
"TABLE_SNAPSHOTS",
|
|
43
|
+
"table.snapshot_digests",
|
|
44
|
+
"must be a non-empty sorted unique tuple",
|
|
45
|
+
)
|
|
46
|
+
if not isinstance(self.fields, tuple) or not 1 <= len(self.fields) <= MAX_FIELDS:
|
|
47
|
+
raise PreparationError("TABLE_FIELDS", "table.fields", "field count is outside bounds")
|
|
48
|
+
names = tuple(field.name for field in self.fields)
|
|
49
|
+
if len(set(names)) != len(names):
|
|
50
|
+
raise PreparationError("TABLE_FIELDS", "table.fields", "field names are not unique")
|
|
51
|
+
if not isinstance(self.rows, tuple) or len(self.rows) > MAX_ROWS:
|
|
52
|
+
raise PreparationError("TABLE_ROWS", "table.rows", "row count is outside bounds")
|
|
53
|
+
for row_index, row in enumerate(self.rows):
|
|
54
|
+
if not isinstance(row, tuple) or len(row) != len(self.fields):
|
|
55
|
+
raise PreparationError(
|
|
56
|
+
"TABLE_ROW_WIDTH",
|
|
57
|
+
f"table.rows[{row_index}]",
|
|
58
|
+
"row width differs from the schema",
|
|
59
|
+
)
|
|
60
|
+
for column_index, (field, value) in enumerate(zip(self.fields, row, strict=True)):
|
|
61
|
+
_validate_cell(value, field, f"table.rows[{row_index}][{column_index}]")
|
|
62
|
+
if not isinstance(self.lineage, tuple) or len(self.lineage) != len(self.fields):
|
|
63
|
+
raise PreparationError(
|
|
64
|
+
"TABLE_LINEAGE",
|
|
65
|
+
"table.lineage",
|
|
66
|
+
"must contain exactly one record per field",
|
|
67
|
+
)
|
|
68
|
+
if tuple(item.output_column for item in self.lineage) != names:
|
|
69
|
+
raise PreparationError(
|
|
70
|
+
"TABLE_LINEAGE",
|
|
71
|
+
"table.lineage",
|
|
72
|
+
"must be ordered exactly like the fields",
|
|
73
|
+
)
|
|
74
|
+
if any(
|
|
75
|
+
item.snapshot_digest not in self.snapshot_digests
|
|
76
|
+
for lineage in self.lineage
|
|
77
|
+
for item in lineage.inputs
|
|
78
|
+
):
|
|
79
|
+
raise PreparationError(
|
|
80
|
+
"TABLE_LINEAGE",
|
|
81
|
+
"table.lineage",
|
|
82
|
+
"references an unbound snapshot digest",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def columns(self) -> tuple[str, ...]:
|
|
87
|
+
return tuple(field.name for field in self.fields)
|
|
88
|
+
|
|
89
|
+
def index(self, column: str) -> int:
|
|
90
|
+
try:
|
|
91
|
+
return self.columns.index(column)
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise PreparationError("TABLE_COLUMN", column, "column does not exist") from None
|
|
94
|
+
|
|
95
|
+
def field(self, column: str) -> FieldSpec:
|
|
96
|
+
return self.fields[self.index(column)]
|
|
97
|
+
|
|
98
|
+
def dictionaries(self) -> tuple[dict[str, Any], ...]:
|
|
99
|
+
return tuple(dict(zip(self.columns, row, strict=True)) for row in self.rows)
|
|
100
|
+
|
|
101
|
+
def replace(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
table_id: str | None = None,
|
|
105
|
+
fields: tuple[FieldSpec, ...] | None = None,
|
|
106
|
+
rows: tuple[tuple[Any, ...], ...] | None = None,
|
|
107
|
+
lineage: tuple[ColumnLineage, ...] | None = None,
|
|
108
|
+
snapshot_digests: tuple[str, ...] | None = None,
|
|
109
|
+
) -> TableState:
|
|
110
|
+
return replace(
|
|
111
|
+
self,
|
|
112
|
+
table_id=self.table_id if table_id is None else table_id,
|
|
113
|
+
fields=self.fields if fields is None else fields,
|
|
114
|
+
rows=self.rows if rows is None else rows,
|
|
115
|
+
lineage=self.lineage if lineage is None else lineage,
|
|
116
|
+
snapshot_digests=(
|
|
117
|
+
self.snapshot_digests if snapshot_digests is None else snapshot_digests
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def table_from_parsed(
|
|
123
|
+
*,
|
|
124
|
+
source_id: str,
|
|
125
|
+
parsed: ParsedTable,
|
|
126
|
+
fields: tuple[FieldSpec, ...],
|
|
127
|
+
) -> TableState:
|
|
128
|
+
"""Bind one parsed acquisition snapshot into a typed preparation table."""
|
|
129
|
+
|
|
130
|
+
if not isinstance(parsed, ParsedTable):
|
|
131
|
+
raise PreparationError(
|
|
132
|
+
"TABLE_PARSED", "parsed", "must be verified acquisition ParsedTable evidence"
|
|
133
|
+
)
|
|
134
|
+
if tuple(field.name for field in fields) != parsed.columns:
|
|
135
|
+
raise PreparationError(
|
|
136
|
+
"TABLE_SCHEMA",
|
|
137
|
+
"fields",
|
|
138
|
+
"declared fields do not exactly match parsed columns",
|
|
139
|
+
)
|
|
140
|
+
if any(field.source_id != source_id for field in fields):
|
|
141
|
+
raise PreparationError("TABLE_SOURCE", "fields", "field source IDs do not match")
|
|
142
|
+
lineage = tuple(
|
|
143
|
+
ColumnLineage(
|
|
144
|
+
output_column=field.name,
|
|
145
|
+
inputs=(
|
|
146
|
+
LineageInput(
|
|
147
|
+
source_id=source_id,
|
|
148
|
+
snapshot_digest=parsed.input_sha256,
|
|
149
|
+
column=field.name,
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
operation_ids=(),
|
|
153
|
+
)
|
|
154
|
+
for field in fields
|
|
155
|
+
)
|
|
156
|
+
return TableState(
|
|
157
|
+
table_id=source_id,
|
|
158
|
+
snapshot_digests=(parsed.input_sha256,),
|
|
159
|
+
fields=fields,
|
|
160
|
+
rows=parsed.rows,
|
|
161
|
+
lineage=lineage,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_columns(state: TableState, columns: Iterable[str], path: str) -> tuple[int, ...]:
|
|
166
|
+
indexes: list[int] = []
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
for column in columns:
|
|
169
|
+
if column in seen:
|
|
170
|
+
raise PreparationError("TABLE_COLUMN", path, "contains duplicate columns")
|
|
171
|
+
seen.add(column)
|
|
172
|
+
indexes.append(state.index(column))
|
|
173
|
+
if not indexes:
|
|
174
|
+
raise PreparationError("TABLE_COLUMN", path, "must contain at least one column")
|
|
175
|
+
return tuple(indexes)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _validate_cell(value: Any, field: FieldSpec, path: str) -> None:
|
|
179
|
+
if value is None:
|
|
180
|
+
if not field.nullable:
|
|
181
|
+
raise PreparationError("TABLE_NULL", path, f"{field.name} is not nullable")
|
|
182
|
+
return
|
|
183
|
+
valid = False
|
|
184
|
+
if field.logical_type == "string":
|
|
185
|
+
valid = (
|
|
186
|
+
isinstance(value, str)
|
|
187
|
+
and len(value.encode("utf-8", errors="surrogatepass")) <= 1_048_576
|
|
188
|
+
and not any(0xD800 <= ord(character) <= 0xDFFF for character in value)
|
|
189
|
+
)
|
|
190
|
+
elif field.logical_type == "int64":
|
|
191
|
+
valid = type(value) is int and -(1 << 63) <= value <= (1 << 63) - 1
|
|
192
|
+
elif field.logical_type == "float64":
|
|
193
|
+
valid = type(value) in {int, float} and math.isfinite(float(value))
|
|
194
|
+
elif field.logical_type == "boolean":
|
|
195
|
+
valid = type(value) is bool
|
|
196
|
+
elif field.logical_type == "date":
|
|
197
|
+
if isinstance(value, str):
|
|
198
|
+
try:
|
|
199
|
+
valid = date.fromisoformat(value).isoformat() == value and len(value) == 10
|
|
200
|
+
except ValueError:
|
|
201
|
+
valid = False
|
|
202
|
+
elif field.logical_type == "timestamp_utc":
|
|
203
|
+
if isinstance(value, str):
|
|
204
|
+
try:
|
|
205
|
+
parse_utc(value, path)
|
|
206
|
+
valid = True
|
|
207
|
+
except PreparationError:
|
|
208
|
+
valid = False
|
|
209
|
+
if not valid:
|
|
210
|
+
raise PreparationError(
|
|
211
|
+
"TABLE_CELL_TYPE",
|
|
212
|
+
path,
|
|
213
|
+
f"value does not satisfy declared {field.logical_type}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = ["TableState", "table_from_parsed", "validate_columns"]
|