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,530 @@
1
+ """Fail-closed public HTTPS target validation and DNS pinning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ import socket
8
+ import unicodedata
9
+ from dataclasses import dataclass
10
+ from typing import Protocol
11
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
12
+
13
+ import idna
14
+
15
+ from mostlyright.data_harness.canonical import canonical_sha256
16
+
17
+ MAX_URL_LENGTH = 2_048
18
+ MAX_DNS_ANSWERS = 32
19
+ DEFAULT_ALLOWED_PORTS = frozenset({443})
20
+
21
+ _METADATA_HOSTS = frozenset(
22
+ {
23
+ "metadata",
24
+ "metadata.google.internal",
25
+ "metadata.goog",
26
+ "instance-data",
27
+ "169.254.169.254",
28
+ "100.100.100.200",
29
+ }
30
+ )
31
+ _FORBIDDEN_NETWORKS = tuple(
32
+ ipaddress.ip_network(value)
33
+ for value in (
34
+ "0.0.0.0/8",
35
+ "10.0.0.0/8",
36
+ "100.64.0.0/10",
37
+ "127.0.0.0/8",
38
+ "169.254.0.0/16",
39
+ "172.16.0.0/12",
40
+ "192.0.0.0/24",
41
+ "192.0.2.0/24",
42
+ "192.168.0.0/16",
43
+ "198.18.0.0/15",
44
+ "198.51.100.0/24",
45
+ "203.0.113.0/24",
46
+ "224.0.0.0/4",
47
+ "240.0.0.0/4",
48
+ "::/128",
49
+ "::1/128",
50
+ "::ffff:0:0/96",
51
+ "64:ff9b::/96",
52
+ "100::/64",
53
+ "2001:db8::/32",
54
+ "fc00::/7",
55
+ "fe80::/10",
56
+ "ff00::/8",
57
+ )
58
+ )
59
+ # The URL Standard's *forbidden domain code points*: the forbidden host code points, plus the C0
60
+ # controls, plus U+0025. A host parser rejects a domain holding one before it reads anything else,
61
+ # so no URL parser keeps any of them inside a machine name.
62
+ #
63
+ # Spelled out rather than probed, for the same reason `_SPECIAL_SCHEMES` is: the list is normative
64
+ # and closed, and `urlsplit` -- the only parser available to probe locally -- is a generic RFC 3986
65
+ # splitter that keeps most of them. Measured against node v24.13.0 over every code point the
66
+ # catalog scanner's reader-terminator class matches, this agrees with the parser on all 33.
67
+ _FORBIDDEN_DOMAIN_CODE_POINTS = frozenset(
68
+ "".join(chr(code) for code in range(0x20)) + " #%/:<>?@[\\]^|"
69
+ )
70
+ _DECIMAL_OR_OBFUSCATED_IP = re.compile(
71
+ r"^(?:0[xX][0-9A-Fa-f]+|0[0-7]+|[0-9]+)(?:\.(?:0[xX][0-9A-Fa-f]+|0[0-7]+|[0-9]+)){0,3}$"
72
+ )
73
+
74
+
75
+ class AcquisitionSecurityError(ValueError):
76
+ """Stable acquisition policy failure."""
77
+
78
+ def __init__(self, code: str, detail: str) -> None:
79
+ self.code = code
80
+ self.detail = detail
81
+ super().__init__(f"{detail} [{code}]")
82
+
83
+
84
+ class Resolver(Protocol):
85
+ def resolve(self, hostname: str, port: int) -> tuple[str, ...]:
86
+ """Return every address candidate that may be used for this connection."""
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class SystemResolver:
91
+ """System DNS resolver with a bounded, deduplicated answer set."""
92
+
93
+ def resolve(self, hostname: str, port: int) -> tuple[str, ...]:
94
+ try:
95
+ results = socket.getaddrinfo(
96
+ hostname,
97
+ port,
98
+ family=socket.AF_UNSPEC,
99
+ type=socket.SOCK_STREAM,
100
+ proto=socket.IPPROTO_TCP,
101
+ )
102
+ except socket.gaierror as exc:
103
+ raise AcquisitionSecurityError(
104
+ "DNS_FAILURE",
105
+ f"public host resolution failed: {exc.errno}",
106
+ ) from None
107
+ addresses = tuple(dict.fromkeys(item[4][0] for item in results))
108
+ if not addresses:
109
+ raise AcquisitionSecurityError("DNS_EMPTY", "public host returned no addresses")
110
+ if len(addresses) > MAX_DNS_ANSWERS:
111
+ raise AcquisitionSecurityError(
112
+ "DNS_ANSWER_LIMIT",
113
+ f"public host returned more than {MAX_DNS_ANSWERS} addresses",
114
+ )
115
+ return addresses
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class EgressPolicy:
120
+ """Coordinator-owned public-host allowlist."""
121
+
122
+ allowed_hostnames: tuple[str, ...]
123
+ allowed_suffixes: tuple[str, ...] = ()
124
+ allowed_ports: frozenset[int] = DEFAULT_ALLOWED_PORTS
125
+
126
+ def __post_init__(self) -> None:
127
+ if not self.allowed_hostnames and not self.allowed_suffixes:
128
+ raise AcquisitionSecurityError(
129
+ "EGRESS_ALLOWLIST_EMPTY",
130
+ "at least one exact hostname or suffix is required",
131
+ )
132
+ for value in (*self.allowed_hostnames, *self.allowed_suffixes):
133
+ normalized = _normalize_hostname(value.lstrip("."))
134
+ if normalized != value.lstrip(".").rstrip(".").lower():
135
+ raise AcquisitionSecurityError(
136
+ "EGRESS_HOST_CANONICAL",
137
+ "egress host entries must use canonical ASCII spelling",
138
+ )
139
+ if not self.allowed_ports or any(
140
+ type(port) is not int or port < 1 or port > 65_535 for port in self.allowed_ports
141
+ ):
142
+ raise AcquisitionSecurityError(
143
+ "EGRESS_PORTS",
144
+ "allowed ports must be a non-empty set of valid integers",
145
+ )
146
+
147
+ def permits(self, hostname: str, port: int) -> bool:
148
+ if port not in self.allowed_ports:
149
+ return False
150
+ if hostname in self.allowed_hostnames:
151
+ return True
152
+ return any(
153
+ hostname != suffix and hostname.endswith("." + suffix)
154
+ for suffix in self.allowed_suffixes
155
+ )
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ValidatedTarget:
160
+ """One URL bound to an exact DNS-validation epoch."""
161
+
162
+ original_url: str
163
+ normalized_url: str
164
+ hostname: str
165
+ port: int
166
+ approved_ips: tuple[str, ...]
167
+ resolution_digest: str
168
+
169
+ def __post_init__(self) -> None:
170
+ if not self.approved_ips:
171
+ raise AcquisitionSecurityError("DNS_EMPTY", "target has no approved peer addresses")
172
+ expected = canonical_sha256(
173
+ {
174
+ "hostname": self.hostname,
175
+ "port": self.port,
176
+ "approved_ips": list(self.approved_ips),
177
+ }
178
+ )
179
+ if expected != self.resolution_digest:
180
+ raise AcquisitionSecurityError(
181
+ "RESOLUTION_DIGEST",
182
+ "target resolution digest does not match its approved peer set",
183
+ )
184
+
185
+ @property
186
+ def request_target(self) -> str:
187
+ parsed = urlsplit(self.normalized_url)
188
+ return urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
189
+
190
+
191
+ def validate_public_https_url(
192
+ url: str,
193
+ *,
194
+ resolver: Resolver,
195
+ egress_policy: EgressPolicy,
196
+ ) -> ValidatedTarget:
197
+ """Validate a public HTTPS URL and bind every DNS answer to the result."""
198
+
199
+ if not isinstance(url, str) or not url or len(url) > MAX_URL_LENGTH:
200
+ raise AcquisitionSecurityError(
201
+ "URL_LENGTH",
202
+ f"URL must contain 1..{MAX_URL_LENGTH} characters",
203
+ )
204
+ if any(0xD800 <= ord(char) <= 0xDFFF for char in url):
205
+ raise AcquisitionSecurityError("URL_UNICODE", "URL contains a non-Unicode-scalar value")
206
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in url):
207
+ raise AcquisitionSecurityError("URL_CONTROL", "URL contains a control character")
208
+ try:
209
+ parsed = urlsplit(url)
210
+ port = parsed.port
211
+ except ValueError:
212
+ raise AcquisitionSecurityError("URL_PORT", "URL has an invalid port") from None
213
+ _validate_url_structure(parsed)
214
+ hostname = canonical_public_hostname(parsed)
215
+ effective_port = port if port is not None else 443
216
+ if not egress_policy.permits(hostname, effective_port):
217
+ raise AcquisitionSecurityError(
218
+ "EGRESS_DENIED",
219
+ "target hostname or port is not coordinator-allowlisted",
220
+ )
221
+ if hostname in _METADATA_HOSTS or hostname.endswith(".internal"):
222
+ raise AcquisitionSecurityError("METADATA_DESTINATION", "metadata/internal hosts are denied")
223
+
224
+ literal = _parse_ip_literal(hostname)
225
+ if literal is not None:
226
+ addresses = (literal.compressed,)
227
+ else:
228
+ if _looks_like_obfuscated_ip(hostname):
229
+ raise AcquisitionSecurityError(
230
+ "OBFUSCATED_IP",
231
+ "non-canonical numeric IP hostnames are denied",
232
+ )
233
+ addresses = resolver.resolve(hostname, effective_port)
234
+ approved: list[str] = []
235
+ for raw in addresses:
236
+ try:
237
+ address = ipaddress.ip_address(raw)
238
+ except ValueError:
239
+ raise AcquisitionSecurityError(
240
+ "DNS_ADDRESS",
241
+ "resolver returned a non-IP address",
242
+ ) from None
243
+ _require_globally_routable(address)
244
+ approved.append(address.compressed)
245
+ approved_ips = tuple(sorted(dict.fromkeys(approved)))
246
+ if not approved_ips:
247
+ raise AcquisitionSecurityError("DNS_EMPTY", "target has no approved public addresses")
248
+ if len(approved_ips) > MAX_DNS_ANSWERS:
249
+ raise AcquisitionSecurityError(
250
+ "DNS_ANSWER_LIMIT",
251
+ f"target has more than {MAX_DNS_ANSWERS} approved addresses",
252
+ )
253
+
254
+ netloc = hostname
255
+ if ":" in hostname and literal is not None:
256
+ netloc = f"[{hostname}]"
257
+ if effective_port != 443:
258
+ netloc = f"{netloc}:{effective_port}"
259
+ normalized_url = urlunsplit(
260
+ (
261
+ "https",
262
+ netloc,
263
+ parsed.path or "/",
264
+ parsed.query,
265
+ "",
266
+ )
267
+ )
268
+ resolution_digest = canonical_sha256(
269
+ {
270
+ "hostname": hostname,
271
+ "port": effective_port,
272
+ "approved_ips": list(approved_ips),
273
+ }
274
+ )
275
+ return ValidatedTarget(
276
+ original_url=url,
277
+ normalized_url=normalized_url,
278
+ hostname=hostname,
279
+ port=effective_port,
280
+ approved_ips=approved_ips,
281
+ resolution_digest=resolution_digest,
282
+ )
283
+
284
+
285
+ def require_approved_peer(target: ValidatedTarget, connected_peer: str) -> str:
286
+ """Verify the actual connected peer belongs to the exact validated epoch."""
287
+
288
+ try:
289
+ peer = ipaddress.ip_address(connected_peer).compressed
290
+ except ValueError:
291
+ raise AcquisitionSecurityError(
292
+ "PEER_ADDRESS",
293
+ "transport returned a non-IP peer address",
294
+ ) from None
295
+ if peer not in target.approved_ips:
296
+ raise AcquisitionSecurityError(
297
+ "PEER_MISMATCH",
298
+ "connected peer was not part of the approved DNS result",
299
+ )
300
+ return peer
301
+
302
+
303
+ def canonical_public_hostname(parsed: SplitResult) -> str:
304
+ """The one canonical ASCII machine name ``parsed`` names, or a typed refusal.
305
+
306
+ This is the repository's single answer to "which host is this string about", and it is a
307
+ function rather than three checks written out at each call site because it has to be *one*
308
+ answer. ``urlsplit`` is not that answer on its own: it is a generic RFC 3986 splitter, and the
309
+ parsers that actually resolve these strings -- every browser, every JavaScript consumer, every
310
+ modern HTTP client -- implement the WHATWG URL Standard instead. Where the two disagree,
311
+ ``urlsplit`` hands back a *string* that no parser would resolve as a machine name, and a rule
312
+ that consults an allowlist about that string is asking about something that is not a host.
313
+
314
+ Four refusals, in this order, and each one is a place the two readings diverge:
315
+
316
+ * a port ``urlsplit`` will not read. ``.hostname`` is computed by splitting everything after
317
+ the last ``:`` off the netloc and never looking at it, so
318
+ ``https://allowed.example:S3cretToken/x`` answers ``allowed.example`` while ``parsed.port``
319
+ raises. Whatever is written where a port goes reaches the caller's artifact verbatim -- a
320
+ literal credential, a private machine name -- and no rule downstream of ``.hostname`` can
321
+ see it. ``validate_public_https_url`` was safe only because it happens to evaluate
322
+ ``parsed.port`` inside a ``try`` of its own before it gets here; the catalog door does not
323
+ call that function, so the shared answer has to carry the question rather than each caller
324
+ duplicating it. Asking here applies the rule to every caller.
325
+ * a backslash anywhere in the authority. ``urlsplit`` keeps it; for a special scheme the URL
326
+ Standard treats ``\\`` exactly as ``/``, so the authority *ends* there. The whole run
327
+ ``a.private.example\\allowed.example`` is one host to ``urlsplit`` and the host
328
+ ``a.private.example`` to everything that resolves it.
329
+ * a percent sign in the host. ``urlsplit`` keeps it; a WHATWG parser refuses the URL outright.
330
+ * anything ``_normalize_hostname`` cannot read as a hostname: the URL Standard's *domain to
331
+ ASCII* (:func:`_domain_to_ascii`), then per-label ``[a-z0-9-]``, at most 63 characters per
332
+ label and 253 overall, with IP literals passed through for the caller to judge.
333
+
334
+ The return value is the *normalised* name, and callers must consult their allowlist about that
335
+ and not about what they were handed. Normalisation is not decoration here: it is what makes the
336
+ name this repository checks the same name a browser resolves, so ``a.example<U+00AD>b.example``
337
+ is checked as the single name a browser goes to rather than as two names glued by a character
338
+ nobody can see.
339
+
340
+ And normalisation is not a licence to *invent* a name. A spelling no URL parser can read is
341
+ refused rather than reduced to whatever a lenient codec leaves behind:
342
+ ``a.private.example<U+200D>allowed.example`` is not a machine name, ``new URL()`` throws on it,
343
+ and answering ``a.private.exampleallowed.example`` -- a name under an allowlisted suffix --
344
+ would be this function inventing the very thing it exists to prevent. Python's ``idna`` codec
345
+ deletes that joiner, so this function instead delegates to :func:`_domain_to_ascii`, which
346
+ applies the standard's derived code-point classes rather than an incident-driven list.
347
+ """
348
+
349
+ try:
350
+ _ = parsed.port
351
+ except ValueError:
352
+ raise AcquisitionSecurityError("URL_PORT", "URL has an invalid port") from None
353
+ if "\\" in parsed.netloc:
354
+ raise AcquisitionSecurityError("URL_BACKSLASH", "URL authority backslashes are forbidden")
355
+ hostname = parsed.hostname or ""
356
+ if "%" in hostname:
357
+ raise AcquisitionSecurityError(
358
+ "URL_HOST_ENCODING",
359
+ "percent-encoded hostnames are forbidden",
360
+ )
361
+ return _normalize_hostname(hostname)
362
+
363
+
364
+ def _validate_url_structure(parsed: SplitResult) -> None:
365
+ if parsed.scheme != "https":
366
+ raise AcquisitionSecurityError("URL_SCHEME", "only HTTPS URLs are allowed")
367
+ if not parsed.netloc or parsed.hostname is None:
368
+ raise AcquisitionSecurityError("URL_HOST", "URL must contain a hostname")
369
+ if parsed.username is not None or parsed.password is not None:
370
+ raise AcquisitionSecurityError("URL_USERINFO", "URL user information is forbidden")
371
+ if parsed.fragment:
372
+ raise AcquisitionSecurityError("URL_FRAGMENT", "URL fragments are forbidden")
373
+
374
+
375
+ def _domain_to_ascii(raw: str) -> str:
376
+ """``raw`` as the URL Standard's *domain to ASCII* reads it, or a typed refusal.
377
+
378
+ Python's built-in ``idna`` **codec** is not this operation and using it here failed open. It
379
+ implements IDNA2003 nameprep, whose "commonly mapped to nothing" table silently *deletes* code
380
+ points that the URL Standard's UTS46 processing refuses outright. The two answers differ in the
381
+ direction that leaks: the codec deletes the invisible character, the glued name ends in an
382
+ allowlisted suffix, the allowlist says public, and the private machine name is written into the
383
+ shared catalog verbatim -- while no URL parser can read the string at all. Swept over the glue
384
+ code points this module was attacked with, node v24.13.0 throws ``Invalid URL`` on
385
+ ``https://internal-wiki.corp.example.com<GLUE>bulks-faostat.fao.org/x`` for U+200C, U+200D,
386
+ U+200E, U+200F, U+061C, U+E0000 and U+E0001 through U+E0004, every one of which the codec
387
+ reduced to a name under ``fao.org``.
388
+
389
+ A name no parser can read is a refusal and never a reduction; that is what
390
+ :func:`canonical_public_hostname` promises its callers. The question is therefore asked of a
391
+ UTS46 implementation with the standard's own validity checks
392
+ (non-transitional processing, ``UseSTD3ASCIIRules=false``, and the joiner and code-point
393
+ classes UTS46 derives from the Unicode tables) rather than of a list of the code points that
394
+ happened to be found.
395
+
396
+ **That was measured over 33 hand-picked code points and the promise is about all of them.**
397
+ Swept instead over the whole code-point space, the pinned ``idna`` table admitted three names
398
+ node v24.13.0 refuses outright -- U+A7D2, U+A7D4 and U+A7F1, the last of which UTS46 *maps* to
399
+ ``s`` -- each of them reducing ``internal-wiki.corp.example.com<GLUE>bulks-faostat.fao.org`` to
400
+ a name under an allowlisted suffix, which is the exact failure this function exists to stop.
401
+ The three are not the point and are not fixed as three. They are all *unassigned* in this
402
+ interpreter's Unicode data and assigned in the newer Unicode the installed ``idna`` was built
403
+ from, and UTS46 marks an unassigned code point ``disallowed``. So the refusal below is that
404
+ rule, asked of :mod:`unicodedata` -- the same source the catalog scanner asks for NFKC -- and
405
+ it holds for code points added to the table ahead of the interpreter rather than for
406
+ the three that happened to exist on the day. It costs refusals for the 38 further unassigned
407
+ code points node currently reads; none of them is a name the public DNS resolves, and refusing
408
+ a name whose spelling this interpreter cannot classify is the direction this module refuses in.
409
+
410
+ The residual set is still governed by a table (``idna``) pinned independently of the ICU in
411
+ every consumer that will read ``catalog.json``, so
412
+ ``tests/h3/test_url_policy.py::test_the_whole_code_point_space_is_pinned_against_the_installed_table``
413
+ sweeps the entire space and pins the answer, and a dependency bump that moves it fails there
414
+ instead of moving silently.
415
+
416
+ A pure-ASCII name is its own domain-to-ASCII form apart from case, so it takes the same path it
417
+ always did and the hostname grammar in :func:`_normalize_hostname` judges it. UTS46 changes and
418
+ deletes nothing in ASCII once STD3 rules are off, so this short-circuit is the standard's
419
+ answer and not an exemption from it -- and it is what keeps IP literals, and the underscore and
420
+ hyphen shapes this module already states as false refusals, exactly where they were.
421
+ """
422
+
423
+ if raw.isascii():
424
+ return raw.lower()
425
+ if any(unicodedata.category(character) == "Cn" for character in raw):
426
+ raise AcquisitionSecurityError(
427
+ "URL_HOST",
428
+ "hostname is spelled with a code point this Unicode data does not assign",
429
+ )
430
+ try:
431
+ encoded = idna.encode(raw, uts46=True, std3_rules=False, transitional=False)
432
+ except UnicodeError:
433
+ raise AcquisitionSecurityError(
434
+ "URL_HOST",
435
+ "hostname is not a domain a URL parser can read",
436
+ ) from None
437
+ return encoded.decode("ascii").lower()
438
+
439
+
440
+ def a_domain_may_contain(character: str) -> bool:
441
+ """Whether a URL parser goes on reading a machine name when it meets ``character``.
442
+
443
+ This is the URL Standard's *forbidden domain code point* set and nothing else. That is the
444
+ whole of the standard's character-level answer: the host parser accumulates code points until
445
+ it meets one of those, and only then runs *domain to ASCII* over the **whole name** it
446
+ collected. Validity is a property of the name, never of a character, and the two questions
447
+ are answered in that order by every parser that will read ``catalog.json``.
448
+
449
+ It exists because reader's punctuation and a parser's alphabet are different sets and the
450
+ catalog scanner had been using one for the other. ``"`` and ``'`` end a link for anyone reading
451
+ a sentence; to every URL parser they are ordinary host characters, so
452
+ ``https://allowed.example"internal.corp/x`` names ``allowed.example"internal.corp`` and
453
+ resolves. A space does not: it is a forbidden domain code point, and the same string written
454
+ with one is refused by every parser. A rule that treats those two the same way is wrong about
455
+ one of them, and which one it is wrong about decides whether the mistake leaks.
456
+
457
+ This function intentionally does not run ``_domain_to_ascii(f"a{character}b")``. Domain-to-
458
+ ASCII is a whole-name operation, while both callers use ``False`` to **stop looking**: the
459
+ catalog scanner reads it as "the machine name ends here", and where the name has not started
460
+ yet that means "this is prose, report no link at all". A per-character domain-to-ASCII probe
461
+ diverges from consumers' ICU tables by construction, and every divergence points at silence.
462
+ Measured over the whole code-point space, such a probe rejects 974,396 code points that are not
463
+ forbidden domain code points, while node v24.13.0 reads a machine name from
464
+ ``https:private.example<C>/x`` for **16,604** of them -- U+00A9 and U+00B0 among them -- so a
465
+ private host and literal credential could otherwise enter the shared catalog with no link
466
+ reported at all.
467
+
468
+ So this answers the alphabet question, exactly, and callers that need to know whether the name
469
+ is one this repository will admit must ask for the name. :func:`canonical_public_hostname` is
470
+ that question and it refuses what it cannot read, which is the direction a shared artifact can
471
+ afford to be wrong in. An underscore is kept inside a machine name by every parser and refused
472
+ by :func:`_normalize_hostname`; that stays a stated false refusal and it stays over there.
473
+
474
+ ``:`` is answered ``False`` here because it is a forbidden domain code point; an authority may
475
+ still carry one, introducing the port, which is a fact about authorities rather than about
476
+ domains and belongs to the caller that is looking at an authority.
477
+ """
478
+
479
+ return character not in _FORBIDDEN_DOMAIN_CODE_POINTS
480
+
481
+
482
+ def _normalize_hostname(hostname: str) -> str:
483
+ raw = hostname.rstrip(".")
484
+ if not raw or len(raw) > 253:
485
+ raise AcquisitionSecurityError("URL_HOST", "hostname length is invalid")
486
+ normalized = _domain_to_ascii(raw)
487
+ labels = normalized.split(".")
488
+ if any(
489
+ not label
490
+ or len(label) > 63
491
+ or label.startswith("-")
492
+ or label.endswith("-")
493
+ or not re.fullmatch(r"[a-z0-9-]+", label)
494
+ for label in labels
495
+ ):
496
+ if _parse_ip_literal(normalized) is None:
497
+ raise AcquisitionSecurityError("URL_HOST", "hostname labels are invalid")
498
+ return normalized
499
+
500
+
501
+ def _parse_ip_literal(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
502
+ try:
503
+ return ipaddress.ip_address(hostname)
504
+ except ValueError:
505
+ return None
506
+
507
+
508
+ def _looks_like_obfuscated_ip(hostname: str) -> bool:
509
+ if _DECIMAL_OR_OBFUSCATED_IP.fullmatch(hostname):
510
+ return _parse_ip_literal(hostname) is None
511
+ return False
512
+
513
+
514
+ def _require_globally_routable(
515
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address,
516
+ ) -> None:
517
+ if (
518
+ not address.is_global
519
+ or address.is_private
520
+ or address.is_loopback
521
+ or address.is_link_local
522
+ or address.is_multicast
523
+ or address.is_reserved
524
+ or address.is_unspecified
525
+ or any(address in network for network in _FORBIDDEN_NETWORKS)
526
+ ):
527
+ raise AcquisitionSecurityError(
528
+ "NON_PUBLIC_DESTINATION",
529
+ "loopback/private/link-local/metadata/reserved destinations are denied",
530
+ )