Skip to content

fix(zarr-metadata): make JSONValue's array arm covariant - #295

Closed
d-v-b wants to merge 4 commits into
mainfrom
jsonvalue-sequence-arm
Closed

fix(zarr-metadata): make JSONValue's array arm covariant#295
d-v-b wants to merge 4 commits into
mainfrom
jsonvalue-sequence-arm

Conversation

@d-v-b

@d-v-b d-v-b commented Aug 14, 2026

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

The problem

JSONValue's array arm is list["JSONValue"] | tuple["JSONValue", ...], and list/tuple are invariant in their element type. So a value typed with any narrower element does not count as a JSON value:

lst: list[str] = ["y"]
val: JSONValue = lst
# error: Type parameter "_T@list" is invariant, but "str" is not the same as "JSONValue"
#   Consider switching from "list" to "Sequence" which is covariant   <- pyright's own suggestion

By extension, any TypedDict whose fields carry precise types (Sequence[str], list[int], ...) is not assignable to Mapping[str, JSONValue] — which is exactly the shape downstream packages want: zarr-cm's convention TypedDicts hit all three failure modes, and ty agrees with pyright on every one of them.

The fix

The array arm becomes Sequence["JSONValue"]. Sequence is covariant, so list[str], tuple[int, ...], and Sequence[float] are all JSON values, and precisely-typed TypedDicts flow into Mapping[str, JSONValue].

The docstring records the deliberate type-level cost: Sequence says nothing about the concrete container and admits str/bytes (str was already a union arm); runtime code narrowing a JSON array must exclude str/bytes/bytearray regardless of how the alias is spelled.

Motivation

Found while unifying zarr-cm's JsonValue with this alias — the two are structurally identical except for this arm (zarr-cm chose Sequence for exactly the covariance reasons above). With this change, pyright unifies the two recursive aliases across the package boundary with zero errors, and zarr-cm can delete its own definition and alias this one.

Verification

From packages/zarr-metadata: just test (595 passed), just typecheck (0 errors), just lint clean. Probes exercising all three failure modes go from 3 errors to 0 against the patched package, under both pyright and ty.

* deps: bump cast-value.rs to >= 0.4.2

* deps: update uv.lock for cast-value-rs >= 0.4.2

The pyproject floor alone left uv.lock pinning 0.4.0, so any
lock-honoring install (uv sync --locked/--frozen) kept the version
that silently transposes non-row-major input.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cast_value): enforce the cast-value-rs floor at runtime

The pyproject floor only binds installs that go through the
zarr[cast-value-rs] extra. An environment that already has an older
cast-value-rs installed kept silently corrupting non-row-major input
after upgrading zarr, which is the failure this floor exists to stop.

Check the installed version at import and raise from _do_cast, so the
error surfaces when the codec is used rather than breaking `import zarr`
for everyone else. A backend without distribution metadata (a `maturin
develop` build) has no version to compare and is left alone.

Assisted-by: ClaudeCode:claude-fable-5

* test(cast_value): cover cast_value next to the transpose codec

Regression test for zarr-developers#4237: a cast_value codec on either side of a
transpose codec must round-trip, because transpose hands the next codec
a non-row-major view.

Imported unchanged from zarr-developers#4238, where this test was written. The
np.ascontiguousarray workaround that accompanied it there is
deliberately left out: cast-value-rs 0.4.2 normalizes layout itself, and
the workaround promotes 0-d arrays to shape (1,), breaking 0-d arrays.

Co-authored-by: Raphael Jolivet <raphael.jolivet@minesparis.psl.eu>
Assisted-by: ClaudeCode:claude-fable-5

---------

Co-authored-by: Raphael Jolivet <contact@raphael-jolivet.name>
Co-authored-by: Raphael Jolivet <raphael.jolivet@minesparis.psl.eu>
`list["JSONValue"] | tuple["JSONValue", ...]` is invariant in the element
type, so a value typed with any narrower element -- a `list[str]` field on
a TypedDict, a `Sequence[float]` -- was not assignable to `JSONValue`, and
a TypedDict carrying such fields was not assignable to
`Mapping[str, JSONValue]`. pyright's diagnostic for the failure suggests
the fix verbatim: "Consider switching from list to Sequence which is
covariant."

The array arm is now `Sequence["JSONValue"]`. The docstring records the
deliberate type-level cost (`Sequence` admits `str`/`bytes`; runtime
narrowing must exclude them regardless of the alias's spelling).

Found while aliasing zarr-cm's JsonValue to this type: the two aliases
are structurally identical except for this arm, and with it changed,
pyright unifies them across the package boundary.

Assisted-by: ClaudeCode:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant