diff --git a/packages/zarr-metadata/changes/4232.removal.md b/packages/zarr-metadata/changes/4232.removal.md new file mode 100644 index 0000000000..73b2a18666 --- /dev/null +++ b/packages/zarr-metadata/changes/4232.removal.md @@ -0,0 +1,63 @@ +Unified the naming grammar for SCREAMING_SNAKE constants with the one used for +type names. A constant's name is now a purely syntactic transformation of the +name of the `Literal` type it manifests, so the format version is spelled +`ZARR_V2`/`ZARR_V3` and comes first, matching the `ZarrV2`/`ZarrV3` prefix on +the corresponding type: + +- `ARRAY_METADATA_STORE_KEY_V2` → `ZARR_V2_ARRAY_METADATA_STORE_KEY` +- `ARRAY_METADATA_STORE_KEY_V3` → `ZARR_V3_ARRAY_METADATA_STORE_KEY` +- `ATTRIBUTES_STORE_KEY_V2` → `ZARR_V2_ATTRIBUTES_STORE_KEY` +- `GROUP_METADATA_STORE_KEY_V2` → `ZARR_V2_GROUP_METADATA_STORE_KEY` +- `GROUP_METADATA_STORE_KEY_V3` → `ZARR_V3_GROUP_METADATA_STORE_KEY` +- `CONSOLIDATED_METADATA_STORE_KEY_V2` → `ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY` +- `ARRAY_ORDER_V2` → `ZARR_V2_ARRAY_ORDER` +- `ARRAY_DIMENSION_SEPARATOR_V2` → `ZARR_V2_ARRAY_DIMENSION_SEPARATOR` +- `CONSOLIDATED_METADATA_KEY_V3` → `ZARR_V3_CONSOLIDATED_METADATA_KEY` + +The old names are removed, not aliased. This supersedes the 0.4.0 convention +under which type names put the format version first while constants put it +last: every constant that manifests a `Literal` type now follows the same rule +as that type. + +The last of those is the one rename the syntactic rule does not force: +`ZARR_V3_CONSOLIDATED_METADATA_KEY` manifests no `Literal` type, so it is +outside the rule and was renamed for consistency with its siblings. + +Digit runs stay glued to the token they follow, so spec vocabulary is +preserved: `Uint8DataTypeName` pairs with `UINT8_DATA_TYPE_NAME` (not +`UINT_8_...`) and `Crc32cCodecName` with `CRC32C_CODEC_NAME`. No dtype, codec, +chunk-grid, or chunk-key-encoding constant changed name. + +Constants that do not manifest a `Literal` type are outside the rule and are +unchanged: the `*_METADATA_*_KEYS_V2`/`_V3` key sets, the +`CANONICAL_*_HEX_FLOAT*` bit patterns, and `UNSET`. The key sets keep the +version-last spelling, so `zarr_metadata.model` exports both +`ARRAY_METADATA_REQUIRED_KEYS_V2` and `ZARR_V2_ARRAY_METADATA_STORE_KEY`. They +name validation policy rather than a spec document, have no paired type to +derive from, and renaming them would be a second breaking change buying only +cosmetic consistency — so it is deliberately deferred. + +`tests/test_public_api.py::test_constant_names_derive_from_their_type_names` +derives every constant name from the type it manifests and asserts they match, +so the two grammars cannot diverge again. + +Store keys also moved to the modules that describe the documents they name, +matching the package's layering (the `v2`/`v3` modules describe the specs; the +`model` layer is built on top of them). `ZARR_V2_ATTRIBUTES_STORE_KEY` now +lives in `zarr_metadata.v2.attributes` beside the `.zattrs` type it names, +rather than in the array model; the other five moved likewise, and +`ZarrV2AttributesStoreKey` is no longer an array-specific concept. +`zarr_metadata.model` re-exports all six, so +`from zarr_metadata.model import ZARR_V2_ARRAY_METADATA_STORE_KEY` is +unaffected. + +`CONSOLIDATED_METADATA_KEY_V3` moved to `zarr_metadata.v3.consolidated` and was +renamed to `ZARR_V3_CONSOLIDATED_METADATA_KEY` for consistency. It is not a +store key: unlike v2's `.zmetadata` file, v3 consolidated metadata is embedded +as an extension field inside the group's own `zarr.json`. + +All seven keys and the six store-key `Literal` aliases are now also exported +from the top-level `zarr_metadata` namespace, alongside the document types and +the rest of the spec vocabulary, so `from zarr_metadata import +ZARR_V2_ARRAY_METADATA_STORE_KEY` works. The model layer's validators, parsers, +type guards, and metadata key sets remain `zarr_metadata.model` imports. diff --git a/packages/zarr-metadata/docs/api/index.md b/packages/zarr-metadata/docs/api/index.md index 2aa39ab161..5e230c7aa2 100644 --- a/packages/zarr-metadata/docs/api/index.md +++ b/packages/zarr-metadata/docs/api/index.md @@ -17,9 +17,12 @@ The package is organized to mirror the structure of the Zarr specifications: [chunk key encodings](v3/chunk_key_encoding.md), [codecs](v3/codec.md), and [data types](v3/data_type.md) -Every public name is also re-exported at the top level, so +The document types, models, and spec vocabulary — including the store keys — +are re-exported at the top level, so `from zarr_metadata import ZarrV3ArrayMetadataJSON` and `from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON` are equivalent. +The model layer's validators, parsers, type guards, and metadata key sets are +imported from [`zarr_metadata.model`](model.md) directly. ## Common types diff --git a/packages/zarr-metadata/src/zarr_metadata/__init__.py b/packages/zarr-metadata/src/zarr_metadata/__init__.py index b5e52e976d..1a6b39f04d 100644 --- a/packages/zarr-metadata/src/zarr_metadata/__init__.py +++ b/packages/zarr-metadata/src/zarr_metadata/__init__.py @@ -3,25 +3,38 @@ from zarr_metadata._common import JSONValue, ZarrV3NamedConfigJSON from zarr_metadata.model import ( UNSET, + ZARR_V2_ARRAY_METADATA_STORE_KEY, + ZARR_V2_ATTRIBUTES_STORE_KEY, + ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY, + ZARR_V2_GROUP_METADATA_STORE_KEY, + ZARR_V3_ARRAY_METADATA_STORE_KEY, + ZARR_V3_CONSOLIDATED_METADATA_KEY, + ZARR_V3_GROUP_METADATA_STORE_KEY, MetadataValidationError, ProblemKind, ValidationProblem, ZarrV2ArrayMetadata, ZarrV2ArrayMetadataPartial, + ZarrV2ArrayMetadataStoreKey, + ZarrV2AttributesStoreKey, ZarrV2ConsolidatedMetadata, + ZarrV2ConsolidatedMetadataStoreKey, ZarrV2GroupMetadata, ZarrV2GroupMetadataPartial, + ZarrV2GroupMetadataStoreKey, ZarrV3ArrayMetadata, ZarrV3ArrayMetadataPartial, + ZarrV3ArrayMetadataStoreKey, ZarrV3ConsolidatedMetadata, ZarrV3GroupMetadata, ZarrV3GroupMetadataPartial, + ZarrV3GroupMetadataStoreKey, ZarrV3MetadataField, ZarrV3NamedConfig, ) from zarr_metadata.v2.array import ( - ARRAY_DIMENSION_SEPARATOR_V2, - ARRAY_ORDER_V2, + ZARR_V2_ARRAY_DIMENSION_SEPARATOR, + ZARR_V2_ARRAY_ORDER, ZarrV2ArrayDimensionSeparator, ZarrV2ArrayMetadataJSON, ZarrV2ArrayMetadataJSONPartial, @@ -217,8 +230,6 @@ __all__ = [ - "ARRAY_DIMENSION_SEPARATOR_V2", - "ARRAY_ORDER_V2", "BLOSC_CNAME", "BLOSC_CODEC_NAME", "BLOSC_SHUFFLE", @@ -260,6 +271,15 @@ "UNSET", "V2_CHUNK_KEY_ENCODING_NAME", "V2_CHUNK_KEY_ENCODING_SEPARATOR", + "ZARR_V2_ARRAY_DIMENSION_SEPARATOR", + "ZARR_V2_ARRAY_METADATA_STORE_KEY", + "ZARR_V2_ARRAY_ORDER", + "ZARR_V2_ATTRIBUTES_STORE_KEY", + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZARR_V2_GROUP_METADATA_STORE_KEY", + "ZARR_V3_ARRAY_METADATA_STORE_KEY", + "ZARR_V3_CONSOLIDATED_METADATA_KEY", + "ZARR_V3_GROUP_METADATA_STORE_KEY", "ZSTD_CODEC_NAME", "BloscCName", "BloscCodecMetadata", @@ -343,15 +363,19 @@ "ZarrV2ArrayMetadataJSON", "ZarrV2ArrayMetadataJSONPartial", "ZarrV2ArrayMetadataPartial", + "ZarrV2ArrayMetadataStoreKey", "ZarrV2ArrayOrder", + "ZarrV2AttributesStoreKey", "ZarrV2CodecMetadata", "ZarrV2ConsolidatedMetadata", "ZarrV2ConsolidatedMetadataJSON", + "ZarrV2ConsolidatedMetadataStoreKey", "ZarrV2DataTypeMetadata", "ZarrV2GroupMetadata", "ZarrV2GroupMetadataJSON", "ZarrV2GroupMetadataJSONPartial", "ZarrV2GroupMetadataPartial", + "ZarrV2GroupMetadataStoreKey", "ZarrV2ZArrayJSON", "ZarrV2ZAttrsJSON", "ZarrV2ZGroupJSON", @@ -359,6 +383,7 @@ "ZarrV3ArrayMetadataJSON", "ZarrV3ArrayMetadataJSONPartial", "ZarrV3ArrayMetadataPartial", + "ZarrV3ArrayMetadataStoreKey", "ZarrV3ConsolidatedMetadata", "ZarrV3ConsolidatedMetadataJSON", "ZarrV3ExtensionField", @@ -366,6 +391,7 @@ "ZarrV3GroupMetadataJSON", "ZarrV3GroupMetadataJSONPartial", "ZarrV3GroupMetadataPartial", + "ZarrV3GroupMetadataStoreKey", "ZarrV3MetadataField", "ZarrV3MetadataFieldJSON", "ZarrV3NamedConfig", diff --git a/packages/zarr-metadata/src/zarr_metadata/model/__init__.py b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py index e726c54d3e..edf3561d1d 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/__init__.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py @@ -12,33 +12,20 @@ """ from zarr_metadata.model._array import ( - ARRAY_METADATA_STORE_KEY_V2, - ARRAY_METADATA_STORE_KEY_V3, - ATTRIBUTES_STORE_KEY_V2, ZarrV2ArrayMetadata, ZarrV2ArrayMetadataPartial, - ZarrV2ArrayMetadataStoreKey, - ZarrV2AttributesStoreKey, ZarrV3ArrayMetadata, ZarrV3ArrayMetadataPartial, - ZarrV3ArrayMetadataStoreKey, ZarrV3MetadataField, ZarrV3NamedConfig, ) from zarr_metadata.model._group import ( - CONSOLIDATED_METADATA_KEY_V3, - CONSOLIDATED_METADATA_STORE_KEY_V2, - GROUP_METADATA_STORE_KEY_V2, - GROUP_METADATA_STORE_KEY_V3, ZarrV2ConsolidatedMetadata, - ZarrV2ConsolidatedMetadataStoreKey, ZarrV2GroupMetadata, ZarrV2GroupMetadataPartial, - ZarrV2GroupMetadataStoreKey, ZarrV3ConsolidatedMetadata, ZarrV3GroupMetadata, ZarrV3GroupMetadataPartial, - ZarrV3GroupMetadataStoreKey, ) from zarr_metadata.model._sentinel import UNSET from zarr_metadata.model._validation import ( @@ -73,23 +60,52 @@ validate_metadata_field_v3, ) +# Store keys are facts about the on-disk specs, so they are defined in the +# `v2`/`v3` modules that describe those documents. They are re-exported here +# because the model layer is where consumers reach for them. +from zarr_metadata.v2.array import ( + ZARR_V2_ARRAY_METADATA_STORE_KEY, + ZarrV2ArrayMetadataStoreKey, +) +from zarr_metadata.v2.attributes import ( + ZARR_V2_ATTRIBUTES_STORE_KEY, + ZarrV2AttributesStoreKey, +) +from zarr_metadata.v2.consolidated import ( + ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY, + ZarrV2ConsolidatedMetadataStoreKey, +) +from zarr_metadata.v2.group import ( + ZARR_V2_GROUP_METADATA_STORE_KEY, + ZarrV2GroupMetadataStoreKey, +) +from zarr_metadata.v3.array import ( + ZARR_V3_ARRAY_METADATA_STORE_KEY, + ZarrV3ArrayMetadataStoreKey, +) +from zarr_metadata.v3.consolidated import ZARR_V3_CONSOLIDATED_METADATA_KEY +from zarr_metadata.v3.group import ( + ZARR_V3_GROUP_METADATA_STORE_KEY, + ZarrV3GroupMetadataStoreKey, +) + __all__ = [ "ARRAY_METADATA_OPTIONAL_KEYS_V3", "ARRAY_METADATA_REQUIRED_KEYS_V2", "ARRAY_METADATA_REQUIRED_KEYS_V3", "ARRAY_METADATA_STANDARD_KEYS_V3", - "ARRAY_METADATA_STORE_KEY_V2", - "ARRAY_METADATA_STORE_KEY_V3", - "ATTRIBUTES_STORE_KEY_V2", - "CONSOLIDATED_METADATA_KEY_V3", - "CONSOLIDATED_METADATA_STORE_KEY_V2", "GROUP_METADATA_OPTIONAL_KEYS_V3", "GROUP_METADATA_REQUIRED_KEYS_V2", "GROUP_METADATA_REQUIRED_KEYS_V3", "GROUP_METADATA_STANDARD_KEYS_V3", - "GROUP_METADATA_STORE_KEY_V2", - "GROUP_METADATA_STORE_KEY_V3", "UNSET", + "ZARR_V2_ARRAY_METADATA_STORE_KEY", + "ZARR_V2_ATTRIBUTES_STORE_KEY", + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZARR_V2_GROUP_METADATA_STORE_KEY", + "ZARR_V3_ARRAY_METADATA_STORE_KEY", + "ZARR_V3_CONSOLIDATED_METADATA_KEY", + "ZARR_V3_GROUP_METADATA_STORE_KEY", "MetadataValidationError", "ProblemKind", "ValidationProblem", diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_array.py b/packages/zarr-metadata/src/zarr_metadata/model/_array.py index c4c967f891..0b562bc188 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_array.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_array.py @@ -6,7 +6,7 @@ import dataclasses from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast +from typing import TYPE_CHECKING, Literal, TypeAlias, cast from typing_extensions import TypedDict, Unpack @@ -22,27 +22,27 @@ parse_array_metadata_v3, parse_metadata_field_v3, ) +from zarr_metadata.v2.array import ZARR_V2_ARRAY_METADATA_STORE_KEY +from zarr_metadata.v2.attributes import ZARR_V2_ATTRIBUTES_STORE_KEY +from zarr_metadata.v3.array import ZARR_V3_ARRAY_METADATA_STORE_KEY if TYPE_CHECKING: from zarr_metadata._common import JSONValue, ZarrV3NamedConfigJSON from zarr_metadata.v2.array import ( ZarrV2ArrayDimensionSeparator, ZarrV2ArrayMetadataJSON, + ZarrV2ArrayMetadataStoreKey, ZarrV2ArrayOrder, ZarrV2DataTypeMetadata, ) + from zarr_metadata.v2.attributes import ZarrV2AttributesStoreKey from zarr_metadata.v2.codec import ZarrV2CodecMetadata from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON - from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON, ZarrV3ExtensionField - -ZarrV3ArrayMetadataStoreKey = Literal["zarr.json"] -ARRAY_METADATA_STORE_KEY_V3: Final[ZarrV3ArrayMetadataStoreKey] = "zarr.json" - -ZarrV2ArrayMetadataStoreKey = Literal[".zarray"] -ARRAY_METADATA_STORE_KEY_V2: Final[ZarrV2ArrayMetadataStoreKey] = ".zarray" - -ZarrV2AttributesStoreKey = Literal[".zattrs"] -ATTRIBUTES_STORE_KEY_V2: Final[ZarrV2AttributesStoreKey] = ".zattrs" + from zarr_metadata.v3.array import ( + ZarrV3ArrayMetadataJSON, + ZarrV3ArrayMetadataStoreKey, + ZarrV3ExtensionField, + ) @dataclass(frozen=True, slots=True, kw_only=True) @@ -319,12 +319,12 @@ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3ArrayMetadata: - return cls.from_json(load_store_json(mapping, ARRAY_METADATA_STORE_KEY_V3)) + return cls.from_json(load_store_json(mapping, ZARR_V3_ARRAY_METADATA_STORE_KEY)) def to_key_value( self, *, indent: int | str | None = None ) -> Mapping[ZarrV3ArrayMetadataStoreKey, bytes]: - return {ARRAY_METADATA_STORE_KEY_V3: dump_store_json(self.to_json(), indent=indent)} + return {ZARR_V3_ARRAY_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)} class ZarrV2ArrayMetadataPartial(TypedDict, total=False): @@ -464,7 +464,7 @@ def from_json(cls, data: object) -> ZarrV2ArrayMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: - zarray_raw = cast("object", load_store_json(mapping, ARRAY_METADATA_STORE_KEY_V2)) + zarray_raw = cast("object", load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY)) if not isinstance(zarray_raw, Mapping): return cls.from_json(zarray_raw) zarray = cast("Mapping[str, object]", zarray_raw) @@ -478,8 +478,8 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: ) ] ) - if ATTRIBUTES_STORE_KEY_V2 in mapping: - zattrs = cast("object", load_store_json(mapping, ATTRIBUTES_STORE_KEY_V2)) + if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping: + zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)) return cls.from_json({**zarray, "attributes": zattrs}) return cls.from_json(zarray) @@ -491,8 +491,8 @@ def to_key_value( # when attributes are set (even empty) — UNSET emits no file. zarray = {k: v for k, v in self.to_json().items() if k != "attributes"} out: dict[ZarrV2ArrayMetadataStoreKey | ZarrV2AttributesStoreKey, bytes] = { - ARRAY_METADATA_STORE_KEY_V2: dump_store_json(zarray, indent=indent) + ZARR_V2_ARRAY_METADATA_STORE_KEY: dump_store_json(zarray, indent=indent) } if self.attributes is not UNSET: - out[ATTRIBUTES_STORE_KEY_V2] = dump_store_json(self.attributes, indent=indent) + out[ZARR_V2_ATTRIBUTES_STORE_KEY] = dump_store_json(self.attributes, indent=indent) return out diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_group.py b/packages/zarr-metadata/src/zarr_metadata/model/_group.py index d576833c26..63dfe5611f 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_group.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_group.py @@ -6,12 +6,11 @@ import dataclasses from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Final, Literal, cast +from typing import TYPE_CHECKING, Literal, cast from typing_extensions import TypedDict, Unpack from zarr_metadata.model._array import ( - ATTRIBUTES_STORE_KEY_V2, ZarrV3ArrayMetadata, must_understand_subset, ) @@ -28,28 +27,20 @@ validate_consolidated_metadata_v3, validate_json, ) +from zarr_metadata.v2.attributes import ZARR_V2_ATTRIBUTES_STORE_KEY +from zarr_metadata.v2.consolidated import ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY +from zarr_metadata.v2.group import ZARR_V2_GROUP_METADATA_STORE_KEY +from zarr_metadata.v3.consolidated import ZARR_V3_CONSOLIDATED_METADATA_KEY +from zarr_metadata.v3.group import ZARR_V3_GROUP_METADATA_STORE_KEY if TYPE_CHECKING: from zarr_metadata._common import JSONValue - from zarr_metadata.model._array import ZarrV2AttributesStoreKey - from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON + from zarr_metadata.v2.attributes import ZarrV2AttributesStoreKey + from zarr_metadata.v2.consolidated import ZarrV2ConsolidatedMetadataStoreKey + from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON, ZarrV2GroupMetadataStoreKey from zarr_metadata.v3.array import ZarrV3ExtensionField from zarr_metadata.v3.consolidated import ZarrV3ConsolidatedMetadataJSON - from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON - -ZarrV3GroupMetadataStoreKey = Literal["zarr.json"] -GROUP_METADATA_STORE_KEY_V3: Final[ZarrV3GroupMetadataStoreKey] = "zarr.json" - -ZarrV2GroupMetadataStoreKey = Literal[".zgroup"] -GROUP_METADATA_STORE_KEY_V2: Final[ZarrV2GroupMetadataStoreKey] = ".zgroup" - -ZarrV2ConsolidatedMetadataStoreKey = Literal[".zmetadata"] -CONSOLIDATED_METADATA_STORE_KEY_V2: Final[ZarrV2ConsolidatedMetadataStoreKey] = ".zmetadata" - -# The key under which consolidated metadata is embedded in a v3 group document. -# This is a reference-implementation convention (not a spec artifact), stored -# as an extension field on the group's `zarr.json`. -CONSOLIDATED_METADATA_KEY_V3: Final = "consolidated_metadata" + from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON, ZarrV3GroupMetadataStoreKey class ZarrV3GroupMetadataPartial(TypedDict, total=False): @@ -88,7 +79,7 @@ class ZarrV3GroupMetadata: extra_fields: dict[str, ZarrV3ExtensionField] def __post_init__(self) -> None: - reserved = GROUP_METADATA_STANDARD_KEYS_V3 | {CONSOLIDATED_METADATA_KEY_V3} + reserved = GROUP_METADATA_STANDARD_KEYS_V3 | {ZARR_V3_CONSOLIDATED_METADATA_KEY} if set(self.extra_fields.keys()).intersection(reserved): raise MetadataValidationError( [ @@ -134,7 +125,7 @@ def to_json(self) -> ZarrV3GroupMetadataJSON: out["attributes"] = copy.deepcopy(self.attributes) if self.consolidated_metadata is not UNSET: # Consolidated metadata is a known non-core top-level JSON field. - out[CONSOLIDATED_METADATA_KEY_V3] = cast( + out[ZARR_V3_CONSOLIDATED_METADATA_KEY] = cast( "ZarrV3ExtensionField", self.consolidated_metadata.to_json() ) for key, value in self.extra_fields.items(): @@ -145,7 +136,7 @@ def to_json(self) -> ZarrV3GroupMetadataJSON: def from_json(cls, data: object) -> ZarrV3GroupMetadata: parsed = parse_group_metadata_v3(arrays_to_tuples(data)) # Cast for narrowing across standard and arbitrary extra TypedDict items. - consolidated_raw = cast("object", parsed.get(CONSOLIDATED_METADATA_KEY_V3, UNSET)) + consolidated_raw = cast("object", parsed.get(ZARR_V3_CONSOLIDATED_METADATA_KEY, UNSET)) consolidated: ZarrV3ConsolidatedMetadata | UNSET if consolidated_raw is UNSET or consolidated_raw is None: # consolidated_metadata: null was written by a historical @@ -162,7 +153,8 @@ def from_json(cls, data: object) -> ZarrV3GroupMetadata: { k: v for k, v in parsed.items() - if k not in GROUP_METADATA_STANDARD_KEYS_V3 and k != CONSOLIDATED_METADATA_KEY_V3 + if k not in GROUP_METADATA_STANDARD_KEYS_V3 + and k != ZARR_V3_CONSOLIDATED_METADATA_KEY }, ) return cls( @@ -185,12 +177,12 @@ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3GroupMetadata: - return cls.from_json(load_store_json(mapping, GROUP_METADATA_STORE_KEY_V3)) + return cls.from_json(load_store_json(mapping, ZARR_V3_GROUP_METADATA_STORE_KEY)) def to_key_value( self, *, indent: int | str | None = None ) -> Mapping[ZarrV3GroupMetadataStoreKey, bytes]: - return {GROUP_METADATA_STORE_KEY_V3: dump_store_json(self.to_json(), indent=indent)} + return {ZARR_V3_GROUP_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)} @dataclass(frozen=True, slots=True, kw_only=True) @@ -322,7 +314,7 @@ def from_json(cls, data: object) -> ZarrV2GroupMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: - zgroup_raw = cast("object", load_store_json(mapping, GROUP_METADATA_STORE_KEY_V2)) + zgroup_raw = cast("object", load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY)) if not isinstance(zgroup_raw, Mapping): return cls.from_json(zgroup_raw) zgroup = cast("Mapping[str, object]", zgroup_raw) @@ -336,8 +328,8 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: ) ] ) - if ATTRIBUTES_STORE_KEY_V2 in mapping: - zattrs = cast("object", load_store_json(mapping, ATTRIBUTES_STORE_KEY_V2)) + if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping: + zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)) return cls.from_json({**zgroup, "attributes": zattrs}) return cls.from_json(zgroup) @@ -349,10 +341,10 @@ def to_key_value( # when attributes are set (even empty) — UNSET emits no file. zgroup = {k: v for k, v in self.to_json().items() if k != "attributes"} out: dict[ZarrV2GroupMetadataStoreKey | ZarrV2AttributesStoreKey, bytes] = { - GROUP_METADATA_STORE_KEY_V2: dump_store_json(zgroup, indent=indent) + ZARR_V2_GROUP_METADATA_STORE_KEY: dump_store_json(zgroup, indent=indent) } if self.attributes is not UNSET: - out[ATTRIBUTES_STORE_KEY_V2] = dump_store_json(self.attributes, indent=indent) + out[ZARR_V2_ATTRIBUTES_STORE_KEY] = dump_store_json(self.attributes, indent=indent) return out @@ -434,9 +426,11 @@ def from_json(cls, data: object) -> ZarrV2ConsolidatedMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ConsolidatedMetadata: - return cls.from_json(load_store_json(mapping, CONSOLIDATED_METADATA_STORE_KEY_V2)) + return cls.from_json(load_store_json(mapping, ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY)) def to_key_value( self, *, indent: int | str | None = None ) -> Mapping[ZarrV2ConsolidatedMetadataStoreKey, bytes]: - return {CONSOLIDATED_METADATA_STORE_KEY_V2: dump_store_json(self.to_json(), indent=indent)} + return { + ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent) + } diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/array.py b/packages/zarr-metadata/src/zarr_metadata/v2/array.py index 84b6446bcb..e026e5c655 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/array.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/array.py @@ -39,7 +39,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html """ -ARRAY_ORDER_V2: Final = ("C", "F") +ZARR_V2_ARRAY_ORDER: Final = ("C", "F") """Tuple of permitted values for the `order` field of v2 array metadata.""" ZarrV2ArrayDimensionSeparator = Literal[".", "/"] @@ -51,7 +51,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html """ -ARRAY_DIMENSION_SEPARATOR_V2: Final = (".", "/") +ZARR_V2_ARRAY_DIMENSION_SEPARATOR: Final = (".", "/") """Tuple of permitted values for the `dimension_separator` field of v2 array metadata.""" @@ -149,12 +149,21 @@ class ZarrV2ArrayMetadataJSONPartial(TypedDict, total=False): """ +ZarrV2ArrayMetadataStoreKey = Literal[".zarray"] +"""Literal type of the store key holding a v2 array's metadata document.""" + +ZARR_V2_ARRAY_METADATA_STORE_KEY: Final[ZarrV2ArrayMetadataStoreKey] = ".zarray" +"""The store key a v2 array's metadata document is persisted under.""" + + __all__ = [ - "ARRAY_DIMENSION_SEPARATOR_V2", - "ARRAY_ORDER_V2", + "ZARR_V2_ARRAY_DIMENSION_SEPARATOR", + "ZARR_V2_ARRAY_METADATA_STORE_KEY", + "ZARR_V2_ARRAY_ORDER", "ZarrV2ArrayDimensionSeparator", "ZarrV2ArrayMetadataJSON", "ZarrV2ArrayMetadataJSONPartial", + "ZarrV2ArrayMetadataStoreKey", "ZarrV2ArrayOrder", "ZarrV2DataTypeMetadata", "ZarrV2ZArrayJSON", diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py b/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py index f7cc31babe..68785d1660 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py @@ -4,6 +4,7 @@ """ from collections.abc import Mapping +from typing import Final, Literal from zarr_metadata._common import JSONValue @@ -17,6 +18,19 @@ """ +ZarrV2AttributesStoreKey = Literal[".zattrs"] +"""Literal type of the store key holding a v2 node's user attributes.""" + +ZARR_V2_ATTRIBUTES_STORE_KEY: Final[ZarrV2AttributesStoreKey] = ".zattrs" +"""The store key a v2 node's user attributes are persisted under. + +Shared by arrays and groups: both node types keep their attributes in a +sibling `.zattrs` file. +""" + + __all__ = [ + "ZARR_V2_ATTRIBUTES_STORE_KEY", + "ZarrV2AttributesStoreKey", "ZarrV2ZAttrsJSON", ] diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py index 6b586bb92e..999c9131da 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py @@ -7,6 +7,7 @@ """ from collections.abc import Mapping +from typing import Final, Literal from typing_extensions import TypedDict @@ -37,6 +38,19 @@ class ZarrV2ConsolidatedMetadataJSON(TypedDict): metadata: Mapping[str, ZarrV2ZArrayJSON | ZarrV2ZGroupJSON | ZarrV2ZAttrsJSON] +ZarrV2ConsolidatedMetadataStoreKey = Literal[".zmetadata"] +"""Literal type of the store key holding a v2 hierarchy's consolidated metadata.""" + +ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY: Final[ZarrV2ConsolidatedMetadataStoreKey] = ".zmetadata" +"""The store key a v2 hierarchy's consolidated metadata is persisted under. + +Like the document it names, this is a reference-implementation convention +rather than a spec artifact; see the module docstring. +""" + + __all__ = [ + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", "ZarrV2ConsolidatedMetadataJSON", + "ZarrV2ConsolidatedMetadataStoreKey", ] diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/group.py b/packages/zarr-metadata/src/zarr_metadata/v2/group.py index 50f2482e6f..34d72742c2 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/group.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/group.py @@ -4,7 +4,7 @@ """ from collections.abc import Mapping -from typing import Literal, NotRequired +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -74,8 +74,17 @@ class ZarrV2GroupMetadataJSONPartial(TypedDict, total=False): attributes: NotRequired[Mapping[str, JSONValue]] +ZarrV2GroupMetadataStoreKey = Literal[".zgroup"] +"""Literal type of the store key holding a v2 group's metadata document.""" + +ZARR_V2_GROUP_METADATA_STORE_KEY: Final[ZarrV2GroupMetadataStoreKey] = ".zgroup" +"""The store key a v2 group's metadata document is persisted under.""" + + __all__ = [ + "ZARR_V2_GROUP_METADATA_STORE_KEY", "ZarrV2GroupMetadataJSON", "ZarrV2GroupMetadataJSONPartial", + "ZarrV2GroupMetadataStoreKey", "ZarrV2ZGroupJSON", ] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/array.py b/packages/zarr-metadata/src/zarr_metadata/v3/array.py index 96341f73ca..31a5f6b755 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/array.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/array.py @@ -1,7 +1,7 @@ """Zarr v3 array metadata types.""" from collections.abc import Mapping -from typing import Literal, NotRequired, TypeAlias +from typing import Final, Literal, NotRequired, TypeAlias from typing_extensions import TypedDict @@ -75,8 +75,21 @@ class ZarrV3ArrayMetadataJSONPartial(TypedDict, total=False, extra_items=ZarrV3E dimension_names: NotRequired[tuple[str | None, ...]] +ZarrV3ArrayMetadataStoreKey = Literal["zarr.json"] +"""Literal type of the store key holding a v3 array's metadata document.""" + +ZARR_V3_ARRAY_METADATA_STORE_KEY: Final[ZarrV3ArrayMetadataStoreKey] = "zarr.json" +"""The store key a v3 array's metadata document is persisted under. + +v3 uses one key for both node types; the document's `node_type` field +distinguishes an array from a group. +""" + + __all__ = [ + "ZARR_V3_ARRAY_METADATA_STORE_KEY", "ZarrV3ArrayMetadataJSON", "ZarrV3ArrayMetadataJSONPartial", + "ZarrV3ArrayMetadataStoreKey", "ZarrV3ExtensionField", ] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py index bcbe675947..a9fe0c1f8f 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py @@ -12,7 +12,7 @@ """ from collections.abc import Mapping -from typing import Literal +from typing import Final, Literal from typing_extensions import TypedDict @@ -34,6 +34,16 @@ class ZarrV3ConsolidatedMetadataJSON(TypedDict): metadata: Mapping[str, ZarrV3ArrayMetadataJSON | ZarrV3GroupMetadataJSON] +ZARR_V3_CONSOLIDATED_METADATA_KEY: Final = "consolidated_metadata" +"""The key under which consolidated metadata is embedded in a v3 group document. + +Unlike the v2 `.zmetadata` file, this is not a store key: consolidated metadata +is carried as an extension field inside the group's own `zarr.json`. Like its v2 +counterpart it is a reference-implementation convention, not a spec artifact. +""" + + __all__ = [ + "ZARR_V3_CONSOLIDATED_METADATA_KEY", "ZarrV3ConsolidatedMetadataJSON", ] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/group.py b/packages/zarr-metadata/src/zarr_metadata/v3/group.py index 033e91ff8c..37bfdd6934 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/group.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/group.py @@ -4,7 +4,7 @@ """ from collections.abc import Mapping -from typing import Literal, NotRequired +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -54,7 +54,20 @@ class ZarrV3GroupMetadataJSONPartial(TypedDict, total=False, extra_items=ZarrV3E attributes: NotRequired[Mapping[str, JSONValue]] +ZarrV3GroupMetadataStoreKey = Literal["zarr.json"] +"""Literal type of the store key holding a v3 group's metadata document.""" + +ZARR_V3_GROUP_METADATA_STORE_KEY: Final[ZarrV3GroupMetadataStoreKey] = "zarr.json" +"""The store key a v3 group's metadata document is persisted under. + +v3 uses one key for both node types; the document's `node_type` field +distinguishes a group from an array. +""" + + __all__ = [ + "ZARR_V3_GROUP_METADATA_STORE_KEY", "ZarrV3GroupMetadataJSON", "ZarrV3GroupMetadataJSONPartial", + "ZarrV3GroupMetadataStoreKey", ] diff --git a/packages/zarr-metadata/tests/model/test_array.py b/packages/zarr-metadata/tests/model/test_array.py index 467ef1e2ad..95dc7aea3a 100644 --- a/packages/zarr-metadata/tests/model/test_array.py +++ b/packages/zarr-metadata/tests/model/test_array.py @@ -64,25 +64,71 @@ def test_guards_exported_from_package() -> None: assert hasattr(zarr_metadata.model, name) +# `ZARR_V3_CONSOLIDATED_METADATA_KEY` is deliberately absent: it names a key +# *inside* a v3 group document, not a store key, so it has no paired `Literal` +# and no `to_key_value` signature to appear in. See `test_v3_consolidated_key_ +# is_not_a_store_key`, which pins that distinction. +STORE_KEY_PAIRS = [ + ("ZARR_V2_ARRAY_METADATA_STORE_KEY", "ZarrV2ArrayMetadataStoreKey", "zarr_metadata.v2.array"), + ("ZARR_V3_ARRAY_METADATA_STORE_KEY", "ZarrV3ArrayMetadataStoreKey", "zarr_metadata.v3.array"), + ("ZARR_V2_ATTRIBUTES_STORE_KEY", "ZarrV2AttributesStoreKey", "zarr_metadata.v2.attributes"), + ("ZARR_V2_GROUP_METADATA_STORE_KEY", "ZarrV2GroupMetadataStoreKey", "zarr_metadata.v2.group"), + ("ZARR_V3_GROUP_METADATA_STORE_KEY", "ZarrV3GroupMetadataStoreKey", "zarr_metadata.v3.group"), + ( + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZarrV2ConsolidatedMetadataStoreKey", + "zarr_metadata.v2.consolidated", + ), +] + + def test_store_key_pairs_exported_from_package() -> None: """Each store-key constant is exported together with its Literal type alias, and the pair cannot drift apart.""" import zarr_metadata.model as m - pairs = [ - ("ARRAY_METADATA_STORE_KEY_V2", "ZarrV2ArrayMetadataStoreKey"), - ("ARRAY_METADATA_STORE_KEY_V3", "ZarrV3ArrayMetadataStoreKey"), - ("ATTRIBUTES_STORE_KEY_V2", "ZarrV2AttributesStoreKey"), - ("GROUP_METADATA_STORE_KEY_V2", "ZarrV2GroupMetadataStoreKey"), - ("GROUP_METADATA_STORE_KEY_V3", "ZarrV3GroupMetadataStoreKey"), - ("CONSOLIDATED_METADATA_STORE_KEY_V2", "ZarrV2ConsolidatedMetadataStoreKey"), - ] - for const_name, alias_name in pairs: + for const_name, alias_name, _ in STORE_KEY_PAIRS: assert const_name in m.__all__ assert alias_name in m.__all__ assert (getattr(m, const_name),) == get_args(getattr(m, alias_name)) +def test_store_keys_are_defined_in_their_spec_modules() -> None: + """Store keys are facts about the on-disk specs, so each is defined in the + `v2`/`v3` module describing that document — not in the model layer, which + only re-exports them.""" + import importlib + + for const_name, alias_name, module_name in STORE_KEY_PAIRS: + module = importlib.import_module(module_name) + for name in (const_name, alias_name): + assert name in module.__all__, f"{name} should be exported by {module_name}" + + +def test_v3_consolidated_key_is_not_a_store_key() -> None: + """v3 consolidated metadata is embedded as a field inside the group's own + `zarr.json`, not persisted under its own store key. It therefore has no + paired `Literal` alias, unlike every true store key — which is why it is + excluded from `STORE_KEY_PAIRS` rather than merely forgotten.""" + import zarr_metadata.model as m + + assert "ZARR_V3_CONSOLIDATED_METADATA_KEY" in m.__all__ + assert not hasattr(m, "ZarrV3ConsolidatedMetadataKey") + assert m.ZARR_V3_CONSOLIDATED_METADATA_KEY not in { + getattr(m, const_name) for const_name, _, _ in STORE_KEY_PAIRS + } + + +def test_v3_node_store_keys_agree() -> None: + """v3 keys both node types' metadata under one store key, distinguished by + the document's `node_type`. The array and group constants are separately + typed but must name the same file; adjacency used to make that obvious, and + they now live in different modules.""" + import zarr_metadata.model as m + + assert m.ZARR_V3_ARRAY_METADATA_STORE_KEY == m.ZARR_V3_GROUP_METADATA_STORE_KEY + + def test_validation_diagnostics_exported_from_package() -> None: """The validation-diagnostic types and validators are exported from the package.""" import zarr_metadata.model diff --git a/packages/zarr-metadata/tests/test_public_api.py b/packages/zarr-metadata/tests/test_public_api.py index e65c680fd1..6613aa394b 100644 --- a/packages/zarr-metadata/tests/test_public_api.py +++ b/packages/zarr-metadata/tests/test_public_api.py @@ -3,7 +3,7 @@ import importlib import pkgutil import re -from typing import get_args +from typing import Literal, get_args, get_origin import zarr_metadata as zm @@ -58,6 +58,23 @@ def _group_rank(s: str) -> int: "MetadataValidationError", "ProblemKind", "UNSET", + # Store keys — the names the documents are persisted under. Defined in the + # v2/v3 spec modules, re-exported through `zarr_metadata.model`. + "ZARR_V2_ARRAY_METADATA_STORE_KEY", + "ZarrV2ArrayMetadataStoreKey", + "ZARR_V2_GROUP_METADATA_STORE_KEY", + "ZarrV2GroupMetadataStoreKey", + "ZARR_V2_ATTRIBUTES_STORE_KEY", + "ZarrV2AttributesStoreKey", + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZarrV2ConsolidatedMetadataStoreKey", + "ZARR_V3_ARRAY_METADATA_STORE_KEY", + "ZarrV3ArrayMetadataStoreKey", + "ZARR_V3_GROUP_METADATA_STORE_KEY", + "ZarrV3GroupMetadataStoreKey", + # Not a store key: v3 consolidated metadata is embedded in the group's own + # `zarr.json`, so it has no paired Literal alias. + "ZARR_V3_CONSOLIDATED_METADATA_KEY", # v2 data-type encoding union "ZarrV2DataTypeMetadata", # Category B — codec canonical unions @@ -147,9 +164,9 @@ def _group_rank(s: str) -> int: "RawBytesDataTypeName", "RawBytesFillValue", # Category E — constant+Literal pairs - "ARRAY_ORDER_V2", + "ZARR_V2_ARRAY_ORDER", "ZarrV2ArrayOrder", - "ARRAY_DIMENSION_SEPARATOR_V2", + "ZARR_V2_ARRAY_DIMENSION_SEPARATOR", "ZarrV2ArrayDimensionSeparator", "ENDIANNESS", "Endianness", @@ -285,14 +302,19 @@ def test_all_is_grouped_and_unique() -> None: ) -def _public_type_names() -> set[tuple[str, str]]: - """Every (module, CamelCase name) pair exported via a public `__all__`.""" +def _iter_module_names() -> set[str]: + """Every public module in the package, including the top-level namespace.""" module_names = {"zarr_metadata"} for info in pkgutil.walk_packages(zm.__path__, prefix="zarr_metadata."): if not any(part.startswith("_") for part in info.name.split(".")[1:]): module_names.add(info.name) + return module_names + + +def _public_type_names() -> set[tuple[str, str]]: + """Every (module, CamelCase name) pair exported via a public `__all__`.""" out: set[tuple[str, str]] = set() - for module_name in module_names: + for module_name in _iter_module_names(): module = importlib.import_module(module_name) for name in getattr(module, "__all__", ()): if name.startswith("_") or name.isupper() or name.islower(): @@ -323,6 +345,8 @@ def test_standalone_vocab_is_not_stale() -> None: def test_promoted_pairs_drift() -> None: + """Each promoted runtime constant holds exactly the values of the `Literal` + type it manifests, so the two cannot drift apart.""" pairs = [ (zm.ENDIANNESS, zm.Endianness), (zm.BLOSC_CNAME, zm.BloscCName), @@ -331,7 +355,134 @@ def test_promoted_pairs_drift() -> None: (zm.NUMPY_TIME_UNIT, zm.NumpyTimeUnit), (zm.CAST_ROUNDING_MODE, zm.CastRoundingMode), (zm.CAST_OUT_OF_RANGE_MODE, zm.CastOutOfRangeMode), - (zm.ARRAY_ORDER_V2, zm.ZarrV2ArrayOrder), + (zm.ZARR_V2_ARRAY_ORDER, zm.ZarrV2ArrayOrder), + (zm.ZARR_V2_ARRAY_DIMENSION_SEPARATOR, zm.ZarrV2ArrayDimensionSeparator), + (zm.DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR, zm.DefaultChunkKeyEncodingSeparator), + (zm.V2_CHUNK_KEY_ENCODING_SEPARATOR, zm.V2ChunkKeyEncodingSeparator), ] for const, lit in pairs: assert set(const) == set(get_args(lit)) + + +def constant_name_for(type_name: str) -> str: + """Derive a constant's name from the name of the type it manifests. + + The transformation is purely syntactic: split at each lowercase-to-uppercase + boundary and before an uppercase run that starts a new word, then uppercase. + Digit runs stay glued to the token they follow (`Uint8` -> `UINT8`, + `Crc32c` -> `CRC32C`), because a digit boundary in CamelCase does not mark a + word boundary in the spec vocabulary these names model. + + Consecutive capitals do not split, so acronym-adjacent names derive badly: + `ZarrV2ZArrayJSON` -> `ZARR_V2ZARRAY_JSON` and `...JSONPartial` -> + `...JSONPARTIAL`. Every such name in the package today is a `TypedDict` or + `TypeAliasType` that backs no constant, so none reaches this function — but + a future `Literal` spelled that way would silently be held to a bad name. + Splitting acronyms correctly needs a vocabulary, not a regex, so the rule + stays syntactic and this stays a known limit. + """ + return re.sub(r"(?<=[a-z0-9])(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])", "_", type_name).upper() + + +def _literal_backed_constants() -> list[tuple[str, str, str]]: + """Every (module, constant, type) triple where a module-level SCREAMING_SNAKE + constant holds exactly the values of a `Literal` type in the same module. + + Pairing is by value, not by proximity: a constant manifests the type whose + members it enumerates. Constants with no such type (extension-field keys, + key sets, canonical bit patterns) are exempt from the naming rule and are + simply absent from the result. + """ + out: list[tuple[str, str, str]] = [] + for module_name in _iter_module_names(): + module = importlib.import_module(module_name) + literals = { + name: frozenset(get_args(obj)) + for name, obj in vars(module).items() + if not name.startswith("_") + and not name.isupper() + and get_origin(obj) is Literal + and get_args(obj) + } + if not literals: + continue + for const_name, value in vars(module).items(): + if const_name.startswith("_") or not const_name.isupper(): + continue + members = frozenset(value) if isinstance(value, tuple) else frozenset({value}) + if not all(isinstance(m, str) for m in members): + continue + matches = [t for t, args in literals.items() if args == members] + # A single unambiguous type means this constant manifests it. Ties + # (two Literals with identical members) carry no signal about which + # name the constant should take, so they are skipped. + if len(matches) == 1: + out.append((module_name, const_name, matches[0])) + return out + + +def _value_tied_constants() -> set[str]: + """Constants whose manifested type is ambiguous because two or more `Literal` + types in the same module share its exact members. + + These are invisible to the derivation check, so they are surfaced here and + counted, rather than silently dropped inside the pairing helper.""" + tied: set[str] = set() + for module_name in _iter_module_names(): + module = importlib.import_module(module_name) + literals = [ + frozenset(get_args(obj)) + for name, obj in vars(module).items() + if not name.startswith("_") + and not name.isupper() + and get_origin(obj) is Literal + and get_args(obj) + ] + for const_name, value in vars(module).items(): + if const_name.startswith("_") or not const_name.isupper(): + continue + members = frozenset(value) if isinstance(value, tuple) else frozenset({value}) + if not all(isinstance(m, str) for m in members): + continue + if sum(1 for args in literals if args == members) > 1: + tied.add(f"{module_name}.{const_name}") + return tied + + +# Constants whose `Literal` type cannot be identified by value because another +# `Literal` in the same module has identical members. Pairing is by value, so a +# tie carries no signal about which name the constant should take. These are +# checked by eye; the count below fails if the tied set grows silently. +KNOWN_VALUE_TIES = 9 + + +def test_constant_names_derive_from_their_type_names() -> None: + """Every `Literal`-backed constant whose type can be identified by value has + a name that is the mechanical transform of that type's name. + + Constants tied to more than one identically-valued `Literal` are exempt (see + `KNOWN_VALUE_TIES`), as are constants in private modules and those backing + no `Literal` at all — so this pins the rule for most of the package, not all + of it.""" + pairs = _literal_backed_constants() + assert pairs, "found no Literal-backed constants to check" + violations = [ + f"{module}: {const} should be {constant_name_for(type_name)} (manifests {type_name})" + for module, const, type_name in pairs + if const != constant_name_for(type_name) + ] + assert not violations, "constants whose names do not derive from their type:\n" + "\n".join( + violations + ) + + +def test_value_tied_constants_are_a_known_set() -> None: + """The derivation check cannot see constants whose type is ambiguous by + value. Pin how many there are, so the exempt set cannot grow unnoticed and + quietly shrink the rule's coverage.""" + tied = _value_tied_constants() + assert len(tied) == KNOWN_VALUE_TIES, ( + f"value-tied constants changed (expected {KNOWN_VALUE_TIES}, got {len(tied)}); " + f"these are unchecked by the derivation rule and must be named by hand:\n" + + "\n".join(sorted(tied)) + )