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,510 @@
|
|
|
1
|
+
"""The one canonical output encoder, the pinned float rule, and the shared delimited reader.
|
|
2
|
+
|
|
3
|
+
Every Reader family emits through this module. The sealed artifact is a Reader's output,
|
|
4
|
+
so byte-stability is a Reader-contract obligation rather than a per-family accident: two
|
|
5
|
+
runs of the same decode on two hosts produce the same bytes, therefore the same digest,
|
|
6
|
+
therefore the same receipt.
|
|
7
|
+
|
|
8
|
+
The float rule, pinned
|
|
9
|
+
----------------------
|
|
10
|
+
This is the contract other families are held to, so it is written as prose a reviewer can
|
|
11
|
+
check rather than left to whatever a formatter happens to do.
|
|
12
|
+
|
|
13
|
+
* Values are IEEE-754 binary64 end to end. Nothing is widened, narrowed, or rounded on the
|
|
14
|
+
way to text, and no fused multiply-add or extended-precision accumulation is permitted in
|
|
15
|
+
a path that produces a value rendered here.
|
|
16
|
+
* The text form is the shortest decimal string that parses back to the identical bit
|
|
17
|
+
pattern. Shortest round-trip, not fixed precision: a formatter that emits more digits is
|
|
18
|
+
as wrong as one that emits fewer, because the extra digits are not information and they
|
|
19
|
+
move the digest.
|
|
20
|
+
* No locale, no thousands separator, and no exponent normalisation beyond the shortest form
|
|
21
|
+
itself. The decimal point is a full stop and nothing else.
|
|
22
|
+
* Negative zero renders as ``-0.0`` and is never folded into ``0.0``. They are two values,
|
|
23
|
+
they compare equal, and a fold would make two different inputs seal to one artifact.
|
|
24
|
+
* A non-finite value is never rendered. It is a missing cell instead. This is what keeps
|
|
25
|
+
the output inside the boundary the sandbox IPC enforces, which refuses non-finite numbers
|
|
26
|
+
outright: a missing value has already become an empty cell before anything crosses it.
|
|
27
|
+
|
|
28
|
+
The missing-value rule
|
|
29
|
+
----------------------
|
|
30
|
+
An absent value and a non-finite value are both the empty cell. The empty cell is the only
|
|
31
|
+
representation of "no value here": there is no sentinel, no ``NULL`` text, and no
|
|
32
|
+
distinction between an absent value and an empty string once the bytes are sealed.
|
|
33
|
+
|
|
34
|
+
One decoder, one delimiter parameter
|
|
35
|
+
------------------------------------
|
|
36
|
+
``decode_delimited`` takes its delimiter as an argument, which is the whole of
|
|
37
|
+
tab-separated support and the whole of decoding a container's member. A separate
|
|
38
|
+
tab-separated reader would be a second implementation of one thing, so there is not one.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import csv
|
|
44
|
+
import io
|
|
45
|
+
import itertools
|
|
46
|
+
import math
|
|
47
|
+
import re
|
|
48
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from mostlyright.data_harness.fast_delimited import certified_rows, certifies
|
|
52
|
+
from mostlyright.data_harness.readers.contracts import ReaderBudgets, ReaderError
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"FALLBACK_STEM",
|
|
56
|
+
"check_declared_size",
|
|
57
|
+
"decode_delimited",
|
|
58
|
+
"decode_delimited_stream",
|
|
59
|
+
"encode_canonical_csv",
|
|
60
|
+
"format_float",
|
|
61
|
+
"sealed_filename",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# A column name may not carry a control character. The rule is the parser's own, so a table
|
|
65
|
+
# a Reader produces is a table the parser would have admitted.
|
|
66
|
+
_SAFE_COLUMN = re.compile(r"^[^\x00-\x1f\x7f]{1,256}$")
|
|
67
|
+
|
|
68
|
+
# The quoting settings. Fixed, not configurable: a configurable quote character would be a
|
|
69
|
+
# knob that moves the sealed bytes without moving the recipe digest.
|
|
70
|
+
_QUOTE_CHARACTER = '"'
|
|
71
|
+
|
|
72
|
+
# The characters a derived sealed filename may carry, from the Reader contract's own rule.
|
|
73
|
+
_FILENAME_SAFE = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
|
|
74
|
+
_FILENAME_STEM_BYTES = 200
|
|
75
|
+
|
|
76
|
+
# The stem used when a family has no caller-supplied name to derive one from, and when a
|
|
77
|
+
# supplied name leaves nothing usable behind. Public because a family with no name of its own
|
|
78
|
+
# -- a gzip stream carries none -- passes it in deliberately rather than inventing a second one.
|
|
79
|
+
FALLBACK_STEM = "decoded"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sealed_filename(stem_source: str, *, suffix: str) -> str:
|
|
83
|
+
"""Derive the sealed artifact's bare filename from whatever the source called the thing.
|
|
84
|
+
|
|
85
|
+
Derived rather than taken. An archive member name, a worksheet title -- both are text a
|
|
86
|
+
stranger chose, and the sealed filename has to satisfy the Reader contract's confined
|
|
87
|
+
bare-name rule. Anything outside that rule becomes an underscore, and a name left with
|
|
88
|
+
nothing usable becomes a fixed stem instead of an empty one.
|
|
89
|
+
|
|
90
|
+
One implementation, shared by every family. A second copy would let two families disagree
|
|
91
|
+
about what a confined name is, and the disagreement would surface as a ``READER_OUTPUT``
|
|
92
|
+
refusal from the result contract rather than as anything a reader of the code could see.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
base = stem_source.replace("\\", "/").rsplit("/", 1)[-1]
|
|
96
|
+
stem = base[: base.rindex(".")] if "." in base[1:] else base
|
|
97
|
+
kept = "".join(character if character in _FILENAME_SAFE else "_" for character in stem)
|
|
98
|
+
kept = kept.lstrip("_.-")[:_FILENAME_STEM_BYTES]
|
|
99
|
+
return (kept or FALLBACK_STEM) + suffix
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_float(value: float) -> str:
|
|
103
|
+
"""Render one binary64 value as the shortest decimal text that round-trips.
|
|
104
|
+
|
|
105
|
+
See the float rule in the module docstring; this function is that rule's one
|
|
106
|
+
implementation. A non-finite value is refused rather than rendered, because the caller
|
|
107
|
+
that has one is meant to emit a missing cell instead.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
if not isinstance(value, float) or isinstance(value, bool):
|
|
111
|
+
raise ReaderError(
|
|
112
|
+
"READER_OUTPUT",
|
|
113
|
+
"reader.output.value",
|
|
114
|
+
"the float rule applies to binary64 values only",
|
|
115
|
+
)
|
|
116
|
+
if not math.isfinite(value):
|
|
117
|
+
raise ReaderError(
|
|
118
|
+
"READER_OUTPUT",
|
|
119
|
+
"reader.output.value",
|
|
120
|
+
"a non-finite value is never rendered; emit a missing cell instead",
|
|
121
|
+
)
|
|
122
|
+
# ``repr`` is Python's shortest-round-trip binary64 formatter. It is named here rather
|
|
123
|
+
# than reimplemented because a hand-rolled shortest-form search is exactly the code that
|
|
124
|
+
# would drift from the rule the docstring pins.
|
|
125
|
+
return repr(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_cell(value: Any, subject: str) -> str:
|
|
129
|
+
"""Render one value as canonical cell text, or as the empty cell when it is missing."""
|
|
130
|
+
|
|
131
|
+
if value is None:
|
|
132
|
+
return ""
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
if "\x00" in value:
|
|
135
|
+
raise ReaderError("READER_OUTPUT", subject, "a cell may not carry a NUL byte")
|
|
136
|
+
return value
|
|
137
|
+
if isinstance(value, bool):
|
|
138
|
+
# Pinned lower-case, so a spreadsheet's boolean seals the same way on every host.
|
|
139
|
+
return "true" if value else "false"
|
|
140
|
+
if isinstance(value, int):
|
|
141
|
+
return str(value)
|
|
142
|
+
if isinstance(value, float):
|
|
143
|
+
return "" if not math.isfinite(value) else format_float(value)
|
|
144
|
+
raise ReaderError(
|
|
145
|
+
"READER_OUTPUT",
|
|
146
|
+
subject,
|
|
147
|
+
f"no pinned rendering exists for a {type(value).__name__} cell",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_columns(
|
|
152
|
+
columns: Sequence[str], budgets: ReaderBudgets, subject: str
|
|
153
|
+
) -> tuple[str, ...]:
|
|
154
|
+
if isinstance(columns, str) or not isinstance(columns, Sequence) or not columns:
|
|
155
|
+
raise ReaderError("READER_OUTPUT", subject, "must be a nonempty sequence of column names")
|
|
156
|
+
if len(columns) > budgets.max_columns:
|
|
157
|
+
raise ReaderError("READER_BUDGET", subject, "exceeds the column budget")
|
|
158
|
+
if any(not isinstance(name, str) or _SAFE_COLUMN.fullmatch(name) is None for name in columns):
|
|
159
|
+
raise ReaderError(
|
|
160
|
+
"READER_OUTPUT",
|
|
161
|
+
subject,
|
|
162
|
+
"column names must be bounded text without control characters",
|
|
163
|
+
)
|
|
164
|
+
if len(set(columns)) != len(columns):
|
|
165
|
+
raise ReaderError("READER_OUTPUT", subject, "column names must be unique")
|
|
166
|
+
return tuple(columns)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_declared_size(
|
|
170
|
+
*,
|
|
171
|
+
row_count: int,
|
|
172
|
+
column_count: int,
|
|
173
|
+
budgets: ReaderBudgets,
|
|
174
|
+
subject: str,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Refuse a declared geometry that is over budget, before anything is decoded.
|
|
177
|
+
|
|
178
|
+
A spreadsheet states its used range and a weather-model message states its grid, so the
|
|
179
|
+
size of the result is knowable before the first cell is read. A family calls this with
|
|
180
|
+
the declared numbers in hand, which is what turns a declared-size bomb into a refusal
|
|
181
|
+
instead of an allocation.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
for name, value in (("row_count", row_count), ("column_count", column_count)):
|
|
185
|
+
if type(value) is not int or value < 0:
|
|
186
|
+
raise ReaderError(
|
|
187
|
+
"READER_BUDGET",
|
|
188
|
+
f"{subject}.{name}",
|
|
189
|
+
"a declared size must be a non-negative integer",
|
|
190
|
+
)
|
|
191
|
+
if column_count > budgets.max_columns:
|
|
192
|
+
raise ReaderError("READER_BUDGET", subject, "the declared column count is over budget")
|
|
193
|
+
if row_count > budgets.max_rows:
|
|
194
|
+
raise ReaderError("READER_BUDGET", subject, "the declared row count is over budget")
|
|
195
|
+
if row_count * column_count > budgets.max_declared_cells:
|
|
196
|
+
raise ReaderError("READER_BUDGET", subject, "the declared cell count is over budget")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def encode_canonical_csv(
|
|
200
|
+
columns: Sequence[str],
|
|
201
|
+
rows: Iterable[Sequence[Any] | Mapping[str, Any]],
|
|
202
|
+
*,
|
|
203
|
+
budgets: ReaderBudgets | None = None,
|
|
204
|
+
) -> bytes:
|
|
205
|
+
"""Encode a table as the canonical sealed bytes: utf-8, ``\\n``, no byte-order mark.
|
|
206
|
+
|
|
207
|
+
Column order is exactly the stated order and never inferred from a row. A row may be a
|
|
208
|
+
sequence matching that order exactly, or a mapping keyed by column name -- a mapping
|
|
209
|
+
naming a column the header does not state is refused rather than dropped, and a column
|
|
210
|
+
the mapping omits is the empty cell.
|
|
211
|
+
|
|
212
|
+
Budgets are enforced while reading the rows, so an over-budget input is refused before
|
|
213
|
+
it is materialised rather than after.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
limits = budgets if budgets is not None else ReaderBudgets()
|
|
217
|
+
header = _validate_columns(columns, limits, "reader.output.columns")
|
|
218
|
+
# One scratch buffer holds one encoded line at a time, and the table accumulates as
|
|
219
|
+
# utf-8 bytes. The whole output therefore exists once, not once as text and once as
|
|
220
|
+
# bytes -- a table near the output budget would otherwise cost twice its own size at
|
|
221
|
+
# the encode boundary. Encoding line by line is byte-identical to encoding the joined
|
|
222
|
+
# text, because utf-8 carries no state between code points.
|
|
223
|
+
scratch = io.StringIO(newline="")
|
|
224
|
+
writer = csv.DictWriter(
|
|
225
|
+
scratch,
|
|
226
|
+
fieldnames=list(header),
|
|
227
|
+
lineterminator="\n",
|
|
228
|
+
extrasaction="raise",
|
|
229
|
+
restval="",
|
|
230
|
+
quotechar=_QUOTE_CHARACTER,
|
|
231
|
+
doublequote=True,
|
|
232
|
+
)
|
|
233
|
+
encoded = bytearray()
|
|
234
|
+
encoded_size = 0
|
|
235
|
+
encode_error: UnicodeEncodeError | None = None
|
|
236
|
+
|
|
237
|
+
def flush_scratch() -> None:
|
|
238
|
+
# The output-byte budget stays an end-of-input refusal, exactly where it has always
|
|
239
|
+
# been: rows keep streaming so an admission refusal later in the input still wins,
|
|
240
|
+
# but the buffer stops growing, so an over-budget input cannot cost its own size in
|
|
241
|
+
# memory before it is refused. An unencodable cell -- a lone surrogate -- is held
|
|
242
|
+
# to the same order: the joined-text form could not crash on it until the loop had
|
|
243
|
+
# finished, so the first such error is recorded here and raised after the loop,
|
|
244
|
+
# before the budget comparison, exactly where the one-shot encode used to raise.
|
|
245
|
+
nonlocal encoded_size, encode_error
|
|
246
|
+
piece_text = scratch.getvalue()
|
|
247
|
+
scratch.seek(0)
|
|
248
|
+
scratch.truncate(0)
|
|
249
|
+
if encode_error is not None:
|
|
250
|
+
return
|
|
251
|
+
try:
|
|
252
|
+
piece = piece_text.encode("utf-8")
|
|
253
|
+
except UnicodeEncodeError as error:
|
|
254
|
+
encode_error = error
|
|
255
|
+
return
|
|
256
|
+
encoded_size += len(piece)
|
|
257
|
+
if encoded_size <= limits.max_output_bytes:
|
|
258
|
+
encoded.extend(piece)
|
|
259
|
+
|
|
260
|
+
writer.writeheader()
|
|
261
|
+
flush_scratch()
|
|
262
|
+
|
|
263
|
+
width = len(header)
|
|
264
|
+
written = 0
|
|
265
|
+
for row in rows:
|
|
266
|
+
if written >= limits.max_rows:
|
|
267
|
+
raise ReaderError("READER_BUDGET", "reader.output.rows", "exceeds the row budget")
|
|
268
|
+
if (written + 1) * width > limits.max_declared_cells:
|
|
269
|
+
raise ReaderError("READER_BUDGET", "reader.output.rows", "exceeds the cell budget")
|
|
270
|
+
subject = f"reader.output.rows[{written}]"
|
|
271
|
+
if isinstance(row, Mapping):
|
|
272
|
+
rendered = {key: _render_cell(value, subject) for key, value in row.items()}
|
|
273
|
+
elif isinstance(row, Sequence) and not isinstance(row, (str, bytes, bytearray)):
|
|
274
|
+
if len(row) != width:
|
|
275
|
+
raise ReaderError("READER_OUTPUT", subject, "row width differs from the header")
|
|
276
|
+
rendered = {
|
|
277
|
+
name: _render_cell(value, subject) for name, value in zip(header, row, strict=True)
|
|
278
|
+
}
|
|
279
|
+
else:
|
|
280
|
+
raise ReaderError("READER_OUTPUT", subject, "a row must be a sequence or a mapping")
|
|
281
|
+
try:
|
|
282
|
+
writer.writerow(rendered)
|
|
283
|
+
except ValueError as error:
|
|
284
|
+
raise ReaderError(
|
|
285
|
+
"READER_OUTPUT",
|
|
286
|
+
subject,
|
|
287
|
+
"row names a column the header does not state",
|
|
288
|
+
) from error
|
|
289
|
+
flush_scratch()
|
|
290
|
+
written += 1
|
|
291
|
+
|
|
292
|
+
if encode_error is not None:
|
|
293
|
+
raise encode_error
|
|
294
|
+
if encoded_size > limits.max_output_bytes:
|
|
295
|
+
raise ReaderError(
|
|
296
|
+
"READER_BUDGET",
|
|
297
|
+
"reader.output.content",
|
|
298
|
+
"exceeds the output byte budget",
|
|
299
|
+
)
|
|
300
|
+
return bytes(encoded)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def decode_delimited(
|
|
304
|
+
content: bytes,
|
|
305
|
+
*,
|
|
306
|
+
delimiter: str,
|
|
307
|
+
header: bool = True,
|
|
308
|
+
budgets: ReaderBudgets | None = None,
|
|
309
|
+
) -> tuple[tuple[str, ...], tuple[tuple[str, ...], ...]]:
|
|
310
|
+
"""Decode delimited text into a header and its rows, refusing what the parser refuses.
|
|
311
|
+
|
|
312
|
+
This is ``decode_delimited_stream`` drained into tuples, for the callers whose input is
|
|
313
|
+
small and whose next step needs the whole table. The admissions, budgets, and refusal
|
|
314
|
+
order are the stream's own -- there is one delimited decoder, not a strict one and a
|
|
315
|
+
lenient one.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
columns, rows = decode_delimited_stream(
|
|
319
|
+
content, delimiter=delimiter, header=header, budgets=budgets
|
|
320
|
+
)
|
|
321
|
+
return columns, tuple(rows)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def decode_delimited_stream(
|
|
325
|
+
content: bytes,
|
|
326
|
+
*,
|
|
327
|
+
delimiter: str,
|
|
328
|
+
header: bool = True,
|
|
329
|
+
budgets: ReaderBudgets | None = None,
|
|
330
|
+
) -> tuple[tuple[str, ...], Iterator[tuple[str, ...]]]:
|
|
331
|
+
"""Decode delimited text into a header and an iterator of admitted rows.
|
|
332
|
+
|
|
333
|
+
The refusals are the ones the acquisition parser already makes, for the same reasons: a
|
|
334
|
+
NUL byte, a repeated column name, and a row whose width differs from its header are all
|
|
335
|
+
refused rather than repaired. Text that is not strict UTF-8 is refused too; one leading
|
|
336
|
+
byte-order mark is removed first, because a mark is an encoding artefact of the file and
|
|
337
|
+
not the first character of the first column's name.
|
|
338
|
+
|
|
339
|
+
When the recipe says there is no header, columns are named by position rather than
|
|
340
|
+
guessed from the first row, so the first row stays data.
|
|
341
|
+
|
|
342
|
+
The header and every prologue refusal are settled before this function returns; each row
|
|
343
|
+
is admitted as it is pulled. Streaming is what keeps a year-scale table from existing
|
|
344
|
+
as millions of live tuples at once: the caller that feeds rows straight into the
|
|
345
|
+
canonical encoder holds one row at a time, and the budgets refuse an over-budget input
|
|
346
|
+
at the same row they always have.
|
|
347
|
+
|
|
348
|
+
Which tokenizer splits the rows is chosen by the input rather than by an option.
|
|
349
|
+
``fast_delimited`` states a closed subset of inputs for which splitting on the line
|
|
350
|
+
terminator and then on the delimiter is the same tokenizing the stdlib parser performs,
|
|
351
|
+
and inside that subset it does the splitting without decoding the whole input into one
|
|
352
|
+
string first. Everything outside the subset -- a quote, a carriage return, a NUL byte,
|
|
353
|
+
a blank line, text that is not strict UTF-8 -- reaches the stdlib parser below and is
|
|
354
|
+
admitted or refused by it exactly as it always was. The one refusal that belongs to the
|
|
355
|
+
tokenizing rather than to this function, a field longer than ``csv.field_size_limit()``,
|
|
356
|
+
the splitter raises itself as the same ``csv.Error``, so it arrives here as the malformed
|
|
357
|
+
refusal it has always arrived as.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
limits = budgets if budgets is not None else ReaderBudgets()
|
|
361
|
+
if (
|
|
362
|
+
not isinstance(delimiter, str)
|
|
363
|
+
or len(delimiter) != 1
|
|
364
|
+
or delimiter == _QUOTE_CHARACTER
|
|
365
|
+
or delimiter in "\r\n"
|
|
366
|
+
):
|
|
367
|
+
raise ReaderError(
|
|
368
|
+
"READER_OPTIONS",
|
|
369
|
+
"reader.decode.delimiter",
|
|
370
|
+
"must be exactly one character that is neither the quote nor a line terminator",
|
|
371
|
+
)
|
|
372
|
+
if not isinstance(content, (bytes, bytearray)):
|
|
373
|
+
raise ReaderError("READER_ADMISSION", "reader.decode.content", "must be exact bytes")
|
|
374
|
+
# Checked before decoding, so an over-budget input is refused without being materialised
|
|
375
|
+
# as text first.
|
|
376
|
+
if len(content) > limits.max_input_bytes:
|
|
377
|
+
raise ReaderError(
|
|
378
|
+
"READER_BUDGET",
|
|
379
|
+
"reader.decode.content",
|
|
380
|
+
"exceeds the input byte budget",
|
|
381
|
+
)
|
|
382
|
+
raw = bytes(content).removeprefix(b"\xef\xbb\xbf")
|
|
383
|
+
# The certified splitter is the same tokenizing for the inputs it admits, so which one ran
|
|
384
|
+
# is not observable in a row, a refusal, or a sealed byte -- only in what the decode cost.
|
|
385
|
+
# An input it declines never reaches it: the stdlib reader below runs from the first byte,
|
|
386
|
+
# including for text that is not strict UTF-8, which the subset excludes rather than
|
|
387
|
+
# refuses so that the refusal keeps its one home.
|
|
388
|
+
certified = certifies(raw, delimiter=delimiter)
|
|
389
|
+
reader: Iterator[list[str]]
|
|
390
|
+
if certified:
|
|
391
|
+
reader = certified_rows(raw, delimiter=delimiter)
|
|
392
|
+
else:
|
|
393
|
+
try:
|
|
394
|
+
text = raw.decode("utf-8", errors="strict")
|
|
395
|
+
except UnicodeDecodeError:
|
|
396
|
+
raise ReaderError(
|
|
397
|
+
"READER_ADMISSION",
|
|
398
|
+
"reader.decode.content",
|
|
399
|
+
"source text must be strict UTF-8",
|
|
400
|
+
) from None
|
|
401
|
+
|
|
402
|
+
reader = csv.reader(
|
|
403
|
+
io.StringIO(text, newline=""),
|
|
404
|
+
delimiter=delimiter,
|
|
405
|
+
quotechar=_QUOTE_CHARACTER,
|
|
406
|
+
doublequote=True,
|
|
407
|
+
strict=True,
|
|
408
|
+
)
|
|
409
|
+
pending: list[list[str]] = []
|
|
410
|
+
try:
|
|
411
|
+
if header:
|
|
412
|
+
first = next(reader, None)
|
|
413
|
+
if first is None:
|
|
414
|
+
raise ReaderError(
|
|
415
|
+
"READER_ADMISSION",
|
|
416
|
+
"reader.decode.header",
|
|
417
|
+
"delimited text has no header row",
|
|
418
|
+
)
|
|
419
|
+
columns = _decoded_columns(first, limits)
|
|
420
|
+
else:
|
|
421
|
+
first = next(reader, None)
|
|
422
|
+
if first is None:
|
|
423
|
+
raise ReaderError(
|
|
424
|
+
"READER_ADMISSION",
|
|
425
|
+
"reader.decode.content",
|
|
426
|
+
"delimited text has no rows",
|
|
427
|
+
)
|
|
428
|
+
positional = [f"column_{index + 1}" for index in range(len(first))]
|
|
429
|
+
columns = _decoded_columns(positional, limits)
|
|
430
|
+
pending.append(first)
|
|
431
|
+
except csv.Error:
|
|
432
|
+
raise ReaderError(
|
|
433
|
+
"READER_ADMISSION",
|
|
434
|
+
"reader.decode.content",
|
|
435
|
+
"delimited text is malformed",
|
|
436
|
+
) from None
|
|
437
|
+
return columns, _admitted_rows(
|
|
438
|
+
reader, pending, len(columns), limits, scan_for_nul=not certified
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _admitted_rows(
|
|
443
|
+
reader: Iterator[list[str]],
|
|
444
|
+
pending: list[list[str]],
|
|
445
|
+
width: int,
|
|
446
|
+
limits: ReaderBudgets,
|
|
447
|
+
*,
|
|
448
|
+
scan_for_nul: bool = True,
|
|
449
|
+
) -> Iterator[tuple[str, ...]]:
|
|
450
|
+
"""Yield each admitted row, refusing exactly what the drained decoder refused.
|
|
451
|
+
|
|
452
|
+
``scan_for_nul`` is not a policy switch and never widens what is admitted: it is off only
|
|
453
|
+
when the whole input has already been searched for a NUL byte and found not to carry one,
|
|
454
|
+
which is one of the conditions the certified subset is defined by. A cell of an input
|
|
455
|
+
with no NUL byte in it cannot contain a NUL byte, so the scan below would spend a pass
|
|
456
|
+
over every cell to reach the answer that pass already has.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
admitted = 0
|
|
460
|
+
try:
|
|
461
|
+
# Chained rather than unpacked: unpacking would read the whole input into a list
|
|
462
|
+
# before the first budget check, which is the allocation the budgets exist to
|
|
463
|
+
# prevent.
|
|
464
|
+
for raw_row in itertools.chain(pending, reader):
|
|
465
|
+
if admitted >= limits.max_rows:
|
|
466
|
+
raise ReaderError("READER_BUDGET", "reader.decode.rows", "exceeds the row budget")
|
|
467
|
+
if (admitted + 1) * width > limits.max_declared_cells:
|
|
468
|
+
raise ReaderError("READER_BUDGET", "reader.decode.rows", "exceeds the cell budget")
|
|
469
|
+
if len(raw_row) != width:
|
|
470
|
+
raise ReaderError(
|
|
471
|
+
"READER_ADMISSION",
|
|
472
|
+
f"reader.decode.rows[{admitted}]",
|
|
473
|
+
"row width differs from its header",
|
|
474
|
+
)
|
|
475
|
+
if scan_for_nul:
|
|
476
|
+
for cell in raw_row:
|
|
477
|
+
if "\x00" in cell:
|
|
478
|
+
raise ReaderError(
|
|
479
|
+
"READER_ADMISSION",
|
|
480
|
+
f"reader.decode.rows[{admitted}]",
|
|
481
|
+
"delimited text contains a NUL byte",
|
|
482
|
+
)
|
|
483
|
+
yield tuple(raw_row)
|
|
484
|
+
admitted += 1
|
|
485
|
+
except csv.Error:
|
|
486
|
+
raise ReaderError(
|
|
487
|
+
"READER_ADMISSION",
|
|
488
|
+
"reader.decode.content",
|
|
489
|
+
"delimited text is malformed",
|
|
490
|
+
) from None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _decoded_columns(values: Sequence[str], budgets: ReaderBudgets) -> tuple[str, ...]:
|
|
494
|
+
if not values:
|
|
495
|
+
raise ReaderError(
|
|
496
|
+
"READER_ADMISSION",
|
|
497
|
+
"reader.decode.header",
|
|
498
|
+
"table must have a nonempty column set",
|
|
499
|
+
)
|
|
500
|
+
if len(values) > budgets.max_columns:
|
|
501
|
+
raise ReaderError("READER_BUDGET", "reader.decode.header", "exceeds the column budget")
|
|
502
|
+
if any(_SAFE_COLUMN.fullmatch(name) is None for name in values):
|
|
503
|
+
raise ReaderError(
|
|
504
|
+
"READER_ADMISSION",
|
|
505
|
+
"reader.decode.header",
|
|
506
|
+
"column names must be bounded text without control characters",
|
|
507
|
+
)
|
|
508
|
+
if len(set(values)) != len(values):
|
|
509
|
+
raise ReaderError("READER_ADMISSION", "reader.decode.header", "column names must be unique")
|
|
510
|
+
return tuple(values)
|