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,809 @@
|
|
|
1
|
+
"""Closed, bounded parsers for untrusted tabular source bytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import datetime
|
|
7
|
+
import decimal
|
|
8
|
+
import hashlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
|
|
17
|
+
from mostlyright.data_harness.canonical import (
|
|
18
|
+
CanonicalJSONError,
|
|
19
|
+
canonical_json_bytes,
|
|
20
|
+
canonical_sha256,
|
|
21
|
+
sha256_bytes,
|
|
22
|
+
)
|
|
23
|
+
from mostlyright.data_harness.formats import (
|
|
24
|
+
ARCHIVE_OR_EXECUTABLE_MAGICS,
|
|
25
|
+
DIRECT_FETCH_MEDIA_TYPES,
|
|
26
|
+
FORMAT_MEDIA_TYPES,
|
|
27
|
+
FORMAT_SUFFIXES,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Read from the single closed table in ``mostlyright.data_harness.formats``; this module
|
|
31
|
+
# keeps the private names it has always used so its call sites are unchanged.
|
|
32
|
+
_FORMAT_MEDIA_TYPES = FORMAT_MEDIA_TYPES
|
|
33
|
+
# The parse admits what the fetch admitted: a direct fetch may carry the weak label, so
|
|
34
|
+
# refusing it here would refuse at the parse exactly what was just let through the door.
|
|
35
|
+
_ADMITTED_MEDIA_TYPES = DIRECT_FETCH_MEDIA_TYPES
|
|
36
|
+
_FORMAT_SUFFIXES = FORMAT_SUFFIXES
|
|
37
|
+
# The archive prefixes are read from the leaf too, for the reason recorded beside them there:
|
|
38
|
+
# the container Readers refuse a nested archive on the same list, ``readers/`` may not import
|
|
39
|
+
# this module, and two copies of what an archive looks like would drift.
|
|
40
|
+
_ARCHIVE_OR_EXECUTABLE_MAGICS = ARCHIVE_OR_EXECUTABLE_MAGICS
|
|
41
|
+
_FORBIDDEN_REFERENCE_KEYS = frozenset(
|
|
42
|
+
{"$ref", "$schema", "external_reference", "external_references"}
|
|
43
|
+
)
|
|
44
|
+
_SAFE_COLUMN = re.compile(r"^[^\x00-\x1f\x7f]{1,256}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# PyArrow is imported on first Parquet use, never at module import. It is the single most
|
|
48
|
+
# expensive import in the sandbox worker's dependency graph -- around 50 ms of the ~110 ms this
|
|
49
|
+
# module used to add to a child interpreter's startup, and every millisecond of it is paid by
|
|
50
|
+
# every sandbox child, including the ones that only probe isolation or parse a CSV. Deferring it
|
|
51
|
+
# costs one dictionary lookup per Parquet parse and gives the operations that never touch Parquet
|
|
52
|
+
# a child that starts in roughly half the time. The module object is cached rather than re-bound
|
|
53
|
+
# into module globals so there is exactly one place that decides whether the import has happened.
|
|
54
|
+
_PYARROW: tuple[Any, Any] | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pyarrow() -> tuple[Any, Any]:
|
|
58
|
+
"""Return ``(pyarrow, pyarrow.parquet)``, importing them on first use."""
|
|
59
|
+
|
|
60
|
+
global _PYARROW
|
|
61
|
+
if _PYARROW is None:
|
|
62
|
+
import pyarrow
|
|
63
|
+
import pyarrow.parquet
|
|
64
|
+
|
|
65
|
+
_PYARROW = (pyarrow, pyarrow.parquet)
|
|
66
|
+
return _PYARROW
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prime_parquet_support() -> None:
|
|
70
|
+
"""Pay the deferred PyArrow import now, before any request is bound.
|
|
71
|
+
|
|
72
|
+
The lazy import above moves the cost off every child that does not parse Parquet; this
|
|
73
|
+
moves it off the request latency of the children that do. A warm sandbox child calls this
|
|
74
|
+
while it is still idle and unbound, so the first Parquet parse it is later asked for pays
|
|
75
|
+
an already-imported module rather than a cold one. Nothing else about the child changes:
|
|
76
|
+
priming reads no request, opens no socket, and touches no per-request state.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
_pyarrow()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ParseLimits:
|
|
84
|
+
"""Coordinator-owned parser budgets."""
|
|
85
|
+
|
|
86
|
+
max_input_bytes: int = 16 * 1024 * 1024
|
|
87
|
+
max_uncompressed_bytes: int = 128 * 1024 * 1024
|
|
88
|
+
max_expansion_ratio: int = 100
|
|
89
|
+
max_rows: int = 1_000_000
|
|
90
|
+
max_columns: int = 1_024
|
|
91
|
+
max_field_bytes: int = 1 * 1024 * 1024
|
|
92
|
+
max_json_depth: int = 32
|
|
93
|
+
max_total_cells: int = 10_000_000
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
bounded = (
|
|
97
|
+
("max_input_bytes", self.max_input_bytes, 1, 1 << 34),
|
|
98
|
+
("max_uncompressed_bytes", self.max_uncompressed_bytes, 1, 1 << 36),
|
|
99
|
+
("max_expansion_ratio", self.max_expansion_ratio, 1, 10_000),
|
|
100
|
+
("max_rows", self.max_rows, 1, 10_000_000),
|
|
101
|
+
("max_columns", self.max_columns, 1, 10_000),
|
|
102
|
+
("max_field_bytes", self.max_field_bytes, 1, 1 << 30),
|
|
103
|
+
("max_json_depth", self.max_json_depth, 1, 128),
|
|
104
|
+
("max_total_cells", self.max_total_cells, 1, 1_000_000_000),
|
|
105
|
+
)
|
|
106
|
+
for name, value, minimum, maximum in bounded:
|
|
107
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
108
|
+
raise AcquisitionSecurityError(
|
|
109
|
+
"PARSE_LIMIT",
|
|
110
|
+
f"{name} must be an integer in [{minimum}, {maximum}]",
|
|
111
|
+
)
|
|
112
|
+
if self.max_uncompressed_bytes < self.max_input_bytes:
|
|
113
|
+
raise AcquisitionSecurityError(
|
|
114
|
+
"PARSE_LIMIT",
|
|
115
|
+
"max_uncompressed_bytes must not be smaller than max_input_bytes",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class ParsedTable:
|
|
121
|
+
"""Immutable, source-independent parsed tabular result."""
|
|
122
|
+
|
|
123
|
+
data_format: str
|
|
124
|
+
input_sha256: str
|
|
125
|
+
columns: tuple[str, ...]
|
|
126
|
+
rows: tuple[tuple[Any, ...], ...]
|
|
127
|
+
schema_digest: str
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
if self.data_format not in _FORMAT_MEDIA_TYPES:
|
|
131
|
+
raise AcquisitionSecurityError("PARSE_FORMAT", "parsed format is not allowlisted")
|
|
132
|
+
if not self.columns or len(set(self.columns)) != len(self.columns):
|
|
133
|
+
raise AcquisitionSecurityError(
|
|
134
|
+
"PARSE_COLUMNS",
|
|
135
|
+
"parsed columns must be non-empty and unique",
|
|
136
|
+
)
|
|
137
|
+
if any(len(row) != len(self.columns) for row in self.rows):
|
|
138
|
+
raise AcquisitionSecurityError(
|
|
139
|
+
"PARSE_ROW_WIDTH",
|
|
140
|
+
"parsed row width differs from the schema",
|
|
141
|
+
)
|
|
142
|
+
expected = canonical_sha256(
|
|
143
|
+
{
|
|
144
|
+
"data_format": self.data_format,
|
|
145
|
+
"columns": list(self.columns),
|
|
146
|
+
"types": [_column_type(self.rows, index) for index in range(len(self.columns))],
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
if expected != self.schema_digest:
|
|
150
|
+
raise AcquisitionSecurityError(
|
|
151
|
+
"PARSE_SCHEMA_DIGEST",
|
|
152
|
+
"parsed schema digest does not match the exact result",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def dictionaries(self) -> tuple[dict[str, Any], ...]:
|
|
156
|
+
return tuple(dict(zip(self.columns, row, strict=True)) for row in self.rows)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_tabular_bytes(
|
|
160
|
+
content: bytes,
|
|
161
|
+
*,
|
|
162
|
+
data_format: str,
|
|
163
|
+
media_type: str,
|
|
164
|
+
filename: str,
|
|
165
|
+
limits: ParseLimits,
|
|
166
|
+
) -> ParsedTable:
|
|
167
|
+
"""Parse one allowlisted format after exact media/suffix and budget validation.
|
|
168
|
+
|
|
169
|
+
Every value that comes back is one the canonical rule can hold exactly. A temporal, decimal, or
|
|
170
|
+
non-finite value is refused with ``PARSE_VALUE`` rather than turned into text, because what
|
|
171
|
+
this function returns can be sealed and a value nobody can round-trip must not be.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
return _parse_tabular(
|
|
175
|
+
content,
|
|
176
|
+
data_format=data_format,
|
|
177
|
+
media_type=media_type,
|
|
178
|
+
filename=filename,
|
|
179
|
+
limits=limits,
|
|
180
|
+
display=False,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_tabular_bytes_for_display(
|
|
185
|
+
content: bytes,
|
|
186
|
+
*,
|
|
187
|
+
data_format: str,
|
|
188
|
+
media_type: str,
|
|
189
|
+
filename: str,
|
|
190
|
+
limits: ParseLimits,
|
|
191
|
+
) -> ParsedTable:
|
|
192
|
+
"""The same parse, for bytes that are being looked at rather than built from.
|
|
193
|
+
|
|
194
|
+
Identical in every guard: the same media, suffix, magic-byte, encoding, row, column, cell,
|
|
195
|
+
field, and decompression budgets, in the same order, from the same code. One thing differs.
|
|
196
|
+
Where :func:`parse_tabular_bytes` refuses a value the canonical rule has no place for -- a
|
|
197
|
+
date, a time, a duration, a decimal, a not-a-number -- this one renders it into its exact text
|
|
198
|
+
and carries on, so a column of dates can be *shown* rather than refused.
|
|
199
|
+
|
|
200
|
+
That is safe here and only here, because nothing this function returns is ever sealed. It has
|
|
201
|
+
exactly one caller, ``ux.peek``, which writes no bytes at all; ``tests/h3/test_parsing.py``
|
|
202
|
+
pins that there is no second one. Exactness is kept by
|
|
203
|
+
the rendering rather than by the refusal: the text is the value's own ``isoformat`` or ``str``,
|
|
204
|
+
never a rounding, and the type of column a person is told about is unchanged -- a rendered
|
|
205
|
+
``date`` is still reported as a ``date``, because the text carries the name of the kind it came
|
|
206
|
+
from.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
return _parse_tabular(
|
|
210
|
+
content,
|
|
211
|
+
data_format=data_format,
|
|
212
|
+
media_type=media_type,
|
|
213
|
+
filename=filename,
|
|
214
|
+
limits=limits,
|
|
215
|
+
display=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def row_digest_for(parsed: ParsedTable) -> str:
|
|
220
|
+
"""Return the canonical row digest of one drained parsed table.
|
|
221
|
+
|
|
222
|
+
The one formula, in one place. ``parse_csv_evidence`` accumulates the identical encoding a
|
|
223
|
+
row at a time for tables too large to hold, and the two are pinned equal by test; a second
|
|
224
|
+
spelling of it anywhere would be a second digest that could disagree with sealed evidence.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
return sha256_bytes(
|
|
228
|
+
canonical_json_bytes(
|
|
229
|
+
{
|
|
230
|
+
"columns": list(parsed.columns),
|
|
231
|
+
"rows": [list(row) for row in parsed.rows],
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class ParsedTabularEvidence:
|
|
239
|
+
"""The facts a receipt states about a parsed table, without the table.
|
|
240
|
+
|
|
241
|
+
``parse_csv_evidence`` computes exactly what ``parse_tabular_bytes`` would have put in
|
|
242
|
+
a receipt -- the columns, the row count, the schema digest, and the canonical row
|
|
243
|
+
digest -- while holding one row at a time. A year-scale verification pass therefore
|
|
244
|
+
costs the size of a row, not the size of the table; the values are pinned equal to the
|
|
245
|
+
drained parse by test, because a digest that moved would move every sealed receipt.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
data_format: str
|
|
249
|
+
input_sha256: str
|
|
250
|
+
columns: tuple[str, ...]
|
|
251
|
+
row_count: int
|
|
252
|
+
schema_digest: str
|
|
253
|
+
row_digest: str
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parse_csv_evidence(
|
|
257
|
+
content: bytes,
|
|
258
|
+
*,
|
|
259
|
+
media_type: str,
|
|
260
|
+
filename: str,
|
|
261
|
+
limits: ParseLimits,
|
|
262
|
+
) -> ParsedTabularEvidence:
|
|
263
|
+
"""Stream one CSV verification pass: the admissions of the drained parse, row by row.
|
|
264
|
+
|
|
265
|
+
The refusals are ``_parse_csv``'s own, at the same rows, in the same order. The one
|
|
266
|
+
check the drained path could only make after the loop -- the total-cell budget that
|
|
267
|
+
``_validate_table`` applies to the whole table -- keeps that position: the flag is
|
|
268
|
+
raised at the end of the input, so a malformed row later in the file still wins.
|
|
269
|
+
|
|
270
|
+
The row digest is fed to the hash incrementally as the canonical encoding of
|
|
271
|
+
``{"columns": [...], "rows": [[...], ...]}``. The framing bytes are literal because
|
|
272
|
+
the canonical encoder is compositional -- sorted keys, no spaces, context-free element
|
|
273
|
+
encoding -- and the equality with the one-shot encoding is pinned by test rather than
|
|
274
|
+
assumed.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
_admit_tabular_input(content, data_format="csv", media_type=media_type, filename=filename)
|
|
278
|
+
if not content or len(content) > limits.max_input_bytes:
|
|
279
|
+
raise AcquisitionSecurityError(
|
|
280
|
+
"PARSE_INPUT_LIMIT",
|
|
281
|
+
"source bytes are empty or exceed the parser input budget",
|
|
282
|
+
)
|
|
283
|
+
if any(content.startswith(magic) for magic in _ARCHIVE_OR_EXECUTABLE_MAGICS):
|
|
284
|
+
raise AcquisitionSecurityError(
|
|
285
|
+
"PARSE_FORBIDDEN_CONTAINER",
|
|
286
|
+
"archives, executables, documents, and nested containers are forbidden",
|
|
287
|
+
)
|
|
288
|
+
text = _strict_utf8(content)
|
|
289
|
+
if "\x00" in text:
|
|
290
|
+
raise AcquisitionSecurityError("PARSE_ENCODING", "CSV contains a NUL byte")
|
|
291
|
+
reader = csv.reader(
|
|
292
|
+
io.StringIO(text, newline=""),
|
|
293
|
+
delimiter=",",
|
|
294
|
+
quotechar='"',
|
|
295
|
+
doublequote=True,
|
|
296
|
+
strict=True,
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
header = next(reader)
|
|
300
|
+
except (StopIteration, csv.Error):
|
|
301
|
+
raise AcquisitionSecurityError("PARSE_CSV", "CSV has no valid header") from None
|
|
302
|
+
columns = _columns(header, limits)
|
|
303
|
+
width = len(columns)
|
|
304
|
+
|
|
305
|
+
row_hash = hashlib.sha256()
|
|
306
|
+
row_hash.update(b'{"columns":' + canonical_json_bytes(list(columns)) + b',"rows":[')
|
|
307
|
+
observed_types: list[set[str]] = [set() for _ in range(width)]
|
|
308
|
+
row_count = 0
|
|
309
|
+
over_cell_budget = False
|
|
310
|
+
canonical_error: CanonicalJSONError | None = None
|
|
311
|
+
try:
|
|
312
|
+
for raw in reader:
|
|
313
|
+
if row_count >= limits.max_rows:
|
|
314
|
+
raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "CSV exceeds the row budget")
|
|
315
|
+
if len(raw) != width:
|
|
316
|
+
raise AcquisitionSecurityError(
|
|
317
|
+
"PARSE_ROW_WIDTH",
|
|
318
|
+
"CSV row width differs from its header",
|
|
319
|
+
)
|
|
320
|
+
row = tuple(raw)
|
|
321
|
+
_validate_row(row, limits)
|
|
322
|
+
if (row_count + 1) * width > limits.max_total_cells:
|
|
323
|
+
over_cell_budget = True
|
|
324
|
+
# A cell the canonical encoder refuses -- reachable only if the process-wide
|
|
325
|
+
# ``csv.field_size_limit`` is raised above the canonical string bound -- keeps
|
|
326
|
+
# the position the one-shot digest gave it: after every parse refusal and the
|
|
327
|
+
# cell budget, never mid-loop. The hash is dead on that path, so it stops
|
|
328
|
+
# accumulating with the first error.
|
|
329
|
+
if canonical_error is None:
|
|
330
|
+
try:
|
|
331
|
+
encoded_row = canonical_json_bytes(list(row))
|
|
332
|
+
except CanonicalJSONError as error:
|
|
333
|
+
canonical_error = error
|
|
334
|
+
else:
|
|
335
|
+
if row_count:
|
|
336
|
+
row_hash.update(b",")
|
|
337
|
+
row_hash.update(encoded_row)
|
|
338
|
+
for index, value in enumerate(row):
|
|
339
|
+
observed_types[index].add("null" if value is None else type(value).__name__)
|
|
340
|
+
row_count += 1
|
|
341
|
+
except csv.Error:
|
|
342
|
+
raise AcquisitionSecurityError("PARSE_CSV", "CSV syntax is malformed") from None
|
|
343
|
+
if over_cell_budget:
|
|
344
|
+
raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "table exceeds the cell budget")
|
|
345
|
+
if canonical_error is not None:
|
|
346
|
+
raise canonical_error
|
|
347
|
+
row_hash.update(b"]}")
|
|
348
|
+
|
|
349
|
+
schema_digest = canonical_sha256(
|
|
350
|
+
{
|
|
351
|
+
"data_format": "csv",
|
|
352
|
+
"columns": list(columns),
|
|
353
|
+
"types": [
|
|
354
|
+
"|".join(sorted(observed)) if observed else "empty" for observed in observed_types
|
|
355
|
+
],
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return ParsedTabularEvidence(
|
|
359
|
+
data_format="csv",
|
|
360
|
+
input_sha256=sha256_bytes(content),
|
|
361
|
+
columns=columns,
|
|
362
|
+
row_count=row_count,
|
|
363
|
+
schema_digest=schema_digest,
|
|
364
|
+
row_digest=row_hash.hexdigest(),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _admit_tabular_input(
|
|
369
|
+
content: bytes,
|
|
370
|
+
*,
|
|
371
|
+
data_format: str,
|
|
372
|
+
media_type: str,
|
|
373
|
+
filename: str,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""The input admissions every tabular parse makes, in the order it makes them."""
|
|
376
|
+
|
|
377
|
+
if not isinstance(content, bytes):
|
|
378
|
+
raise AcquisitionSecurityError("PARSE_INPUT", "parser input must be exact bytes")
|
|
379
|
+
if data_format not in _FORMAT_MEDIA_TYPES:
|
|
380
|
+
raise AcquisitionSecurityError("PARSE_FORMAT", "data format is not allowlisted")
|
|
381
|
+
normalized_media = media_type.split(";", 1)[0].strip().lower()
|
|
382
|
+
if normalized_media not in _ADMITTED_MEDIA_TYPES[data_format]:
|
|
383
|
+
raise AcquisitionSecurityError(
|
|
384
|
+
"PARSE_MEDIA_MISMATCH",
|
|
385
|
+
"declared media type does not match the selected parser",
|
|
386
|
+
)
|
|
387
|
+
suffix = _suffix(filename)
|
|
388
|
+
if suffix not in _FORMAT_SUFFIXES[data_format]:
|
|
389
|
+
raise AcquisitionSecurityError(
|
|
390
|
+
"PARSE_SUFFIX_MISMATCH",
|
|
391
|
+
"filename suffix does not match the selected parser",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_tabular(
|
|
396
|
+
content: bytes,
|
|
397
|
+
*,
|
|
398
|
+
data_format: str,
|
|
399
|
+
media_type: str,
|
|
400
|
+
filename: str,
|
|
401
|
+
limits: ParseLimits,
|
|
402
|
+
display: bool,
|
|
403
|
+
) -> ParsedTable:
|
|
404
|
+
_admit_tabular_input(content, data_format=data_format, media_type=media_type, filename=filename)
|
|
405
|
+
if not content or len(content) > limits.max_input_bytes:
|
|
406
|
+
raise AcquisitionSecurityError(
|
|
407
|
+
"PARSE_INPUT_LIMIT",
|
|
408
|
+
"source bytes are empty or exceed the parser input budget",
|
|
409
|
+
)
|
|
410
|
+
if any(content.startswith(magic) for magic in _ARCHIVE_OR_EXECUTABLE_MAGICS):
|
|
411
|
+
raise AcquisitionSecurityError(
|
|
412
|
+
"PARSE_FORBIDDEN_CONTAINER",
|
|
413
|
+
"archives, executables, documents, and nested containers are forbidden",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Exhaustive over the format table, with no catch-all decoder. The admission checks
|
|
417
|
+
# above read ``mostlyright.data_harness.formats``, so widening that table admits a name
|
|
418
|
+
# here too; if the last branch were ``else: _parse_parquet(...)`` the new name's bytes
|
|
419
|
+
# would be handed to the Parquet reader, and the ``ParsedTable`` and its
|
|
420
|
+
# ``schema_digest`` -- which folds ``data_format`` in -- would then attest a decoder
|
|
421
|
+
# that never ran. A format the table admits and no branch claims is refused here
|
|
422
|
+
# instead, naming itself, so adding a Reader family to ``formats.py`` is a loud failure
|
|
423
|
+
# until a decoder is taught to honour it rather than a silent misparse.
|
|
424
|
+
if data_format == "csv":
|
|
425
|
+
columns, rows = _parse_csv(content, limits)
|
|
426
|
+
elif data_format in {"json", "ndjson"}:
|
|
427
|
+
columns, rows = _parse_json(content, data_format, limits)
|
|
428
|
+
elif data_format == "parquet":
|
|
429
|
+
columns, rows = _parse_parquet(content, limits, display=display)
|
|
430
|
+
else:
|
|
431
|
+
raise AcquisitionSecurityError(
|
|
432
|
+
"PARSE_FORMAT_UNHANDLED",
|
|
433
|
+
f"data format {data_format!r} is in the format table but no decoder claims it",
|
|
434
|
+
)
|
|
435
|
+
_validate_table(columns, rows, limits)
|
|
436
|
+
schema_digest = canonical_sha256(
|
|
437
|
+
{
|
|
438
|
+
"data_format": data_format,
|
|
439
|
+
"columns": list(columns),
|
|
440
|
+
"types": [_column_type(rows, index) for index in range(len(columns))],
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
return ParsedTable(
|
|
444
|
+
data_format=data_format,
|
|
445
|
+
input_sha256=sha256_bytes(content),
|
|
446
|
+
columns=columns,
|
|
447
|
+
rows=rows,
|
|
448
|
+
schema_digest=schema_digest,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def column_types(table: ParsedTable) -> tuple[str, ...]:
|
|
453
|
+
"""Return the observed type of each column, in column order.
|
|
454
|
+
|
|
455
|
+
This is the exact list the schema digest already commits to, so it exposes a sealed fact
|
|
456
|
+
rather than inferring a new one.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
return tuple(_column_type(table.rows, index) for index in range(len(table.columns)))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _parse_csv(
|
|
463
|
+
content: bytes,
|
|
464
|
+
limits: ParseLimits,
|
|
465
|
+
) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
|
|
466
|
+
text = _strict_utf8(content)
|
|
467
|
+
if "\x00" in text:
|
|
468
|
+
raise AcquisitionSecurityError("PARSE_ENCODING", "CSV contains a NUL byte")
|
|
469
|
+
reader = csv.reader(
|
|
470
|
+
io.StringIO(text, newline=""),
|
|
471
|
+
delimiter=",",
|
|
472
|
+
quotechar='"',
|
|
473
|
+
doublequote=True,
|
|
474
|
+
strict=True,
|
|
475
|
+
)
|
|
476
|
+
try:
|
|
477
|
+
header = next(reader)
|
|
478
|
+
except (StopIteration, csv.Error):
|
|
479
|
+
raise AcquisitionSecurityError("PARSE_CSV", "CSV has no valid header") from None
|
|
480
|
+
columns = _columns(header, limits)
|
|
481
|
+
rows: list[tuple[Any, ...]] = []
|
|
482
|
+
try:
|
|
483
|
+
for raw in reader:
|
|
484
|
+
if len(rows) >= limits.max_rows:
|
|
485
|
+
raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "CSV exceeds the row budget")
|
|
486
|
+
if len(raw) != len(columns):
|
|
487
|
+
raise AcquisitionSecurityError(
|
|
488
|
+
"PARSE_ROW_WIDTH",
|
|
489
|
+
"CSV row width differs from its header",
|
|
490
|
+
)
|
|
491
|
+
row = tuple(raw)
|
|
492
|
+
_validate_row(row, limits)
|
|
493
|
+
rows.append(row)
|
|
494
|
+
except csv.Error:
|
|
495
|
+
raise AcquisitionSecurityError("PARSE_CSV", "CSV syntax is malformed") from None
|
|
496
|
+
return columns, tuple(rows)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _parse_json(
|
|
500
|
+
content: bytes,
|
|
501
|
+
data_format: str,
|
|
502
|
+
limits: ParseLimits,
|
|
503
|
+
) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
|
|
504
|
+
text = _strict_utf8(content)
|
|
505
|
+
decoder = json.JSONDecoder(
|
|
506
|
+
object_pairs_hook=_unique_object,
|
|
507
|
+
parse_constant=lambda value: _invalid_json_number(value),
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
if data_format == "json":
|
|
511
|
+
value = decoder.decode(text)
|
|
512
|
+
if isinstance(value, dict) and {"nbformat", "cells"} <= set(value):
|
|
513
|
+
raise AcquisitionSecurityError(
|
|
514
|
+
"PARSE_NOTEBOOK",
|
|
515
|
+
"notebook documents are not tabular JSON sources",
|
|
516
|
+
)
|
|
517
|
+
records = value
|
|
518
|
+
else:
|
|
519
|
+
records = []
|
|
520
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
521
|
+
if not line.strip():
|
|
522
|
+
continue
|
|
523
|
+
if len(records) >= limits.max_rows:
|
|
524
|
+
raise AcquisitionSecurityError(
|
|
525
|
+
"PARSE_ROW_LIMIT",
|
|
526
|
+
"NDJSON exceeds the row budget",
|
|
527
|
+
)
|
|
528
|
+
try:
|
|
529
|
+
records.append(decoder.decode(line))
|
|
530
|
+
except json.JSONDecodeError:
|
|
531
|
+
raise AcquisitionSecurityError(
|
|
532
|
+
"PARSE_JSON",
|
|
533
|
+
f"NDJSON line {line_number} is malformed",
|
|
534
|
+
) from None
|
|
535
|
+
except json.JSONDecodeError:
|
|
536
|
+
raise AcquisitionSecurityError("PARSE_JSON", "JSON syntax is malformed") from None
|
|
537
|
+
if not isinstance(records, list) or not records:
|
|
538
|
+
raise AcquisitionSecurityError(
|
|
539
|
+
"PARSE_JSON_SHAPE",
|
|
540
|
+
"JSON tabular input must be a non-empty array of objects",
|
|
541
|
+
)
|
|
542
|
+
if len(records) > limits.max_rows:
|
|
543
|
+
raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "JSON exceeds the row budget")
|
|
544
|
+
if not all(isinstance(item, dict) for item in records):
|
|
545
|
+
raise AcquisitionSecurityError(
|
|
546
|
+
"PARSE_JSON_SHAPE",
|
|
547
|
+
"every JSON row must be an object",
|
|
548
|
+
)
|
|
549
|
+
first = records[0]
|
|
550
|
+
assert isinstance(first, dict)
|
|
551
|
+
columns = _columns(list(first), limits)
|
|
552
|
+
expected = set(columns)
|
|
553
|
+
rows: list[tuple[Any, ...]] = []
|
|
554
|
+
for index, item in enumerate(records):
|
|
555
|
+
assert isinstance(item, dict)
|
|
556
|
+
_validate_json_value(item, depth=1, limits=limits)
|
|
557
|
+
if set(item) != expected:
|
|
558
|
+
raise AcquisitionSecurityError(
|
|
559
|
+
"PARSE_ROW_SCHEMA",
|
|
560
|
+
f"JSON row {index + 1} does not match the first-row schema",
|
|
561
|
+
)
|
|
562
|
+
row = tuple(item[column] for column in columns)
|
|
563
|
+
_validate_row(row, limits)
|
|
564
|
+
rows.append(row)
|
|
565
|
+
return columns, tuple(rows)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _parse_parquet(
|
|
569
|
+
content: bytes,
|
|
570
|
+
limits: ParseLimits,
|
|
571
|
+
*,
|
|
572
|
+
display: bool = False,
|
|
573
|
+
) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
|
|
574
|
+
if not (content.startswith(b"PAR1") and content.endswith(b"PAR1")):
|
|
575
|
+
raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet magic bytes are invalid")
|
|
576
|
+
pa, pq = _pyarrow()
|
|
577
|
+
try:
|
|
578
|
+
parquet = pq.ParquetFile(pa.BufferReader(content))
|
|
579
|
+
metadata = parquet.metadata
|
|
580
|
+
schema = parquet.schema_arrow
|
|
581
|
+
except (pa.ArrowException, OSError, ValueError):
|
|
582
|
+
raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet metadata is malformed") from None
|
|
583
|
+
if metadata.num_rows > limits.max_rows:
|
|
584
|
+
raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "Parquet exceeds the row budget")
|
|
585
|
+
if metadata.num_columns > limits.max_columns:
|
|
586
|
+
raise AcquisitionSecurityError("PARSE_COLUMN_LIMIT", "Parquet exceeds the column budget")
|
|
587
|
+
if metadata.num_rows * max(metadata.num_columns, 1) > limits.max_total_cells:
|
|
588
|
+
raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "Parquet exceeds the cell budget")
|
|
589
|
+
uncompressed = sum(
|
|
590
|
+
metadata.row_group(group).total_byte_size for group in range(metadata.num_row_groups)
|
|
591
|
+
)
|
|
592
|
+
if uncompressed > limits.max_uncompressed_bytes:
|
|
593
|
+
raise AcquisitionSecurityError(
|
|
594
|
+
"PARSE_DECOMPRESSION_LIMIT",
|
|
595
|
+
"Parquet decoded bytes exceed the configured budget",
|
|
596
|
+
)
|
|
597
|
+
if uncompressed > max(len(content), 1) * limits.max_expansion_ratio:
|
|
598
|
+
raise AcquisitionSecurityError(
|
|
599
|
+
"PARSE_EXPANSION_RATIO",
|
|
600
|
+
"Parquet expansion ratio exceeds the configured budget",
|
|
601
|
+
)
|
|
602
|
+
for field in schema:
|
|
603
|
+
if isinstance(field.type, pa.ExtensionType):
|
|
604
|
+
raise AcquisitionSecurityError(
|
|
605
|
+
"PARSE_EXTENSION_TYPE",
|
|
606
|
+
"Parquet extension types are not allowlisted",
|
|
607
|
+
)
|
|
608
|
+
columns = _columns(schema.names, limits)
|
|
609
|
+
try:
|
|
610
|
+
table = parquet.read()
|
|
611
|
+
records = table.to_pylist()
|
|
612
|
+
except (pa.ArrowException, OSError, ValueError):
|
|
613
|
+
raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet row decoding failed") from None
|
|
614
|
+
rows = tuple(tuple(item[column] for column in columns) for item in records)
|
|
615
|
+
if display:
|
|
616
|
+
# Before the budget check, never instead of it: a rendered value is measured in bytes
|
|
617
|
+
# exactly like any other string, so the field budget still applies to every cell.
|
|
618
|
+
rows = tuple(
|
|
619
|
+
tuple(_displayed(value, depth=0, limits=limits) for value in row) for row in rows
|
|
620
|
+
)
|
|
621
|
+
for row in rows:
|
|
622
|
+
_validate_row(row, limits)
|
|
623
|
+
return columns, rows
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# ------------------------------------------------------------------------------------------------
|
|
627
|
+
# Rendering for display, which is the one thing a look may do that a build may not
|
|
628
|
+
# ------------------------------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class _ExactText(str):
|
|
632
|
+
"""The exact text of a value the canonical rule has no place for.
|
|
633
|
+
|
|
634
|
+
A ``str`` subclass rather than a plain ``str`` so the kind of value it came from survives the
|
|
635
|
+
rendering. Each subclass is named after that kind and :func:`_column_type` reads a value's type
|
|
636
|
+
name, so a rendered ``datetime.date`` is still reported as a ``date`` column: a person and an
|
|
637
|
+
agent are told what the file holds, not what the display did to it.
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
__slots__ = ()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# The kinds that are rendered, and the text each is rendered into. Keyed by exact type rather than
|
|
644
|
+
# by `isinstance`, because `datetime` is a subclass of `date` and the two are different kinds of
|
|
645
|
+
# column. `isoformat` and `str` are the value's own exact spellings -- neither rounds, truncates,
|
|
646
|
+
# or reformats -- so the text is the value and not a summary of it.
|
|
647
|
+
_DISPLAY_TEXT: dict[type, Any] = {
|
|
648
|
+
datetime.date: datetime.date.isoformat,
|
|
649
|
+
datetime.datetime: datetime.datetime.isoformat,
|
|
650
|
+
datetime.time: datetime.time.isoformat,
|
|
651
|
+
datetime.timedelta: str,
|
|
652
|
+
decimal.Decimal: str,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# One named subclass per kind, built once.
|
|
656
|
+
_DISPLAY_CLASSES: dict[type, type[_ExactText]] = {
|
|
657
|
+
kind: type(kind.__name__, (_ExactText,), {"__slots__": ()}) for kind in _DISPLAY_TEXT
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# A not-a-number or an infinity is a `float` like any other, and the column it sits in is a float
|
|
661
|
+
# column, so its rendering keeps that name. `repr` is the exact spelling Python reads back.
|
|
662
|
+
_DISPLAY_FLOAT: type[_ExactText] = type("float", (_ExactText,), {"__slots__": ()})
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _displayed(value: Any, *, depth: int, limits: ParseLimits) -> Any:
|
|
666
|
+
"""``value`` unchanged when the canonical rule holds it, and its exact text when it does not.
|
|
667
|
+
|
|
668
|
+
Anything still unrenderable is left exactly as it is, so :func:`_validate_row` refuses it with
|
|
669
|
+
``PARSE_VALUE`` as before: this widens what can be looked at, it does not remove the floor.
|
|
670
|
+
"""
|
|
671
|
+
|
|
672
|
+
if depth > limits.max_json_depth:
|
|
673
|
+
raise AcquisitionSecurityError("PARSE_DEPTH_LIMIT", "value exceeds the nesting budget")
|
|
674
|
+
if isinstance(value, list | tuple):
|
|
675
|
+
return [_displayed(item, depth=depth + 1, limits=limits) for item in value]
|
|
676
|
+
if isinstance(value, dict):
|
|
677
|
+
return {
|
|
678
|
+
key: _displayed(item, depth=depth + 1, limits=limits) for key, item in value.items()
|
|
679
|
+
}
|
|
680
|
+
if type(value) is float and not math.isfinite(value):
|
|
681
|
+
return _DISPLAY_FLOAT(repr(value))
|
|
682
|
+
render = _DISPLAY_TEXT.get(type(value))
|
|
683
|
+
if render is None:
|
|
684
|
+
return value
|
|
685
|
+
return _DISPLAY_CLASSES[type(value)](render(value))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _columns(values: list[Any], limits: ParseLimits) -> tuple[str, ...]:
|
|
689
|
+
if not values or len(values) > limits.max_columns:
|
|
690
|
+
raise AcquisitionSecurityError(
|
|
691
|
+
"PARSE_COLUMN_LIMIT",
|
|
692
|
+
"table must have a non-empty bounded column set",
|
|
693
|
+
)
|
|
694
|
+
if not all(isinstance(value, str) and _SAFE_COLUMN.fullmatch(value) for value in values):
|
|
695
|
+
raise AcquisitionSecurityError(
|
|
696
|
+
"PARSE_COLUMN_NAME",
|
|
697
|
+
"column names must be bounded non-control strings",
|
|
698
|
+
)
|
|
699
|
+
columns = tuple(values)
|
|
700
|
+
if len(set(columns)) != len(columns):
|
|
701
|
+
raise AcquisitionSecurityError("PARSE_COLUMN_DUPLICATE", "column names must be unique")
|
|
702
|
+
return columns
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _validate_table(
|
|
706
|
+
columns: tuple[str, ...],
|
|
707
|
+
rows: tuple[tuple[Any, ...], ...],
|
|
708
|
+
limits: ParseLimits,
|
|
709
|
+
) -> None:
|
|
710
|
+
if len(rows) > limits.max_rows:
|
|
711
|
+
raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "table exceeds the row budget")
|
|
712
|
+
if len(rows) * len(columns) > limits.max_total_cells:
|
|
713
|
+
raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "table exceeds the cell budget")
|
|
714
|
+
for row in rows:
|
|
715
|
+
if len(row) != len(columns):
|
|
716
|
+
raise AcquisitionSecurityError(
|
|
717
|
+
"PARSE_ROW_WIDTH",
|
|
718
|
+
"parsed row width differs from its schema",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _validate_row(row: tuple[Any, ...], limits: ParseLimits) -> None:
|
|
723
|
+
for value in row:
|
|
724
|
+
if isinstance(value, str):
|
|
725
|
+
size = len(value.encode("utf-8"))
|
|
726
|
+
elif isinstance(value, bytes):
|
|
727
|
+
size = len(value)
|
|
728
|
+
else:
|
|
729
|
+
try:
|
|
730
|
+
size = len(
|
|
731
|
+
json.dumps(
|
|
732
|
+
value,
|
|
733
|
+
ensure_ascii=False,
|
|
734
|
+
separators=(",", ":"),
|
|
735
|
+
allow_nan=False,
|
|
736
|
+
).encode("utf-8")
|
|
737
|
+
)
|
|
738
|
+
except (TypeError, ValueError):
|
|
739
|
+
raise AcquisitionSecurityError(
|
|
740
|
+
"PARSE_VALUE",
|
|
741
|
+
"parsed field contains a non-canonical value",
|
|
742
|
+
) from None
|
|
743
|
+
if size > limits.max_field_bytes:
|
|
744
|
+
raise AcquisitionSecurityError(
|
|
745
|
+
"PARSE_FIELD_LIMIT",
|
|
746
|
+
"parsed field exceeds the byte budget",
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _validate_json_value(value: Any, *, depth: int, limits: ParseLimits) -> None:
|
|
751
|
+
if depth > limits.max_json_depth:
|
|
752
|
+
raise AcquisitionSecurityError("PARSE_DEPTH_LIMIT", "JSON exceeds the nesting budget")
|
|
753
|
+
if isinstance(value, dict):
|
|
754
|
+
for key, item in value.items():
|
|
755
|
+
if key in _FORBIDDEN_REFERENCE_KEYS:
|
|
756
|
+
raise AcquisitionSecurityError(
|
|
757
|
+
"PARSE_EXTERNAL_REFERENCE",
|
|
758
|
+
"parser-driven external references are forbidden",
|
|
759
|
+
)
|
|
760
|
+
_validate_json_value(item, depth=depth + 1, limits=limits)
|
|
761
|
+
elif isinstance(value, list):
|
|
762
|
+
for item in value:
|
|
763
|
+
_validate_json_value(item, depth=depth + 1, limits=limits)
|
|
764
|
+
elif isinstance(value, float) and not math.isfinite(value):
|
|
765
|
+
raise AcquisitionSecurityError("PARSE_NUMBER", "non-finite JSON numbers are forbidden")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
769
|
+
result: dict[str, Any] = {}
|
|
770
|
+
for key, value in pairs:
|
|
771
|
+
if key in result:
|
|
772
|
+
raise AcquisitionSecurityError("PARSE_JSON_DUPLICATE", "JSON keys must be unique")
|
|
773
|
+
result[key] = value
|
|
774
|
+
return result
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _invalid_json_number(value: str) -> Any:
|
|
778
|
+
raise AcquisitionSecurityError("PARSE_NUMBER", f"JSON number {value!r} is forbidden")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _strict_utf8(content: bytes) -> str:
|
|
782
|
+
try:
|
|
783
|
+
return content.decode("utf-8", errors="strict")
|
|
784
|
+
except UnicodeDecodeError:
|
|
785
|
+
raise AcquisitionSecurityError(
|
|
786
|
+
"PARSE_ENCODING",
|
|
787
|
+
"source text must be strict UTF-8",
|
|
788
|
+
) from None
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _suffix(filename: str) -> str:
|
|
792
|
+
if (
|
|
793
|
+
not isinstance(filename, str)
|
|
794
|
+
or not filename
|
|
795
|
+
or "/" in filename
|
|
796
|
+
or "\\" in filename
|
|
797
|
+
or filename.startswith(".")
|
|
798
|
+
):
|
|
799
|
+
raise AcquisitionSecurityError(
|
|
800
|
+
"PARSE_FILENAME",
|
|
801
|
+
"source filename must be one non-hidden path component",
|
|
802
|
+
)
|
|
803
|
+
dot = filename.rfind(".")
|
|
804
|
+
return filename[dot:].lower() if dot >= 0 else ""
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _column_type(rows: tuple[tuple[Any, ...], ...], index: int) -> str:
|
|
808
|
+
observed = sorted({"null" if row[index] is None else type(row[index]).__name__ for row in rows})
|
|
809
|
+
return "|".join(observed) if observed else "empty"
|