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,178 @@
|
|
|
1
|
+
"""One rule for reading a file somebody named on the command line, in one place.
|
|
2
|
+
|
|
3
|
+
Every command that takes a path from a person has the same three questions to answer before it
|
|
4
|
+
reads a byte: is there anything there, is what is there a plain file, and is it small enough. The
|
|
5
|
+
obvious way to answer them -- ``path.stat()``, then ``path.is_file()``, then ``path.read_bytes()``
|
|
6
|
+
-- gets all three wrong at once. ``st_size`` is 0 for a FIFO, a pipe and a character device, so a
|
|
7
|
+
cap taken off the path governs nothing; the stat follows links, so a link to ``/dev/zero`` reads
|
|
8
|
+
the same way; and the file the size was measured on is not the file the read then opens, so a
|
|
9
|
+
declared bound is a bound on a moment that has passed.
|
|
10
|
+
|
|
11
|
+
So the rule here is the open-then-fstat rule the sealed tree opens everything with: open once, ask
|
|
12
|
+
the descriptor what it is, and read that same descriptor under a bound. ``O_NONBLOCK`` is part of
|
|
13
|
+
the rule rather than a flourish -- opening a FIFO for reading blocks until somebody opens the other
|
|
14
|
+
end, so without it the refusal below could never be reached and the command would hang instead of
|
|
15
|
+
answering. On a plain file the flag does nothing at all.
|
|
16
|
+
|
|
17
|
+
**Why the refusal is untyped here.** :class:`PlainFileRefusal` carries a reason and the path and no
|
|
18
|
+
wording, because the wording belongs to the command the person ran: ``author`` says a Recipe is
|
|
19
|
+
written in a file, ``peek`` says there is nothing there to look at, and neither sentence would read
|
|
20
|
+
right on the other surface. What the callers share is the rule, and sharing the rule is the whole
|
|
21
|
+
point: one implementation keeps every command on the same file-type and byte-boundary contract.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import stat
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Why a named file was not read. Four reasons, so a caller answers four sentences rather than
|
|
31
|
+
# branching on an errno.
|
|
32
|
+
MISSING = "missing"
|
|
33
|
+
NOT_PLAIN = "not a plain file"
|
|
34
|
+
TOO_LARGE = "too large"
|
|
35
|
+
|
|
36
|
+
# ``os.open`` on a link that leads nowhere raises ``FileNotFoundError``, but the named entry still
|
|
37
|
+
# exists and needs link-specific repair. It is a distinct *reason* rather than a shade of missing
|
|
38
|
+
# because absence and a dangling link name different fixes.
|
|
39
|
+
DANGLING = "a link that leads nowhere"
|
|
40
|
+
|
|
41
|
+
# The non-blocking open is the FIFO part of the rule, and it is a POSIX part: Windows has no
|
|
42
|
+
# ``O_NONBLOCK`` and no FIFO a plain ``open`` could hang on, so there the flag is simply absent.
|
|
43
|
+
# Looked up once here rather than at the call, so the thin client (ADR 0021) answers ``whoami``
|
|
44
|
+
# on Windows instead of failing before it reads a byte -- the any-OS smoke matrix found this.
|
|
45
|
+
_NONBLOCKING = getattr(os, "O_NONBLOCK", 0)
|
|
46
|
+
|
|
47
|
+
# ⚠ THE OTHER HALF OF THAT SENTENCE, AND THE HALF THE MATRIX CANNOT SEE. ``O_BINARY`` exists only
|
|
48
|
+
# on Windows, where ``os.open`` otherwise leaves the C runtime free to translate CRLF on the way
|
|
49
|
+
# through -- so the bytes this rule returned would not be the bytes on disk. Every caller reads
|
|
50
|
+
# something whose EXACT bytes are the point: a canonical-JSON credential that is parsed back, a
|
|
51
|
+
# Recipe that is about to be digested. A silently rewritten byte is not a crash the smoke job
|
|
52
|
+
# could catch; it is a digest that no longer matches, on one operating system only.
|
|
53
|
+
_BINARY = getattr(os, "O_BINARY", 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PlainFileRefusal(Exception):
|
|
57
|
+
"""A named file this rule will not read, with the reason and the path but no wording.
|
|
58
|
+
|
|
59
|
+
Every caller translates this into its own vocabulary and its own error class. Nothing catches
|
|
60
|
+
it at the command line, so a caller that forgets to translate fails loudly in its own tests
|
|
61
|
+
rather than reaching a person as an untyped traceback.
|
|
62
|
+
|
|
63
|
+
``mode`` is the ``st_mode`` the descriptor reported, for :data:`NOT_PLAIN`; the ``st_mode`` the
|
|
64
|
+
one ``lstat`` reported, for :data:`DANGLING`; and ``None`` for the other two reasons. It is not
|
|
65
|
+
a reason of its own and it is not a case added to the rule: it is the fact this rule already
|
|
66
|
+
looked at to decide, handed back rather than thrown away, so a caller whose vocabulary
|
|
67
|
+
distinguishes a folder from a pipe can say which it found. A caller whose vocabulary does not
|
|
68
|
+
-- ``peek`` answers all four with one sentence -- ignores it.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, reason: str, path: Path, *, mode: int | None = None) -> None:
|
|
72
|
+
self.reason = reason
|
|
73
|
+
self.path = path
|
|
74
|
+
self.mode = mode
|
|
75
|
+
super().__init__(f"{path}: {reason}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def open_plain_file(path: Path | str) -> tuple[int, int]:
|
|
79
|
+
"""A descriptor on a plain file and the size that descriptor reports.
|
|
80
|
+
|
|
81
|
+
The caller owns the descriptor and must close it. Only :data:`MISSING`, :data:`DANGLING` and
|
|
82
|
+
:data:`NOT_PLAIN` are answered here; every other ``OSError`` -- a permission refusal, a
|
|
83
|
+
component of the path that is not a folder -- is left to travel, because those are the command
|
|
84
|
+
line's own boundary to answer and inventing a sentence for them here would be a second answer
|
|
85
|
+
to a question that already has one.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
target = Path(path)
|
|
89
|
+
try:
|
|
90
|
+
descriptor = os.open(target, os.O_RDONLY | _NONBLOCKING | _BINARY)
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
# One `lstat` before the answer, because `ENOENT` is two findings and only one of them is
|
|
93
|
+
# "nothing is there". The open resolved the name and found nothing at the end of it; this
|
|
94
|
+
# asks about the name itself, without following it, and an entry that answers is an entry
|
|
95
|
+
# that exists. Only a look that also fails is `MISSING`.
|
|
96
|
+
try:
|
|
97
|
+
entry = os.lstat(target)
|
|
98
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
99
|
+
raise PlainFileRefusal(MISSING, target) from None
|
|
100
|
+
except OSError as refused_look:
|
|
101
|
+
# The name resolved to nothing and the look itself was refused, so this rule has no
|
|
102
|
+
# finding of its own; the operating system's refusal is the answer, and the command
|
|
103
|
+
# line's boundary already has a sentence for it. `MISSING` here would be a guess.
|
|
104
|
+
raise refused_look from None
|
|
105
|
+
raise PlainFileRefusal(DANGLING, target, mode=entry.st_mode) from None
|
|
106
|
+
except OSError as open_error:
|
|
107
|
+
# Some kernels refuse to open a Unix-domain socket even with ``O_NONBLOCK``. The
|
|
108
|
+
# descriptor-first rule therefore cannot reach ``fstat`` for that path kind. A
|
|
109
|
+
# best-effort fallback classification is safe here because it is used only to refuse a
|
|
110
|
+
# non-regular target; it never licenses a file for reading. If the path vanished, is a
|
|
111
|
+
# regular file, or cannot be inspected, preserve the original system refusal.
|
|
112
|
+
try:
|
|
113
|
+
refused = target.stat()
|
|
114
|
+
except OSError:
|
|
115
|
+
raise open_error from None
|
|
116
|
+
if not stat.S_ISREG(refused.st_mode):
|
|
117
|
+
raise PlainFileRefusal(NOT_PLAIN, target, mode=refused.st_mode) from None
|
|
118
|
+
raise
|
|
119
|
+
try:
|
|
120
|
+
opened = os.fstat(descriptor)
|
|
121
|
+
except BaseException:
|
|
122
|
+
os.close(descriptor)
|
|
123
|
+
raise
|
|
124
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
125
|
+
os.close(descriptor)
|
|
126
|
+
raise PlainFileRefusal(NOT_PLAIN, target, mode=opened.st_mode)
|
|
127
|
+
return descriptor, opened.st_size
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_bounded(descriptor: int, *, max_bytes: int) -> bytes:
|
|
131
|
+
"""Everything the descriptor holds, up to one byte past the cap and never further.
|
|
132
|
+
|
|
133
|
+
The cap governs the read as well as the size check above it. A file that grows between the
|
|
134
|
+
``fstat`` and the read is the case the size check alone cannot see, and it is the case a
|
|
135
|
+
declared bound exists for.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
chunks: list[bytes] = []
|
|
139
|
+
remaining = max_bytes + 1
|
|
140
|
+
while remaining > 0:
|
|
141
|
+
chunk = os.read(descriptor, min(remaining, 1 << 16))
|
|
142
|
+
if not chunk:
|
|
143
|
+
break
|
|
144
|
+
chunks.append(chunk)
|
|
145
|
+
remaining -= len(chunk)
|
|
146
|
+
return b"".join(chunks)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_plain_file(path: Path | str, *, max_bytes: int) -> bytes:
|
|
150
|
+
"""The whole rule in one call: open it, check it, read it under the bound.
|
|
151
|
+
|
|
152
|
+
Both refusals a size can produce are :data:`TOO_LARGE`: the one the descriptor reports before
|
|
153
|
+
the read, and the one the read itself finds when the file grew in between.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
target = Path(path)
|
|
157
|
+
descriptor, size = open_plain_file(target)
|
|
158
|
+
try:
|
|
159
|
+
if size > max_bytes:
|
|
160
|
+
raise PlainFileRefusal(TOO_LARGE, target)
|
|
161
|
+
raw = read_bounded(descriptor, max_bytes=max_bytes)
|
|
162
|
+
finally:
|
|
163
|
+
os.close(descriptor)
|
|
164
|
+
if len(raw) > max_bytes:
|
|
165
|
+
raise PlainFileRefusal(TOO_LARGE, target)
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"DANGLING",
|
|
171
|
+
"MISSING",
|
|
172
|
+
"NOT_PLAIN",
|
|
173
|
+
"TOO_LARGE",
|
|
174
|
+
"PlainFileRefusal",
|
|
175
|
+
"open_plain_file",
|
|
176
|
+
"read_bounded",
|
|
177
|
+
"read_plain_file",
|
|
178
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Try a plan by building it for real into a folder that is then thrown away.
|
|
2
|
+
|
|
3
|
+
The command exercises the real build path without retaining an immutable output directory. It is
|
|
4
|
+
the safe way to test a plan before choosing the path for a durable Build.
|
|
5
|
+
|
|
6
|
+
**There is one build path, and this is it.** :func:`pipeline.build_candidate` is called unmodified,
|
|
7
|
+
with the plan value exactly as it was handed in. Nothing here re-implements a lighter check, skips a
|
|
8
|
+
gate, or relaxes a limit: a check that is easier to pass than execution is invalid
|
|
9
|
+
you wanted to know. A recording-wrapper test asserts that call happens exactly once, so this
|
|
10
|
+
paragraph is machine-checked rather than believed.
|
|
11
|
+
|
|
12
|
+
**Nobody ran this build, so its fingerprint is nobody's.** A build's own fingerprint binds who ran
|
|
13
|
+
it and under which attempt; those two values are synthesised here (:data:`CHECK_PRINCIPAL`,
|
|
14
|
+
:data:`CHECK_ATTEMPT`) because a dry run has no real runner. The fingerprint that comes out is
|
|
15
|
+
therefore not the one the real build will have, and reporting it would send somebody chasing a
|
|
16
|
+
mismatch that was never a mismatch. :class:`PlanCheck` has no field for it -- absent, not empty --
|
|
17
|
+
and every run says so in plain words. The *data* fingerprint is a different
|
|
18
|
+
matter: it depends on the plan and the sources and nothing else, so it is reported, and a test
|
|
19
|
+
measures it against a real build of the same plan rather than against a written-down constant.
|
|
20
|
+
|
|
21
|
+
**The folder goes away, whatever happens.** The cleanup runs in a ``finally``, so an interrupt takes
|
|
22
|
+
the folder with it. It also has to survive a recorded quirk of this repository: the folder a build
|
|
23
|
+
seals is mode 0555 on purpose, so removing it means restoring owner write permission from the bottom
|
|
24
|
+
up first. A cleanup that fails is reported as a fact rather than raised, because a folder left
|
|
25
|
+
behind is worth knowing about but is not a reason to call a build that worked a failure.
|
|
26
|
+
|
|
27
|
+
**Two things about the folder are checked before anything is built into it.** It must be private to
|
|
28
|
+
its owner, and it must not sit inside the folder the sources are read from -- the second because a
|
|
29
|
+
build refuses to read from the folder it writes to, and meeting that refusal through a path you
|
|
30
|
+
never chose would be a puzzle rather than an answer. Both are refusals that name themselves, and
|
|
31
|
+
both clean up the folder they refused.
|
|
32
|
+
|
|
33
|
+
One more thing, recorded because it costs an afternoon otherwise: the system temporary folder is
|
|
34
|
+
often reached through a link, and sealing refuses an output whose parent chain contains one. The
|
|
35
|
+
folder is therefore resolved to its real path before anything is built into it.
|
|
36
|
+
|
|
37
|
+
Nothing here prints. :meth:`PlanCheck.to_dict` returns the one payload both renderings are built
|
|
38
|
+
from, so the same facts can be shown in a browser later without re-deriving them.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import stat
|
|
46
|
+
import tempfile
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from mostlyright.data_harness import pipeline
|
|
52
|
+
from mostlyright.data_harness.local_contracts import (
|
|
53
|
+
GraphTablePlan,
|
|
54
|
+
TablePlan,
|
|
55
|
+
parse_fresh_plan_document,
|
|
56
|
+
refuse_inconsistent_units,
|
|
57
|
+
refuse_retired_operations,
|
|
58
|
+
)
|
|
59
|
+
from mostlyright.data_harness.pipeline import REFERENCE_BACKEND_NAME, BuildError
|
|
60
|
+
|
|
61
|
+
# Calling the command's check builder rather than restating its wording keeps one
|
|
62
|
+
# check reading the same in `mr-data plan-check` as it does in `mr-data review --local`. Two
|
|
63
|
+
# spellings of one check are two things a person has to learn.
|
|
64
|
+
from mostlyright.data_harness.ux.local_review import CHECKS_KEY, LocalCheck, _quality_checks
|
|
65
|
+
|
|
66
|
+
# The one status this command reports. A plan that does not build raises instead, so this word is
|
|
67
|
+
# never on a failure.
|
|
68
|
+
PLAN_CHECK_STATUS = "plan_checked"
|
|
69
|
+
|
|
70
|
+
# The synthetic identity every dry run builds under. Marked as a check rather than borrowed from
|
|
71
|
+
# the caller: these two values are the reason the resulting build fingerprint is not the real
|
|
72
|
+
# build's, and a value that came from the operator would blur that distinction.
|
|
73
|
+
CHECK_PRINCIPAL = "plan-check"
|
|
74
|
+
CHECK_ATTEMPT = "throwaway"
|
|
75
|
+
|
|
76
|
+
# The name the folder is made under, so a leftover one can be recognised for what it is.
|
|
77
|
+
TEMP_PREFIX = "mr-data-plan-check-"
|
|
78
|
+
|
|
79
|
+
# What `mkdtemp` promises. Asserted rather than assumed: a whole build lands inside it.
|
|
80
|
+
PRIVATE_MODE = 0o700
|
|
81
|
+
|
|
82
|
+
# The two refusals this module makes on its own. They are spelled out again at the raise sites
|
|
83
|
+
# below, because the gate that proves every code names its fix reads code literals out of the
|
|
84
|
+
# source; a test asserts the raised identifier is the one named here, so the pair cannot drift.
|
|
85
|
+
TEMP_OVERLAP_CODE = "PLAN_CHECK_TEMP_OVERLAP"
|
|
86
|
+
TEMP_NOT_PRIVATE_CODE = "PLAN_CHECK_TEMP_NOT_PRIVATE"
|
|
87
|
+
|
|
88
|
+
# Where the closing sentence sits in the payload. It is a key a person reads, and it renders last.
|
|
89
|
+
UNPREDICTED_KEY = "what this does not tell you"
|
|
90
|
+
|
|
91
|
+
# Why no build fingerprint is shown. Said in the same breath as the data fingerprint that is
|
|
92
|
+
# shown, because the distinction between the two is the whole of what this command has to teach.
|
|
93
|
+
WHY_NO_BUILD_FINGERPRINT = (
|
|
94
|
+
"The data fingerprint above is the one your real build will produce. The build's own "
|
|
95
|
+
"fingerprint is not shown, because it depends on who runs the build and under which attempt, "
|
|
96
|
+
"and a fingerprint that will not match is worse than none."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_DELETED = "This was a real build, into a folder that has now been deleted."
|
|
100
|
+
_KEPT = (
|
|
101
|
+
"This was a real build, into the folder named above, which was left in place as you asked. "
|
|
102
|
+
"Run chmod -R u+w on it before removing it: the folder a build seals is read-only on purpose."
|
|
103
|
+
)
|
|
104
|
+
_NOT_REMOVED = (
|
|
105
|
+
"This was a real build, into the folder named above, which could not be removed afterwards. "
|
|
106
|
+
"Run chmod -R u+w on it before removing it: the folder a build seals is read-only on purpose."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class PlanCheck:
|
|
112
|
+
"""What a real build of one plan produced, from a folder that no longer exists.
|
|
113
|
+
|
|
114
|
+
``built`` is a property rather than a field because a plan that does not build raises: this
|
|
115
|
+
object exists only when the build happened, so there is no state in which it could be false.
|
|
116
|
+
There is deliberately no field for the build's own fingerprint because the synthetic runner
|
|
117
|
+
identity guarantees that it will differ from the durable Build's fingerprint.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
row_count: int
|
|
121
|
+
columns: tuple[str, ...]
|
|
122
|
+
table_sha256: str
|
|
123
|
+
quality: tuple[LocalCheck, ...]
|
|
124
|
+
engine: str
|
|
125
|
+
temp_root: Path
|
|
126
|
+
cleanup_failed: bool = False
|
|
127
|
+
kept: bool = False
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def built(self) -> bool:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def removed(self) -> bool:
|
|
135
|
+
"""Whether the throwaway folder is gone. False when it was kept, or would not go."""
|
|
136
|
+
|
|
137
|
+
return not self.kept and not self.cleanup_failed
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, Any]:
|
|
140
|
+
"""The one payload both renderings are built from."""
|
|
141
|
+
|
|
142
|
+
width = len(str(max(len(self.quality), 1)))
|
|
143
|
+
payload: dict[str, Any] = {
|
|
144
|
+
"status": PLAN_CHECK_STATUS,
|
|
145
|
+
"built": self.built,
|
|
146
|
+
"table_sha256": self.table_sha256,
|
|
147
|
+
"row_count": self.row_count,
|
|
148
|
+
"columns": list(self.columns),
|
|
149
|
+
"engine": self.engine,
|
|
150
|
+
"throwaway_folder_removed": self.removed,
|
|
151
|
+
}
|
|
152
|
+
if not self.removed:
|
|
153
|
+
# Named only when there is something to go and look at, and named before the sentence
|
|
154
|
+
# that tells you what to do with it.
|
|
155
|
+
payload["throwaway_folder"] = str(self.temp_root)
|
|
156
|
+
payload[CHECKS_KEY] = {
|
|
157
|
+
f"check {index:0{width}d}": check.line
|
|
158
|
+
for index, check in enumerate(self.quality, start=1)
|
|
159
|
+
}
|
|
160
|
+
payload[UNPREDICTED_KEY] = f"{self._folder_sentence()} {WHY_NO_BUILD_FINGERPRINT}"
|
|
161
|
+
return payload
|
|
162
|
+
|
|
163
|
+
def _folder_sentence(self) -> str:
|
|
164
|
+
if self.removed:
|
|
165
|
+
return _DELETED
|
|
166
|
+
return _KEPT if self.kept else _NOT_REMOVED
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_plan(
|
|
170
|
+
plan_value: Any,
|
|
171
|
+
*,
|
|
172
|
+
input_root: Path | str,
|
|
173
|
+
engine: str = REFERENCE_BACKEND_NAME,
|
|
174
|
+
keep: bool = False,
|
|
175
|
+
) -> PlanCheck:
|
|
176
|
+
"""Build ``plan_value`` for real into a throwaway folder and report what came out.
|
|
177
|
+
|
|
178
|
+
``keep`` leaves the folder in place for someone looking at a failure; every other path removes
|
|
179
|
+
it, including the one an interrupt takes. A refusal from the build is raised exactly as the
|
|
180
|
+
real build raises it, so a plan that would fail fails here in the same words.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
# The same gate `build` applies to the same bytes: a dry run of a plan nobody could build is
|
|
184
|
+
# a dry run that lies about what the real build would do. The isinstance short-circuit mirrors
|
|
185
|
+
# `build_candidate`, which this function hands the value straight to: it accepts an
|
|
186
|
+
# already-parsed plan, and so must the exported reuse point in front of it.
|
|
187
|
+
if isinstance(plan_value, (TablePlan, GraphTablePlan)):
|
|
188
|
+
refuse_retired_operations(plan_value)
|
|
189
|
+
refuse_inconsistent_units(plan_value)
|
|
190
|
+
else:
|
|
191
|
+
parse_fresh_plan_document(plan_value)
|
|
192
|
+
root = Path(os.path.realpath(input_root))
|
|
193
|
+
# Resolved to its real path: the system temporary folder is often reached through a link, and
|
|
194
|
+
# sealing refuses an output whose parent chain contains one.
|
|
195
|
+
temp_root = Path(os.path.realpath(tempfile.mkdtemp(prefix=TEMP_PREFIX)))
|
|
196
|
+
try:
|
|
197
|
+
_require_private(temp_root)
|
|
198
|
+
_refuse_overlap(temp_root, root)
|
|
199
|
+
except BaseException:
|
|
200
|
+
# A folder nobody asked for and nothing was built into is never left behind, whatever the
|
|
201
|
+
# caller asked to keep.
|
|
202
|
+
_remove_tree(temp_root)
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
cleanup_failed = False
|
|
206
|
+
try:
|
|
207
|
+
try:
|
|
208
|
+
# The real build, unmodified, on the plan value exactly as it was given. Called
|
|
209
|
+
# through the module so a test can record the call without a second build path
|
|
210
|
+
# existing for it to record.
|
|
211
|
+
result = pipeline.build_candidate(
|
|
212
|
+
plan_value,
|
|
213
|
+
input_root=root,
|
|
214
|
+
output_dir=temp_root / "check",
|
|
215
|
+
producer_principal=CHECK_PRINCIPAL,
|
|
216
|
+
producer_attempt=CHECK_ATTEMPT,
|
|
217
|
+
engine=engine,
|
|
218
|
+
)
|
|
219
|
+
# One reading of the sealed tree, for the facts a plan's author asked about: what the
|
|
220
|
+
# columns came out as, and which checks it passed.
|
|
221
|
+
inspection = pipeline.inspect_candidate(result.run_dir)
|
|
222
|
+
except BuildError as error:
|
|
223
|
+
raise _named_if_kept(error, temp_root, keep=keep) from error
|
|
224
|
+
finally:
|
|
225
|
+
if not keep:
|
|
226
|
+
cleanup_failed = not _remove_tree(temp_root)
|
|
227
|
+
|
|
228
|
+
return PlanCheck(
|
|
229
|
+
row_count=inspection.row_count,
|
|
230
|
+
columns=tuple(inspection.columns),
|
|
231
|
+
table_sha256=inspection.table_sha256,
|
|
232
|
+
quality=tuple(_quality_checks(inspection.quality)),
|
|
233
|
+
engine=engine,
|
|
234
|
+
temp_root=temp_root,
|
|
235
|
+
cleanup_failed=cleanup_failed,
|
|
236
|
+
kept=keep,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _named_if_kept(error: BuildError, temp_root: Path, *, keep: bool) -> BuildError:
|
|
241
|
+
"""The build's own refusal, saying where the folder was left when one was left.
|
|
242
|
+
|
|
243
|
+
The kind, the identifier and the severity are the real build's; only the message gains the
|
|
244
|
+
place to go and look. A refusal that keeps evidence and does not say where it put it is a
|
|
245
|
+
refusal somebody has to guess at.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
if not keep:
|
|
249
|
+
return error
|
|
250
|
+
return BuildError(
|
|
251
|
+
error.finding_id,
|
|
252
|
+
f"{error}; the folder it was building into was kept at {temp_root}",
|
|
253
|
+
severity=error.severity,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _require_private(temp_root: Path) -> None:
|
|
258
|
+
"""Refuse a folder other people can read. A whole build is about to land inside it."""
|
|
259
|
+
|
|
260
|
+
mode = stat.S_IMODE(temp_root.stat().st_mode)
|
|
261
|
+
if mode != PRIVATE_MODE:
|
|
262
|
+
raise BuildError(
|
|
263
|
+
"PLAN_CHECK_TEMP_NOT_PRIVATE",
|
|
264
|
+
f"the temporary folder {temp_root} is not private to its owner",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _refuse_overlap(temp_root: Path, root: Path) -> None:
|
|
269
|
+
"""Refuse a temporary folder inside the input folder, in words about the folder that moved.
|
|
270
|
+
|
|
271
|
+
A build refuses to read from the folder it writes to. That refusal is right, but meeting it
|
|
272
|
+
through a path the caller never chose is a puzzle, so the overlap is named here instead.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
if temp_root == root or root in temp_root.parents or temp_root in root.parents:
|
|
276
|
+
raise BuildError(
|
|
277
|
+
"PLAN_CHECK_TEMP_OVERLAP",
|
|
278
|
+
f"the temporary folder {temp_root} sits inside the input folder {root}",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _remove_tree(temp_root: Path) -> bool:
|
|
283
|
+
"""Remove the throwaway folder, restoring write permission first. True when it is gone.
|
|
284
|
+
|
|
285
|
+
A sealed build is mode 0555 on purpose, so a plain removal fails with a permission error part
|
|
286
|
+
way through and leaves the rest behind. Owner write is added from the bottom up -- the
|
|
287
|
+
directories are what the removal needs, and a directory has to be walked before it is fixed.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
for directory, _dirnames, _filenames in os.walk(temp_root, topdown=False):
|
|
292
|
+
os.chmod(directory, PRIVATE_MODE)
|
|
293
|
+
shutil.rmtree(temp_root)
|
|
294
|
+
except OSError:
|
|
295
|
+
return False
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = [
|
|
300
|
+
"CHECK_ATTEMPT",
|
|
301
|
+
"CHECK_PRINCIPAL",
|
|
302
|
+
"PLAN_CHECK_STATUS",
|
|
303
|
+
"PRIVATE_MODE",
|
|
304
|
+
"TEMP_NOT_PRIVATE_CODE",
|
|
305
|
+
"TEMP_OVERLAP_CODE",
|
|
306
|
+
"TEMP_PREFIX",
|
|
307
|
+
"UNPREDICTED_KEY",
|
|
308
|
+
"WHY_NO_BUILD_FINGERPRINT",
|
|
309
|
+
"PlanCheck",
|
|
310
|
+
"check_plan",
|
|
311
|
+
]
|