Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e82680e
docs(plans): add ITL-638 implementation plan for stable type annotati…
kurodo3[bot] Sep 2, 2026
e8e9952
docs(specs): add ITL-638 stable type annotation hashing design spec
kurodo3[bot] Sep 2, 2026
3806f39
test(hashing): add pre-fix golden hash values for type annotation has…
kurodo3[bot] Sep 2, 2026
04b7a0f
test(hashing): add golden-diff regression tests for type annotation c…
kurodo3[bot] Sep 2, 2026
8faf36a
test(hashing): fix generator script to import functions from test mod…
kurodo3[bot] Sep 2, 2026
ef14df9
test(hashing): add clarifying comment on UUID annotation routing (ITL…
kurodo3[bot] Sep 2, 2026
415eb47
feat(hashing): extend canonical_annotation_str to resolve registered …
kurodo3[bot] Sep 2, 2026
f971d3a
style(hashing): move test imports to top of test_hash_utils.py (ITL-638)
kurodo3[bot] Sep 2, 2026
d8a9170
feat(hashing): make TypeObjectHandler resolve logical types to canoni…
kurodo3[bot] Sep 2, 2026
c11e07a
fix(hashing): use getattr fallback for _logical_type_registry access;…
kurodo3[bot] Sep 2, 2026
73bda23
feat(hashing): canonicalize param and return annotations in FunctionS…
kurodo3[bot] Sep 2, 2026
f7a35fa
feat(hashing): thread logical_type_registry through register_builtin_…
kurodo3[bot] Sep 2, 2026
2256c7a
test(hashing): add schema hash golden tests to lock in post-fix canon…
kurodo3[bot] Sep 2, 2026
b2becea
refactor(hashing): expose get_logical_type on TypeConverterProtocol; …
kurodo3[bot] Sep 2, 2026
2088e70
fix(hashing): check origin in _annotation_contains_registered_type fo…
kurodo3[bot] Sep 2, 2026
e4189ba
refactor(hashing): always store raw return annotation; TypeObjectHand…
kurodo3[bot] Sep 2, 2026
927d54a
test(hashing): add explicit regression guard asserting returns is nev…
kurodo3[bot] Sep 2, 2026
795cfe9
fix(hashing): remove unused import, correct docstrings, update spec t…
kurodo3[bot] Sep 2, 2026
e3c0c14
refactor(hashing): reconstruct param strings from components instead …
kurodo3[bot] Sep 2, 2026
8c6b7bd
test(hashing): replace spurious repr test with meaningful varargs/kwa…
kurodo3[bot] Sep 2, 2026
bca94e0
test(function_info_extractors): tighten param assertions to exact equ…
kurodo3[bot] Sep 2, 2026
6ec4aad
test(function_info_extractors): add Path/UUID canonicalization coverage
kurodo3[bot] Sep 3, 2026
c043922
test(hashing): verify Path/UUID canonical serialization in return pos…
kurodo3[bot] Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/orcapod/contexts/data/v0.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@
"_class": "orcapod.hashing.semantic_hashing.function_info_extractors.FunctionSignatureExtractor",
"_config": {
"include_module": true,
"include_defaults": true
"include_defaults": true,
"type_converter": {"_ref": "type_converter"}
}
},
"python_type_handler_registry": {
Expand All @@ -110,7 +111,7 @@
[{"_type": "types.FunctionType"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.FunctionHandler", "_config": {"function_info_extractor": {"_ref": "function_semantic_hasher"}}}],
[{"_type": "types.BuiltinFunctionType"},{"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.FunctionHandler", "_config": {"function_info_extractor": {"_ref": "function_semantic_hasher"}}}],
[{"_type": "types.MethodType"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.FunctionHandler", "_config": {"function_info_extractor": {"_ref": "function_semantic_hasher"}}}],
[{"_type": "builtins.type"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.TypeObjectHandler", "_config": {}}],
[{"_type": "builtins.type"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.TypeObjectHandler", "_config": {"type_converter": {"_ref": "type_converter"}}}],
[{"_type": "types.GenericAlias"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.GenericAliasHandler", "_config": {}}],
[{"_type": "types.UnionType"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.UnionTypeHandler", "_config": {}}],
[{"_type": "typing._GenericAlias"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.GenericAliasHandler", "_config": {}}],
Expand Down
49 changes: 41 additions & 8 deletions src/orcapod/hashing/hash_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
import zlib
from collections.abc import Callable, Collection
from pathlib import Path
from typing import TYPE_CHECKING

import xxhash
from upath import UPath

from orcapod.types import ContentHash, PathLike

if TYPE_CHECKING:
from orcapod.protocols.semantic_types_protocols import TypeConverterProtocol

logger = logging.getLogger(__name__)


Expand All @@ -32,29 +36,58 @@ def is_union_annotation(annotation: object) -> bool:
return getattr(annotation, "__origin__", None) is typing.Union


def canonical_annotation_str(annotation: object) -> str:
def canonical_annotation_str(
annotation: object,
type_converter: "TypeConverterProtocol | None" = None,
) -> str:
"""Return a stable, canonical string for a type annotation.

Resolves types registered in *type_converter* (e.g. ``orcapod.File``) to
their stable ``logical_type_name`` (e.g. ``"orcapod.file"``) via
``type_converter.get_logical_type()``, so that internal module relocations
do not change the string representation.

For union types (both PEP 604 ``X | Y`` and ``typing.Union[X, Y]``),
members are sorted byte-wise so that ``str | Path`` and ``Path | str``
produce the same canonical string. Non-union types fall through to
``inspect.formatannotation``, preserving existing behaviour exactly.
produce the same canonical string.

The canonical ordering key is the fully qualified type name produced by
``inspect.formatannotation`` (e.g. ``"pathlib.Path"``, ``"str"``),
sorted lexicographically. This is stable across Python versions and
machines and does not depend on ``id()`` or ``__hash__``.
For generic aliases (``list[X]``, ``dict[K, V]``), args are recursed with
the same converter so nested orcapod types are also canonicalized.

Non-union, non-generic types not found in the converter fall through to
``inspect.formatannotation``, preserving existing behaviour exactly.

Args:
annotation: A type annotation object.
type_converter: Optional ``TypeConverterProtocol``. When provided,
registered logical types resolve to their stable
``logical_type_name`` via ``get_logical_type()``.

Returns:
A canonical string representation.
"""
# Registered logical type: use stable canonical name (e.g. "orcapod.file")
if type_converter is not None and isinstance(annotation, type):
lt = type_converter.get_logical_type(annotation)
if lt is not None:
return lt.logical_type_name

# Union types (PEP 604 X | Y and typing.Union): sort members for order-independence
if is_union_annotation(annotation):
args = getattr(annotation, "__args__", ()) or ()
member_strs = sorted(canonical_annotation_str(a) for a in args)
member_strs = sorted(canonical_annotation_str(a, type_converter) for a in args)
return " | ".join(member_strs)

# Generic aliases (list[X], dict[K, V], etc.): recurse over args
origin = getattr(annotation, "__origin__", None)
if origin is not None and not is_union_annotation(annotation):
args = getattr(annotation, "__args__", None) or ()
origin_str = canonical_annotation_str(origin, type_converter)
if args:
args_str = ", ".join(canonical_annotation_str(a, type_converter) for a in args)
return f"{origin_str}[{args_str}]"
return origin_str

return inspect.formatannotation(annotation)


Expand Down
30 changes: 28 additions & 2 deletions src/orcapod/hashing/semantic_hashing/builtin_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
HandlerRegistryProtocol,
SemanticHasherProtocol,
)
from orcapod.protocols.semantic_types_protocols import TypeConverterProtocol

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -83,14 +84,32 @@ def handle(self, obj: Any, hasher: "SemanticHasherProtocol") -> Any:
class TypeObjectHandler:
"""Hasher for type objects (classes passed as values).

Returns a stable string of the form ``"type:<module>.<qualname>"``.
Resolves types registered in the ``LogicalTypeRegistry`` exposed by
*type_converter* to their stable ``logical_type_name``
(e.g. ``"type:orcapod.file"`` for ``op.File``).
Falls back to ``"type:<module>.<qualname>"`` for unregistered types or
when no ``type_converter`` is provided.

Args:
type_converter: Optional ``TypeConverterProtocol``. When provided,
``type_converter.get_logical_type(obj)`` is called to resolve
registered logical types to their stable canonical name. When
``None`` (the default), the fallback ``"type:<module>.<qualname>"``
serialisation is always used.
"""

def __init__(self, type_converter: "TypeConverterProtocol | None" = None) -> None:
self._type_converter = type_converter

def handle(self, obj: Any, hasher: "SemanticHasherProtocol") -> Any:
if not isinstance(obj, type):
raise TypeError(
f"TypeObjectHandler: expected a type/class, got {type(obj)!r}"
)
if self._type_converter is not None:
lt = self._type_converter.get_logical_type(obj)
if lt is not None:
return f"type:{lt.logical_type_name}"
module: str = obj.__module__ or "<unknown>"
qualname: str = obj.__qualname__
return f"type:{module}.{qualname}"
Expand Down Expand Up @@ -418,6 +437,7 @@ def register_builtin_python_type_handlers(
function_info_extractor: Any = None,
arrow_hasher: "ArrowHasherProtocol | None" = None,
directory_hasher: Any = None,
type_converter: "TypeConverterProtocol | None" = None,
) -> None:
"""Register all built-in semantic hashers into *registry*.

Expand Down Expand Up @@ -448,6 +468,11 @@ def register_builtin_python_type_handlers(
When ``None``, lazy resolution via the default context is used.
directory_hasher: Optional ``DirectoryHasherProtocol`` for directory tree hashing.
Defaults to ``BasicDirectoryHasher(sha256)``.
type_converter: Optional ``TypeConverterProtocol`` forwarded to
``TypeObjectHandler`` and ``FunctionSignatureExtractor`` for stable
canonical type-name resolution via ``get_logical_type()``.
When ``None`` (the default), both handlers fall back to the raw
``"type:<module>.<qualname>"`` serialisation.
"""
if file_hasher is None:
from orcapod.hashing.file_hashers import FileHasher
Expand All @@ -467,6 +492,7 @@ def register_builtin_python_type_handlers(
function_info_extractor = FunctionSignatureExtractor(
include_module=True,
include_defaults=True,
type_converter=type_converter,
)

bytes_hasher = BytesHandler()
Expand All @@ -488,7 +514,7 @@ def register_builtin_python_type_handlers(
registry.register(_types.BuiltinFunctionType, function_hasher)
registry.register(_types.MethodType, function_hasher)

registry.register(type, TypeObjectHandler())
registry.register(type, TypeObjectHandler(type_converter=type_converter))
registry.register(_types.UnionType, UnionTypeHandler())

generic_alias_hasher = GenericAliasHandler()
Expand Down
138 changes: 110 additions & 28 deletions src/orcapod/hashing/semantic_hashing/function_info_extractors.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,73 @@
import inspect
from collections.abc import Callable
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal

from orcapod.hashing.hash_utils import canonical_annotation_str, is_union_annotation
from orcapod.hashing.hash_utils import canonical_annotation_str
from orcapod.protocols.hashing_protocols import FunctionInfoExtractorProtocol
from orcapod.types import Schema

if TYPE_CHECKING:
from orcapod.protocols.semantic_types_protocols import TypeConverterProtocol

class FunctionNameExtractor:
"""
Extractor that only uses the function name for information extraction.

def _format_param(
param: inspect.Parameter,
canonical_annotation: str | None,
include_defaults: bool,
) -> str:
"""Reconstruct a parameter string from its structured components.

Produces output identical to ``str(inspect.Parameter)`` for normal inputs,
but substitutes ``canonical_annotation`` for the raw annotation string without
any string search/replace. This avoids two failure modes of the substitution
approach:

1. Accidentally matching the annotation string inside a complex default
value's ``repr`` (e.g. a dataclass whose repr embeds the type name).
2. Truncating annotations that contain ``=`` (e.g. ``Literal["a=b"]``) when
stripping defaults via ``str.split("=")``.

The format follows CPython's ``inspect.Parameter.__str__`` exactly:

- With annotation and default: ``name: type = repr(default)``
- With annotation, no default: ``name: type``
- No annotation, with default: ``name=repr(default)`` (no spaces around ``=``)
- No annotation, no default: ``name``
- ``*args`` / ``**kwargs`` get the corresponding prefix.

Args:
param: The parameter to format.
canonical_annotation: Canonical string for the annotation, or ``None``
when the parameter carries no annotation.
include_defaults: Whether to include the default value.

Returns:
A formatted parameter string.
"""
if param.kind == inspect.Parameter.VAR_POSITIONAL:
prefix = "*"
elif param.kind == inspect.Parameter.VAR_KEYWORD:
prefix = "**"
else:
prefix = ""

base = f"{prefix}{param.name}"
has_default = include_defaults and param.default is not inspect.Parameter.empty

if canonical_annotation is not None:
# "name: type" or "name: type = default"
if has_default:
return f"{base}: {canonical_annotation} = {repr(param.default)}"
return f"{base}: {canonical_annotation}"
else:
# "name" or "name=default" (CPython omits spaces around = without annotation)
if has_default:
return f"{base}={repr(param.default)}"
return base


class FunctionNameExtractor:
"""Extractor that only uses the function name for information extraction."""

def extract_function_info(
self,
Expand All @@ -26,16 +83,35 @@ def extract_function_info(


class FunctionSignatureExtractor:
"""
Extractor that uses the function signature for information extraction.
"""Extractor that uses the function signature for information extraction.

When a ``type_converter`` is provided, orcapod logical types in annotations
are replaced with their stable ``logical_type_name``
(e.g. ``"orcapod.file"``) rather than the full import path (e.g.
``"orcapod.logical_types.file_type.File"``). This prevents internal
module reorganisations from invalidating cached function-pod signatures.

For **parameter** annotations each parameter string is reconstructed from
its structured components (name, kind, canonical annotation, default) via
``_format_param``, avoiding any string search/replace.

For **return** annotations the raw annotation object is stored unchanged.
Canonicalisation happens at hash time: ``TypeObjectHandler`` (wired with the
same ``type_converter``) resolves registered types to their stable name, so
both registered and unregistered return types are handled consistently
without any special casing here.
"""

def __init__(self, include_module: bool = True, include_defaults: bool = True):
def __init__(
self,
include_module: bool = True,
include_defaults: bool = True,
type_converter: "TypeConverterProtocol | None" = None,
):
self.include_module = include_module
self.include_defaults = include_defaults
self._type_converter = type_converter

# FIXME: Fix this implementation!!
# BUG: Currently this is not using the input_types and output_types parameters
def extract_function_info(
self,
func: Callable[..., Any],
Expand All @@ -48,7 +124,7 @@ def extract_function_info(

# Use eval_str=True so that string annotations produced by
# ``from __future__ import annotations`` (PEP 563) are resolved to live
# type objects before we check for union types.
# type objects before we canonicalise them.
try:
sig = inspect.signature(func, eval_str=True)
except (NameError, TypeError, AttributeError, SyntaxError):
Expand All @@ -57,8 +133,7 @@ def extract_function_info(
# module scope).
sig = inspect.signature(func)

# Build the signature string
parts = {}
parts: dict[str, Any] = {}

# Add module if requested
if self.include_module and hasattr(func, "__module__"):
Expand All @@ -67,26 +142,33 @@ def extract_function_info(
# Add function name
parts["name"] = function_name or func.__name__

# Add parameters
tc = self._type_converter

# Build each parameter string from structured components.
# Using _format_param instead of str(param) + string substitution avoids
# accidentally matching the annotation text inside a default value's repr,
# and correctly handles annotations that contain '=' (e.g. Literal["a=b"]).
param_strs = []
for name, param in sig.parameters.items():
param_str = str(param)
for _, param in sig.parameters.items():
annotation = param.annotation
if annotation is not inspect.Parameter.empty and is_union_annotation(annotation):
old_ann = inspect.formatannotation(annotation)
new_ann = canonical_annotation_str(annotation)
# Replace ": <old_ann>" with ": <new_ann>" (first occurrence only).
# The ": " prefix distinguishes the annotation from the default value.
param_str = param_str.replace(f": {old_ann}", f": {new_ann}", 1)
if not self.include_defaults and "=" in param_str:
param_str = param_str.split("=")[0].strip()
param_strs.append(param_str)
canonical_ann = (
canonical_annotation_str(annotation, tc)
if annotation is not inspect.Parameter.empty
else None
)
param_strs.append(_format_param(param, canonical_ann, self.include_defaults))

parts["params"] = ", ".join(param_strs)

# Add return annotation if present
if sig.return_annotation is not inspect.Signature.empty:
parts["returns"] = sig.return_annotation
# Add return annotation if present.
# The raw annotation object is stored here and hashed by the type
# handler registry — TypeObjectHandler (configured with a type_converter)
# canonicalises registered logical types to their stable
# ``logical_type_name`` (e.g. ``"type:orcapod.file"``), so no special
# casing is required here.
ret_ann = sig.return_annotation
if ret_ann is not inspect.Signature.empty:
parts["returns"] = ret_ann

return parts

Expand Down
4 changes: 4 additions & 0 deletions src/orcapod/protocols/semantic_types_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
if TYPE_CHECKING:
import pyarrow as pa

from orcapod.logical_types.protocols import LogicalTypeProtocol


class TypeConverterProtocol(Protocol):
def python_type_to_arrow_type(self, python_type: DataType) -> "pa.DataType": ...
Expand Down Expand Up @@ -53,4 +55,6 @@ def get_arrow_to_python_converter(

def ensure_types_registered_for_schemas(self, *schemas: Schema) -> None: ...

def get_logical_type(self, python_type: type) -> "LogicalTypeProtocol | None": ...


Loading