"""
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