mostlyright-data 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""The deterministic unit grammar: a documented UCUM subset, read from a pinned table.
|
|
2
|
+
|
|
3
|
+
A declared unit is a code in the subset of UCUM that ``docs/UNITS.md`` states, and validation is
|
|
4
|
+
the question "does it resolve against the pinned table" rather than "is it in a hand-grown list".
|
|
5
|
+
The table under ``ucum/`` carries the atoms, the prefixes and their exact values; nothing here
|
|
6
|
+
guesses, converts through floating point, or reaches a network.
|
|
7
|
+
|
|
8
|
+
Two things this module deliberately does not do.
|
|
9
|
+
|
|
10
|
+
It does not rewrite a declared code. ``semantics.columns.unit`` is sealed as the author wrote it,
|
|
11
|
+
so a code is resolved for comparison and never canonicalised in place: rewriting ``ug/m3`` into a
|
|
12
|
+
canonical spelling would move the digest of every Build that carried it.
|
|
13
|
+
|
|
14
|
+
And it does not treat a temperature as a ratio. ``Cel`` and ``[degF]`` are affine: zero degrees
|
|
15
|
+
Celsius is not zero of anything, so no multiplier turns one into the other. They resolve with an
|
|
16
|
+
exact scale *and* an exact offset, and every comparison here uses the pair. A caller that reduces
|
|
17
|
+
a unit to one number is refused rather than quietly answered.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from fractions import Fraction
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from types import MappingProxyType
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
UCUM_SUBSET_VERSION: Final = "ucum-subset.v1"
|
|
32
|
+
|
|
33
|
+
#: The sentinel a column carries when it declares no physical unit at all. It is not a UCUM code
|
|
34
|
+
#: and never resolves to one: ``unit_state`` uses it to mean "there is nothing here to convert",
|
|
35
|
+
#: which is a different claim from the dimensionless ``1``.
|
|
36
|
+
NO_UNIT: Final = "none"
|
|
37
|
+
|
|
38
|
+
_TABLE_DIRECTORY: Final = Path(__file__).resolve().parent / "ucum"
|
|
39
|
+
_TABLE_FILE: Final = "ucum-subset.v1.json"
|
|
40
|
+
|
|
41
|
+
# Bounds. A unit code is a label on a column, not a document: these are wide enough for every
|
|
42
|
+
# code the subset can spell and narrow enough that no input turns resolution into work.
|
|
43
|
+
MAX_UNIT_LENGTH: Final = 64
|
|
44
|
+
MAX_UNIT_TERMS: Final = 16
|
|
45
|
+
MAX_UNIT_EXPONENT: Final = 9
|
|
46
|
+
MAX_ANNOTATION_LENGTH: Final = 32
|
|
47
|
+
# Magnitude, which the length and term bounds do not constrain: sixteen terms of a large atom
|
|
48
|
+
# raised to the ninth power stay inside every bound above and resolve to a number with thousands of
|
|
49
|
+
# digits, which is past what the interpreter will render and therefore past what this module can
|
|
50
|
+
# report. Nothing anybody measures in lives outside this window -- a yottamole is 6 times 10 to the
|
|
51
|
+
# 47 -- so a code that leaves it is refused here rather than at some later `str()`.
|
|
52
|
+
MAX_UNIT_MAGNITUDE_EXPONENT: Final = 120
|
|
53
|
+
|
|
54
|
+
_ANNOTATION = re.compile(r"^[A-Za-z0-9_]+$")
|
|
55
|
+
_EXPONENT = re.compile(r"^[+-]?[0-9]{1,2}$")
|
|
56
|
+
|
|
57
|
+
# Constructs UCUM has and this subset does not. Each is named in the refusal so the author is told
|
|
58
|
+
# what was rejected rather than that something was.
|
|
59
|
+
_UNSUPPORTED_CONSTRUCTS: Final = (
|
|
60
|
+
("(", "a parenthesised sub-expression"),
|
|
61
|
+
(")", "a parenthesised sub-expression"),
|
|
62
|
+
("*", "a numeric factor such as 10*3"),
|
|
63
|
+
("^", "a numeric factor such as 10^3"),
|
|
64
|
+
(" ", "a space"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UnitError(ValueError):
|
|
69
|
+
"""A typed failure to resolve a unit code, with the exact construct that was refused."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, code: str, detail: str, *, unit: str) -> None:
|
|
72
|
+
self.code = code
|
|
73
|
+
self.detail = detail
|
|
74
|
+
self.unit = unit
|
|
75
|
+
super().__init__(f"{unit!r}: {detail} [{code}]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Unit:
|
|
80
|
+
"""One resolved unit: its dimension, its exact scale, and its exact offset.
|
|
81
|
+
|
|
82
|
+
``factor`` and ``offset`` are the affine map into the table's base units: a value ``x`` in
|
|
83
|
+
this unit is ``x * factor + offset`` in base units. ``offset`` is zero for every ratio-scale
|
|
84
|
+
unit, which is every unit in the subset except ``Cel`` and ``[degF]``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
code: str
|
|
88
|
+
exponents: tuple[int, ...]
|
|
89
|
+
factor: Fraction
|
|
90
|
+
offset: Fraction
|
|
91
|
+
annotations: tuple[str, ...]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def affine(self) -> bool:
|
|
95
|
+
"""Report whether this unit's zero is somewhere other than the base unit's zero."""
|
|
96
|
+
|
|
97
|
+
return self.offset != 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def dimensionless(self) -> bool:
|
|
101
|
+
return not any(self.exponents)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class UnitTable:
|
|
106
|
+
"""The pinned atom and prefix table, loaded once and never mutated."""
|
|
107
|
+
|
|
108
|
+
version: str
|
|
109
|
+
standard_version: str
|
|
110
|
+
standard_release: str
|
|
111
|
+
dimensions: tuple[str, ...]
|
|
112
|
+
prefixes: Any
|
|
113
|
+
atoms: Any
|
|
114
|
+
legacy_aliases: Any
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fraction(value: str) -> Fraction:
|
|
118
|
+
return Fraction(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_MAGNITUDE_CEILING: Final = Fraction(10) ** MAX_UNIT_MAGNITUDE_EXPONENT
|
|
122
|
+
_MAGNITUDE_FLOOR: Final = Fraction(1) / _MAGNITUDE_CEILING
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_bounded(factor: Fraction, unit: str) -> None:
|
|
126
|
+
if not _MAGNITUDE_FLOOR <= factor <= _MAGNITUDE_CEILING:
|
|
127
|
+
raise UnitError(
|
|
128
|
+
"UCUM_MAGNITUDE_RANGE",
|
|
129
|
+
"the resolved magnitude is outside 10 to the plus or minus "
|
|
130
|
+
f"{MAX_UNIT_MAGNITUDE_EXPONENT}, which no measured quantity reaches",
|
|
131
|
+
unit=unit,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@lru_cache(maxsize=1)
|
|
136
|
+
def unit_table() -> UnitTable:
|
|
137
|
+
"""Load the pinned subset table.
|
|
138
|
+
|
|
139
|
+
The read happens once per process. The table is data rather than code precisely so that the
|
|
140
|
+
values it carries can be pinned by digest and recomputed from their own definitions, which is
|
|
141
|
+
what ``tests/test_units_table.py`` does.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
document = json.loads((_TABLE_DIRECTORY / _TABLE_FILE).read_text(encoding="utf-8"))
|
|
145
|
+
if document.get("schema") != UCUM_SUBSET_VERSION:
|
|
146
|
+
raise UnitError(
|
|
147
|
+
"UCUM_TABLE_VERSION",
|
|
148
|
+
f"the unit table declares {document.get('schema')!r}, not {UCUM_SUBSET_VERSION!r}",
|
|
149
|
+
unit="",
|
|
150
|
+
)
|
|
151
|
+
dimensions = tuple(item["symbol"] for item in document["dimensions"])
|
|
152
|
+
width = len(dimensions)
|
|
153
|
+
atoms: dict[str, dict[str, Any]] = {}
|
|
154
|
+
for symbol, entry in document["atoms"].items():
|
|
155
|
+
exponents = tuple(int(value) for value in entry["exponents"])
|
|
156
|
+
if len(exponents) != width:
|
|
157
|
+
raise UnitError(
|
|
158
|
+
"UCUM_TABLE_SHAPE",
|
|
159
|
+
f"atom {symbol!r} records {len(exponents)} exponents for {width} dimensions",
|
|
160
|
+
unit=symbol,
|
|
161
|
+
)
|
|
162
|
+
atoms[symbol] = {
|
|
163
|
+
"name": entry["name"],
|
|
164
|
+
"kind": entry["kind"],
|
|
165
|
+
"metric": bool(entry["metric"]),
|
|
166
|
+
"exponents": exponents,
|
|
167
|
+
"factor": _fraction(entry["factor"]),
|
|
168
|
+
"offset": _fraction(entry["offset"]) if "offset" in entry else Fraction(0),
|
|
169
|
+
"definition": entry.get("definition"),
|
|
170
|
+
}
|
|
171
|
+
prefixes = {
|
|
172
|
+
symbol: {"name": entry["name"], "factor": _fraction(entry["factor"])}
|
|
173
|
+
for symbol, entry in document["prefixes"].items()
|
|
174
|
+
}
|
|
175
|
+
return UnitTable(
|
|
176
|
+
version=document["schema"],
|
|
177
|
+
standard_version=document["standard_version"],
|
|
178
|
+
standard_release=document["standard_release"],
|
|
179
|
+
dimensions=dimensions,
|
|
180
|
+
prefixes=MappingProxyType(prefixes),
|
|
181
|
+
atoms=MappingProxyType(atoms),
|
|
182
|
+
legacy_aliases=MappingProxyType(dict(document["legacy_aliases"])),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def legacy_aliases() -> Any:
|
|
187
|
+
"""The fifteen tokens the hand-grown list held, mapped to the codes they always meant.
|
|
188
|
+
|
|
189
|
+
``none`` is absent on purpose: it was never a unit and does not become one here.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
return unit_table().legacy_aliases
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _split_terms(text: str, unit: str) -> list[tuple[int, str]]:
|
|
196
|
+
"""Split an expression into signed terms without splitting inside a bracketed atom."""
|
|
197
|
+
|
|
198
|
+
terms: list[tuple[int, str]] = []
|
|
199
|
+
sign = 1
|
|
200
|
+
start = 0
|
|
201
|
+
depth = 0
|
|
202
|
+
braces = 0
|
|
203
|
+
if text.startswith("/"):
|
|
204
|
+
sign = -1
|
|
205
|
+
start = 1
|
|
206
|
+
position = start
|
|
207
|
+
while position < len(text):
|
|
208
|
+
character = text[position]
|
|
209
|
+
if character == "[":
|
|
210
|
+
depth += 1
|
|
211
|
+
elif character == "]":
|
|
212
|
+
depth -= 1
|
|
213
|
+
if depth < 0:
|
|
214
|
+
raise UnitError("UCUM_SYNTAX", "a ']' closes nothing", unit=unit)
|
|
215
|
+
elif character == "{":
|
|
216
|
+
braces += 1
|
|
217
|
+
elif character == "}":
|
|
218
|
+
braces -= 1
|
|
219
|
+
if braces < 0:
|
|
220
|
+
raise UnitError("UCUM_SYNTAX", "a '}' closes nothing", unit=unit)
|
|
221
|
+
elif character in "./" and depth == 0 and braces == 0:
|
|
222
|
+
terms.append((sign, text[start:position]))
|
|
223
|
+
sign = 1 if character == "." else -1
|
|
224
|
+
start = position + 1
|
|
225
|
+
position += 1
|
|
226
|
+
if depth or braces:
|
|
227
|
+
raise UnitError("UCUM_SYNTAX", "a bracket or brace is left open", unit=unit)
|
|
228
|
+
terms.append((sign, text[start:]))
|
|
229
|
+
return terms
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _annotation(term: str, unit: str) -> tuple[str, str | None]:
|
|
233
|
+
"""Split one trailing ``{...}`` annotation off a term."""
|
|
234
|
+
|
|
235
|
+
if not term.endswith("}"):
|
|
236
|
+
if "{" in term or "}" in term:
|
|
237
|
+
raise UnitError(
|
|
238
|
+
"UCUM_ANNOTATION",
|
|
239
|
+
"an annotation must be a '{...}' suffix on one term",
|
|
240
|
+
unit=unit,
|
|
241
|
+
)
|
|
242
|
+
return term, None
|
|
243
|
+
opened = term.find("{")
|
|
244
|
+
if opened < 0:
|
|
245
|
+
raise UnitError("UCUM_SYNTAX", "a '}' closes nothing", unit=unit)
|
|
246
|
+
text = term[opened + 1 : -1]
|
|
247
|
+
if not 1 <= len(text) <= MAX_ANNOTATION_LENGTH or not _ANNOTATION.fullmatch(text):
|
|
248
|
+
raise UnitError(
|
|
249
|
+
"UCUM_ANNOTATION",
|
|
250
|
+
f"an annotation must be 1..{MAX_ANNOTATION_LENGTH} letters, digits or underscores",
|
|
251
|
+
unit=unit,
|
|
252
|
+
)
|
|
253
|
+
return term[:opened], text
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _exponent(body: str, unit: str) -> tuple[str, int]:
|
|
257
|
+
"""Split a trailing integer power off a term body."""
|
|
258
|
+
|
|
259
|
+
if body != "1" and body.lstrip("+-").isdigit():
|
|
260
|
+
raise UnitError(
|
|
261
|
+
"UCUM_UNSUPPORTED_CONSTRUCT",
|
|
262
|
+
f"a numeric factor such as {body} is outside {UCUM_SUBSET_VERSION}",
|
|
263
|
+
unit=unit,
|
|
264
|
+
)
|
|
265
|
+
digits = ""
|
|
266
|
+
while len(body) > 1 and body[-1].isdigit():
|
|
267
|
+
digits = body[-1] + digits
|
|
268
|
+
body = body[:-1]
|
|
269
|
+
if len(body) > 1 and body[-1] in "+-" and digits:
|
|
270
|
+
digits = body[-1] + digits
|
|
271
|
+
body = body[:-1]
|
|
272
|
+
if not digits:
|
|
273
|
+
return body, 1
|
|
274
|
+
if not _EXPONENT.fullmatch(digits):
|
|
275
|
+
raise UnitError("UCUM_SYNTAX", f"{digits!r} is not an integer power", unit=unit)
|
|
276
|
+
exponent = int(digits)
|
|
277
|
+
if not -MAX_UNIT_EXPONENT <= exponent <= MAX_UNIT_EXPONENT or exponent == 0:
|
|
278
|
+
raise UnitError(
|
|
279
|
+
"UCUM_EXPONENT_RANGE",
|
|
280
|
+
f"an integer power must be between -{MAX_UNIT_EXPONENT} and {MAX_UNIT_EXPONENT} "
|
|
281
|
+
"and may not be zero",
|
|
282
|
+
unit=unit,
|
|
283
|
+
)
|
|
284
|
+
return body, exponent
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _resolve_atom(symbol: str, unit: str) -> tuple[dict[str, Any], Fraction]:
|
|
288
|
+
"""Resolve one atom, taking the whole symbol first and a prefix split only if it is not one.
|
|
289
|
+
|
|
290
|
+
The order is the rule, not a preference. ``cd`` is the candela and not a centi-day, and ``Pa``
|
|
291
|
+
is the pascal and not a peta-year, because a symbol the table holds outright is never read as
|
|
292
|
+
something else. Where the whole symbol is unknown, exactly one prefix split may resolve it; a
|
|
293
|
+
symbol that two splits could explain is refused rather than silently decided.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
table = unit_table()
|
|
297
|
+
atom = table.atoms.get(symbol)
|
|
298
|
+
if atom is not None:
|
|
299
|
+
return atom, Fraction(1)
|
|
300
|
+
matches: list[tuple[str, dict[str, Any], Fraction]] = []
|
|
301
|
+
special: list[str] = []
|
|
302
|
+
non_metric: list[str] = []
|
|
303
|
+
for prefix, entry in table.prefixes.items():
|
|
304
|
+
if not symbol.startswith(prefix):
|
|
305
|
+
continue
|
|
306
|
+
remainder = symbol[len(prefix) :]
|
|
307
|
+
candidate = table.atoms.get(remainder)
|
|
308
|
+
if candidate is None:
|
|
309
|
+
continue
|
|
310
|
+
if candidate["kind"] == "special":
|
|
311
|
+
special.append(remainder)
|
|
312
|
+
continue
|
|
313
|
+
if not candidate["metric"]:
|
|
314
|
+
non_metric.append(remainder)
|
|
315
|
+
continue
|
|
316
|
+
matches.append((prefix, candidate, entry["factor"]))
|
|
317
|
+
if not matches and special:
|
|
318
|
+
raise UnitError(
|
|
319
|
+
"UCUM_PREFIX_ON_SPECIAL",
|
|
320
|
+
f"{special[0]!r} is an offset unit and this subset accepts no prefix on one",
|
|
321
|
+
unit=unit,
|
|
322
|
+
)
|
|
323
|
+
if not matches and non_metric:
|
|
324
|
+
raise UnitError(
|
|
325
|
+
"UCUM_PREFIX_ON_NON_METRIC",
|
|
326
|
+
f"{non_metric[0]!r} takes no prefix, so {symbol!r} is not a unit",
|
|
327
|
+
unit=unit,
|
|
328
|
+
)
|
|
329
|
+
if not matches:
|
|
330
|
+
raise UnitError(
|
|
331
|
+
"UCUM_UNKNOWN_ATOM",
|
|
332
|
+
f"{symbol!r} is not an atom in {UCUM_SUBSET_VERSION}",
|
|
333
|
+
unit=unit,
|
|
334
|
+
)
|
|
335
|
+
if len(matches) > 1:
|
|
336
|
+
spellings = ", ".join(f"{prefix}+{symbol[len(prefix) :]}" for prefix, _, _ in matches)
|
|
337
|
+
raise UnitError(
|
|
338
|
+
"UCUM_AMBIGUOUS_ATOM",
|
|
339
|
+
f"{symbol!r} reads as more than one prefixed atom ({spellings})",
|
|
340
|
+
unit=unit,
|
|
341
|
+
)
|
|
342
|
+
_prefix, candidate, factor = matches[0]
|
|
343
|
+
return candidate, factor
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def parse_unit(unit: str) -> Unit:
|
|
347
|
+
"""Resolve one UCUM code from the documented subset, or refuse it by name.
|
|
348
|
+
|
|
349
|
+
This is the whole of the acceptance test for ``semantics.columns.unit``. It is total: every
|
|
350
|
+
input either resolves to a :class:`Unit` or raises a :class:`UnitError` whose code says which
|
|
351
|
+
rule refused it.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
if not isinstance(unit, str):
|
|
355
|
+
raise UnitError("UCUM_SYNTAX", "a unit code must be text", unit=str(unit))
|
|
356
|
+
if not unit:
|
|
357
|
+
raise UnitError("UCUM_SYNTAX", "a unit code must not be empty", unit=unit)
|
|
358
|
+
if len(unit) > MAX_UNIT_LENGTH:
|
|
359
|
+
raise UnitError(
|
|
360
|
+
"UCUM_LENGTH", f"a unit code may not exceed {MAX_UNIT_LENGTH} characters", unit=unit
|
|
361
|
+
)
|
|
362
|
+
for character, description in _UNSUPPORTED_CONSTRUCTS:
|
|
363
|
+
if character in unit:
|
|
364
|
+
raise UnitError(
|
|
365
|
+
"UCUM_UNSUPPORTED_CONSTRUCT",
|
|
366
|
+
f"{description} is outside {UCUM_SUBSET_VERSION}",
|
|
367
|
+
unit=unit,
|
|
368
|
+
)
|
|
369
|
+
if unit != unit.strip() or any(ord(character) < 0x20 for character in unit):
|
|
370
|
+
raise UnitError("UCUM_SYNTAX", "a unit code is one plain line of text", unit=unit)
|
|
371
|
+
|
|
372
|
+
terms = _split_terms(unit, unit)
|
|
373
|
+
if len(terms) > MAX_UNIT_TERMS:
|
|
374
|
+
raise UnitError(
|
|
375
|
+
"UCUM_LENGTH", f"a unit code may not exceed {MAX_UNIT_TERMS} terms", unit=unit
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
table = unit_table()
|
|
379
|
+
exponents = [0] * len(table.dimensions)
|
|
380
|
+
factor = Fraction(1)
|
|
381
|
+
offset = Fraction(0)
|
|
382
|
+
annotations: list[str] = []
|
|
383
|
+
special_terms = 0
|
|
384
|
+
for sign, term in terms:
|
|
385
|
+
if not term:
|
|
386
|
+
raise UnitError("UCUM_SYNTAX", "a term is empty", unit=unit)
|
|
387
|
+
body, annotation = _annotation(term, unit)
|
|
388
|
+
if annotation is not None:
|
|
389
|
+
annotations.append(annotation)
|
|
390
|
+
if not body:
|
|
391
|
+
# A bare annotation is unity carrying a label, which is what ``{count}`` is.
|
|
392
|
+
if sign < 0:
|
|
393
|
+
raise UnitError(
|
|
394
|
+
"UCUM_SYNTAX", "a bare annotation cannot be a denominator", unit=unit
|
|
395
|
+
)
|
|
396
|
+
continue
|
|
397
|
+
body, exponent = _exponent(body, unit)
|
|
398
|
+
atom, prefix_factor = _resolve_atom(body, unit)
|
|
399
|
+
if atom["kind"] == "special":
|
|
400
|
+
special_terms += 1
|
|
401
|
+
if annotation is not None or exponent != 1 or len(terms) > 1 or sign < 0:
|
|
402
|
+
raise UnitError(
|
|
403
|
+
"UCUM_SPECIAL_UNIT_NOT_COMPOSABLE",
|
|
404
|
+
f"{body!r} is an offset unit: it stands alone, with no power, no "
|
|
405
|
+
"annotation and nothing multiplied by it",
|
|
406
|
+
unit=unit,
|
|
407
|
+
)
|
|
408
|
+
offset = atom["offset"]
|
|
409
|
+
power = sign * exponent
|
|
410
|
+
factor *= (prefix_factor * atom["factor"]) ** power
|
|
411
|
+
for index, value in enumerate(atom["exponents"]):
|
|
412
|
+
exponents[index] += value * power
|
|
413
|
+
# The resolved magnitude, once, rather than the running product: ``mol9/mol9`` comes to one and
|
|
414
|
+
# is a unit, and bounding the partial product would have refused it for a number it never has.
|
|
415
|
+
# The intermediate is bounded by the term and exponent caps rather than by this, and stays
|
|
416
|
+
# small enough to hold; what it is not always small enough to do is be written out.
|
|
417
|
+
_require_bounded(factor, unit)
|
|
418
|
+
if special_terms > 1: # pragma: no cover - the single-term rule above already refuses this.
|
|
419
|
+
raise UnitError(
|
|
420
|
+
"UCUM_SPECIAL_UNIT_NOT_COMPOSABLE", "two offset units cannot be combined", unit=unit
|
|
421
|
+
)
|
|
422
|
+
return Unit(
|
|
423
|
+
code=unit,
|
|
424
|
+
exponents=tuple(exponents),
|
|
425
|
+
factor=factor,
|
|
426
|
+
offset=offset,
|
|
427
|
+
annotations=tuple(sorted(set(annotations))),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def resolve_declared_unit(declared: str) -> Unit:
|
|
432
|
+
"""Resolve a declared unit, reading a legacy token as the code it always meant.
|
|
433
|
+
|
|
434
|
+
The fifteen tokens the hand-grown list held keep resolving forever. They are aliases rather
|
|
435
|
+
than a second vocabulary: ``celsius`` and ``Cel`` resolve to the same unit, so a Build sealed
|
|
436
|
+
with one and a plan authored with the other are talking about the same thing.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
if not isinstance(declared, str):
|
|
440
|
+
# Before the alias lookup, which hashes what it is given: a declaration that is not text
|
|
441
|
+
# would otherwise leave here as a TypeError, one call short of the boundary this module
|
|
442
|
+
# promises to be total at.
|
|
443
|
+
raise UnitError("UCUM_SYNTAX", "a unit code must be text", unit=str(declared))
|
|
444
|
+
if declared == NO_UNIT:
|
|
445
|
+
raise UnitError(
|
|
446
|
+
"UCUM_NOT_A_UNIT",
|
|
447
|
+
f"{NO_UNIT!r} declares the absence of a unit and resolves to no code",
|
|
448
|
+
unit=declared,
|
|
449
|
+
)
|
|
450
|
+
return parse_unit(legacy_aliases().get(declared, declared))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def is_declarable_unit(declared: str) -> bool:
|
|
454
|
+
"""Report whether a declared string resolves, without raising."""
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
resolve_declared_unit(declared)
|
|
458
|
+
except UnitError:
|
|
459
|
+
return False
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def commensurable(left: Unit, right: Unit) -> bool:
|
|
464
|
+
"""Report whether two units measure the same kind of thing."""
|
|
465
|
+
|
|
466
|
+
return left.exponents == right.exponents
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def same_unit(left: Unit, right: Unit) -> bool:
|
|
470
|
+
"""Report whether two codes name one unit, annotations included.
|
|
471
|
+
|
|
472
|
+
``{count}`` and ``1`` are both dimensionless with factor one and are still not the same unit:
|
|
473
|
+
a count of hospitals is not a ratio, and the annotation is the only thing that says so.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
left.exponents == right.exponents
|
|
478
|
+
and left.factor == right.factor
|
|
479
|
+
and left.offset == right.offset
|
|
480
|
+
and left.annotations == right.annotations
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def conversion(source: Unit, target: Unit) -> tuple[Fraction, Fraction]:
|
|
485
|
+
"""Return the exact affine map ``(scale, offset)`` taking a value in ``source`` to ``target``.
|
|
486
|
+
|
|
487
|
+
``value_target == value_source * scale + offset``. For every ratio-scale pair the offset is
|
|
488
|
+
zero and the scale is the factor a conversion has to multiply by. For the two offset units it
|
|
489
|
+
is not: Celsius to Fahrenheit is ``9/5`` and ``32``, and a check that looked only at the scale
|
|
490
|
+
would accept a conversion that is wrong by thirty-two degrees at every temperature.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
if not commensurable(source, target):
|
|
494
|
+
raise UnitError(
|
|
495
|
+
"UCUM_NOT_COMMENSURABLE",
|
|
496
|
+
f"{source.code!r} and {target.code!r} do not measure the same kind of thing",
|
|
497
|
+
unit=source.code,
|
|
498
|
+
)
|
|
499
|
+
scale = source.factor / target.factor
|
|
500
|
+
offset = (source.offset - target.offset) / target.factor
|
|
501
|
+
return scale, offset
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def ratio_scale_factor(unit: Unit) -> Fraction:
|
|
505
|
+
"""The single multiplier that takes this unit into base units, for a ratio-scale unit only.
|
|
506
|
+
|
|
507
|
+
An offset unit has no such number. Twenty degrees Celsius is not twenty of anything, and a
|
|
508
|
+
caller that multiplied by this unit's scale and stopped would be wrong by the offset at every
|
|
509
|
+
value -- which is the whole reason the two temperature scales are carried as a pair. So the
|
|
510
|
+
question is refused rather than answered with half of it.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
if unit.affine:
|
|
514
|
+
raise UnitError(
|
|
515
|
+
"UCUM_AFFINE_UNIT",
|
|
516
|
+
f"{unit.code!r} is an offset unit: it has a scale and an offset, and no single "
|
|
517
|
+
"multiplier stands for both. Use conversion() instead",
|
|
518
|
+
unit=unit.code,
|
|
519
|
+
)
|
|
520
|
+
return unit.factor
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def dimension_signature(unit: Unit) -> str:
|
|
524
|
+
"""A stable, readable spelling of a unit's dimension for a refusal message."""
|
|
525
|
+
|
|
526
|
+
table = unit_table()
|
|
527
|
+
parts = [
|
|
528
|
+
symbol if exponent == 1 else f"{symbol}{exponent}"
|
|
529
|
+
for symbol, exponent in zip(table.dimensions, unit.exponents, strict=True)
|
|
530
|
+
if exponent
|
|
531
|
+
]
|
|
532
|
+
return ".".join(parts) if parts else "1"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
COUNT_UNIT_CODE: Final = "{count}"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def is_count_unit(unit: Unit) -> bool:
|
|
539
|
+
"""Report whether a unit is the dimensionless count, however it was spelled."""
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
unit.dimensionless
|
|
543
|
+
and unit.factor == 1
|
|
544
|
+
and unit.offset == 0
|
|
545
|
+
and unit.annotations == ("count",)
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
__all__ = [
|
|
550
|
+
"COUNT_UNIT_CODE",
|
|
551
|
+
"MAX_ANNOTATION_LENGTH",
|
|
552
|
+
"MAX_UNIT_EXPONENT",
|
|
553
|
+
"MAX_UNIT_LENGTH",
|
|
554
|
+
"MAX_UNIT_MAGNITUDE_EXPONENT",
|
|
555
|
+
"MAX_UNIT_TERMS",
|
|
556
|
+
"NO_UNIT",
|
|
557
|
+
"UCUM_SUBSET_VERSION",
|
|
558
|
+
"Unit",
|
|
559
|
+
"UnitError",
|
|
560
|
+
"UnitTable",
|
|
561
|
+
"commensurable",
|
|
562
|
+
"conversion",
|
|
563
|
+
"dimension_signature",
|
|
564
|
+
"is_count_unit",
|
|
565
|
+
"is_declarable_unit",
|
|
566
|
+
"legacy_aliases",
|
|
567
|
+
"parse_unit",
|
|
568
|
+
"ratio_scale_factor",
|
|
569
|
+
"resolve_declared_unit",
|
|
570
|
+
"same_unit",
|
|
571
|
+
"unit_table",
|
|
572
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Human-facing surfaces for ``mr-data``.
|
|
2
|
+
|
|
3
|
+
Every command emits the same facts twice: as plain labelled lines by default and as one JSON
|
|
4
|
+
object under ``--json``. Both renderings are built from a single payload mapping, so a fact that
|
|
5
|
+
reaches one rendering reaches the other.
|
|
6
|
+
|
|
7
|
+
This package holds the rendering and the command registry only. It deliberately re-exports
|
|
8
|
+
nothing, so importing it stays cheap for the CLI startup path.
|
|
9
|
+
"""
|