blob: b85ffc6d4d77126c232a1aecb94ceaa696d9caed [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Fast dataclass-to-tuple conversion via JIT-compiled unpacking.
This module provides ``unpack_dataclass_to_tuple``, a function that recursively
converts dataclass instances to tuples. It JIT-compiles a per-class unpacker
on first call and caches it per-thread for ~5-11x speedup over
``dataclasses.astuple`` with no deep-copy of leaf values.
"""
from __future__ import annotations
import dataclasses
import keyword
import sys
import threading
import typing
from typing import Any
# Support both typing.Union and types.UnionType (PEP 604, Python 3.10+)
if sys.version_info >= (3, 10):
import types
_LEAF_CONTAINER_ORIGINS = (tuple, dict, set, frozenset, typing.Union, types.UnionType)
else:
_LEAF_CONTAINER_ORIGINS = (tuple, dict, set, frozenset, typing.Union)
# Type alias for dataclass-to-tuple schema (internal).
# Schema values:
# None -> leaf, direct attribute access (zero cost)
# "unpack" -> dynamic dispatch via unpack_dataclass_to_tuple at runtime
# dict -> nested struct, recurse inline
# Example: {"x": None, "y": None} -> (__x.x, __x.y,)
# Example: {"cfg": {"x": None, "y": None}, "data": "unpack"}
# -> ((__x.cfg.x, __x.cfg.y,), __dispatch(__x.data),)
DataclassToTupleSchema = dict # dict[str, None | str | DataclassToTupleSchema]
# Sentinel value in schema: field should be dynamically dispatched
UNPACK = "unpack"
# Thread-local cache for JIT-compiled per-class unpack functions
_tls = threading.local()
# Types known to be safe leaves (never contain dataclass instances)
_KNOWN_LEAF_TYPES: set[type] = {int, float, str, bool, bytes, complex, type(None)}
def _is_known_leaf_type(tp: Any) -> bool:
"""Check if a type is definitely a leaf (no dataclass content or conversion needed).
Note: list is NOT a leaf because it must be converted to a tuple per the
unpack contract (matching dataclasses.astuple behavior).
"""
if isinstance(tp, type):
return tp in _KNOWN_LEAF_TYPES
if tp is Ellipsis:
return True
origin = typing.get_origin(tp)
if origin is not None:
# list is NOT a leaf — must be converted to tuple
# tuple/dict/set/frozenset/Union are leaves if all args are leaves
if origin in _LEAF_CONTAINER_ORIGINS:
args = typing.get_args(tp)
return bool(args) and all(_is_known_leaf_type(a) for a in args)
return False
def _classify_field_type(
field_type: Any, memo: set[type] | None = None
) -> None | str | DataclassToTupleSchema:
"""Classify a resolved field type into a schema entry.
Conservative: only returns None (leaf) when we are certain the type
cannot contain a dataclass. Otherwise returns UNPACK (dynamic dispatch).
"""
if isinstance(field_type, str) or field_type is Any or field_type is object:
return UNPACK
if dataclasses.is_dataclass(field_type) and isinstance(field_type, type):
# Guard against infinite recursion for self-referential dataclasses
if memo is not None and field_type in memo:
return UNPACK
return _extract_dataclass_to_tuple_schema(field_type, memo=memo)
# Known primitive types -> leaf
if isinstance(field_type, type) and field_type in _KNOWN_LEAF_TYPES:
return None
# Generic containers: check element types
# list always needs UNPACK (must be converted to tuple)
# tuple/dict/set/frozenset/Union are leaves if all args are known leaves
# Generic containers: list always UNPACK (must convert to tuple).
# tuple/dict/set/frozenset/Union are leaves only if all args are known leaves.
# Everything else (unknown type) -> UNPACK (conservative).
origin = typing.get_origin(field_type)
if origin in _LEAF_CONTAINER_ORIGINS:
args = typing.get_args(field_type)
if args and all(_is_known_leaf_type(a) for a in args):
return None
return UNPACK
def _compile_dataclass_to_tuple_schema(prefix: str, schema: DataclassToTupleSchema) -> str:
"""Compile a DataclassToTupleSchema into an inline tuple expression.
Parameters
----------
prefix
The variable expression to unpack (e.g. "__x" or "__x.field").
schema
The schema dict mapping field names to:
- None: leaf, direct attribute access
- "unpack": dynamic dispatch via __dispatch() at runtime
- nested dict: recurse inline
Returns
-------
A string expression that evaluates to a tuple of the unpacked fields.
"""
parts: list[str] = []
for field_name, sub_schema in schema.items():
field_expr = f"{prefix}.{field_name}"
if sub_schema is None:
parts.append(field_expr)
elif sub_schema == UNPACK:
parts.append(f"__dispatch({field_expr})")
else:
parts.append(_compile_dataclass_to_tuple_schema(field_expr, sub_schema))
return "(" + ", ".join(parts) + (",)" if parts else ")")
def _validate_dataclass_to_tuple_schema(schema: DataclassToTupleSchema) -> None:
"""Validate that a DataclassToTupleSchema contains only safe identifiers.
This is critical for security since field names are embedded directly
in generated code via exec(). The validation ensures:
- Keys are strings (type check)
- Keys pass str.isidentifier() — rejects any non-identifier characters
- Keys are not Python keywords — rejects control flow injection
- Values are only None, "unpack", or recursively-validated dicts
Combined with the hardcoded prefix ("__x") and restricted exec_globals,
this prevents any code injection through crafted field names.
"""
if not isinstance(schema, dict):
raise TypeError(f"DataclassToTupleSchema must be a dict, got {type(schema).__name__}")
for field_name, sub_schema in schema.items():
if not isinstance(field_name, str):
raise TypeError(f"Schema field name must be a string, got {type(field_name).__name__}")
if not field_name.isidentifier():
raise ValueError(f"Schema field name {field_name!r} is not a valid Python identifier")
if keyword.iskeyword(field_name):
raise ValueError(f"Schema field name {field_name!r} is a Python keyword")
if sub_schema is not None and sub_schema != UNPACK:
_validate_dataclass_to_tuple_schema(sub_schema)
def _extract_dataclass_to_tuple_schema(
cls: type, *, memo: set[type] | None = None
) -> DataclassToTupleSchema:
"""Extract a DataclassToTupleSchema from a dataclass class using type annotations.
Classification per field (conservative: only leaf when certain):
- Known dataclass type -> nested schema (recurse inline)
- Known primitive type (int, float, str, bool, bytes, complex) -> None (leaf)
- Container with only known-leaf args (list[int], dict[str, float]) -> None (leaf)
- Container with dataclass/unknown args (list[Config]) -> "unpack" (dynamic dispatch)
- Any, object, unresolved string annotation -> "unpack" (dynamic dispatch)
- Unknown class -> "unpack" (dynamic dispatch)
Uses typing.get_type_hints() to resolve PEP 563 string annotations.
Uses memo set to prevent infinite recursion on self-referential dataclasses.
"""
if not dataclasses.is_dataclass(cls) or not isinstance(cls, type):
raise TypeError(f"Expected a dataclass class, got {cls!r}")
if memo is None:
memo = set()
memo.add(cls)
try:
type_hints = typing.get_type_hints(cls)
except (NameError, TypeError, AttributeError):
type_hints = {}
schema: DataclassToTupleSchema = {}
for f in dataclasses.fields(cls):
field_type = type_hints.get(f.name, f.type)
schema[f.name] = _classify_field_type(field_type, memo=memo)
return schema
def unpack_dataclass_to_tuple(x: Any) -> Any:
"""Fast recursively unpack a dataclass value to tuple representation.
- Dataclass instances are unpacked to tuples of their field values.
- Lists and tuples are recursed element-wise, returning a tuple.
- Dicts are recursed on values, returning a new dict.
- All other values are returned as-is (leaf passthrough).
This function optimizes speed via JIT-compiling the conversion per dataclass
class and caching it per-thread. It brings about 5-11x speedup vs
``dataclasses.astuple`` and does not deep-copy leaf values.
Parameters
----------
x
The value to unpack.
Returns
-------
The unpacked tuple representation, or ``x`` unchanged if it's a leaf.
"""
try:
cache = _tls.cache
except AttributeError:
cache = _tls.cache = {}
cls = type(x)
fn = cache.get(cls)
if fn is not None:
return fn(x)
# Cache miss — classify the type
if dataclasses.is_dataclass(cls) and isinstance(cls, type):
schema = _extract_dataclass_to_tuple_schema(cls)
# Validate that all field names in the schema are safe Python identifiers.
# This is critical: field names are embedded directly in the generated code string.
# _validate_dataclass_to_tuple_schema ensures no code injection is possible via
# crafted field names (isidentifier + iskeyword checks).
_validate_dataclass_to_tuple_schema(schema)
code_expr = _compile_dataclass_to_tuple_schema("__x", schema)
code = f"def __unpack(__x): return {code_expr}"
namespace: dict[str, Any] = {}
# exec_globals only exposes __dispatch (our own function), no other capabilities.
exec(code, {"__dispatch": unpack_dataclass_to_tuple}, namespace)
fn = namespace["__unpack"]
cache[cls] = fn
return fn(x)
if isinstance(x, (list, tuple)):
return type(x)(unpack_dataclass_to_tuple(e) for e in x)
if isinstance(x, dict):
return {k: unpack_dataclass_to_tuple(v) for k, v in x.items()}
# True leaf — cache identity so next call is just dict.get + return
cache[cls] = _LEAF_IDENTITY
return x
def _LEAF_IDENTITY(x: Any) -> Any:
"""Identity function cached for known leaf types."""
return x