From 929012a38a5d7b7c66d0e48189b9cd7a7fb654a6 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 3 Feb 2024 19:29:14 +0300 Subject: [PATCH] add the first implementation (POC) of model conversion (the first that I decided to commit) --- pyproject.toml | 1 + .../_internal/code_tools/ast_templater.py | 20 ++ .../_internal/code_tools/context_namespace.py | 23 +- src/adaptix/_internal/common.py | 1 + src/adaptix/_internal/compat.py | 8 + src/adaptix/_internal/conversion/__init__.py | 0 .../_internal/conversion/binding_provider.py | 49 +++ .../conversion/broaching/__init__.py | 0 .../conversion/broaching/code_generator.py | 175 ++++++++++ .../conversion/broaching/definitions.py | 63 ++++ .../_internal/conversion/coercer_provider.py | 47 +++ .../conversion/converter_provider.py | 298 ++++++++++++++++++ .../_internal/conversion/facade/__init__.py | 0 .../_internal/conversion/facade/func.py | 43 +++ .../_internal/conversion/facade/provider.py | 20 ++ .../_internal/conversion/facade/retort.py | 47 +++ .../_internal/conversion/request_cls.py | 63 ++++ .../_internal/morphing/model/basic_gen.py | 11 +- .../morphing/model/dumper_provider.py | 17 +- .../morphing/model/loader_provider.py | 17 +- src/adaptix/_internal/provider/essential.py | 27 +- src/adaptix/_internal/provider/fields.py | 15 +- src/adaptix/conversion/__init__.py | 12 + 23 files changed, 926 insertions(+), 31 deletions(-) create mode 100644 src/adaptix/_internal/code_tools/ast_templater.py create mode 100644 src/adaptix/_internal/conversion/__init__.py create mode 100644 src/adaptix/_internal/conversion/binding_provider.py create mode 100644 src/adaptix/_internal/conversion/broaching/__init__.py create mode 100644 src/adaptix/_internal/conversion/broaching/code_generator.py create mode 100644 src/adaptix/_internal/conversion/broaching/definitions.py create mode 100644 src/adaptix/_internal/conversion/coercer_provider.py create mode 100644 src/adaptix/_internal/conversion/converter_provider.py create mode 100644 src/adaptix/_internal/conversion/facade/__init__.py create mode 100644 src/adaptix/_internal/conversion/facade/func.py create mode 100644 src/adaptix/_internal/conversion/facade/provider.py create mode 100644 src/adaptix/_internal/conversion/facade/retort.py create mode 100644 src/adaptix/_internal/conversion/request_cls.py create mode 100644 src/adaptix/conversion/__init__.py diff --git a/pyproject.toml b/pyproject.toml index c6c937d3..2a5017a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = 'README.md' requires-python = '>=3.8' dependencies = [ 'exceptiongroup>=1.1.3; python_version<"3.11"', + 'astunparse>=1.6.3; python_version<="3.8"', ] classifiers = [ diff --git a/src/adaptix/_internal/code_tools/ast_templater.py b/src/adaptix/_internal/code_tools/ast_templater.py new file mode 100644 index 00000000..7f27a85f --- /dev/null +++ b/src/adaptix/_internal/code_tools/ast_templater.py @@ -0,0 +1,20 @@ +import ast +from ast import AST, NodeTransformer +from typing import Mapping + + +class Substitutor(NodeTransformer): + __slots__ = ('substitution', ) + + def __init__(self, substitution: Mapping[str, AST]): + self._substitution = substitution + + def visit_Name(self, node: ast.Name): # pylint: disable=invalid-name + if node.id in self._substitution: + return self._substitution[node.id] + return node + + +def ast_substitute(template: str, **kwargs: AST) -> AST: + substitution = {f"__{key}__": value for key, value in kwargs.items()} + return Substitutor(substitution).generic_visit(ast.parse(template)) diff --git a/src/adaptix/_internal/code_tools/context_namespace.py b/src/adaptix/_internal/code_tools/context_namespace.py index a96eee9e..9ec2c81c 100644 --- a/src/adaptix/_internal/code_tools/context_namespace.py +++ b/src/adaptix/_internal/code_tools/context_namespace.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, Optional +from typing import AbstractSet, Dict, Optional class ContextNamespace(ABC): @@ -7,18 +7,35 @@ class ContextNamespace(ABC): def add(self, name: str, value: object) -> None: ... + @abstractmethod + def __contains__(self, item: str) -> bool: + ... + class BuiltinContextNamespace(ContextNamespace): - def __init__(self, namespace: Optional[Dict[str, object]] = None): + __slots__ = ('dict', '_occupied') + + def __init__( + self, + namespace: Optional[Dict[str, object]] = None, + occupied: Optional[AbstractSet[str]] = None, + ): if namespace is None: namespace = {} + if occupied is None: + occupied = set() self.dict = namespace + self._occupied = occupied def add(self, name: str, value: object) -> None: + if name in self._occupied: + raise KeyError(f"Key {name} is duplicated") if name in self.dict: if value is self.dict[name]: return raise KeyError(f"Key {name} is duplicated") - self.dict[name] = value + + def __contains__(self, item: str) -> bool: + return item in self.dict or item in self._occupied diff --git a/src/adaptix/_internal/common.py b/src/adaptix/_internal/common.py index 580c5670..023a3fca 100644 --- a/src/adaptix/_internal/common.py +++ b/src/adaptix/_internal/common.py @@ -7,6 +7,7 @@ Loader = Callable[[Any], V_co] Dumper = Callable[[K_contra], Any] Converter = Callable[..., Any] +Coercer = Callable[[Any], Any] TypeHint = Any diff --git a/src/adaptix/_internal/compat.py b/src/adaptix/_internal/compat.py index 8218d5c4..eacffd3c 100644 --- a/src/adaptix/_internal/compat.py +++ b/src/adaptix/_internal/compat.py @@ -4,3 +4,11 @@ from exceptiongroup import ExceptionGroup # type: ignore[no-redef] # noqa: A004 CompatExceptionGroup = ExceptionGroup + + +try: + from ast import unparse +except ImportError: + from astunparse import unparse # type: ignore[no-redef] + +compat_ast_unparse = unparse diff --git a/src/adaptix/_internal/conversion/__init__.py b/src/adaptix/_internal/conversion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/adaptix/_internal/conversion/binding_provider.py b/src/adaptix/_internal/conversion/binding_provider.py new file mode 100644 index 00000000..bc824e49 --- /dev/null +++ b/src/adaptix/_internal/conversion/binding_provider.py @@ -0,0 +1,49 @@ +from abc import ABC, abstractmethod +from typing import Iterable + +from ..provider.essential import CannotProvide, Mediator +from ..provider.loc_stack_filtering import LocStackChecker +from ..provider.static_provider import StaticProvider, static_provision_action +from .request_cls import BindingRequest, BindingResult, BindingSource, SourceCandidates + + +class BindingProvider(StaticProvider, ABC): + @static_provision_action + @abstractmethod + def _provide_binder(self, mediator: Mediator, request: BindingRequest) -> BindingResult: + ... + + +def iterate_source_candidates(candidates: SourceCandidates) -> Iterable[BindingSource]: + for source in reversed(candidates): + if isinstance(source, tuple): + yield from source + else: + yield source + + +class SameNameBindingProvider(BindingProvider): + def __init__(self, is_default: bool): + self._is_default = is_default + + def _provide_binder(self, mediator: Mediator, request: BindingRequest) -> BindingResult: + target_field_id = request.destination.last.id + for source in iterate_source_candidates(request.sources): + if source.last.id == target_field_id: + return BindingResult(source=source, is_default=self._is_default) + raise CannotProvide + + +class MatchingBindingProvider(BindingProvider): + def __init__(self, src_lsc: LocStackChecker, dst_lsc: LocStackChecker): + self._src_lsc = src_lsc + self._dst_lsc = dst_lsc + + def _provide_binder(self, mediator: Mediator, request: BindingRequest) -> BindingResult: + if not self._dst_lsc.check_loc_stack(mediator, request.destination.to_loc_stack()): + raise CannotProvide + + for source in iterate_source_candidates(request.sources): + if self._src_lsc.check_loc_stack(mediator, source.to_loc_stack()): + return BindingResult(source=source) + raise CannotProvide diff --git a/src/adaptix/_internal/conversion/broaching/__init__.py b/src/adaptix/_internal/conversion/broaching/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/adaptix/_internal/conversion/broaching/code_generator.py b/src/adaptix/_internal/conversion/broaching/code_generator.py new file mode 100644 index 00000000..7095a8f5 --- /dev/null +++ b/src/adaptix/_internal/conversion/broaching/code_generator.py @@ -0,0 +1,175 @@ +import ast +import itertools +from abc import ABC, abstractmethod +from ast import AST +from collections import defaultdict +from inspect import Signature +from typing import DefaultDict, Mapping, Tuple, Union + +from ...code_tools.ast_templater import ast_substitute +from ...code_tools.code_builder import CodeBuilder +from ...code_tools.context_namespace import BuiltinContextNamespace, ContextNamespace +from ...code_tools.utils import get_literal_expr +from ...compat import compat_ast_unparse +from ...model_tools.definitions import DescriptorAccessor, ItemAccessor +from ...special_cases_optimization import as_is_stub +from .definitions import ( + AccessorElement, + ConstantElement, + FunctionElement, + KeywordArg, + ParameterElement, + PositionalArg, + UnpackIterable, + UnpackMapping, +) + +BroachingPlan = Union[ + ParameterElement, + ConstantElement, + FunctionElement['BroachingPlan'], + AccessorElement['BroachingPlan'], +] + + +class GenState: + def __init__(self, ctx_namespace: ContextNamespace): + self._ctx_namespace = ctx_namespace + self._prefix_counter: DefaultDict[str, int] = defaultdict(lambda: 0) + + def register_next_id(self, prefix: str, obj: object) -> str: + number = self._prefix_counter[prefix] + self._prefix_counter[prefix] += 1 + name = f"{prefix}_{number}" + return self.register_mangled(name, obj) + + def register_mangled(self, base: str, obj: object) -> str: + if base not in self._ctx_namespace: + self._ctx_namespace.add(base, obj) + return base + + for i in itertools.count(1): + name = f'{base}_{i}' + if name not in self._ctx_namespace: + self._ctx_namespace.add(base, obj) + return name + raise RuntimeError + + +class BroachingCodeGenerator(ABC): + @abstractmethod + def produce_code(self, closure_name: str, signature: Signature) -> Tuple[str, Mapping[str, object]]: + ... + + +class BuiltinBroachingCodeGenerator(BroachingCodeGenerator): + def __init__(self, plan: BroachingPlan): + self._plan = plan + + def _create_state(self, ctx_namespace: ContextNamespace) -> GenState: + return GenState( + ctx_namespace=ctx_namespace, + ) + + def produce_code(self, closure_name: str, signature: Signature) -> Tuple[str, Mapping[str, object]]: + builder = CodeBuilder() + ctx_namespace = BuiltinContextNamespace(occupied=signature.parameters.keys()) + state = self._create_state(ctx_namespace=ctx_namespace) + + ctx_namespace.add('_closure_signature', signature) + no_types_signature = signature.replace( + parameters=[param.replace(annotation=Signature.empty) for param in signature.parameters.values()], + return_annotation=Signature.empty, + ) + with builder(f'def {closure_name}{no_types_signature}:'): + body = self._gen_plan_element_dispatch(state, self._plan) + builder += 'return ' + compat_ast_unparse(body) + + builder += f'{closure_name}.__signature__ = _closure_signature' + return builder.string(), ctx_namespace.dict + + def _gen_plan_element_dispatch(self, state: GenState, element: BroachingPlan) -> AST: + if isinstance(element, ParameterElement): + return self._gen_parameter_element(state, element) + if isinstance(element, ConstantElement): + return self._gen_constant_element(state, element) + if isinstance(element, FunctionElement): + return self._gen_function_element(state, element) + if isinstance(element, AccessorElement): + return self._gen_accessor_element(state, element) + raise TypeError + + def _gen_parameter_element(self, state: GenState, element: ParameterElement) -> AST: + return ast.Name(id=element.name, ctx=ast.Load()) + + def _gen_constant_element(self, state: GenState, element: ConstantElement) -> AST: + expr = get_literal_expr(element.value) + if expr is not None: + return ast.parse(expr) + + name = state.register_next_id('constant', element.value) + return ast.Name(id=name, ctx=ast.Load()) + + def _gen_function_element(self, state: GenState, element: FunctionElement[BroachingPlan]) -> AST: + if ( + element.func == as_is_stub + and len(element.args) == 1 + and isinstance(element.args[0], PositionalArg) + ): + return self._gen_plan_element_dispatch(state, element.args[0].element) + + if getattr(element.func, '__name__', None) is not None: + name = state.register_mangled(element.func.__name__, element.func) + else: + name = state.register_next_id('func', element.func) + + args = [] + keywords = [] + for arg in element.args: + if isinstance(arg, PositionalArg): + sub_ast = self._gen_plan_element_dispatch(state, arg.element) + args.append(sub_ast) + elif isinstance(arg, KeywordArg): + sub_ast = self._gen_plan_element_dispatch(state, arg.element) + keywords.append(ast.keyword(arg=arg.key, value=sub_ast)) + elif isinstance(arg, UnpackMapping): + sub_ast = self._gen_plan_element_dispatch(state, arg.element) + keywords.append(ast.keyword(value=sub_ast)) + elif isinstance(arg, UnpackIterable): + sub_ast = self._gen_plan_element_dispatch(state, arg.element) + args.append(ast.Starred(value=sub_ast, ctx=ast.Load())) + else: + raise TypeError + + return ast.Call( + func=ast.Name(name, ast.Load()), + args=args, + keywords=keywords, + ) + + def _gen_accessor_element(self, state: GenState, element: AccessorElement[BroachingPlan]) -> AST: + target_expr = self._gen_plan_element_dispatch(state, element.target) + if isinstance(element.accessor, DescriptorAccessor): + if element.accessor.attr_name.isidentifier(): + return ast_substitute( + f'__target_expr__.{element.accessor.attr_name}', + target_expr=target_expr, + ) + return ast_substitute( + f"getattr(__target_expr__, {element.accessor.attr_name!r})", + target_expr=target_expr, + ) + + if isinstance(element.accessor, ItemAccessor): + literal_expr = get_literal_expr(element.accessor.key) + if literal_expr is not None: + return ast_substitute( + f"__target_expr__[{literal_expr!r}]", + target_expr=target_expr, + ) + + name = state.register_next_id('accessor', element.accessor.getter) + return ast_substitute( + f"{name}(__target_expr__)", + target_expr=target_expr, + ) diff --git a/src/adaptix/_internal/conversion/broaching/definitions.py b/src/adaptix/_internal/conversion/broaching/definitions.py new file mode 100644 index 00000000..bd443ebe --- /dev/null +++ b/src/adaptix/_internal/conversion/broaching/definitions.py @@ -0,0 +1,63 @@ +from abc import ABC +from dataclasses import dataclass +from typing import Any, Callable, Generic, TypeVar, Union + +from adaptix._internal.common import VarTuple +from adaptix._internal.model_tools.definitions import Accessor + + +class BasePlanElement(ABC): + pass + + +PlanT = TypeVar('PlanT', bound=BasePlanElement) + + +@dataclass(frozen=True) +class ParameterElement(BasePlanElement): + name: str + + +@dataclass(frozen=True) +class ConstantElement(BasePlanElement): + value: Any + + +@dataclass(frozen=True) +class PositionalArg(Generic[PlanT]): + element: PlanT + + +@dataclass(frozen=True) +class KeywordArg(Generic[PlanT]): + key: str + element: PlanT + + +@dataclass(frozen=True) +class UnpackIterable(Generic[PlanT]): + element: PlanT + + +@dataclass(frozen=True) +class UnpackMapping(Generic[PlanT]): + element: PlanT + + +@dataclass(frozen=True) +class FunctionElement(BasePlanElement, Generic[PlanT]): + func: Callable[..., Any] + args: VarTuple[ + Union[ + PositionalArg[PlanT], + KeywordArg[PlanT], + UnpackIterable[PlanT], + UnpackMapping[PlanT], + ] + ] + + +@dataclass(frozen=True) +class AccessorElement(BasePlanElement, Generic[PlanT]): + target: PlanT + accessor: Accessor diff --git a/src/adaptix/_internal/conversion/coercer_provider.py b/src/adaptix/_internal/conversion/coercer_provider.py new file mode 100644 index 00000000..c34ad46b --- /dev/null +++ b/src/adaptix/_internal/conversion/coercer_provider.py @@ -0,0 +1,47 @@ +from abc import ABC, abstractmethod + +from ..common import Coercer +from ..provider.essential import CannotProvide, Mediator +from ..provider.loc_stack_filtering import LocStackChecker +from ..provider.request_cls import try_normalize_type +from ..provider.static_provider import StaticProvider, static_provision_action +from ..special_cases_optimization import as_is_stub +from ..type_tools import strip_tags +from .request_cls import CoercerRequest + + +class CoercerProvider(StaticProvider, ABC): + @static_provision_action + @abstractmethod + def _provide_coercer(self, mediator: Mediator, request: CoercerRequest) -> Coercer: + ... + + +class SameTypeCoercerProvider(CoercerProvider): + def _provide_coercer(self, mediator: Mediator, request: CoercerRequest) -> Coercer: + src_tp = request.src[-1].type + dst_tp = request.dst[-1].type + + if src_tp == dst_tp: + return as_is_stub + + norm_src = try_normalize_type(src_tp) + norm_dst = try_normalize_type(dst_tp) + if strip_tags(norm_src) == strip_tags(norm_dst): + return as_is_stub + raise CannotProvide + + +class MatchingCoercerProvider(CoercerProvider): + def __init__(self, src_lsc: LocStackChecker, dst_lsc: LocStackChecker, coercer): + self._src_lsc = src_lsc + self._dst_lsc = dst_lsc + self._coercer = coercer + + def _provide_coercer(self, mediator: Mediator, request: CoercerRequest) -> Coercer: + if ( + self._src_lsc.check_loc_stack(mediator, request.src.to_loc_stack()) + and self._dst_lsc.check_loc_stack(mediator, request.dst.to_loc_stack()) + ): + return self._coercer + raise CannotProvide diff --git a/src/adaptix/_internal/conversion/converter_provider.py b/src/adaptix/_internal/conversion/converter_provider.py new file mode 100644 index 00000000..25c46431 --- /dev/null +++ b/src/adaptix/_internal/conversion/converter_provider.py @@ -0,0 +1,298 @@ +from abc import ABC, abstractmethod +from functools import reduce +from inspect import Parameter, Signature +from typing import Any, Iterable, Optional, Sequence, cast, final + +from ..code_tools.compiler import BasicClosureCompiler, ClosureCompiler +from ..common import Converter, TypeHint +from ..conversion.broaching.code_generator import BroachingCodeGenerator, BroachingPlan, BuiltinBroachingCodeGenerator +from ..conversion.broaching.definitions import ( + AccessorElement, + FunctionElement, + KeywordArg, + ParameterElement, + PositionalArg, +) +from ..conversion.request_cls import ( + BindingDest, + BindingRequest, + BindingResult, + BindingSource, + CoercerRequest, + ConverterRequest, +) +from ..model_tools.definitions import BaseField, DefaultValue, InputField, InputShape, NoDefault, OutputShape, ParamKind +from ..morphing.model.basic_gen import NameSanitizer, compile_closure_with_globals_capturing, fetch_code_gen_hook +from ..provider.essential import CannotProvide, Mediator, mandatory_apply_by_iterable +from ..provider.fields import base_field_to_loc_map, input_field_to_loc_map +from ..provider.request_cls import LocStack +from ..provider.shape_provider import InputShapeRequest, OutputShapeRequest, provide_generic_resolved_shape +from ..provider.static_provider import StaticProvider, static_provision_action + + +class ConverterProvider(StaticProvider, ABC): + @final + @static_provision_action + def _outer_provide_converter(self, mediator: Mediator, request: ConverterRequest): + return self._provide_converter(mediator, request) + + @abstractmethod + def _provide_converter(self, mediator: Mediator, request: ConverterRequest) -> Converter: + ... + + +class BuiltinConverterProvider(ConverterProvider): + def __init__(self, *, name_sanitizer: NameSanitizer = NameSanitizer()): + self._name_sanitizer = name_sanitizer + + def _provide_converter(self, mediator: Mediator, request: ConverterRequest) -> Converter: + signature = request.signature + if len(signature.parameters.values()) == 0: + raise CannotProvide( + message='At least one parameter is required', + is_demonstrative=True, + ) + if any( + param.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for param in signature.parameters.values() + ): + raise CannotProvide( + message='Parameters specified by *args and **kwargs are not supported', + is_demonstrative=True, + ) + + source_model_field, *extra_params = map(self._param_to_base_field, signature.parameters.values()) + dst_shape = self._fetch_dst_shape(mediator, signature.return_annotation) + src_shape = self._fetch_src_shape(mediator, source_model_field) + broaching_plan = self._make_broaching_plan( + mediator=mediator, + dst_shape=dst_shape, + src_shape=src_shape, + owner_binding_src=BindingSource(), + owner_binding_dst=BindingDest(), + extra_params=tuple(map(BindingSource, extra_params)), + ) + return self._make_converter(mediator, request, broaching_plan) + + def _make_converter(self, mediator: Mediator, request: ConverterRequest, broaching_plan: BroachingPlan): + code_gen = self._create_broaching_code_gen(broaching_plan) + closure_name = self._get_closure_name(request) + dumper_code, dumper_namespace = code_gen.produce_code( + closure_name=closure_name, + signature=request.signature, + ) + return compile_closure_with_globals_capturing( + compiler=self._get_compiler(), + code_gen_hook=fetch_code_gen_hook(mediator, LocStack()), + namespace=dumper_namespace, + closure_code=dumper_code, + closure_name=closure_name, + file_name=self._get_file_name(request), + ) + + def _get_closure_name(self, request: ConverterRequest) -> str: + if request.function_name is not None: + return request.function_name + src = next(iter(request.signature.parameters.values())) + dst = self._get_type_from_annotation(request.signature.return_annotation) + return self._name_sanitizer.sanitize(f'convert_{src}_to_{dst}') + + def _get_file_name(self, request: ConverterRequest) -> str: + if request.function_name is not None: + return request.function_name + src = next(iter(request.signature.parameters.values())) + dst = self._get_type_from_annotation(request.signature.return_annotation) + return f'convert_{src}_to_{dst}' + + def _get_type_from_annotation(self, annotation: Any) -> TypeHint: + return Any if annotation == Signature.empty else annotation + + def _param_to_base_field(self, parameter: Parameter) -> BaseField: + return BaseField( + id=parameter.name, + type=self._get_type_from_annotation(parameter.annotation), + default=NoDefault() if parameter.default == Signature.empty else DefaultValue(parameter.default), + metadata={}, + original=None, + ) + + def _fetch_dst_shape(self, mediator: Mediator, return_annotation: Any) -> InputShape: + dst_loc_map = input_field_to_loc_map( + InputField( + id='__return__', + type=self._get_type_from_annotation(return_annotation), + metadata={}, + default=NoDefault(), + original=None, + is_required=True, + ) + ) + return provide_generic_resolved_shape( + mediator, + InputShapeRequest(loc_stack=LocStack(dst_loc_map)), + ) + + def _fetch_src_shape(self, mediator: Mediator, source_model_field: BaseField) -> OutputShape: + src_loc_map = base_field_to_loc_map(source_model_field) + return provide_generic_resolved_shape( + mediator, + OutputShapeRequest(loc_stack=LocStack(src_loc_map)), + ) + + def _get_compiler(self) -> ClosureCompiler: + return BasicClosureCompiler() + + def _create_broaching_code_gen(self, plan: BroachingPlan) -> BroachingCodeGenerator: + return BuiltinBroachingCodeGenerator(plan=plan) + + def _fetch_bindings( + self, + mediator: Mediator, + dst_shape: InputShape, + src_shape: OutputShape, + owner_binding_src: BindingSource, + owner_binding_dst: BindingDest, + extra_params: Sequence[BindingSource], + ) -> Iterable[BindingResult]: + model_binding_sources = tuple( + owner_binding_src.append_with(src_field) + for src_field in src_shape.fields + ) + bindings = mediator.mandatory_provide_by_iterable( + [ + BindingRequest( + sources=( + model_binding_sources, + *extra_params, + ), + destination=owner_binding_dst.append_with(dst_field), + ) + for dst_field in dst_shape.fields + ] + ) + return bindings + + def _get_nested_models_sub_plan( + self, + mediator: Mediator, + binding_src: BindingSource, + binding_dst: BindingDest, + extra_params: Sequence[BindingSource], + ) -> Optional[BroachingPlan]: + try: + dst_shape = provide_generic_resolved_shape( + mediator, + InputShapeRequest(loc_stack=binding_src.to_loc_stack()) + ) + src_shape = provide_generic_resolved_shape( + mediator, + OutputShapeRequest(loc_stack=binding_dst.to_loc_stack()) + ) + except CannotProvide: + return None + + return self._make_broaching_plan( + mediator=mediator, + dst_shape=dst_shape, + src_shape=src_shape, + extra_params=extra_params, + owner_binding_src=binding_src, + owner_binding_dst=binding_dst, + ) + + def _binding_source_to_plan(self, binding_src: BindingSource) -> BroachingPlan: + return reduce( + function=lambda plan, item: ( + AccessorElement( + target=plan, + accessor=item.accessor, + ) + ), + sequence=binding_src.tail, + initial=cast(BroachingPlan, ParameterElement(binding_src.head.id)), + ) + + def _get_coercer_sub_plan( + self, + mediator: Mediator, + binding_src: BindingSource, + binding_dst: BindingDest, + ) -> BroachingPlan: + coercer = mediator.provide( + CoercerRequest( + src=binding_src, + dst=binding_dst, + ) + ) + return FunctionElement( + func=coercer, + args=( + PositionalArg( + self._binding_source_to_plan(binding_src) + ), + ), + ) + + def _generate_binding_sub_plans( + self, + mediator: Mediator, + dst_shape: InputShape, + extra_params: Sequence[BindingSource], + bindings: Iterable[BindingResult], + owner_binding_dst: BindingDest, + ) -> Iterable[BroachingPlan]: + def generate_sub_plan(input_field: InputField, binding: BindingResult): + binding_dst = owner_binding_dst.append_with(input_field) + result = self._get_nested_models_sub_plan( + mediator=mediator, + binding_src=binding.source, + binding_dst=binding_dst, + extra_params=extra_params, + ) + if result is not None: + return result + + return self._get_coercer_sub_plan( + mediator=mediator, + binding_src=binding.source, + binding_dst=binding_dst, + ) + + return mandatory_apply_by_iterable( + generate_sub_plan, + zip(dst_shape.fields, bindings), + ) + + def _make_broaching_plan( + self, + mediator: Mediator, + dst_shape: InputShape, + src_shape: OutputShape, + extra_params: Sequence[BindingSource], + owner_binding_src: BindingSource, + owner_binding_dst: BindingDest, + ) -> BroachingPlan: + bindings = self._fetch_bindings( + mediator=mediator, + dst_shape=dst_shape, + src_shape=src_shape, + extra_params=extra_params, + owner_binding_src=owner_binding_src, + owner_binding_dst=owner_binding_dst, + ) + sub_plans = self._generate_binding_sub_plans( + mediator=mediator, + dst_shape=dst_shape, + bindings=bindings, + extra_params=extra_params, + owner_binding_dst=owner_binding_dst, + ) + return FunctionElement( + func=dst_shape.constructor, + args=tuple( + KeywordArg(param.name, sub_plan) + if param.kind == ParamKind.KW_ONLY else + PositionalArg(sub_plan) + for param, binding, sub_plan in zip(dst_shape.params, bindings, sub_plans) + ), + ) diff --git a/src/adaptix/_internal/conversion/facade/__init__.py b/src/adaptix/_internal/conversion/facade/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/adaptix/_internal/conversion/facade/func.py b/src/adaptix/_internal/conversion/facade/func.py new file mode 100644 index 00000000..2cfa138e --- /dev/null +++ b/src/adaptix/_internal/conversion/facade/func.py @@ -0,0 +1,43 @@ +import inspect +from functools import partial +from typing import Callable, Iterable, Optional, TypeVar, overload + +from ...provider.essential import Provider +from .retort import AdornedConverterRetort, ConverterRetort + +_global_retort = ConverterRetort() + +SrcT = TypeVar('SrcT') +DstT = TypeVar('DstT') +CallableT = TypeVar('CallableT', bound=Callable) + + +@overload +def impl_converter(func_stub: CallableT, /) -> CallableT: + ... + + +@overload +def impl_converter( + *, + retort: AdornedConverterRetort = _global_retort, + recipe: Iterable[Provider] = (), +) -> Callable[[CallableT], CallableT]: + ... + + +def impl_converter( + func_stub: Optional[Callable] = None, + *, + retort: AdornedConverterRetort = _global_retort, + recipe: Iterable[Provider] = (), +): + if func_stub is None: + return partial(impl_converter, retort=retort, recipe=recipe) + + if recipe: + retort = retort.extend(recipe=recipe) + return retort.produce_converter( + signature=inspect.signature(func_stub), + function_name=getattr(func_stub, '__name__', None), + ) diff --git a/src/adaptix/_internal/conversion/facade/provider.py b/src/adaptix/_internal/conversion/facade/provider.py new file mode 100644 index 00000000..e57c550b --- /dev/null +++ b/src/adaptix/_internal/conversion/facade/provider.py @@ -0,0 +1,20 @@ +from ...common import Coercer +from ...provider.essential import Provider +from ...provider.loc_stack_filtering import Pred, create_loc_stack_checker +from ..binding_provider import MatchingBindingProvider +from ..coercer_provider import MatchingCoercerProvider + + +def bind(src: Pred, dst: Pred) -> Provider: + return MatchingBindingProvider( + src_lsc=create_loc_stack_checker(src), + dst_lsc=create_loc_stack_checker(dst), + ) + + +def coercer(src: Pred, dst: Pred, func: Coercer) -> Provider: + return MatchingCoercerProvider( + src_lsc=create_loc_stack_checker(src), + dst_lsc=create_loc_stack_checker(dst), + coercer=func, + ) diff --git a/src/adaptix/_internal/conversion/facade/retort.py b/src/adaptix/_internal/conversion/facade/retort.py new file mode 100644 index 00000000..ae2621b2 --- /dev/null +++ b/src/adaptix/_internal/conversion/facade/retort.py @@ -0,0 +1,47 @@ +from inspect import Signature +from typing import Any, Callable, Iterable, Optional, TypeVar + +from adaptix import Provider + +from ...provider.shape_provider import BUILTIN_SHAPE_PROVIDER +from ...retort.operating_retort import OperatingRetort +from ..binding_provider import SameNameBindingProvider +from ..coercer_provider import SameTypeCoercerProvider +from ..converter_provider import BuiltinConverterProvider +from ..request_cls import ConverterRequest + + +class FilledConverterRetort(OperatingRetort): + recipe = [ + BUILTIN_SHAPE_PROVIDER, + BuiltinConverterProvider(), + SameNameBindingProvider(is_default=True), + SameTypeCoercerProvider(), + ] + + +AR = TypeVar('AR', bound='AdornedConverterRetort') + + +class AdornedConverterRetort(OperatingRetort): + def extend(self: AR, *, recipe: Iterable[Provider]) -> AR: + # pylint: disable=protected-access + with self._clone() as clone: + clone._inc_instance_recipe = ( + tuple(recipe) + clone._inc_instance_recipe + ) + + return clone + + def produce_converter(self, signature: Signature, function_name: Optional[str]) -> Callable[..., Any]: + return self._facade_provide( + ConverterRequest( + signature=signature, + function_name=function_name, + ), + error_message=f'Cannot produce loader for signature {signature!r}', + ) + + +class ConverterRetort(FilledConverterRetort, AdornedConverterRetort): + pass diff --git a/src/adaptix/_internal/conversion/request_cls.py b/src/adaptix/_internal/conversion/request_cls.py new file mode 100644 index 00000000..daa07c4c --- /dev/null +++ b/src/adaptix/_internal/conversion/request_cls.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from inspect import Signature +from itertools import chain, islice +from typing import Iterator, Optional, Union + +from ..common import Coercer, VarTuple +from ..datastructures import ImmutableStack +from ..model_tools.definitions import BaseField, InputField, OutputField +from ..provider.essential import Request +from ..provider.fields import base_field_to_loc_map, input_field_to_loc_map, output_field_to_loc_map +from ..provider.request_cls import LocStack + +BindingSourceItem = Union[OutputField, BaseField] + + +class BindingSource(ImmutableStack[BindingSourceItem]): + def to_loc_stack(self) -> LocStack: + return LocStack.from_iter( + chain( + (base_field_to_loc_map(self.head), ), + map(output_field_to_loc_map, self.tail) + ) + ) + + @property + def head(self) -> BaseField: + return self[0] + + @property + def tail(self) -> Iterator[OutputField]: + return islice(self, 1, None) # type: ignore[arg-type] + + +class BindingDest(ImmutableStack[InputField]): + def to_loc_stack(self) -> LocStack: + return LocStack.from_iter(map(input_field_to_loc_map, self)) + + +@dataclass(frozen=True) +class BindingResult: + source: BindingSource + is_default: bool = False + + +SourceCandidates = VarTuple[Union[BindingSource, VarTuple[BindingSource]]] + + +@dataclass(frozen=True) +class BindingRequest(Request[BindingResult]): + sources: SourceCandidates + destination: BindingDest + + +@dataclass(frozen=True) +class CoercerRequest(Request[Coercer]): + src: BindingSource + dst: BindingDest + + +@dataclass(frozen=True) +class ConverterRequest(Request): + signature: Signature + function_name: Optional[str] diff --git a/src/adaptix/_internal/morphing/model/basic_gen.py b/src/adaptix/_internal/morphing/model/basic_gen.py index 7c99e3a0..72d26825 100644 --- a/src/adaptix/_internal/morphing/model/basic_gen.py +++ b/src/adaptix/_internal/morphing/model/basic_gen.py @@ -9,8 +9,8 @@ from ...code_tools.compiler import ClosureCompiler from ...code_tools.utils import get_literal_expr from ...model_tools.definitions import InputField, InputShape, OutputField, OutputShape -from ...provider.essential import Mediator -from ...provider.request_cls import LocatedRequest, TypeHintLoc +from ...provider.essential import CannotProvide, Mediator +from ...provider.request_cls import LocatedRequest, LocStack, TypeHintLoc from ...provider.static_provider import StaticProvider, static_provision_action from .crown_definitions import ( BaseCrown, @@ -50,6 +50,13 @@ class CodeGenHookRequest(LocatedRequest[CodeGenHook]): pass +def fetch_code_gen_hook(mediator: Mediator, loc_stack: LocStack) -> CodeGenHook: + try: + return mediator.delegating_provide(CodeGenHookRequest(loc_stack=loc_stack)) + except CannotProvide: + return stub_code_gen_hook + + class CodeGenAccumulator(StaticProvider): """Accumulates all generated code. It may be useful for debugging""" diff --git a/src/adaptix/_internal/morphing/model/dumper_provider.py b/src/adaptix/_internal/morphing/model/dumper_provider.py index 79b4102e..0fc1019a 100644 --- a/src/adaptix/_internal/morphing/model/dumper_provider.py +++ b/src/adaptix/_internal/morphing/model/dumper_provider.py @@ -2,26 +2,25 @@ from adaptix._internal.provider.fields import output_field_to_loc_map -from ...code_tools.compiler import BasicClosureCompiler +from ...code_tools.compiler import BasicClosureCompiler, ClosureCompiler from ...common import Dumper from ...definitions import DebugTrail from ...model_tools.definitions import OutputShape -from ...provider.essential import CannotProvide, Mediator +from ...provider.essential import Mediator from ...provider.request_cls import DebugTrailRequest, TypeHintLoc from ...provider.shape_provider import OutputShapeRequest, provide_generic_resolved_shape from ..provider_template import DumperProvider from ..request_cls import DumperRequest from .basic_gen import ( - CodeGenHookRequest, ModelDumperGen, NameSanitizer, compile_closure_with_globals_capturing, + fetch_code_gen_hook, get_extra_targets_at_crown, get_optional_fields_at_list_crown, get_skipped_fields, get_wild_extra_targets, strip_output_shape_fields, - stub_code_gen_hook, ) from .crown_definitions import OutputNameLayout, OutputNameLayoutRequest from .dumper_gen import BuiltinModelDumperGen @@ -35,15 +34,9 @@ def _provide_dumper(self, mediator: Mediator, request: DumperRequest) -> Dumper: dumper_gen = self._fetch_model_dumper_gen(mediator, request) closure_name = self._get_closure_name(request) dumper_code, dumper_namespace = dumper_gen.produce_code(closure_name=closure_name) - - try: - code_gen_hook = mediator.delegating_provide(CodeGenHookRequest(loc_stack=request.loc_stack)) - except CannotProvide: - code_gen_hook = stub_code_gen_hook - return compile_closure_with_globals_capturing( compiler=self._get_compiler(), - code_gen_hook=code_gen_hook, + code_gen_hook=fetch_code_gen_hook(mediator, request.loc_stack), namespace=dumper_namespace, closure_code=dumper_code, closure_name=closure_name, @@ -115,7 +108,7 @@ def _get_closure_name(self, request: DumperRequest) -> str: 'model_dumper', self._name_sanitizer.sanitize(self._request_to_view_string(request)), ) - def _get_compiler(self): + def _get_compiler(self) -> ClosureCompiler: return BasicClosureCompiler() def _fetch_shape(self, mediator: Mediator, request: DumperRequest) -> OutputShape: diff --git a/src/adaptix/_internal/morphing/model/loader_provider.py b/src/adaptix/_internal/morphing/model/loader_provider.py index 75525ece..aadb2752 100644 --- a/src/adaptix/_internal/morphing/model/loader_provider.py +++ b/src/adaptix/_internal/morphing/model/loader_provider.py @@ -2,28 +2,27 @@ from adaptix._internal.provider.fields import input_field_to_loc_map -from ...code_tools.compiler import BasicClosureCompiler +from ...code_tools.compiler import BasicClosureCompiler, ClosureCompiler from ...common import Loader from ...definitions import DebugTrail from ...model_tools.definitions import InputShape -from ...provider.essential import CannotProvide, Mediator +from ...provider.essential import Mediator from ...provider.request_cls import DebugTrailRequest, StrictCoercionRequest, TypeHintLoc from ...provider.shape_provider import InputShapeRequest, provide_generic_resolved_shape from ..model.loader_gen import BuiltinModelLoaderGen, ModelLoaderProps from ..provider_template import LoaderProvider from ..request_cls import LoaderRequest from .basic_gen import ( - CodeGenHookRequest, ModelLoaderGen, NameSanitizer, compile_closure_with_globals_capturing, + fetch_code_gen_hook, get_extra_targets_at_crown, get_optional_fields_at_list_crown, get_skipped_fields, get_wild_extra_targets, has_collect_policy, strip_input_shape_fields, - stub_code_gen_hook, ) from .crown_definitions import InputNameLayout, InputNameLayoutRequest @@ -42,15 +41,9 @@ def _provide_loader(self, mediator: Mediator, request: LoaderRequest) -> Loader: loader_gen = self._fetch_model_loader_gen(mediator, request) closure_name = self._get_closure_name(request) loader_code, loader_namespace = loader_gen.produce_code(closure_name=closure_name) - - try: - code_gen_hook = mediator.delegating_provide(CodeGenHookRequest(loc_stack=request.loc_stack)) - except CannotProvide: - code_gen_hook = stub_code_gen_hook - return compile_closure_with_globals_capturing( compiler=self._get_compiler(), - code_gen_hook=code_gen_hook, + code_gen_hook=fetch_code_gen_hook(mediator, request.loc_stack), namespace=loader_namespace, closure_code=loader_code, closure_name=closure_name, @@ -128,7 +121,7 @@ def _get_closure_name(self, request: LoaderRequest) -> str: 'model_loader', self._name_sanitizer.sanitize(self._request_to_view_string(request)), ) - def _get_compiler(self): + def _get_compiler(self) -> ClosureCompiler: return BasicClosureCompiler() def _fetch_shape(self, mediator: Mediator, request: LoaderRequest) -> InputShape: diff --git a/src/adaptix/_internal/provider/essential.py b/src/adaptix/_internal/provider/essential.py index 85976516..55bffbb1 100644 --- a/src/adaptix/_internal/provider/essential.py +++ b/src/adaptix/_internal/provider/essential.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Callable, Generic, Iterable, Optional, Sequence, TypeVar, final +from typing import Any, Callable, Generic, Iterable, Optional, Sequence, TypeVar, final +from ..common import VarTuple from ..compat import CompatExceptionGroup from ..feature_requirement import HAS_NATIVE_EXC_GROUP from ..utils import with_module @@ -187,6 +188,30 @@ def mandatory_provide_by_iterable( return results +def mandatory_apply_by_iterable( + func: Callable[..., T], + args_iterable: Iterable[VarTuple[Any]], + error_describer: Optional[Callable[[], str]] = None, +) -> Iterable[T]: + results = [] + exceptions = [] + for args in args_iterable: + try: + result = func(*args) + except CannotProvide as e: + exceptions.append(e) + else: + results.append(result) + if exceptions: + raise AggregateCannotProvide.make( + '' if error_describer is None else error_describer(), + exceptions, + is_demonstrative=True, + is_terminal=True, + ) + return results + + class Provider(ABC): """An object that can process Request instances""" diff --git a/src/adaptix/_internal/provider/fields.py b/src/adaptix/_internal/provider/fields.py index 5284fe14..653b30b7 100644 --- a/src/adaptix/_internal/provider/fields.py +++ b/src/adaptix/_internal/provider/fields.py @@ -34,9 +34,22 @@ def output_field_to_loc_map(field: OutputField) -> LocMap: ) +def base_field_to_loc_map(field: BaseField) -> LocMap: + return LocMap( + TypeHintLoc( + type=field.type, + ), + FieldLoc( + field_id=field.id, + default=field.default, + metadata=field.metadata, + ), + ) + + def field_to_loc_map(field: BaseField) -> LocMap: if isinstance(field, InputField): return input_field_to_loc_map(field) if isinstance(field, OutputField): return output_field_to_loc_map(field) - raise TypeError + return base_field_to_loc_map(field) diff --git a/src/adaptix/conversion/__init__.py b/src/adaptix/conversion/__init__.py new file mode 100644 index 00000000..53ef09a4 --- /dev/null +++ b/src/adaptix/conversion/__init__.py @@ -0,0 +1,12 @@ +from adaptix._internal.conversion.facade.func import impl_converter +from adaptix._internal.conversion.facade.provider import bind, coercer +from adaptix._internal.conversion.facade.retort import AdornedConverterRetort, ConverterRetort, FilledConverterRetort + +__all__ = ( + 'impl_converter', + 'bind', + 'coercer', + 'AdornedConverterRetort', + 'FilledConverterRetort', + 'ConverterRetort', +)