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,1124 @@
|
|
|
1
|
+
"""Reading the builds you already have: one build in full, and every build under a folder.
|
|
2
|
+
|
|
3
|
+
Two readings live here, and the difference between them is the whole point of the module.
|
|
4
|
+
|
|
5
|
+
The full reading goes through :func:`pipeline.inspect_candidate` -- the descriptor-relative,
|
|
6
|
+
ancestor-race-checked, lease-validated reader that replays the sealed tree before it reports a
|
|
7
|
+
single fact. Nothing on that path opens a receipt with a plain read, because a plain read looks
|
|
8
|
+
identical and silently drops every guarantee this product sells.
|
|
9
|
+
|
|
10
|
+
The listing is deliberately cheap. It reads the receipt header off each folder without replaying
|
|
11
|
+
anything, so a folder full of builds lists in the time one verification would take. That is a real
|
|
12
|
+
trade and it is stated out loud rather than hidden: every listed row, in both output modes, ends
|
|
13
|
+
with ``listed, not verified`` until someone asks for the re-check.
|
|
14
|
+
|
|
15
|
+
Nothing here prints. Every function returns facts or text, so the same reading can be rendered
|
|
16
|
+
into a browser page later without re-deriving it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import errno
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import tempfile
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import fcntl
|
|
33
|
+
except ImportError: # pragma: no cover - descriptor workspaces are POSIX-only
|
|
34
|
+
fcntl = None # type: ignore[assignment]
|
|
35
|
+
|
|
36
|
+
from mostlyright.data_harness.canonical import CanonicalJSONError, parse_json
|
|
37
|
+
from mostlyright.data_harness.pipeline import (
|
|
38
|
+
MAX_CANDIDATE_BYTES,
|
|
39
|
+
MAX_CANDIDATE_MEMBER_BYTES,
|
|
40
|
+
RECIPE_MANIFEST_VERSION,
|
|
41
|
+
BuildError,
|
|
42
|
+
inspect_candidate,
|
|
43
|
+
)
|
|
44
|
+
from mostlyright.data_harness.recipe import version_bindings, version_summary
|
|
45
|
+
from mostlyright.data_harness.ux.headline import naming, plain_headline
|
|
46
|
+
from mostlyright.data_harness.ux.path_kind import (
|
|
47
|
+
A_FILE,
|
|
48
|
+
A_FOLDER,
|
|
49
|
+
UNKNOWN_KIND,
|
|
50
|
+
entry_at,
|
|
51
|
+
kind_at,
|
|
52
|
+
presence_at,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Where builds land when nobody says otherwise, and how far down to look for them. Six levels
|
|
56
|
+
# covers `.work/<anything>/<a few nested folders>/<build>` without ever walking a whole home
|
|
57
|
+
# directory by accident.
|
|
58
|
+
DEFAULT_LIST_ROOT = Path(".work")
|
|
59
|
+
DEFAULT_LIST_DEPTH = 6
|
|
60
|
+
MAX_LIST_DEPTH = 32
|
|
61
|
+
|
|
62
|
+
# What a listing says about itself. These are the exact phrases the honesty test pins, and they
|
|
63
|
+
# are the same strings in the plain lines and in the machine-readable object.
|
|
64
|
+
LISTED_ONLY = "listed, not verified"
|
|
65
|
+
VERIFIED = "verified"
|
|
66
|
+
UNREADABLE = "could not be read"
|
|
67
|
+
# What the listing as a whole says about itself once `--verify` has run. The listing-level word is
|
|
68
|
+
# derived from the entries and never from the flag: asking for a re-check is not the same fact as
|
|
69
|
+
# every build passing one, and a listing that reports the mode it was asked for under the same key
|
|
70
|
+
# an entry uses for its outcome tells a machine that a tampered tree verified.
|
|
71
|
+
NOT_ALL_VERIFIED = "not all verified"
|
|
72
|
+
NOTHING_TO_VERIFY = "nothing to verify"
|
|
73
|
+
# A folder the walk stopped at that holds no Build at all -- a Workbench folder made but not run
|
|
74
|
+
# yet, most often. It is not a build that could not be read, and saying so is the difference
|
|
75
|
+
# between "you have not run this" and "something is wrong with this".
|
|
76
|
+
NOTHING_BUILT = "nothing built here yet"
|
|
77
|
+
|
|
78
|
+
# The two plain sentences an entry can carry instead of a build's facts. They are sentences rather
|
|
79
|
+
# than the exception's own text on purpose: an errno string names an internal path nobody typed,
|
|
80
|
+
# and a raise-site message is written in the words the module around it uses. The exact wording is
|
|
81
|
+
# never lost -- it is what `mr-data show` and `mr-data verify` print for that same folder, and the
|
|
82
|
+
# listing's note says so.
|
|
83
|
+
NOTHING_BUILT_REASON = "Nothing has been built in this folder yet."
|
|
84
|
+
UNREADABLE_REASON = "Its Receipt is there but could not be read."
|
|
85
|
+
# A Workbench folder whose durable record could not be read at all. The Build underneath may be
|
|
86
|
+
# perfectly good -- `mr-data show` on it often still answers -- but the listing does not know where
|
|
87
|
+
# it is, and saying "nothing has been built here" about a folder that holds a Receipt is the one
|
|
88
|
+
# answer that is affirmatively false.
|
|
89
|
+
UNRESOLVED_REASON = "Its Workbench record could not be read, so its Build was not opened."
|
|
90
|
+
# A descendant the descriptor-rooted walk could not even classify. It cannot truthfully claim a
|
|
91
|
+
# Receipt or Workbench record exists, but it must still make the incomplete part of the catalog a
|
|
92
|
+
# typed unreadable row instead of making that subtree indistinguishable from an empty one.
|
|
93
|
+
DESCENDANT_UNREADABLE_REASON = "This folder could not be read, so any Build in it was not opened."
|
|
94
|
+
# The commonest reason a Workbench record cannot be read right now, and the only one that resolves
|
|
95
|
+
# itself: a build is holding the folder. The listing says so and moves on rather than joining the
|
|
96
|
+
# queue behind a build that may run for minutes.
|
|
97
|
+
BUSY_REASON = "A build is running in this folder, so its record was not read."
|
|
98
|
+
|
|
99
|
+
# The sealed tree's own folder name. It is a contract identifier -- it is spelled this way inside
|
|
100
|
+
# every receipt this product has ever written -- so it is written once here and referred to by
|
|
101
|
+
# name everywhere else, rather than repeated as a bare word the vocabulary sweep has to argue for.
|
|
102
|
+
_SEALED_TREE = Path("candidate")
|
|
103
|
+
_RECEIPT = _SEALED_TREE / "manifest.json"
|
|
104
|
+
_PROFILE = _SEALED_TREE / "evidence" / "profile.json"
|
|
105
|
+
_CONTROL_DIRECTORY = ".mr-data"
|
|
106
|
+
# The child a Workbench folder keeps its run under. This is not a guess the listing makes: the run
|
|
107
|
+
# state records this path and the coordinator validates the recorded value to be exactly this, so
|
|
108
|
+
# it is where the run is whenever the record can be read at all. It is used as the fallback when
|
|
109
|
+
# the record cannot be read, so a Build under a damaged Workbench folder is still pointed at rather
|
|
110
|
+
# than dropped from the listing.
|
|
111
|
+
_RUN_CHILD = Path("result")
|
|
112
|
+
_STATE_FILE = "state.json"
|
|
113
|
+
_LOCK_FILE = ".lock"
|
|
114
|
+
_MAX_WORKBENCH_STATE_BYTES = 2 * 1024 * 1024
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------------------------------------
|
|
118
|
+
# One build, in full
|
|
119
|
+
# ------------------------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class BuildFacts:
|
|
124
|
+
"""Everything a reader asks about one sealed build, from one verified reading of it."""
|
|
125
|
+
|
|
126
|
+
run_dir: Path
|
|
127
|
+
candidate_digest: str
|
|
128
|
+
table_sha256: str
|
|
129
|
+
manifest_version: str
|
|
130
|
+
validation_policy_digest: str
|
|
131
|
+
producer_principal: str
|
|
132
|
+
producer_attempt: str
|
|
133
|
+
question: str
|
|
134
|
+
feasibility: str | None
|
|
135
|
+
grain: tuple[str, ...]
|
|
136
|
+
columns: tuple[str, ...]
|
|
137
|
+
column_types: tuple[tuple[str, str], ...]
|
|
138
|
+
row_count: int
|
|
139
|
+
sources: tuple[dict[str, Any], ...]
|
|
140
|
+
join: dict[str, Any]
|
|
141
|
+
quality: dict[str, Any]
|
|
142
|
+
lineage: dict[str, Any]
|
|
143
|
+
lineage_digest: str
|
|
144
|
+
member_paths: tuple[str, ...]
|
|
145
|
+
recipe_id: str | None = None
|
|
146
|
+
recipe_version: int | None = None
|
|
147
|
+
recipe_digest: str | None = None
|
|
148
|
+
approval_id: str | None = None
|
|
149
|
+
approval_digest: str | None = None
|
|
150
|
+
execution_digest: str | None = None
|
|
151
|
+
execution_mode: str | None = None
|
|
152
|
+
|
|
153
|
+
def type_of(self, column: str) -> str | None:
|
|
154
|
+
return dict(self.column_types).get(column)
|
|
155
|
+
|
|
156
|
+
def to_dict(self, *, members: bool = False) -> dict[str, Any]:
|
|
157
|
+
"""The one payload both renderings are built from.
|
|
158
|
+
|
|
159
|
+
Facts taken from the data -- column names, check names, source names -- go in values, never
|
|
160
|
+
in keys. A key is given a plain label when it is shown to a person, and a column named
|
|
161
|
+
``max_temp_c`` used as a key would be shown as ``Max temp c``: a name the data does not
|
|
162
|
+
have.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
payload: dict[str, Any] = {
|
|
166
|
+
"status": "build_shown",
|
|
167
|
+
"run_dir": str(self.run_dir),
|
|
168
|
+
"candidate_digest": self.candidate_digest,
|
|
169
|
+
"table_sha256": self.table_sha256,
|
|
170
|
+
"manifest_version": self.manifest_version,
|
|
171
|
+
"row_count": self.row_count,
|
|
172
|
+
"columns": list(self.columns),
|
|
173
|
+
"grain": list(self.grain),
|
|
174
|
+
"question": self.question,
|
|
175
|
+
"built_by": self.producer_principal,
|
|
176
|
+
"build_attempt": self.producer_attempt,
|
|
177
|
+
"validation_policy_digest": self.validation_policy_digest,
|
|
178
|
+
"lineage_digest": self.lineage_digest,
|
|
179
|
+
"schema": self._schema_payload(),
|
|
180
|
+
"quality": self._quality_payload(),
|
|
181
|
+
"sources": self._sources_payload(),
|
|
182
|
+
"join": self._join_payload(),
|
|
183
|
+
}
|
|
184
|
+
if self.feasibility is not None:
|
|
185
|
+
payload["feasibility"] = self.feasibility
|
|
186
|
+
if self.recipe_digest is not None:
|
|
187
|
+
payload["recipe_id"] = self.recipe_id
|
|
188
|
+
payload["recipe_version"] = self.recipe_version
|
|
189
|
+
payload["recipe_digest"] = self.recipe_digest
|
|
190
|
+
payload["approval_id"] = self.approval_id
|
|
191
|
+
payload["approval_digest"] = self.approval_digest
|
|
192
|
+
payload["execution_digest"] = self.execution_digest
|
|
193
|
+
payload["execution_mode"] = self.execution_mode
|
|
194
|
+
if members:
|
|
195
|
+
payload["member_paths"] = list(self.member_paths)
|
|
196
|
+
return payload
|
|
197
|
+
|
|
198
|
+
def _schema_payload(self) -> dict[str, dict[str, Any]]:
|
|
199
|
+
width = len(str(max(len(self.columns), 1)))
|
|
200
|
+
return {
|
|
201
|
+
f"column {index:0{width}d}": {"name": name, "type": self.type_of(name)}
|
|
202
|
+
for index, name in enumerate(self.columns, start=1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _quality_payload(self) -> dict[str, Any]:
|
|
206
|
+
checks = _checks(self.quality)
|
|
207
|
+
width = len(str(max(len(checks), 1)))
|
|
208
|
+
payload: dict[str, Any] = {"result": self.quality.get("status")}
|
|
209
|
+
for index, check in enumerate(checks, start=1):
|
|
210
|
+
payload[f"check {index:0{width}d}"] = {
|
|
211
|
+
"name": check.get("check_id"),
|
|
212
|
+
"result": "passed" if check.get("passed") else "failed",
|
|
213
|
+
"wanted": check.get("expected"),
|
|
214
|
+
"found": check.get("observed"),
|
|
215
|
+
}
|
|
216
|
+
return payload
|
|
217
|
+
|
|
218
|
+
def _sources_payload(self) -> dict[str, Any]:
|
|
219
|
+
width = len(str(max(len(self.sources), 1)))
|
|
220
|
+
return {
|
|
221
|
+
f"source {index:0{width}d}": {
|
|
222
|
+
"name": source.get("source_id"),
|
|
223
|
+
"file": source.get("relative_path"),
|
|
224
|
+
"size in bytes": source.get("bytes"),
|
|
225
|
+
"content fingerprint": source.get("sha256"),
|
|
226
|
+
"came from": source.get("origin"),
|
|
227
|
+
}
|
|
228
|
+
for index, source in enumerate(self.sources, start=1)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _join_payload(self) -> dict[str, Any]:
|
|
232
|
+
if not self.join:
|
|
233
|
+
return {}
|
|
234
|
+
return {
|
|
235
|
+
"left": self.join.get("left_source"),
|
|
236
|
+
"right": self.join.get("right_source"),
|
|
237
|
+
"keys": list(self.join.get("keys", ())),
|
|
238
|
+
"relationship": self.join.get("cardinality"),
|
|
239
|
+
"rows in": self.join.get("left_rows"),
|
|
240
|
+
"rows out": self.join.get("output_rows"),
|
|
241
|
+
"rows that did not match": self.join.get("unmatched_left_rows"),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_facts(run_dir: Path | str) -> BuildFacts:
|
|
246
|
+
"""Read one sealed build, verifying it on the way.
|
|
247
|
+
|
|
248
|
+
Everything reported here comes from :func:`inspect_candidate`, which replays the whole sealed
|
|
249
|
+
tree, plus the two recipe-side readers that go through the same verified snapshot. A folder
|
|
250
|
+
with no build in it is named as such before any of that runs, because the reader's own refusal
|
|
251
|
+
for a missing tree reads like a break-in rather than a typo.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
target = Path(run_dir)
|
|
255
|
+
require_a_build(target)
|
|
256
|
+
inspection = inspect_candidate(target)
|
|
257
|
+
summary = version_summary(target)
|
|
258
|
+
bindings = (
|
|
259
|
+
version_bindings(target) if inspection.manifest_version == RECIPE_MANIFEST_VERSION else None
|
|
260
|
+
)
|
|
261
|
+
return BuildFacts(
|
|
262
|
+
run_dir=target,
|
|
263
|
+
candidate_digest=inspection.candidate_digest,
|
|
264
|
+
table_sha256=inspection.table_sha256,
|
|
265
|
+
manifest_version=inspection.manifest_version,
|
|
266
|
+
validation_policy_digest=inspection.validation_policy_digest,
|
|
267
|
+
producer_principal=inspection.producer_principal,
|
|
268
|
+
producer_attempt=inspection.producer_attempt,
|
|
269
|
+
question=inspection.question,
|
|
270
|
+
feasibility=inspection.feasibility,
|
|
271
|
+
grain=inspection.grain,
|
|
272
|
+
columns=inspection.columns,
|
|
273
|
+
column_types=summary.column_types,
|
|
274
|
+
row_count=inspection.row_count,
|
|
275
|
+
sources=inspection.sources,
|
|
276
|
+
join=inspection.join,
|
|
277
|
+
quality=inspection.quality,
|
|
278
|
+
lineage=inspection.lineage,
|
|
279
|
+
lineage_digest=summary.lineage_digest,
|
|
280
|
+
member_paths=inspection.member_paths,
|
|
281
|
+
recipe_id=None if bindings is None else bindings.recipe.recipe_id,
|
|
282
|
+
recipe_version=None if bindings is None else bindings.recipe.recipe_version,
|
|
283
|
+
recipe_digest=None if bindings is None else bindings.recipe.digest,
|
|
284
|
+
approval_id=None if bindings is None else bindings.approval.approval_id,
|
|
285
|
+
approval_digest=None if bindings is None else bindings.approval.digest,
|
|
286
|
+
execution_digest=None if bindings is None else bindings.execution.digest,
|
|
287
|
+
execution_mode=None if bindings is None else bindings.execution.mode,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def plain_card(facts: BuildFacts) -> str:
|
|
292
|
+
"""The plain-language card for one build, written from the facts above.
|
|
293
|
+
|
|
294
|
+
It does not read, and must not read, the sealed card member of the build. Those bytes sit
|
|
295
|
+
inside seven recorded digests, so rewording them moves every one of those digests, and no
|
|
296
|
+
command shows them to a person today. Everything a card would say is already in the facts a
|
|
297
|
+
verified reading returns, so the card is written from those instead.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
lines = [
|
|
301
|
+
f"What this answers: {facts.question}",
|
|
302
|
+
f"One row is one {_grain_phrase(facts.grain)}.",
|
|
303
|
+
f"{facts.row_count} rows, {len(facts.columns)} columns:",
|
|
304
|
+
]
|
|
305
|
+
lines.extend(f" {name} ({facts.type_of(name) or 'unknown'})" for name in facts.columns)
|
|
306
|
+
if facts.sources:
|
|
307
|
+
lines.append("Where it came from:")
|
|
308
|
+
for source in facts.sources:
|
|
309
|
+
name = source.get("source_id", "a source")
|
|
310
|
+
where = source.get("relative_path", "an unnamed file")
|
|
311
|
+
lines.append(f" {name}: {where}")
|
|
312
|
+
lines.append(_checks_sentence(facts.quality))
|
|
313
|
+
if facts.recipe_id is not None:
|
|
314
|
+
lines.append(f"Made by the Recipe {facts.recipe_id}, version {facts.recipe_version}.")
|
|
315
|
+
lines.append(f"Build fingerprint: {facts.candidate_digest}")
|
|
316
|
+
lines.append(f"Data fingerprint: {facts.table_sha256}")
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _grain_phrase(grain: Sequence[str]) -> str:
|
|
321
|
+
"""What one row is, said the way a person would say it."""
|
|
322
|
+
|
|
323
|
+
names = [str(name) for name in grain]
|
|
324
|
+
if not names:
|
|
325
|
+
return "record"
|
|
326
|
+
if len(names) == 1:
|
|
327
|
+
return names[0]
|
|
328
|
+
return f"{', '.join(names[:-1])} and {names[-1]}"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _checks_sentence(quality: Mapping[str, Any]) -> str:
|
|
332
|
+
"""One honest sentence about the checks, naming the ones that did not pass."""
|
|
333
|
+
|
|
334
|
+
checks = _checks(quality)
|
|
335
|
+
if not checks:
|
|
336
|
+
return "No checks were recorded for this build."
|
|
337
|
+
failed = [str(check.get("check_id")) for check in checks if not check.get("passed")]
|
|
338
|
+
if not failed:
|
|
339
|
+
return f"Checks: all {len(checks)} passed."
|
|
340
|
+
passed = len(checks) - len(failed)
|
|
341
|
+
return f"Checks: {passed} of {len(checks)} passed. Did not pass: {', '.join(failed)}"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _checks(quality: Mapping[str, Any]) -> tuple[dict[str, Any], ...]:
|
|
345
|
+
raw = quality.get("checks") if isinstance(quality, Mapping) else None
|
|
346
|
+
if not isinstance(raw, list):
|
|
347
|
+
return ()
|
|
348
|
+
return tuple(item for item in raw if isinstance(item, dict))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def require_a_build(run_dir: Path) -> None:
|
|
352
|
+
"""Refuse a folder with no build in it by name, before the verified reader is asked.
|
|
353
|
+
|
|
354
|
+
This is a signpost, not a check that anything is trusted: the verified reading still runs and
|
|
355
|
+
still decides. Its only job is that pointing a command at the wrong folder reads like the typo
|
|
356
|
+
it usually is.
|
|
357
|
+
|
|
358
|
+
Public because the comparison in :mod:`mostlyright.data_harness.ux.diffing` needs the same
|
|
359
|
+
signpost, and two copies of one sentence are two sentences that can drift apart.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
if not (run_dir / _RECEIPT).is_file():
|
|
363
|
+
raise BuildError("CANDIDATE_ABSENT", f"there is no build at {run_dir}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def holds_a_build(directory: Path) -> bool:
|
|
367
|
+
"""Whether this folder is, or leads to, a Build -- the question the plain sentences need.
|
|
368
|
+
|
|
369
|
+
A Workbench folder keeps its run underneath, so it counts when that run holds a Receipt; any
|
|
370
|
+
other folder counts when it holds one itself. Public because the command line's error boundary
|
|
371
|
+
has to tell "you have not built here yet" apart from "this build cannot be opened safely", and
|
|
372
|
+
those two answers name completely different fixes.
|
|
373
|
+
|
|
374
|
+
The answer is the Receipt, never the readability of the durable record. A Workbench folder
|
|
375
|
+
whose record cannot be read is one of the two cases the boundary is here to tell apart, and
|
|
376
|
+
answering ``False`` for it would hand the class rule -- no answer about a Build where there is
|
|
377
|
+
no Build -- a false premise about a folder that holds one.
|
|
378
|
+
|
|
379
|
+
The question is asked of the entry, not of what the name leads to. A resolving
|
|
380
|
+
``(stop.run_dir / manifest.json).is_file()`` check would treat an ordinary
|
|
381
|
+
``~/builds/latest -> real-build`` link as "a Build is there" even though the harness refuses
|
|
382
|
+
to open that path: ``mr-data build --output ~/builds/latest`` says the path
|
|
383
|
+
"already holds something ... Run: mr-data show
|
|
384
|
+
~/builds/latest", and that command exits 1 with ``CANDIDATE_ANCESTOR_SYMLINK``. So the question
|
|
385
|
+
is now the one every reader here actually asks: is the entry a folder, and is the Receipt in it
|
|
386
|
+
a plain file. A link is not a Build this harness will open, and saying it is was the whole
|
|
387
|
+
defect.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
if kind_at(directory) != A_FOLDER:
|
|
391
|
+
return False
|
|
392
|
+
if _receipt_entry(directory):
|
|
393
|
+
return True
|
|
394
|
+
return kind_at(directory / _CONTROL_DIRECTORY) == A_FOLDER and _receipt_entry(
|
|
395
|
+
directory / _RUN_CHILD
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _receipt_entry(run_dir: Path) -> bool:
|
|
400
|
+
"""Whether the Receipt in this folder is a plain file, asked of the entry and never followed.
|
|
401
|
+
|
|
402
|
+
A resolving ``(run_dir / _RECEIPT).is_file()`` check is wrong in two ways. It **follows the
|
|
403
|
+
name**, so a link where the Receipt should be answers "a Build is here" about a file no reader
|
|
404
|
+
in this package will open. And ``Path.is_file`` *raises* when the account may not look -- which
|
|
405
|
+
is load-bearing: a listing that answered ``False`` there would
|
|
406
|
+
report a folder it cannot read as a folder with nothing built in it, which is a second silent
|
|
407
|
+
answer of exactly the kind this module exists to end.
|
|
408
|
+
|
|
409
|
+
So this is one ``lstat``, it follows nothing, and the two refusals that mean "no Receipt is
|
|
410
|
+
there" are the only ones it turns into an answer. Every other refusal is raised, the way it
|
|
411
|
+
always was, and the callers that have somewhere to put it -- ``cli._subject_holds_a_build``,
|
|
412
|
+
``preflight._holds_a_build`` -- still turn it into "I cannot tell". That is
|
|
413
|
+
:func:`ux.path_kind.entry_at`, which exists because this is the question, asked once, in the
|
|
414
|
+
module that owns it.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
return entry_at(run_dir / _RECEIPT) == A_FILE
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------------------------------------------
|
|
421
|
+
# Every build under a folder
|
|
422
|
+
# ------------------------------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@dataclass(frozen=True)
|
|
426
|
+
class BuildEntry:
|
|
427
|
+
"""One folder a listing stopped at, and how much was actually read to describe it.
|
|
428
|
+
|
|
429
|
+
``reason`` is one plain sentence, present exactly when the facts above it are not. It is the
|
|
430
|
+
same string in both output modes, because a listing has one set of facts and renders them
|
|
431
|
+
twice; the listing does not carry a second, engineering wording for a machine to read.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
run_dir: Path
|
|
435
|
+
verified: bool
|
|
436
|
+
manifest_version: str | None = None
|
|
437
|
+
candidate_digest: str | None = None
|
|
438
|
+
table_sha256: str | None = None
|
|
439
|
+
row_count: int | None = None
|
|
440
|
+
reason: str | None = None
|
|
441
|
+
nothing_built: bool = False
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def verification(self) -> str:
|
|
445
|
+
if self.nothing_built:
|
|
446
|
+
return NOTHING_BUILT
|
|
447
|
+
if self.reason is not None:
|
|
448
|
+
return UNREADABLE
|
|
449
|
+
return VERIFIED if self.verified else LISTED_ONLY
|
|
450
|
+
|
|
451
|
+
def to_dict(self) -> dict[str, Any]:
|
|
452
|
+
payload: dict[str, Any] = {
|
|
453
|
+
"run_dir": str(self.run_dir),
|
|
454
|
+
"manifest_version": self.manifest_version,
|
|
455
|
+
"candidate_digest": self.candidate_digest,
|
|
456
|
+
"table_sha256": self.table_sha256,
|
|
457
|
+
"row_count": self.row_count,
|
|
458
|
+
"verification": self.verification,
|
|
459
|
+
}
|
|
460
|
+
if self.reason is not None:
|
|
461
|
+
payload["why"] = self.reason
|
|
462
|
+
return payload
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def listing_verification(entries: Sequence[BuildEntry], *, verify: bool) -> str:
|
|
466
|
+
"""What the listing as a whole may claim, taken from the entries rather than from the flag.
|
|
467
|
+
|
|
468
|
+
``--verify`` is a request. Whether every build under the root answered it is a fact, and the
|
|
469
|
+
two are not the same sentence. The listing-level word shares its key and its vocabulary with
|
|
470
|
+
the per-entry one, so a reader that branches on either gets the same answer about the same
|
|
471
|
+
tree: ``verified`` only when every entry verified, and a word that says otherwise when one did
|
|
472
|
+
not. A root with nothing under it verified nothing, and says that rather than claiming a clean
|
|
473
|
+
result over an empty listing.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
if not verify:
|
|
477
|
+
return LISTED_ONLY
|
|
478
|
+
if not entries:
|
|
479
|
+
return NOTHING_TO_VERIFY
|
|
480
|
+
if all(entry.verification == VERIFIED for entry in entries):
|
|
481
|
+
return VERIFIED
|
|
482
|
+
return NOT_ALL_VERIFIED
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def list_builds(
|
|
486
|
+
root: Path | str,
|
|
487
|
+
*,
|
|
488
|
+
verify: bool = False,
|
|
489
|
+
max_depth: int = DEFAULT_LIST_DEPTH,
|
|
490
|
+
) -> tuple[BuildEntry, ...]:
|
|
491
|
+
"""Every build under ``root``, in folder order.
|
|
492
|
+
|
|
493
|
+
Without ``verify`` this reads each receipt and nothing else, and every entry says so. With
|
|
494
|
+
``verify`` each build is replayed through the same reader ``show`` uses, and only then does an
|
|
495
|
+
entry say it was verified.
|
|
496
|
+
|
|
497
|
+
A folder that cannot be read comes back as an entry carrying the reason. A build you cannot
|
|
498
|
+
read is exactly the thing you want a listing to put in front of you, so it is never dropped.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
start = Path(root)
|
|
502
|
+
try:
|
|
503
|
+
root_before = _open_listing_root(start)
|
|
504
|
+
except OSError as error:
|
|
505
|
+
found_at_root = presence_at(start)
|
|
506
|
+
if found_at_root == A_FOLDER:
|
|
507
|
+
# The entry is the required kind, but this account could not open it for traversal.
|
|
508
|
+
# Preserve that operating-system refusal so the command's shared remediation says the
|
|
509
|
+
# access was refused; calling an unreadable folder absent would erase the distinction.
|
|
510
|
+
raise
|
|
511
|
+
# `is_dir` follows links and cannot distinguish a dangling link or plain file from an absent
|
|
512
|
+
# listing root. Use the noun from `ux.path_kind`'s non-following lookup instead.
|
|
513
|
+
raise BuildError(
|
|
514
|
+
"LIST_ROOT_ABSENT",
|
|
515
|
+
f"there is {found_at_root or UNKNOWN_KIND} at {start}, and a listing reads a folder",
|
|
516
|
+
) from error
|
|
517
|
+
try:
|
|
518
|
+
depth = _bounded_depth(max_depth)
|
|
519
|
+
found = sorted(
|
|
520
|
+
_build_directories(root_before.descriptor, start, depth),
|
|
521
|
+
key=lambda stop: stop.run_dir,
|
|
522
|
+
)
|
|
523
|
+
entries = tuple(
|
|
524
|
+
_entry(stop, root_descriptor=root_before.descriptor, verify=verify) for stop in found
|
|
525
|
+
)
|
|
526
|
+
try:
|
|
527
|
+
root_after = _open_listing_root(start)
|
|
528
|
+
except OSError:
|
|
529
|
+
root_after = None
|
|
530
|
+
try:
|
|
531
|
+
if root_after is None or root_after.identity != root_before.identity:
|
|
532
|
+
# No row collected through a different namespace is allowed to leave this
|
|
533
|
+
# function. The first observation used to be followed by an unbound pathname walk,
|
|
534
|
+
# so replacing the checked folder with a link in that gap made outside Receipts
|
|
535
|
+
# look like children of the original spelling. The no-follow directory opens bind
|
|
536
|
+
# both observations to concrete inodes; a changed name becomes a typed refusal
|
|
537
|
+
# before either renderer gets the tuple above.
|
|
538
|
+
raise BuildError(
|
|
539
|
+
"LIST_ROOT_ABSENT",
|
|
540
|
+
f"the listing root changed while it was being read: {start}",
|
|
541
|
+
)
|
|
542
|
+
finally:
|
|
543
|
+
if root_after is not None:
|
|
544
|
+
os.close(root_after.descriptor)
|
|
545
|
+
return entries
|
|
546
|
+
finally:
|
|
547
|
+
os.close(root_before.descriptor)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass(frozen=True)
|
|
551
|
+
class _ListingRootHandle:
|
|
552
|
+
"""An opened directory and the filesystem identity bound by that same operation."""
|
|
553
|
+
|
|
554
|
+
descriptor: int
|
|
555
|
+
identity: tuple[int, int]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _open_listing_root(root: Path) -> _ListingRootHandle:
|
|
559
|
+
"""Open the named root as the directory itself, never a link target."""
|
|
560
|
+
|
|
561
|
+
descriptor = os.open(
|
|
562
|
+
root,
|
|
563
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
564
|
+
)
|
|
565
|
+
try:
|
|
566
|
+
observed = os.fstat(descriptor)
|
|
567
|
+
except BaseException:
|
|
568
|
+
os.close(descriptor)
|
|
569
|
+
raise
|
|
570
|
+
return _ListingRootHandle(descriptor, (observed.st_dev, observed.st_ino))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _bounded_depth(max_depth: int) -> int:
|
|
574
|
+
if type(max_depth) is not int or not 0 <= max_depth <= MAX_LIST_DEPTH:
|
|
575
|
+
raise BuildError(
|
|
576
|
+
"LIST_DEPTH",
|
|
577
|
+
f"how deep to look must be a whole number from 0 to {MAX_LIST_DEPTH}",
|
|
578
|
+
)
|
|
579
|
+
return max_depth
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@dataclass(frozen=True)
|
|
583
|
+
class _Stop:
|
|
584
|
+
"""One folder the walk stopped at, before anything in it was read.
|
|
585
|
+
|
|
586
|
+
``run_dir`` is where the Build would be. ``reason`` is set only when resolving the folder was
|
|
587
|
+
itself refused -- the record could not be read, or a build is holding it -- and it is carried
|
|
588
|
+
into the entry rather than thrown away, because a folder whose record cannot be read is not a
|
|
589
|
+
folder nothing has been built in. ``nothing_built`` is the one narrow case where that second
|
|
590
|
+
sentence is true: there is no run recorded here at all.
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
run_dir: Path
|
|
594
|
+
relative: tuple[str, ...]
|
|
595
|
+
reason: str | None = None
|
|
596
|
+
nothing_built: bool = False
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _open_directory_beneath(root_descriptor: int, relative: tuple[str, ...]) -> int:
|
|
600
|
+
"""Open one descendant from the retained root, refusing every linked component."""
|
|
601
|
+
|
|
602
|
+
descriptor = os.dup(root_descriptor)
|
|
603
|
+
try:
|
|
604
|
+
for name in relative:
|
|
605
|
+
child = os.open(
|
|
606
|
+
name,
|
|
607
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
608
|
+
dir_fd=descriptor,
|
|
609
|
+
)
|
|
610
|
+
os.close(descriptor)
|
|
611
|
+
descriptor = child
|
|
612
|
+
return descriptor
|
|
613
|
+
except BaseException:
|
|
614
|
+
os.close(descriptor)
|
|
615
|
+
raise
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _read_regular_at(
|
|
619
|
+
directory_descriptor: int,
|
|
620
|
+
relative: tuple[str, ...],
|
|
621
|
+
*,
|
|
622
|
+
maximum: int | None,
|
|
623
|
+
required_mode: int | None = None,
|
|
624
|
+
) -> bytes:
|
|
625
|
+
"""Read one bounded regular descendant through directory descriptors only."""
|
|
626
|
+
|
|
627
|
+
if not relative:
|
|
628
|
+
raise FileNotFoundError("a file name is required")
|
|
629
|
+
parent = _open_directory_beneath(directory_descriptor, relative[:-1])
|
|
630
|
+
descriptor: int | None = None
|
|
631
|
+
try:
|
|
632
|
+
descriptor = os.open(
|
|
633
|
+
relative[-1],
|
|
634
|
+
os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
635
|
+
dir_fd=parent,
|
|
636
|
+
)
|
|
637
|
+
observed = os.fstat(descriptor)
|
|
638
|
+
if not stat.S_ISREG(observed.st_mode):
|
|
639
|
+
raise OSError(errno.EINVAL, "listed member is not a regular file")
|
|
640
|
+
if required_mode is not None and stat.S_IMODE(observed.st_mode) != required_mode:
|
|
641
|
+
raise OSError(errno.EPERM, "listed member mode changed")
|
|
642
|
+
if maximum is not None and observed.st_size > maximum:
|
|
643
|
+
raise OSError(errno.EFBIG, "listed member exceeds its read bound")
|
|
644
|
+
chunks: list[bytes] = []
|
|
645
|
+
remaining = maximum + 1 if maximum is not None else None
|
|
646
|
+
while remaining is None or remaining:
|
|
647
|
+
chunk = os.read(
|
|
648
|
+
descriptor, min(remaining, 1 << 16) if remaining is not None else 1 << 16
|
|
649
|
+
)
|
|
650
|
+
if not chunk:
|
|
651
|
+
break
|
|
652
|
+
chunks.append(chunk)
|
|
653
|
+
if remaining is not None:
|
|
654
|
+
remaining -= len(chunk)
|
|
655
|
+
raw = b"".join(chunks)
|
|
656
|
+
if maximum is not None and len(raw) > maximum:
|
|
657
|
+
raise OSError(errno.EFBIG, "listed member grew beyond its read bound")
|
|
658
|
+
return raw
|
|
659
|
+
finally:
|
|
660
|
+
if descriptor is not None:
|
|
661
|
+
os.close(descriptor)
|
|
662
|
+
os.close(parent)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _read_unbounded_regular_at(
|
|
666
|
+
directory_descriptor: int,
|
|
667
|
+
relative: tuple[str, ...],
|
|
668
|
+
) -> bytes:
|
|
669
|
+
"""Read a rooted regular listing member without a byte limit."""
|
|
670
|
+
|
|
671
|
+
return _read_regular_at(directory_descriptor, relative, maximum=None)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _plain_file_at(directory_descriptor: int, relative: tuple[str, ...]) -> bool:
|
|
675
|
+
"""Whether one descendant entry is a regular file, without following any component."""
|
|
676
|
+
|
|
677
|
+
if not relative:
|
|
678
|
+
return False
|
|
679
|
+
try:
|
|
680
|
+
parent = _open_directory_beneath(directory_descriptor, relative[:-1])
|
|
681
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
682
|
+
return False
|
|
683
|
+
try:
|
|
684
|
+
observed = os.stat(relative[-1], dir_fd=parent, follow_symlinks=False)
|
|
685
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
686
|
+
return False
|
|
687
|
+
finally:
|
|
688
|
+
os.close(parent)
|
|
689
|
+
return stat.S_ISREG(observed.st_mode)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _folder_at(directory_descriptor: int, name: str) -> bool:
|
|
693
|
+
try:
|
|
694
|
+
observed = os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False)
|
|
695
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
696
|
+
return False
|
|
697
|
+
return stat.S_ISDIR(observed.st_mode)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _build_directories(root_descriptor: int, root: Path, max_depth: int) -> list[_Stop]:
|
|
701
|
+
"""Walk below the retained root descriptor, never reopening the root pathname."""
|
|
702
|
+
|
|
703
|
+
found: list[_Stop] = []
|
|
704
|
+
pending: list[tuple[tuple[str, ...], int]] = [((), 0)]
|
|
705
|
+
while pending:
|
|
706
|
+
relative, depth = pending.pop()
|
|
707
|
+
try:
|
|
708
|
+
directory = _open_directory_beneath(root_descriptor, relative)
|
|
709
|
+
except OSError:
|
|
710
|
+
if not relative:
|
|
711
|
+
raise
|
|
712
|
+
found.append(
|
|
713
|
+
_Stop(
|
|
714
|
+
root.joinpath(*relative),
|
|
715
|
+
relative,
|
|
716
|
+
reason=DESCENDANT_UNREADABLE_REASON,
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
continue
|
|
720
|
+
try:
|
|
721
|
+
try:
|
|
722
|
+
stop = _build_directory_at(directory, root, relative)
|
|
723
|
+
except OSError:
|
|
724
|
+
found.append(
|
|
725
|
+
_Stop(
|
|
726
|
+
root.joinpath(*relative),
|
|
727
|
+
relative,
|
|
728
|
+
reason=DESCENDANT_UNREADABLE_REASON,
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
continue
|
|
732
|
+
if stop is not None:
|
|
733
|
+
found.append(stop)
|
|
734
|
+
continue
|
|
735
|
+
if depth >= max_depth:
|
|
736
|
+
continue
|
|
737
|
+
try:
|
|
738
|
+
children, unreadable, incomplete = _child_directories(directory)
|
|
739
|
+
except OSError:
|
|
740
|
+
found.append(
|
|
741
|
+
_Stop(
|
|
742
|
+
root.joinpath(*relative),
|
|
743
|
+
relative,
|
|
744
|
+
reason=DESCENDANT_UNREADABLE_REASON,
|
|
745
|
+
)
|
|
746
|
+
)
|
|
747
|
+
continue
|
|
748
|
+
if incomplete:
|
|
749
|
+
found.append(
|
|
750
|
+
_Stop(
|
|
751
|
+
root.joinpath(*relative),
|
|
752
|
+
relative,
|
|
753
|
+
reason=DESCENDANT_UNREADABLE_REASON,
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
found.extend(
|
|
757
|
+
_Stop(
|
|
758
|
+
root.joinpath(*relative, name),
|
|
759
|
+
(*relative, name),
|
|
760
|
+
reason=DESCENDANT_UNREADABLE_REASON,
|
|
761
|
+
)
|
|
762
|
+
for name in unreadable
|
|
763
|
+
)
|
|
764
|
+
pending.extend(((*relative, name), depth + 1) for name in children)
|
|
765
|
+
finally:
|
|
766
|
+
os.close(directory)
|
|
767
|
+
return found
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _build_directory_at(
|
|
771
|
+
directory_descriptor: int,
|
|
772
|
+
root: Path,
|
|
773
|
+
relative: tuple[str, ...],
|
|
774
|
+
) -> _Stop | None:
|
|
775
|
+
"""The run represented by an already opened descendant, if it holds one."""
|
|
776
|
+
|
|
777
|
+
display = root.joinpath(*relative)
|
|
778
|
+
if _folder_at(directory_descriptor, _CONTROL_DIRECTORY):
|
|
779
|
+
return _workbench_run_directory(directory_descriptor, display, relative)
|
|
780
|
+
if _plain_file_at(directory_descriptor, _RECEIPT.parts):
|
|
781
|
+
return _Stop(display, relative)
|
|
782
|
+
return None
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _workbench_run_directory(
|
|
786
|
+
directory_descriptor: int,
|
|
787
|
+
display: Path,
|
|
788
|
+
relative: tuple[str, ...],
|
|
789
|
+
) -> _Stop:
|
|
790
|
+
"""Read a Workbench's fixed result child through its retained directory descriptor."""
|
|
791
|
+
|
|
792
|
+
child_relative = (*relative, _RUN_CHILD.name)
|
|
793
|
+
child_display = display / _RUN_CHILD
|
|
794
|
+
control: int | None = None
|
|
795
|
+
lock: int | None = None
|
|
796
|
+
try:
|
|
797
|
+
control = os.open(
|
|
798
|
+
_CONTROL_DIRECTORY,
|
|
799
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
800
|
+
dir_fd=directory_descriptor,
|
|
801
|
+
)
|
|
802
|
+
if fcntl is None:
|
|
803
|
+
raise OSError(errno.ENOTSUP, "workbench locks are unavailable")
|
|
804
|
+
try:
|
|
805
|
+
lock = os.open(
|
|
806
|
+
_LOCK_FILE,
|
|
807
|
+
os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
808
|
+
dir_fd=control,
|
|
809
|
+
)
|
|
810
|
+
if not stat.S_ISREG(os.fstat(lock).st_mode):
|
|
811
|
+
raise OSError(errno.EINVAL, "workbench lock is not a regular file")
|
|
812
|
+
except FileNotFoundError:
|
|
813
|
+
return _Stop(display, relative, nothing_built=True)
|
|
814
|
+
try:
|
|
815
|
+
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
816
|
+
except OSError as error:
|
|
817
|
+
if error.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
818
|
+
return _Stop(child_display, child_relative, reason=BUSY_REASON)
|
|
819
|
+
raise
|
|
820
|
+
try:
|
|
821
|
+
raw = _read_regular_at(
|
|
822
|
+
control,
|
|
823
|
+
(_STATE_FILE,),
|
|
824
|
+
maximum=_MAX_WORKBENCH_STATE_BYTES,
|
|
825
|
+
required_mode=0o600,
|
|
826
|
+
)
|
|
827
|
+
except FileNotFoundError:
|
|
828
|
+
return _Stop(display, relative, nothing_built=True)
|
|
829
|
+
state = parse_json(raw)
|
|
830
|
+
if not isinstance(state, dict) or state.get("paths") != {
|
|
831
|
+
"input_root": "inputs",
|
|
832
|
+
"result": _RUN_CHILD.name,
|
|
833
|
+
}:
|
|
834
|
+
raise ValueError("workbench state does not name the fixed result child")
|
|
835
|
+
return _Stop(child_display, child_relative)
|
|
836
|
+
except (OSError, CanonicalJSONError, ValueError):
|
|
837
|
+
if _plain_file_at(directory_descriptor, _RUN_CHILD.parts + _RECEIPT.parts):
|
|
838
|
+
return _Stop(child_display, child_relative, reason=UNRESOLVED_REASON)
|
|
839
|
+
return _Stop(display, relative, reason=UNRESOLVED_REASON)
|
|
840
|
+
finally:
|
|
841
|
+
if lock is not None:
|
|
842
|
+
os.close(lock)
|
|
843
|
+
if control is not None:
|
|
844
|
+
os.close(control)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _child_directories(directory_descriptor: int) -> tuple[list[str], list[str], bool]:
|
|
848
|
+
children: list[str] = []
|
|
849
|
+
unreadable: list[str] = []
|
|
850
|
+
incomplete = False
|
|
851
|
+
with os.scandir(directory_descriptor) as entries:
|
|
852
|
+
iterator = iter(entries)
|
|
853
|
+
while True:
|
|
854
|
+
try:
|
|
855
|
+
entry = next(iterator)
|
|
856
|
+
except StopIteration:
|
|
857
|
+
break
|
|
858
|
+
except OSError:
|
|
859
|
+
incomplete = True
|
|
860
|
+
break
|
|
861
|
+
try:
|
|
862
|
+
if entry.is_dir(follow_symlinks=False):
|
|
863
|
+
children.append(entry.name)
|
|
864
|
+
except OSError:
|
|
865
|
+
unreadable.append(entry.name)
|
|
866
|
+
return sorted(children), sorted(unreadable), incomplete
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _entry(stop: _Stop, *, root_descriptor: int, verify: bool) -> BuildEntry:
|
|
870
|
+
"""One row, from a folder the walk stopped at.
|
|
871
|
+
|
|
872
|
+
A stop that already carries a reason is answered with that reason in both readings. Asking for
|
|
873
|
+
a verified reading of a folder whose record could not be read cannot make it readable, and
|
|
874
|
+
replaying it anyway would report the folder under whichever wording the replay happened to
|
|
875
|
+
fail with rather than the one true thing known about it.
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
if stop.reason is not None:
|
|
879
|
+
return BuildEntry(run_dir=stop.run_dir, verified=False, reason=stop.reason)
|
|
880
|
+
if stop.nothing_built:
|
|
881
|
+
return BuildEntry(
|
|
882
|
+
run_dir=stop.run_dir,
|
|
883
|
+
verified=False,
|
|
884
|
+
reason=NOTHING_BUILT_REASON,
|
|
885
|
+
nothing_built=True,
|
|
886
|
+
)
|
|
887
|
+
if verify:
|
|
888
|
+
return _verified_entry(stop, root_descriptor)
|
|
889
|
+
return _listed_entry(stop, root_descriptor)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _copy_directory_snapshot(
|
|
893
|
+
source_descriptor: int,
|
|
894
|
+
destination: Path,
|
|
895
|
+
remaining: list[int],
|
|
896
|
+
) -> None:
|
|
897
|
+
"""Copy one descriptor-bound tree into a private path for the existing full verifier."""
|
|
898
|
+
|
|
899
|
+
with os.scandir(source_descriptor) as entries:
|
|
900
|
+
for entry in entries:
|
|
901
|
+
observed = entry.stat(follow_symlinks=False)
|
|
902
|
+
target = destination / entry.name
|
|
903
|
+
if stat.S_ISDIR(observed.st_mode):
|
|
904
|
+
child = os.open(
|
|
905
|
+
entry.name,
|
|
906
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
907
|
+
dir_fd=source_descriptor,
|
|
908
|
+
)
|
|
909
|
+
try:
|
|
910
|
+
target.mkdir(mode=0o700)
|
|
911
|
+
_copy_directory_snapshot(child, target, remaining)
|
|
912
|
+
target.chmod(stat.S_IMODE(observed.st_mode))
|
|
913
|
+
finally:
|
|
914
|
+
os.close(child)
|
|
915
|
+
continue
|
|
916
|
+
if stat.S_ISREG(observed.st_mode):
|
|
917
|
+
source = os.open(
|
|
918
|
+
entry.name,
|
|
919
|
+
os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
920
|
+
dir_fd=source_descriptor,
|
|
921
|
+
)
|
|
922
|
+
destination_fd: int | None = None
|
|
923
|
+
try:
|
|
924
|
+
opened = os.fstat(source)
|
|
925
|
+
if not stat.S_ISREG(opened.st_mode) or (
|
|
926
|
+
opened.st_dev,
|
|
927
|
+
opened.st_ino,
|
|
928
|
+
) != (observed.st_dev, observed.st_ino):
|
|
929
|
+
raise OSError(errno.ESTALE, "listed member changed during snapshot")
|
|
930
|
+
if opened.st_size > MAX_CANDIDATE_MEMBER_BYTES or opened.st_size > remaining[0]:
|
|
931
|
+
raise OSError(errno.EFBIG, "listed Build exceeds its verification bound")
|
|
932
|
+
destination_fd = os.open(
|
|
933
|
+
target,
|
|
934
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
935
|
+
0o600,
|
|
936
|
+
)
|
|
937
|
+
member_bytes = 0
|
|
938
|
+
while chunk := os.read(source, 1 << 20):
|
|
939
|
+
member_bytes += len(chunk)
|
|
940
|
+
remaining[0] -= len(chunk)
|
|
941
|
+
if member_bytes > MAX_CANDIDATE_MEMBER_BYTES or remaining[0] < 0:
|
|
942
|
+
raise OSError(
|
|
943
|
+
errno.EFBIG,
|
|
944
|
+
"listed Build grew beyond its verification bound",
|
|
945
|
+
)
|
|
946
|
+
view = memoryview(chunk)
|
|
947
|
+
while view:
|
|
948
|
+
written = os.write(destination_fd, view)
|
|
949
|
+
view = view[written:]
|
|
950
|
+
target.chmod(stat.S_IMODE(opened.st_mode))
|
|
951
|
+
finally:
|
|
952
|
+
if destination_fd is not None:
|
|
953
|
+
os.close(destination_fd)
|
|
954
|
+
os.close(source)
|
|
955
|
+
continue
|
|
956
|
+
if stat.S_ISLNK(observed.st_mode):
|
|
957
|
+
target.symlink_to(os.readlink(entry.name, dir_fd=source_descriptor))
|
|
958
|
+
continue
|
|
959
|
+
raise OSError(errno.EINVAL, "listed tree contains an unsupported entry kind")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _snapshot_for_verification(root_descriptor: int, relative: tuple[str, ...]) -> Path:
|
|
963
|
+
"""Materialise only bytes reachable below the retained listing root descriptor."""
|
|
964
|
+
|
|
965
|
+
temporary = Path(os.path.realpath(tempfile.mkdtemp(prefix="mr-data-list-verify-")))
|
|
966
|
+
run = temporary / "run"
|
|
967
|
+
run.mkdir()
|
|
968
|
+
source: int | None = None
|
|
969
|
+
candidate: int | None = None
|
|
970
|
+
try:
|
|
971
|
+
source = _open_directory_beneath(root_descriptor, relative)
|
|
972
|
+
candidate = os.open(
|
|
973
|
+
_SEALED_TREE.name,
|
|
974
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
975
|
+
dir_fd=source,
|
|
976
|
+
)
|
|
977
|
+
candidate_info = os.fstat(candidate)
|
|
978
|
+
candidate_copy = run / _SEALED_TREE
|
|
979
|
+
candidate_copy.mkdir(mode=0o700)
|
|
980
|
+
_copy_directory_snapshot(candidate, candidate_copy, [MAX_CANDIDATE_BYTES])
|
|
981
|
+
candidate_copy.chmod(stat.S_IMODE(candidate_info.st_mode))
|
|
982
|
+
return run
|
|
983
|
+
except BaseException:
|
|
984
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
985
|
+
raise
|
|
986
|
+
finally:
|
|
987
|
+
if candidate is not None:
|
|
988
|
+
os.close(candidate)
|
|
989
|
+
if source is not None:
|
|
990
|
+
os.close(source)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _discard_snapshot(snapshot: Path) -> None:
|
|
994
|
+
"""Make the private sealed copy removable, then remove it."""
|
|
995
|
+
|
|
996
|
+
for directory, _children, files in os.walk(snapshot.parent):
|
|
997
|
+
folder = Path(directory)
|
|
998
|
+
try:
|
|
999
|
+
folder.chmod(0o700)
|
|
1000
|
+
except OSError:
|
|
1001
|
+
pass
|
|
1002
|
+
for name in files:
|
|
1003
|
+
try:
|
|
1004
|
+
(folder / name).chmod(0o600, follow_symlinks=False)
|
|
1005
|
+
except OSError:
|
|
1006
|
+
pass
|
|
1007
|
+
shutil.rmtree(snapshot.parent, ignore_errors=True)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _verified_entry(stop: _Stop, root_descriptor: int) -> BuildEntry:
|
|
1011
|
+
snapshot: Path | None = None
|
|
1012
|
+
try:
|
|
1013
|
+
snapshot = _snapshot_for_verification(root_descriptor, stop.relative)
|
|
1014
|
+
facts = build_facts(snapshot)
|
|
1015
|
+
except BuildError as error:
|
|
1016
|
+
if error.finding_id == "CANDIDATE_ABSENT":
|
|
1017
|
+
return BuildEntry(
|
|
1018
|
+
run_dir=stop.run_dir,
|
|
1019
|
+
verified=False,
|
|
1020
|
+
reason=NOTHING_BUILT_REASON,
|
|
1021
|
+
nothing_built=True,
|
|
1022
|
+
)
|
|
1023
|
+
# A typed refusal from the replay. Its raise-site wording is translated through the same
|
|
1024
|
+
# boundary the command line uses, so a listing cannot put an internal word on a line a
|
|
1025
|
+
# person reads; a message that is already plain comes through untouched.
|
|
1026
|
+
plain = plain_headline(error.finding_id, str(error), raised_as=naming(error))
|
|
1027
|
+
return BuildEntry(run_dir=stop.run_dir, verified=False, reason=plain or f"{error}.")
|
|
1028
|
+
except Exception:
|
|
1029
|
+
return BuildEntry(run_dir=stop.run_dir, verified=False, reason=UNREADABLE_REASON)
|
|
1030
|
+
finally:
|
|
1031
|
+
if snapshot is not None:
|
|
1032
|
+
_discard_snapshot(snapshot)
|
|
1033
|
+
return BuildEntry(
|
|
1034
|
+
run_dir=stop.run_dir,
|
|
1035
|
+
verified=True,
|
|
1036
|
+
manifest_version=facts.manifest_version,
|
|
1037
|
+
candidate_digest=facts.candidate_digest,
|
|
1038
|
+
table_sha256=facts.table_sha256,
|
|
1039
|
+
row_count=facts.row_count,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _listed_entry(stop: _Stop, root_descriptor: int) -> BuildEntry:
|
|
1044
|
+
"""Read the receipt header and the row count, and nothing else.
|
|
1045
|
+
|
|
1046
|
+
This is the one place in the module that opens a sealed file directly, and it buys speed at
|
|
1047
|
+
the cost of every guarantee the verified reading gives. That is why the entry it returns says
|
|
1048
|
+
``listed, not verified`` in both output modes, and why nothing else in this module does it.
|
|
1049
|
+
"""
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
directory = _open_directory_beneath(root_descriptor, stop.relative)
|
|
1053
|
+
try:
|
|
1054
|
+
receipt = parse_json(_read_unbounded_regular_at(directory, _RECEIPT.parts))
|
|
1055
|
+
finally:
|
|
1056
|
+
os.close(directory)
|
|
1057
|
+
except FileNotFoundError:
|
|
1058
|
+
# Not a build that could not be read: a folder the walk stopped at -- almost always a
|
|
1059
|
+
# Workbench folder that has been made but not run -- which holds no Receipt at all.
|
|
1060
|
+
return BuildEntry(
|
|
1061
|
+
run_dir=stop.run_dir,
|
|
1062
|
+
verified=False,
|
|
1063
|
+
reason=NOTHING_BUILT_REASON,
|
|
1064
|
+
nothing_built=True,
|
|
1065
|
+
)
|
|
1066
|
+
except (OSError, CanonicalJSONError, ValueError):
|
|
1067
|
+
return BuildEntry(run_dir=stop.run_dir, verified=False, reason=UNREADABLE_REASON)
|
|
1068
|
+
if not isinstance(receipt, dict):
|
|
1069
|
+
return BuildEntry(run_dir=stop.run_dir, verified=False, reason=UNREADABLE_REASON)
|
|
1070
|
+
return BuildEntry(
|
|
1071
|
+
run_dir=stop.run_dir,
|
|
1072
|
+
verified=False,
|
|
1073
|
+
manifest_version=_text(receipt.get("manifest_version")),
|
|
1074
|
+
candidate_digest=_text(receipt.get("candidate_digest")),
|
|
1075
|
+
table_sha256=_text(receipt.get("table_sha256")),
|
|
1076
|
+
row_count=_listed_row_count(root_descriptor, stop.relative),
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _listed_row_count(root_descriptor: int, relative: tuple[str, ...]) -> int | None:
|
|
1081
|
+
"""The row count off the same unverified reading; the receipt itself does not carry one."""
|
|
1082
|
+
|
|
1083
|
+
try:
|
|
1084
|
+
directory = _open_directory_beneath(root_descriptor, relative)
|
|
1085
|
+
try:
|
|
1086
|
+
profile = parse_json(_read_unbounded_regular_at(directory, _PROFILE.parts))
|
|
1087
|
+
finally:
|
|
1088
|
+
os.close(directory)
|
|
1089
|
+
except (OSError, CanonicalJSONError, ValueError):
|
|
1090
|
+
return None
|
|
1091
|
+
if not isinstance(profile, dict):
|
|
1092
|
+
return None
|
|
1093
|
+
count = profile.get("row_count")
|
|
1094
|
+
return count if isinstance(count, int) and not isinstance(count, bool) else None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _text(value: Any) -> str | None:
|
|
1098
|
+
return value if isinstance(value, str) else None
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
__all__ = [
|
|
1102
|
+
"BUSY_REASON",
|
|
1103
|
+
"DEFAULT_LIST_DEPTH",
|
|
1104
|
+
"DEFAULT_LIST_ROOT",
|
|
1105
|
+
"DESCENDANT_UNREADABLE_REASON",
|
|
1106
|
+
"LISTED_ONLY",
|
|
1107
|
+
"MAX_LIST_DEPTH",
|
|
1108
|
+
"NOTHING_BUILT",
|
|
1109
|
+
"NOTHING_BUILT_REASON",
|
|
1110
|
+
"NOTHING_TO_VERIFY",
|
|
1111
|
+
"NOT_ALL_VERIFIED",
|
|
1112
|
+
"UNREADABLE",
|
|
1113
|
+
"UNREADABLE_REASON",
|
|
1114
|
+
"UNRESOLVED_REASON",
|
|
1115
|
+
"VERIFIED",
|
|
1116
|
+
"BuildEntry",
|
|
1117
|
+
"BuildFacts",
|
|
1118
|
+
"build_facts",
|
|
1119
|
+
"holds_a_build",
|
|
1120
|
+
"list_builds",
|
|
1121
|
+
"listing_verification",
|
|
1122
|
+
"plain_card",
|
|
1123
|
+
"require_a_build",
|
|
1124
|
+
]
|