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,568 @@
|
|
|
1
|
+
"""Closed deterministic cleaning, normalization, resolution, and projection operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import date
|
|
9
|
+
from decimal import Decimal, InvalidOperation
|
|
10
|
+
from fractions import Fraction
|
|
11
|
+
from functools import cmp_to_key
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mostlyright.data_harness.preparation.contracts import (
|
|
15
|
+
OPERATION_AUDIT_VERSION,
|
|
16
|
+
PHYSICAL_TYPES,
|
|
17
|
+
ColumnLineage,
|
|
18
|
+
OperationSpec,
|
|
19
|
+
parse_utc,
|
|
20
|
+
)
|
|
21
|
+
from mostlyright.data_harness.preparation.errors import PreparationError
|
|
22
|
+
from mostlyright.data_harness.preparation.profile import TableProfile, profile_table
|
|
23
|
+
from mostlyright.data_harness.preparation.table import TableState, validate_columns
|
|
24
|
+
|
|
25
|
+
_ASCII_TRIM = "\t\n\v\f\r "
|
|
26
|
+
_DIGEST = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
27
|
+
_INT64 = re.compile(r"\A-?[0-9]+\Z")
|
|
28
|
+
_FLOAT64 = re.compile(r"\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE]-?[0-9]+)?\Z")
|
|
29
|
+
_UNIT_CONVERSIONS: dict[tuple[str, str], tuple[int, int, int, int]] = {
|
|
30
|
+
("celsius", "fahrenheit"): (9, 5, 32, 1),
|
|
31
|
+
("fahrenheit", "celsius"): (5, 9, -160, 9),
|
|
32
|
+
("celsius", "kelvin"): (1, 1, 27315, 100),
|
|
33
|
+
("kelvin", "celsius"): (1, 1, -27315, 100),
|
|
34
|
+
("meter", "kilometer"): (1, 1000, 0, 1),
|
|
35
|
+
("kilometer", "meter"): (1000, 1, 0, 1),
|
|
36
|
+
("mile", "kilometer"): (25146, 15625, 0, 1),
|
|
37
|
+
("kilometer", "mile"): (15625, 25146, 0, 1),
|
|
38
|
+
("second", "minute"): (1, 60, 0, 1),
|
|
39
|
+
("minute", "second"): (60, 1, 0, 1),
|
|
40
|
+
("minute", "hour"): (1, 60, 0, 1),
|
|
41
|
+
("hour", "minute"): (60, 1, 0, 1),
|
|
42
|
+
("ratio", "percent"): (100, 1, 0, 1),
|
|
43
|
+
("percent", "ratio"): (1, 100, 0, 1),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class OperationAudit:
|
|
49
|
+
schema_version: str
|
|
50
|
+
operation_id: str
|
|
51
|
+
kind: str
|
|
52
|
+
parameters_digest: str
|
|
53
|
+
input_profile_digest: str
|
|
54
|
+
output_profile_digest: str
|
|
55
|
+
input_rows: int
|
|
56
|
+
output_rows: int
|
|
57
|
+
changed_cells: int
|
|
58
|
+
removed_rows: int
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
if self.schema_version != OPERATION_AUDIT_VERSION:
|
|
62
|
+
raise PreparationError("OPERATION_AUDIT_VERSION", "audit.schema_version", "unsupported")
|
|
63
|
+
for name in (
|
|
64
|
+
"parameters_digest",
|
|
65
|
+
"input_profile_digest",
|
|
66
|
+
"output_profile_digest",
|
|
67
|
+
):
|
|
68
|
+
value = getattr(self, name)
|
|
69
|
+
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
70
|
+
raise PreparationError(
|
|
71
|
+
"OPERATION_AUDIT_DIGEST",
|
|
72
|
+
f"audit.{name}",
|
|
73
|
+
"must be a lowercase SHA-256 digest",
|
|
74
|
+
)
|
|
75
|
+
for name in (
|
|
76
|
+
"input_rows",
|
|
77
|
+
"output_rows",
|
|
78
|
+
"changed_cells",
|
|
79
|
+
"removed_rows",
|
|
80
|
+
):
|
|
81
|
+
value = getattr(self, name)
|
|
82
|
+
if type(value) is not int or value < 0:
|
|
83
|
+
raise PreparationError(
|
|
84
|
+
"OPERATION_AUDIT_COUNT",
|
|
85
|
+
f"audit.{name}",
|
|
86
|
+
"must be nonnegative",
|
|
87
|
+
)
|
|
88
|
+
if self.removed_rows != self.input_rows - self.output_rows:
|
|
89
|
+
raise PreparationError(
|
|
90
|
+
"OPERATION_AUDIT_COUNT",
|
|
91
|
+
"audit.removed_rows",
|
|
92
|
+
"does not reconcile input and output rows",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"schema_version": self.schema_version,
|
|
98
|
+
"operation_id": self.operation_id,
|
|
99
|
+
"kind": self.kind,
|
|
100
|
+
"parameters_digest": self.parameters_digest,
|
|
101
|
+
"input_profile_digest": self.input_profile_digest,
|
|
102
|
+
"output_profile_digest": self.output_profile_digest,
|
|
103
|
+
"input_rows": self.input_rows,
|
|
104
|
+
"output_rows": self.output_rows,
|
|
105
|
+
"changed_cells": self.changed_cells,
|
|
106
|
+
"removed_rows": self.removed_rows,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class PreparedSource:
|
|
112
|
+
state: TableState
|
|
113
|
+
before_profile: TableProfile
|
|
114
|
+
after_profile: TableProfile
|
|
115
|
+
operation_audits: tuple[OperationAudit, ...]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_operations(
|
|
119
|
+
state: TableState,
|
|
120
|
+
operations: tuple[OperationSpec, ...],
|
|
121
|
+
) -> PreparedSource:
|
|
122
|
+
"""Apply a fixed ordered operation list and bind every before/after profile."""
|
|
123
|
+
|
|
124
|
+
if not isinstance(operations, tuple):
|
|
125
|
+
raise PreparationError("OPERATION_COLLECTION", "operations", "must be an immutable tuple")
|
|
126
|
+
before = profile_table(state)
|
|
127
|
+
audits: list[OperationAudit] = []
|
|
128
|
+
current = state
|
|
129
|
+
for operation in operations:
|
|
130
|
+
if operation.source_id != state.table_id:
|
|
131
|
+
raise PreparationError(
|
|
132
|
+
"OPERATION_SOURCE",
|
|
133
|
+
operation.operation_id,
|
|
134
|
+
"operation source does not match the prepared source",
|
|
135
|
+
)
|
|
136
|
+
input_profile = profile_table(current)
|
|
137
|
+
output, changed = _apply(current, operation)
|
|
138
|
+
output_profile = profile_table(output)
|
|
139
|
+
audits.append(
|
|
140
|
+
OperationAudit(
|
|
141
|
+
schema_version=OPERATION_AUDIT_VERSION,
|
|
142
|
+
operation_id=operation.operation_id,
|
|
143
|
+
kind=operation.kind,
|
|
144
|
+
parameters_digest=operation.parameters_digest,
|
|
145
|
+
input_profile_digest=input_profile.digest,
|
|
146
|
+
output_profile_digest=output_profile.digest,
|
|
147
|
+
input_rows=len(current.rows),
|
|
148
|
+
output_rows=len(output.rows),
|
|
149
|
+
changed_cells=changed,
|
|
150
|
+
removed_rows=len(current.rows) - len(output.rows),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
current = output
|
|
154
|
+
return PreparedSource(
|
|
155
|
+
state=current,
|
|
156
|
+
before_profile=before,
|
|
157
|
+
after_profile=profile_table(current),
|
|
158
|
+
operation_audits=tuple(audits),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _apply(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
163
|
+
dispatch = {
|
|
164
|
+
"trim": _trim,
|
|
165
|
+
"case_normalize": _case_normalize,
|
|
166
|
+
"null_tokens": _null_tokens,
|
|
167
|
+
"strict_cast": _strict_cast,
|
|
168
|
+
"unit_convert": _unit_convert,
|
|
169
|
+
"entity_resolve": _entity_resolve,
|
|
170
|
+
"rename": _rename,
|
|
171
|
+
"project": _project,
|
|
172
|
+
"deduplicate": _deduplicate,
|
|
173
|
+
}
|
|
174
|
+
return dispatch[operation.kind](state, operation)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _map_column(
|
|
178
|
+
state: TableState,
|
|
179
|
+
operation: OperationSpec,
|
|
180
|
+
transform: Any,
|
|
181
|
+
*,
|
|
182
|
+
field_transform: Any | None = None,
|
|
183
|
+
) -> tuple[TableState, int]:
|
|
184
|
+
column = operation.parameter("column")
|
|
185
|
+
index = state.index(column)
|
|
186
|
+
changed = 0
|
|
187
|
+
rows: list[tuple[Any, ...]] = []
|
|
188
|
+
for row_index, row in enumerate(state.rows):
|
|
189
|
+
value = transform(row[index], row_index)
|
|
190
|
+
if _typed_key(value) != _typed_key(row[index]):
|
|
191
|
+
changed += 1
|
|
192
|
+
mutable = list(row)
|
|
193
|
+
mutable[index] = value
|
|
194
|
+
rows.append(tuple(mutable))
|
|
195
|
+
fields = state.fields
|
|
196
|
+
if field_transform is not None:
|
|
197
|
+
mutable_fields = list(fields)
|
|
198
|
+
mutable_fields[index] = field_transform(fields[index])
|
|
199
|
+
fields = tuple(mutable_fields)
|
|
200
|
+
lineage = _append_operation(state.lineage, (column,), operation.operation_id)
|
|
201
|
+
return state.replace(fields=fields, rows=tuple(rows), lineage=lineage), changed
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _trim(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
205
|
+
field = state.field(operation.parameter("column"))
|
|
206
|
+
if field.logical_type != "string":
|
|
207
|
+
raise PreparationError("OPERATION_TYPE", operation.operation_id, "trim requires string")
|
|
208
|
+
return _map_column(
|
|
209
|
+
state,
|
|
210
|
+
operation,
|
|
211
|
+
lambda value, _row: value.strip(_ASCII_TRIM) if value is not None else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _case_normalize(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
216
|
+
field = state.field(operation.parameter("column"))
|
|
217
|
+
if field.logical_type != "string":
|
|
218
|
+
raise PreparationError(
|
|
219
|
+
"OPERATION_TYPE",
|
|
220
|
+
operation.operation_id,
|
|
221
|
+
"case normalization requires string",
|
|
222
|
+
)
|
|
223
|
+
mode = operation.parameter("mode")
|
|
224
|
+
|
|
225
|
+
def normalize(value: Any, _row: int) -> Any:
|
|
226
|
+
if value is None:
|
|
227
|
+
return None
|
|
228
|
+
if mode == "lower":
|
|
229
|
+
return value.lower()
|
|
230
|
+
if mode == "upper":
|
|
231
|
+
return value.upper()
|
|
232
|
+
return value.casefold()
|
|
233
|
+
|
|
234
|
+
return _map_column(state, operation, normalize)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _null_tokens(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
238
|
+
field = state.field(operation.parameter("column"))
|
|
239
|
+
if field.logical_type != "string":
|
|
240
|
+
raise PreparationError(
|
|
241
|
+
"OPERATION_TYPE",
|
|
242
|
+
operation.operation_id,
|
|
243
|
+
"null tokens require string",
|
|
244
|
+
)
|
|
245
|
+
tokens = frozenset(operation.parameter("tokens"))
|
|
246
|
+
return _map_column(
|
|
247
|
+
state,
|
|
248
|
+
operation,
|
|
249
|
+
lambda value, _row: None if value in tokens else value,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _strict_cast(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
254
|
+
column = operation.parameter("column")
|
|
255
|
+
target = operation.parameter("to_type")
|
|
256
|
+
field = state.field(column)
|
|
257
|
+
if target == field.logical_type:
|
|
258
|
+
raise PreparationError("OPERATION_CAST", operation.operation_id, "cast must change type")
|
|
259
|
+
|
|
260
|
+
def cast(value: Any, row_index: int) -> Any:
|
|
261
|
+
if value is None:
|
|
262
|
+
return None
|
|
263
|
+
path = f"{operation.operation_id}.row[{row_index}]"
|
|
264
|
+
if target == "string":
|
|
265
|
+
if type(value) is bool:
|
|
266
|
+
return "true" if value else "false"
|
|
267
|
+
if type(value) is int:
|
|
268
|
+
return str(value)
|
|
269
|
+
if type(value) is float and math.isfinite(value):
|
|
270
|
+
return format(value, ".17g")
|
|
271
|
+
if isinstance(value, str):
|
|
272
|
+
return value
|
|
273
|
+
if not isinstance(value, str):
|
|
274
|
+
raise PreparationError("CAST_VALUE", path, "cast input must be a canonical string")
|
|
275
|
+
if target == "int64":
|
|
276
|
+
if _INT64.fullmatch(value) is None:
|
|
277
|
+
raise PreparationError("CAST_INT64", path, "value is not strict int64 syntax")
|
|
278
|
+
parsed = int(value)
|
|
279
|
+
if not -(1 << 63) <= parsed <= (1 << 63) - 1:
|
|
280
|
+
raise PreparationError("CAST_INT64", path, "value is outside int64 range")
|
|
281
|
+
return parsed
|
|
282
|
+
if target == "float64":
|
|
283
|
+
if _FLOAT64.fullmatch(value) is None:
|
|
284
|
+
raise PreparationError(
|
|
285
|
+
"CAST_FLOAT64",
|
|
286
|
+
path,
|
|
287
|
+
"value is not strict finite float syntax",
|
|
288
|
+
)
|
|
289
|
+
parsed = float(value)
|
|
290
|
+
if not math.isfinite(parsed):
|
|
291
|
+
raise PreparationError("CAST_FLOAT64", path, "value is not finite")
|
|
292
|
+
return parsed
|
|
293
|
+
if target == "boolean":
|
|
294
|
+
if value not in {"false", "true"}:
|
|
295
|
+
raise PreparationError("CAST_BOOLEAN", path, "value must be true or false")
|
|
296
|
+
return value == "true"
|
|
297
|
+
if target == "date":
|
|
298
|
+
try:
|
|
299
|
+
parsed_date = date.fromisoformat(value)
|
|
300
|
+
except ValueError:
|
|
301
|
+
raise PreparationError("CAST_DATE", path, "value is not a real ISO date") from None
|
|
302
|
+
if parsed_date.isoformat() != value or len(value) != 10:
|
|
303
|
+
raise PreparationError("CAST_DATE", path, "value is not canonical ISO date")
|
|
304
|
+
return value
|
|
305
|
+
if target == "timestamp_utc":
|
|
306
|
+
parse_utc(value, path)
|
|
307
|
+
return value
|
|
308
|
+
raise PreparationError("CAST_VALUE", path, "cast conversion is unsupported")
|
|
309
|
+
|
|
310
|
+
return _map_column(
|
|
311
|
+
state,
|
|
312
|
+
operation,
|
|
313
|
+
cast,
|
|
314
|
+
field_transform=lambda current: replace(
|
|
315
|
+
current,
|
|
316
|
+
logical_type=target,
|
|
317
|
+
physical_type=PHYSICAL_TYPES[target],
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _unit_convert(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
323
|
+
column = operation.parameter("column")
|
|
324
|
+
source = operation.parameter("from_unit")
|
|
325
|
+
target = operation.parameter("to_unit")
|
|
326
|
+
field = state.field(column)
|
|
327
|
+
if field.logical_type not in {"int64", "float64"} or field.semantic_type != "measure":
|
|
328
|
+
raise PreparationError(
|
|
329
|
+
"OPERATION_TYPE",
|
|
330
|
+
operation.operation_id,
|
|
331
|
+
"unit conversion requires a numeric measure",
|
|
332
|
+
)
|
|
333
|
+
if field.unit != source:
|
|
334
|
+
raise PreparationError(
|
|
335
|
+
"OPERATION_UNIT",
|
|
336
|
+
operation.operation_id,
|
|
337
|
+
"declared source unit differs",
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
scale_numerator, scale_denominator, offset_numerator, offset_denominator = (
|
|
341
|
+
_UNIT_CONVERSIONS[(source, target)]
|
|
342
|
+
)
|
|
343
|
+
except KeyError:
|
|
344
|
+
raise PreparationError(
|
|
345
|
+
"OPERATION_UNIT",
|
|
346
|
+
operation.operation_id,
|
|
347
|
+
"conversion is not in the closed rational registry",
|
|
348
|
+
) from None
|
|
349
|
+
|
|
350
|
+
def convert(value: Any, row_index: int) -> Any:
|
|
351
|
+
if value is None:
|
|
352
|
+
return None
|
|
353
|
+
try:
|
|
354
|
+
converted = Fraction(Decimal(str(value))) * Fraction(
|
|
355
|
+
scale_numerator, scale_denominator
|
|
356
|
+
) + Fraction(offset_numerator, offset_denominator)
|
|
357
|
+
except (InvalidOperation, ValueError):
|
|
358
|
+
raise PreparationError(
|
|
359
|
+
"OPERATION_UNIT",
|
|
360
|
+
f"{operation.operation_id}.row[{row_index}]",
|
|
361
|
+
"numeric value cannot be converted",
|
|
362
|
+
) from None
|
|
363
|
+
result = float(converted)
|
|
364
|
+
if not math.isfinite(result):
|
|
365
|
+
raise PreparationError(
|
|
366
|
+
"OPERATION_UNIT",
|
|
367
|
+
f"{operation.operation_id}.row[{row_index}]",
|
|
368
|
+
"conversion produced a non-finite value",
|
|
369
|
+
)
|
|
370
|
+
return result
|
|
371
|
+
|
|
372
|
+
return _map_column(
|
|
373
|
+
state,
|
|
374
|
+
operation,
|
|
375
|
+
convert,
|
|
376
|
+
field_transform=lambda current: replace(
|
|
377
|
+
current,
|
|
378
|
+
logical_type="float64",
|
|
379
|
+
physical_type=PHYSICAL_TYPES["float64"],
|
|
380
|
+
unit=target,
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _entity_resolve(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
386
|
+
column = operation.parameter("column")
|
|
387
|
+
field = state.field(column)
|
|
388
|
+
if field.logical_type != "string" or field.semantic_type != "entity":
|
|
389
|
+
raise PreparationError(
|
|
390
|
+
"OPERATION_TYPE",
|
|
391
|
+
operation.operation_id,
|
|
392
|
+
"entity resolution requires a string entity field",
|
|
393
|
+
)
|
|
394
|
+
if field.entity != operation.parameter("entity_type"):
|
|
395
|
+
raise PreparationError(
|
|
396
|
+
"ENTITY_TYPE",
|
|
397
|
+
operation.operation_id,
|
|
398
|
+
"entity type differs from the field declaration",
|
|
399
|
+
)
|
|
400
|
+
aliases = dict(operation.parameter("aliases"))
|
|
401
|
+
canonical = frozenset(aliases.values())
|
|
402
|
+
|
|
403
|
+
def resolve(value: Any, row_index: int) -> Any:
|
|
404
|
+
if value is None or value in canonical:
|
|
405
|
+
return value
|
|
406
|
+
try:
|
|
407
|
+
return aliases[value]
|
|
408
|
+
except KeyError:
|
|
409
|
+
raise PreparationError(
|
|
410
|
+
"ENTITY_UNRESOLVED",
|
|
411
|
+
f"{operation.operation_id}.row[{row_index}]",
|
|
412
|
+
"entity value is neither canonical nor an allowlisted alias",
|
|
413
|
+
) from None
|
|
414
|
+
|
|
415
|
+
return _map_column(state, operation, resolve)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _rename(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
419
|
+
mappings = dict(operation.parameter("mappings"))
|
|
420
|
+
unknown = set(mappings) - set(state.columns)
|
|
421
|
+
if unknown:
|
|
422
|
+
raise PreparationError(
|
|
423
|
+
"OPERATION_RENAME",
|
|
424
|
+
operation.operation_id,
|
|
425
|
+
"references unknown columns",
|
|
426
|
+
)
|
|
427
|
+
output_names = tuple(mappings.get(name, name) for name in state.columns)
|
|
428
|
+
if len(set(output_names)) != len(output_names):
|
|
429
|
+
raise PreparationError(
|
|
430
|
+
"OPERATION_RENAME",
|
|
431
|
+
operation.operation_id,
|
|
432
|
+
"creates a name collision",
|
|
433
|
+
)
|
|
434
|
+
fields = tuple(
|
|
435
|
+
replace(
|
|
436
|
+
field,
|
|
437
|
+
name=mappings.get(field.name, field.name),
|
|
438
|
+
availability_column=(
|
|
439
|
+
mappings.get(field.availability_column, field.availability_column)
|
|
440
|
+
if field.availability_column is not None
|
|
441
|
+
else None
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
for field in state.fields
|
|
445
|
+
)
|
|
446
|
+
lineage = tuple(
|
|
447
|
+
ColumnLineage(
|
|
448
|
+
output_column=mappings.get(item.output_column, item.output_column),
|
|
449
|
+
inputs=item.inputs,
|
|
450
|
+
operation_ids=(*item.operation_ids, operation.operation_id),
|
|
451
|
+
)
|
|
452
|
+
for item in state.lineage
|
|
453
|
+
)
|
|
454
|
+
return state.replace(fields=fields, lineage=lineage), len(state.rows) * len(mappings)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _project(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
458
|
+
columns = operation.parameter("columns")
|
|
459
|
+
indexes = validate_columns(state, columns, operation.operation_id)
|
|
460
|
+
fields = tuple(state.fields[index] for index in indexes)
|
|
461
|
+
output_names = frozenset(columns)
|
|
462
|
+
for field in fields:
|
|
463
|
+
if field.availability_column is not None and field.availability_column not in output_names:
|
|
464
|
+
raise PreparationError(
|
|
465
|
+
"TEMPORAL_SEMANTICS",
|
|
466
|
+
operation.operation_id,
|
|
467
|
+
f"projection drops availability column for {field.name}",
|
|
468
|
+
)
|
|
469
|
+
rows = tuple(tuple(row[index] for index in indexes) for row in state.rows)
|
|
470
|
+
lineage_by_name = {item.output_column: item for item in state.lineage}
|
|
471
|
+
lineage = tuple(
|
|
472
|
+
ColumnLineage(
|
|
473
|
+
output_column=column,
|
|
474
|
+
inputs=lineage_by_name[column].inputs,
|
|
475
|
+
operation_ids=(*lineage_by_name[column].operation_ids, operation.operation_id),
|
|
476
|
+
)
|
|
477
|
+
for column in columns
|
|
478
|
+
)
|
|
479
|
+
removed_columns = len(state.columns) - len(columns)
|
|
480
|
+
return state.replace(fields=fields, rows=rows, lineage=lineage), len(rows) * removed_columns
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _deduplicate(state: TableState, operation: OperationSpec) -> tuple[TableState, int]:
|
|
484
|
+
key_indexes = validate_columns(state, operation.parameter("keys"), operation.operation_id)
|
|
485
|
+
authority = operation.parameter("authority")
|
|
486
|
+
authority_indexes = tuple((state.index(column), direction) for column, direction in authority)
|
|
487
|
+
groups: dict[tuple[Any, ...], list[tuple[int, tuple[Any, ...]]]] = {}
|
|
488
|
+
for ordinal, row in enumerate(state.rows):
|
|
489
|
+
key = tuple(row[index] for index in key_indexes)
|
|
490
|
+
if any(value is None for value in key):
|
|
491
|
+
raise PreparationError(
|
|
492
|
+
"DEDUPLICATE_NULL_KEY",
|
|
493
|
+
f"{operation.operation_id}.row[{ordinal}]",
|
|
494
|
+
"deduplication keys cannot be null",
|
|
495
|
+
)
|
|
496
|
+
if any(row[index] is None for index, _ in authority_indexes):
|
|
497
|
+
raise PreparationError(
|
|
498
|
+
"DEDUPLICATE_NULL_AUTHORITY",
|
|
499
|
+
f"{operation.operation_id}.row[{ordinal}]",
|
|
500
|
+
"authority values cannot be null",
|
|
501
|
+
)
|
|
502
|
+
groups.setdefault(key, []).append((ordinal, row))
|
|
503
|
+
selected: list[tuple[int, tuple[Any, ...]]] = []
|
|
504
|
+
for candidates in groups.values():
|
|
505
|
+
candidates.sort(
|
|
506
|
+
key=cmp_to_key(
|
|
507
|
+
lambda left, right: _compare_authority(
|
|
508
|
+
left,
|
|
509
|
+
right,
|
|
510
|
+
authority_indexes,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
selected.append(candidates[0])
|
|
515
|
+
selected.sort(key=lambda item: item[0])
|
|
516
|
+
lineage = _append_operation(state.lineage, state.columns, operation.operation_id)
|
|
517
|
+
rows = tuple(row for _, row in selected)
|
|
518
|
+
return state.replace(rows=rows, lineage=lineage), len(state.rows) - len(rows)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _compare_authority(
|
|
522
|
+
left: tuple[int, tuple[Any, ...]],
|
|
523
|
+
right: tuple[int, tuple[Any, ...]],
|
|
524
|
+
authority: tuple[tuple[int, str], ...],
|
|
525
|
+
) -> int:
|
|
526
|
+
for index, direction in authority:
|
|
527
|
+
left_key = _sortable(left[1][index])
|
|
528
|
+
right_key = _sortable(right[1][index])
|
|
529
|
+
if left_key < right_key:
|
|
530
|
+
return -1 if direction == "ascending" else 1
|
|
531
|
+
if left_key > right_key:
|
|
532
|
+
return 1 if direction == "ascending" else -1
|
|
533
|
+
return (left[0] > right[0]) - (left[0] < right[0])
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _sortable(value: Any) -> tuple[int, Any]:
|
|
537
|
+
if type(value) is bool:
|
|
538
|
+
return (0, value)
|
|
539
|
+
if type(value) in {int, float}:
|
|
540
|
+
return (1, Decimal(str(value)))
|
|
541
|
+
return (2, value)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _append_operation(
|
|
545
|
+
lineage: tuple[ColumnLineage, ...],
|
|
546
|
+
columns: tuple[str, ...],
|
|
547
|
+
operation_id: str,
|
|
548
|
+
) -> tuple[ColumnLineage, ...]:
|
|
549
|
+
selected = frozenset(columns)
|
|
550
|
+
return tuple(
|
|
551
|
+
ColumnLineage(
|
|
552
|
+
output_column=item.output_column,
|
|
553
|
+
inputs=item.inputs,
|
|
554
|
+
operation_ids=(
|
|
555
|
+
(*item.operation_ids, operation_id)
|
|
556
|
+
if item.output_column in selected
|
|
557
|
+
else item.operation_ids
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
for item in lineage
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _typed_key(value: Any) -> tuple[type[Any], Any]:
|
|
565
|
+
return (type(value), value)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
__all__ = ["OperationAudit", "PreparedSource", "apply_operations"]
|