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,1162 @@
1
+ """Generation-fenced Google Cloud Storage implementation of :mod:`ingestion.store`.
2
+
3
+ The ingestion contracts deliberately do not mention a filesystem. This implementation keeps
4
+ that promise: manifests use Cloud Storage's object generation as their CAS token and chunks are
5
+ create-only objects named by their SHA-256. The one local concern is a bounded private staging
6
+ directory, needed to learn a stream's digest before naming its content-addressed object. Bytes
7
+ are never accumulated into one Python object.
8
+
9
+ ``GoogleCloudStorageJSONTransport`` uses only the standard library and asks a workload identity
10
+ for a fresh access token for every request. Callers can instead supply a transport implementing
11
+ ``GCSObjectTransport``; this is how emulators and the deterministic fake in the tests exercise
12
+ lost acknowledgements and concurrent writers. Neither store form accepts, persists, or logs a
13
+ long-lived plaintext capability.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fcntl
19
+ import hashlib
20
+ import http.client
21
+ import os
22
+ import re
23
+ import stat
24
+ from collections.abc import Callable, Iterable, Iterator
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
28
+ from urllib.parse import quote, urlencode
29
+
30
+ from mostlyright.data_harness.canonical import (
31
+ CanonicalJSONError,
32
+ canonical_json_bytes,
33
+ parse_canonical_json,
34
+ parse_json,
35
+ )
36
+ from mostlyright.data_harness.ingestion.contracts import (
37
+ ChunkRef,
38
+ IngestionConflict,
39
+ IngestionCorrupt,
40
+ IngestionError,
41
+ IngestionManifestV1,
42
+ )
43
+ from mostlyright.data_harness.ingestion.spool import SpooledChunk
44
+ from mostlyright.data_harness.ingestion.store import (
45
+ DEFAULT_MAX_SPOOL_BYTES,
46
+ DEFAULT_MAX_SPOOL_TOTAL_BYTES,
47
+ DEFAULT_STALE_SPOOL_AGE_SECONDS,
48
+ MAX_MANIFEST_BYTES,
49
+ LocalIngestionStore,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from mostlyright.data_harness.hosted_ingestion_contract import (
54
+ IngestionDeliveryBindingV1,
55
+ IngestionManifestReferenceV1,
56
+ )
57
+
58
+ GCS_STORAGE_HOST = "storage.googleapis.com"
59
+ GCS_METADATA_HOST = "169.254.169.254"
60
+ GCS_METADATA_TOKEN_PATH = "/computeMetadata/v1/instance/service-accounts/default/token"
61
+ GCS_STORAGE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write"
62
+ GCS_READ_CHUNK_BYTES = 1024 * 1024
63
+ GCS_METADATA_BYTES = 64 * 1024
64
+ GCS_TOKEN_BYTES = 16 * 1024
65
+
66
+ _BUCKET = re.compile(r"[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]\Z")
67
+ _PREFIX_SEGMENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
68
+ _GENERATION = re.compile(r"[1-9][0-9]*\Z")
69
+
70
+
71
+ class GCSStorageError(RuntimeError):
72
+ """A transport failure whose text is safe to report but deliberately nonspecific."""
73
+
74
+
75
+ class GCSObjectNotFound(GCSStorageError):
76
+ """The named object does not exist."""
77
+
78
+
79
+ class GCSPreconditionFailed(GCSStorageError):
80
+ """A create/CAS/read-generation precondition did not hold."""
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class GCSObjectVersion:
85
+ """A GCS object identity suitable for an exact subsequent read or CAS."""
86
+
87
+ generation: str
88
+ byte_length: int
89
+
90
+ def __post_init__(self) -> None:
91
+ if _GENERATION.fullmatch(self.generation) is None:
92
+ raise ValueError("generation must be one positive decimal string")
93
+ if (
94
+ isinstance(self.byte_length, bool)
95
+ or not isinstance(self.byte_length, int)
96
+ or self.byte_length < 0
97
+ ):
98
+ raise ValueError("byte_length must be one non-negative integer")
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class GCSObjectRead:
103
+ """One generation-pinned body stream; consuming it never requires object-sized memory."""
104
+
105
+ version: GCSObjectVersion
106
+ parts: Iterable[bytes]
107
+ closer: Callable[[], None] = lambda: None
108
+
109
+ def close(self) -> None:
110
+ """Release an unread transport response; this operation is idempotent."""
111
+
112
+ self.closer()
113
+
114
+
115
+ @runtime_checkable
116
+ class GCSObjectTransport(Protocol):
117
+ """The small create/CAS/get subset the store needs from Cloud Storage.
118
+
119
+ ``if_generation_match`` is either ``"0"`` for create-only, a generation returned by
120
+ :meth:`get`, or ``None`` for an unconstrained metadata lookup. Implementations must consume
121
+ ``parts`` at most once and must not return success before the server has made the full object
122
+ durable. This intentionally small protocol makes the production HTTP transport replaceable
123
+ by a GCS emulator without making a bearer token part of the store API.
124
+ """
125
+
126
+ def put(
127
+ self,
128
+ *,
129
+ bucket: str,
130
+ name: str,
131
+ parts: Iterable[bytes],
132
+ byte_length: int,
133
+ if_generation_match: str,
134
+ ) -> GCSObjectVersion:
135
+ """Create or CAS-replace one object, or raise a typed transport error."""
136
+
137
+ def get(
138
+ self,
139
+ *,
140
+ bucket: str,
141
+ name: str,
142
+ if_generation_match: str | None = None,
143
+ ) -> GCSObjectRead:
144
+ """Return an optionally generation-pinned stream, or raise a typed transport error."""
145
+
146
+
147
+ def workload_identity_access_token() -> str:
148
+ """Read one short-lived storage token directly from the Cloud Run metadata service.
149
+
150
+ The result exists only in the call stack of a subsequent HTTP request. It is deliberately
151
+ neither cached nor accepted as a constructor argument, so a persisted ingestion checkpoint
152
+ cannot accidentally become a bearer-capability store.
153
+ """
154
+
155
+ query = urlencode({"scopes": GCS_STORAGE_SCOPE})
156
+ connection = http.client.HTTPConnection(GCS_METADATA_HOST, timeout=10.0)
157
+ try:
158
+ connection.request(
159
+ "GET",
160
+ GCS_METADATA_TOKEN_PATH + "?" + query,
161
+ headers={"Metadata-Flavor": "Google"},
162
+ )
163
+ response = connection.getresponse()
164
+ raw = _read_bounded_response(response, GCS_TOKEN_BYTES)
165
+ if response.status != 200:
166
+ raise GCSStorageError("workload identity did not return an access token")
167
+ except (OSError, http.client.HTTPException) as error:
168
+ raise GCSStorageError("workload identity could not be reached") from error
169
+ finally:
170
+ connection.close()
171
+ try:
172
+ document = parse_json(raw)
173
+ except CanonicalJSONError as error:
174
+ raise GCSStorageError("workload identity returned invalid token metadata") from error
175
+ token = document.get("access_token") if isinstance(document, dict) else None
176
+ if (
177
+ not isinstance(token, str)
178
+ or not token
179
+ or len(token.encode("utf-8")) > 8192
180
+ or any(ord(character) < 0x21 or ord(character) > 0x7E for character in token)
181
+ ):
182
+ raise GCSStorageError("workload identity returned no usable access token")
183
+ return token
184
+
185
+
186
+ @dataclass
187
+ class GoogleCloudStorageJSONTransport:
188
+ """Streaming JSON-API transport with no proxy endpoint or credential configuration surface."""
189
+
190
+ access_token_provider: Callable[[], str] = workload_identity_access_token
191
+ timeout_seconds: float = 60.0
192
+
193
+ def __post_init__(self) -> None:
194
+ if not callable(self.access_token_provider):
195
+ raise ValueError("access_token_provider must be callable")
196
+ if (
197
+ isinstance(self.timeout_seconds, bool)
198
+ or not isinstance(self.timeout_seconds, (int, float))
199
+ or self.timeout_seconds <= 0
200
+ or self.timeout_seconds > 600
201
+ ):
202
+ raise ValueError("timeout_seconds must be between zero and 600")
203
+
204
+ def put(
205
+ self,
206
+ *,
207
+ bucket: str,
208
+ name: str,
209
+ parts: Iterable[bytes],
210
+ byte_length: int,
211
+ if_generation_match: str,
212
+ ) -> GCSObjectVersion:
213
+ _require_object_request(bucket, name, byte_length, if_generation_match)
214
+ query = urlencode(
215
+ {
216
+ "uploadType": "media",
217
+ "name": name,
218
+ "ifGenerationMatch": if_generation_match,
219
+ }
220
+ )
221
+ path = f"/upload/storage/v1/b/{quote(bucket, safe='')}/o?{query}"
222
+ connection = self._connection()
223
+ try:
224
+ connection.putrequest("POST", path)
225
+ connection.putheader("Authorization", self._authorization())
226
+ connection.putheader("Content-Type", "application/octet-stream")
227
+ connection.putheader("Content-Length", str(byte_length))
228
+ connection.endheaders()
229
+ remaining = byte_length
230
+ for part in parts:
231
+ view = _part_view(part)
232
+ if view.nbytes > remaining:
233
+ raise GCSStorageError("object upload source exceeded its declared byte length")
234
+ connection.send(view)
235
+ remaining -= view.nbytes
236
+ if remaining:
237
+ raise GCSStorageError("object upload source ended before its declared byte length")
238
+ response = connection.getresponse()
239
+ raw = _read_bounded_response(response, GCS_METADATA_BYTES)
240
+ _raise_for_status(response.status, name, successful_statuses=(200, 201))
241
+ return _parse_object_version(raw, name, expected_size=byte_length)
242
+ except GCSStorageError:
243
+ raise
244
+ except (OSError, http.client.HTTPException) as error:
245
+ raise GCSStorageError("Cloud Storage object upload failed") from error
246
+ finally:
247
+ connection.close()
248
+
249
+ def get(
250
+ self,
251
+ *,
252
+ bucket: str,
253
+ name: str,
254
+ if_generation_match: str | None = None,
255
+ ) -> GCSObjectRead:
256
+ _require_get_request(bucket, name, if_generation_match)
257
+ metadata = self._metadata(bucket=bucket, name=name)
258
+ if if_generation_match is not None and metadata.generation != if_generation_match:
259
+ raise GCSPreconditionFailed("Cloud Storage object generation differs")
260
+ query = urlencode({"alt": "media", "ifGenerationMatch": metadata.generation})
261
+ path = f"/storage/v1/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}?{query}"
262
+ connection = self._connection()
263
+ try:
264
+ connection.putrequest("GET", path)
265
+ connection.putheader("Authorization", self._authorization())
266
+ connection.endheaders()
267
+ response = connection.getresponse()
268
+ if response.status != 200:
269
+ _read_bounded_response(response, GCS_METADATA_BYTES)
270
+ _raise_for_status(response.status, name)
271
+ raise AssertionError("unreachable")
272
+ content_length = response.getheader("Content-Length")
273
+ if content_length is not None and content_length != str(metadata.byte_length):
274
+ response.close()
275
+ raise GCSStorageError("Cloud Storage media response length differs from metadata")
276
+ body = _HTTPResponseParts(response, connection, metadata.byte_length)
277
+ return GCSObjectRead(version=metadata, parts=body, closer=body.close)
278
+ except GCSStorageError:
279
+ connection.close()
280
+ raise
281
+ except (OSError, http.client.HTTPException) as error:
282
+ connection.close()
283
+ raise GCSStorageError("Cloud Storage object read failed") from error
284
+
285
+ def _metadata(self, *, bucket: str, name: str) -> GCSObjectVersion:
286
+ path = f"/storage/v1/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}"
287
+ connection = self._connection()
288
+ try:
289
+ connection.request("GET", path, headers={"Authorization": self._authorization()})
290
+ response = connection.getresponse()
291
+ raw = _read_bounded_response(response, GCS_METADATA_BYTES)
292
+ _raise_for_status(response.status, name)
293
+ return _parse_object_version(raw, name)
294
+ except GCSStorageError:
295
+ raise
296
+ except (OSError, http.client.HTTPException) as error:
297
+ raise GCSStorageError("Cloud Storage object metadata read failed") from error
298
+ finally:
299
+ connection.close()
300
+
301
+ def _connection(self) -> http.client.HTTPSConnection:
302
+ return http.client.HTTPSConnection(GCS_STORAGE_HOST, timeout=float(self.timeout_seconds))
303
+
304
+ def _authorization(self) -> str:
305
+ token = self.access_token_provider()
306
+ if (
307
+ not isinstance(token, str)
308
+ or not token
309
+ or len(token.encode("utf-8")) > 8192
310
+ or any(ord(character) < 0x21 or ord(character) > 0x7E for character in token)
311
+ ):
312
+ raise GCSStorageError("access-token provider returned no usable token")
313
+ return "Bearer " + token
314
+
315
+
316
+ @dataclass(frozen=True)
317
+ class _VersionedManifest:
318
+ manifest: IngestionManifestV1
319
+ generation: str
320
+ raw_sha256: str
321
+ byte_length: int
322
+
323
+
324
+ @dataclass
325
+ class GCSIngestionStore:
326
+ """A bounded, generation-fenced Cloud Storage ingestion store.
327
+
328
+ ``staging_root`` must be a private local directory. It is used only for bounded temporary
329
+ chunk spools: all durable manifests and immutable content-addressed chunks reside in GCS.
330
+ The backend's only remote authority is the configured bucket/prefix plus the workload's
331
+ ambient identity, not a serializable upload URL or bearer secret.
332
+ """
333
+
334
+ bucket: str
335
+ prefix: str
336
+ staging_root: Path
337
+ manifest_prefix: str | None = None
338
+ chunk_prefix: str | None = None
339
+ delivery_binding: IngestionDeliveryBindingV1 | None = None
340
+ transport: GCSObjectTransport | None = None
341
+ maximum_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
342
+ maximum_spool_total_bytes: int = DEFAULT_MAX_SPOOL_TOTAL_BYTES
343
+ stale_spool_age_seconds: int = DEFAULT_STALE_SPOOL_AGE_SECONDS
344
+
345
+ def __post_init__(self) -> None:
346
+ # Import lazily: hosted delivery contracts themselves import the core ingestion contract,
347
+ # while this package's public initializer also exposes GCSIngestionStore.
348
+ from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryBindingV1
349
+
350
+ if not isinstance(self.bucket, str) or _BUCKET.fullmatch(self.bucket) is None:
351
+ raise IngestionError("INGESTION_GCS_BUCKET", "gcs.bucket", "is not a valid bucket name")
352
+ self.prefix = _normalize_prefix(self.prefix)
353
+ self.manifest_prefix = _normalize_prefix(self.manifest_prefix or self.prefix)
354
+ self.chunk_prefix = _normalize_prefix(self.chunk_prefix or self.prefix)
355
+ if self.delivery_binding is not None and not isinstance(
356
+ self.delivery_binding, IngestionDeliveryBindingV1
357
+ ):
358
+ raise IngestionError(
359
+ "INGESTION_DELIVERY_BINDING",
360
+ "gcs.delivery_binding",
361
+ "must be IngestionDeliveryBindingV1",
362
+ )
363
+ if (
364
+ self.delivery_binding is not None
365
+ and self.manifest_prefix.rsplit("/", 1)[-1] != self.delivery_binding.delivery_id
366
+ ):
367
+ raise IngestionError(
368
+ "INGESTION_DELIVERY_NAMESPACE",
369
+ "gcs.manifest_prefix",
370
+ "must end in delivery_binding.delivery_id",
371
+ )
372
+ self.staging_root = Path(self.staging_root)
373
+ if self.transport is None:
374
+ self.transport = GoogleCloudStorageJSONTransport()
375
+ if not isinstance(self.transport, GCSObjectTransport):
376
+ raise IngestionError(
377
+ "INGESTION_GCS_TRANSPORT", "gcs.transport", "does not implement GCSObjectTransport"
378
+ )
379
+ # Reuse the carefully reviewed private directory, lock, aggregate quota, and stale-spool
380
+ # discipline from the local store. Its runs/chunks directories remain empty here.
381
+ self._staging = LocalIngestionStore(
382
+ self.staging_root,
383
+ maximum_spool_bytes=self.maximum_spool_bytes,
384
+ maximum_spool_total_bytes=self.maximum_spool_total_bytes,
385
+ maximum_store_bytes=self.maximum_spool_total_bytes,
386
+ stale_spool_age_seconds=self.stale_spool_age_seconds,
387
+ )
388
+
389
+ def create(self, manifest: IngestionManifestV1) -> IngestionManifestV1:
390
+ if (
391
+ not isinstance(manifest, IngestionManifestV1)
392
+ or manifest.state != "open"
393
+ or manifest.chunks
394
+ or manifest.previous_manifest_sha256 is not None
395
+ ):
396
+ raise IngestionConflict(
397
+ "INGESTION_CREATE_STATE",
398
+ "ingestion_manifest",
399
+ "only an initial open manifest may be created",
400
+ )
401
+ raw = self._manifest_raw(manifest)
402
+ try:
403
+ self._put(self._manifest_name(manifest.run_id), [raw], len(raw), "0")
404
+ except GCSPreconditionFailed:
405
+ existing = self.load(manifest.run_id)
406
+ if existing is not None and existing.digest == manifest.digest:
407
+ return existing
408
+ raise IngestionConflict(
409
+ "INGESTION_RUN_EXISTS",
410
+ "ingestion_manifest.run_id",
411
+ "already has a different manifest",
412
+ ) from None
413
+ except GCSStorageError as error:
414
+ # A connection can fail after GCS committed a create. Resolve only the exact replay;
415
+ # otherwise surface an I/O failure rather than guessing the durable outcome.
416
+ existing = self.load(manifest.run_id)
417
+ if existing is not None and existing.digest == manifest.digest:
418
+ return existing
419
+ raise _storage_refusal("ingestion_manifest", error) from error
420
+ return manifest
421
+
422
+ def load(self, run_id: str) -> IngestionManifestV1 | None:
423
+ self._validate_run_id(run_id)
424
+ current = self._load_versioned(run_id)
425
+ return None if current is None else current.manifest
426
+
427
+ def current_manifest_reference(self, *, run_id: str) -> IngestionManifestReferenceV1:
428
+ """Return the exact raw document coordinate after validating its delivery envelope."""
429
+
430
+ self._validate_run_id(run_id)
431
+ current = self._require_versioned(run_id)
432
+ from mostlyright.data_harness.hosted_ingestion_contract import IngestionManifestReferenceV1
433
+
434
+ return IngestionManifestReferenceV1(
435
+ object_name=self._manifest_name(run_id),
436
+ generation=current.generation,
437
+ manifest_sha256=current.raw_sha256,
438
+ byte_length=current.byte_length,
439
+ )
440
+
441
+ def load_manifest_reference(
442
+ self,
443
+ *,
444
+ reference: IngestionManifestReferenceV1,
445
+ manifest_prefix: str,
446
+ run_id: str,
447
+ ) -> IngestionManifestV1:
448
+ """Load one preceding delivery manifest only when every reference coordinate holds.
449
+
450
+ ``manifest_prefix`` comes from Studio's frozen predecessor object, rather than being
451
+ inferred from a foreign object name. The read is generation-pinned and verifies the raw
452
+ canonical envelope digest before exposing its core manifest.
453
+ """
454
+
455
+ from mostlyright.data_harness.hosted_ingestion_contract import IngestionManifestReferenceV1
456
+
457
+ if not isinstance(reference, IngestionManifestReferenceV1):
458
+ raise IngestionError(
459
+ "INGESTION_MANIFEST_REF", "manifest_ref", "must be IngestionManifestReferenceV1"
460
+ )
461
+ self._validate_run_id(run_id)
462
+ reference.require_for_prefix(prefix=manifest_prefix, run_id=run_id)
463
+ try:
464
+ stored = self.transport.get(
465
+ bucket=self.bucket,
466
+ name=reference.object_name,
467
+ if_generation_match=reference.generation,
468
+ )
469
+ except GCSObjectNotFound as error:
470
+ raise IngestionCorrupt(
471
+ "INGESTION_MANIFEST_REF_MISSING",
472
+ "manifest_ref.object_name",
473
+ "referenced delivery manifest is absent",
474
+ ) from error
475
+ except GCSPreconditionFailed as error:
476
+ raise IngestionCorrupt(
477
+ "INGESTION_MANIFEST_REF_GENERATION",
478
+ "manifest_ref.generation",
479
+ "referenced delivery manifest changed",
480
+ ) from error
481
+ except GCSStorageError as error:
482
+ raise _storage_refusal("manifest_ref", error) from error
483
+ try:
484
+ if stored.version.byte_length != reference.byte_length:
485
+ raise IngestionCorrupt(
486
+ "INGESTION_MANIFEST_REF_LENGTH",
487
+ "manifest_ref.byte_length",
488
+ "does not match object metadata",
489
+ )
490
+ raw = _collect_bounded(stored.parts, maximum=MAX_MANIFEST_BYTES, field="manifest_ref")
491
+ finally:
492
+ stored.close()
493
+ if len(raw) != reference.byte_length:
494
+ raise IngestionCorrupt(
495
+ "INGESTION_MANIFEST_REF_LENGTH",
496
+ "manifest_ref.byte_length",
497
+ "does not match object bytes",
498
+ )
499
+ if hashlib.sha256(raw).hexdigest() != reference.manifest_sha256:
500
+ raise IngestionCorrupt(
501
+ "INGESTION_MANIFEST_REF_DIGEST",
502
+ "manifest_ref.manifest_sha256",
503
+ "does not match canonical manifest bytes",
504
+ )
505
+ try:
506
+ from mostlyright.data_harness.hosted_ingestion_contract import (
507
+ IngestionDeliveryManifestV1,
508
+ )
509
+
510
+ envelope = IngestionDeliveryManifestV1.from_dict(parse_canonical_json(raw))
511
+ except (CanonicalJSONError, IngestionError) as error:
512
+ raise IngestionCorrupt(
513
+ "INGESTION_MANIFEST_REF_CORRUPT",
514
+ "manifest_ref",
515
+ "does not name a valid delivery manifest",
516
+ ) from error
517
+ if envelope.manifest.run_id != run_id:
518
+ raise IngestionCorrupt(
519
+ "INGESTION_MANIFEST_REF_RUN",
520
+ "manifest_ref.object_name",
521
+ "does not bind the requested run",
522
+ )
523
+ if manifest_prefix.rsplit("/", 1)[-1] != envelope.delivery.delivery_id:
524
+ raise IngestionCorrupt(
525
+ "INGESTION_MANIFEST_REF_DELIVERY",
526
+ "manifest_ref.object_name",
527
+ "does not name the envelope's delivery namespace",
528
+ )
529
+ return envelope.manifest
530
+
531
+ def seed_from_reference(
532
+ self,
533
+ *,
534
+ reference: IngestionManifestReferenceV1,
535
+ manifest_prefix: str,
536
+ run_id: str,
537
+ ) -> IngestionManifestV1:
538
+ """Verify a predecessor then atomically copy it into this delivery's own namespace."""
539
+
540
+ if self.delivery_binding is None:
541
+ raise IngestionConflict(
542
+ "INGESTION_DELIVERY_BINDING",
543
+ "gcs.delivery_binding",
544
+ "is required to seed a predecessor into a delivery namespace",
545
+ )
546
+ manifest = self.load_manifest_reference(
547
+ reference=reference, manifest_prefix=manifest_prefix, run_id=run_id
548
+ )
549
+ self.delivery_binding.require_manifest(manifest)
550
+ raw = self._manifest_raw(manifest)
551
+ try:
552
+ self._put(self._manifest_name(run_id), [raw], len(raw), "0")
553
+ except (GCSPreconditionFailed, GCSStorageError) as error:
554
+ existing = self._load_versioned(run_id)
555
+ if existing is not None and existing.manifest.digest == manifest.digest:
556
+ return existing.manifest
557
+ if isinstance(error, GCSPreconditionFailed):
558
+ raise IngestionConflict(
559
+ "INGESTION_DELIVERY_SEED_FENCE",
560
+ "ingestion_manifest.run_id",
561
+ "already names a different delivery manifest",
562
+ ) from None
563
+ raise _storage_refusal("ingestion_manifest", error) from error
564
+ return manifest
565
+
566
+ def append_stream(
567
+ self,
568
+ *,
569
+ run_id: str,
570
+ expected_manifest_sha256: str,
571
+ chunk: ChunkRef,
572
+ parts: Iterable[bytes | bytearray | memoryview],
573
+ ) -> IngestionManifestV1:
574
+ self._validate_run_id(run_id)
575
+ self._validate_manifest_digest(expected_manifest_sha256)
576
+ if not isinstance(chunk, ChunkRef):
577
+ raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
578
+ spooled = self._staging.spool(parts)
579
+ try:
580
+ self._verify_staged_spool(spooled, chunk)
581
+ current = self._require_versioned(run_id)
582
+ replay = _matching_replay(current.manifest, chunk)
583
+ if replay is not None:
584
+ return replay
585
+ if current.manifest.is_finalized:
586
+ raise IngestionConflict(
587
+ "INGESTION_FINALIZED",
588
+ "ingestion_manifest.state",
589
+ "cannot append to a finalized run",
590
+ )
591
+ if current.manifest.digest != expected_manifest_sha256:
592
+ raise IngestionConflict(
593
+ "INGESTION_MANIFEST_FENCE",
594
+ "expected_manifest_sha256",
595
+ "does not match the current manifest",
596
+ )
597
+ successor = current.manifest.append(chunk)
598
+ self._ensure_chunk(chunk, spooled)
599
+ return self._put_successor(
600
+ run_id=run_id,
601
+ predecessor=current,
602
+ successor=successor,
603
+ expected_manifest_sha256=expected_manifest_sha256,
604
+ chunk=chunk,
605
+ )
606
+ finally:
607
+ self._discard_staged_spool(spooled)
608
+
609
+ def finalize(self, *, run_id: str, expected_manifest_sha256: str) -> IngestionManifestV1:
610
+ self._validate_run_id(run_id)
611
+ self._validate_manifest_digest(expected_manifest_sha256)
612
+ current = self._require_versioned(run_id)
613
+ if current.manifest.is_finalized:
614
+ if (
615
+ current.manifest.digest == expected_manifest_sha256
616
+ or current.manifest.is_direct_successor_of(expected_manifest_sha256)
617
+ ):
618
+ return current.manifest
619
+ raise IngestionConflict(
620
+ "INGESTION_MANIFEST_FENCE",
621
+ "expected_manifest_sha256",
622
+ "does not name the finalization predecessor",
623
+ )
624
+ if current.manifest.digest != expected_manifest_sha256:
625
+ raise IngestionConflict(
626
+ "INGESTION_MANIFEST_FENCE",
627
+ "expected_manifest_sha256",
628
+ "does not match the current manifest",
629
+ )
630
+ for item in current.manifest.chunks:
631
+ self._verify_remote_chunk(item)
632
+ successor = current.manifest.finalize()
633
+ raw = self._manifest_raw(successor)
634
+ try:
635
+ self._put(self._manifest_name(run_id), [raw], len(raw), current.generation)
636
+ return successor
637
+ except (GCSPreconditionFailed, GCSStorageError) as error:
638
+ resolved = self._load_versioned(run_id)
639
+ if (
640
+ resolved is not None
641
+ and resolved.manifest.is_finalized
642
+ and resolved.manifest.is_direct_successor_of(expected_manifest_sha256)
643
+ ):
644
+ return resolved.manifest
645
+ if isinstance(error, GCSPreconditionFailed):
646
+ raise IngestionConflict(
647
+ "INGESTION_MANIFEST_FENCE",
648
+ "expected_manifest_sha256",
649
+ "does not match the current manifest",
650
+ ) from None
651
+ raise _storage_refusal("ingestion_manifest", error) from error
652
+
653
+ def iter_chunk_parts(self, chunk: ChunkRef) -> Iterator[bytes]:
654
+ if not isinstance(chunk, ChunkRef):
655
+ raise IngestionError("INGESTION_CHUNK", "chunk", "must be a ChunkRef")
656
+ # Verify a complete generation before returning a readable generation. This matches the
657
+ # local backend's guarantee that consumers never act on bytes later found corrupt, while
658
+ # retaining a bounded two-pass remote read rather than materialising the object.
659
+ verified = self._verify_remote_chunk(chunk)
660
+ try:
661
+ readable = self.transport.get(
662
+ bucket=self.bucket,
663
+ name=self._chunk_name(chunk.content_sha256),
664
+ if_generation_match=verified.generation,
665
+ )
666
+ except GCSObjectNotFound as error:
667
+ raise IngestionCorrupt(
668
+ "INGESTION_BLOB_MISSING",
669
+ "chunk_ref.content_sha256",
670
+ "referenced Cloud Storage object is absent",
671
+ ) from error
672
+ except GCSPreconditionFailed as error:
673
+ raise IngestionCorrupt(
674
+ "INGESTION_BLOB_GENERATION",
675
+ "chunk_ref.content_sha256",
676
+ "referenced Cloud Storage object changed during verified read",
677
+ ) from error
678
+ except GCSStorageError as error:
679
+ raise _storage_refusal("chunk_ref.content_sha256", error) from error
680
+ try:
681
+ if readable.version != verified or readable.version.byte_length != chunk.byte_length:
682
+ raise IngestionCorrupt(
683
+ "INGESTION_BLOB_GENERATION",
684
+ "chunk_ref.content_sha256",
685
+ "referenced Cloud Storage generation changed during verified read",
686
+ )
687
+ yield from _bounded_parts(
688
+ readable.parts, expected_length=chunk.byte_length, field="chunk_ref.content_sha256"
689
+ )
690
+ finally:
691
+ readable.close()
692
+
693
+ def _put_successor(
694
+ self,
695
+ *,
696
+ run_id: str,
697
+ predecessor: _VersionedManifest,
698
+ successor: IngestionManifestV1,
699
+ expected_manifest_sha256: str,
700
+ chunk: ChunkRef,
701
+ ) -> IngestionManifestV1:
702
+ raw = self._manifest_raw(successor)
703
+ try:
704
+ self._put(self._manifest_name(run_id), [raw], len(raw), predecessor.generation)
705
+ return successor
706
+ except (GCSPreconditionFailed, GCSStorageError) as error:
707
+ # Both a real CAS loss and a lost response are resolved by reading the current state.
708
+ # Returning a matching ordinal is safe even if another writer advanced farther: it is
709
+ # exactly the acknowledgement-safe replay rule the local store implements.
710
+ resolved = self._load_versioned(run_id)
711
+ if resolved is not None:
712
+ replay = _matching_replay(resolved.manifest, chunk)
713
+ if replay is not None:
714
+ return replay
715
+ if isinstance(error, GCSPreconditionFailed):
716
+ raise IngestionConflict(
717
+ "INGESTION_MANIFEST_FENCE",
718
+ "expected_manifest_sha256",
719
+ "does not match the current manifest",
720
+ ) from None
721
+ raise _storage_refusal("ingestion_manifest", error) from error
722
+
723
+ def _ensure_chunk(self, chunk: ChunkRef, spooled: SpooledChunk) -> None:
724
+ name = self._chunk_name(chunk.content_sha256)
725
+ try:
726
+ parts = self._staged_spool_parts(spooled, chunk)
727
+ try:
728
+ self._put(name, parts, chunk.byte_length, "0")
729
+ finally:
730
+ parts.close()
731
+ except GCSPreconditionFailed:
732
+ pass
733
+ except GCSStorageError as error:
734
+ # An upload acknowledgement can disappear after GCS made the create durable. It is
735
+ # safe to continue only when the immutable object now verifies byte-for-byte.
736
+ try:
737
+ self._verify_remote_chunk(chunk)
738
+ except IngestionCorrupt:
739
+ raise _storage_refusal("chunk_ref.content_sha256", error) from error
740
+ return
741
+ self._verify_remote_chunk(chunk)
742
+
743
+ def _load_versioned(self, run_id: str) -> _VersionedManifest | None:
744
+ try:
745
+ stored = self.transport.get(bucket=self.bucket, name=self._manifest_name(run_id))
746
+ except GCSObjectNotFound:
747
+ return None
748
+ except GCSStorageError as error:
749
+ raise _storage_refusal("ingestion_manifest", error) from error
750
+ try:
751
+ if stored.version.byte_length > MAX_MANIFEST_BYTES:
752
+ raise IngestionCorrupt(
753
+ "INGESTION_FILE_LIMIT",
754
+ "ingestion_manifest",
755
+ "Cloud Storage manifest exceeds its limit",
756
+ )
757
+ raw = _collect_bounded(
758
+ stored.parts, maximum=MAX_MANIFEST_BYTES, field="ingestion_manifest"
759
+ )
760
+ finally:
761
+ stored.close()
762
+ if len(raw) != stored.version.byte_length:
763
+ raise IngestionCorrupt(
764
+ "INGESTION_MANIFEST_SHAPE",
765
+ "ingestion_manifest",
766
+ "Cloud Storage metadata length differs",
767
+ )
768
+ try:
769
+ manifest = self._parse_manifest_raw(raw)
770
+ except IngestionCorrupt:
771
+ raise
772
+ except (CanonicalJSONError, IngestionError) as error:
773
+ raise IngestionCorrupt(
774
+ "INGESTION_MANIFEST_CORRUPT",
775
+ "ingestion_manifest",
776
+ "is not a valid canonical manifest",
777
+ ) from error
778
+ if manifest.run_id != run_id:
779
+ raise IngestionCorrupt(
780
+ "INGESTION_MANIFEST_RUN",
781
+ "ingestion_manifest.run_id",
782
+ "does not match its object coordinate",
783
+ )
784
+ return _VersionedManifest(
785
+ manifest=manifest,
786
+ generation=stored.version.generation,
787
+ raw_sha256=hashlib.sha256(raw).hexdigest(),
788
+ byte_length=stored.version.byte_length,
789
+ )
790
+
791
+ def _require_versioned(self, run_id: str) -> _VersionedManifest:
792
+ current = self._load_versioned(run_id)
793
+ if current is None:
794
+ raise IngestionConflict(
795
+ "INGESTION_RUN_MISSING", "ingestion_manifest.run_id", "does not exist"
796
+ )
797
+ return current
798
+
799
+ def _manifest_raw(self, manifest: IngestionManifestV1) -> bytes:
800
+ if self.delivery_binding is None:
801
+ return canonical_json_bytes(manifest.to_dict())
802
+ self.delivery_binding.require_manifest(manifest)
803
+ from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryManifestV1
804
+
805
+ return canonical_json_bytes(
806
+ IngestionDeliveryManifestV1(delivery=self.delivery_binding, manifest=manifest).to_dict()
807
+ )
808
+
809
+ def _parse_manifest_raw(self, raw: bytes) -> IngestionManifestV1:
810
+ document = parse_canonical_json(raw)
811
+ if self.delivery_binding is None:
812
+ return IngestionManifestV1.from_dict(document)
813
+ from mostlyright.data_harness.hosted_ingestion_contract import IngestionDeliveryManifestV1
814
+
815
+ envelope = IngestionDeliveryManifestV1.from_dict(document)
816
+ if envelope.delivery != self.delivery_binding:
817
+ raise IngestionCorrupt(
818
+ "INGESTION_DELIVERY_FENCE",
819
+ "delivery_manifest.delivery",
820
+ "does not match this delivery's fenced binding",
821
+ )
822
+ return envelope.manifest
823
+
824
+ def _verify_staged_spool(self, spooled: SpooledChunk, chunk: ChunkRef) -> None:
825
+ parts = self._staged_spool_parts(spooled, chunk)
826
+ try:
827
+ for _part in parts:
828
+ pass
829
+ finally:
830
+ parts.close()
831
+
832
+ def _staged_spool_parts(self, spooled: SpooledChunk, chunk: ChunkRef) -> _StagedSpoolParts:
833
+ return _StagedSpoolParts(spooled=spooled, chunk=chunk, spool_directory=self._staging._spool)
834
+
835
+ def _discard_staged_spool(self, spooled: SpooledChunk) -> None:
836
+ try:
837
+ info = spooled.path.stat(follow_symlinks=False)
838
+ if (
839
+ stat.S_ISREG(info.st_mode)
840
+ and info.st_dev == spooled.device
841
+ and info.st_ino == spooled.inode
842
+ and spooled.path.parent == self._staging._spool
843
+ ):
844
+ spooled.path.unlink()
845
+ except OSError:
846
+ # A later bounded stale-spool sweep owns cleanup. The durable remote transition is
847
+ # already known, so surfacing a local cleanup failure would make its retry ambiguous.
848
+ pass
849
+
850
+ def _verify_remote_chunk(self, chunk: ChunkRef) -> GCSObjectVersion:
851
+ try:
852
+ stored = self.transport.get(
853
+ bucket=self.bucket, name=self._chunk_name(chunk.content_sha256)
854
+ )
855
+ except GCSObjectNotFound as error:
856
+ raise IngestionCorrupt(
857
+ "INGESTION_BLOB_MISSING",
858
+ "chunk_ref.content_sha256",
859
+ "referenced Cloud Storage object is absent",
860
+ ) from error
861
+ except GCSStorageError as error:
862
+ raise _storage_refusal("chunk_ref.content_sha256", error) from error
863
+ digest = hashlib.sha256()
864
+ total = 0
865
+ try:
866
+ if stored.version.byte_length != chunk.byte_length:
867
+ raise IngestionCorrupt(
868
+ "INGESTION_BLOB_SHAPE",
869
+ "chunk_ref.content_sha256",
870
+ "object has an unexpected length",
871
+ )
872
+ for part in _bounded_parts(
873
+ stored.parts, expected_length=chunk.byte_length, field="chunk_ref.content_sha256"
874
+ ):
875
+ digest.update(part)
876
+ total += len(part)
877
+ finally:
878
+ stored.close()
879
+ if total != chunk.byte_length or digest.hexdigest() != chunk.content_sha256:
880
+ raise IngestionCorrupt(
881
+ "INGESTION_BLOB_DIGEST",
882
+ "chunk_ref.content_sha256",
883
+ "object bytes differ from digest",
884
+ )
885
+ return stored.version
886
+
887
+ def _put(
888
+ self, name: str, parts: Iterable[bytes], byte_length: int, generation: str
889
+ ) -> GCSObjectVersion:
890
+ try:
891
+ result = self.transport.put(
892
+ bucket=self.bucket,
893
+ name=name,
894
+ parts=parts,
895
+ byte_length=byte_length,
896
+ if_generation_match=generation,
897
+ )
898
+ except (GCSStorageError, IngestionError):
899
+ raise
900
+ except Exception as error: # pragma: no cover - protects adapter boundary only
901
+ raise GCSStorageError("Cloud Storage adapter failed") from error
902
+ if not isinstance(result, GCSObjectVersion) or result.byte_length != byte_length:
903
+ raise GCSStorageError("Cloud Storage returned an invalid object version")
904
+ return result
905
+
906
+ def _manifest_name(self, run_id: str) -> str:
907
+ return f"{self.manifest_prefix}/runs/{run_id}.json"
908
+
909
+ def _chunk_name(self, digest: str) -> str:
910
+ return f"{self.chunk_prefix}/chunks/{digest}.bin"
911
+
912
+ _validate_run_id = staticmethod(LocalIngestionStore._validate_run_id)
913
+ _validate_manifest_digest = staticmethod(LocalIngestionStore._validate_manifest_digest)
914
+
915
+
916
+ class _StagedSpoolParts:
917
+ """An opened, checked staging spool held against the local stale reaper while uploading."""
918
+
919
+ def __init__(self, *, spooled: SpooledChunk, chunk: ChunkRef, spool_directory: Path) -> None:
920
+ if (
921
+ not isinstance(spooled, SpooledChunk)
922
+ or spooled.path.parent != spool_directory
923
+ or not spooled.path.name.startswith(".spool.")
924
+ or spooled.content_sha256 != chunk.content_sha256
925
+ or spooled.byte_length != chunk.byte_length
926
+ ):
927
+ raise IngestionError(
928
+ "INGESTION_SPOOL_PATH", "spooled", "does not name this store's staging spool"
929
+ )
930
+ self._descriptor = -1
931
+ try:
932
+ self._descriptor = os.open(spooled.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
933
+ info = os.fstat(self._descriptor)
934
+ if (
935
+ not stat.S_ISREG(info.st_mode)
936
+ or info.st_nlink != 1
937
+ or info.st_dev != spooled.device
938
+ or info.st_ino != spooled.inode
939
+ or info.st_size != spooled.byte_length
940
+ ):
941
+ raise IngestionError(
942
+ "INGESTION_SPOOL_PATH", "spooled.path", "changed after staging"
943
+ )
944
+ # This prevents a concurrent LocalIngestionStore reaper from deleting the completed
945
+ # temporary during a slow GCS upload. The global quota lock is intentionally not held
946
+ # across network I/O; the spool's own shared advisory lock is the narrow ownership bit.
947
+ fcntl.flock(self._descriptor, fcntl.LOCK_SH)
948
+ digest = hashlib.sha256()
949
+ total = 0
950
+ while raw := os.read(self._descriptor, GCS_READ_CHUNK_BYTES):
951
+ digest.update(raw)
952
+ total += len(raw)
953
+ if total != chunk.byte_length or digest.hexdigest() != chunk.content_sha256:
954
+ raise IngestionError(
955
+ "INGESTION_SPOOL_DIGEST", "spooled.path", "bytes differ from chunk"
956
+ )
957
+ os.lseek(self._descriptor, 0, os.SEEK_SET)
958
+ except OSError as error:
959
+ self.close()
960
+ raise IngestionError(
961
+ "INGESTION_SPOOL_IO", "spooled.path", "cannot stream staging spool"
962
+ ) from error
963
+ except BaseException:
964
+ self.close()
965
+ raise
966
+
967
+ def __iter__(self) -> Iterator[bytes]:
968
+ try:
969
+ while raw := os.read(self._descriptor, GCS_READ_CHUNK_BYTES):
970
+ yield raw
971
+ except OSError as error:
972
+ raise IngestionError(
973
+ "INGESTION_SPOOL_IO", "spooled.path", "cannot stream staging spool"
974
+ ) from error
975
+ finally:
976
+ self.close()
977
+
978
+ def close(self) -> None:
979
+ if self._descriptor >= 0:
980
+ descriptor = self._descriptor
981
+ self._descriptor = -1
982
+ os.close(descriptor)
983
+
984
+
985
+ def _matching_replay(manifest: IngestionManifestV1, chunk: ChunkRef) -> IngestionManifestV1 | None:
986
+ if chunk.ordinal >= len(manifest.chunks):
987
+ return None
988
+ if manifest.chunks[chunk.ordinal] != chunk:
989
+ raise IngestionConflict(
990
+ "INGESTION_REPLAY_MISMATCH",
991
+ "chunk_ref.ordinal",
992
+ "already names different committed chunk bytes or metadata",
993
+ )
994
+ return manifest
995
+
996
+
997
+ def _normalize_prefix(value: object) -> str:
998
+ if not isinstance(value, str) or not value or len(value) > 512:
999
+ raise IngestionError(
1000
+ "INGESTION_GCS_PREFIX", "gcs.prefix", "must be one bounded object prefix"
1001
+ )
1002
+ segments = value.split("/")
1003
+ if any(_PREFIX_SEGMENT.fullmatch(segment) is None for segment in segments):
1004
+ raise IngestionError(
1005
+ "INGESTION_GCS_PREFIX", "gcs.prefix", "must contain only safe nonempty path segments"
1006
+ )
1007
+ return value
1008
+
1009
+
1010
+ def _require_object_request(bucket: str, name: str, byte_length: int, generation: str) -> None:
1011
+ _require_get_request(bucket, name, generation if generation != "0" else None)
1012
+ if generation != "0" and _GENERATION.fullmatch(generation) is None:
1013
+ raise ValueError("if_generation_match must be zero or a positive generation")
1014
+ if isinstance(byte_length, bool) or not isinstance(byte_length, int) or byte_length < 0:
1015
+ raise ValueError("byte_length must be non-negative")
1016
+
1017
+
1018
+ def _require_get_request(bucket: str, name: str, generation: str | None) -> None:
1019
+ if not isinstance(bucket, str) or _BUCKET.fullmatch(bucket) is None:
1020
+ raise ValueError("bucket is invalid")
1021
+ if (
1022
+ not isinstance(name, str)
1023
+ or not name
1024
+ or name.startswith("/")
1025
+ or "//" in name
1026
+ or ".." in name
1027
+ ):
1028
+ raise ValueError("object name is invalid")
1029
+ if generation is not None and _GENERATION.fullmatch(generation) is None:
1030
+ raise ValueError("if_generation_match must be a positive generation")
1031
+
1032
+
1033
+ def _part_view(value: object) -> memoryview:
1034
+ if not isinstance(value, (bytes, bytearray, memoryview)):
1035
+ raise GCSStorageError("object stream yielded a non-bytes part")
1036
+ view = memoryview(value)
1037
+ if not view.c_contiguous:
1038
+ raise GCSStorageError("object stream yielded a non-contiguous part")
1039
+ if view.format != "B" or view.ndim != 1:
1040
+ view = view.cast("B")
1041
+ return view
1042
+
1043
+
1044
+ def _bounded_parts(parts: Iterable[bytes], *, expected_length: int, field: str) -> Iterator[bytes]:
1045
+ total = 0
1046
+ for value in parts:
1047
+ view = _part_view(value)
1048
+ total += view.nbytes
1049
+ if total > expected_length:
1050
+ raise IngestionCorrupt(
1051
+ "INGESTION_BLOB_SHAPE", field, "object exceeds its metadata length"
1052
+ )
1053
+ yield bytes(view)
1054
+ if total != expected_length:
1055
+ raise IngestionCorrupt(
1056
+ "INGESTION_BLOB_SHAPE", field, "object is shorter than its metadata length"
1057
+ )
1058
+
1059
+
1060
+ def _collect_bounded(parts: Iterable[bytes], *, maximum: int, field: str) -> bytes:
1061
+ result: list[bytes] = []
1062
+ total = 0
1063
+ for value in parts:
1064
+ view = _part_view(value)
1065
+ total += view.nbytes
1066
+ if total > maximum:
1067
+ raise IngestionCorrupt(
1068
+ "INGESTION_FILE_LIMIT", field, "object exceeds its permitted byte limit"
1069
+ )
1070
+ result.append(bytes(view))
1071
+ return b"".join(result)
1072
+
1073
+
1074
+ def _storage_refusal(path: str, error: GCSStorageError) -> IngestionError:
1075
+ return IngestionError("INGESTION_GCS_IO", path, "Cloud Storage request failed")
1076
+
1077
+
1078
+ def _read_bounded_response(response: http.client.HTTPResponse, maximum: int) -> bytes:
1079
+ chunks: list[bytes] = []
1080
+ total = 0
1081
+ while raw := response.read(min(GCS_READ_CHUNK_BYTES, maximum + 1)):
1082
+ total += len(raw)
1083
+ if total > maximum:
1084
+ raise GCSStorageError("Cloud Storage metadata response exceeds its limit")
1085
+ chunks.append(raw)
1086
+ return b"".join(chunks)
1087
+
1088
+
1089
+ class _HTTPResponseParts:
1090
+ """One response body with explicit cleanup even when callers reject it before iteration."""
1091
+
1092
+ def __init__(
1093
+ self,
1094
+ response: http.client.HTTPResponse,
1095
+ connection: http.client.HTTPSConnection,
1096
+ expected_length: int,
1097
+ ) -> None:
1098
+ self._response = response
1099
+ self._connection = connection
1100
+ self._expected_length = expected_length
1101
+ self._closed = False
1102
+
1103
+ def __iter__(self) -> Iterator[bytes]:
1104
+ remaining = self._expected_length
1105
+ try:
1106
+ while remaining:
1107
+ raw = self._response.read(min(GCS_READ_CHUNK_BYTES, remaining))
1108
+ if not raw:
1109
+ raise GCSStorageError("Cloud Storage media response ended early")
1110
+ remaining -= len(raw)
1111
+ yield raw
1112
+ if self._response.read(1):
1113
+ raise GCSStorageError("Cloud Storage media response exceeded metadata length")
1114
+ except (OSError, http.client.HTTPException) as error:
1115
+ raise GCSStorageError("Cloud Storage media response failed") from error
1116
+ finally:
1117
+ self.close()
1118
+
1119
+ def close(self) -> None:
1120
+ if not self._closed:
1121
+ self._closed = True
1122
+ self._response.close()
1123
+ self._connection.close()
1124
+
1125
+
1126
+ def _raise_for_status(
1127
+ status: int, name: str, *, successful_statuses: tuple[int, ...] = (200,)
1128
+ ) -> None:
1129
+ if status in successful_statuses:
1130
+ return
1131
+ if status == 404:
1132
+ raise GCSObjectNotFound("Cloud Storage object does not exist")
1133
+ if status == 412:
1134
+ raise GCSPreconditionFailed("Cloud Storage object generation precondition failed")
1135
+ raise GCSStorageError("Cloud Storage returned an unsuccessful status")
1136
+
1137
+
1138
+ def _parse_object_version(
1139
+ raw: bytes, name: str, *, expected_size: int | None = None
1140
+ ) -> GCSObjectVersion:
1141
+ try:
1142
+ document = parse_json(raw)
1143
+ except CanonicalJSONError as error:
1144
+ raise GCSStorageError("Cloud Storage returned invalid object metadata") from error
1145
+ if not isinstance(document, dict):
1146
+ raise GCSStorageError("Cloud Storage returned invalid object metadata")
1147
+ returned_name = document.get("name")
1148
+ generation = document.get("generation")
1149
+ size = document.get("size")
1150
+ if returned_name != name or not isinstance(generation, str) or not isinstance(size, str):
1151
+ raise GCSStorageError("Cloud Storage object metadata does not bind the requested object")
1152
+ try:
1153
+ parsed_size = int(size)
1154
+ except ValueError as error:
1155
+ raise GCSStorageError("Cloud Storage object metadata has an invalid size") from error
1156
+ try:
1157
+ result = GCSObjectVersion(generation=generation, byte_length=parsed_size)
1158
+ except ValueError as error:
1159
+ raise GCSStorageError("Cloud Storage object metadata is invalid") from error
1160
+ if expected_size is not None and result.byte_length != expected_size:
1161
+ raise GCSStorageError("Cloud Storage upload length differs from its response")
1162
+ return result