Source code for linkml.utils.deprecation

"""
Utilities for deprecating functionality and dependencies.

- Emitting DeprecationWarnings
- Tracking deprecated and removed in versions
- Fail tests when something marked as removed_in is still present in the specified version

To deprecate something:

- Create a :class:`.Deprecation` object within the `DEPRECATIONS` tuple
- Use the :func:`.deprecation_warning` function wherever the deprecated feature would be used to emit the warning

See also

- https://linkml.io/linkml/maintainers/deprecation.html
"""

import functools
import re
import warnings
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, version
from typing import Optional, TypeVar

# Stolen from https://github.com/pypa/packaging/blob/main/src/packaging/version.py
# Updated to include major, minor, and patch versions
PEP440_PATTERN = r"""
    v?
    (?:
        (?:(?P<epoch>[0-9]+)!)?                           # epoch
        (?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)
        (?P<pre>                                          # pre-release
            [-_\.]?
            (?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
            [-_\.]?
            (?P<pre_n>[0-9]+)?
        )?
        (?P<post>                                         # post release
            (?:-(?P<post_n1>[0-9]+))
            |
            (?:
                [-_\.]?
                (?P<post_l>post|rev|r)
                [-_\.]?
                (?P<post_n2>[0-9]+)?
            )
        )?
        (?P<dev>                                          # dev release
            [-_\.]?
            (?P<dev_l>dev)
            [-_\.]?
            (?P<dev_n>[0-9]+)?
        )?
    )
    (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
"""
PEP440 = re.compile(r"^\s*" + PEP440_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)


[docs] @dataclass class SemVer: """ Representation of semantic version that supports inequality comparisons. .. note:: The inequality methods test the numeric major, minor, and patch components of the version, and treat pre-release versions (rc, alpha, beta, etc.) as less than the corresponding release version. For example, 1.10.0-rc1 < 1.10.0. This is not intended to be a general SemVer inequality calculator, but used only for testing deprecations. """ major: int = 0 minor: int = 0 patch: int = 0 epoch: int | None = None pre: str | None = None pre_l: str | None = None pre_n: str | None = None post: str | None = None post_n1: str | None = None post_l: str | None = None post_n2: str | None = None dev: str | None = None dev_l: str | None = None dev_n: str | None = None local: str | None = None def __post_init__(self): self.major = int(self.major) self.minor = int(self.minor) self.patch = int(self.patch)
[docs] @classmethod def from_str(cls, v: str) -> Optional["SemVer"]: """ Create a SemVer from a string using `PEP 440 <https://peps.python.org/pep-0440/>`_ syntax. Examples: .. code-block:: python >>> version = SemVer.from_str("v0.1.0") >>> print(version) 0.1.0 """ match = PEP440.search(v) if match is None: return None return SemVer(**match.groupdict())
[docs] @classmethod def from_package(cls, package: str) -> "SemVer": """Get semver from package name, returning 0.0.0 if metadata is unavailable.""" try: v = version(package) except PackageNotFoundError: v = "0.0.0" return SemVer.from_str(v)
def __eq__(self, other: "SemVer"): # Versions are equal only if major, minor, patch AND pre-release status match # e.g., 1.10.0 != 1.10.0-rc1 return ( self.major == other.major and self.minor == other.minor and self.patch == other.patch and self.pre == other.pre ) def __lt__(self, other: "SemVer"): # fall through each if elif only if version component is equal for field in ("major", "minor", "patch"): if getattr(self, field) < getattr(other, field): return True elif getattr(self, field) > getattr(other, field): return False # If major.minor.patch are equal, check pre-release # Pre-release versions are considered less than the release version # e.g., 1.10.0-rc1 < 1.10.0 if self.pre is not None and other.pre is None: return True elif self.pre is None and other.pre is not None: return False # otherwise, equal (which is False) return False def __gt__(self, other: "SemVer"): return not (self < other) and not (self == other) def __le__(self, other: "SemVer"): return (self < other) or (self == other) def __ge__(self, other: "SemVer"): return (self > other) or (self == other) def __str__(self) -> str: return ".".join([str(item) for item in [self.major, self.minor, self.patch]])
[docs] @dataclass class Deprecation: """ Parameterization of a deprecation. """ name: str """Shorthand, unique name used to refer to this deprecation""" message: str """Message to be displayed explaining the deprecation""" deprecated_in: SemVer """Version that the feature was deprecated in""" removed_in: SemVer | None = None """Version that the feature will be removed in""" recommendation: str | None = None """Recommendation about what to do to replace the deprecated behavior""" issue: int | None = None """GitHub version describing deprecation""" def __post_init__(self): if self.deprecated_in is not None and isinstance(self.deprecated_in, str): self.deprecated_in = SemVer.from_str(self.deprecated_in) if self.removed_in is not None and isinstance(self.removed_in, str): self.removed_in = SemVer.from_str(self.removed_in) def __str__(self) -> str: msg = f"[{self.name}] " if self.removed: msg += "REMOVED" elif self.deprecated: msg += "DEPRECATED" msg += f"\n{self.message}" msg += f"\nDeprecated In: {str(self.deprecated_in)}" if self.removed_in is not None: msg += f"\nRemoved In: {str(self.removed_in)}" if self.recommendation is not None: msg += f"\nRecommendation: {self.recommendation}" if self.issue is not None: msg += f"\nSee: https://github.com/linkml/linkml/issues/{self.issue}" return msg @property def deprecated(self) -> bool: return SemVer.from_package("linkml") >= self.deprecated_in @property def removed(self) -> bool: if self.removed_in is None: return False return SemVer.from_package("linkml") >= self.removed_in
[docs] def warn(self, stack_level=3, **kwargs): if self.deprecated: # ensure filter has expected value warnings.filterwarnings("default", category=DeprecationWarning) warnings.warn(message=str(self), category=DeprecationWarning, stacklevel=stack_level, **kwargs)
DEPRECATIONS = ( Deprecation( name="metadata-flag", deprecated_in=SemVer.from_str("1.9.6"), removed_in=SemVer.from_str("1.13.0"), message=( "Use of flags `head` or `emit_metadata` to get a metadata header " "on some generators is no longer supported. " "Flags `head`, `emit_metadata` and `metadata` were being used for " "the same purpose. " "They have been unified, leaving only the flag `metadata`." ), recommendation="Use flag `metadata` instead", issue=1799, ), Deprecation( name="owlgen-skip-vacuous-min-zero-cardinality-default", deprecated_in=SemVer.from_str("1.10.0"), message=( "The default for `skip_vacuous_min_zero_cardinality_axioms` in `OwlSchemaGenerator` " "will change from `False` to `True` in a future release." ), recommendation="Set `skip_vacuous_min_zero_cardinality_axioms=True` to suppress vacuous axioms, " "or set it explicitly to `False` to preserve current behaviour and silence this warning.", issue=3190, ), Deprecation( name="owlgen-skip-vacuous-local-range-default", deprecated_in=SemVer.from_str("1.10.0"), message=( "The default for `skip_vacuous_local_range_axioms` in `OwlSchemaGenerator` " "will change from `False` to `True` in a future release." ), recommendation="Set `skip_vacuous_local_range_axioms=True` to suppress vacuous axioms, " "or set it explicitly to `False` to preserve current behaviour and silence this warning.", issue=3190, ), Deprecation( name="owlgen-consolidate-cardinality-axioms-default", deprecated_in=SemVer.from_str("1.10.0"), message=( "The default for `consolidate_cardinality_axioms` in `OwlSchemaGenerator` " "will change from `False` to `True` in a future release." ), recommendation="Set `consolidate_cardinality_axioms=True` to emit consolidated axioms, " "or set it explicitly to `False` to preserve current behaviour and silence this warning.", issue=3191, ), Deprecation( name="schema-builder-import-location", deprecated_in=SemVer.from_str("1.11.0"), removed_in=SemVer.from_str("1.12.0"), message=( "Importing SchemaBuilder from linkml.utils.schema_builder is deprecated. " "SchemaBuilder now lives only in linkml_runtime." ), recommendation="Use `from linkml_runtime.utils.schema_builder import SchemaBuilder` instead.", issue=2372, ), Deprecation( name="lint-validate-flag", deprecated_in=SemVer.from_str("1.11.0"), removed_in=SemVer.from_str("1.13.0"), message=( "The --validate flag for linkml-lint is deprecated. Metamodel validation now always runs before linting." ), recommendation="Remove the --validate flag from your command. Metamodel validation is now automatic.", issue=3259, ), Deprecation( name="lint-validate-only-flag", deprecated_in=SemVer.from_str("1.11.0"), removed_in=SemVer.from_str("1.13.0"), message=("The --validate-only flag for linkml-lint is deprecated. Use 'linkml validate schema.yaml' instead."), recommendation="Use 'linkml validate schema.yaml' for schema validation without lint rules.", issue=3387, ), ) # type: tuple[Deprecation, ...] EMITTED = set() # type: set[str]
[docs] def deprecation_warning(name: str, stack_level: int = 3): """ Call this with the name of the deprecation object wherever the deprecated functionality will be used This function will - emit a warning if the current version is greater than ``deprecated_in`` - log that the deprecated feature was accessed in ``EMITTED`` for testing deprecations and muting warnings """ global DEPRECATIONS global EMITTED dep = [dep for dep in DEPRECATIONS if dep.name == name] if len(dep) == 1: dep = dep[0] elif len(dep) > 1: raise RuntimeError(f"Duplicate deprecations found with name {name}") else: EMITTED.add(name) return if dep.name not in EMITTED: dep.warn(stack_level) EMITTED.add(name)
METADATA_FLAG = "metadata-flag" T = TypeVar("T")
[docs] def deprecated_fields(deprecated_map: dict[str, str]): """ Decorator to handle deprecated fields in dataclasses. :param deprecated_map: Mapping from old field names to new field names """ def decorator(cls: type[T]) -> type[T]: # Store the original __init__ method original_init = cls.__init__ # Create a new __init__ that handles deprecated fields @functools.wraps(original_init) def new_init(self, *args, **kwargs): # Process kwargs to handle deprecated fields for old_field, new_field in deprecated_map.items(): if old_field in kwargs: deprecation_warning(METADATA_FLAG, stack_level=4) if new_field not in kwargs: kwargs[new_field] = kwargs[old_field] # Remove the old field to prevent the "unexpected keyword argument" error del kwargs[old_field] # Call the original __init__ with the processed kwargs original_init(self, *args, **kwargs) # Replace the __init__ method cls.__init__ = new_init # Add property accessors for deprecated fields for old_field, new_field in deprecated_map.items(): # Create a property for the deprecated field using a closure def make_property(new_name): def getter(self): deprecation_warning(METADATA_FLAG, stack_level=4) return getattr(self, new_name) def setter(self, value): deprecation_warning(METADATA_FLAG, stack_level=4) setattr(self, new_name, value) return property(getter, setter) # Add the property to the class setattr(cls, old_field, make_property(new_field)) return cls return decorator