Skip to content
Merged
8 changes: 2 additions & 6 deletions src/mountainash/conform/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,12 +956,8 @@ def _build_field_expr(
# Other backends fall through to base type cast only.
elif fld.categories is not None:
# Extract values from categories (handles both simple and object forms)
cat_values: list[Any] = []
for cat in fld.categories:
if isinstance(cat, dict):
cat_values.append(cat["value"])
else:
cat_values.append(cat)
from mountainash.typespec._categorical import categorical_values
cat_values = categorical_values(fld.categories)

# Step 1: base type cast (if needed)
if fld.type and fld.type != UniversalType.ANY:
Expand Down
6 changes: 5 additions & 1 deletion src/mountainash/core/dtypes/target_narwhals.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ def parse_type_string(s: str) -> Optional[Any]:
"Categorical", "Enum")
}
result = parse_constructor_repr(s, namespace)
return result if isinstance(result, type) or result is not None else None
# No nw.DataType base class exists to isinstance-check instances against;
# the namespace is closed + AST-validated upstream, so any non-None
# construction is a legitimate parameterized dtype (DTypeClass instances
# are not `type`s — isinstance(None-check) is precisely `result is not None`).
return result
30 changes: 18 additions & 12 deletions src/mountainash/core/dtypes/target_pyarrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,24 @@ def parse_type_string(s: str) -> Optional[Any]:
return pa.type_for_alias(s) # unparameterized names, unchanged
except (KeyError, ValueError):
pass
if m := _TIMESTAMP_RE.match(s):
unit, tz = m.groups()
return pa.timestamp(unit, tz=tz) if tz else pa.timestamp(unit)
if m := _DURATION_RE.match(s):
return pa.duration(m.group(1))
if m := _TIME_RE.match(s):
bits, unit = m.groups()
return (pa.time32 if bits == "32" else pa.time64)(unit)
if m := _DECIMAL_RE.match(s):
bits, prec, scale = m.groups()
ctor = pa.decimal128 if bits == "128" else pa.decimal256
return ctor(int(prec), int(scale))
try:
if m := _TIMESTAMP_RE.match(s):
unit, tz = m.groups()
return pa.timestamp(unit, tz=tz) if tz else pa.timestamp(unit)
if m := _DURATION_RE.match(s):
return pa.duration(m.group(1))
if m := _TIME_RE.match(s):
bits, unit = m.groups()
return (pa.time32 if bits == "32" else pa.time64)(unit)
if m := _DECIMAL_RE.match(s):
bits, prec, scale = m.groups()
ctor = pa.decimal128 if bits == "128" else pa.decimal256
return ctor(int(prec), int(scale))
except (KeyError, ValueError):
# Syntactically-valid-but-semantically-invalid (timestamp[badunit],
# decimal128(999, 10)) — return None so the resolver raises the typed
# InvalidBackendTypeError instead of leaking a raw Arrow ValueError.
return None
return None


Expand Down
20 changes: 20 additions & 0 deletions src/mountainash/typespec/_categorical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Shared categorical value extraction (item 54, gap 3).

The value-extraction logic for Frictionless ``categories`` (simple
``["a", "b"]`` form vs object ``[{"value":.., "label":..}]`` form) was
copy-paste-identical between conform's stage-5b expression builder and the
typespec converters. This module is the single home for it — both call sites
import from here so they can never diverge.
"""
from __future__ import annotations

from typing import Any


def categorical_values(categories: list[Any]) -> list[Any]:
"""Extract raw values from a categories list (simple or {value,label}
object form). Returns a NEW list — never aliases the input."""
return [c["value"] if isinstance(c, dict) else c for c in categories]


__all__ = ["categorical_values"]
84 changes: 79 additions & 5 deletions src/mountainash/typespec/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
InvalidBackendTypeError,
MountainashDtype,
TypeTarget,
UnknownDtypeError,
registry,
)
from mountainash.typespec.universal_types import to_canonical
Expand Down Expand Up @@ -48,7 +49,51 @@ def _resolve_field_native(field: "FieldSpec", target: TypeTarget) -> Any:
canon = to_canonical(field.type)
if canon is None: # ANY
canon = MountainashDtype.STRING
return registry.to_native_schema(canon, target)
native = registry.to_native_schema(canon, target)

if canon is MountainashDtype.LIST and field.item_type:
native = _resolve_list_inner(field.name, field.item_type, target, native)
return native


def _resolve_list_inner(
field_name: str, item_type_str: str, target: TypeTarget, bare_native: Any
) -> Any:
"""Parameterize a bare list container with its Frictionless item_type
(item 54, gap 2). Layered AFTER the backend_type/raise branch — a
backend_type wins first; canonical LIST + item_type is the fallback
enrichment, not a new top-priority branch.

Pandas returns bare_native unchanged — no native parameterized list
dtype, "object" is the correct, only representation.
"""
from mountainash.typespec.universal_types import parse_universal
try:
item_universal = parse_universal(item_type_str)
except UnknownDtypeError as e:
# parse_universal raises without field context; chain it so the error
# is traceable to its source field while keeping UnknownDtypeError
# (the repo convention for "input not recognized as any dtype").
raise UnknownDtypeError(
f"field {field_name!r}: item_type {item_type_str!r} is not a "
f"recognized UniversalType"
) from e
item_canon = to_canonical(item_universal)
if item_canon is None: # item_type == "any" — no parameterization possible
return bare_native
inner_native = registry.to_native_schema(item_canon, target)
if target is TypeTarget.POLARS:
from mountainash.core.lazy_imports import import_polars
return import_polars().List(inner_native)
if target is TypeTarget.NARWHALS:
from mountainash.core.lazy_imports import import_narwhals
return import_narwhals().List(inner_native)
if target is TypeTarget.PYARROW:
from mountainash.core.lazy_imports import import_pyarrow
return import_pyarrow().list_(inner_native)
if target is TypeTarget.IBIS:
return f"array<{inner_native}>" # ibis schema is string-keyed
return bare_native # PANDAS — no native parameterized list dtype


# ============================================================================
Expand All @@ -75,33 +120,62 @@ def to_polars_schema(schema: TypeSpec) -> Dict[str, Any]:
{'id': Int64, 'name': Utf8}
"""
from mountainash.core.lazy_imports import import_polars
from mountainash.typespec._categorical import categorical_values
pl = import_polars()
if pl is None:
raise ImportError("polars is required for to_polars_schema()")
return {f.name: _resolve_field_native(f, TypeTarget.POLARS) for f in schema.fields}
result = {}
for f in schema.fields:
if f.categories is not None:
# categories takes priority over backend_type/type entirely
# (mirrors conform stage 5's branch order exactly).
values = categorical_values(f.categories)
result[f.name] = (
pl.Enum([str(v) for v in values]) if f.categories_ordered
else pl.Categorical
)
else:
result[f.name] = _resolve_field_native(f, TypeTarget.POLARS)
return result


# ============================================================================
# Pandas Converters
# ============================================================================

def to_pandas_dtypes(schema: TypeSpec) -> Dict[str, str]:
def to_pandas_dtypes(schema: TypeSpec) -> Dict[str, Any]:
"""
Convert TypeSpec to pandas dtypes dict.

Non-categorical fields map to pandas dtype strings; a field with
``categories`` set maps to a real ``pd.CategoricalDtype`` instance
(item 54, gap 3) — accepted directly by ``df.astype(...)``.

Args:
schema: TypeSpec to convert

Returns:
Dict mapping column names to pandas dtype strings
Dict mapping column names to pandas dtype strings (or
pd.CategoricalDtype instances for categorical fields)

Example:
>>> schema = TypeSpec.from_simple_dict({"id": "integer", "name": "string"})
>>> pandas_dtypes = to_pandas_dtypes(schema)
>>> pandas_dtypes
{'id': 'Int64', 'name': 'string'}
"""
return {f.name: _resolve_field_native(f, TypeTarget.PANDAS) for f in schema.fields}
from mountainash.typespec._categorical import categorical_values
result: Dict[str, Any] = {}
for f in schema.fields:
if f.categories is not None:
values = categorical_values(f.categories)
import pandas as pd
result[f.name] = pd.CategoricalDtype(
categories=values, ordered=bool(f.categories_ordered)
)
else:
result[f.name] = _resolve_field_native(f, TypeTarget.PANDAS)
return result


# ============================================================================
Expand Down
10 changes: 10 additions & 0 deletions tests/core/dtypes/test_target_narwhals.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ def test_bare_name_guard_is_isinstance_type(self):
bare-name parse proves the guard was adapted, not copy-pasted."""
assert target_narwhals.parse_type_string("Int64") is nw.Int64

def test_namespace_classes_present_at_pinned_floor(self):
"""Floor verification (spec §2.3 / plan Task 13, 2026-08-17): the four
namespace classes the safe-eval parser constructs — Array, Enum,
Categorical, Decimal — plus the DTypeClass instantiation mechanism
were verified present (class defs + top-level exports) in the narwhals
2.20.0 wheel (the pinned floor in pyproject.toml). No floor change was
needed; this documents the check so a future floor raise re-runs it."""
for name in ("Array", "Enum", "Categorical", "Decimal"):
assert hasattr(nw, name), f"nw.{name} missing at resolved version"


class TestParameterizedRoundTrip:
@pytest.mark.parametrize("s,expected", [
Expand Down
17 changes: 17 additions & 0 deletions tests/core/dtypes/test_target_pyarrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ def test_bare_name_via_alias_unchanged(self):
assert target_pyarrow.parse_type_string("string") == pa.string()


class TestSemanticallyInvalidReturnsNone:
"""Syntactically-valid-but-semantically-invalid strings match their regex
but must return None (never leak a raw Arrow constructor ValueError) so
the resolver raises the typed InvalidBackendTypeError instead."""

@pytest.mark.parametrize("s", [
"timestamp[badunit]",
"decimal128(999, 10)",
"decimal256(999, 10)",
"time64[badunit]",
"time32[badunit]",
"duration[badunit]",
])
def test_semantically_invalid_returns_none(self, s):
assert target_pyarrow.parse_type_string(s) is None


class TestOutOfScopeStaysNone:
@pytest.mark.parametrize("s", [
"list<item: int64>", # recursive list grammar — out of scope
Expand Down
Loading
Loading