diff --git a/.changelog/4883.removed b/.changelog/4883.removed new file mode 100644 index 0000000000..385d399335 --- /dev/null +++ b/.changelog/4883.removed @@ -0,0 +1 @@ +`opentelemetry-instrumentation`: remove the runtime `packaging` dependency in favor of an internal PEP 440/508 implementation diff --git a/opentelemetry-instrumentation/pyproject.toml b/opentelemetry-instrumentation/pyproject.toml index c18f0681d2..ff3b0f3264 100644 --- a/opentelemetry-instrumentation/pyproject.toml +++ b/opentelemetry-instrumentation/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "opentelemetry-api ~= 1.4", "opentelemetry-semantic-conventions == 0.66b0.dev", "wrapt >= 1.0.0, < 3.0.0", - "packaging >= 18.0", ] [project.scripts] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/__init__.py new file mode 100644 index 0000000000..7aa684f0eb --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/__init__.py @@ -0,0 +1,12 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Internal, dependency-free replacements for the subset of ``packaging`` used +by OpenTelemetry instrumentation. + +This package exists so that ``opentelemetry-instrumentation`` and the +instrumentations that build on it do not need ``packaging`` as a runtime +dependency. Only the behaviour actually relied on by this repository is +implemented, following PEP 440 (versions and specifiers) and PEP 508 +(requirements and environment markers). +""" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/markers.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/markers.py new file mode 100644 index 0000000000..715b2576b7 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/markers.py @@ -0,0 +1,368 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""A minimal PEP 508 environment marker implementation. + +This provides the subset of :mod:`packaging.markers` that OpenTelemetry +instrumentation relies on: parsing a marker expression such as +``extra == "instruments"`` (optionally combined with ``and``/``or`` and other +environment markers) and evaluating it against an environment. +""" + +from __future__ import annotations + +from operator import eq, ne +from os import name as os_name +from platform import ( + machine, + python_implementation, + python_version, + python_version_tuple, + release, + system, +) +from platform import ( + version as platform_version, +) +from sys import implementation +from sys import platform as sys_platform +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union + +from opentelemetry.instrumentation._packaging.specifiers import ( + InvalidSpecifier, + Specifier, +) + +__all__ = [ + "InvalidMarker", + "Marker", + "UndefinedComparison", + "UndefinedEnvironmentName", + "default_environment", +] + + +class InvalidMarker(ValueError): + """Raised when a marker string does not conform to PEP 508.""" + + +class UndefinedComparison(ValueError): + """Raised when a marker uses a comparison that is undefined for its values.""" + + +class UndefinedEnvironmentName(ValueError): + """Raised when a marker references a value missing from the environment.""" + + +_VARIABLES = frozenset( + { + "implementation_name", + "implementation_version", + "os_name", + "platform_machine", + "platform_release", + "platform_system", + "platform_version", + "python_full_version", + "platform_python_implementation", + "python_version", + "sys_platform", + "extra", + } +) + +_MARKERS_REQUIRING_VERSION = frozenset( + { + "python_full_version", + "platform_release", + "implementation_version", + "python_version", + } +) + +# The PyPA Dependency Specifiers spec (which supersedes PEP 508) directs +# tools to "treat >= and <= as equivalent to == and treat > and < as always +# being False" for ordered comparisons on string marker fields. This mirrors +# packaging.markers._operators. (PEP 508's older wording instead fell back to +# Python string comparison for these operators.) +_STRING_OPERATORS: Dict[str, Callable[[str, Any], bool]] = { + "in": lambda lhs, rhs: lhs in rhs, + "not in": lambda lhs, rhs: lhs not in rhs, + "<": lambda lhs, rhs: False, + "<=": eq, + "==": eq, + "!=": ne, + ">=": eq, + ">": lambda lhs, rhs: False, +} + +# Comparison/boolean operators recognized by the tokenizer, longest first. +_OPERATORS = ("===", "~=", "==", "!=", "<=", ">=", "<", ">") + +# A parsed marker node is either a comparison tuple, or an ("and"/"or", [nodes]). +_Value = Tuple[str, str] # ("var", name) or ("str", literal) +_Comparison = Tuple[str, _Value, str, _Value] # ("cmp", lhs, op, rhs) +_Node = Union[_Comparison, Tuple[str, List["_Node"]]] + + +def canonicalize_name(name: str) -> str: + """Normalize a project/extra name per PEP 503.""" + value = name.lower().replace("_", "-").replace(".", "-") + while "--" in value: + value = value.replace("--", "-") + return value + + +def _format_full_version(info: Any) -> str: + version = f"{info.major}.{info.minor}.{info.micro}" + kind = info.releaselevel + if kind != "final": + version += kind[0] + str(info.serial) + return version + + +def default_environment() -> Dict[str, str]: + """Return the default marker environment for the current Python process.""" + return { + "implementation_name": implementation.name, + "implementation_version": _format_full_version(implementation.version), + "os_name": os_name, + "platform_machine": machine(), + "platform_release": release(), + "platform_system": system(), + "platform_version": platform_version(), + "python_full_version": python_version(), + "platform_python_implementation": python_implementation(), + "python_version": ".".join(python_version_tuple()[:2]), + "sys_platform": sys_platform, + } + + +def _tokenize(marker: str) -> List[Tuple[str, str]]: + tokens: List[Tuple[str, str]] = [] + index = 0 + length = len(marker) + while index < length: + char = marker[index] + if char.isspace(): + index += 1 + continue + if char in ("(", ")"): + kind = "LPAREN" if char == "(" else "RPAREN" + tokens.append((kind, char)) + index += 1 + continue + if char in ("'", '"'): + end = marker.find(char, index + 1) + if end == -1: + raise InvalidMarker( + f"Unterminated string in marker: {marker!r}" + ) + tokens.append(("STR", marker[index + 1 : end])) + index = end + 1 + continue + matched_operator = next( + (op for op in _OPERATORS if marker.startswith(op, index)), None + ) + if matched_operator is not None: + tokens.append(("OP", matched_operator)) + index += len(matched_operator) + continue + if char.isalpha() or char == "_": + start = index + while index < length and ( + marker[index].isalnum() or marker[index] in "_." + ): + index += 1 + word = marker[start:index] + lowered = word.lower() + if lowered in ("and", "or"): + tokens.append(("BOOL", lowered)) + elif lowered == "in": + tokens.append(("OP", "in")) + elif lowered == "not": + tokens.append(("NOT", "not")) + else: + tokens.append(("VAR", word)) + continue + raise InvalidMarker( + f"Unexpected character {char!r} in marker: {marker!r}" + ) + return tokens + + +class _Parser: + def __init__(self, marker: str) -> None: + self._tokens = _tokenize(marker) + self._pos = 0 + self._marker = marker + + def _peek(self) -> Optional[Tuple[str, str]]: + if self._pos < len(self._tokens): + return self._tokens[self._pos] + return None + + def _next(self) -> Tuple[str, str]: + token = self._peek() + if token is None: + raise InvalidMarker(f"Unexpected end of marker: {self._marker!r}") + self._pos += 1 + return token + + def parse(self) -> _Node: + node = self._parse_or() + if self._peek() is not None: + raise InvalidMarker(f"Trailing tokens in marker: {self._marker!r}") + return node + + def _parse_or(self) -> _Node: + nodes = [self._parse_and()] + while self._peek() == ("BOOL", "or"): + self._next() + nodes.append(self._parse_and()) + return nodes[0] if len(nodes) == 1 else ("or", nodes) + + def _parse_and(self) -> _Node: + nodes = [self._parse_atom()] + while self._peek() == ("BOOL", "and"): + self._next() + nodes.append(self._parse_atom()) + return nodes[0] if len(nodes) == 1 else ("and", nodes) + + def _parse_atom(self) -> _Node: + token = self._peek() + if token is not None and token[0] == "LPAREN": + self._next() + node = self._parse_or() + closing = self._next() + if closing[0] != "RPAREN": + raise InvalidMarker( + f"Expected ')' in marker: {self._marker!r}" + ) + return node + return self._parse_comparison() + + def _parse_comparison(self) -> _Node: + lhs = self._parse_value() + op = self._parse_operator() + rhs = self._parse_value() + return ("cmp", lhs, op, rhs) + + def _parse_operator(self) -> str: + token = self._next() + if token[0] == "NOT": + following = self._next() + if following != ("OP", "in"): + raise InvalidMarker( + f"Expected 'not in' in marker: {self._marker!r}" + ) + return "not in" + if token[0] == "OP": + return token[1] + raise InvalidMarker(f"Expected operator in marker: {self._marker!r}") + + def _parse_value(self) -> _Value: + token = self._next() + if token[0] == "STR": + return ("str", token[1]) + if token[0] == "VAR": + if token[1] not in _VARIABLES: + raise InvalidMarker( + f"Unknown marker variable {token[1]!r}: {self._marker!r}" + ) + return ("var", token[1]) + raise InvalidMarker(f"Expected value in marker: {self._marker!r}") + + +def _normalize_node(node: _Node) -> _Node: + """Canonicalize any ``extra`` literal in the parsed marker (PEP 685).""" + kind = node[0] + if kind in ("and", "or"): + return (kind, [_normalize_node(child) for child in node[1]]) + _, lhs, op, rhs = node + if lhs == ("var", "extra") and rhs[0] == "str": + rhs = ("str", canonicalize_name(rhs[1])) + elif rhs == ("var", "extra") and lhs[0] == "str": + lhs = ("str", canonicalize_name(lhs[1])) + return ("cmp", lhs, op, rhs) + + +def _eval_op(lhs: str, op: str, rhs: str, key: str) -> bool: + if key in _MARKERS_REQUIRING_VERSION: + try: + spec = Specifier(f"{op}{rhs}") + except InvalidSpecifier: + pass + else: + return spec.contains(lhs, prereleases=True) + oper = _STRING_OPERATORS.get(op) + if oper is None: + raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.") + return oper(lhs, rhs) + + +def _evaluate(node: _Node, environment: Mapping[str, str]) -> bool: + kind = node[0] + if kind == "or": + return any(_evaluate(child, environment) for child in node[1]) + if kind == "and": + return all(_evaluate(child, environment) for child in node[1]) + + _, lhs, op, rhs = node + if lhs[0] == "var": + key = lhs[1] + try: + lhs_value = environment[key] + except KeyError as exc: + raise UndefinedEnvironmentName( + f"{key!r} does not exist in evaluation environment." + ) from exc + rhs_value = rhs[1] + else: + key = rhs[1] + try: + rhs_value = environment[key] + except KeyError as exc: + raise UndefinedEnvironmentName( + f"{key!r} does not exist in evaluation environment." + ) from exc + lhs_value = lhs[1] + return _eval_op(lhs_value, op, rhs_value, key) + + +class Marker: + def __init__(self, marker: str) -> None: + self._markers: _Node = _normalize_node(_Parser(marker).parse()) + + def __str__(self) -> str: + return _format_node(self._markers, top_level=True) + + def __repr__(self) -> str: + return f"" + + def evaluate( + self, environment: Optional[Mapping[str, str]] = None + ) -> bool: + current: Dict[str, str] = default_environment() + current["extra"] = "" + if environment is not None: + current.update(environment) + if "extra" in current: + extra = current["extra"] + current["extra"] = canonicalize_name(extra) if extra else "" + return _evaluate(self._markers, current) + + +def _format_value(value: _Value) -> str: + if value[0] == "var": + return value[1] + return f'"{value[1]}"' + + +def _format_node(node: _Node, top_level: bool = False) -> str: + kind = node[0] + if kind in ("and", "or"): + inner = f" {kind} ".join(_format_node(child) for child in node[1]) + return inner if top_level else f"({inner})" + _, lhs, op, rhs = node + return f"{_format_value(lhs)} {op} {_format_value(rhs)}" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/requirements.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/requirements.py new file mode 100644 index 0000000000..22cd0d0b8e --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/requirements.py @@ -0,0 +1,105 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""A minimal PEP 508 requirement implementation. + +This provides the subset of :mod:`packaging.requirements` that OpenTelemetry +instrumentation relies on: parsing a requirement string such as +``flask >= 2.2.0; extra == "instruments"`` into its ``name``, ``specifier`` and +``marker`` components. +""" + +from __future__ import annotations + +from re import compile as re_compile +from typing import Optional, Set + +from opentelemetry.instrumentation._packaging.markers import ( + InvalidMarker, + Marker, +) +from opentelemetry.instrumentation._packaging.specifiers import ( + InvalidSpecifier, + SpecifierSet, +) + +__all__ = ["InvalidRequirement", "Requirement"] + +# PEP 508 project name: alphanumerics separated by ``.``, ``-`` or ``_``. +_NAME_REGEX = re_compile( + r"^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)", +) + + +class InvalidRequirement(ValueError): + """Raised when a requirement string does not conform to PEP 508.""" + + +class Requirement: + def __init__(self, requirement_string: str) -> None: + marker_string: Optional[str] = None + remainder = requirement_string + if ";" in remainder: + remainder, marker_string = remainder.split(";", 1) + + remainder = remainder.strip() + name_match = _NAME_REGEX.match(remainder) + if name_match is None: + raise InvalidRequirement( + f"Invalid requirement: {requirement_string!r}" + ) + + self.name: str = name_match.group(1) + remainder = remainder[name_match.end() :].strip() + + self.extras: Set[str] = set() + if remainder.startswith("["): + end = remainder.find("]") + if end == -1: + raise InvalidRequirement( + f"Invalid requirement: {requirement_string!r}" + ) + self.extras = { + extra.strip() + for extra in remainder[1:end].split(",") + if extra.strip() + } + remainder = remainder[end + 1 :].strip() + + self.url: Optional[str] = None + if remainder.startswith("@"): + self.url = remainder[1:].strip() + remainder = "" + + try: + self.specifier: SpecifierSet = SpecifierSet(remainder) + except InvalidSpecifier as exc: + raise InvalidRequirement( + f"Invalid requirement: {requirement_string!r}" + ) from exc + + self.marker: Optional[Marker] = None + if marker_string is not None and marker_string.strip(): + try: + self.marker = Marker(marker_string) + except InvalidMarker as exc: + raise InvalidRequirement( + f"Invalid requirement: {requirement_string!r}" + ) from exc + + def __str__(self) -> str: + parts = [self.name] + if self.extras: + parts.append(f"[{','.join(sorted(self.extras))}]") + if len(self.specifier): + parts.append(str(self.specifier)) + if self.url: + parts.append(f"@ {self.url}") + if self.marker: + parts.append(" ") + if self.marker: + parts.append(f"; {self.marker}") + return "".join(parts) + + def __repr__(self) -> str: + return f"" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/specifiers.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/specifiers.py new file mode 100644 index 0000000000..6b8317bab1 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/specifiers.py @@ -0,0 +1,459 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""A minimal PEP 440 version specifier implementation. + +This provides the subset of :mod:`packaging.specifiers` that OpenTelemetry +instrumentation relies on: parsing a specifier set such as ``>=1.0,<2.0`` and +testing whether a version satisfies it via :meth:`SpecifierSet.contains` and +:meth:`SpecifierSet.filter`, including the PEP 440 pre-release matching rules. +""" + +from __future__ import annotations + +from itertools import takewhile +from typing import Iterable, Iterator, List, Optional, Tuple, Union + +from opentelemetry.instrumentation._packaging.version import ( + InvalidVersion, + Version, +) + +__all__ = ["InvalidSpecifier", "Specifier", "SpecifierSet"] + +_OPERATORS = ("===", "~=", "==", "!=", "<=", ">=", "<", ">") + + +class InvalidSpecifier(ValueError): + """Raised when a specifier string does not conform to PEP 440.""" + + +def _coerce_version(item: Union[str, Version]) -> Optional[Version]: + if isinstance(item, Version): + return item + try: + return Version(item) + except InvalidVersion: + return None + + +def _canonical_public(item: Union[str, Version]) -> str: + """Normalize ``item`` to its public (local-less) version string.""" + version = item if isinstance(item, Version) else Version(item) + return version.public + + +def _version_split(version: str) -> List[str]: + result: List[str] = [] + epoch, _, rest = version.rpartition("!") + result.append(epoch or "0") + for item in rest.split("."): + # Split a trailing pre-release attached to a numeric segment, e.g. the + # "1a1" in "1.1a1" becomes the two components "1" and "a1". + index = len(item) + for pos, char in enumerate(item): + if char in "abcr": + index = pos + break + if 0 < index < len(item): + result.append(item[:index]) + result.append(item[index:]) + else: + result.append(item) + return result + + +def _version_join(components: List[str]) -> str: + epoch, *rest = components + return f"{epoch}!{'.'.join(rest)}" + + +def _is_not_suffix(segment: str) -> bool: + return not any( + segment.startswith(prefix) + for prefix in ("dev", "a", "b", "rc", "post") + ) + + +def _numeric_prefix_len(split: List[str]) -> int: + count = 0 + for segment in split: + if not segment.isdigit(): + break + count += 1 + return count + + +def _left_pad(split: List[str], target_numeric_len: int) -> List[str]: + numeric_len = _numeric_prefix_len(split) + pad_needed = target_numeric_len - numeric_len + if pad_needed <= 0: + return split + return [ + *split[:numeric_len], + *(["0"] * pad_needed), + *split[numeric_len:], + ] + + +def _earliest_prerelease(version: Version) -> Version: + parts = [version.base_version] + if version.pre is not None: + parts.append(f"{version.pre[0]}{version.pre[1]}") + if version.is_postrelease: + parts.append(f".post{version.post}") + parts.append(".dev0") + return Version("".join(parts)) + + +def _post_base(version: Version) -> Version: + parts = [version.base_version] + if version.pre is not None: + parts.append(f"{version.pre[0]}{version.pre[1]}") + return Version("".join(parts)) + + +class Specifier: + """A single PEP 440 specifier, e.g. ``>=1.0`` or ``==1.4.*``.""" + + def __init__( + self, spec: str = "", prereleases: Optional[bool] = None + ) -> None: + spec = spec.strip() + operator = "" + for candidate in _OPERATORS: + if spec.startswith(candidate): + operator = candidate + break + if not operator: + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") + + version = spec[len(operator) :].strip() + if not version: + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") + + if operator != "===": + wildcard = version.endswith(".*") + if wildcard and operator not in ("==", "!="): + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") + base = version[:-2] if wildcard else version + try: + parsed = Version(base) + except InvalidVersion as exc: + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") from exc + if wildcard and ( + parsed.pre is not None + or parsed.is_postrelease + or parsed.is_devrelease + or parsed.local is not None + ): + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") + if operator == "~=" and len(parsed.release) < 2: + raise InvalidSpecifier(f"Invalid specifier: {spec!r}") + + self._spec: Tuple[str, str] = (operator, version) + self._prereleases = prereleases + + @property + def operator(self) -> str: + return self._spec[0] + + @property + def version(self) -> str: + return self._spec[1] + + @property + def prereleases(self) -> Optional[bool]: + if self._prereleases is not None: + return self._prereleases + operator, version = self._spec + if operator == "!=": + return False + if operator == "==" and version.endswith(".*"): + return False + if operator == "===": + return None + return Version(version).is_prerelease + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + + def __str__(self) -> str: + return f"{self._spec[0]}{self._spec[1]}" + + def __repr__(self) -> str: + return f"" + + def __hash__(self) -> int: + return hash(self._spec) + + def __eq__(self, other: object) -> bool: + if isinstance(other, str): + try: + other = Specifier(other) + except InvalidSpecifier: + return NotImplemented + elif not isinstance(other, Specifier): + return NotImplemented + return self._spec == other._spec + + def _compare_compatible(self, prospective: Version, spec: str) -> bool: + prefix = _version_join( + list(takewhile(_is_not_suffix, _version_split(spec)))[:-1] + ) + prefix += ".*" + return self._compare_greater_than_equal( + prospective, spec + ) and self._compare_equal(prospective, prefix) + + @staticmethod + def _compare_equal(prospective: Version, spec: str) -> bool: + if spec.endswith(".*"): + normalized_spec = _canonical_public(spec[:-2]) + split_spec = _version_split(normalized_spec) + spec_numeric_len = _numeric_prefix_len(split_spec) + + normalized_prospective = _canonical_public(prospective) + split_prospective = _version_split(normalized_prospective) + padded_prospective = _left_pad(split_prospective, spec_numeric_len) + shortened_prospective = padded_prospective[: len(split_spec)] + return shortened_prospective == split_spec + + spec_version = Version(spec) + if not spec_version.local: + prospective = Version(prospective.public) + return prospective == spec_version + + def _compare_not_equal(self, prospective: Version, spec: str) -> bool: + return not self._compare_equal(prospective, spec) + + @staticmethod + def _compare_less_than_equal(prospective: Version, spec: str) -> bool: + return Version(prospective.public) <= Version(spec) + + @staticmethod + def _compare_greater_than_equal(prospective: Version, spec: str) -> bool: + return Version(prospective.public) >= Version(spec) + + @staticmethod + def _compare_less_than(prospective: Version, spec_str: str) -> bool: + spec = Version(spec_str) + if not prospective < spec: + return False + if ( + not spec.is_prerelease + and prospective.is_prerelease + and prospective >= _earliest_prerelease(spec) + ): + return False + return True + + @staticmethod + def _compare_greater_than(prospective: Version, spec_str: str) -> bool: + spec = Version(spec_str) + if not prospective > spec: + return False + if ( + not spec.is_postrelease + and prospective.is_postrelease + and _post_base(prospective) == spec + ): + return False + if ( + prospective.local is not None + and Version(prospective.public) == spec + ): + return False + return True + + @staticmethod + def _compare_arbitrary( + prospective: Union[Version, str], spec: str + ) -> bool: + return str(prospective).lower() == str(spec).lower() + + def _operator_callable(self, prospective: Version, spec: str) -> bool: + return { + "~=": self._compare_compatible, + "==": self._compare_equal, + "!=": self._compare_not_equal, + "<=": self._compare_less_than_equal, + ">=": self._compare_greater_than_equal, + "<": self._compare_less_than, + ">": self._compare_greater_than, + }[self.operator](prospective, spec) + + def contains( + self, item: Union[str, Version], prereleases: Optional[bool] = None + ) -> bool: + return bool(list(self.filter([item], prereleases=prereleases))) + + def filter( + self, + iterable: Iterable[Union[str, Version]], + prereleases: Optional[bool] = None, + ) -> Iterator[Union[str, Version]]: + found_prereleases: List[Union[str, Version]] = [] + found_non_prereleases = False + include_prereleases = ( + prereleases if prereleases is not None else self.prereleases + ) + + for version in iterable: + parsed_version = _coerce_version(version) + if parsed_version is None: + if self.operator == "===" and self._compare_arbitrary( + version, self.version + ): + yield version + continue + if self.operator == "===": + if self._compare_arbitrary(version, self.version): + found_non_prereleases = True + yield version + continue + + if self._operator_callable(parsed_version, self.version): + if not parsed_version.is_prerelease or include_prereleases: + found_non_prereleases = True + yield version + elif prereleases is None and self._prereleases is not False: + found_prereleases.append(version) + + if ( + not found_non_prereleases + and prereleases is None + and self._prereleases is not False + ): + yield from found_prereleases + + +class SpecifierSet: + """A comma-separated set of :class:`Specifier` instances, ANDed together.""" + + def __init__( + self, specifiers: str = "", prereleases: Optional[bool] = None + ) -> None: + split = [s.strip() for s in specifiers.split(",") if s.strip()] + self._specs: Tuple[Specifier, ...] = tuple( + Specifier(spec) for spec in split + ) + self._has_arbitrary = "===" in specifiers + self._prereleases = prereleases + + @property + def prereleases(self) -> Optional[bool]: + if self._prereleases is not None: + return self._prereleases + if not self._specs: + return None + if any(spec.prereleases for spec in self._specs): + return True + return None + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + + def __iter__(self) -> Iterator[Specifier]: + return iter(self._specs) + + def __len__(self) -> int: + return len(self._specs) + + def __str__(self) -> str: + return ",".join(sorted(str(spec) for spec in self._specs)) + + def __repr__(self) -> str: + return f"" + + def __hash__(self) -> int: + return hash(frozenset(self._specs)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, str): + other = SpecifierSet(other) + elif not isinstance(other, SpecifierSet): + return NotImplemented + return frozenset(self._specs) == frozenset(other._specs) + + def contains( + self, + item: Union[str, Version], + prereleases: Optional[bool] = None, + installed: Optional[bool] = None, + ) -> bool: + version = _coerce_version(item) + if version is not None and installed and version.is_prerelease: + prereleases = True + if version is None or ( + self._has_arbitrary and not isinstance(item, Version) + ): + check_item: Union[str, Version] = item + else: + check_item = version + return bool(list(self.filter([check_item], prereleases=prereleases))) + + def filter( + self, + iterable: Iterable[Union[str, Version]], + prereleases: Optional[bool] = None, + ) -> Iterator[Union[str, Version]]: + if prereleases is None and self.prereleases is not None: + prereleases = self.prereleases + + if self._specs: + result: Iterable[Union[str, Version]] = iterable + for spec in self._specs: + result = spec.filter( + result, + prereleases=True if prereleases is None else prereleases, + ) + result = list(result) + if prereleases is not None: + return iter(result) + return self._prefer_final_releases(result) + + if prereleases is True: + return iter(iterable) + if prereleases is False: + return iter( + item + for item in iterable + if ( + (version := _coerce_version(item)) is None + or not version.is_prerelease + ) + ) + return self._prefer_final_releases(iterable) + + @staticmethod + def _prefer_final_releases( + iterable: Iterable[Union[str, Version]], + ) -> Iterator[Union[str, Version]]: + # PEP 440: exclude prereleases unless no final releases exist. Items + # that are not valid versions have already passed all specifiers, so + # they are always kept (their order relative to finals is preserved). + all_nonfinal: List[Union[str, Version]] = [] + arbitrary_strings: List[Union[str, Version]] = [] + found_final = False + for item in iterable: + parsed = _coerce_version(item) + if parsed is None: + if found_final: + yield item + else: + arbitrary_strings.append(item) + all_nonfinal.append(item) + continue + if not parsed.is_prerelease: + if not found_final: + yield from arbitrary_strings + found_final = True + yield item + continue + if not found_final: + all_nonfinal.append(item) + if not found_final: + yield from all_nonfinal diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/version.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/version.py new file mode 100644 index 0000000000..acee39fd2f --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_packaging/version.py @@ -0,0 +1,328 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""A minimal PEP 440 version implementation. + +This provides the subset of :mod:`packaging.version` that OpenTelemetry +instrumentation relies on: parsing, normalization and ordering of PEP 440 +versions, the ``release`` tuple and the pre/post/dev release predicates used +by the specifier logic. It intentionally does not implement legacy (non +PEP 440) versions, which ``packaging`` dropped support for. +""" + +from __future__ import annotations + +from functools import total_ordering +from itertools import dropwhile +from re import IGNORECASE, VERBOSE +from re import compile as re_compile +from typing import NamedTuple, Optional, Tuple, Union + +__all__ = ["InvalidVersion", "Version", "parse"] + + +class InvalidVersion(ValueError): + """Raised when a version string is not a valid PEP 440 version.""" + + +class _InfinityType: + def __repr__(self) -> str: + return "Infinity" + + def __lt__(self, other: object) -> bool: + return False + + def __le__(self, other: object) -> bool: + return False + + def __eq__(self, other: object) -> bool: + return isinstance(other, type(self)) + + def __gt__(self, other: object) -> bool: + return True + + def __ge__(self, other: object) -> bool: + return True + + def __hash__(self) -> int: + return hash(repr(self)) + + +_Infinity = _InfinityType() + + +class _NegativeInfinityType: + def __repr__(self) -> str: + return "-Infinity" + + def __lt__(self, other: object) -> bool: + return True + + def __le__(self, other: object) -> bool: + return True + + def __eq__(self, other: object) -> bool: + return isinstance(other, type(self)) + + def __gt__(self, other: object) -> bool: + return False + + def __ge__(self, other: object) -> bool: + return False + + def __hash__(self) -> int: + return hash(repr(self)) + + +_NegativeInfinity = _NegativeInfinityType() + +# A single pre/post/dev segment is a ("a"/"b"/"rc"/"post"/"dev", int) pair. +_LetterVersion = Tuple[str, int] +_LocalSegment = Union[int, str] +_LocalVersion = Optional[Tuple[_LocalSegment, ...]] + + +class _Parsed(NamedTuple): + epoch: int + release: Tuple[int, ...] + pre: Optional[_LetterVersion] + post: Optional[_LetterVersion] + dev: Optional[_LetterVersion] + local: _LocalVersion + + +# Comparison key element types: after normalization each of the pre/post/dev +# fields collapses to a comparable value that may be an infinity sentinel. +_CmpPrePostDev = Union[_InfinityType, _NegativeInfinityType, _LetterVersion] +_CmpLocal = Union[ + _NegativeInfinityType, + Tuple[Union[Tuple[int, str], Tuple[_NegativeInfinityType, str]], ...], +] +_CmpKey = Tuple[ + int, + Tuple[int, ...], + _CmpPrePostDev, + _CmpPrePostDev, + _CmpPrePostDev, + _CmpLocal, +] + +# The canonical PEP 440 version pattern. +_VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?Palpha|a|beta|b|preview|pre|c|rc)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+_VERSION_REGEX = re_compile(
+    r"^\s*" + _VERSION_PATTERN + r"\s*$",
+    VERBOSE | IGNORECASE,
+)
+
+_LOCAL_SEGMENT_SPLIT = re_compile(r"[\._-]")
+
+
+def _parse_letter_version(
+    letter: Optional[str], number: Optional[str]
+) -> Optional[_LetterVersion]:
+    if letter:
+        # A pre-release without an explicit number implies 0.
+        normalized = letter.lower()
+        if normalized == "alpha":
+            normalized = "a"
+        elif normalized == "beta":
+            normalized = "b"
+        elif normalized in ("c", "pre", "preview"):
+            normalized = "rc"
+        elif normalized in ("rev", "r"):
+            normalized = "post"
+        return normalized, int(number) if number else 0
+
+    if number:
+        # An implicit post release, e.g. the "-1" in "1.0-1".
+        return "post", int(number)
+
+    return None
+
+
+def _parse_local_version(local: Optional[str]) -> _LocalVersion:
+    if local is None:
+        return None
+    return tuple(
+        part.lower() if not part.isdigit() else int(part)
+        for part in _LOCAL_SEGMENT_SPLIT.split(local)
+    )
+
+
+def _build_cmp_key(parsed: _Parsed) -> _CmpKey:
+    # Trailing zeros in the release segment are not significant for ordering,
+    # e.g. 1.0 == 1.0.0.
+    release = tuple(
+        reversed(list(dropwhile(lambda x: x == 0, reversed(parsed.release))))
+    )
+
+    # A version with no pre-segment sorts after one that has a pre-segment,
+    # unless it is a dev release with neither pre nor post, which sorts first.
+    if parsed.pre is None and parsed.post is None and parsed.dev is not None:
+        pre: _CmpPrePostDev = _NegativeInfinity
+    elif parsed.pre is None:
+        pre = _Infinity
+    else:
+        pre = parsed.pre
+
+    post: _CmpPrePostDev = (
+        _NegativeInfinity if parsed.post is None else parsed.post
+    )
+    dev: _CmpPrePostDev = _Infinity if parsed.dev is None else parsed.dev
+
+    if parsed.local is None:
+        # No local version sorts before any local version.
+        local: _CmpLocal = _NegativeInfinity
+    else:
+        # Per PEP 440, numeric local segments sort after alphabetic ones.
+        local = tuple(
+            (segment, "")
+            if isinstance(segment, int)
+            else (_NegativeInfinity, segment)
+            for segment in parsed.local
+        )
+
+    return parsed.epoch, release, pre, post, dev, local
+
+
+@total_ordering
+class Version:
+    """A PEP 440 version, orderable and hashable."""
+
+    def __init__(self, version: str) -> None:
+        match = _VERSION_REGEX.match(version)
+        if match is None:
+            raise InvalidVersion(f"Invalid version: '{version}'")
+
+        self._parsed = _Parsed(
+            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
+            release=tuple(
+                int(part) for part in match.group("release").split(".")
+            ),
+            pre=_parse_letter_version(
+                match.group("pre_l"), match.group("pre_n")
+            ),
+            post=_parse_letter_version(
+                match.group("post_l"),
+                match.group("post_n1") or match.group("post_n2"),
+            ),
+            dev=_parse_letter_version(
+                match.group("dev_l"), match.group("dev_n")
+            ),
+            local=_parse_local_version(match.group("local")),
+        )
+        self._key = _build_cmp_key(self._parsed)
+
+    @property
+    def epoch(self) -> int:
+        return self._parsed.epoch
+
+    @property
+    def release(self) -> Tuple[int, ...]:
+        return self._parsed.release
+
+    @property
+    def pre(self) -> Optional[_LetterVersion]:
+        return self._parsed.pre
+
+    @property
+    def post(self) -> Optional[int]:
+        return self._parsed.post[1] if self._parsed.post else None
+
+    @property
+    def dev(self) -> Optional[int]:
+        return self._parsed.dev[1] if self._parsed.dev else None
+
+    @property
+    def local(self) -> Optional[str]:
+        if self._parsed.local is None:
+            return None
+        return ".".join(str(segment) for segment in self._parsed.local)
+
+    @property
+    def is_prerelease(self) -> bool:
+        return self._parsed.pre is not None or self._parsed.dev is not None
+
+    @property
+    def is_postrelease(self) -> bool:
+        return self._parsed.post is not None
+
+    @property
+    def is_devrelease(self) -> bool:
+        return self._parsed.dev is not None
+
+    @property
+    def base_version(self) -> str:
+        parts = []
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+        parts.append(".".join(str(part) for part in self.release))
+        return "".join(parts)
+
+    @property
+    def public(self) -> str:
+        """The version string without its local segment."""
+        return str(self).split("+", 1)[0]
+
+    def __str__(self) -> str:
+        parts = [self.base_version]
+        if self._parsed.pre is not None:
+            parts.append("".join(str(item) for item in self._parsed.pre))
+        if self._parsed.post is not None:
+            parts.append(f".post{self._parsed.post[1]}")
+        if self._parsed.dev is not None:
+            parts.append(f".dev{self._parsed.dev[1]}")
+        if self.local is not None:
+            parts.append(f"+{self.local}")
+        return "".join(parts)
+
+    def __repr__(self) -> str:
+        return f""
+
+    def __hash__(self) -> int:
+        return hash(self._key)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Version):
+            return NotImplemented
+        return self._key == other._key
+
+    def __lt__(self, other: object) -> bool:
+        if not isinstance(other, Version):
+            return NotImplemented
+        return self._key < other._key
+
+
+def parse(version: str) -> Version:
+    """Parse ``version`` into a :class:`Version`, raising :class:`InvalidVersion`."""
+    return Version(version)
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py
index 251d56fefa..7c8a2d1320 100644
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py
@@ -9,8 +9,9 @@
 from typing import Container, Mapping, MutableMapping
 from urllib.parse import urlparse
 
-from packaging import version as package_version
-
+from opentelemetry.instrumentation._packaging import (
+    version as package_version,
+)
 from opentelemetry.instrumentation.utils import http_status_to_status_code
 from opentelemetry.semconv._incubating.attributes.db_attributes import (
     DB_NAME,
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py
index 2959789b7c..b0b04cb0a8 100644
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py
@@ -13,8 +13,9 @@
 )
 from typing import Optional
 
-from packaging.requirements import Requirement
-
+from opentelemetry.instrumentation._packaging.requirements import (
+    Requirement,
+)
 from opentelemetry.instrumentation.bootstrap_gen import (
     default_instrumentations as gen_default_instrumentations,
 )
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py
index 856232c7c9..5ce92472a6 100644
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py
@@ -6,8 +6,10 @@
 from logging import getLogger
 from typing import Collection
 
-from packaging.requirements import InvalidRequirement, Requirement
-
+from opentelemetry.instrumentation._packaging.requirements import (
+    InvalidRequirement,
+    Requirement,
+)
 from opentelemetry.util._importlib_metadata import (
     Distribution,
     PackageNotFoundError,
diff --git a/opentelemetry-instrumentation/tests/test_dependencies.py b/opentelemetry-instrumentation/tests/test_dependencies.py
index e9aacbcca9..9cd7f9974f 100644
--- a/opentelemetry-instrumentation/tests/test_dependencies.py
+++ b/opentelemetry-instrumentation/tests/test_dependencies.py
@@ -6,8 +6,8 @@
 from unittest.mock import patch
 
 import pytest
-from packaging.requirements import Requirement
 
+from opentelemetry.instrumentation._packaging.requirements import Requirement
 from opentelemetry.instrumentation.dependencies import (
     DependencyConflict,
     get_dependency_conflicts,
diff --git a/opentelemetry-instrumentation/tests/test_packaging_markers.py b/opentelemetry-instrumentation/tests/test_packaging_markers.py
new file mode 100644
index 0000000000..6fc0acada2
--- /dev/null
+++ b/opentelemetry-instrumentation/tests/test_packaging_markers.py
@@ -0,0 +1,107 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from unittest import TestCase, main
+
+from opentelemetry.instrumentation._packaging.markers import (
+    InvalidMarker,
+    Marker,
+)
+
+
+class TestMarker(TestCase):
+    def test_extra_equality(self):
+        """PEP 508, the 'extra' marker variable: "The 'extra' variable is
+        special. It is used by wheels to signal which specifications apply to a
+        given extra in the wheel METADATA file ..." This is the only marker the
+        instrumentation actually evaluates."""
+        marker = Marker('extra == "instruments"')
+        self.assertTrue(marker.evaluate({"extra": "instruments"}))
+        self.assertFalse(marker.evaluate({"extra": "other"}))
+        self.assertFalse(marker.evaluate({"extra": ""}))
+
+    def test_extra_name_is_normalized(self):
+        """PEP 685, extra name normalization: "When comparing extra names,
+        tools MUST normalize the names being compared using the semantics
+        outlined in PEP 503 for names: re.sub(r'[-_.]+', '-', name).lower()".
+        So "Instruments-Any", "instruments-any" and "Instruments_Any" all
+        compare equal."""
+        marker = Marker('extra == "Instruments-Any"')
+        self.assertTrue(marker.evaluate({"extra": "instruments-any"}))
+        self.assertTrue(marker.evaluate({"extra": "Instruments_Any"}))
+
+    def test_python_version(self):
+        """PEP 508 -- python_version is a version field, so per the Dependency
+        Specifiers spec its comparisons "use the PEP 440 version comparison
+        rules when those are defined (that is when both sides have a valid
+        version specifier)"."""
+        self.assertTrue(
+            Marker('python_version >= "3.0"').evaluate(
+                {"python_version": "3.12"}
+            )
+        )
+        self.assertFalse(
+            Marker('python_version >= "3.20"').evaluate(
+                {"python_version": "3.12"}
+            )
+        )
+
+    def test_and_or(self):
+        """PEP 508 marker grammar -- markers combine with boolean operators
+        (capture/action annotations elided):
+            marker_and = marker_expr 'and' marker_expr | marker_expr
+            marker_or  = marker_and 'or' marker_and | marker_and"""
+        marker = Marker('python_version >= "3.0" and extra == "instruments"')
+        self.assertTrue(
+            marker.evaluate({"python_version": "3.12", "extra": "instruments"})
+        )
+        self.assertFalse(
+            marker.evaluate({"python_version": "3.12", "extra": "other"})
+        )
+
+        marker = Marker('extra == "a" or extra == "b"')
+        self.assertTrue(marker.evaluate({"extra": "b"}))
+
+    def test_parentheses(self):
+        """PEP 508 marker grammar -- a parenthesized marker is itself a
+        marker_expr (capture/action annotations elided):
+            marker_expr = marker_var marker_op marker_var | '(' marker ')'
+        so an 'or' can be nested inside an 'and'."""
+        marker = Marker(
+            '(extra == "a" or extra == "b") and python_version >= "3.0"'
+        )
+        self.assertTrue(
+            marker.evaluate({"extra": "a", "python_version": "3.12"})
+        )
+        self.assertFalse(
+            marker.evaluate({"extra": "c", "python_version": "3.12"})
+        )
+
+    def test_string_ordering_operators(self):
+        """PyPA Dependency Specifiers spec (supersedes PEP 508) on ordered
+        comparisons of string fields: "... locking and installation tools
+        SHOULD implement the following behavior: treat >= and <= as equivalent
+        to == and treat > and < as always being False." sys_platform is a
+        string field, so "<"/">" are always False and "<="/">=" reduce to
+        equality. (PEP 508's older wording instead fell back to Python string
+        comparison here.)"""
+        env = {"sys_platform": "linux"}
+        self.assertFalse(Marker('sys_platform < "z"').evaluate(env))
+        self.assertFalse(Marker('sys_platform < "a"').evaluate(env))
+        self.assertFalse(Marker('sys_platform > "a"').evaluate(env))
+        self.assertFalse(Marker('sys_platform > "z"').evaluate(env))
+        self.assertTrue(Marker('sys_platform <= "linux"').evaluate(env))
+        self.assertFalse(Marker('sys_platform <= "z"').evaluate(env))
+        self.assertTrue(Marker('sys_platform >= "linux"').evaluate(env))
+        self.assertFalse(Marker('sys_platform >= "a"').evaluate(env))
+
+    def test_invalid_marker(self):
+        """PEP 508 -- a malformed marker, or one referencing a variable not in
+        the defined env_var set, is rejected with InvalidMarker."""
+        for value in ('extra = "x"', "extra ==", 'unknown_var == "x"'):
+            with self.assertRaises(InvalidMarker):
+                Marker(value)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/opentelemetry-instrumentation/tests/test_packaging_requirements.py b/opentelemetry-instrumentation/tests/test_packaging_requirements.py
new file mode 100644
index 0000000000..11cd32ba4f
--- /dev/null
+++ b/opentelemetry-instrumentation/tests/test_packaging_requirements.py
@@ -0,0 +1,77 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from unittest import TestCase, main
+
+from opentelemetry.instrumentation._packaging.requirements import (
+    InvalidRequirement,
+    Requirement,
+)
+
+
+class TestRequirement(TestCase):
+    def test_name_and_specifier(self):
+        """PEP 508 grammar: "name = identifier" followed by an optional
+        "versionspec"; the version comparison operators are
+        "version_cmp = wsp* '<' | '<=' | '!=' | '==' | '>=' | '>' | '~=' |
+        '==='" and "Versions may be specified according to the PEP 440
+        rules.\" """
+        req = Requirement("flask >= 2.2.0, < 4.0")
+        self.assertEqual(req.name, "flask")
+        self.assertEqual(str(req.specifier), "<4.0,>=2.2.0")
+        self.assertIsNone(req.marker)
+        self.assertTrue(req.specifier.contains("3.0.0"))
+
+    def test_name_with_separators(self):
+        """PEP 508 name grammar (matched case-insensitively):
+        "^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$" -- so '-' and '_' are
+        legal interior separators in a distribution name."""
+        self.assertEqual(
+            Requirement("google-cloud-aiplatform >= 1.64").name,
+            "google-cloud-aiplatform",
+        )
+        self.assertEqual(Requirement("aio_pika >= 7.2.0").name, "aio_pika")
+
+    def test_no_specifier(self):
+        """PEP 508 -- the "versionspec" is optional, so a bare name is a valid
+        requirement that constrains no version."""
+        req = Requirement("pytest")
+        self.assertEqual(req.name, "pytest")
+        self.assertEqual(len(req.specifier), 0)
+        self.assertTrue(req.specifier.contains("1.2.3"))
+
+    def test_extras(self):
+        """PEP 508 extras grammar: "extras = '[' wsp* extras_list? wsp* ']'"
+        with "extras_list = identifier (wsp* ',' wsp* identifier)*.\" """
+        req = Requirement("requests[security,socks] >= 2.0")
+        self.assertEqual(req.name, "requests")
+        self.assertEqual(req.extras, {"security", "socks"})
+
+    def test_marker(self):
+        """PEP 508 -- a requirement may carry an environment marker after ';'.
+        This is exactly how the instrumentation declares its instrumented
+        library via the 'extra' marker (extra == "instruments")."""
+        req = Requirement('flask >= 2.2.0; extra == "instruments"')
+        self.assertEqual(req.name, "flask")
+        self.assertIsNotNone(req.marker)
+        self.assertTrue(req.marker.evaluate({"extra": "instruments"}))
+        self.assertFalse(req.marker.evaluate({"extra": "other"}))
+
+    def test_str_roundtrip(self):
+        """PEP 508 -- a parsed requirement renders back to its canonical
+        string form (name + specifier + "; " + marker)."""
+        self.assertEqual(
+            str(Requirement('test-pkg ~= 1.0; extra == "instruments"')),
+            'test-pkg~=1.0; extra == "instruments"',
+        )
+
+    def test_invalid_requirement(self):
+        """PEP 508 -- strings that do not conform to the requirement grammar
+        are rejected with InvalidRequirement."""
+        for value in ("", "== 1.0", "flask >= not-a-version"):
+            with self.assertRaises(InvalidRequirement):
+                Requirement(value)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/opentelemetry-instrumentation/tests/test_packaging_specifiers.py b/opentelemetry-instrumentation/tests/test_packaging_specifiers.py
new file mode 100644
index 0000000000..e0ba03244f
--- /dev/null
+++ b/opentelemetry-instrumentation/tests/test_packaging_specifiers.py
@@ -0,0 +1,112 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from unittest import TestCase, main
+
+from opentelemetry.instrumentation._packaging.specifiers import (
+    InvalidSpecifier,
+    Specifier,
+    SpecifierSet,
+)
+
+
+class TestSpecifierSet(TestCase):
+    def test_simple_bounds(self):
+        """PEP 440, Inclusive/exclusive ordered comparison: "Comparison and
+        ordering of release segments considers the numeric value of each
+        component of the release segment in turn." (">=2.2.0,<4.0" combines an
+        inclusive lower bound with an exclusive upper bound.)"""
+        spec = SpecifierSet(">=2.2.0,<4.0")
+        self.assertTrue(spec.contains("2.2.0"))
+        self.assertTrue(spec.contains("3.5.1"))
+        self.assertFalse(spec.contains("2.1.9"))
+        self.assertFalse(spec.contains("4.0"))
+        self.assertFalse(spec.contains("4.0.0"))
+
+    def test_compatible_release(self):
+        """PEP 440, Compatible release: "For a given release identifier V.N,
+        the compatible release clause is approximately equivalent to the pair
+        of comparison clauses: >= V.N, == V.*\" """
+        spec = SpecifierSet("~=1.4")
+        self.assertTrue(spec.contains("1.4.5"))
+        self.assertFalse(spec.contains("2.0"))
+
+    def test_wildcard_equal(self):
+        """PEP 440, Version matching: "Prefix matching may be requested instead
+        of strict comparison, by appending a trailing .* to the version
+        identifier in the version matching clause. This means that additional
+        trailing segments will be ignored ...\" """
+        spec = SpecifierSet("==1.4.*")
+        self.assertTrue(spec.contains("1.4.9"))
+        self.assertFalse(spec.contains("1.5"))
+
+    def test_not_equal(self):
+        """PEP 440, Version exclusion: "The allowed version identifiers and
+        comparison semantics are the same as those of the Version matching
+        operator, except that the sense of any match is inverted.\" """
+        spec = SpecifierSet("!=1.5")
+        self.assertFalse(spec.contains("1.5"))
+        self.assertTrue(spec.contains("1.6"))
+
+    def test_empty_set_matches_any_final(self):
+        """PEP 440 -- an empty specifier imposes no version constraint, but
+        default pre-release handling still applies: "Pre-releases ... are
+        implicitly excluded from all version specifiers, unless ...\" """
+        spec = SpecifierSet("")
+        self.assertTrue(spec.contains("1.0"))
+        self.assertTrue(spec.contains("99.9"))
+
+    def test_prerelease_default_excluded(self):
+        """PEP 440, Handling of pre-releases: "Pre-releases of any kind,
+        including developmental releases, are implicitly excluded from all
+        version specifiers, unless they are already present on the system,
+        explicitly requested by the user, or if the only available version
+        that satisfies the version specifier is a pre-release." So a
+        pre-release of the lower bound is excluded, while an in-range
+        pre-release with no final alternative is admitted."""
+        spec = SpecifierSet(">=1.0")
+        self.assertFalse(spec.contains("1.0.0rc1"))
+        self.assertTrue(spec.contains("2.0.0rc1"))
+
+    def test_prerelease_explicit(self):
+        """PEP 440, Handling of pre-releases -- an in-range pre-release is
+        admitted by default (no final alternative present) but excluded when
+        pre-releases are explicitly disallowed: "Pre-releases ... are
+        implicitly excluded from all version specifiers, unless ...\" """
+        spec = SpecifierSet(">=1.0")
+        self.assertTrue(spec.contains("2.0.0rc1"))
+        self.assertFalse(spec.contains("2.0.0rc1", prereleases=False))
+
+    def test_specifier_in_a_prerelease_spec(self):
+        """PEP 440, Handling of pre-releases -- a specifier that itself names a
+        pre-release accepts pre-releases: "... unless they are already present
+        on the system, explicitly requested by the user ...\" """
+        spec = SpecifierSet(">=2.0b0")
+        self.assertTrue(spec.contains("2.0b0"))
+
+    def test_filter_prefers_final_releases(self):
+        """PEP 440, Handling of pre-releases -- filtering drops pre-releases
+        when final releases are available, but yields them when they are "the
+        only available version that satisfies the version specifier.\" """
+        spec = SpecifierSet(">=1.0")
+        self.assertEqual(
+            list(spec.filter(["1.0", "2.0a1", "2.0"])), ["1.0", "2.0"]
+        )
+        self.assertEqual(list(spec.filter(["2.0a1"])), ["2.0a1"])
+
+    def test_str_is_sorted(self):
+        """The string form of a SpecifierSet is deterministic (clauses in a
+        stable order), matching packaging's canonical rendering."""
+        self.assertEqual(str(SpecifierSet("<4.0,>=2.2.0")), "<4.0,>=2.2.0")
+
+    def test_invalid_specifier(self):
+        """PEP 440 -- malformed clauses are rejected. Note ~=1 is invalid:
+        "This operator MUST NOT be used with a single segment version number
+        such as ~=1.\" """
+        for value in ("=>1.0", "1.0", "~=1", "==1.*.5"):
+            with self.assertRaises(InvalidSpecifier):
+                Specifier(value)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/opentelemetry-instrumentation/tests/test_packaging_version.py b/opentelemetry-instrumentation/tests/test_packaging_version.py
new file mode 100644
index 0000000000..1bef0cc293
--- /dev/null
+++ b/opentelemetry-instrumentation/tests/test_packaging_version.py
@@ -0,0 +1,120 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from unittest import TestCase, main
+
+from opentelemetry.instrumentation._packaging.version import (
+    InvalidVersion,
+    Version,
+    parse,
+)
+
+
+class TestVersion(TestCase):
+    def test_parse_returns_version(self):
+        """PEP 440 -- a normal public version identifier (a plain release
+        segment such as "1.2.3") parses to a Version."""
+        self.assertIsInstance(parse("1.2.3"), Version)
+
+    def test_release_tuple(self):
+        """PEP 440, Release segment: "Comparison and ordering of release
+        segments considers the numeric value of each component of the release
+        segment in turn." ``.release`` exposes those components as ints."""
+        self.assertEqual(parse("1.4").release, (1, 4))
+        self.assertEqual(parse("2.0.0").release, (2, 0, 0))
+        self.assertEqual(parse("v1.2.3").release, (1, 2, 3))
+
+    def test_release_tuple_comparison(self):
+        """PEP 440, Release segment: "Comparison and ordering of release
+        segments considers the numeric value of each component of the release
+        segment in turn." (This is the comparison the sqlalchemy
+        instrumentation performs on ``.release``.)"""
+        self.assertTrue(parse("1.4.0").release >= (1, 4))
+        self.assertTrue(parse("2.0.0").release >= (1, 4))
+        self.assertFalse(parse("1.3.5").release >= (1, 4))
+
+    def test_ordering(self):
+        """PEP 440, Version ordering: "X.Y and X.Y.0 are not considered
+        distinct release numbers, as the release segment comparison rules
+        implicit expand the two component form to X.Y.0 when comparing it to
+        any release segment that includes three components.\" """
+        self.assertLess(parse("2.1.9"), parse("2.2.0"))
+        self.assertGreaterEqual(parse("3.0.0"), parse("3.0.0"))
+        self.assertGreater(parse("4.0"), parse("3.1.0"))
+        self.assertEqual(parse("1.0"), parse("1.0.0"))
+
+    def test_prerelease_ordering(self):
+        """PEP 440, Summary of permitted suffixes and relative ordering:
+        "Within a numeric release (1.0, 2.7.3), the following suffixes are
+        permitted and MUST be ordered as shown: .devN, aN, bN, rcN,
+        , .postN\" """
+        self.assertLess(parse("1.0a1"), parse("1.0a2"))
+        self.assertLess(parse("1.0a2"), parse("1.0b1"))
+        self.assertLess(parse("1.0b1"), parse("1.0rc1"))
+        self.assertLess(parse("1.0rc1"), parse("1.0"))
+        self.assertLess(parse("1.0.dev1"), parse("1.0a1"))
+        self.assertLess(parse("1.0"), parse("1.0.post1"))
+
+    def test_prerelease_letter_normalization(self):
+        """PEP 440, Pre-release spelling: "Pre-releases allow the additional
+        spellings of alpha, beta, c, pre, and preview for a, b, rc, rc, and rc
+        respectively." Also: "Installation tools SHOULD interpret c versions
+        as being equivalent to rc versions (that is, c1 indicates the same
+        version as rc1).\" """
+        self.assertEqual(parse("1.0alpha1"), parse("1.0a1"))
+        self.assertEqual(parse("1.0beta1"), parse("1.0b1"))
+        self.assertEqual(parse("1.0c1"), parse("1.0rc1"))
+        self.assertEqual(parse("1.0preview1"), parse("1.0rc1"))
+
+    def test_predicates(self):
+        """PEP 440, developmental and post-release segments: "The
+        developmental release segment consists of the string .dev, followed by
+        a non-negative integer value." / "The post-release segment consists of
+        the string .post, followed by a non-negative integer value.\" """
+        self.assertTrue(parse("1.0rc1").is_prerelease)
+        self.assertTrue(parse("1.0.dev1").is_prerelease)
+        self.assertTrue(parse("1.0.dev1").is_devrelease)
+        self.assertTrue(parse("1.0.post1").is_postrelease)
+        self.assertFalse(parse("1.0").is_prerelease)
+        self.assertFalse(parse("1.0").is_postrelease)
+
+    def test_epoch_and_local(self):
+        """PEP 440, Version epochs: "If included in a version identifier, the
+        epoch appears before all other components, separated from the release
+        segment by an exclamation mark: E!X.Y." Local version identifiers: "If
+        a segment consists entirely of ASCII digits then that section should be
+        considered an integer for comparison purposes ...\" """
+        self.assertGreater(parse("1!1.0"), parse("2.0"))
+        self.assertEqual(parse("1!1.0").epoch, 1)
+        self.assertEqual(parse("1.0+abc").local, "abc")
+        self.assertGreater(parse("1.0+abc.2"), parse("1.0+abc.1"))
+
+    def test_str_normalization(self):
+        """PEP 440, Normalization -- the canonical string form uses the
+        normalized pre-release spelling: "Pre-releases allow the additional
+        spellings of alpha, beta, c, pre, and preview for a, b, rc, rc, and rc
+        respectively." (so "1.0alpha1" normalizes to "1.0a1")."""
+        self.assertEqual(str(parse("1.0")), "1.0")
+        self.assertEqual(str(parse("v1.0.0")), "1.0.0")
+        self.assertEqual(str(parse("1.0alpha1")), "1.0a1")
+
+    def test_hash_equal_versions(self):
+        """PEP 440, Version ordering: "X.Y and X.Y.0 are not considered
+        distinct release numbers ...", so equal versions must hash equally."""
+        self.assertEqual(hash(parse("1.0")), hash(parse("1.0.0")))
+
+    def test_invalid_version(self):
+        """PEP 440 -- strings that are not valid version identifiers are
+        rejected with InvalidVersion."""
+        for value in ("", "abc", "1..0", "not-a-version", "1.0.0+"):
+            with self.assertRaises(InvalidVersion):
+                Version(value)
+
+    def test_invalid_version_is_value_error(self):
+        """InvalidVersion subclasses ValueError, matching packaging so callers
+        can keep catching ValueError."""
+        self.assertIsInstance(InvalidVersion(), ValueError)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/uv.lock b/uv.lock
index ae320af72d..1ddb9d6aa9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2842,7 +2842,6 @@ source = { editable = "opentelemetry-instrumentation" }
 dependencies = [
     { name = "opentelemetry-api" },
     { name = "opentelemetry-semantic-conventions" },
-    { name = "packaging" },
     { name = "wrapt" },
 ]
 
@@ -2850,7 +2849,6 @@ dependencies = [
 requires-dist = [
     { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" },
     { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" },
-    { name = "packaging", specifier = ">=18.0" },
     { name = "wrapt", specifier = ">=1.0.0,<3.0.0" },
 ]