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,1087 @@
|
|
|
1
|
+
"""Build the private local index from bounded, exact-verified sealed Builds.
|
|
2
|
+
|
|
3
|
+
Discovery and verification are deliberately separate operations. Discovery retains the supplied
|
|
4
|
+
root descriptor, follows no names, and returns only root-relative candidate coordinates plus
|
|
5
|
+
sanitized diagnostics. It never reads a dataset member. Publication later opens each coordinate
|
|
6
|
+
through the existing exact verifier and writes only beneath the explicit index root.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import errno
|
|
13
|
+
import heapq
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from mostlyright.data_harness import pipeline
|
|
26
|
+
from mostlyright.data_harness.canonical import canonical_json_bytes, canonical_sha256, sha256_bytes
|
|
27
|
+
from mostlyright.data_harness.local_search.contracts import (
|
|
28
|
+
BuildDiagnosticDocument,
|
|
29
|
+
BuildField,
|
|
30
|
+
BuildIndexDiagnostic,
|
|
31
|
+
BuildIndexEntry,
|
|
32
|
+
BuildIndexManifest,
|
|
33
|
+
BuildLocator,
|
|
34
|
+
BuildLocatorDocument,
|
|
35
|
+
BuildRecipeLineage,
|
|
36
|
+
BuildSourceFacts,
|
|
37
|
+
BuildTimeCoverage,
|
|
38
|
+
LocalSearchContractError,
|
|
39
|
+
SealedProfileFactsV1,
|
|
40
|
+
SnapshotMember,
|
|
41
|
+
manifest_aggregate_sha256,
|
|
42
|
+
)
|
|
43
|
+
from mostlyright.data_harness.local_search.sealed import capture_build_index_snapshot
|
|
44
|
+
|
|
45
|
+
_OPEN_DIRECTORY = (
|
|
46
|
+
os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
47
|
+
)
|
|
48
|
+
_RENAME_ARGTYPES = [
|
|
49
|
+
ctypes.c_int,
|
|
50
|
+
ctypes.c_char_p,
|
|
51
|
+
ctypes.c_int,
|
|
52
|
+
ctypes.c_char_p,
|
|
53
|
+
ctypes.c_uint,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LocalSearchIndexError(RuntimeError):
|
|
58
|
+
"""A stable private-index refusal without raw filesystem error text."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, code: str, path: str, detail: str) -> None:
|
|
61
|
+
self.code = code
|
|
62
|
+
self.path = path
|
|
63
|
+
self.detail = detail
|
|
64
|
+
super().__init__(f"{path}: {detail} [{code}]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class BuildDiscoveryLimits:
|
|
69
|
+
"""Aggregate ceilings for one discovery and indexing operation."""
|
|
70
|
+
|
|
71
|
+
max_depth: int = 6
|
|
72
|
+
max_entries: int = 100_000
|
|
73
|
+
max_builds: int = 10_000
|
|
74
|
+
max_receipt_bytes: int = 2 * 1024 * 1024
|
|
75
|
+
max_member_bytes: int = pipeline.MAX_CANDIDATE_MEMBER_BYTES
|
|
76
|
+
max_aggregate_bytes: int = pipeline.MAX_CANDIDATE_BYTES
|
|
77
|
+
max_elapsed_seconds: float = 60.0
|
|
78
|
+
max_diagnostics: int = 100_000
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
integer_bounds = (
|
|
82
|
+
("max_depth", self.max_depth, 32),
|
|
83
|
+
("max_entries", self.max_entries, 1_000_000),
|
|
84
|
+
("max_builds", self.max_builds, 100_000),
|
|
85
|
+
("max_receipt_bytes", self.max_receipt_bytes, 64 * 1024 * 1024),
|
|
86
|
+
# Bounded by the candidate ceilings in ``pipeline``: a member may be one
|
|
87
|
+
# year-scale normalized source and the aggregate may hold the sources a
|
|
88
|
+
# graph execution retains, so the search index admits what a build seals.
|
|
89
|
+
("max_member_bytes", self.max_member_bytes, pipeline.MAX_CANDIDATE_MEMBER_BYTES),
|
|
90
|
+
("max_aggregate_bytes", self.max_aggregate_bytes, pipeline.MAX_CANDIDATE_BYTES),
|
|
91
|
+
("max_diagnostics", self.max_diagnostics, 100_000),
|
|
92
|
+
)
|
|
93
|
+
for name, value, maximum in integer_bounds:
|
|
94
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
95
|
+
raise LocalSearchIndexError(
|
|
96
|
+
"BUILD_LIMIT", f"limits.{name}", f"must be an integer in [1, {maximum}]"
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
isinstance(self.max_elapsed_seconds, bool)
|
|
100
|
+
or not isinstance(self.max_elapsed_seconds, (int, float))
|
|
101
|
+
or not 0 < self.max_elapsed_seconds <= 3600
|
|
102
|
+
):
|
|
103
|
+
raise LocalSearchIndexError(
|
|
104
|
+
"BUILD_LIMIT",
|
|
105
|
+
"limits.max_elapsed_seconds",
|
|
106
|
+
"must be a number in (0, 3600]",
|
|
107
|
+
)
|
|
108
|
+
if self.max_receipt_bytes > self.max_member_bytes:
|
|
109
|
+
raise LocalSearchIndexError(
|
|
110
|
+
"BUILD_LIMIT",
|
|
111
|
+
"limits.max_receipt_bytes",
|
|
112
|
+
"cannot exceed max_member_bytes",
|
|
113
|
+
)
|
|
114
|
+
if self.max_member_bytes > self.max_aggregate_bytes:
|
|
115
|
+
raise LocalSearchIndexError(
|
|
116
|
+
"BUILD_LIMIT",
|
|
117
|
+
"limits.max_member_bytes",
|
|
118
|
+
"cannot exceed max_aggregate_bytes",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class BuildDiscovery:
|
|
124
|
+
"""Deterministic candidate coordinates and bounded diagnostics from one root."""
|
|
125
|
+
|
|
126
|
+
candidates: tuple[str, ...]
|
|
127
|
+
diagnostics: tuple[BuildIndexDiagnostic, ...]
|
|
128
|
+
entries_seen: int
|
|
129
|
+
builds_seen: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class PublishedBuildIndex:
|
|
134
|
+
"""One completely published private index successor."""
|
|
135
|
+
|
|
136
|
+
index_root: Path
|
|
137
|
+
manifest: BuildIndexManifest
|
|
138
|
+
manifest_sha256: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class _RootHandle:
|
|
143
|
+
descriptor: int
|
|
144
|
+
identity: tuple[int, int]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class _WalkState:
|
|
149
|
+
entries_seen: int = 0
|
|
150
|
+
builds_seen: int = 0
|
|
151
|
+
stopped: bool = False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class _DescendingName:
|
|
156
|
+
"""Heap item whose smallest value is the lexicographically greatest retained name."""
|
|
157
|
+
|
|
158
|
+
name: str
|
|
159
|
+
|
|
160
|
+
def __lt__(self, other: _DescendingName) -> bool:
|
|
161
|
+
return self.name > other.name
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def discover_build_candidates(
|
|
165
|
+
root: Path,
|
|
166
|
+
*,
|
|
167
|
+
limits: BuildDiscoveryLimits | None = None,
|
|
168
|
+
) -> BuildDiscovery:
|
|
169
|
+
"""Find possible sealed Builds below one retained, no-follow directory root.
|
|
170
|
+
|
|
171
|
+
A candidate is only a coordinate whose ``candidate/manifest.json`` entry is a bounded,
|
|
172
|
+
single-link regular file. No receipt bytes or dataset rows are read here. Exact admission is
|
|
173
|
+
exclusively the job of :func:`pipeline.open_verified_snapshot` during publication.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
if not isinstance(root, Path):
|
|
177
|
+
raise LocalSearchIndexError("BUILD_INVALID", "root", "must be a pathlib.Path")
|
|
178
|
+
selected = limits or BuildDiscoveryLimits()
|
|
179
|
+
if not isinstance(selected, BuildDiscoveryLimits):
|
|
180
|
+
raise LocalSearchIndexError("BUILD_LIMIT", "limits", "must be BuildDiscoveryLimits")
|
|
181
|
+
|
|
182
|
+
opened = _open_root(root)
|
|
183
|
+
try:
|
|
184
|
+
discovered = _discover_from_open_root(opened, selected, started=time.monotonic())
|
|
185
|
+
_validate_root_binding(root, opened)
|
|
186
|
+
return discovered
|
|
187
|
+
finally:
|
|
188
|
+
os.close(opened.descriptor)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _discover_from_open_root(
|
|
192
|
+
opened: _RootHandle,
|
|
193
|
+
limits: BuildDiscoveryLimits,
|
|
194
|
+
*,
|
|
195
|
+
started: float,
|
|
196
|
+
) -> BuildDiscovery:
|
|
197
|
+
state = _WalkState()
|
|
198
|
+
candidates: list[str] = []
|
|
199
|
+
diagnostics: list[BuildIndexDiagnostic] = []
|
|
200
|
+
pending: list[tuple[tuple[str, ...], int]] = [((), 0)]
|
|
201
|
+
while pending and not state.stopped:
|
|
202
|
+
relative, depth = heapq.heappop(pending)
|
|
203
|
+
if time.monotonic() - started > limits.max_elapsed_seconds:
|
|
204
|
+
_diagnose(
|
|
205
|
+
diagnostics,
|
|
206
|
+
limits,
|
|
207
|
+
"BUILD_LIMIT",
|
|
208
|
+
_diagnostic_path(relative),
|
|
209
|
+
)
|
|
210
|
+
break
|
|
211
|
+
try:
|
|
212
|
+
directory = _open_directory_beneath(opened.descriptor, relative)
|
|
213
|
+
except OSError:
|
|
214
|
+
_diagnose(
|
|
215
|
+
diagnostics,
|
|
216
|
+
limits,
|
|
217
|
+
"BUILD_UNREADABLE",
|
|
218
|
+
_diagnostic_path(relative),
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
candidate = _candidate_disposition(directory, relative, limits)
|
|
223
|
+
if candidate is not None:
|
|
224
|
+
disposition, candidate_entries = candidate
|
|
225
|
+
if state.entries_seen + candidate_entries > limits.max_entries:
|
|
226
|
+
state.stopped = True
|
|
227
|
+
_diagnose(
|
|
228
|
+
diagnostics,
|
|
229
|
+
limits,
|
|
230
|
+
"BUILD_LIMIT",
|
|
231
|
+
_diagnostic_path(relative),
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
state.entries_seen += candidate_entries
|
|
235
|
+
if disposition != "BUILD_CANDIDATE":
|
|
236
|
+
_diagnose(
|
|
237
|
+
diagnostics,
|
|
238
|
+
limits,
|
|
239
|
+
disposition,
|
|
240
|
+
_diagnostic_path(relative),
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
if state.builds_seen >= limits.max_builds:
|
|
244
|
+
_diagnose(
|
|
245
|
+
diagnostics,
|
|
246
|
+
limits,
|
|
247
|
+
"BUILD_LIMIT",
|
|
248
|
+
_diagnostic_path(relative),
|
|
249
|
+
)
|
|
250
|
+
state.stopped = True
|
|
251
|
+
continue
|
|
252
|
+
state.builds_seen += 1
|
|
253
|
+
candidates.append(_relative_path(relative))
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
remaining_entries = limits.max_entries - state.entries_seen
|
|
257
|
+
children, incomplete, first_excluded = _directory_children(
|
|
258
|
+
directory, maximum=remaining_entries
|
|
259
|
+
)
|
|
260
|
+
for name, kind in children:
|
|
261
|
+
_consume_entry(state, limits)
|
|
262
|
+
if kind != "directory":
|
|
263
|
+
if kind != "regular":
|
|
264
|
+
_diagnose(diagnostics, limits, kind, _join(relative, name))
|
|
265
|
+
continue
|
|
266
|
+
child_relative = (*relative, name)
|
|
267
|
+
if depth >= limits.max_depth:
|
|
268
|
+
_diagnose(
|
|
269
|
+
diagnostics,
|
|
270
|
+
limits,
|
|
271
|
+
"BUILD_LIMIT",
|
|
272
|
+
_relative_path(child_relative),
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
heapq.heappush(pending, (child_relative, depth + 1))
|
|
276
|
+
if first_excluded is not None:
|
|
277
|
+
state.stopped = True
|
|
278
|
+
_diagnose(
|
|
279
|
+
diagnostics,
|
|
280
|
+
limits,
|
|
281
|
+
"BUILD_LIMIT",
|
|
282
|
+
_join(relative, first_excluded),
|
|
283
|
+
)
|
|
284
|
+
if incomplete:
|
|
285
|
+
_diagnose(
|
|
286
|
+
diagnostics,
|
|
287
|
+
limits,
|
|
288
|
+
"BUILD_UNREADABLE",
|
|
289
|
+
_diagnostic_path(relative),
|
|
290
|
+
)
|
|
291
|
+
finally:
|
|
292
|
+
os.close(directory)
|
|
293
|
+
return BuildDiscovery(
|
|
294
|
+
candidates=tuple(sorted(candidates)),
|
|
295
|
+
diagnostics=tuple(
|
|
296
|
+
sorted(set(diagnostics), key=lambda item: (item.relative_path, item.code))
|
|
297
|
+
),
|
|
298
|
+
entries_seen=state.entries_seen,
|
|
299
|
+
builds_seen=state.builds_seen,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _validate_root_binding(root: Path, opened: _RootHandle) -> None:
|
|
304
|
+
after = _open_root(root)
|
|
305
|
+
try:
|
|
306
|
+
if after.identity != opened.identity:
|
|
307
|
+
raise LocalSearchIndexError(
|
|
308
|
+
"BUILD_RACE", "root", "changed while Build discovery was in progress"
|
|
309
|
+
)
|
|
310
|
+
finally:
|
|
311
|
+
os.close(after.descriptor)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _open_root(root: Path) -> _RootHandle:
|
|
315
|
+
try:
|
|
316
|
+
named = os.lstat(root)
|
|
317
|
+
except OSError as error:
|
|
318
|
+
raise LocalSearchIndexError(
|
|
319
|
+
"BUILD_UNREADABLE", "root", "cannot inspect the discovery root"
|
|
320
|
+
) from error
|
|
321
|
+
if stat.S_ISLNK(named.st_mode):
|
|
322
|
+
raise LocalSearchIndexError(
|
|
323
|
+
"BUILD_SYMLINK", "root", "the discovery root must not be a link"
|
|
324
|
+
)
|
|
325
|
+
try:
|
|
326
|
+
descriptor = os.open(root, _OPEN_DIRECTORY)
|
|
327
|
+
except OSError as error:
|
|
328
|
+
raise LocalSearchIndexError(
|
|
329
|
+
"BUILD_UNREADABLE", "root", "cannot open a retained no-follow directory"
|
|
330
|
+
) from error
|
|
331
|
+
try:
|
|
332
|
+
opened = os.fstat(descriptor)
|
|
333
|
+
if not stat.S_ISDIR(named.st_mode) or not stat.S_ISDIR(opened.st_mode):
|
|
334
|
+
raise LocalSearchIndexError(
|
|
335
|
+
"BUILD_SPECIAL", "root", "the discovery root must be a directory"
|
|
336
|
+
)
|
|
337
|
+
identity = (opened.st_dev, opened.st_ino)
|
|
338
|
+
if identity != (named.st_dev, named.st_ino):
|
|
339
|
+
raise LocalSearchIndexError(
|
|
340
|
+
"BUILD_RACE", "root", "named and opened discovery roots differ"
|
|
341
|
+
)
|
|
342
|
+
return _RootHandle(descriptor, identity)
|
|
343
|
+
except BaseException:
|
|
344
|
+
os.close(descriptor)
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _open_directory_beneath(root_fd: int, relative: tuple[str, ...]) -> int:
|
|
349
|
+
descriptor = os.dup(root_fd)
|
|
350
|
+
try:
|
|
351
|
+
for name in relative:
|
|
352
|
+
named = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
|
353
|
+
child = os.open(name, _OPEN_DIRECTORY, dir_fd=descriptor)
|
|
354
|
+
opened = os.fstat(child)
|
|
355
|
+
if (
|
|
356
|
+
not stat.S_ISDIR(named.st_mode)
|
|
357
|
+
or not stat.S_ISDIR(opened.st_mode)
|
|
358
|
+
or (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino)
|
|
359
|
+
):
|
|
360
|
+
os.close(child)
|
|
361
|
+
raise OSError(errno.ESTALE, "descendant directory changed")
|
|
362
|
+
os.close(descriptor)
|
|
363
|
+
descriptor = child
|
|
364
|
+
return descriptor
|
|
365
|
+
except BaseException:
|
|
366
|
+
os.close(descriptor)
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _candidate_disposition(
|
|
371
|
+
directory_fd: int,
|
|
372
|
+
relative: tuple[str, ...],
|
|
373
|
+
limits: BuildDiscoveryLimits,
|
|
374
|
+
) -> tuple[str, int] | None:
|
|
375
|
+
try:
|
|
376
|
+
candidate = os.stat("candidate", dir_fd=directory_fd, follow_symlinks=False)
|
|
377
|
+
except FileNotFoundError:
|
|
378
|
+
return None
|
|
379
|
+
except OSError:
|
|
380
|
+
return "BUILD_UNREADABLE", 1
|
|
381
|
+
if stat.S_ISLNK(candidate.st_mode):
|
|
382
|
+
return "BUILD_SYMLINK", 1
|
|
383
|
+
if not stat.S_ISDIR(candidate.st_mode):
|
|
384
|
+
return "BUILD_SPECIAL", 1
|
|
385
|
+
|
|
386
|
+
candidate_fd = -1
|
|
387
|
+
try:
|
|
388
|
+
candidate_fd = os.open("candidate", _OPEN_DIRECTORY, dir_fd=directory_fd)
|
|
389
|
+
opened = os.fstat(candidate_fd)
|
|
390
|
+
if (candidate.st_dev, candidate.st_ino) != (opened.st_dev, opened.st_ino):
|
|
391
|
+
return "BUILD_RACE", 1
|
|
392
|
+
try:
|
|
393
|
+
receipt = os.stat("manifest.json", dir_fd=candidate_fd, follow_symlinks=False)
|
|
394
|
+
except FileNotFoundError:
|
|
395
|
+
return "BUILD_INVALID", 1
|
|
396
|
+
except OSError:
|
|
397
|
+
return "BUILD_UNREADABLE", 2
|
|
398
|
+
if stat.S_ISLNK(receipt.st_mode):
|
|
399
|
+
return "BUILD_SYMLINK", 2
|
|
400
|
+
if not stat.S_ISREG(receipt.st_mode):
|
|
401
|
+
return "BUILD_SPECIAL", 2
|
|
402
|
+
if receipt.st_nlink != 1:
|
|
403
|
+
return "BUILD_HARDLINK", 2
|
|
404
|
+
if receipt.st_size > limits.max_receipt_bytes:
|
|
405
|
+
return "BUILD_LIMIT", 2
|
|
406
|
+
if not relative:
|
|
407
|
+
return "BUILD_INVALID", 2
|
|
408
|
+
return "BUILD_CANDIDATE", 2
|
|
409
|
+
except OSError:
|
|
410
|
+
return "BUILD_RACE", 1
|
|
411
|
+
finally:
|
|
412
|
+
if candidate_fd >= 0:
|
|
413
|
+
os.close(candidate_fd)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _directory_children(
|
|
417
|
+
directory_fd: int, *, maximum: int
|
|
418
|
+
) -> tuple[list[tuple[str, str]], bool, str | None]:
|
|
419
|
+
"""Return only the lexically first bounded child set from an incremental enumeration."""
|
|
420
|
+
|
|
421
|
+
retained: list[_DescendingName] = []
|
|
422
|
+
incomplete = False
|
|
423
|
+
try:
|
|
424
|
+
with os.scandir(directory_fd) as entries:
|
|
425
|
+
iterator = iter(entries)
|
|
426
|
+
while True:
|
|
427
|
+
try:
|
|
428
|
+
entry = next(iterator)
|
|
429
|
+
except StopIteration:
|
|
430
|
+
break
|
|
431
|
+
except OSError:
|
|
432
|
+
incomplete = True
|
|
433
|
+
break
|
|
434
|
+
if entry.name == "candidate":
|
|
435
|
+
continue
|
|
436
|
+
item = _DescendingName(entry.name)
|
|
437
|
+
if len(retained) < maximum + 1:
|
|
438
|
+
heapq.heappush(retained, item)
|
|
439
|
+
elif entry.name < retained[0].name:
|
|
440
|
+
heapq.heapreplace(retained, item)
|
|
441
|
+
except OSError:
|
|
442
|
+
incomplete = True
|
|
443
|
+
|
|
444
|
+
selected_names = sorted(item.name for item in retained)
|
|
445
|
+
first_excluded = selected_names[maximum] if len(selected_names) > maximum else None
|
|
446
|
+
children: list[tuple[str, str]] = []
|
|
447
|
+
for name in selected_names[:maximum]:
|
|
448
|
+
try:
|
|
449
|
+
observed = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
450
|
+
except OSError:
|
|
451
|
+
children.append((name, "BUILD_UNREADABLE"))
|
|
452
|
+
continue
|
|
453
|
+
if stat.S_ISLNK(observed.st_mode):
|
|
454
|
+
children.append((name, "BUILD_SYMLINK"))
|
|
455
|
+
elif stat.S_ISDIR(observed.st_mode):
|
|
456
|
+
children.append((name, "directory"))
|
|
457
|
+
elif not stat.S_ISREG(observed.st_mode):
|
|
458
|
+
children.append((name, "BUILD_SPECIAL"))
|
|
459
|
+
else:
|
|
460
|
+
children.append((name, "regular"))
|
|
461
|
+
return children, incomplete, first_excluded
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _consume_entry(state: _WalkState, limits: BuildDiscoveryLimits) -> bool:
|
|
465
|
+
if state.entries_seen >= limits.max_entries:
|
|
466
|
+
state.stopped = True
|
|
467
|
+
return False
|
|
468
|
+
state.entries_seen += 1
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _diagnose(
|
|
473
|
+
diagnostics: list[BuildIndexDiagnostic],
|
|
474
|
+
limits: BuildDiscoveryLimits,
|
|
475
|
+
code: str,
|
|
476
|
+
relative_path: str,
|
|
477
|
+
) -> None:
|
|
478
|
+
if len(diagnostics) >= limits.max_diagnostics:
|
|
479
|
+
return
|
|
480
|
+
diagnostics.append(BuildIndexDiagnostic(code, relative_path))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _diagnostic_path(relative: tuple[str, ...]) -> str:
|
|
484
|
+
return _relative_path(relative) if relative else "root"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _join(relative: tuple[str, ...], name: str) -> str:
|
|
488
|
+
return _relative_path((*relative, name))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _relative_path(relative: tuple[str, ...]) -> str:
|
|
492
|
+
return PurePosixPath(*relative).as_posix()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def build_private_index(
|
|
496
|
+
build_root: Path,
|
|
497
|
+
index_root: Path,
|
|
498
|
+
*,
|
|
499
|
+
limits: BuildDiscoveryLimits | None = None,
|
|
500
|
+
fault_hook: Callable[[str], None] | None = None,
|
|
501
|
+
) -> PublishedBuildIndex:
|
|
502
|
+
"""Verify local Builds once and atomically publish a complete private index successor."""
|
|
503
|
+
|
|
504
|
+
if not isinstance(build_root, Path) or not isinstance(index_root, Path):
|
|
505
|
+
raise LocalSearchIndexError(
|
|
506
|
+
"BUILD_INVALID", "root", "Build and index roots must be pathlib.Path values"
|
|
507
|
+
)
|
|
508
|
+
selected = limits or BuildDiscoveryLimits()
|
|
509
|
+
if not isinstance(selected, BuildDiscoveryLimits):
|
|
510
|
+
raise LocalSearchIndexError("BUILD_LIMIT", "limits", "must be BuildDiscoveryLimits")
|
|
511
|
+
if fault_hook is not None and not callable(fault_hook):
|
|
512
|
+
raise LocalSearchIndexError("BUILD_INVALID", "fault_hook", "must be callable")
|
|
513
|
+
|
|
514
|
+
build_root = Path(os.path.abspath(os.fspath(build_root)))
|
|
515
|
+
index_root = Path(os.path.abspath(os.fspath(index_root)))
|
|
516
|
+
try:
|
|
517
|
+
common = Path(os.path.commonpath((build_root, index_root)))
|
|
518
|
+
except ValueError as error:
|
|
519
|
+
raise LocalSearchIndexError(
|
|
520
|
+
"BUILD_INVALID", "index_root", "must share a local filesystem coordinate"
|
|
521
|
+
) from error
|
|
522
|
+
if common == build_root:
|
|
523
|
+
raise LocalSearchIndexError(
|
|
524
|
+
"BUILD_INVALID", "index_root", "must be outside the Build discovery root"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
opened = _open_root(build_root)
|
|
528
|
+
started = time.monotonic()
|
|
529
|
+
try:
|
|
530
|
+
discovered = _discover_from_open_root(opened, selected, started=started)
|
|
531
|
+
diagnostics = list(discovered.diagnostics)
|
|
532
|
+
entries_by_digest: dict[str, BuildIndexEntry] = {}
|
|
533
|
+
locators: list[BuildLocator] = []
|
|
534
|
+
aggregate_bytes = 0
|
|
535
|
+
|
|
536
|
+
for relative_path in discovered.candidates:
|
|
537
|
+
if time.monotonic() - started > selected.max_elapsed_seconds:
|
|
538
|
+
_diagnose(diagnostics, selected, "BUILD_LIMIT", relative_path)
|
|
539
|
+
break
|
|
540
|
+
relative = tuple(PurePosixPath(relative_path).parts)
|
|
541
|
+
try:
|
|
542
|
+
run_fd = _open_directory_beneath(opened.descriptor, relative)
|
|
543
|
+
except OSError:
|
|
544
|
+
_diagnose(diagnostics, selected, "BUILD_RACE", relative_path)
|
|
545
|
+
continue
|
|
546
|
+
try:
|
|
547
|
+
remaining_aggregate_bytes = selected.max_aggregate_bytes - aggregate_bytes
|
|
548
|
+
if remaining_aggregate_bytes < 1:
|
|
549
|
+
_diagnose(diagnostics, selected, "BUILD_LIMIT", relative_path)
|
|
550
|
+
continue
|
|
551
|
+
with pipeline.open_verified_snapshot(
|
|
552
|
+
build_root.joinpath(*relative),
|
|
553
|
+
_run_fd=run_fd,
|
|
554
|
+
_ancestor_validator=lambda: _validate_retained_root(build_root, opened),
|
|
555
|
+
_max_manifest_bytes=min(selected.max_receipt_bytes, remaining_aggregate_bytes),
|
|
556
|
+
_max_member_bytes=min(selected.max_member_bytes, remaining_aggregate_bytes),
|
|
557
|
+
_max_aggregate_bytes=remaining_aggregate_bytes,
|
|
558
|
+
) as snapshot:
|
|
559
|
+
verified = snapshot.verified
|
|
560
|
+
manifest = verified.manifest
|
|
561
|
+
member_sizes = _verified_member_sizes(manifest)
|
|
562
|
+
if any(size > selected.max_member_bytes for size in member_sizes):
|
|
563
|
+
_diagnose(diagnostics, selected, "BUILD_LIMIT", relative_path)
|
|
564
|
+
continue
|
|
565
|
+
build_bytes = sum(member_sizes) + len(
|
|
566
|
+
pipeline.canonical_json_line_bytes(manifest)
|
|
567
|
+
)
|
|
568
|
+
if aggregate_bytes + build_bytes > selected.max_aggregate_bytes:
|
|
569
|
+
_diagnose(diagnostics, selected, "BUILD_LIMIT", relative_path)
|
|
570
|
+
continue
|
|
571
|
+
entry = _entry_from_verified_snapshot(snapshot)
|
|
572
|
+
if time.monotonic() - started > selected.max_elapsed_seconds:
|
|
573
|
+
_diagnose(diagnostics, selected, "BUILD_LIMIT", relative_path)
|
|
574
|
+
continue
|
|
575
|
+
before_digest = verified.candidate_digest
|
|
576
|
+
snapshot.validate()
|
|
577
|
+
after_digest = snapshot.verified.candidate_digest
|
|
578
|
+
if after_digest != before_digest or entry.candidate_sha256 != before_digest:
|
|
579
|
+
_diagnose(diagnostics, selected, "BUILD_RACE", relative_path)
|
|
580
|
+
continue
|
|
581
|
+
aggregate_bytes += build_bytes
|
|
582
|
+
entries_by_digest.setdefault(entry.facts_sha256, entry)
|
|
583
|
+
locators.append(BuildLocator(entry.facts_sha256, relative_path))
|
|
584
|
+
except pipeline.BuildError as error:
|
|
585
|
+
_diagnose(
|
|
586
|
+
diagnostics,
|
|
587
|
+
selected,
|
|
588
|
+
_diagnostic_for_build_error(error),
|
|
589
|
+
relative_path,
|
|
590
|
+
)
|
|
591
|
+
except LocalSearchContractError:
|
|
592
|
+
_diagnose(diagnostics, selected, "BUILD_INVALID", relative_path)
|
|
593
|
+
finally:
|
|
594
|
+
os.close(run_fd)
|
|
595
|
+
|
|
596
|
+
_validate_retained_root(build_root, opened)
|
|
597
|
+
finally:
|
|
598
|
+
os.close(opened.descriptor)
|
|
599
|
+
|
|
600
|
+
ordered_entries = tuple(entries_by_digest[key] for key in sorted(entries_by_digest))
|
|
601
|
+
locator_document = BuildLocatorDocument(
|
|
602
|
+
tuple(sorted(set(locators), key=lambda item: (item.facts_sha256, item.relative_path)))
|
|
603
|
+
)
|
|
604
|
+
diagnostic_document = BuildDiagnosticDocument(
|
|
605
|
+
tuple(sorted(set(diagnostics), key=lambda item: (item.relative_path, item.code)))
|
|
606
|
+
)
|
|
607
|
+
return _publish_index(
|
|
608
|
+
index_root,
|
|
609
|
+
ordered_entries,
|
|
610
|
+
locator_document,
|
|
611
|
+
diagnostic_document,
|
|
612
|
+
fault_hook=fault_hook,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _validate_retained_root(root: Path, opened: _RootHandle) -> None:
|
|
617
|
+
observed = os.fstat(opened.descriptor)
|
|
618
|
+
if (observed.st_dev, observed.st_ino) != opened.identity:
|
|
619
|
+
raise LocalSearchIndexError("BUILD_RACE", "root", "retained Build root changed")
|
|
620
|
+
_validate_root_binding(root, opened)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _verified_member_sizes(manifest: Mapping[str, Any]) -> tuple[int, ...]:
|
|
624
|
+
members = manifest.get("members")
|
|
625
|
+
if not isinstance(members, list):
|
|
626
|
+
raise pipeline.BuildError("MANIFEST_INVALID", "candidate members are invalid")
|
|
627
|
+
sizes: list[int] = []
|
|
628
|
+
for member in members:
|
|
629
|
+
if not isinstance(member, Mapping) or type(member.get("bytes")) is not int:
|
|
630
|
+
raise pipeline.BuildError("MANIFEST_INVALID", "candidate member size is invalid")
|
|
631
|
+
sizes.append(member["bytes"])
|
|
632
|
+
return tuple(sizes)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _entry_from_verified_snapshot(snapshot: pipeline.VerifiedSnapshot) -> BuildIndexEntry:
|
|
636
|
+
"""Derive every index field from the one retained verified observation."""
|
|
637
|
+
|
|
638
|
+
verified = snapshot.verified
|
|
639
|
+
inspection = snapshot.inspection()
|
|
640
|
+
profile_raw = snapshot.member_bytes("evidence/profile.json")
|
|
641
|
+
profile = _json_object(profile_raw, "evidence/profile.json")
|
|
642
|
+
lineage = _json_object(snapshot.member_bytes("evidence/lineage.json"), "evidence/lineage.json")
|
|
643
|
+
sources_value = _json_value(
|
|
644
|
+
snapshot.member_bytes("evidence/sources.json"), "evidence/sources.json"
|
|
645
|
+
)
|
|
646
|
+
if not isinstance(sources_value, list):
|
|
647
|
+
raise pipeline.BuildError("CANDIDATE_EVIDENCE_SCHEMA", "sources evidence is invalid")
|
|
648
|
+
|
|
649
|
+
columns = profile.get("columns")
|
|
650
|
+
types = profile.get("types")
|
|
651
|
+
null_counts = profile.get("null_counts")
|
|
652
|
+
row_count = profile.get("row_count")
|
|
653
|
+
if (
|
|
654
|
+
profile.get("schema_version") != pipeline.PROFILE_EVIDENCE_VERSION
|
|
655
|
+
or not isinstance(columns, list)
|
|
656
|
+
or not isinstance(types, Mapping)
|
|
657
|
+
or not isinstance(null_counts, Mapping)
|
|
658
|
+
or type(row_count) is not int
|
|
659
|
+
or set(types) != set(columns)
|
|
660
|
+
or set(null_counts) != set(columns)
|
|
661
|
+
):
|
|
662
|
+
raise pipeline.BuildError("CANDIDATE_EVIDENCE_SCHEMA", "profile evidence is invalid")
|
|
663
|
+
|
|
664
|
+
requirements_raw = _optional_snapshot_member(snapshot, "requirements.json")
|
|
665
|
+
requirements = (
|
|
666
|
+
_json_object(requirements_raw, "requirements.json")
|
|
667
|
+
if requirements_raw is not None
|
|
668
|
+
else None
|
|
669
|
+
)
|
|
670
|
+
manifest = verified.manifest
|
|
671
|
+
requirements_sha256 = (
|
|
672
|
+
sha256_bytes(requirements_raw)
|
|
673
|
+
if requirements_raw is not None
|
|
674
|
+
else _manifest_digest(manifest, "plan_sha256")
|
|
675
|
+
)
|
|
676
|
+
time_coverage = _time_coverage(requirements)
|
|
677
|
+
|
|
678
|
+
source_facts: list[BuildSourceFacts] = []
|
|
679
|
+
for source in sources_value:
|
|
680
|
+
if not isinstance(source, Mapping):
|
|
681
|
+
raise pipeline.BuildError("CANDIDATE_EVIDENCE_SCHEMA", "source evidence is invalid")
|
|
682
|
+
rights = source.get("rights")
|
|
683
|
+
if not isinstance(rights, Mapping):
|
|
684
|
+
raise pipeline.BuildError("CANDIDATE_EVIDENCE_SCHEMA", "source rights are invalid")
|
|
685
|
+
source_facts.append(
|
|
686
|
+
BuildSourceFacts(
|
|
687
|
+
source_id=source.get("source_id"),
|
|
688
|
+
content_sha256=source.get("sha256"),
|
|
689
|
+
rights_status=rights.get("status"),
|
|
690
|
+
rights_evidence_sha256=sha256_bytes(canonical_json_bytes(dict(rights))),
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
recipe_lineage = _recipe_lineage(snapshot)
|
|
695
|
+
return BuildIndexEntry(
|
|
696
|
+
question=inspection.question,
|
|
697
|
+
question_sha256=sha256_bytes(inspection.question.encode("utf-8")),
|
|
698
|
+
requirements_sha256=requirements_sha256,
|
|
699
|
+
grain=inspection.grain,
|
|
700
|
+
time_coverage=time_coverage,
|
|
701
|
+
fields=tuple(BuildField(name, str(types[name])) for name in columns),
|
|
702
|
+
sources=tuple(sorted(source_facts, key=lambda item: item.source_id)),
|
|
703
|
+
profile=SealedProfileFactsV1(
|
|
704
|
+
evidence_sha256=sha256_bytes(profile_raw),
|
|
705
|
+
row_count=row_count,
|
|
706
|
+
columns=tuple(columns),
|
|
707
|
+
logical_types=tuple(str(types[name]) for name in columns),
|
|
708
|
+
null_counts=tuple(null_counts[name] for name in columns),
|
|
709
|
+
),
|
|
710
|
+
recipe_lineage=recipe_lineage,
|
|
711
|
+
candidate_sha256=verified.candidate_digest,
|
|
712
|
+
table_sha256=verified.table_sha256,
|
|
713
|
+
validation_policy_sha256=verified.validation_policy_digest,
|
|
714
|
+
lineage_sha256=sha256_bytes(canonical_json_bytes(lineage)),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _optional_snapshot_member(snapshot: pipeline.VerifiedSnapshot, path: str) -> bytes | None:
|
|
719
|
+
member_paths = {
|
|
720
|
+
item.get("path")
|
|
721
|
+
for item in snapshot.verified.manifest.get("members", ())
|
|
722
|
+
if isinstance(item, Mapping)
|
|
723
|
+
}
|
|
724
|
+
if path not in member_paths:
|
|
725
|
+
return None
|
|
726
|
+
return snapshot.member_bytes(path)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _recipe_lineage(snapshot: pipeline.VerifiedSnapshot) -> BuildRecipeLineage | None:
|
|
730
|
+
recipe_raw = _optional_snapshot_member(snapshot, "recipe.json")
|
|
731
|
+
if recipe_raw is None:
|
|
732
|
+
return None
|
|
733
|
+
execution_raw = _optional_snapshot_member(snapshot, "recipe-execution.json")
|
|
734
|
+
if execution_raw is None:
|
|
735
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_MISMATCH", "recipe execution is absent")
|
|
736
|
+
recipe = _json_object(recipe_raw, "recipe.json")
|
|
737
|
+
execution = _json_object(execution_raw, "recipe-execution.json")
|
|
738
|
+
recipe_sha256 = canonical_sha256(recipe)
|
|
739
|
+
if execution.get("recipe_digest") != recipe_sha256:
|
|
740
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_INVALID", "recipe lineage is invalid")
|
|
741
|
+
return BuildRecipeLineage(
|
|
742
|
+
recipe_id=recipe.get("recipe_id"),
|
|
743
|
+
recipe_version=recipe.get("recipe_version"),
|
|
744
|
+
recipe_sha256=recipe_sha256,
|
|
745
|
+
predecessor_candidate_sha256=execution.get("predecessor_candidate_digest"),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _time_coverage(requirements: Mapping[str, Any] | None) -> BuildTimeCoverage | None:
|
|
750
|
+
if requirements is None:
|
|
751
|
+
return None
|
|
752
|
+
value = requirements.get("time_range")
|
|
753
|
+
if not isinstance(value, Mapping):
|
|
754
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_INVALID", "requirements time range is invalid")
|
|
755
|
+
start = value.get("start_inclusive")
|
|
756
|
+
end = value.get("end_exclusive")
|
|
757
|
+
if not isinstance(start, str) or not isinstance(end, str):
|
|
758
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_INVALID", "requirements time range is invalid")
|
|
759
|
+
return BuildTimeCoverage(start[:10], end[:10])
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _manifest_digest(manifest: Mapping[str, Any], name: str) -> str:
|
|
763
|
+
value = manifest.get(name)
|
|
764
|
+
if not isinstance(value, str):
|
|
765
|
+
raise pipeline.BuildError("MANIFEST_INVALID", f"candidate {name} is invalid")
|
|
766
|
+
return value
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _json_value(raw: bytes, path: str) -> Any:
|
|
770
|
+
try:
|
|
771
|
+
return json.loads(raw)
|
|
772
|
+
except (UnicodeError, json.JSONDecodeError) as error:
|
|
773
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_INVALID", f"{path} is invalid") from error
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _json_object(raw: bytes, path: str) -> Mapping[str, Any]:
|
|
777
|
+
value = _json_value(raw, path)
|
|
778
|
+
if not isinstance(value, Mapping):
|
|
779
|
+
raise pipeline.BuildError("CANDIDATE_MEMBER_INVALID", f"{path} is not an object")
|
|
780
|
+
return value
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _diagnostic_for_build_error(error: pipeline.BuildError) -> str:
|
|
784
|
+
finding = error.finding_id
|
|
785
|
+
if "RACE" in finding:
|
|
786
|
+
return "BUILD_RACE"
|
|
787
|
+
if "TOO_LARGE" in finding or "LIMIT" in finding:
|
|
788
|
+
return "BUILD_LIMIT"
|
|
789
|
+
if "SYMLINK" in finding:
|
|
790
|
+
return "BUILD_SYMLINK"
|
|
791
|
+
if "HARDLINK" in finding:
|
|
792
|
+
return "BUILD_HARDLINK"
|
|
793
|
+
return "BUILD_INVALID"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _publish_index(
|
|
797
|
+
index_root: Path,
|
|
798
|
+
entries: tuple[BuildIndexEntry, ...],
|
|
799
|
+
locators: BuildLocatorDocument,
|
|
800
|
+
diagnostics: BuildDiagnosticDocument,
|
|
801
|
+
*,
|
|
802
|
+
fault_hook: Callable[[str], None] | None,
|
|
803
|
+
) -> PublishedBuildIndex:
|
|
804
|
+
parent = index_root.parent
|
|
805
|
+
parent_fd = _open_publish_parent(parent)
|
|
806
|
+
staging_name = f".{index_root.name}.tmp-{uuid.uuid4().hex}"
|
|
807
|
+
staging = parent / staging_name
|
|
808
|
+
published = False
|
|
809
|
+
staging_exists = False
|
|
810
|
+
staging_fd = -1
|
|
811
|
+
cleanup_fd = -1
|
|
812
|
+
cleanup_identity: tuple[int, int] | None = None
|
|
813
|
+
try:
|
|
814
|
+
try:
|
|
815
|
+
os.mkdir(staging_name, mode=0o700, dir_fd=parent_fd)
|
|
816
|
+
except FileExistsError as error: # pragma: no cover - UUID collision
|
|
817
|
+
raise LocalSearchIndexError(
|
|
818
|
+
"INDEX_DURABILITY", "index_root", "staging coordinate already exists"
|
|
819
|
+
) from error
|
|
820
|
+
staging_exists = True
|
|
821
|
+
staging_fd = os.open(staging_name, _OPEN_DIRECTORY, dir_fd=parent_fd)
|
|
822
|
+
staging_named = os.stat(staging_name, dir_fd=parent_fd, follow_symlinks=False)
|
|
823
|
+
staging_opened = os.fstat(staging_fd)
|
|
824
|
+
cleanup_identity = _directory_identity(staging_opened)
|
|
825
|
+
if (
|
|
826
|
+
not stat.S_ISDIR(staging_named.st_mode)
|
|
827
|
+
or _directory_identity(staging_named) != cleanup_identity
|
|
828
|
+
):
|
|
829
|
+
raise LocalSearchIndexError(
|
|
830
|
+
"INDEX_DURABILITY", "index_root", "staging root identity changed"
|
|
831
|
+
)
|
|
832
|
+
cleanup_fd = staging_fd
|
|
833
|
+
manifest = _write_index_snapshot(staging_fd, entries, locators, diagnostics)
|
|
834
|
+
manifest_bytes = manifest.canonical_bytes
|
|
835
|
+
manifest_sha256 = sha256_bytes(manifest_bytes)
|
|
836
|
+
os.fsync(staging_fd)
|
|
837
|
+
|
|
838
|
+
capture_build_index_snapshot(staging, expected_manifest_sha256=manifest_sha256)
|
|
839
|
+
if fault_hook is not None:
|
|
840
|
+
fault_hook("after_staging")
|
|
841
|
+
|
|
842
|
+
existing = _entry_stat(parent_fd, index_root.name)
|
|
843
|
+
if existing is None:
|
|
844
|
+
os.rename(staging_name, index_root.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
845
|
+
staging_exists = False
|
|
846
|
+
elif stat.S_ISDIR(existing.st_mode):
|
|
847
|
+
previous_fd = os.open(index_root.name, _OPEN_DIRECTORY, dir_fd=parent_fd)
|
|
848
|
+
previous_opened = os.fstat(previous_fd)
|
|
849
|
+
if _directory_identity(existing) != _directory_identity(previous_opened):
|
|
850
|
+
os.close(previous_fd)
|
|
851
|
+
raise LocalSearchIndexError(
|
|
852
|
+
"INDEX_DURABILITY", "index_root", "existing index root identity changed"
|
|
853
|
+
)
|
|
854
|
+
try:
|
|
855
|
+
_rename_exchange(parent_fd, staging_name, index_root.name)
|
|
856
|
+
except BaseException:
|
|
857
|
+
os.close(previous_fd)
|
|
858
|
+
raise
|
|
859
|
+
cleanup_fd = previous_fd
|
|
860
|
+
cleanup_identity = _directory_identity(previous_opened)
|
|
861
|
+
else:
|
|
862
|
+
raise LocalSearchIndexError(
|
|
863
|
+
"INDEX_DURABILITY", "index_root", "existing index root is not a directory"
|
|
864
|
+
)
|
|
865
|
+
published = True
|
|
866
|
+
os.fsync(parent_fd)
|
|
867
|
+
if fault_hook is not None:
|
|
868
|
+
fault_hook("after_publish")
|
|
869
|
+
captured = capture_build_index_snapshot(
|
|
870
|
+
index_root, expected_manifest_sha256=manifest_sha256
|
|
871
|
+
)
|
|
872
|
+
if captured.manifest != manifest:
|
|
873
|
+
raise LocalSearchIndexError(
|
|
874
|
+
"INDEX_DURABILITY", "index_root", "published successor failed exact readback"
|
|
875
|
+
)
|
|
876
|
+
return PublishedBuildIndex(index_root, manifest, manifest_sha256)
|
|
877
|
+
finally:
|
|
878
|
+
try:
|
|
879
|
+
if staging_exists and cleanup_fd >= 0 and cleanup_identity is not None:
|
|
880
|
+
_remove_open_tree_at(
|
|
881
|
+
parent_fd,
|
|
882
|
+
staging_name,
|
|
883
|
+
cleanup_fd,
|
|
884
|
+
expected_identity=cleanup_identity,
|
|
885
|
+
ignore_errors=published,
|
|
886
|
+
)
|
|
887
|
+
finally:
|
|
888
|
+
if cleanup_fd >= 0 and cleanup_fd != staging_fd:
|
|
889
|
+
os.close(cleanup_fd)
|
|
890
|
+
if staging_fd >= 0:
|
|
891
|
+
os.close(staging_fd)
|
|
892
|
+
os.close(parent_fd)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _open_publish_parent(parent: Path) -> int:
|
|
896
|
+
try:
|
|
897
|
+
named = os.lstat(parent)
|
|
898
|
+
descriptor = os.open(parent, _OPEN_DIRECTORY)
|
|
899
|
+
opened = os.fstat(descriptor)
|
|
900
|
+
except OSError as error:
|
|
901
|
+
raise LocalSearchIndexError(
|
|
902
|
+
"INDEX_DURABILITY", "index_root", "cannot open the retained publication parent"
|
|
903
|
+
) from error
|
|
904
|
+
if not stat.S_ISDIR(named.st_mode) or (named.st_dev, named.st_ino) != (
|
|
905
|
+
opened.st_dev,
|
|
906
|
+
opened.st_ino,
|
|
907
|
+
):
|
|
908
|
+
os.close(descriptor)
|
|
909
|
+
raise LocalSearchIndexError(
|
|
910
|
+
"INDEX_DURABILITY", "index_root", "publication parent identity changed"
|
|
911
|
+
)
|
|
912
|
+
return descriptor
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _write_index_snapshot(
|
|
916
|
+
root_fd: int,
|
|
917
|
+
entries: tuple[BuildIndexEntry, ...],
|
|
918
|
+
locators: BuildLocatorDocument,
|
|
919
|
+
diagnostics: BuildDiagnosticDocument,
|
|
920
|
+
) -> BuildIndexManifest:
|
|
921
|
+
os.mkdir("entries", mode=0o700, dir_fd=root_fd)
|
|
922
|
+
entries_fd = os.open("entries", _OPEN_DIRECTORY, dir_fd=root_fd)
|
|
923
|
+
entry_members: list[SnapshotMember] = []
|
|
924
|
+
try:
|
|
925
|
+
for entry in sorted(entries, key=lambda item: item.facts_sha256):
|
|
926
|
+
raw = entry.canonical_bytes
|
|
927
|
+
name = f"{entry.facts_sha256}.json"
|
|
928
|
+
_create_file_at(entries_fd, name, raw, mode=0o444)
|
|
929
|
+
entry_members.append(SnapshotMember(f"entries/{name}", len(raw), sha256_bytes(raw)))
|
|
930
|
+
os.fsync(entries_fd)
|
|
931
|
+
finally:
|
|
932
|
+
os.close(entries_fd)
|
|
933
|
+
|
|
934
|
+
locator_raw = locators.canonical_bytes
|
|
935
|
+
diagnostic_raw = diagnostics.canonical_bytes
|
|
936
|
+
_create_file_at(root_fd, "locators.json", locator_raw, mode=0o600)
|
|
937
|
+
_create_file_at(root_fd, "diagnostics.json", diagnostic_raw, mode=0o600)
|
|
938
|
+
locator_member = SnapshotMember("locators.json", len(locator_raw), sha256_bytes(locator_raw))
|
|
939
|
+
diagnostic_member = SnapshotMember(
|
|
940
|
+
"diagnostics.json", len(diagnostic_raw), sha256_bytes(diagnostic_raw)
|
|
941
|
+
)
|
|
942
|
+
members = (*entry_members, locator_member, diagnostic_member)
|
|
943
|
+
manifest = BuildIndexManifest(
|
|
944
|
+
entries=tuple(entry_members),
|
|
945
|
+
locators=locator_member,
|
|
946
|
+
diagnostics=diagnostic_member,
|
|
947
|
+
entry_count=len(entry_members),
|
|
948
|
+
locator_count=len(locators.locators),
|
|
949
|
+
diagnostic_count=len(diagnostics.diagnostics),
|
|
950
|
+
total_bytes=sum(item.bytes for item in members),
|
|
951
|
+
aggregate_sha256=manifest_aggregate_sha256(members),
|
|
952
|
+
)
|
|
953
|
+
_create_file_at(root_fd, "manifest.json", manifest.canonical_bytes, mode=0o600)
|
|
954
|
+
return manifest
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _create_file_at(parent_fd: int, name: str, raw: bytes, *, mode: int) -> None:
|
|
958
|
+
flags = (
|
|
959
|
+
os.O_RDWR
|
|
960
|
+
| os.O_CREAT
|
|
961
|
+
| os.O_EXCL
|
|
962
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
963
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
964
|
+
)
|
|
965
|
+
descriptor = os.open(name, flags, 0o600, dir_fd=parent_fd)
|
|
966
|
+
try:
|
|
967
|
+
view = memoryview(raw)
|
|
968
|
+
while view:
|
|
969
|
+
written = os.write(descriptor, view)
|
|
970
|
+
if written < 1:
|
|
971
|
+
raise LocalSearchIndexError(
|
|
972
|
+
"INDEX_DURABILITY", name, "member write stopped making progress"
|
|
973
|
+
)
|
|
974
|
+
view = view[written:]
|
|
975
|
+
os.fsync(descriptor)
|
|
976
|
+
observed = os.pread(descriptor, len(raw) + 1, 0)
|
|
977
|
+
if observed != raw:
|
|
978
|
+
raise LocalSearchIndexError(
|
|
979
|
+
"INDEX_DURABILITY", name, "member readback differs from committed bytes"
|
|
980
|
+
)
|
|
981
|
+
os.fchmod(descriptor, mode)
|
|
982
|
+
os.fsync(descriptor)
|
|
983
|
+
finally:
|
|
984
|
+
os.close(descriptor)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _entry_stat(parent_fd: int, name: str) -> os.stat_result | None:
|
|
988
|
+
try:
|
|
989
|
+
return os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
990
|
+
except FileNotFoundError:
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _directory_identity(observed: os.stat_result) -> tuple[int, int]:
|
|
995
|
+
return observed.st_dev, observed.st_ino
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _remove_open_tree_at(
|
|
999
|
+
parent_fd: int,
|
|
1000
|
+
name: str,
|
|
1001
|
+
descriptor: int,
|
|
1002
|
+
*,
|
|
1003
|
+
expected_identity: tuple[int, int],
|
|
1004
|
+
ignore_errors: bool,
|
|
1005
|
+
) -> None:
|
|
1006
|
+
"""Remove only the still-bound descriptor-rooted staging tree."""
|
|
1007
|
+
|
|
1008
|
+
try:
|
|
1009
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1010
|
+
opened = os.fstat(descriptor)
|
|
1011
|
+
if (
|
|
1012
|
+
not stat.S_ISDIR(named.st_mode)
|
|
1013
|
+
or not stat.S_ISDIR(opened.st_mode)
|
|
1014
|
+
or _directory_identity(named) != expected_identity
|
|
1015
|
+
or _directory_identity(opened) != expected_identity
|
|
1016
|
+
):
|
|
1017
|
+
return
|
|
1018
|
+
_clear_open_directory(descriptor)
|
|
1019
|
+
after = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1020
|
+
if _directory_identity(after) != expected_identity:
|
|
1021
|
+
return
|
|
1022
|
+
os.rmdir(name, dir_fd=parent_fd)
|
|
1023
|
+
except FileNotFoundError:
|
|
1024
|
+
return
|
|
1025
|
+
except OSError as error:
|
|
1026
|
+
if not ignore_errors:
|
|
1027
|
+
raise LocalSearchIndexError(
|
|
1028
|
+
"INDEX_DURABILITY", "index_root", "staging cleanup failed safely"
|
|
1029
|
+
) from error
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _clear_open_directory(directory_fd: int) -> None:
|
|
1033
|
+
with os.scandir(directory_fd) as iterator:
|
|
1034
|
+
names = sorted(entry.name for entry in iterator)
|
|
1035
|
+
for name in names:
|
|
1036
|
+
named = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
1037
|
+
if stat.S_ISDIR(named.st_mode):
|
|
1038
|
+
child_fd = os.open(name, _OPEN_DIRECTORY, dir_fd=directory_fd)
|
|
1039
|
+
try:
|
|
1040
|
+
opened = os.fstat(child_fd)
|
|
1041
|
+
identity = _directory_identity(opened)
|
|
1042
|
+
if _directory_identity(named) != identity:
|
|
1043
|
+
raise OSError(errno.ESTALE, "cleanup child identity changed")
|
|
1044
|
+
_clear_open_directory(child_fd)
|
|
1045
|
+
after = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
1046
|
+
if _directory_identity(after) != identity:
|
|
1047
|
+
raise OSError(errno.ESTALE, "cleanup child identity changed")
|
|
1048
|
+
os.rmdir(name, dir_fd=directory_fd)
|
|
1049
|
+
finally:
|
|
1050
|
+
os.close(child_fd)
|
|
1051
|
+
else:
|
|
1052
|
+
os.unlink(name, dir_fd=directory_fd)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _rename_exchange(parent_fd: int, left: str, right: str) -> None:
|
|
1056
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
1057
|
+
left_raw = os.fsencode(left)
|
|
1058
|
+
right_raw = os.fsencode(right)
|
|
1059
|
+
if sys.platform == "darwin" and hasattr(libc, "renameatx_np"):
|
|
1060
|
+
rename = libc.renameatx_np
|
|
1061
|
+
rename.argtypes = _RENAME_ARGTYPES
|
|
1062
|
+
rename.restype = ctypes.c_int
|
|
1063
|
+
result = rename(parent_fd, left_raw, parent_fd, right_raw, 0x00000002)
|
|
1064
|
+
elif hasattr(libc, "renameat2"):
|
|
1065
|
+
rename = libc.renameat2
|
|
1066
|
+
rename.argtypes = _RENAME_ARGTYPES
|
|
1067
|
+
rename.restype = ctypes.c_int
|
|
1068
|
+
result = rename(parent_fd, left_raw, parent_fd, right_raw, 0x00000002)
|
|
1069
|
+
else: # pragma: no cover - supported product platforms provide one primitive
|
|
1070
|
+
raise LocalSearchIndexError(
|
|
1071
|
+
"INDEX_DURABILITY", "index_root", "atomic directory exchange is unavailable"
|
|
1072
|
+
)
|
|
1073
|
+
if result != 0:
|
|
1074
|
+
error_number = ctypes.get_errno()
|
|
1075
|
+
raise LocalSearchIndexError(
|
|
1076
|
+
"INDEX_DURABILITY", "index_root", "atomic directory exchange failed"
|
|
1077
|
+
) from OSError(error_number, os.strerror(error_number))
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
__all__ = [
|
|
1081
|
+
"BuildDiscovery",
|
|
1082
|
+
"BuildDiscoveryLimits",
|
|
1083
|
+
"LocalSearchIndexError",
|
|
1084
|
+
"PublishedBuildIndex",
|
|
1085
|
+
"build_private_index",
|
|
1086
|
+
"discover_build_candidates",
|
|
1087
|
+
]
|