Skip to content
Merged
9 changes: 7 additions & 2 deletions src/mountainash/core/dtypes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
parse_dtype,
)
from .casts import SAFE_CASTS, UNSAFE_CASTS, CastSafety, classify_cast, is_safe_cast
from .errors import DtypeError, DtypeMappingError, UnknownDtypeError
from .errors import (
DtypeError,
DtypeMappingError,
InvalidBackendTypeError,
UnknownDtypeError,
)
from .registry import DtypeRegistry, registry
from .targets import TypeTarget, detect_target

Expand All @@ -23,7 +28,7 @@
"parse_dtype", "parse_cast_target",
"TypeTarget", "detect_target",
"DtypeRegistry", "registry",
"DtypeError", "UnknownDtypeError", "DtypeMappingError",
"DtypeError", "UnknownDtypeError", "DtypeMappingError", "InvalidBackendTypeError",
"SAFE_CASTS", "UNSAFE_CASTS", "is_safe_cast",
"CastSafety", "classify_cast",
]
81 changes: 81 additions & 0 deletions src/mountainash/core/dtypes/_paramstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Shared safe parser for constructor-call-style dtype repr strings.

Polars and Narwhals dtype reprs (``str(pl.Datetime(time_unit='us',
time_zone='UTC'))``, ``str(pl.List(pl.Int64))``,
``str(nw.Enum(categories=['a','b']))``) are valid Python constructor-call
syntax against their own module namespace. This walks the AST and evaluates
ONLY a bounded grammar — a bare Name (resolved against ``namespace``), or a
Call(Name, *args, **kwargs) whose arguments are themselves only
Constant / Name (resolved against the SAME ``namespace``) / list[arg] /
tuple[arg] — before ever calling ``eval()``. Anything else (attribute access,
subscripts, comprehensions, imports, starred/double-starred unpacking,
arbitrary calls) is rejected before eval ever runs.

GLM-5.2 review (2026-08-16) Critical finding: an earlier draft's
``_safe_literal`` accepted only Constant/List/Tuple, rejecting a bare
``Name`` inside ``args`` — which broke ``List(Int64)`` and
``Array(Int64, shape=(5,))``, the exact motivating round-trip cases
(``Int64``/``shape`` values are bare Names in the AST, not Constants). Fixed
here: a Name node inside an argument position resolves against the same
closed ``namespace`` the top-level Call already validated against — never a
wider lookup.
"""
from __future__ import annotations

import ast
from typing import Any, Mapping, Optional


def _safe_arg(node: ast.AST, namespace: Mapping[str, Any]) -> Any:
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
if node.id not in namespace:
raise ValueError(f"name {node.id!r} not in whitelist")
return namespace[node.id]
if isinstance(node, (ast.List, ast.Tuple)):
vals = [_safe_arg(e, namespace) for e in node.elts]
return vals if isinstance(node, ast.List) else tuple(vals)
raise ValueError(f"unsupported argument node: {ast.dump(node)}")


def parse_constructor_repr(s: str, namespace: Mapping[str, Any]) -> Optional[Any]:
"""Parse `s` as a bare name or constructor call against `namespace`.

Returns the constructed value, or None if `s` doesn't match the bounded
grammar or references a name outside `namespace`. Never calls eval()/exec()
on unvalidated input — the AST is walked and every node type-checked first.

Note: `**dict`-unpacked keywords (`ast.keyword(arg=None)`, e.g.
`Datetime(**{'time_zone': 'UTC'})`) are deliberately DROPPED, not rejected —
real Polars/Narwhals `str()` output never emits this form, so it is
robustness-only; §7.2 adversarially tests that the drop (not a silent
wrong-value construction) is what happens.
"""
try:
tree = ast.parse(s, mode="eval")
except SyntaxError:
return None
node = tree.body

if isinstance(node, ast.Name):
return namespace.get(node.id)

if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in namespace:
return None
target = namespace[node.func.id]
try:
args = [_safe_arg(a, namespace) for a in node.args]
kwargs = {
kw.arg: _safe_arg(kw.value, namespace)
for kw in node.keywords if kw.arg # kw.arg is None for **unpack — dropped
}
except ValueError:
return None
try:
return target(*args, **kwargs)
except Exception:
return None

return None
18 changes: 18 additions & 0 deletions src/mountainash/core/dtypes/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
"""
from __future__ import annotations

from typing import TYPE_CHECKING

from mountainash.core.errors import MountainashError

if TYPE_CHECKING:
from .targets import TypeTarget


class DtypeError(MountainashError, ValueError):
"""Base for canonical dtype-system errors."""
Expand All @@ -19,3 +24,16 @@ class UnknownDtypeError(DtypeError):

class DtypeMappingError(DtypeError):
"""The canonical dtype has no mapping for the requested target/use."""


class InvalidBackendTypeError(DtypeError):
"""A non-empty FieldSpec.backend_type could not be parsed for the target."""

def __init__(self, field_name: str, backend_type: str, target: "TypeTarget") -> None:
self.field_name = field_name
self.backend_type = backend_type
self.target = target
super().__init__(
f"field {field_name!r}: backend_type {backend_type!r} is not a valid "
f"{target.value} dtype string"
)
19 changes: 13 additions & 6 deletions src/mountainash/core/dtypes/target_narwhals.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,16 @@ def from_native(native: Any) -> Optional[D]:


def parse_type_string(s: str) -> Optional[Any]:
if "(" in s:
return None
t = getattr(nw, s, None)
if t is not None and isinstance(t, type):
return t
return None
if "(" not in s:
t = getattr(nw, s, None)
return t if isinstance(t, type) else None # NOT issubclass(t, nw.DataType) — no such class
from ._paramstring import parse_constructor_repr
namespace = {
n: getattr(nw, n) for n in
("Datetime", "Duration", "Decimal", "List", "Array", "Int8", "Int16",
"Int32", "Int64", "UInt8", "UInt16", "UInt32", "UInt64", "Float32",
"Float64", "Boolean", "String", "Binary", "Date", "Time",
"Categorical", "Enum")
}
result = parse_constructor_repr(s, namespace)
return result if isinstance(result, type) or result is not None else None
19 changes: 14 additions & 5 deletions src/mountainash/core/dtypes/target_polars.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,18 @@ def from_native(native: Any) -> Optional[D]:


def parse_type_string(s: str) -> Optional[Any]:
if "(" in s: # parameterized reprs not reconstructable via getattr
return None
t = getattr(pl, s, None)
if isinstance(t, type) and issubclass(t, pl.DataType):
return t
if "(" not in s:
t = getattr(pl, s, None)
return t if isinstance(t, type) and issubclass(t, pl.DataType) else None
from ._paramstring import parse_constructor_repr
namespace = {
n: getattr(pl, n) for n in
("Datetime", "Duration", "Decimal", "List", "Array", "Int8", "Int16",
"Int32", "Int64", "UInt8", "UInt16", "UInt32", "UInt64", "Float32",
"Float64", "Boolean", "String", "Binary", "Date", "Time",
"Categorical", "Enum")
}
result = parse_constructor_repr(s, namespace)
if result is not None and isinstance(result, pl.DataType):
return result
return None
26 changes: 24 additions & 2 deletions src/mountainash/core/dtypes/target_pyarrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""PyArrow target mappings. Imported lazily."""
from __future__ import annotations

import re
from typing import Any, Optional

import pyarrow as pa
Expand Down Expand Up @@ -56,6 +57,27 @@ def from_native(native: Any) -> Optional[D]:

def parse_type_string(s: str) -> Optional[Any]:
try:
return pa.type_for_alias(s)
return pa.type_for_alias(s) # unparameterized names, unchanged
except (KeyError, ValueError):
return None
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))
return None


# Bounded bracket/paren family — the only parameterized forms PyArrow's
# str() emits that type_for_alias does not already parse.
_TIMESTAMP_RE = re.compile(r"^timestamp\[(\w+)(?:, tz=(.+))?\]$")
_DURATION_RE = re.compile(r"^duration\[(\w+)\]$")
_TIME_RE = re.compile(r"^time(32|64)\[(\w+)\]$")
_DECIMAL_RE = re.compile(r"^decimal(128|256)\((\d+),\s*(\d+)\)$")
12 changes: 11 additions & 1 deletion src/mountainash/typespec/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

from typing import TYPE_CHECKING, Any, Dict

from mountainash.core.dtypes import MountainashDtype, TypeTarget, registry
from mountainash.core.dtypes import (
InvalidBackendTypeError,
MountainashDtype,
TypeTarget,
registry,
)
from mountainash.typespec.universal_types import to_canonical

if TYPE_CHECKING:
Expand All @@ -35,6 +40,11 @@ def _resolve_field_native(field: "FieldSpec", target: TypeTarget) -> Any:
parsed = registry.parse_type_string(field.backend_type, target)
if parsed is not None:
return parsed
# Validation strictness (item 54, §5): only a non-empty, non-None
# backend_type that the target cannot parse raises. None/"" means "no
# override given" and falls through to canonical (item 53's ANY->STRING
# case relies on that).
raise InvalidBackendTypeError(field.name, field.backend_type, target)
canon = to_canonical(field.type)
if canon is None: # ANY
canon = MountainashDtype.STRING
Expand Down
112 changes: 112 additions & 0 deletions tests/core/dtypes/test_paramstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Unit tests for the restricted AST-validated dtype-repr parser.

Security-relevant surface: ``parse_constructor_repr`` must NEVER evaluate
arbitrary input — only a bounded grammar (bare Name, or Call(Name, args)
whose arguments are Constant / whitelisted-Name / list / tuple). Every
adversarial case below must return None, never raise or execute.

Positive cases cover the Critical-finding fix: a bare ``Name`` used as a
positional/keyword argument (``List(Int64)``, ``Array(Int64, shape=(5,))``)
resolves against the SAME closed namespace, not a wider lookup.
"""
from __future__ import annotations

import pytest

from mountainash.core.dtypes._paramstring import parse_constructor_repr


class _Box:
"""Minimal stand-in for a dtype constructor: records its arguments."""

def __init__(self, *args, **kwargs) -> None:
self.args = args
self.kwargs = kwargs


# Closed namespace mirroring the shape of the polars/narwhals whitelists.
_NS = {
"Datetime": _Box,
"List": _Box,
"Array": _Box,
"Enum": _Box,
"Int64": "INT64",
"shape": "SHAPE",
}


class TestPositive:
def test_bare_name(self):
assert parse_constructor_repr("Int64", _NS) == "INT64"

def test_bare_name_arg_resolves_against_namespace(self):
"""The Critical-finding case: Name args must resolve, not be rejected."""
result = parse_constructor_repr("List(Int64)", _NS)
assert isinstance(result, _Box)
assert result.args == ("INT64",)

def test_name_arg_plus_tuple_of_constants_kwarg(self):
result = parse_constructor_repr("Array(Int64, shape=(5,))", _NS)
assert isinstance(result, _Box)
assert result.args == ("INT64",)
assert result.kwargs == {"shape": (5,)}

def test_all_constant_kwargs(self):
result = parse_constructor_repr(
"Datetime(time_unit='us', time_zone='UTC')", _NS
)
assert isinstance(result, _Box)
assert result.kwargs == {"time_unit": "us", "time_zone": "UTC"}

def test_list_arg(self):
result = parse_constructor_repr("Enum(categories=['a', 'b'])", _NS)
assert isinstance(result, _Box)
assert result.kwargs == {"categories": ["a", "b"]}


class TestNegative:
"""Everything outside the bounded grammar returns None — never executes."""

@pytest.mark.parametrize("s", [
"os.system('x')", # attribute access
"eval('1')", # arbitrary call
"Datetime.__init__()", # method call
"(lambda: 1)()", # lambda
"[x for x in [1, 2]]", # comprehension (also not a valid expr form)
"__import__('os')", # import builtin
"Datetime(*[1, 2])", # starred unpacking
"List(__builtins__)", # Name outside whitelist in arg position
"1 + 1", # operator expression
"Datetime.time_unit", # attribute access as target
])
def test_rejected_returns_none(self, s):
assert parse_constructor_repr(s, _NS) is None

def test_unparseable_syntax_returns_none(self):
assert parse_constructor_repr("not valid python !!!", _NS) is None

def test_empty_string_returns_none(self):
assert parse_constructor_repr("", _NS) is None

def test_unknown_top_level_name_returns_none(self):
assert parse_constructor_repr("NotInNamespace", _NS) is None
assert parse_constructor_repr("NotInNamespace(1)", _NS) is None


class TestDoubleStarKwargDrop:
def test_unpacked_kwargs_are_dropped_not_applied(self):
"""Deliberate, documented behavior: **dict-unpacked keywords are
dropped (real Polars/Narwhals str() output never emits this form)."""
result = parse_constructor_repr(
"Datetime(time_unit='us', **{'time_zone': 'UTC'})", _NS
)
assert isinstance(result, _Box)
assert result.kwargs == {"time_unit": "us"}
assert "time_zone" not in result.kwargs

def test_fully_unpacked_kwargs_construct_with_nothing(self):
"""All-keywords-unpacked form drops everything — constructs with zero
kwargs, never rejects (matches the documented drop, not a raise)."""
result = parse_constructor_repr("Datetime(**{'a': 1})", _NS)
assert isinstance(result, _Box)
assert result.kwargs == {}
11 changes: 9 additions & 2 deletions tests/core/dtypes/test_target_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,15 @@ def test_from_native_unknown_raises(self):
def test_parse_type_string_bare_name(self):
assert target_polars.parse_type_string("Int32") is pl.Int32

def test_parse_type_string_parameterized_returns_none(self):
assert target_polars.parse_type_string("Datetime(time_unit='us', time_zone=None)") is None
def test_parse_type_string_parameterized_round_trips(self):
# Parameterized reprs now reconstruct via the safe-eval parser
# (item 54, gap 1) — no longer dropped to canonical fallback.
assert target_polars.parse_type_string(
"Datetime(time_unit='us', time_zone=None)"
) == pl.Datetime(time_unit="us", time_zone=None)

def test_parse_type_string_garbage_returns_none(self):
assert target_polars.parse_type_string("garbage") is None


class TestPandas:
Expand Down
Loading
Loading