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.
Files changed (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,885 @@
1
+ """Fenced ingestion-store protocol and a crash-safe local implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import hashlib
8
+ import os
9
+ import secrets
10
+ import stat
11
+ import time
12
+ from collections.abc import Iterable, Iterator
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ from mostlyright.data_harness.canonical import (
18
+ CanonicalJSONError,
19
+ canonical_json_bytes,
20
+ parse_canonical_json,
21
+ )
22
+ from mostlyright.data_harness.ingestion.contracts import (
23
+ ChunkRef,
24
+ IngestionConflict,
25
+ IngestionCorrupt,
26
+ IngestionError,
27
+ IngestionManifestV1,
28
+ )
29
+ from mostlyright.data_harness.ingestion.faults import DeterministicFaultInjector
30
+ from mostlyright.data_harness.ingestion.spool import SpooledChunk, spool_hash_stream
31
+
32
+ MAX_MANIFEST_BYTES = 16 * 1024 * 1024
33
+ DEFAULT_MAX_SPOOL_BYTES = 256 * 1024 * 1024
34
+ DEFAULT_MAX_SPOOL_TOTAL_BYTES = 512 * 1024 * 1024
35
+ DEFAULT_MAX_STORE_BYTES = 4 * 1024 * 1024 * 1024
36
+ DEFAULT_STALE_SPOOL_AGE_SECONDS = 60 * 60
37
+ _READ_CHUNK_SIZE = 1024 * 1024
38
+
39
+
40
+ @runtime_checkable
41
+ class IngestionStore(Protocol):
42
+ """Durable source-pinned state transitions required by the ingestion engine.
43
+
44
+ A caller supplies the digest it observed before a transition. Implementations must
45
+ either apply that exact transition or report a conflict; an immediate replay of an
46
+ already applied append/finalization may instead return the committed successor.
47
+ """
48
+
49
+ def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
50
+ """Create an initial manifest, returning it on exact create replay."""
51
+
52
+ def load(self, run_id: str) -> IngestionManifestV1 | None:
53
+ """Return the current manifest for a run, or ``None`` if it does not exist."""
54
+
55
+ def append_stream(
56
+ self,
57
+ *,
58
+ run_id: str,
59
+ expected_manifest_sha256: str,
60
+ chunk: ChunkRef,
61
+ parts: Iterable[bytes | bytearray | memoryview],
62
+ ) -> IngestionManifestV1:
63
+ """Durably spool exact stream bytes then append their reference under a manifest fence."""
64
+
65
+ def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
66
+ """Freeze a gap-free sequence under a manifest fence."""
67
+
68
+ def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
69
+ """Yield one verified immutable chunk without materializing its full byte payload."""
70
+
71
+
72
+ @dataclass
73
+ class LocalIngestionStore:
74
+ """Private-directory reference store with durable immutable blobs and atomic manifests.
75
+
76
+ The only irreversible ordering is ``fsync(blob) -> link immutable blob -> fsync(chunk
77
+ directory) -> replace+fsync manifest``. A crash before the final replacement leaves an
78
+ unreferenced immutable blob, which is safe to reuse; a crash after it leaves a valid
79
+ successor manifest, which an idempotent retry recognizes through its predecessor link.
80
+ """
81
+
82
+ root: Path
83
+ fault_injector: DeterministicFaultInjector | None = None
84
+ maximum_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
85
+ maximum_spool_total_bytes: int = DEFAULT_MAX_SPOOL_TOTAL_BYTES
86
+ maximum_store_bytes: int = DEFAULT_MAX_STORE_BYTES
87
+ stale_spool_age_seconds: int = DEFAULT_STALE_SPOOL_AGE_SECONDS
88
+
89
+ def __post_init__(self) -> None:
90
+ self.root = Path(self.root)
91
+ if not self.root.is_absolute() or self.root.is_symlink() or not self.root.is_dir():
92
+ raise IngestionError(
93
+ "INGESTION_STORE_ROOT",
94
+ "ingestion_store.root",
95
+ "must be an existing absolute directory",
96
+ )
97
+ self._require_private_directory(self.root, "ingestion_store.root")
98
+ self._validate_limits()
99
+ for name in ("runs", "chunks", "spool"):
100
+ path = self.root / name
101
+ try:
102
+ path.mkdir(mode=0o700)
103
+ except FileExistsError:
104
+ pass
105
+ except OSError as error:
106
+ raise IngestionError(
107
+ "INGESTION_STORE_IO", "ingestion_store.root", "could not create store directory"
108
+ ) from error
109
+ self._require_private_directory(path, f"ingestion_store.{name}")
110
+ self._runs = self.root / "runs"
111
+ self._chunks = self.root / "chunks"
112
+ self._spool = self.root / "spool"
113
+ self._lock_path = self.root / ".ingestion.lock"
114
+ self._checkpoint("store.before_root_fsync")
115
+ self._fsync_directory(self.root, "ingestion_store.root")
116
+ self._checkpoint("store.after_root_fsync")
117
+ with self._locked():
118
+ self._reap_stale_spools_locked(now=time.time())
119
+
120
+ def spool(
121
+ self,
122
+ parts: Iterable[bytes | bytearray | memoryview],
123
+ *,
124
+ maximum_bytes: int | None = None,
125
+ ) -> SpooledChunk:
126
+ """Spool a byte stream once; this does not mutate a run manifest."""
127
+
128
+ limit = self.maximum_spool_bytes if maximum_bytes is None else maximum_bytes
129
+ if (
130
+ isinstance(limit, bool)
131
+ or not isinstance(limit, int)
132
+ or limit < 0
133
+ or limit > self.maximum_spool_bytes
134
+ ):
135
+ raise IngestionError(
136
+ "INGESTION_SPOOL_LIMIT",
137
+ "spool.maximum_bytes",
138
+ "must not exceed the local per-spool quota",
139
+ )
140
+ return spool_hash_stream(
141
+ self._spool,
142
+ parts,
143
+ maximum_bytes=limit,
144
+ fault_injector=self.fault_injector,
145
+ reserve_bytes=self._reserve_spool_bytes,
146
+ )
147
+
148
+ def reap_stale_spools(self, *, now: float | None = None) -> int:
149
+ """Reclaim abandoned private temporary spools without touching a live writer."""
150
+
151
+ current = time.time() if now is None else now
152
+ if not isinstance(current, (int, float)) or current < 0:
153
+ raise IngestionError("INGESTION_SPOOL_CLOCK", "spool.now", "must be a Unix timestamp")
154
+ with self._locked():
155
+ return self._reap_stale_spools_locked(now=float(current))
156
+
157
+ def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
158
+ if (
159
+ manifest.state != "open"
160
+ or manifest.chunks
161
+ or manifest.previous_manifest_sha256 is not None
162
+ ):
163
+ raise IngestionConflict(
164
+ "INGESTION_CREATE_STATE",
165
+ "ingestion_manifest",
166
+ "only an initial open manifest may be created",
167
+ )
168
+ with self._locked():
169
+ current = self._load_locked(manifest.run_id)
170
+ if current is not None:
171
+ if current.digest == manifest.digest:
172
+ return current
173
+ raise IngestionConflict(
174
+ "INGESTION_RUN_EXISTS",
175
+ "ingestion_manifest.run_id",
176
+ "already has a different manifest",
177
+ )
178
+ self._replace_manifest_locked(manifest, checkpoint_prefix="store.create")
179
+ return manifest
180
+
181
+ def load(self, run_id: str) -> IngestionManifestV1 | None:
182
+ self._validate_run_id(run_id)
183
+ with self._locked():
184
+ return self._load_locked(run_id)
185
+
186
+ def append_stream(
187
+ self,
188
+ *,
189
+ run_id: str,
190
+ expected_manifest_sha256: str,
191
+ chunk: ChunkRef,
192
+ parts: Iterable[bytes | bytearray | memoryview],
193
+ ) -> IngestionManifestV1:
194
+ """Spool one replayable stream then perform the fenced durable append."""
195
+
196
+ spooled = self.spool(parts)
197
+ return self.append_spooled(
198
+ run_id=run_id,
199
+ expected_manifest_sha256=expected_manifest_sha256,
200
+ chunk=chunk,
201
+ spooled=spooled,
202
+ )
203
+
204
+ def append_spooled(
205
+ self,
206
+ *,
207
+ run_id: str,
208
+ expected_manifest_sha256: str,
209
+ chunk: ChunkRef,
210
+ spooled: SpooledChunk,
211
+ ) -> IngestionManifestV1:
212
+ self._validate_run_id(run_id)
213
+ self._validate_manifest_digest(expected_manifest_sha256)
214
+ if not isinstance(chunk, ChunkRef):
215
+ raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
216
+ self._verify_spool(spooled, chunk)
217
+ try:
218
+ with self._locked():
219
+ current = self._require_manifest_locked(run_id)
220
+ if chunk.ordinal < len(current.chunks):
221
+ if current.chunks[chunk.ordinal] != chunk:
222
+ raise IngestionConflict(
223
+ "INGESTION_REPLAY_MISMATCH",
224
+ "chunk_ref.ordinal",
225
+ "already names different committed chunk bytes or metadata",
226
+ )
227
+ result = current
228
+ else:
229
+ if current.is_finalized:
230
+ raise IngestionConflict(
231
+ "INGESTION_FINALIZED",
232
+ "ingestion_manifest.state",
233
+ "cannot append to a finalized run",
234
+ )
235
+ if current.digest != expected_manifest_sha256:
236
+ raise IngestionConflict(
237
+ "INGESTION_MANIFEST_FENCE",
238
+ "expected_manifest_sha256",
239
+ "does not match the current manifest",
240
+ )
241
+ successor = current.append(chunk)
242
+ self._publish_spooled_blob_locked(spooled, chunk)
243
+ self._checkpoint("store.after_blob_publish")
244
+ self._replace_manifest_locked(successor, checkpoint_prefix="store.append")
245
+ result = successor
246
+ except IngestionConflict:
247
+ # No blob is published until after all logical fences (including source drift) have
248
+ # passed. A conflict therefore owns no durable work and can release its temporary.
249
+ self._discard_spool(spooled)
250
+ raise
251
+ self._discard_spool(spooled)
252
+ return result
253
+
254
+ def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
255
+ self._validate_run_id(run_id)
256
+ self._validate_manifest_digest(expected_manifest_sha256)
257
+ with self._locked():
258
+ current = self._require_manifest_locked(run_id)
259
+ if current.is_finalized:
260
+ if current.digest == expected_manifest_sha256 or current.is_direct_successor_of(
261
+ expected_manifest_sha256
262
+ ):
263
+ return current
264
+ raise IngestionConflict(
265
+ "INGESTION_MANIFEST_FENCE",
266
+ "expected_manifest_sha256",
267
+ "does not name the finalization predecessor",
268
+ )
269
+ if current.digest != expected_manifest_sha256:
270
+ raise IngestionConflict(
271
+ "INGESTION_MANIFEST_FENCE",
272
+ "expected_manifest_sha256",
273
+ "does not match the current manifest",
274
+ )
275
+ # A Merkle commitment protects the ordered *references*. Before making that
276
+ # commitment externally consumable, prove every local immutable object still has
277
+ # the exact bytes those references name.
278
+ for chunk in current.chunks:
279
+ self._verify_chunk_file(self._chunk_path(chunk.content_sha256), chunk)
280
+ successor = current.finalize()
281
+ self._replace_manifest_locked(successor, checkpoint_prefix="store.finalize")
282
+ return successor
283
+
284
+ def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
285
+ """Yield verified immutable bytes in bounded pieces without materializing a chunk.
286
+
287
+ The complete object is hashed once before yielding from the still-open descriptor.
288
+ Immutable-object publication means that descriptor is then stable after the store
289
+ lock is released, without holding writers hostage for the duration of a large read.
290
+ """
291
+
292
+ if not isinstance(chunk, ChunkRef):
293
+ raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
294
+ descriptor = -1
295
+ try:
296
+ with self._locked():
297
+ path = self._chunk_path(chunk.content_sha256)
298
+ try:
299
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
300
+ except FileNotFoundError as error:
301
+ raise IngestionCorrupt(
302
+ "INGESTION_BLOB_MISSING",
303
+ "chunk_ref.content_sha256",
304
+ "referenced blob is absent",
305
+ ) from error
306
+ except OSError as error:
307
+ raise IngestionCorrupt(
308
+ "INGESTION_BLOB_IO",
309
+ "chunk_ref.content_sha256",
310
+ "referenced blob cannot be opened",
311
+ ) from error
312
+ info = os.fstat(descriptor)
313
+ if not stat.S_ISREG(info.st_mode) or info.st_size != chunk.byte_length:
314
+ raise IngestionCorrupt(
315
+ "INGESTION_BLOB_SHAPE",
316
+ "chunk_ref.content_sha256",
317
+ "referenced blob is not the expected regular-file length",
318
+ )
319
+ if self._hash_descriptor(descriptor) != chunk.content_sha256:
320
+ raise IngestionCorrupt(
321
+ "INGESTION_BLOB_DIGEST",
322
+ "chunk_ref.content_sha256",
323
+ "referenced blob hash differs",
324
+ )
325
+ os.lseek(descriptor, 0, os.SEEK_SET)
326
+ while raw := os.read(descriptor, _READ_CHUNK_SIZE):
327
+ yield raw
328
+ finally:
329
+ if descriptor >= 0:
330
+ os.close(descriptor)
331
+
332
+ def read_chunk(self, chunk: ChunkRef, *, maximum_bytes: int = 64 * 1024 * 1024) -> bytes:
333
+ """Read a small verified blob; streaming callers must use :meth:`iter_chunk_parts`."""
334
+
335
+ if not isinstance(chunk, ChunkRef):
336
+ raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
337
+ if (
338
+ isinstance(maximum_bytes, bool)
339
+ or not isinstance(maximum_bytes, int)
340
+ or maximum_bytes < 0
341
+ or chunk.byte_length > maximum_bytes
342
+ ):
343
+ raise IngestionError(
344
+ "INGESTION_READ_LIMIT",
345
+ "chunk_ref.byte_length",
346
+ "requires bounded streaming access",
347
+ )
348
+ return b"".join(self.iter_chunk_parts(chunk))
349
+
350
+ @contextlib.contextmanager
351
+ def _locked(self) -> Iterator[None]:
352
+ descriptor = -1
353
+ try:
354
+ # The lock coordinate itself must not be creatable or replaceable by another local
355
+ # user. Check its parent before opening and then check the opened inode before flock.
356
+ self._require_private_directory(self.root, "ingestion_store.root")
357
+ descriptor = os.open(
358
+ self._lock_path,
359
+ os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
360
+ 0o600,
361
+ )
362
+ info = os.fstat(descriptor)
363
+ if (
364
+ not stat.S_ISREG(info.st_mode)
365
+ or info.st_nlink != 1
366
+ or info.st_uid != os.geteuid()
367
+ or stat.S_IMODE(info.st_mode) != 0o600
368
+ ):
369
+ raise IngestionCorrupt(
370
+ "INGESTION_LOCK_PATH",
371
+ "ingestion_store.lock",
372
+ "must be an effective-user-owned mode-0600 regular file",
373
+ )
374
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
375
+ self._require_private_directory(self.root, "ingestion_store.root")
376
+ self._require_private_directory(self._runs, "ingestion_store.runs")
377
+ self._require_private_directory(self._chunks, "ingestion_store.chunks")
378
+ self._require_private_directory(self._spool, "ingestion_store.spool")
379
+ yield
380
+ except OSError as error:
381
+ raise IngestionError(
382
+ "INGESTION_STORE_IO", "ingestion_store.lock", "lock operation failed"
383
+ ) from error
384
+ finally:
385
+ if descriptor >= 0:
386
+ os.close(descriptor)
387
+
388
+ def _load_locked(self, run_id: str) -> IngestionManifestV1 | None:
389
+ path = self._manifest_path(run_id)
390
+ try:
391
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
392
+ except FileNotFoundError:
393
+ return None
394
+ except OSError as error:
395
+ raise IngestionCorrupt(
396
+ "INGESTION_MANIFEST_IO", "ingestion_manifest", "manifest cannot be opened"
397
+ ) from error
398
+ try:
399
+ info = os.fstat(descriptor)
400
+ if (
401
+ not stat.S_ISREG(info.st_mode)
402
+ or info.st_nlink != 1
403
+ or info.st_size > MAX_MANIFEST_BYTES
404
+ ):
405
+ raise IngestionCorrupt(
406
+ "INGESTION_MANIFEST_SHAPE",
407
+ "ingestion_manifest",
408
+ "manifest has an unsafe file shape",
409
+ )
410
+ raw = self._read_descriptor(descriptor, maximum=MAX_MANIFEST_BYTES)
411
+ finally:
412
+ os.close(descriptor)
413
+ try:
414
+ manifest = IngestionManifestV1.from_dict(parse_canonical_json(raw))
415
+ except (CanonicalJSONError, IngestionError) as error:
416
+ raise IngestionCorrupt(
417
+ "INGESTION_MANIFEST_CORRUPT",
418
+ "ingestion_manifest",
419
+ "is not a valid canonical manifest",
420
+ ) from error
421
+ if manifest.run_id != run_id:
422
+ raise IngestionCorrupt(
423
+ "INGESTION_MANIFEST_RUN",
424
+ "ingestion_manifest.run_id",
425
+ "does not match its file coordinate",
426
+ )
427
+ return manifest
428
+
429
+ def _require_manifest_locked(self, run_id: str) -> IngestionManifestV1:
430
+ current = self._load_locked(run_id)
431
+ if current is None:
432
+ raise IngestionConflict(
433
+ "INGESTION_RUN_MISSING", "ingestion_manifest.run_id", "does not exist"
434
+ )
435
+ return current
436
+
437
+ def _publish_spooled_blob_locked(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
438
+ self._verify_spool(spooled, chunk)
439
+ destination = self._chunk_path(chunk.content_sha256)
440
+ if destination.exists():
441
+ self._verify_chunk_file(destination, chunk)
442
+ return
443
+ self._checkpoint("store.before_blob_link")
444
+ try:
445
+ os.link(spooled.path, destination, follow_symlinks=False)
446
+ except FileExistsError:
447
+ self._verify_chunk_file(destination, chunk)
448
+ return
449
+ except OSError as error:
450
+ raise IngestionError(
451
+ "INGESTION_BLOB_IO", "chunk_ref.content_sha256", "could not publish immutable blob"
452
+ ) from error
453
+ try:
454
+ self._verify_chunk_file(destination, chunk)
455
+ directory_descriptor = os.open(
456
+ self._chunks, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
457
+ )
458
+ try:
459
+ os.fsync(directory_descriptor)
460
+ finally:
461
+ os.close(directory_descriptor)
462
+ except OSError as error:
463
+ raise IngestionError(
464
+ "INGESTION_BLOB_IO", "chunk_ref.content_sha256", "could not sync blob directory"
465
+ ) from error
466
+
467
+ def _replace_manifest_locked(
468
+ self, manifest: IngestionManifestV1, *, checkpoint_prefix: str
469
+ ) -> None:
470
+ raw = canonical_json_bytes(manifest.to_dict())
471
+ if len(raw) > MAX_MANIFEST_BYTES:
472
+ raise IngestionError(
473
+ "INGESTION_MANIFEST_LIMIT", "ingestion_manifest", "exceeds the manifest byte limit"
474
+ )
475
+ self._ensure_store_capacity_locked(len(raw))
476
+ destination = self._manifest_path(manifest.run_id)
477
+ temporary = self._runs / f".manifest.{os.getpid()}.{secrets.token_hex(16)}.tmp"
478
+ descriptor = -1
479
+ replaced = False
480
+ try:
481
+ descriptor = os.open(
482
+ temporary,
483
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
484
+ 0o600,
485
+ )
486
+ self._write_all(descriptor, raw)
487
+ os.fsync(descriptor)
488
+ os.close(descriptor)
489
+ descriptor = -1
490
+ self._checkpoint(f"{checkpoint_prefix}.before_manifest_replace")
491
+ os.replace(temporary, destination)
492
+ replaced = True
493
+ directory_descriptor = os.open(self._runs, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
494
+ try:
495
+ os.fsync(directory_descriptor)
496
+ finally:
497
+ os.close(directory_descriptor)
498
+ self._checkpoint(f"{checkpoint_prefix}.after_manifest_replace")
499
+ except OSError as error:
500
+ raise IngestionError(
501
+ "INGESTION_MANIFEST_IO",
502
+ "ingestion_manifest",
503
+ "could not atomically replace manifest",
504
+ ) from error
505
+ finally:
506
+ if descriptor >= 0:
507
+ os.close(descriptor)
508
+ if not replaced:
509
+ try:
510
+ temporary.unlink(missing_ok=True)
511
+ except OSError:
512
+ pass
513
+
514
+ def _verify_spool(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
515
+ if not isinstance(spooled, SpooledChunk):
516
+ raise IngestionError("INGESTION_SPOOL", "spooled", "must be a SpooledChunk")
517
+ if spooled.path.parent != self._spool or spooled.path.name.startswith(".") is False:
518
+ raise IngestionError(
519
+ "INGESTION_SPOOL_PATH",
520
+ "spooled.path",
521
+ "does not belong to this store's private spool",
522
+ )
523
+ if (
524
+ spooled.content_sha256 != chunk.content_sha256
525
+ or spooled.byte_length != chunk.byte_length
526
+ ):
527
+ raise IngestionError(
528
+ "INGESTION_SPOOL_DIGEST", "spooled", "does not match the proposed chunk reference"
529
+ )
530
+ try:
531
+ descriptor = os.open(spooled.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
532
+ except OSError as error:
533
+ raise IngestionError(
534
+ "INGESTION_SPOOL_IO", "spooled.path", "cannot be opened"
535
+ ) from error
536
+ try:
537
+ info = os.fstat(descriptor)
538
+ if (
539
+ not stat.S_ISREG(info.st_mode)
540
+ or info.st_dev != spooled.device
541
+ or info.st_ino != spooled.inode
542
+ or info.st_size != spooled.byte_length
543
+ ):
544
+ raise IngestionError(
545
+ "INGESTION_SPOOL_PATH", "spooled.path", "changed after it was spooled"
546
+ )
547
+ digest = self._hash_descriptor(descriptor)
548
+ finally:
549
+ os.close(descriptor)
550
+ if digest != spooled.content_sha256:
551
+ raise IngestionError(
552
+ "INGESTION_SPOOL_DIGEST", "spooled.path", "bytes differ from the spooled digest"
553
+ )
554
+
555
+ def _verify_chunk_file(self, path: Path, chunk: ChunkRef) -> None:
556
+ try:
557
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
558
+ except OSError as error:
559
+ raise IngestionCorrupt(
560
+ "INGESTION_BLOB_IO", "chunk_ref.content_sha256", "blob cannot be opened"
561
+ ) from error
562
+ try:
563
+ info = os.fstat(descriptor)
564
+ if not stat.S_ISREG(info.st_mode) or info.st_size != chunk.byte_length:
565
+ raise IngestionCorrupt(
566
+ "INGESTION_BLOB_SHAPE",
567
+ "chunk_ref.content_sha256",
568
+ "blob has an unexpected shape",
569
+ )
570
+ digest = self._hash_descriptor(descriptor)
571
+ finally:
572
+ os.close(descriptor)
573
+ if digest != chunk.content_sha256:
574
+ raise IngestionCorrupt(
575
+ "INGESTION_BLOB_DIGEST", "chunk_ref.content_sha256", "blob digest differs"
576
+ )
577
+
578
+ def _discard_spool(self, spooled: SpooledChunk) -> None:
579
+ try:
580
+ info = spooled.path.stat(follow_symlinks=False)
581
+ if (
582
+ stat.S_ISREG(info.st_mode)
583
+ and info.st_dev == spooled.device
584
+ and info.st_ino == spooled.inode
585
+ and spooled.path.parent == self._spool
586
+ ):
587
+ spooled.path.unlink()
588
+ except FileNotFoundError:
589
+ pass
590
+ except OSError:
591
+ # The durable state is already committed. A private temporary may be reclaimed by
592
+ # a later maintenance operation; surfacing this as failure would make retry unsafe.
593
+ pass
594
+
595
+ def _reserve_spool_bytes(self, descriptor: int, reserved_length: int) -> None:
596
+ """Durably reserve one spool's next logical length without holding it while it streams.
597
+
598
+ The temporary file's ``st_size`` is the durable reservation ledger. Reserving its next
599
+ exact length under the global lock makes every concurrent quota check see pending bytes;
600
+ writing the part happens after the lock is released. An interrupted writer therefore
601
+ leaves only a locked, stale-reapable temporary file, never an invisible quota promise.
602
+ """
603
+
604
+ if isinstance(reserved_length, bool) or not isinstance(reserved_length, int):
605
+ raise IngestionError(
606
+ "INGESTION_SPOOL_LIMIT", "spool.parts", "must have an integer byte length"
607
+ )
608
+ if reserved_length < 0:
609
+ raise IngestionError(
610
+ "INGESTION_SPOOL_LIMIT", "spool.parts", "must not have a negative byte length"
611
+ )
612
+ try:
613
+ before = os.fstat(descriptor)
614
+ except OSError as error:
615
+ raise IngestionError(
616
+ "INGESTION_SPOOL_IO", "spool.path", "cannot inspect temporary spool"
617
+ ) from error
618
+ if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
619
+ raise IngestionError(
620
+ "INGESTION_SPOOL_PATH", "spool.path", "must be a private regular file"
621
+ )
622
+ if reserved_length < before.st_size:
623
+ raise IngestionError(
624
+ "INGESTION_SPOOL_LIMIT", "spool.parts", "cannot shrink a quota reservation"
625
+ )
626
+ with self._locked():
627
+ spool_bytes, store_bytes = self._usage_locked()
628
+ additional_bytes = reserved_length - before.st_size
629
+ if spool_bytes + additional_bytes > self.maximum_spool_total_bytes:
630
+ raise IngestionError(
631
+ "INGESTION_SPOOL_QUOTA",
632
+ "spool.parts",
633
+ "would exceed the aggregate temporary-spool quota",
634
+ )
635
+ if store_bytes + additional_bytes > self.maximum_store_bytes:
636
+ raise IngestionError(
637
+ "INGESTION_STORE_QUOTA",
638
+ "spool.parts",
639
+ "would exceed the aggregate local-store quota",
640
+ )
641
+ try:
642
+ os.ftruncate(descriptor, reserved_length)
643
+ os.fsync(descriptor)
644
+ except OSError as error:
645
+ raise IngestionError(
646
+ "INGESTION_SPOOL_IO", "spool.path", "could not persist quota reservation"
647
+ ) from error
648
+
649
+ def _reap_stale_spools_locked(self, *, now: float) -> int:
650
+ """Delete only stale private temporary names not locked by an active writer."""
651
+
652
+ removed = 0
653
+ try:
654
+ entries = list(os.scandir(self._spool))
655
+ except OSError as error:
656
+ raise IngestionError(
657
+ "INGESTION_SPOOL_IO", "spool.directory", "cannot be listed"
658
+ ) from error
659
+ for entry in entries:
660
+ if not entry.name.startswith(".spool.") or not entry.name.endswith(".tmp"):
661
+ continue
662
+ descriptor = -1
663
+ try:
664
+ descriptor = os.open(entry.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
665
+ info = os.fstat(descriptor)
666
+ if (
667
+ not stat.S_ISREG(info.st_mode)
668
+ or now - info.st_mtime < self.stale_spool_age_seconds
669
+ ):
670
+ continue
671
+ try:
672
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
673
+ except BlockingIOError:
674
+ continue
675
+ # Re-stat after obtaining the exclusive advisory lock: a writer that was active
676
+ # when we inspected the directory cannot be unlinked behind its back.
677
+ info = os.fstat(descriptor)
678
+ if now - info.st_mtime < self.stale_spool_age_seconds:
679
+ continue
680
+ os.unlink(entry.path)
681
+ removed += 1
682
+ except FileNotFoundError:
683
+ continue
684
+ except OSError as error:
685
+ raise IngestionError(
686
+ "INGESTION_SPOOL_IO", "spool.directory", "stale spool cleanup failed"
687
+ ) from error
688
+ finally:
689
+ if descriptor >= 0:
690
+ os.close(descriptor)
691
+ if removed:
692
+ self._fsync_directory(self._spool, "ingestion_store.spool")
693
+ return removed
694
+
695
+ def _usage_locked(self) -> tuple[int, int]:
696
+ """Return logical ``(spool_bytes, total_store_bytes)`` with hard links counted once."""
697
+
698
+ spool_seen: set[tuple[int, int]] = set()
699
+ store_seen: set[tuple[int, int]] = set()
700
+ spool_bytes = 0
701
+ store_bytes = 0
702
+ for directory, is_spool in (
703
+ (self._runs, False),
704
+ (self._chunks, False),
705
+ (self._spool, True),
706
+ ):
707
+ try:
708
+ entries = list(os.scandir(directory))
709
+ except OSError as error:
710
+ raise IngestionError(
711
+ "INGESTION_STORE_IO", "ingestion_store", "cannot inspect quota usage"
712
+ ) from error
713
+ for entry in entries:
714
+ try:
715
+ info = entry.stat(follow_symlinks=False)
716
+ except FileNotFoundError:
717
+ continue
718
+ except OSError as error:
719
+ raise IngestionError(
720
+ "INGESTION_STORE_IO", "ingestion_store", "cannot inspect stored object"
721
+ ) from error
722
+ if not stat.S_ISREG(info.st_mode):
723
+ raise IngestionCorrupt(
724
+ "INGESTION_STORE_SHAPE",
725
+ "ingestion_store",
726
+ "contains a non-regular stored object",
727
+ )
728
+ identity = (info.st_dev, info.st_ino)
729
+ if is_spool and identity not in spool_seen:
730
+ spool_seen.add(identity)
731
+ spool_bytes += info.st_size
732
+ if identity not in store_seen:
733
+ store_seen.add(identity)
734
+ store_bytes += info.st_size
735
+ return spool_bytes, store_bytes
736
+
737
+ def _ensure_store_capacity_locked(self, additional_bytes: int) -> None:
738
+ """Reserve temporary manifest-write space before creating the replacement file."""
739
+
740
+ _spool_bytes, store_bytes = self._usage_locked()
741
+ if store_bytes + additional_bytes > self.maximum_store_bytes:
742
+ raise IngestionError(
743
+ "INGESTION_STORE_QUOTA",
744
+ "ingestion_manifest",
745
+ "would exceed the aggregate local-store quota",
746
+ )
747
+
748
+ def _manifest_path(self, run_id: str) -> Path:
749
+ return self._runs / f"{run_id}.json"
750
+
751
+ def _chunk_path(self, digest: str) -> Path:
752
+ return self._chunks / f"{digest}.bin"
753
+
754
+ def _checkpoint(self, name: str) -> None:
755
+ if self.fault_injector is not None:
756
+ self.fault_injector.checkpoint(name)
757
+
758
+ @staticmethod
759
+ def _fsync_directory(path: Path, field: str) -> None:
760
+ descriptor = -1
761
+ try:
762
+ descriptor = os.open(
763
+ path,
764
+ os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
765
+ )
766
+ os.fsync(descriptor)
767
+ except OSError as error:
768
+ raise IngestionError("INGESTION_STORE_IO", field, "directory sync failed") from error
769
+ finally:
770
+ if descriptor >= 0:
771
+ os.close(descriptor)
772
+
773
+ def _validate_limits(self) -> None:
774
+ for field, value in (
775
+ ("maximum_spool_bytes", self.maximum_spool_bytes),
776
+ ("maximum_spool_total_bytes", self.maximum_spool_total_bytes),
777
+ ("maximum_store_bytes", self.maximum_store_bytes),
778
+ ):
779
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
780
+ raise IngestionError(
781
+ "INGESTION_STORE_QUOTA", field, "must be a non-negative integer"
782
+ )
783
+ if self.maximum_spool_bytes > self.maximum_spool_total_bytes:
784
+ raise IngestionError(
785
+ "INGESTION_STORE_QUOTA",
786
+ "maximum_spool_bytes",
787
+ "must not exceed the aggregate temporary-spool quota",
788
+ )
789
+ if self.maximum_spool_total_bytes > self.maximum_store_bytes:
790
+ raise IngestionError(
791
+ "INGESTION_STORE_QUOTA",
792
+ "maximum_spool_total_bytes",
793
+ "must not exceed the aggregate local-store quota",
794
+ )
795
+ if (
796
+ isinstance(self.stale_spool_age_seconds, bool)
797
+ or not isinstance(self.stale_spool_age_seconds, int)
798
+ or self.stale_spool_age_seconds < 0
799
+ ):
800
+ raise IngestionError(
801
+ "INGESTION_SPOOL_AGE",
802
+ "stale_spool_age_seconds",
803
+ "must be a non-negative integer",
804
+ )
805
+
806
+ @staticmethod
807
+ def _require_private_directory(path: Path, field: str) -> None:
808
+ try:
809
+ info = path.stat(follow_symlinks=False)
810
+ except OSError as error:
811
+ raise IngestionError("INGESTION_STORE_IO", field, "cannot be inspected") from error
812
+ if (
813
+ not stat.S_ISDIR(info.st_mode)
814
+ or info.st_uid != os.geteuid()
815
+ or stat.S_IMODE(info.st_mode) != 0o700
816
+ ):
817
+ raise IngestionError(
818
+ "INGESTION_STORE_PATH",
819
+ field,
820
+ "must be an effective-user-owned mode-0700 non-symlink directory",
821
+ )
822
+
823
+ @staticmethod
824
+ def _validate_run_id(run_id: str) -> None:
825
+ # Constructing a temporary manifest would be misleading; the manifest constructor is
826
+ # intentionally the sole authority for this grammar, so reject path separators here too.
827
+ if (
828
+ not isinstance(run_id, str)
829
+ or not run_id
830
+ or len(run_id) > 128
831
+ or any(
832
+ character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
833
+ for character in run_id
834
+ )
835
+ or run_id[0] in ".-"
836
+ ):
837
+ raise IngestionError("INGESTION_RUN_ID", "run_id", "is invalid")
838
+
839
+ @staticmethod
840
+ def _validate_manifest_digest(value: str) -> None:
841
+ if (
842
+ not isinstance(value, str)
843
+ or len(value) != 64
844
+ or any(character not in "0123456789abcdef" for character in value)
845
+ ):
846
+ raise IngestionError(
847
+ "INGESTION_MANIFEST_DIGEST",
848
+ "expected_manifest_sha256",
849
+ "must be a lowercase SHA-256",
850
+ )
851
+
852
+ @staticmethod
853
+ def _write_all(descriptor: int, raw: bytes) -> None:
854
+ view = memoryview(raw)
855
+ while view:
856
+ written = os.write(descriptor, view)
857
+ if written <= 0:
858
+ raise OSError("short manifest write")
859
+ view = view[written:]
860
+
861
+ @staticmethod
862
+ def _hash_descriptor(descriptor: int) -> str:
863
+ digest = hashlib.sha256()
864
+ os.lseek(descriptor, 0, os.SEEK_SET)
865
+ while raw := os.read(descriptor, _READ_CHUNK_SIZE):
866
+ digest.update(raw)
867
+ return digest.hexdigest()
868
+
869
+ @staticmethod
870
+ def _read_descriptor(descriptor: int, *, maximum: int) -> bytes:
871
+ """Read a small control-plane document under a strict byte ceiling."""
872
+
873
+ parts: list[bytes] = []
874
+ total = 0
875
+ os.lseek(descriptor, 0, os.SEEK_SET)
876
+ while raw := os.read(descriptor, _READ_CHUNK_SIZE):
877
+ total += len(raw)
878
+ if total > maximum:
879
+ raise IngestionCorrupt(
880
+ "INGESTION_FILE_LIMIT",
881
+ "ingestion_store",
882
+ "file exceeds its permitted byte limit",
883
+ )
884
+ parts.append(raw)
885
+ return b"".join(parts)