Source code for pyiron_snippets.factory

"""
Tools for making dynamically generated classes unique, and their instances pickleable.

Provides two main user-facing tools: :func:`classfactory`, which should be used
_exclusively_ as a decorator (this restriction pertains to namespace requirements for
re-importing), and `ClassFactory`, which can be used to instantiate a new factory from
some existing factory function.

In both cases, the decorated function/input argument should be a pickleable function
taking only positional arguments, and returning a tuple suitable for use in dynamic
class creation via :func:`builtins.type` -- i.e. taking a class name, a tuple of base
classes, a dictionary of class attributes, and a dictionary of values to be expanded
into kwargs for `__subclass_init__`.

The resulting factory produces classes that are (a) pickleable, and (b) the same object
as any previously built class with the same name. (Note: avoiding class degeneracy with
respect to class name is the responsibility of the person writing the factory function.)

These classes are then themselves pickleable, and produce instances which are in turn
pickleable (so long as any data they've been fed as inputs or attributes is pickleable,
i.e. here the only pickle-barrier we resolve is that of having come from a dynamically
generated class).

Since users need to build their own class factories returning classes with sensible
names, we also provide a helper function :func:`sanitize_callable_name`, which makes
sure a string is compliant with use as a class name. This is run internally on user-
provided names, and failure for the user name and sanitized name to match will give a
clear error message.

Constructed classes can, in turn be used as bases in further class factories.
"""

from __future__ import annotations

from abc import ABCMeta
from collections.abc import Callable
from functools import wraps
from importlib import import_module
from inspect import Parameter, signature
from re import sub
from typing import ClassVar


class _SingleInstance(ABCMeta):
    """Simple singleton pattern."""

    _instance = None

    def __call__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__call__(*args, **kwargs)
        return cls._instance


class _FactoryTown(metaclass=_SingleInstance):
    """
    Makes sure two factories created around the same factory function are the same
    factory object.
    """

    factories: dict[str, _ClassFactory] = {}

    @classmethod
    def clear(cls):
        """
        Remove factories.

        Can be useful if you're re-invoking `ClassFactory` on an updated factory
        function.
        """
        cls.factories = {}

    @staticmethod
    def _factory_address(factory_function: Callable) -> str:
        return f"{factory_function.__module__}.{factory_function.__qualname__}"

    def get_factory(
        self, factory_function: Callable[..., tuple[str, tuple[type, ...], dict, dict]]
    ) -> _ClassFactory:

        self._verify_function_only_takes_positional_args(factory_function)

        address = self._factory_address(factory_function)

        try:
            return self.factories[address]
        except KeyError:
            factory = self._build_factory(factory_function)
            self.factories[address] = factory
            return factory

    @staticmethod
    def _build_factory(factory_function):
        """
        Subclass :class:`_ClassFactory` and make an instance.
        """
        new_factory_class = type(
            sanitize_callable_name(
                f"{factory_function.__module__}{factory_function.__qualname__}"
                f"{factory_function.__name__.title()}"
                f"{_ClassFactory.__name__}"
            ).replace("_", ""),
            (_ClassFactory,),
            {},
            factory_function=factory_function,
        )
        return wraps(factory_function)(new_factory_class())

    @staticmethod
    def _verify_function_only_takes_positional_args(factory_function: Callable):
        parameters = signature(factory_function).parameters.values()
        if any(
            p.kind not in [Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL]
            for p in parameters
        ):
            raise InvalidFactorySignature(
                f"{_ClassFactory.__name__} can only be subclassed using factory "
                f"functions that take exclusively positional arguments, but "
                f"{factory_function.__name__} has the parameters {parameters}"
            )


_FACTORY_TOWN = _FactoryTown()


[docs] class InvalidFactorySignature(ValueError): """When the factory function's arguments are not purely positional""" pass
[docs] class InvalidClassNameError(ValueError): """When a string isn't a good class name""" pass
class _ClassFactory(metaclass=_SingleInstance): """ For making dynamically created classes the same class. """ _decorated_as_classfactory: bool = False factory_function: ClassVar[Callable[..., tuple[str, tuple[type, ...], dict, dict]]] class_registry: ClassVar[dict[str, type[_FactoryMade]]] = {} def __init_subclass__(cls, /, factory_function, **kwargs): super().__init_subclass__(**kwargs) cls.factory_function = staticmethod(factory_function) cls.class_registry = {} def __call__(self, *args) -> type[_FactoryMade]: name, bases, class_dict, sc_init_kwargs = self.factory_function(*args) self._verify_name_is_legal(name) try: return self.class_registry[name] except KeyError: factory_made = self._build_class( name, bases, class_dict, sc_init_kwargs, args, ) self.class_registry[name] = factory_made return factory_made @classmethod def clear(cls, *class_names, skip_missing=True): """ Remove constructed class(es). Can be useful if you've updated the constructor and want to remove old instances. Args: *class_names (str): The names of classes to remove. Removes all of them when empty. skip_missing (bool): Whether to pass over key errors when a name is requested that is not currently in the class registry. (Default is True, let missing names pass silently.) """ if len(class_names) == 0: cls.class_registry = {} else: for name in class_names: try: cls.class_registry.pop(name) except KeyError: if skip_missing: continue else: raise KeyError(f"Could not find class {name}") from None def _build_class( self, name, bases, class_dict, sc_init_kwargs, class_factory_args ) -> type[_FactoryMade]: if "__module__" not in class_dict: class_dict["__module__"] = self.factory_function.__module__ if "__qualname__" not in class_dict: class_dict["__qualname__"] = f"{self.factory_function.__qualname__}.{name}" sc_init_kwargs["class_factory"] = self sc_init_kwargs["class_factory_args"] = class_factory_args if not any(_FactoryMade in base.mro() for base in bases): bases = (_FactoryMade, *bases) return type(name, bases, class_dict, **sc_init_kwargs) @staticmethod def _verify_name_is_legal(name): sanitized_name = sanitize_callable_name(name) if name != sanitized_name: raise InvalidClassNameError( f"The class name {name} failed to match with its sanitized version" f"({sanitized_name}), please supply a valid class name." ) def __reduce__(self): if ( self._decorated_as_classfactory and "<locals>" not in self.factory_function.__qualname__ ): return ( _import_object, (self.factory_function.__module__, self.factory_function.__qualname__), ) else: return (_FACTORY_TOWN.get_factory, (self.factory_function,)) def _import_object(module_name, qualname): module = import_module(module_name) obj = module for name in qualname.split("."): obj = getattr(obj, name) return obj class _FactoryMade: """ A mix-in to make class-factory-produced classes pickleable. If the factory is used as a decorator for another function (or class), it will conflict with this function (i.e. the owned function will be the true function, and will mismatch with imports from that location, which will return the post-decorator factory made class). This can be resolved by setting the :attr:`_reduce_imports_as` attribute to a tuple of the (module, qualname) obtained from the decorated definition in order to manually specify where it should be re-imported from. (DEPRECATED alternative: set :attr:`_class_returns_from_decorated_function` attribute to be the decorated function in the decorator definition.) """ # DEPRECATED: Use _reduce_imports_as instead _class_returns_from_decorated_function: ClassVar[Callable | None] = None _reduce_imports_as: ClassVar[tuple[str, str] | None] = None # Module and qualname def __init_subclass__(cls, /, class_factory, class_factory_args, **kwargs): super().__init_subclass__(**kwargs) cls._class_factory = class_factory cls._class_factory_args = class_factory_args cls._factory_town = _FACTORY_TOWN def __reduce__(self): if ( self._class_returns_from_decorated_function is not None and "<locals>" not in self._class_returns_from_decorated_function.__qualname__ ): # When we create a class by decorating some other function, this class # conflicts with its own factory_function attribute in the namespace, so we # rely on directly re-importing the factory return ( _instantiate_from_decorated, ( self._class_returns_from_decorated_function.__module__, self._class_returns_from_decorated_function.__qualname__, self.__getnewargs_ex__(), ), self.__getstate__(), ) elif ( self._reduce_imports_as is not None and "<locals>" not in self._reduce_imports_as[1] ): return ( _instantiate_from_decorated, ( self._reduce_imports_as[0], self._reduce_imports_as[1], self.__getnewargs_ex__(), ), self.__getstate__(), ) else: return ( _instantiate_from_factory, ( self._class_factory, self._class_factory_args, self.__getnewargs_ex__(), ), self.__getstate__(), ) def __getnewargs_ex__(self): # Child classes can override this as needed return (), {} def __getstate__(self): # Python <3.11 compatibility try: return super().__getstate__() except AttributeError: return dict(self.__dict__) def __setstate__(self, state): # Python <3.11 compatibility try: super().__setstate__(state) except AttributeError: self.__dict__.update(**state) def _instantiate_from_factory(factory, factory_args, newargs_ex): """ Recover the dynamic class, then invoke its `__new__` to avoid instantiation (and the possibility of positional args in `__init__`). """ cls = factory(*factory_args) return cls.__new__(cls, *newargs_ex[0], **newargs_ex[1]) def _instantiate_from_decorated(module, qualname, newargs_ex): """ In case the class comes from a decorated function, we need to import it directly. """ cls = _import_object(module, qualname) return cls.__new__(cls, *newargs_ex[0], **newargs_ex[1])
[docs] def classfactory( factory_function: Callable[..., tuple[str, tuple[type, ...], dict, dict]], ) -> _ClassFactory: """ A decorator for building dynamic class factories whose classes are unique and whose terminal instances can be pickled. Under the hood, classes created by factories get dependence on :class:`_FactoryMade` mixed in. This class leverages :meth:`__reduce__` and :meth:`__init_subclass__` and uses up the class namespace :attr:`_class_factory` and :attr:`_class_factory_args` to hold data (using up corresponding public variable names in the :meth:`__init_subclass__` kwargs), so any interference with these fields may cause unexpected side effects. For un-pickling, the dynamic class gets recreated then its :meth:`__new__` is called using `__newargs_ex__`; a default implementation returning no arguments is provided on :class:`_FactoryMade` but can be overridden. Args: factory_function (Callable[..., tuple[str, tuple[type, ...], dict, dict]]): A function returning arguments that would be passed to `builtins.type` to dynamically generate a class. The function must accept exclusively positional arguments Returns: (type[_ClassFactory]): A new callable that returns unique classes whose instances can be pickled. Notes: If the :param:`factory_function` itself, or any data stored on instances of its resulting class(es) cannot be pickled, then the instances will not be able to be pickled. Here we only remove the trouble associated with pickling dynamically created classes. If the `__init_subclass__` kwargs are exploited, remember that these are subject to all the same "gotchas" as their regular non-factory use; namely, all child classes must specify _all_ parent class kwargs in order to avoid them getting overwritten by the parent class defaults! Dynamically generated classes can, in turn, be used as base classes for further `@classfactory` decorated factory functions. Warnings: Use _exclusively_ as a decorator. For an inline constructor for an existing callable, use :class:`ClassFactory` instead. Examples: >>> import pickle >>> >>> from pyiron_snippets.factory import classfactory >>> from abc import ABC >>> >>> class HasN(ABC): ... '''Some class I want to make dynamically subclass.''' ... def __init_subclass__(cls, /, n=0, s="foo", **kwargs): ... super(HasN, cls).__init_subclass__(**kwargs) ... cls.n = n ... cls.s = s ... ... def __init__(self, x, y=0): ... self.x = x ... self.y = y >>> >>> @classfactory ... def has_n_factory(n, s="wrapped_function", /): ... return ( ... f"{HasN.__name__}{n}{s}", # New class name ... (HasN,), # Base class(es) ... {}, # Class attributes dictionary ... {"n": n, "s": s} ... # dict of `builtins.type` kwargs (passed to `__init_subclass__`) ... ) >>> >>> Has2 = has_n_factory(2, "my_dynamic_class") >>> HasToo = has_n_factory(2, "my_dynamic_class") >>> HasToo is Has2 True >>> foo = Has2(42, y=-1) >>> print(foo.n, foo.s, foo.x, foo.y) 2 my_dynamic_class 42 -1 >>> reloaded = pickle.loads(pickle.dumps(foo)) # doctest: +SKIP >>> print(reloaded.n, reloaded.s, reloaded.x, reloaded.y) # doctest: +SKIP 2 my_dynamic_class 42 -1 # doctest: +SKIP """ factory = _FACTORY_TOWN.get_factory(factory_function) factory._decorated_as_classfactory = True return factory
[docs] class ClassFactory: """ A constructor for new class factories. Use on existing class factory callables, _not_ as a decorator. Cf. the :func:`classfactory` decorator for more info. """ def __new__(cls, factory_function): return _FACTORY_TOWN.get_factory(factory_function)
[docs] def sanitize_callable_name(name: str): """ A helper class for sanitizing a string so it's appropriate as a class/function name. """ # Replace non-alphanumeric characters except underscores sanitized_name = sub(r"\W+", "_", name) # Ensure the name starts with a letter or underscore if ( len(sanitized_name) > 0 and not sanitized_name[0].isalpha() and sanitized_name[0] != "_" ): sanitized_name = "_" + sanitized_name return sanitized_name