Source code for pyiron_snippets.versions

"""
Tools for reliably and robustly extracting versioning info from python objects.
"""

from __future__ import annotations

import dataclasses
import importlib
import sys
from collections.abc import Callable
from types import BuiltinMethodType, ModuleType
from typing import Any, Self, TypeAlias

VersionScraperType: TypeAlias = Callable[[str], str | None]
VersionScrapingMap: TypeAlias = dict[str, VersionScraperType]


[docs] @dataclasses.dataclass(frozen=True) class VersionInfo: """ Immutable record of an object's module, qualified name, and module version. This is useful for capturing provenance metadata about classes and instances, e.g. for serialization or reproducibility tracking. Attributes: module: The dotted module path where the object's type is defined. qualname: The qualified name of the object's type within its module. A None qualname indicates that the object is itself a module. version: The version string of the top-level package, or ``None`` if no version could be determined. Example: >>> from pyiron_snippets import versions >>> >>> versions.VersionInfo.of(42) # doctest: +ELLIPSIS VersionInfo(module='builtins', qualname='int', version=...) Note: For object instances, this is version info about that object's _type_, not information about where the instance itself is living. """ module: str qualname: str | None version: str | None @property def fully_qualified_name(self) -> str: if self.qualname is None: return self.module return f"{self.module}.{self.qualname}" @property def findable_at(self) -> str: if self.qualname is None: return self.module elif self.module == "builtins": return self.qualname else: return self.fully_qualified_name @property def is_local(self) -> bool: return self.qualname is not None and "<locals>" in self.qualname @property def is_lambda(self) -> bool: return self.qualname is not None and "<lambda>" in self.qualname @property def in_main(self) -> bool: return "__main__" in self.module @property def has_version(self) -> bool: return self.version is not None
[docs] @classmethod def of( cls, obj: object, version_scraping: VersionScrapingMap | None = None, forbid_main: bool = False, forbid_locals: bool = False, forbid_lambda: bool = False, require_version: bool = False, strict: bool = False, ) -> VersionInfo: """ Construct a :class:`VersionInfo` by introspecting *obj*. If a `__module__` or `__qualname__` is immediately available, they are used, otherwise the same fields are sought on the object's type. Args: obj: The object to introspect. version_scraping: Optional mapping from top-level package names to callables that return a version string (or ``None``). Used to handle packages that don't expose ``__version__``. forbid_main: If ``True``, raise :exc:`ValueError` when the module is ``__main__``. forbid_locals: If ``True``, raise :exc:`ValueError` when the qualname contains ``<locals>`` (i.e. the type was defined inside a function). forbid_lambda: If ``True``, raise :exc:`ValueError` when the qualname contains ``<lambda>``. require_version: If ``True``, raise :exc:`ValueError` when no version can be determined for the module. strict: A shortcut to turn on all the other forbid and require flags. Returns: A new :class:`VersionInfo` instance. Raises: ValueError: If any of the ``forbid_*`` / ``require_*`` constraints are violated. """ module = get_module(obj) qualname = get_qualname(obj) version = get_version(module, version_scraping=version_scraping) info = cls(module=module, qualname=qualname, version=version) info.validate_constraints( forbid_main=forbid_main, forbid_locals=forbid_locals, forbid_lambda=forbid_lambda, require_version=require_version, strict=strict, ) return info
[docs] def validate_constraints( self, forbid_main: bool = False, forbid_locals: bool = False, forbid_lambda: bool = False, require_version: bool = False, strict: bool = False, ) -> Self: if (strict or forbid_main) and self.in_main: raise ValueError(f"Found forbidden module '__main__' in module for {self}") if (strict or forbid_locals) and self.is_local: raise ValueError(f"Found forbidden <locals> in qualname for {self}") if (strict or forbid_lambda) and self.is_lambda: raise ValueError(f"Found forbidden <lambda> in qualname for {self}") if (strict or require_version) and not self.has_version: raise ValueError(f"Could not find a version for {self}") return self
[docs] @dataclasses.dataclass(frozen=True) class VersionInfoFactory: """ A simple stateful wrapper for :class:`VersionInfo` that is useful when getting info from multiple objects with the same settings. """ version_scraping: VersionScrapingMap | None = None forbid_main: bool = False forbid_locals: bool = False require_version: bool = False
[docs] def of(self, obj: object) -> VersionInfo: return VersionInfo.of( obj, version_scraping=self.version_scraping, forbid_main=self.forbid_main, forbid_locals=self.forbid_locals, require_version=self.require_version, )
[docs] def validate_constraints(self, info: VersionInfo) -> VersionInfo: return info.validate_constraints( forbid_main=self.forbid_main, forbid_locals=self.forbid_locals, require_version=self.require_version, )
[docs] def get_module(obj: Any) -> str: """ Get module information for an arbitrary object. Note: For object instances, this is version info about that object's _type_, not information about where the instance itself is living. """ if isinstance(obj, ModuleType): return obj.__name__ # Try the obvious path first module = getattr(obj, "__module__", None) if module is not None: return module # For bound builtin methods, look up the defining class if isinstance(obj, BuiltinMethodType): # obj.__self__ is the instance, obj.__name__ is the method name self_obj = getattr(obj, "__self__", None) if self_obj is not None: for cls in type(self_obj).__mro__: if obj.__name__ in cls.__dict__: module = getattr(cls.__dict__[obj.__name__], "__module__", None) if module is not None: return module # Fall back to the type's module module = getattr(type(self_obj), "__module__", None) if module is not None: return module # Last resort module = getattr(type(obj), "__module__", None) if module is not None: return module raise AttributeError( f"Could not find a module on obj {obj} or type(obj) {type(obj)}." )
[docs] def get_qualname(obj: Any) -> str | None: """ Get module information for an arbitrary object. Note: For object instances, this is version info about that object's _type_, not information about where the instance itself is living. """ if isinstance(obj, ModuleType): return None qualname = getattr( # Prefer __qualname__ on the object (works for functions, classes, methods) obj, "__qualname__", getattr( # For C-level callable instances (ufuncs, etc.), __name__ is typically set # even when __qualname__ is not # Typically safe insofar as regular instances don't have either __name__ or # __qualname__, so we can return early instead of proceeding to type checks # if it is there obj, "__name__", getattr( # Fall back to the type's qualname (for regular instances like `42`) type(obj), "__qualname__", None, ), ), ) qualname_source = "(`obj.__qualname__` > `obj.__name__` > type(obj).__qualname__`)" if not isinstance(qualname, str): raise TypeError( f"Expected a string qualname source {qualname_source}, but {obj} had " f"'{qualname}'." ) if len(qualname) == 0: raise ValueError( f"Expected a _non-empty_ string as the qualname source {qualname_source} " f"for {obj}." ) return qualname
[docs] def get_version( module_name: str, version_scraping: VersionScrapingMap | None = None, ) -> str | None: """ Given a module name, get its associated version (if any) by iteratively checking each module level for an available version. By default, this simply looks for the :attr:`__version__` attribute on the imported module, but searching behaviour can be customized with the :arg:`version_scraping` argument. The first found version walking up the module path takes precedence over higher versions, and the version scraping map entries take precedence over the default :attr:`__version__` check at each step while walking. For :mod:`builtins`, the python interpreter version is given. Args: module_name (str): The module to recursively examine. version_scraping (VersionScrapingMap | None): Since some modules may store their version in other ways, this provides an optional map between module names and callables to leverage for extracting that module's version. Returns: (str | None): The module's version as a string, if any can be found. Warnings: This imports the module in the process, so it is not "safe". """ if module_name == "builtins": return _python_version() scraper = (version_scraping or {}).get(module_name, _scrape_version_attribute) scraped_version = scraper(module_name) next_module = module_name.rsplit(".", maxsplit=1)[0] if scraped_version is not None or next_module == module_name: return scraped_version else: return get_version(next_module, version_scraping=version_scraping)
def _scrape_version_attribute(module_name: str) -> str | None: if module_name in sys.stdlib_module_names: return _python_version() module = importlib.import_module(module_name) try: return str(module.__version__) except AttributeError: return None def _python_version() -> str: vi = sys.version_info return f"{vi.major}.{vi.minor}.{vi.micro}"