diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index 54d6e978c6..961a0b4e76 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -3632,6 +3632,76 @@ jobs: - name: Run tests run: uv run --no-sync tox -e py310-test-util-http -- -ra + py310-test-agent-oldest: + name: agent-oldest + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + persist-credentials: false + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + architecture: "x64" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py310-test-agent-oldest -- -ra + + py310-test-agent-latest: + name: agent-latest + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + persist-credentials: false + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + architecture: "x64" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py310-test-agent-latest -- -ra + py310-test-exporter-credential-provider-gcp: name: exporter-credential-provider-gcp runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff037f3c3c..e6e42d76e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13921,6 +13921,252 @@ jobs: - name: Run tests run: uv run --no-sync tox -e pypy3-test-util-http -- -ra + py310-test-agent-oldest_ubuntu-latest: + name: agent-oldest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py310-test-agent-oldest -- -ra + + py310-test-agent-latest_ubuntu-latest: + name: agent-latest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py310-test-agent-latest -- -ra + + py311-test-agent-oldest_ubuntu-latest: + name: agent-oldest 3.11 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py311-test-agent-oldest -- -ra + + py311-test-agent-latest_ubuntu-latest: + name: agent-latest 3.11 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py311-test-agent-latest -- -ra + + py312-test-agent-oldest_ubuntu-latest: + name: agent-oldest 3.12 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py312-test-agent-oldest -- -ra + + py312-test-agent-latest_ubuntu-latest: + name: agent-latest 3.12 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py312-test-agent-latest -- -ra + + py313-test-agent-oldest_ubuntu-latest: + name: agent-oldest 3.13 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py313-test-agent-oldest -- -ra + + py313-test-agent-latest_ubuntu-latest: + name: agent-latest 3.13 Ubuntu + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py313-test-agent-latest -- -ra + + py314-test-agent-oldest_ubuntu-latest: + name: agent-oldest 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.14 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.14" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py314-test-agent-oldest -- -ra + + py314-test-agent-latest_ubuntu-latest: + name: agent-latest 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Set up Python 3.14 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.14" + + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Install tox + run: uv sync --locked --only-group tox + + - name: Run tests + run: uv run --no-sync tox -e py314-test-agent-latest -- -ra + py310-test-exporter-credential-provider-gcp_ubuntu-latest: name: exporter-credential-provider-gcp 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6c3e08c117..421d7b34af 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # C extensions *.so +*.pyd # Packages *.egg diff --git a/agent/opentelemetry-agent/README.rst b/agent/opentelemetry-agent/README.rst new file mode 100644 index 0000000000..4873e865d5 --- /dev/null +++ b/agent/opentelemetry-agent/README.rst @@ -0,0 +1,34 @@ +OpenTelemetry Python Agent +========================== + +A self-contained OpenTelemetry distribution: the API, SDK, exporters, the auto-instrumentation framework +and instrumentations, with all their dependencies vendored under ``opentelemetry.agent._vendor``. It can be +installed into any Python 3.10+ environment without affecting (or being affected by) the application's own +packages. + +Usage +----- + +:: + + pip install opentelemetry-agent + opentelemetry-instrument python app.py + +``opentelemetry-instrument`` takes the same options and ``OTEL_*`` environment variables as the one from +``opentelemetry-instrumentation``. The ``otlp`` exporter uses ``http/protobuf``: the gRPC exporters aren't +included. + +Isolation +--------- + +* Only the vendored plugins (exporters, propagators, samplers, resource detectors) and instrumentations are + discovered; OpenTelemetry packages installed in the application's environment aren't used. +* The application's own ``opentelemetry-api`` (if any) is a separate copy, bridged to the agent: spans it + records are exported by the agent's SDK, and they parent (and are parented by) the agent's + instrumentation spans. ``opentelemetry-instrument`` selects the bridge by setting ``OTEL_PYTHON_CONTEXT`` + and ``OTEL_PYTHON_TRACER_PROVIDER`` to ``opentelemetry_agent``, unless they're already set; a tracer + provider set by the application before its first ``get_tracer_provider()`` call is kept. Metrics and + logs recorded with the application's API don't reach the agent's providers yet. Tested with + ``opentelemetry-api`` 1.16 and later. +* ``opentelemetry-instrumentation`` installs an ``opentelemetry-instrument`` script as well; when both are + installed, the package installed last owns it. diff --git a/agent/opentelemetry-agent/hatch_build.py b/agent/opentelemetry-agent/hatch_build.py new file mode 100644 index 0000000000..8ad3c50c35 --- /dev/null +++ b/agent/opentelemetry-agent/hatch_build.py @@ -0,0 +1,45 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tag the wheel as platform-specific when native extensions were built into the vendor tree. + +`opentelemetry-vendor build-native` (run before the wheel build) compiles stable-ABI (abi3) +extensions into ``_vendor``. Without them, the wheel is pure Python (``py3-none-any``). +""" + +from __future__ import annotations + +import sysconfig +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +VENDOR_DIR = Path("src/opentelemetry/agent/_vendor") +EXTENSION_SUFFIXES = (".so", ".pyd") + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + if self.target_name != "wheel": + return + root = Path(self.root) + extensions = sorted( + path.relative_to(root).as_posix() + for path in (root / VENDOR_DIR).rglob("*") + if path.is_file() and path.name.endswith(EXTENSION_SUFFIXES) + ) + if not extensions: + return + not_abi3 = [path for path in extensions if not (".abi3." in path or path.endswith(".pyd"))] + if not_abi3: + raise ValueError(f"vendored extensions must use the stable ABI (abi3): {not_abi3}") + abi3_tag = self.config.get("abi3-tag", "cp310") + # Same platform tag as setuptools' bdist_wheel: the interpreter's deployment target (e.g. + # macosx_11_0_arm64), not the build machine's OS version. Linux tags (linux_x86_64) are + # meant to be repaired to manylinux/musllinux by auditwheel (cibuildwheel). + platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + build_data["pure_python"] = False + build_data["tag"] = f"{abi3_tag}-abi3-{platform}" + # Compiled extensions are gitignored, which hatch would otherwise exclude. + build_data["artifacts"].extend(extensions) diff --git a/agent/opentelemetry-agent/native-build-requirements.in b/agent/opentelemetry-agent/native-build-requirements.in new file mode 100644 index 0000000000..797312dee0 --- /dev/null +++ b/agent/opentelemetry-agent/native-build-requirements.in @@ -0,0 +1,2 @@ +# Build-time requirements of the native extensions (see [tool.opentelemetry-vendor.native]). +setuptools diff --git a/agent/opentelemetry-agent/native-build-requirements.txt b/agent/opentelemetry-agent/native-build-requirements.txt new file mode 100644 index 0000000000..3f5c5f29d3 --- /dev/null +++ b/agent/opentelemetry-agent/native-build-requirements.txt @@ -0,0 +1,6 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --python 3.10 --universal --generate-hashes native-build-requirements.in -o native-build-requirements.txt +setuptools==84.2.1 \ + --hash=sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc \ + --hash=sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2 + # via -r native-build-requirements.in diff --git a/agent/opentelemetry-agent/patches/certifi-resources.patch b/agent/opentelemetry-agent/patches/certifi-resources.patch new file mode 100644 index 0000000000..50f5a6e1ef --- /dev/null +++ b/agent/opentelemetry-agent/patches/certifi-resources.patch @@ -0,0 +1,40 @@ +Look up the CA bundle in the vendored certifi package. + +Upstream passes the top-level name "certifi" to importlib.resources, which would import +the application's certifi (or fail if it isn't installed). __package__ resolves to the +vendored package. + +diff --git a/certifi/core.py b/certifi/core.py +--- a/certifi/core.py ++++ b/certifi/core.py +@@ -37,14 +37,14 @@ if sys.version_info >= (3, 11): + # We also have to hold onto the actual context manager, because + # it will do the cleanup whenever it gets garbage collected, so + # we will also store that at the global level as well. +- _CACERT_CTX = as_file(files("certifi").joinpath("cacert.pem")) ++ _CACERT_CTX = as_file(files(__package__).joinpath("cacert.pem")) + _CACERT_PATH = str(_CACERT_CTX.__enter__()) + atexit.register(exit_cacert_ctx) + + return _CACERT_PATH + + def contents() -> str: +- return files("certifi").joinpath("cacert.pem").read_text(encoding="ascii") ++ return files(__package__).joinpath("cacert.pem").read_text(encoding="ascii") + + else: + +@@ -73,11 +73,11 @@ else: + # We also have to hold onto the actual context manager, because + # it will do the cleanup whenever it gets garbage collected, so + # we will also store that at the global level as well. +- _CACERT_CTX = get_path("certifi", "cacert.pem") ++ _CACERT_CTX = get_path(__package__, "cacert.pem") + _CACERT_PATH = str(_CACERT_CTX.__enter__()) + atexit.register(exit_cacert_ctx) + + return _CACERT_PATH + + def contents() -> str: +- return read_text("certifi", "cacert.pem", encoding="ascii") ++ return read_text(__package__, "cacert.pem", encoding="ascii") diff --git a/agent/opentelemetry-agent/patches/native/protobuf-upb.patch b/agent/opentelemetry-agent/patches/native/protobuf-upb.patch new file mode 100644 index 0000000000..491f4ff133 --- /dev/null +++ b/agent/opentelemetry-agent/patches/native/protobuf-upb.patch @@ -0,0 +1,53 @@ +Build protobuf's upb extension under the agent's vendor namespace, as abi3. + +The extension's module name, its type names (their __module__) and the Python modules it imports by +absolute name all come from the PYUPB_* macros in python/protobuf.h; they are moved under +opentelemetry.agent._vendor so the vendored extension never imports (or poses as) the application's +protobuf. PYUPB_DESCRIPTOR_PROTO_PACKAGE is a protobuf package name (google.protobuf.FileDescriptorProto, +...), not a Python module, and stays. setup.py builds the extension under the new name with the limited +API (cp310-abi3), like upstream's Bazel-built wheels. + +diff --git a/python/protobuf.h b/python/protobuf.h +--- a/python/protobuf.h ++++ b/python/protobuf.h +@@ -22,12 +22,12 @@ + #include "upb/mem/arena.h" + #include "upb/reflection/def.h" + +-#define PYUPB_PROTOBUF_PUBLIC_PACKAGE "google.protobuf" +-#define PYUPB_PROTOBUF_INTERNAL_PACKAGE "google.protobuf.internal" ++#define PYUPB_PROTOBUF_PUBLIC_PACKAGE "opentelemetry.agent._vendor.google.protobuf" ++#define PYUPB_PROTOBUF_INTERNAL_PACKAGE "opentelemetry.agent._vendor.google.protobuf.internal" + #define PYUPB_DESCRIPTOR_PROTO_PACKAGE "google.protobuf" +-#define PYUPB_MODULE_NAME "google._upb._message" ++#define PYUPB_MODULE_NAME "opentelemetry.agent._vendor.google._upb._message" + +-#define PYUPB_DESCRIPTOR_MODULE "google.protobuf.descriptor_pb2" ++#define PYUPB_DESCRIPTOR_MODULE "opentelemetry.agent._vendor.google.protobuf.descriptor_pb2" + #define PYUPB_RETURN_OOM return PyErr_SetNone(PyExc_MemoryError), NULL + + struct PyUpb_WeakMap; +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -84,13 +84,19 @@ setup( + install_requires=[], + ext_modules=[ + Extension( +- 'google._upb._message', ++ # Vendored by opentelemetry-agent under its private namespace. ++ 'opentelemetry.agent._vendor.google._upb._message', + srcs, + include_dirs=[current_dir, os.path.join(current_dir, 'utf8_range')], + language='c', + extra_link_args=extra_link_args, + extra_compile_args=extra_compile_args, ++ # One stable-ABI build for CPython 3.10+, like upstream's abi3 wheels. It ++ # must be compiled against Python 3.10 headers. ++ define_macros=[('Py_LIMITED_API', '0x030A0000')], ++ py_limited_api=True, + ) + ], ++ options={'bdist_wheel': {'py_limited_api': 'cp310'}}, + python_requires='>=3.10', + ) diff --git a/agent/opentelemetry-agent/patches/otel-api-bridge.patch b/agent/opentelemetry-agent/patches/otel-api-bridge.patch new file mode 100644 index 0000000000..24a62346bb --- /dev/null +++ b/agent/opentelemetry-agent/patches/otel-api-bridge.patch @@ -0,0 +1,97 @@ +Select the agent's bridge for the application's opentelemetry-api. + +The application's opentelemetry-api is a separate copy from the vendored one. It loads its runtime +context (OTEL_PYTHON_CONTEXT) and tracer provider (OTEL_PYTHON_TRACER_PROVIDER) from entry points, +and the opentelemetry-agent distribution registers the bridge (opentelemetry.agent._bridge) under the +name _AGENT_BRIDGE. The bridge shares the vendored context and delegates to the vendored tracer +provider. sitecustomize sets both variables unless they're already set. + +The vendored API reads the same variables but only sees the vendored distributions' entry points +(see otel-importlib-metadata.patch), so it treats _AGENT_BRIDGE as unset: it uses its default runtime +context, which the bridge is built on, and its global tracer provider, which the bridge delegates to. + +diff --git a/opentelemetry/context/__init__.py b/opentelemetry/context/__init__.py +index 7e96f7105..360da46b9 100644 +--- a/opentelemetry/context/__init__.py ++++ b/opentelemetry/context/__init__.py +@@ -11,7 +11,7 @@ from uuid import uuid4 + # pylint: disable=wrong-import-position + from opentelemetry.agent._vendor.opentelemetry.context.context import Context, _RuntimeContext + from opentelemetry.agent._vendor.opentelemetry.context.contextvars_context import ContextVarsRuntimeContext +-from opentelemetry.agent._vendor.opentelemetry.environment_variables import OTEL_PYTHON_CONTEXT ++from opentelemetry.agent._vendor.opentelemetry.environment_variables import _AGENT_BRIDGE, OTEL_PYTHON_CONTEXT + + logger = logging.getLogger(__name__) + +@@ -23,7 +23,9 @@ def _load_runtime_context() -> _RuntimeContext: + An instance of RuntimeContext. + """ + configured_context = os.environ.get(OTEL_PYTHON_CONTEXT) +- if not configured_context: ++ # opentelemetry-agent: the agent's bridge (selected for the application's opentelemetry-api) stores ++ # the application's context in this runtime context. ++ if not configured_context or configured_context == _AGENT_BRIDGE: + return ContextVarsRuntimeContext() + + # pylint: disable=import-outside-toplevel,no-name-in-module +diff --git a/opentelemetry/environment_variables/__init__.py b/opentelemetry/environment_variables/__init__.py +index b335c8263..cf913396d 100644 +--- a/opentelemetry/environment_variables/__init__.py ++++ b/opentelemetry/environment_variables/__init__.py +@@ -70,3 +70,8 @@ _OTEL_PYTHON_LOGGER_PROVIDER = "OTEL_PYTHON_LOGGER_PROVIDER" + """ + .. envvar:: OTEL_PYTHON_LOGGER_PROVIDER + """ ++ ++# opentelemetry-agent: the value of OTEL_PYTHON_CONTEXT and OTEL_PYTHON_TRACER_PROVIDER that selects the ++# agent's bridge in the application's opentelemetry-api: the name of the opentelemetry-agent ++# distribution's opentelemetry_context and opentelemetry_tracer_provider entry points. ++_AGENT_BRIDGE = "opentelemetry_agent" +diff --git a/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py b/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py +index 46407e298..0e3284434 100644 +--- a/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py ++++ b/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py +@@ -1,6 +1,19 @@ + # Copyright The OpenTelemetry Authors + # SPDX-License-Identifier: Apache-2.0 + ++from os import environ ++ ++from opentelemetry.agent._vendor.opentelemetry.environment_variables import ( ++ _AGENT_BRIDGE, ++ OTEL_PYTHON_CONTEXT, ++ OTEL_PYTHON_TRACER_PROVIDER, ++) + from opentelemetry.agent._vendor.opentelemetry.instrumentation.auto_instrumentation import initialize + ++# opentelemetry-agent: make the application's opentelemetry-api load the agent's bridge (entry points of ++# the opentelemetry-agent distribution), which shares the vendored API's context and tracer provider. ++# Explicitly configured implementations are kept. ++for _variable in (OTEL_PYTHON_CONTEXT, OTEL_PYTHON_TRACER_PROVIDER): ++ environ.setdefault(_variable, _AGENT_BRIDGE) ++ + initialize() +diff --git a/opentelemetry/trace/__init__.py b/opentelemetry/trace/__init__.py +index 9e0fac92e..b36a85c8f 100644 +--- a/opentelemetry/trace/__init__.py ++++ b/opentelemetry/trace/__init__.py +@@ -74,7 +74,7 @@ from opentelemetry.agent._vendor.typing_extensions import deprecated + from opentelemetry.agent._vendor.opentelemetry import context as context_api + from opentelemetry.agent._vendor.opentelemetry.attributes import BoundedAttributes + from opentelemetry.agent._vendor.opentelemetry.context.context import Context +-from opentelemetry.agent._vendor.opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER ++from opentelemetry.agent._vendor.opentelemetry.environment_variables import _AGENT_BRIDGE, OTEL_PYTHON_TRACER_PROVIDER + from opentelemetry.agent._vendor.opentelemetry.trace.propagation import ( + _SPAN_KEY, + get_current_span, +@@ -566,7 +566,9 @@ def get_tracer_provider() -> TracerProvider: + if _TRACER_PROVIDER is None: + # if a global tracer provider has not been set either via code or env + # vars, return a proxy tracer provider +- if OTEL_PYTHON_TRACER_PROVIDER not in os.environ: ++ # opentelemetry-agent: the agent's bridge (selected for the application's opentelemetry-api) ++ # delegates to this global provider. ++ if os.environ.get(OTEL_PYTHON_TRACER_PROVIDER, _AGENT_BRIDGE) == _AGENT_BRIDGE: + return _PROXY_TRACER_PROVIDER + + tracer_provider: TracerProvider = _load_provider(OTEL_PYTHON_TRACER_PROVIDER, "tracer_provider") diff --git a/agent/opentelemetry-agent/patches/otel-configuration-loader.patch b/agent/opentelemetry-agent/patches/otel-configuration-loader.patch new file mode 100644 index 0000000000..14ac9f19dc --- /dev/null +++ b/agent/opentelemetry-agent/patches/otel-configuration-loader.patch @@ -0,0 +1,190 @@ +Parse and validate configuration files with ruamel.yaml and fastjsonschema. + +opentelemetry-configuration uses pyyaml and jsonschema, which have no pure-Python wheels (jsonschema needs +rpds-py), so they are excluded (vendor-excludes.txt) and the vendored, pure-Python ruamel.yaml and +fastjsonschema stand in for them: + +* YAML: ruamel.yaml's PyYAML-style SafeLoader (with the same Node classes, resolve() and + construct_document()). It resolves scalars with the YAML 1.2 core schema, which the configuration + spec requires (PyYAML implements YAML 1.1, where e.g. "on" and "no" are booleans). +* Schema: a fastjsonschema validator compiled once. Its draft 2019-09 generator, used for the 2020-12 + $schema, covers every keyword the schema uses ($ref into $defs, oneOf, exclusiveMinimum, ...). + Errors map to the same ConfigurationError messages. + +The schema is also read relative to this file rather than with importlib.resources.files( +"opentelemetry.configuration"), which would resolve the application's package. + +diff --git a/opentelemetry/configuration/file/_loader.py b/opentelemetry/configuration/file/_loader.py +index dfa0037..44029a9 100644 +--- a/opentelemetry/configuration/file/_loader.py ++++ b/opentelemetry/configuration/file/_loader.py +@@ -3,7 +3,6 @@ + + """Configuration file loading and parsing.""" + +-import importlib.resources + import json + import logging + import os +@@ -14,32 +13,23 @@ from typing import Any + from opentelemetry.agent._vendor.opentelemetry.configuration._conversion import _dict_to_dataclass + from opentelemetry.agent._vendor.opentelemetry.configuration._exceptions import ( + ConfigurationError, +- MissingDependencyError, + ) + from opentelemetry.agent._vendor.opentelemetry.configuration.file._env_substitution import ( + substitute_env_vars, + ) + from opentelemetry.agent._vendor.opentelemetry.configuration.models import OpenTelemetryConfiguration + +-try: +- import yaml +-except ImportError as exc: +- raise MissingDependencyError( +- package="pyyaml", +- feature="File configuration", +- install_name="opentelemetry-sdk", +- extras="file-configuration", +- ) from exc +- +-try: +- import jsonschema +-except ImportError as exc: +- raise MissingDependencyError( +- package="jsonschema", +- feature="File configuration", +- install_name="opentelemetry-sdk", +- extras="file-configuration", +- ) from exc ++# opentelemetry-agent: pyyaml and jsonschema have no pure-Python wheels (jsonschema needs rpds-py), ++# so the vendored ruamel.yaml (its pure-Python, PyYAML-style loader API) and fastjsonschema stand in. ++from opentelemetry.agent._vendor import fastjsonschema ++from opentelemetry.agent._vendor.ruamel.yaml import nodes as _yaml_nodes ++from opentelemetry.agent._vendor.ruamel.yaml.error import YAMLError ++from opentelemetry.agent._vendor.ruamel.yaml.loader import SafeLoader as _RuamelSafeLoader ++ ++ ++class SafeLoader(_RuamelSafeLoader): ++ # Only ruamel.yaml's YAML class sets the composer's nesting limit; 0 means none, like PyYAML. ++ max_depth = 0 + + # Schema version vendored in schema.json. ``file_format`` values are accepted + # per the configuration spec's versioning rules: the major version must match, +@@ -50,15 +40,24 @@ _SUPPORTED_SCHEMA_MAJOR = 1 + _SUPPORTED_SCHEMA_MINOR = 1 + + _schema_cache: list[dict] = [] ++_validator_cache: list[Any] = [] + + + def _get_schema() -> dict: + if not _schema_cache: +- schema_path = importlib.resources.files("opentelemetry.configuration") / "schema.json" ++ # opentelemetry-agent: resolve the schema relative to this (vendored) package, not by name. ++ schema_path = Path(__file__).resolve().parent.parent / "schema.json" + _schema_cache.append(json.loads(schema_path.read_text(encoding="utf-8"))) + return _schema_cache[0] + + ++def _get_validator() -> Any: ++ # Compiling the schema generates and executes Python code; do it once. ++ if not _validator_cache: ++ _validator_cache.append(fastjsonschema.compile(_get_schema())) ++ return _validator_cache[0] ++ ++ + _logger = logging.getLogger(__name__) + + _YAML_STR_TAG = "tag:yaml.org,2002:str" +@@ -73,8 +72,8 @@ _STANDALONE_ENV_REF = re.compile(r"\A\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}\Z" + + + def _substitute_env_in_yaml_node( +- node: yaml.Node, +- loader: yaml.SafeLoader, ++ node: _yaml_nodes.Node, ++ loader: SafeLoader, + visited: set[int] | None = None, + ): + """Apply env-var substitution to string configuration values in a node tree. +@@ -120,7 +119,7 @@ def _substitute_env_in_yaml_node( + # The parser hands this function the value node of attribute_count_limit, + # which has value="${LIMIT}", tag=str, style=None (plain/unquoted). Then: + # +- # 1. isinstance(node, yaml.ScalarNode) -> True: it is a leaf value. ++ # 1. isinstance(node, ScalarNode) -> True: it is a leaf value. + # 2. node.tag == _YAML_STR_TAG -> True: "${LIMIT}" is not a number or + # bool, so the parser tagged it str. We substitute in place, giving + # node.value = "100". +@@ -131,7 +130,7 @@ def _substitute_env_in_yaml_node( + # + # Counter-cases that reach check 3 but stop there: "${LIMIT}" quoted fails + # the style test; x${LIMIT} fails the regex. Both keep the str tag. +- if isinstance(node, yaml.ScalarNode): ++ if isinstance(node, _yaml_nodes.ScalarNode): + # A ``ScalarNode`` is a leaf -- one configuration value. Only a + # ``str``-tagged one can hold a ``${VAR}`` reference (nodes the parser + # already typed as int, float, bool or null cannot), so those are the +@@ -153,12 +152,12 @@ def _substitute_env_in_yaml_node( + # str) YAML would assign to ``node.value``; the ``(True, False)`` + # implicit-flag pair marks it as a plain scalar so that + # type-guessing applies, exactly as during normal parsing. +- node.tag = loader.resolve(yaml.ScalarNode, node.value, (True, False)) +- elif isinstance(node, yaml.SequenceNode): ++ node.tag = loader.resolve(_yaml_nodes.ScalarNode, node.value, (True, False)) ++ elif isinstance(node, _yaml_nodes.SequenceNode): + # A sequence (list): each item is itself a value node -- recurse. + for item in node.value: + _substitute_env_in_yaml_node(item, loader, visited) +- elif isinstance(node, yaml.MappingNode): ++ elif isinstance(node, _yaml_nodes.MappingNode): + # A mapping (dict): recurse into values only; keys are not + # substitution candidates per the spec. A merge key (``<<: *base``) + # appears here as an ordinary pair whose value node is the anchored +@@ -197,7 +196,8 @@ def _parse_config_content(content: str, suffix: str, file_path: str | os.PathLik + try: + if suffix == ".json": + return _substitute_env_in_json_value(json.loads(content)) +- yaml_loader = yaml.SafeLoader(content) ++ # YAML 1.2 core schema, as the configuration spec requires (PyYAML implements YAML 1.1). ++ yaml_loader = SafeLoader(content) + try: + root_node = yaml_loader.get_single_node() + if root_node is None: +@@ -206,7 +206,7 @@ def _parse_config_content(content: str, suffix: str, file_path: str | os.PathLik + return yaml_loader.construct_document(root_node) + finally: + yaml_loader.dispose() +- except yaml.YAMLError as exc: ++ except YAMLError as exc: + _logger.exception("Failed to parse YAML from %s", file_path) + raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc + except json.JSONDecodeError as exc: +@@ -294,19 +294,17 @@ def _validate_schema(data: dict) -> None: + ConfigurationError: If the data does not conform to the schema. + """ + try: +- jsonschema.validate( +- instance=data, +- schema=_get_schema(), +- cls=jsonschema.Draft202012Validator, +- ) +- except jsonschema.ValidationError as exc: ++ _get_validator()(data) ++ except fastjsonschema.JsonSchemaValueException as exc: ++ # The path starts with the root's name ("data"). ++ path = exc.path[1:] + raise ConfigurationError( +- f"Configuration does not match schema: {exc.message} (at {' -> '.join(str(p) for p in exc.absolute_path)})" +- if exc.absolute_path ++ f"Configuration does not match schema: {exc.message} (at {' -> '.join(str(p) for p in path)})" ++ if path + else f"Configuration does not match schema: {exc.message}" + ) from exc +- except jsonschema.SchemaError as exc: +- raise ConfigurationError(f"Invalid configuration schema: {exc.message}") from exc ++ except fastjsonschema.JsonSchemaDefinitionException as exc: ++ raise ConfigurationError(f"Invalid configuration schema: {exc}") from exc + + + def _validate_file_format(data: dict) -> None: diff --git a/agent/opentelemetry-agent/patches/otel-distro-http-protocol.patch b/agent/opentelemetry-agent/patches/otel-distro-http-protocol.patch new file mode 100644 index 0000000000..765b4a46a0 --- /dev/null +++ b/agent/opentelemetry-agent/patches/otel-distro-http-protocol.patch @@ -0,0 +1,17 @@ +Default the distro's OTLP protocol to http/protobuf. + +The distro defaults OTEL_EXPORTER_OTLP_PROTOCOL to grpc, but the gRPC exporters aren't vendored +(grpcio has no pure-Python wheel). With http/protobuf the SDK resolves the "otlp" exporter to the +vendored otlp_proto_http one. An explicitly set OTEL_EXPORTER_OTLP_PROTOCOL still wins. + +diff --git a/opentelemetry/distro/__init__.py b/opentelemetry/distro/__init__.py +index 4d00198..6165e7a 100644 +--- a/opentelemetry/distro/__init__.py ++++ b/opentelemetry/distro/__init__.py +@@ -28,4 +28,5 @@ class OpenTelemetryDistro(BaseDistro): + os.environ.setdefault(OTEL_TRACES_EXPORTER, "otlp") + os.environ.setdefault(OTEL_METRICS_EXPORTER, "otlp") + os.environ.setdefault(OTEL_LOGS_EXPORTER, "otlp") +- os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, "grpc") ++ # opentelemetry-agent: the gRPC exporters aren't vendored (grpcio has no pure-Python wheel). ++ os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, "http/protobuf") diff --git a/agent/opentelemetry-agent/patches/otel-importlib-metadata.patch b/agent/opentelemetry-agent/patches/otel-importlib-metadata.patch new file mode 100644 index 0000000000..5019da80e2 --- /dev/null +++ b/agent/opentelemetry-agent/patches/otel-importlib-metadata.patch @@ -0,0 +1,69 @@ +Scope OpenTelemetry's entry point lookups to the vendored distributions. + +All OpenTelemetry plugin discovery (distro, configurator, instrumentors, exporters, samplers, +propagators, resource detectors, HTTP transports) goes through opentelemetry.util._importlib_metadata. +Upstream it reads every installed distribution's entry points: the vendored copies have no +dist-info on sys.path, and the application's OpenTelemetry plugins point at un-vendored modules. +opentelemetry-vendor sync keeps the vendored distributions' METADATA and namespaced entry points in +_vendor/_metadata (metadata-dir); entry_points() and distributions() only search there. + +version() and requires() keep looking at the application's distributions: instrumentors check that the +installed version of the library they patch matches their "instruments" requirements. + +diff --git a/opentelemetry/util/_importlib_metadata.py b/opentelemetry/util/_importlib_metadata.py +index 656afbb..c7c5399 100644 +--- a/opentelemetry/util/_importlib_metadata.py ++++ b/opentelemetry/util/_importlib_metadata.py +@@ -11,38 +11,35 @@ It also normalizes minor differences across python versions 3.10+. References to + - https://github.com/open-telemetry/opentelemetry-python/pull/5203 + """ + +-import itertools ++import os + from functools import cache + from importlib.metadata import ( + Distribution, + EntryPoint, + EntryPoints, + PackageNotFoundError, +- distributions, + requires, + version, + ) +-from importlib.metadata import entry_points as original_entry_points +-from typing import Any ++from importlib.metadata import distributions as _all_distributions ++from typing import Any, Iterable ++ ++# opentelemetry-agent: entry points are only looked up in the vendored distributions' metadata ++# (written to _vendor/_metadata by opentelemetry-vendor sync), never in the application's, whose ++# OpenTelemetry plugins point at un-vendored modules. version() and requires() still see the ++# application's distributions: instrumentors check the installed versions of the libraries they patch. ++_VENDORED_METADATA_PATH = [ ++ os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "_metadata") ++] + + +-def _as_entry_points(eps: Any) -> EntryPoints: +- # Python versions greater than 3.11 return EntryPoints. +- if isinstance(eps, EntryPoints): +- return eps +- # In Python 3.10 and 3.11 entry_points() returns +- # a dict-like SelectableGroups object. +- # Use dict.values() instead of eps.values() to avoid the DeprecationWarning +- # that SelectableGroups raises when calling .values(). +- if isinstance(eps, dict): +- return EntryPoints(itertools.chain.from_iterable(dict.values(eps))) +- # This case should be unreachable, but is included as a fallback. +- return EntryPoints(ep for group in eps.groups for ep in eps.select(group=group)) ++def distributions(**kwargs: Any) -> Iterable[Distribution]: ++ return _all_distributions(path=_VENDORED_METADATA_PATH, **kwargs) + + + @cache + def _original_entry_points_cached() -> EntryPoints: +- return _as_entry_points(original_entry_points()) ++ return EntryPoints(entry_point for dist in distributions() for entry_point in dist.entry_points) + + + def entry_points(**params) -> EntryPoints: diff --git a/agent/opentelemetry-agent/patches/otel-sdk-vendored-names.patch b/agent/opentelemetry-agent/patches/otel-sdk-vendored-names.patch new file mode 100644 index 0000000000..96839c2b63 --- /dev/null +++ b/agent/opentelemetry-agent/patches/otel-sdk-vendored-names.patch @@ -0,0 +1,65 @@ +Report upstream module names from the vendored SDK. + +Instrumentations name their tracers, meters and loggers after their module (get_tracer(__name__)), +which is opentelemetry.agent._vendor.opentelemetry... once vendored. InstrumentationScope (and the +deprecated InstrumentationInfo) strip the vendor prefix, so telemetry reports the same scope names as +the upstream packages, e.g. opentelemetry.instrumentation.aiohttp_client. + +The TracerProvider's warning filter for its own InstrumentationInfo deprecation names its module by +string; use __name__ so it matches the vendored module rather than the application's SDK. + +diff --git a/opentelemetry/sdk/trace/__init__.py b/opentelemetry/sdk/trace/__init__.py +index 06fd2b3..1f7ab18 100644 +--- a/opentelemetry/sdk/trace/__init__.py ++++ b/opentelemetry/sdk/trace/__init__.py +@@ -1351,7 +1351,8 @@ class TracerProvider(trace_api.TracerProvider): + "ignore", + message=(r"You should use InstrumentationScope. Deprecated since version 1.11.1."), + category=DeprecationWarning, +- module="opentelemetry.sdk.trace", ++ # opentelemetry-agent: match this (vendored) module, not the application's SDK. ++ module=__name__, + ) + + instrumentation_info = InstrumentationInfo( +diff --git a/opentelemetry/sdk/util/instrumentation.py b/opentelemetry/sdk/util/instrumentation.py +index ac64c2d..9cf8491 100644 +--- a/opentelemetry/sdk/util/instrumentation.py ++++ b/opentelemetry/sdk/util/instrumentation.py +@@ -13,6 +13,18 @@ from opentelemetry.agent._vendor.opentelemetry.util.types import ( # TODO: see + ) + + ++# opentelemetry-agent: instrumentations vendored under the agent's namespace name their scope after ++# their module (get_tracer(__name__)); report the upstream module name instead, e.g. ++# "opentelemetry.instrumentation.aiohttp_client" rather than "opentelemetry.agent._vendor.opentelemetry...". ++_VENDOR_PREFIX = __name__.removesuffix("opentelemetry.sdk.util.instrumentation") ++ ++ ++def _unvendored_name(name: str) -> str: ++ if isinstance(name, str) and name.startswith(_VENDOR_PREFIX): ++ return name[len(_VENDOR_PREFIX) :] ++ return name ++ ++ + class InstrumentationInfo: + """Immutable information about an instrumentation library module. + +@@ -29,7 +41,7 @@ class InstrumentationInfo: + version: str | None = None, + schema_url: str | None = None, + ): +- self._name = name ++ self._name = _unvendored_name(name) + self._version = version + if schema_url is None: + schema_url = "" +@@ -92,7 +104,7 @@ class InstrumentationScope: + schema_url: str | None = None, + attributes: Attributes = None, + ) -> None: +- self._name = name ++ self._name = _unvendored_name(name) + self._version = version + if schema_url is None: + schema_url = "" diff --git a/agent/opentelemetry-agent/patches/protobuf-implementation.patch b/agent/opentelemetry-agent/patches/protobuf-implementation.patch new file mode 100644 index 0000000000..b6e56eb791 --- /dev/null +++ b/agent/opentelemetry-agent/patches/protobuf-implementation.patch @@ -0,0 +1,119 @@ +Select the vendored protobuf implementation: upb when built, else pure Python. + +Upstream probes for the upb/cpp C extensions with importlib.import_module("google._upb._message") +and friends, which imports the application's protobuf by its top-level name, and honours +PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION, which is meant for the application's protobuf: "cpp" makes +descriptor.py import the missing vendored pyext and crash. Only the vendored upb extension (included in +platform builds of the agent, see patches/native/protobuf-upb.patch) is considered; otherwise the +pure-Python implementation is used. + +diff --git a/google/protobuf/internal/api_implementation.py b/google/protobuf/internal/api_implementation.py +--- a/google/protobuf/internal/api_implementation.py ++++ b/google/protobuf/internal/api_implementation.py +@@ -6,7 +6,6 @@ + # https://developers.google.com/open-source/licenses/bsd + """Determine which implementation of the protobuf API is used in this process.""" + +-import importlib + import os + import sys + import warnings +@@ -24,89 +23,19 @@ def _ApiVersionToImplementationType(api_version): + return None + + +-_implementation_type = None ++# Only the vendored upb extension is considered (platform builds of the agent include it; ++# the pure-Python build doesn't). Never probe for the application's google._upb / ++# google.protobuf.pyext by their top-level names, and don't follow ++# PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION, which configures the application's protobuf ++# (e.g. "cpp" would make descriptor.py import the missing vendored pyext._message). + try: + # pylint: disable=g-import-not-at-top +- from opentelemetry.agent._vendor.google.protobuf.internal import _api_implementation +- # The compile-time constants in the _api_implementation module can be used to +- # switch to a certain implementation of the Python API at build time. +- _implementation_type = _ApiVersionToImplementationType( +- _api_implementation.api_version +- ) +-except ImportError: +- pass # Unspecified by compiler flags. +- +- +-def _CanImport(mod_name): +- try: +- mod = importlib.import_module(mod_name) +- # Work around a known issue in the classic bootstrap .par import hook. +- if not mod: +- raise ImportError(mod_name + ' import succeeded but was None') +- return True +- except ImportError: +- return False +- +- +-if _implementation_type is None: +- if _CanImport('google._upb._message'): +- _implementation_type = 'upb' +- elif _CanImport('google.protobuf.pyext._message'): +- _implementation_type = 'cpp' +- else: +- _implementation_type = 'python' +- +-# This environment variable can be used to switch to a certain implementation +-# of the Python API, overriding the compile-time constants in the +-# _api_implementation module. Right now only 'python', 'cpp' and 'upb' are +-# valid values. Any other value will raise error. +-_implementation_type = os.getenv( +- 'PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', _implementation_type +-) +- +-if _implementation_type not in ('python', 'cpp', 'upb'): +- raise ValueError( +- 'PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION {0} is not ' +- "supported. Please set to 'python', 'cpp' or " +- "'upb'.".format(_implementation_type) +- ) ++ from opentelemetry.agent._vendor.google._upb import _message as _c_module + +-if 'PyPy' in sys.version and _implementation_type == 'cpp': +- warnings.warn( +- 'PyPy does not work yet with cpp protocol buffers. ' +- 'Falling back to the python implementation.' +- ) ++ _implementation_type = 'upb' ++except ImportError: + _implementation_type = 'python' +- +-_c_module = None +- +-if _implementation_type == 'cpp': +- try: +- # pylint: disable=g-import-not-at-top +- from opentelemetry.agent._vendor.google.protobuf.pyext import _message +- +- sys.modules['google3.net.proto2.python.internal.cpp._message'] = _message +- _c_module = _message +- del _message +- except ImportError: +- # TODO: fail back to python +- warnings.warn('Selected implementation cpp is not available.') +- pass +- +-if _implementation_type == 'upb': +- try: +- # pylint: disable=g-import-not-at-top +- from opentelemetry.agent._vendor.google._upb import _message +- +- _c_module = _message +- del _message +- except ImportError: +- warnings.warn( +- 'Selected implementation upb is not available. ' +- 'Falling back to the python implementation.' +- ) +- _implementation_type = 'python' +- pass ++ _c_module = None + + # Detect if serialization should be deterministic by default + try: diff --git a/agent/opentelemetry-agent/patches/requests-chardet.patch b/agent/opentelemetry-agent/patches/requests-chardet.patch new file mode 100644 index 0000000000..dd05afa6d6 --- /dev/null +++ b/agent/opentelemetry-agent/patches/requests-chardet.patch @@ -0,0 +1,46 @@ +Use the vendored charset_normalizer instead of looking up chardet. + +Upstream prefers an installed chardet (via importlib.import_module and a version +import), which would load the application's package. + +diff --git a/requests/__init__.py b/requests/__init__.py +--- a/requests/__init__.py ++++ b/requests/__init__.py +@@ -51,10 +51,8 @@ try: + except ImportError: + charset_normalizer_version = None + +-try: +- from chardet import __version__ as chardet_version # type: ignore[import-not-found] +-except ImportError: +- chardet_version = None ++# Only the vendored charset_normalizer is used, never the application's chardet. ++chardet_version = None + + + def check_compatibility( +diff --git a/requests/compat.py b/requests/compat.py +--- a/requests/compat.py ++++ b/requests/compat.py +@@ -34,19 +34,8 @@ except (TypeError, AttributeError): + # ------------------- + + +-def _resolve_char_detection() -> ModuleType | None: +- """Find supported character detection libraries.""" +- chardet = None +- for lib in ("chardet", "charset_normalizer"): +- if chardet is None: +- try: +- chardet = importlib.import_module(lib) +- except ImportError: +- pass +- return chardet +- +- +-chardet = _resolve_char_detection() ++# Always use the vendored charset_normalizer, never the application's chardet. ++from opentelemetry.agent._vendor import charset_normalizer as chardet + + # ------- + # Pythons diff --git a/agent/opentelemetry-agent/patches/requests-packages.patch b/agent/opentelemetry-agent/patches/requests-packages.patch new file mode 100644 index 0000000000..fd76330cfc --- /dev/null +++ b/agent/opentelemetry-agent/patches/requests-packages.patch @@ -0,0 +1,37 @@ +Disable the requests.packages compatibility aliases. + +Upstream imports urllib3/idna/chardet by their top-level names and registers them as +`requests.packages.*` in the global sys.modules, which would load and alias the +application's own packages. + +diff --git a/requests/packages.py b/requests/packages.py +--- a/requests/packages.py ++++ b/requests/packages.py +@@ -1,23 +1,4 @@ +-import sys +- +-from .compat import chardet +- +-# This code exists for backwards compatibility reasons. +-# I don't like it either. Just look the other way. :) +- +-for package in ("urllib3", "idna"): +- locals()[package] = __import__(package) +- # This traversal is apparently necessary such that the identities are +- # preserved (requests.packages.urllib3.* is urllib3.*) +- for mod in list(sys.modules): +- if mod == package or mod.startswith(f"{package}."): +- sys.modules[f"requests.packages.{mod}"] = sys.modules[mod] +- +-if chardet is not None: +- target = chardet.__name__ +- for mod in list(sys.modules): +- if mod == target or mod.startswith(f"{target}."): +- imported_mod = sys.modules[mod] +- sys.modules[f"requests.packages.{mod}"] = imported_mod +- mod = mod.replace(target, "chardet") +- sys.modules[f"requests.packages.{mod}"] = imported_mod ++# The upstream module imports urllib3, idna and chardet by their top-level names and ++# registers them as `requests.packages.*` in the global sys.modules. In the vendored copy ++# that would load (and alias) the application's own packages, so the backwards ++# compatibility aliases are disabled. diff --git a/agent/opentelemetry-agent/patches/ruamel-yaml-pure.patch b/agent/opentelemetry-agent/patches/ruamel-yaml-pure.patch new file mode 100644 index 0000000000..7fe8173f68 --- /dev/null +++ b/agent/opentelemetry-agent/patches/ruamel-yaml-pure.patch @@ -0,0 +1,24 @@ +Never load the libyaml binding in the vendored ruamel.yaml. + +ruamel.yaml imports the optional C parser and emitter from the _ruamel_yaml extension module +(ruamel.yaml.clib), which would be the application's, not vendored. ruamel/yaml/cyaml.py is dropped; this +patch makes main.py fall back to the pure-Python implementation unconditionally. + +diff --git a/ruamel/yaml/main.py b/ruamel/yaml/main.py +index fdf7c72..5e23910 100644 +--- a/ruamel/yaml/main.py ++++ b/ruamel/yaml/main.py +@@ -42,10 +42,9 @@ if False: # MYPY + from types import TracebackType + from pathlib import Path + +-try: +- from _ruamel_yaml import CParser, CEmitter # type: ignore +-except: # NOQA +- CParser = CEmitter = None ++# opentelemetry-agent: always use the pure-Python parser and emitter; the libyaml binding ++# (_ruamel_yaml) would be the application's, not vendored. ++CParser = CEmitter = None + + # import io + diff --git a/agent/opentelemetry-agent/pyproject.toml b/agent/opentelemetry-agent/pyproject.toml new file mode 100644 index 0000000000..d4a6c59f9d --- /dev/null +++ b/agent/opentelemetry-agent/pyproject.toml @@ -0,0 +1,194 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-agent" +dynamic = ["version"] +description = "OpenTelemetry Python Agent" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [] + +[project.scripts] +# The vendored opentelemetry-instrument: its sitecustomize hook imports the vendored auto-instrumentation. +opentelemetry-instrument = "opentelemetry.agent._vendor.opentelemetry.instrumentation.auto_instrumentation:run" + +# The bridge for the application's opentelemetry-api, selected by the vendored sitecustomize (see +# patches/otel-api-bridge.patch). The names must match _AGENT_BRIDGE. +[project.entry-points.opentelemetry_context] +opentelemetry_agent = "opentelemetry.agent._bridge.context:BridgeRuntimeContext" + +[project.entry-points.opentelemetry_tracer_provider] +opentelemetry_agent = "opentelemetry.agent._bridge.trace:BridgeTracerProvider" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/agent/opentelemetry-agent" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.opentelemetry-vendor] +destination = "src/opentelemetry/agent/_vendor/" +requirements = "src/opentelemetry/agent/_vendor/requirements.txt" +namespace = "opentelemetry.agent._vendor" +python-version = "3.10" +protected-files = ["__init__.py", "README.rst", "requirements.txt"] +drop = [ + # Typing markers, tests and CLI entry points aren't used by the agent. + "**/py.typed", + "**/__main__.py", + "certifi/tests", + "charset_normalizer/cli", + "idna/cli.py", + # Debug-report helper (`python -m requests.help`); imports pyOpenSSL/cryptography. + "requests/help.py", + # Optional urllib3 integrations that would import the user's packages. Their imports are + # guarded: SOCKS proxies raise InvalidSchema in requests (no PySocks), pyOpenSSL is only + # used without SNI, emscripten only on Pyodide, and HTTP/2 (h2) only after an explicit + # urllib3.http2.inject_into_urllib3(). + "urllib3/contrib/socks.py", + "urllib3/contrib/pyopenssl.py", + "urllib3/contrib/emscripten", + "urllib3/http2/connection.py", + # protobuf: benchmarks and test helpers (import google_benchmark/numpy/unittest_pb2) and the protoc + # plugin API. google/protobuf/pyext is kept: its cpp_message.py wraps the vendored upb extension. + "google/protobuf/internal/*_benchmark.py", + "google/protobuf/internal/testing_refleaks.py", + "google/protobuf/testdata", + "google/protobuf/compiler", + # gRPC service stubs (import grpcio, which isn't vendored: no pure-Python wheel). + "google/longrunning/operations_pb2_grpc.py", + "opentelemetry/proto/collector/**/*_pb2_grpc.py", + # prometheus_client integrations with web frameworks the agent doesn't use (import the application's + # aiohttp, Django and Twisted). + "prometheus_client/aiohttp", + "prometheus_client/django", + "prometheus_client/twisted", + # ruamel.yaml's libyaml binding wrappers and the build script of its optional C extension. + "ruamel/yaml/cyaml.py", + "ruamel/yaml/clibz", +] +# Applied in order after imports are rewritten; paths inside the patches are relative to destination. +patches = [ + "patches/requests-packages.patch", + "patches/requests-chardet.patch", + "patches/certifi-resources.patch", + "patches/protobuf-implementation.patch", + "patches/otel-importlib-metadata.patch", + "patches/otel-sdk-vendored-names.patch", + "patches/otel-distro-http-protocol.patch", + "patches/otel-configuration-loader.patch", + "patches/ruamel-yaml-pure.patch", + "patches/otel-api-bridge.patch", +] +sbom-file = "src/opentelemetry/agent/_vendor/bom.cdx.json" +# Scoped METADATA and namespaced entry points for the vendored importlib.metadata lookups; see +# patches/otel-importlib-metadata.patch. +metadata-dir = "_metadata" + +# These wheels declare Apache-2.0 but ship no license file; use the repository's (identical) Apache-2.0 text. +[tool.opentelemetry-vendor.license-files] +opentelemetry-configuration = "../../LICENSE" +opentelemetry-distro = "../../LICENSE" +opentelemetry-util-http = "../../LICENSE" + +# Files whose findings from the vendored-code checks have been reviewed, per check. +[tool.opentelemetry-vendor.allow] +dynamic-imports = [ + # Only imports stdlib codec modules (encodings.*, _codecs_*). + "charset_normalizer/cd.py", + "charset_normalizer/utils.py", + # Instrumentation imports (and wrapt patches) the application's libraries by module name: intended. + "opentelemetry/instrumentation/utils.py", + "wrapt/importer.py", + "wrapt/patches.py", + "wrapt/proxies.py", + # Python-object tags of ruamel.yaml's unsafe constructors; the configuration loader uses SafeLoader. + "ruamel/yaml/constructor.py", + # YAML(plug_ins=...): the vendored tree has no plug-ins and the agent doesn't pass any. + "ruamel/yaml/main.py", +] +side-effects = [ + # Warning filters only target the vendored packages' own warning classes (or, for the SDK's + # TracerProvider, its own vendored module; see patches/otel-sdk-vendored-names.patch). + "opentelemetry/sdk/_logs/_internal/__init__.py", + "opentelemetry/sdk/trace/__init__.py", + "requests/__init__.py", + "ruamel/yaml/error.py", + "urllib3/__init__.py", + # set_environ() temporarily sets an environment variable and restores it. + "requests/utils.py", + # Configuring the process through OTEL_* environment variables (and PYTHONPATH for the + # sitecustomize hook) is what the distro and opentelemetry-instrument are for. + "opentelemetry/distro/__init__.py", + "opentelemetry/instrumentation/auto_instrumentation/__init__.py", + # Selects the agent's bridge for the application's opentelemetry-api (OTEL_PYTHON_CONTEXT and + # OTEL_PYTHON_TRACER_PROVIDER); see patches/otel-api-bridge.patch. + "opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py", + # Copies the deprecated prometheus_multiproc_dir variable to PROMETHEUS_MULTIPROC_DIR when set. + "prometheus_client/multiprocess.py", + "prometheus_client/values.py", +] +external-imports = [ + # Instrumentations import and patch the application's libraries: that's what they are for. + "opentelemetry/instrumentation/*", + # Optional, guarded imports of the application's packages: + # - gRPC exporters from file configuration fail with a ConfigurationError (they aren't vendored); + "opentelemetry/configuration/_logger_provider.py", + "opentelemetry/configuration/_meter_provider.py", + "opentelemetry/configuration/_tracer_provider.py", + # - psutil for the process resource detector's process.* attributes; + "opentelemetry/sdk/resources/__init__.py", + # - numpy, only when the application converts a repeated field with numpy.asarray(); + "google/protobuf/internal/containers.py", + # - snappy for Prometheus remote-write compression; + "prometheus_client/exposition.py", + # - cryptography (version warning), simplejson and _typeshed (type checking only); + "requests/__init__.py", + "requests/compat.py", + "requests/cookies.py", + # - ordereddict (Python 2 fallback) and configobj (configobj_walker(), unused); + "ruamel/yaml/compat.py", + "ruamel/yaml/util.py", + # - brotli and zstandard content decoding. + "urllib3/response.py", + "urllib3/util/request.py", +] + +# Native extensions, built from the sdist by `opentelemetry-vendor build-native` for platform wheels. +[tool.opentelemetry-vendor.native.protobuf] +# Builds google._upb._message as opentelemetry.agent._vendor.google._upb._message (abi3). +patches = ["patches/native/protobuf-upb.patch"] +build-requirements = "native-build-requirements.txt" + +[tool.hatch.version] +path = "src/opentelemetry/agent/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +# Tags the wheel cp310-abi3- when `opentelemetry-vendor build-native` built extensions +# into the vendor tree; otherwise it stays py3-none-any. +[tool.hatch.build.targets.wheel.hooks.custom] +abi3-tag = "cp310" diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/__init__.py b/agent/opentelemetry-agent/src/opentelemetry/agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/__init__.py b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/__init__.py new file mode 100644 index 0000000000..952adae249 --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/__init__.py @@ -0,0 +1,36 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Bridge from the application's ``opentelemetry-api`` to the agent's vendored API and SDK. + +The application's API is a separate copy, with its own globals and types. The vendored sitecustomize +selects the bridge through ``OTEL_PYTHON_CONTEXT`` and ``OTEL_PYTHON_TRACER_PROVIDER``. The application's +API then loads it from this distribution's entry points: + +* `context.BridgeRuntimeContext` stores the application's context in the vendored runtime context, + translating the keys of the current span, baggage and instrumentation suppression, so spans of the + application and of the vendored instrumentations parent each other. +* `trace.BridgeTracerProvider` creates spans with the vendored tracer provider, wrapped as the + application's span type. + +Only the application's API modules imported by then are imported here: the bridge must not import the +application's ``opentelemetry.trace`` (or any module that does) while its ``opentelemetry.context`` is +still being imported. +""" + +from __future__ import annotations + +from os import environ + +from opentelemetry.agent._vendor.opentelemetry.environment_variables import _AGENT_BRIDGE + + +def release_environment_variable(name: str) -> None: + """Unset ``name`` once the application's API has loaded the bridge it selects. + + Child processes inherit the environment. Ones that don't run the agent (another virtual environment, + or ``PYTHONPATH`` removed) would fail to load the bridge: ``get_tracer_provider()`` raises. Ones + that run the agent set it again in sitecustomize. + """ + if environ.get(name) == _AGENT_BRIDGE: + del environ[name] diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/context.py b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/context.py new file mode 100644 index 0000000000..1d530b97d1 --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/context.py @@ -0,0 +1,159 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""The application's runtime context, stored in the vendored one. + +The application's ``opentelemetry.context`` loads `BridgeRuntimeContext` while it's being imported +(``OTEL_PYTHON_CONTEXT``), so only its ``opentelemetry.context.context`` module is imported here. + +Both APIs create their context keys with ``create_key()``, which makes them unique: the application's +keys of the current span, baggage and instrumentation suppression are translated to the vendored +ones. They're looked up in the application's modules once those have defined them; other keys are +kept as they are. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Mapping +from contextvars import Token +from dataclasses import dataclass +from typing import cast + +from opentelemetry.agent._bridge import release_environment_variable +from opentelemetry.agent._vendor.opentelemetry import baggage as agent_baggage +from opentelemetry.agent._vendor.opentelemetry import context as agent_context +from opentelemetry.agent._vendor.opentelemetry.context.context import Context as AgentContext +from opentelemetry.agent._vendor.opentelemetry.environment_variables import OTEL_PYTHON_CONTEXT +from opentelemetry.agent._vendor.opentelemetry.trace import propagation as agent_trace_propagation +from opentelemetry.context.context import Context, _RuntimeContext + +_Convert = Callable[[object], object] + + +def _identity(value: object) -> object: + return value + + +def _span_converters() -> tuple[_Convert, _Convert]: + # Imports the application's opentelemetry.trace.span, loaded by the time it defines the span key. + from opentelemetry.agent._bridge import span # noqa: PLC0415 # pylint: disable=import-outside-toplevel + + return span.to_app_span, span.to_agent_span + + +def _no_converters() -> tuple[_Convert, _Convert]: + return _identity, _identity + + +@dataclass(frozen=True) +class _KeyBridge: + module: str + """The application's module that defines the key.""" + attribute: str + agent_key: str + converters: Callable[[], tuple[_Convert, _Convert]] + """Return the value converters to the application's and the vendored API; called once the key is found.""" + + +_KEY_BRIDGES = ( + _KeyBridge("opentelemetry.trace.propagation", "_SPAN_KEY", agent_trace_propagation._SPAN_KEY, _span_converters), + _KeyBridge("opentelemetry.baggage", "_BAGGAGE_KEY", agent_baggage._BAGGAGE_KEY, _no_converters), + _KeyBridge( + "opentelemetry.context", + "_SUPPRESS_INSTRUMENTATION_KEY", + agent_context._SUPPRESS_INSTRUMENTATION_KEY, + _no_converters, + ), + # Not defined by opentelemetry-api versions before 1.22. + _KeyBridge( + "opentelemetry.context", + "_SUPPRESS_HTTP_INSTRUMENTATION_KEY", + agent_context._SUPPRESS_HTTP_INSTRUMENTATION_KEY, + _no_converters, + ), +) + + +@dataclass(frozen=True) +class _KeyTable: + pending: tuple[_KeyBridge, ...] + """Keys the application's API hasn't defined yet.""" + to_app: Mapping[str, tuple[str, _Convert]] + """Vendored key → the application's key and the value converter.""" + to_agent: Mapping[str, tuple[str, _Convert]] + """The application's key → vendored key and the value converter.""" + + +_key_table = _KeyTable(pending=_KEY_BRIDGES, to_app={}, to_agent={}) + + +def _keys() -> _KeyTable: + """The current key table, after looking up the pending keys. + + Updates replace the table as a whole; concurrent ones compute the same table. + """ + global _key_table # pylint: disable=global-statement + table = _key_table + if not table.pending: + return table + found: list[tuple[_KeyBridge, str]] = [] + for bridge in table.pending: + app_key = getattr(sys.modules.get(bridge.module), bridge.attribute, None) + if isinstance(app_key, str): + found.append((bridge, app_key)) + if not found: + return table + to_app = dict(table.to_app) + to_agent = dict(table.to_agent) + for bridge, app_key in found: + to_app_value, to_agent_value = bridge.converters() + to_app[bridge.agent_key] = (app_key, to_app_value) + to_agent[app_key] = (bridge.agent_key, to_agent_value) + found_bridges = {bridge for bridge, _ in found} + table = _KeyTable( + pending=tuple(bridge for bridge in table.pending if bridge not in found_bridges), + to_app=to_app, + to_agent=to_agent, + ) + _key_table = table + return table + + +def _translate(context: Mapping[str, object], keys: Mapping[str, tuple[str, _Convert]]) -> dict[str, object]: + values: dict[str, object] = {} + for key, value in context.items(): + translation = keys.get(key) + if translation is None: + values[key] = value + else: + translated_key, convert = translation + values[translated_key] = convert(value) + return values + + +def to_app_context(context: Mapping[str, object]) -> Context: + """The application's `Context` with the values of a vendored context.""" + return Context(_translate(context, _keys().to_app)) + + +def to_agent_context(context: Mapping[str, object]) -> AgentContext: + """The vendored `Context` with the values of an application's context.""" + return AgentContext(_translate(context, _keys().to_agent)) + + +class BridgeRuntimeContext(_RuntimeContext): + """The application's runtime context: the vendored runtime context, with translated keys.""" + + def __init__(self) -> None: + release_environment_variable(OTEL_PYTHON_CONTEXT) + + def attach(self, context: Context) -> Token[Context]: + # The token is opaque to the application's API: it's only passed back to detach(). + return cast("Token[Context]", agent_context._RUNTIME_CONTEXT.attach(to_agent_context(context))) + + def get_current(self) -> Context: + return to_app_context(agent_context._RUNTIME_CONTEXT.get_current()) + + def detach(self, token: Token[Context]) -> None: + agent_context._RUNTIME_CONTEXT.detach(cast("Token[AgentContext]", token)) diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/span.py b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/span.py new file mode 100644 index 0000000000..f7672d33e1 --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/span.py @@ -0,0 +1,145 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conversions between the application's and the vendored spans, span contexts and statuses. + +Only the application's ``opentelemetry.trace.span`` and ``opentelemetry.trace.status`` are imported: +the context bridge imports this module as soon as the application's ``opentelemetry.trace.propagation`` +defines its span key, before its ``opentelemetry.trace`` has finished importing. +""" + +from __future__ import annotations + +import types +import weakref +from collections.abc import Mapping + +from opentelemetry.agent._vendor.opentelemetry.trace import span as agent_span +from opentelemetry.agent._vendor.opentelemetry.trace import status as agent_status +from opentelemetry.trace.span import INVALID_SPAN, Span, SpanContext, TraceFlags, TraceState +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util import types as app_types + +# The application's spans that aren't bridge spans (e.g. extracted by its propagators) are stored as vendored +# non-recording spans: map those back to the original span. +_APP_SPANS: weakref.WeakKeyDictionary[agent_span.Span, Span] = weakref.WeakKeyDictionary() + + +def to_agent_span_context(span_context: SpanContext) -> agent_span.SpanContext: + return agent_span.SpanContext( + trace_id=span_context.trace_id, + span_id=span_context.span_id, + is_remote=span_context.is_remote, + trace_flags=agent_span.TraceFlags(span_context.trace_flags), + trace_state=agent_span.TraceState(list(span_context.trace_state.items())), + ) + + +def to_app_span_context(span_context: agent_span.SpanContext) -> SpanContext: + return SpanContext( + trace_id=span_context.trace_id, + span_id=span_context.span_id, + is_remote=span_context.is_remote, + trace_flags=TraceFlags(span_context.trace_flags), + trace_state=TraceState(list(span_context.trace_state.items())), + ) + + +def to_agent_status_code(status_code: StatusCode) -> agent_status.StatusCode: + return agent_status.StatusCode[status_code.name] + + +def to_agent_status(status: Status) -> agent_status.Status: + return agent_status.Status(to_agent_status_code(status.status_code), status.description) + + +class BridgeSpan(Span): + """The application's span type, wrapping a vendored span. + + Bridge spans are created for each lookup of the current span; they're equal when they wrap the same span. + """ + + def __init__(self, span: agent_span.Span) -> None: + self._span = span + + def end(self, end_time: int | None = None) -> None: + self._span.end(end_time) + + def get_span_context(self) -> SpanContext: + return to_app_span_context(self._span.get_span_context()) + + def set_attributes(self, attributes: Mapping[str, app_types.AttributeValue]) -> None: + self._span.set_attributes(attributes) + + def set_attribute(self, key: str, value: app_types.AttributeValue) -> None: + self._span.set_attribute(key, value) + + def add_event(self, name: str, attributes: app_types.Attributes = None, timestamp: int | None = None) -> None: + self._span.add_event(name, attributes, timestamp) + + def add_link(self, context: SpanContext, attributes: app_types.Attributes = None) -> None: + self._span.add_link(to_agent_span_context(context), attributes) + + def update_name(self, name: str) -> None: + self._span.update_name(name) + + def is_recording(self) -> bool: + return self._span.is_recording() + + def set_status(self, status: Status | StatusCode, description: str | None = None) -> None: + if isinstance(status, Status): + self._span.set_status(to_agent_status(status), description) + else: + self._span.set_status(to_agent_status_code(status), description) + + def record_exception( + self, + exception: BaseException, + attributes: app_types.Attributes = None, + timestamp: int | None = None, + escaped: bool = False, + ) -> None: + self._span.record_exception(exception, attributes, timestamp, escaped) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + # The SDK's span records the exception and sets the error status (as configured at its start). + self._span.__exit__(exc_type, exc_val, exc_tb) + + def __eq__(self, other: object) -> bool: + return isinstance(other, BridgeSpan) and other._span is self._span + + def __hash__(self) -> int: + return hash(self._span) + + def __repr__(self) -> str: + return f"BridgeSpan({self._span!r})" + + +def to_app_span(span: object) -> object: + """The application's span for a vendored span (in the vendored context).""" + if span is agent_span.INVALID_SPAN: + return INVALID_SPAN + if not isinstance(span, agent_span.Span): + return span + app_span = _APP_SPANS.get(span) + if app_span is not None: + return app_span + return BridgeSpan(span) + + +def to_agent_span(span: object) -> object: + """The vendored span for an application's span (in the application's context).""" + if isinstance(span, BridgeSpan): + return span._span + if span is INVALID_SPAN: + return agent_span.INVALID_SPAN + if not isinstance(span, Span): + return span + agent = agent_span.NonRecordingSpan(to_agent_span_context(span.get_span_context())) + _APP_SPANS[agent] = span + return agent diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/trace.py b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/trace.py new file mode 100644 index 0000000000..90892b7428 --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/_bridge/trace.py @@ -0,0 +1,126 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""The application's tracer provider, creating spans with the vendored one. + +The application's ``get_tracer_provider()`` loads `BridgeTracerProvider` (``OTEL_PYTHON_TRACER_PROVIDER``) +unless the application set its own provider first. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import TYPE_CHECKING + +from opentelemetry import context as app_context +from opentelemetry import trace as app_trace +from opentelemetry.agent._bridge import release_environment_variable +from opentelemetry.agent._bridge.context import BridgeRuntimeContext, to_agent_context +from opentelemetry.agent._bridge.span import BridgeSpan, to_agent_span_context +from opentelemetry.agent._vendor.opentelemetry import trace as agent_trace +from opentelemetry.agent._vendor.opentelemetry.context.context import Context as AgentContext +from opentelemetry.agent._vendor.opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER +from opentelemetry.context import Context +from opentelemetry.util import types as app_types + +if TYPE_CHECKING: + from opentelemetry.util._decorator import _agnosticcontextmanager +else: + try: + from opentelemetry.util._decorator import _agnosticcontextmanager + except ImportError: # opentelemetry-api < 1.24: start_as_current_span isn't usable as a decorator. + from contextlib import contextmanager as _agnosticcontextmanager + + +class BridgeTracerProvider(app_trace.TracerProvider): + """Gets tracers from the vendored global tracer provider (set when the agent configures the SDK).""" + + def __init__(self) -> None: + release_environment_variable(OTEL_PYTHON_TRACER_PROVIDER) + + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + schema_url: str | None = None, + attributes: app_types.Attributes = None, + ) -> app_trace.Tracer: + return BridgeTracer( + agent_trace.get_tracer_provider().get_tracer( + instrumenting_module_name, instrumenting_library_version, schema_url, attributes + ) + ) + + def force_flush(self, timeout_millis: int = 30000) -> bool: + """Flush the vendored SDK's span processors, like the SDK's ``TracerProvider.force_flush()``.""" + force_flush = getattr(agent_trace.get_tracer_provider(), "force_flush", None) + return force_flush(timeout_millis) if force_flush is not None else True + + +def _agent_parent_context(context: Context | None) -> AgentContext | None: + if context is not None: + return to_agent_context(context) + if isinstance(app_context._RUNTIME_CONTEXT, BridgeRuntimeContext): + # The vendored SDK reads the same current context. + return None + # The application selected another runtime context: its current span isn't the vendored one. + return to_agent_context(app_context.get_current()) + + +def _to_agent_links(links: Sequence[app_trace.Link] | None) -> list[agent_trace.Link] | None: + if links is None: + return None + return [agent_trace.Link(to_agent_span_context(link.context), link.attributes) for link in links] + + +class BridgeTracer(app_trace.Tracer): + def __init__(self, tracer: agent_trace.Tracer) -> None: + self._tracer = tracer + + def start_span( + self, + name: str, + context: Context | None = None, + kind: app_trace.SpanKind = app_trace.SpanKind.INTERNAL, + attributes: app_types.Attributes = None, + links: Sequence[app_trace.Link] | None = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + ) -> app_trace.Span: + span = self._tracer.start_span( + name, + context=_agent_parent_context(context), + kind=agent_trace.SpanKind[kind.name], + attributes=attributes, + links=_to_agent_links(links), + start_time=start_time, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) + return BridgeSpan(span) + + @_agnosticcontextmanager + def start_as_current_span( + self, + name: str, + context: Context | None = None, + kind: app_trace.SpanKind = app_trace.SpanKind.INTERNAL, + attributes: app_types.Attributes = None, + links: Sequence[app_trace.Link] | None = None, + start_time: int | None = None, + record_exception: bool = True, + set_status_on_exception: bool = True, + end_on_exit: bool = True, + ) -> Iterator[app_trace.Span]: + # Same as the SDK's Tracer.start_as_current_span; use_span sets the span in the application's context. + span = self.start_span( + name, context, kind, attributes, links, start_time, record_exception, set_status_on_exception + ) + with app_trace.use_span( + span, + end_on_exit=end_on_exit, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) as current_span: + yield current_span diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_vendor/README.rst b/agent/opentelemetry-agent/src/opentelemetry/agent/_vendor/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/_vendor/requirements.txt b/agent/opentelemetry-agent/src/opentelemetry/agent/_vendor/requirements.txt new file mode 100644 index 0000000000..1ee43e94b1 --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/_vendor/requirements.txt @@ -0,0 +1,466 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --python 3.10 --universal --generate-hashes --excludes vendor-excludes.txt vendor.in -o src/opentelemetry/agent/_vendor/requirements.txt +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 + # via requests +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f + # via requests +fastjsonschema==2.22.3 \ + --hash=sha256:8330c91b027dcf941efcb7b1f0998515ff95d177ea15bbabdfe8671965620901 \ + --hash=sha256:f670b443830b75aca3f00b75949efa20f4b2a80302ec7a21505c28dd54cffd43 + # via -r vendor.in +googleapis-common-protos==1.76.0 \ + --hash=sha256:59e13344a9f3651aa201a03e2bee815a3bf43a4baf1f9ff8a9b62a840ce25fac \ + --hash=sha256:71db0e44a1d4c2dd024ebad44ca67129cc1e2892e4861df466764749d5ef8642 + # via opentelemetry-exporter-otlp-proto-http +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via requests +opentelemetry-api==1.45.2 \ + --hash=sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34 \ + --hash=sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4 + # via + # -r vendor.in + # opentelemetry-configuration + # opentelemetry-distro + # opentelemetry-exporter-http-transport + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-exporter-prometheus + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-propagator-b3 + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-configuration==0.66b2 \ + --hash=sha256:2427a005b9cf0d2dc77cf26cbeef11ae549fecb3089818b702e6e71f27a9091b \ + --hash=sha256:de7f1b5682cab12d35f22330f025ed5a54d2a8151515eede5b0a3ed9629dcc61 + # via -r vendor.in +opentelemetry-distro==0.66b2 \ + --hash=sha256:a488231479bbc17f5c34d4ef740e9285fa8f8cd1114f73078cac9d8520eacfbd \ + --hash=sha256:b12af671779fbd31abdfe8b8dd49a31f6c0ae5d4cfe503e7d5e8d84ba0658fed + # via -r vendor.in +opentelemetry-exporter-http-transport==0.66b2 \ + --hash=sha256:66d6ed810a515432c8891da9a834464332fd70badeca45bdeeae81b4280c903b \ + --hash=sha256:fd5f5b490c099acda1cd7b909d5e8981436bf18cc8c6098d302d114c271720e9 + # via + # -r vendor.in + # opentelemetry-exporter-otlp-json-http + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-common==0.66b2 \ + --hash=sha256:06f5a9b88638e310efa1f1d206cdf6b75393e112aba4e174a963416192bd1028 \ + --hash=sha256:ace86efb75489f925f1ef809856ae5f0c7db534e4afdc624530222228b1236a5 + # via + # -r vendor.in + # opentelemetry-exporter-otlp-json-file + # opentelemetry-exporter-otlp-json-http + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-json-common==0.66b2 \ + --hash=sha256:6e83ba9312edb60fc1d4929b73457e8cca7579db4b4492b32cb71a1a8ddecdc7 \ + --hash=sha256:fa3e4e7d19d5bc63e0a6855b839651a0a20070f3ec9f72744449dce883c36933 + # via + # -r vendor.in + # opentelemetry-exporter-otlp-json-file + # opentelemetry-exporter-otlp-json-http +opentelemetry-exporter-otlp-json-file==0.66b2 \ + --hash=sha256:a500f13bcc7760a74b5db7ece7d72ec8a78e0b515539fec81705970cb2efd7ca \ + --hash=sha256:e0b64653b7bb37a67ce60051bf9e35bfa968a52081eb178a38b420f6fb73eaa6 + # via -r vendor.in +opentelemetry-exporter-otlp-json-http==0.66b2 \ + --hash=sha256:35cab5377d5c4f9934a4f3e4638b605bd20a82ab7d4097ecbbcbf64d50557f1b \ + --hash=sha256:56a91c92e09c83c01d07380f48df6f649f3ce89fd47aa8c4862ef84de8e47ddb + # via -r vendor.in +opentelemetry-exporter-otlp-proto-common==1.45.2 \ + --hash=sha256:af483b24217028892f017096731b794b196b785025d2e6808874720fd0b05a80 \ + --hash=sha256:f1f2806f3f28ce7938033823e50934fcd91595a39de38e69a28a51e19f40fc6d + # via + # -r vendor.in + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-http==1.45.2 \ + --hash=sha256:5984a3fb46bc4628e4e2b85d4a6e72637e03962aae6cf372791137768a4ad939 \ + --hash=sha256:6e4d025138829d0ee57946930c2ccad6491ad4d92a958e9c7eea69278fe60cfc + # via -r vendor.in +opentelemetry-exporter-prometheus==0.66b2 \ + --hash=sha256:ada6b2507a31e10405f686e6a8498cb463a6931b5791337b04cfe14288db497f \ + --hash=sha256:b73998788aa30bec3bfe20fae312650878adb59f6563ed8bf37c188cb450a189 + # via -r vendor.in +opentelemetry-instrumentation==0.66b2 \ + --hash=sha256:02b094150e53b76f5906d75e628dbb3833bbc6e62fa109b5c7ba0c081865e923 \ + --hash=sha256:fa9ccfdab147a14af8107d19ef0a7b5015675273c5adced7a1fc38f28b42ec4c + # via + # -r vendor.in + # opentelemetry-distro + # opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-aiohttp-client==0.66b2 \ + --hash=sha256:33c51743ca225c2405b81b40ac744697804aef44b530f64d1f524c9a9cd3dca2 \ + --hash=sha256:9b1b3ec682b7b1df3d3c8f7ad1745703be847391bb722c9bd39b661902c667a4 + # via -r vendor.in +opentelemetry-propagator-b3==1.45.2 \ + --hash=sha256:86c7f921a8390c0f7d88a27450765b6d075a3579f2592baa623723e9e527a1b8 \ + --hash=sha256:e3ab95b91a3fd70c89b30bc366702984f1eb811a53416bf8555e700c290997ab + # via -r vendor.in +opentelemetry-proto==1.45.2 \ + --hash=sha256:03a7ca1b43f89fd5b965d5975902bb2515c0c86e2428b456836e9a6149c6235a \ + --hash=sha256:bd8d23cf770605b0af9b7be2071d26a464fea289d35429abfd2a66226114ad64 + # via + # -r vendor.in + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-http +opentelemetry-proto-json==0.66b2 \ + --hash=sha256:0d6e905393710d4fca041b22aea8685f825c2da177de0de2d9ebf4218f5a6654 \ + --hash=sha256:4da45dc527e68bc00fd4d16773c58b6a9a229ebb7a8dce997403414eaa03b3bb + # via + # -r vendor.in + # opentelemetry-exporter-otlp-json-common + # opentelemetry-exporter-otlp-json-http +opentelemetry-sdk==1.45.2 \ + --hash=sha256:7eec4de13dd21ff3a1670dcb0e02079e692736f94d733dfdce5af461d884248d \ + --hash=sha256:ff974ea70ee4f1d51e2ec152db37c2b626c7bf82bb3f7d86114fd8704e7199c6 + # via + # -r vendor.in + # opentelemetry-configuration + # opentelemetry-distro + # opentelemetry-exporter-otlp-common + # opentelemetry-exporter-otlp-json-common + # opentelemetry-exporter-otlp-json-file + # opentelemetry-exporter-otlp-json-http + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-exporter-prometheus +opentelemetry-semantic-conventions==0.66b2 \ + --hash=sha256:4cbe61e5f9275d57ab31708446e7938ef4291ebbe6a7fca6f7a629c5acae87d8 \ + --hash=sha256:7da5f1a5dc4383eb66711d4d3d3e71824d9403ac1559fea021cc103c1a4c4976 + # via + # -r vendor.in + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-sdk +opentelemetry-util-http==0.66b2 \ + --hash=sha256:7ba066ea609202e474679ddf1d0f753a1e6619bd206b36afaecba4bbb86e25d0 \ + --hash=sha256:c4bb5d6744e60353bcaa3e325bc529a1515346b51cb4d9c8e5b730579337e06d + # via opentelemetry-instrumentation-aiohttp-client +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via opentelemetry-instrumentation +prometheus-client==0.26.0 \ + --hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \ + --hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6 + # via opentelemetry-exporter-prometheus +protobuf==7.36.2 \ + --hash=sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb \ + --hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 \ + --hash=sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728 \ + --hash=sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353 \ + --hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e \ + --hash=sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e \ + --hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e \ + --hash=sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf + # via + # -r vendor.in + # googleapis-common-protos + # opentelemetry-proto +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via + # -r vendor.in + # opentelemetry-exporter-http-transport + # opentelemetry-exporter-otlp-proto-http +ruamel-yaml==0.19.1 \ + --hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \ + --hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993 + # via -r vendor.in +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # -r vendor.in + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-propagator-b3 + # opentelemetry-sdk + # opentelemetry-semantic-conventions +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + # via + # opentelemetry-exporter-http-transport + # requests +wrapt==2.5.2 \ + --hash=sha256:01ace38a377198e0b981b027a46cbdb5502d0123bd39c1a0f60a83a5eab7698e \ + --hash=sha256:035cd71e2aadfc0ad366ab4785b49e62093370885ea4b527b61b308b7a92ff14 \ + --hash=sha256:04703a2a4eea122c3a6b3af89e72bbbaa20779e5c7199c3d06e3fcb6601bc7d4 \ + --hash=sha256:0743950d3dda9940e56f81f5334ba051c4fe2e1421e4e4a4ef8eb8fb86a90894 \ + --hash=sha256:08d1d7bcb7dd277b7b976fd914455c3c1088f5c101ac7469ae5a5b8c03be0a0a \ + --hash=sha256:092472b7b1494deef40dac5e9f6295013a9d51b112b5fbc4350f64dc2574e46a \ + --hash=sha256:0d7639c3287d4efcdc2c8837f1854d1ecf27aae761714ba3d95e61fe8eb48db3 \ + --hash=sha256:0fa61d2f1033039aa47f8f1b4e076ca3c9d0fc08e448c4b1bd06d8b59da89cbf \ + --hash=sha256:11655df8e17658a67628cdc97fe95d6b263e023213cf18067f21e5ec0b0285f3 \ + --hash=sha256:1433ddff7786f2d0d4096646530a7aa04de0e776715014b80bc289e874fd8538 \ + --hash=sha256:143a3f8b33984e35950ae5d0159be2bac93356b6f79b509af9941a4be64c4368 \ + --hash=sha256:18819c05409a161999be68da8e451636470c35f77e25c8201825a33a7d88c89c \ + --hash=sha256:1ad706a42b139ad7f03f32aed57da5e10fe8ed707e29849cfa94f1d49cba1405 \ + --hash=sha256:1c17ca5ce83c7145eddaa20073398ec2192dad65c8d8ff4d9b78fd2917c46a75 \ + --hash=sha256:217798fb0cfb31479a264f841b0242bc1bed1ffc70d233e8444a876cec57928c \ + --hash=sha256:229859aebfc909626ea6b5a3ae9523aba43c63fd828f876f2a813f4a5767726f \ + --hash=sha256:244d57e361833389278820e5242f4a364305f633572d9500d3a85f40417aefee \ + --hash=sha256:2594e0be4ee09b2f19828265a39df90e591def44215fe8c5b8d6b83aa4cc18b5 \ + --hash=sha256:269321b17467a10997cc6e14793bf1816e6ce488f9711c74b72552466d206739 \ + --hash=sha256:2861d50a4f330fa140ddc2a9ddee33f7e4f9bdb649a23dc9be8ed6310ffa47fc \ + --hash=sha256:2bf23cde96e0f771a6a2c68973c1ded45c9707f8bd519ede30ecf075f3c720f6 \ + --hash=sha256:2c17ec4310cc06f864799a4c37c1c716d5dcf05ceead2e63ca992a9466903b73 \ + --hash=sha256:32c0387872e67b9dfdd2b599bf31fe127e597ed8526c5f192cdaca57a491e9dc \ + --hash=sha256:36021ef761856bdbc10d5f3da9a95af93ef60d6acf9c72c8f849937ba2b902aa \ + --hash=sha256:3b6885250b809532a634acd56e360d57c201c1cdc75cbf2b5e106241ece6cbc3 \ + --hash=sha256:40e03506f09dbf4854fd69848e843dadd9b7fbbdd217837cb27b37fb8f0b5bc3 \ + --hash=sha256:40f8ae3eb1388763fd2e60b55e1d569f66e80552e02a333f3d2fa9139a509d1e \ + --hash=sha256:430070776fb30d925a7a1e13d68086e4731b63eedf4c43ee62fc4c8108920cce \ + --hash=sha256:450cc93f5a2d58d4a74d384d52db4b41d5728065c0057c9bb4666c6115f2a2c9 \ + --hash=sha256:459bb66cd4bc43e35cba1f1ab2bdfb9b507e0d8dc0ec40c93a060c9cb0c5415e \ + --hash=sha256:46cee4400437c9f33e755522be9e7ce6ebd097273f464eb6f57bee09787f0c59 \ + --hash=sha256:48fd5026977adb3e2fda292a6a796967b511935d23f97dc2ae1f127a3a8639d5 \ + --hash=sha256:4d5faf5c3e435207b3a51294ed5a00e5e17e3842442ccbbd864ee9bc0efc63fb \ + --hash=sha256:4e86f3543c67d72b4d4c08c3e351523c2d69cd05932df65937d79bc472e9d62f \ + --hash=sha256:51465dcbab69d9c02e8ca943befa1eabc37ccb07f9335c240d78a78330285a2d \ + --hash=sha256:53cf804402d49fa2c920a90dfab971a82bca67fb43e54adc7c3b24c054485b4d \ + --hash=sha256:550a597a9c0510fb8bd79864025225f4fc51717adb617191561295565337f068 \ + --hash=sha256:551cb49e88b3722faad780303833bc4fa70685fb1773270fb98b40d39d9fddbf \ + --hash=sha256:5726faa7342ba473041bc1a276486d232b42e3927d35427729b266b1b5ecaa42 \ + --hash=sha256:594c6d10b9cfb951a09f7338531a41d31194e7eee9dee206c0a8992421682719 \ + --hash=sha256:59b7e6d1dea7c3f38bab21954f587499c27c16ea537058eee4add6e4da1780ea \ + --hash=sha256:5ab31d929ca8b2b7bf9320ad2a927edfb1841609951a869a9b98723b285ffbc6 \ + --hash=sha256:61eb54e9540ea522c2c4752a57549a83d3c67e6704ed708403d28e067aaf90f0 \ + --hash=sha256:62b339565cfdfcc0ba71c527b4ad13ac9de25504d474fa4ae5db95d1c7e4c6a2 \ + --hash=sha256:73d432588d1e0a8fcb88d46ef2733e848ff0c3e14111a0420c77199fd2a8cd30 \ + --hash=sha256:7a7efde469ad904b8e05f98c76d63b437f4756c30ec7b9136bef02bdb5d1b14c \ + --hash=sha256:7f89423965b23aa62a90bf21c20353056a829ca9a60c01fde9aec9876c99392e \ + --hash=sha256:7ffa0be31f651da63ee51e5b74701339feac1860b561a985c7b4869d4600e800 \ + --hash=sha256:80eec154f8ce6d26a553481772f111a064091b7e37700c690e0024921d57c569 \ + --hash=sha256:81bdf0bd141d9b1706a0751b2ee068c7afe6be4042712d427b32b3072d5c40b9 \ + --hash=sha256:81e685c7662fab9958a852b9c86f3b5b8eee45d047a2766d6c23b63ad1eee423 \ + --hash=sha256:84619ca6b6f67a7dd53754ad402cafceb4ddc9d8981f144f62718b783f05786e \ + --hash=sha256:8581eab7cb0a3bc6e8c8f64f853a1a814664d71f7ca74efb3011b4df2a40314b \ + --hash=sha256:858511731d6ede4217960c5fecdb5ecbf90e306dfd93b837395658d61374a498 \ + --hash=sha256:88f982c2bb306732271aa15cded4002ce28b5d9023de9d11e927da4dcf65e441 \ + --hash=sha256:8b039b3a296762db1908d6108365225393c4fb33c1dbc5f00aa36f8302eaeaa0 \ + --hash=sha256:8b240a879405591da10e2d20c2d1ad29b978d40ec37e24801296cfee1d4c04f9 \ + --hash=sha256:90dad90c8a74293d2cb3ed225760a8c8f20009522efe519d6380f5de7f5caece \ + --hash=sha256:99978d196df45c115273c6119e4d0dacbc67bc267a196506625bf7084b741f0b \ + --hash=sha256:9c0e0342eba94829491985516e951d1b6a2de402ae4a44d1ea0debbd929cb7a9 \ + --hash=sha256:9cb33f293e3aa04b36f7d165dc05b42db85e0a88e20a8fd81f60426d1f17d535 \ + --hash=sha256:9d4e1cdf2efdc7181c8177f6090886e22278e70f7257df12863d2f52a9dcbbd7 \ + --hash=sha256:a0541e62f7b9c0d42130e3469d6ac3b1767f019314ae795646a2850a75c64412 \ + --hash=sha256:a60863d874cc6ac917bbf44324e3f195564064dee15c84e83b19e1fbbccd3169 \ + --hash=sha256:a829b0febab0828f5d47903b11474f9f23711da5ac55e15d0d177e907d3c15e7 \ + --hash=sha256:a8626135ecbb0b892ca79c388581ab0ef57e087f09dc0e51a2f34185d3682055 \ + --hash=sha256:ac00ba7d22d55d3c8d085005ce4942ff3bb118d480f889b5984f1d3454052917 \ + --hash=sha256:ad9daa883bb795b8cbe960570590878280a444173fece51f7bbfb7532a9538e2 \ + --hash=sha256:ae84013e89ec1874415549a46cfea373e28ddecbb72e1f5456cddfa2b76a28e6 \ + --hash=sha256:af9cecb68173b0945a53e7aaabc65ef6ef458f6dd201a9ebb23139e804f1f5e7 \ + --hash=sha256:afd99530994449aaac9a58e1e268eb080bb1ffd758be43ab2f8a249ce8970c9c \ + --hash=sha256:b3ebc07822649eda442382c1d20983bc6ec5aa2bf79a600da11b29ecb62a0fb2 \ + --hash=sha256:b528eb5dbf54cf9c3e9b9c5b1680e0f4965eb8750ae738f43dd14d1bc332fa42 \ + --hash=sha256:b632d90ac0d04e3ba8821b19954d6c36ea8f714b75db53c58015025fefccb1ac \ + --hash=sha256:b680bef5200899524667f813fa34dcfde9313fef9f9fa432985d4bc7b4195ba3 \ + --hash=sha256:b9ce67ec8803e6c56e78c9ebf726f61116516212c28dfa3683d0962e15cf9a62 \ + --hash=sha256:b9f41029f9bc6ba80755b9c07f5e77e9e7aafb71c625b9f67614e4ebfb24302a \ + --hash=sha256:bbb6e959c1e2c32bbad291f83af6320c8c46bb64e256801e85f55d88a09522d5 \ + --hash=sha256:bec3b6b1ae2f7ed763fb0ba1776b9b5f11283e6ff40c9cbe3a855ea652c326cf \ + --hash=sha256:c1ea16d7dfe7d235d29435307b1539495b4925e88b135d532892973a04569245 \ + --hash=sha256:c60a5cd8b7b510056f2643d94f1c144533a568ade2891a9dd35733f5e0055777 \ + --hash=sha256:c6b4bd8c662c333518a9aedb35bb9f4f7d1584fbef4bc1507a6c6a47357c628e \ + --hash=sha256:c954a582cd96faa9a7598b634efdc92c9fa7710538dae93fbd9222c3a1e13beb \ + --hash=sha256:cfd508866c421687a99bed9ce5b7b4f26fd9b93d3753685d81947ab37d54c496 \ + --hash=sha256:d108bd98ab2c5ccbf55cf2154e0a73341df930030df2e01ee35d71a44a99d3ad \ + --hash=sha256:d41c45517912610de55546188695e2bd0fbcd70b585087cd759f85a96f47be7e \ + --hash=sha256:d5322e78248f6079f6525f0a9476750112750ea0a3f3fca13fd5f18a1c864d3b \ + --hash=sha256:d8dc92a832446351ce3bf5876aa558e4f6b456ca0613d02969ce0db4762394b2 \ + --hash=sha256:da51f5ac290f2e024ee842799c6592f68cd880c20f6b3a54305d1b6aeab459a4 \ + --hash=sha256:ded0118adcb43b080cfcbe2a96dcf27171b8fd73d0b418603d899f4e6309ac50 \ + --hash=sha256:e1af6136ac5bcfab17190a70b25d8437eb8d8f9ca2b821a0655d39275a8cb7e7 \ + --hash=sha256:e1fb4af8aac3573770ed3e64efc39d953342b12bcb6e6479512a945eb5557523 \ + --hash=sha256:e33578755e07edf3a70df3241f4cf376dad507e2cc73e737311bb00b5067f4d7 \ + --hash=sha256:e4362b3b4e4b050b8f1f518e0c71a09f2e938a9f62ee559ea1743a13b3c9e8f1 \ + --hash=sha256:e57d79e3338b3cecee1dd80d6ef8f945f8ea7b92f0d8d5591f840ffa0afc14dc \ + --hash=sha256:eeaa40251c40e18c5c97ef11d5dbbad28a7b7b99fdd7a7469662799100d74dc7 \ + --hash=sha256:eff91af619ad5d587f8855f6c02164e12d33d205c702eb73d487deb499520265 \ + --hash=sha256:f00c726586871c22f542df6d5ce57a81e60e9339e4d40bcc7d7d5c6130db3cce \ + --hash=sha256:f01f56a6ddffd89179bf0424c3b5bbd187690cfa2f972472aa2d563a4e0a7fdb \ + --hash=sha256:fa642d3781c061579cb138773add4a55b16f99de9ebc41603e89a756cf9e62c6 \ + --hash=sha256:fabc60720d1e4a36bbe8aaf1c32024f04c6674991313f5acaca5eeadb564c600 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client diff --git a/agent/opentelemetry-agent/src/opentelemetry/agent/version.py b/agent/opentelemetry-agent/src/opentelemetry/agent/version.py new file mode 100644 index 0000000000..b83363231d --- /dev/null +++ b/agent/opentelemetry-agent/src/opentelemetry/agent/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.67b0.dev" diff --git a/agent/opentelemetry-agent/tests/requirements.latest.txt b/agent/opentelemetry-agent/tests/requirements.latest.txt new file mode 100644 index 0000000000..a026febbf6 --- /dev/null +++ b/agent/opentelemetry-agent/tests/requirements.latest.txt @@ -0,0 +1,5 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# The application's opentelemetry-api (and SDK) come from the core repository ([testenv] test_deps). +pytest==7.4.4 diff --git a/agent/opentelemetry-agent/tests/requirements.oldest.txt b/agent/opentelemetry-agent/tests/requirements.oldest.txt new file mode 100644 index 0000000000..917e81947b --- /dev/null +++ b/agent/opentelemetry-agent/tests/requirements.oldest.txt @@ -0,0 +1,7 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# The oldest opentelemetry-api the bridge is tested with, as the application's API: older versions +# load entry points with pkg_resources, which recent setuptools versions don't include. +opentelemetry-api==1.16.0 +pytest==7.4.4 diff --git a/agent/opentelemetry-agent/tests/test_bridge.py b/agent/opentelemetry-agent/tests/test_bridge.py new file mode 100644 index 0000000000..1ed7c2c71a --- /dev/null +++ b/agent/opentelemetry-agent/tests/test_bridge.py @@ -0,0 +1,337 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""The bridge from the application's opentelemetry-api (installed in the test environment) to the vendored SDK. + +The application's API selects the bridge through environment variables when it's imported, and keeps global +state: each test runs a script in a new interpreter. Its spans are exported in memory by the vendored SDK and +printed as JSON. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +from opentelemetry.agent._vendor.opentelemetry.environment_variables import ( + _AGENT_BRIDGE, + OTEL_PYTHON_CONTEXT, + OTEL_PYTHON_TRACER_PROVIDER, +) + +_BRIDGE_ENVIRONMENT = {OTEL_PYTHON_CONTEXT: _AGENT_BRIDGE, OTEL_PYTHON_TRACER_PROVIDER: _AGENT_BRIDGE} + +_PRELUDE = """ +import json + +from opentelemetry.agent._vendor.opentelemetry import trace as agent_trace +from opentelemetry.agent._vendor.opentelemetry.sdk.trace import TracerProvider +from opentelemetry.agent._vendor.opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.agent._vendor.opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +exporter = InMemorySpanExporter() +agent_provider = TracerProvider() +agent_provider.add_span_processor(SimpleSpanProcessor(exporter)) +agent_trace.set_tracer_provider(agent_provider) +results = {} +""" + +_EPILOGUE = """ +results["spans"] = [ + { + "name": span.name, + "kind": span.kind.name, + "trace_id": span.context.trace_id, + "span_id": span.context.span_id, + "parent_id": span.parent.span_id if span.parent else None, + "status": [span.status.status_code.name, span.status.description], + "attributes": dict(span.attributes), + "events": [[event.name, dict(event.attributes)] for event in span.events], + "links": [[link.context.span_id, dict(link.attributes)] for link in span.links], + } + for span in exporter.get_finished_spans() +] +print(json.dumps(results)) +""" + + +def _run(script: str, environment: dict[str, str] | None = None, prelude: bool = True) -> Any: + env = { + key: value + for key, value in os.environ.items() + if key not in (OTEL_PYTHON_CONTEXT, OTEL_PYTHON_TRACER_PROVIDER, "PYTHONPATH") + } + env |= _BRIDGE_ENVIRONMENT if environment is None else environment + source = textwrap.dedent(script) + if prelude: + source = _PRELUDE + source + _EPILOGUE + result = subprocess.run( + [sys.executable, "-c", source], env=env, capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr, result.stderr + return json.loads(result.stdout.splitlines()[-1]) + + +def _spans_by_name(results: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {span["name"]: span for span in results["spans"]} + + +def test_app_spans_are_exported_by_the_vendored_sdk() -> None: + results = _run( + """ + from opentelemetry import trace + + results["provider"] = type(trace.get_tracer_provider()).__name__ + tracer = trace.get_tracer("app") + with tracer.start_as_current_span("parent", kind=trace.SpanKind.SERVER, attributes={"a": 1}) as parent: + results["is_app_span"] = isinstance(trace.get_current_span(), trace.Span) + results["current_is_parent"] = trace.get_current_span() == parent + with tracer.start_as_current_span("child", links=[trace.Link(parent.get_span_context(), {"l": "v"})]) as child: + child.set_attribute("b", "x") + child.add_event("event", {"c": 2.5}) + child.set_status(trace.Status(trace.StatusCode.ERROR, "failed")) + span = tracer.start_span("status-code") + span.set_status(trace.StatusCode.OK) + span.end() + """ + ) + + spans = _spans_by_name(results) + assert results["provider"] == "BridgeTracerProvider" + assert results["is_app_span"] is True + assert results["current_is_parent"] is True + parent, child = spans["parent"], spans["child"] + assert parent["kind"] == "SERVER" + assert parent["parent_id"] is None + assert parent["attributes"] == {"a": 1} + assert child["kind"] == "INTERNAL" + assert child["trace_id"] == parent["trace_id"] + assert child["parent_id"] == parent["span_id"] + assert child["attributes"] == {"b": "x"} + assert child["events"] == [["event", {"c": 2.5}]] + assert child["links"] == [[parent["span_id"], {"l": "v"}]] + assert child["status"] == ["ERROR", "failed"] + assert spans["status-code"]["status"] == ["OK", None] + + +def test_exception_in_app_span() -> None: + results = _run( + """ + from opentelemetry import trace + + tracer = trace.get_tracer("app") + try: + with tracer.start_as_current_span("failing"): + raise ValueError("boom") + except ValueError: + pass + try: + with tracer.start_span("failing-span"): + raise KeyError("key") + except KeyError: + pass + """ + ) + + spans = _spans_by_name(results) + for name, description in (("failing", "ValueError: boom"), ("failing-span", "KeyError: 'key'")): + assert spans[name]["status"] == ["ERROR", description] + [(event_name, attributes)] = spans[name]["events"] + assert event_name == "exception" + assert attributes["exception.type"] in ("ValueError", "KeyError") + + +def test_vendored_and_app_spans_parent_each_other() -> None: + results = _run( + """ + from opentelemetry import trace + + tracer = trace.get_tracer("app") + agent_tracer = agent_trace.get_tracer("instrumentation") + with tracer.start_as_current_span("app-outer"): + results["agent_current"] = agent_trace.get_current_span().name + with agent_tracer.start_as_current_span("instrumentation"): + with tracer.start_as_current_span("app-inner"): + pass + """ + ) + + spans = _spans_by_name(results) + assert results["agent_current"] == "app-outer" + assert spans["instrumentation"]["parent_id"] == spans["app-outer"]["span_id"] + assert spans["app-inner"]["parent_id"] == spans["instrumentation"]["span_id"] + + +def test_explicit_parent_context() -> None: + results = _run( + """ + from opentelemetry import trace + + tracer = trace.get_tracer("app") + root = tracer.start_span("root") + child = tracer.start_span("child", context=trace.set_span_in_context(root)) + child.end() + root.end() + orphan = tracer.start_span("orphan", context=trace.set_span_in_context(trace.INVALID_SPAN)) + orphan.end() + """ + ) + + spans = _spans_by_name(results) + assert spans["child"]["parent_id"] == spans["root"]["span_id"] + assert spans["orphan"]["parent_id"] is None + + +def test_app_propagators_use_bridged_spans() -> None: + trace_id = 0x0AF7651916CD43DD8448EB211C80319C + results = _run( + """ + from opentelemetry import propagate, trace + + tracer = trace.get_tracer("app") + context = propagate.extract({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}) + with tracer.start_as_current_span("remote-child", context=context) as span: + carrier = {} + propagate.inject(carrier) + results["traceparent"] = carrier["traceparent"] + results["span_id"] = span.get_span_context().span_id + """ + ) + + [span] = results["spans"] + assert span["trace_id"] == trace_id + assert span["parent_id"] == 0xB7AD6B7169203331 + assert results["traceparent"].startswith(f"00-{trace_id:032x}-{results['span_id']:016x}-") + + +def test_extracted_span_round_trips() -> None: + results = _run( + """ + from opentelemetry import context, propagate, trace + + extracted = trace.get_current_span(propagate.extract({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"})) + token = context.attach(trace.set_span_in_context(extracted)) + results["same_span"] = trace.get_current_span() is extracted + context.detach(token) + results["detached"] = trace.get_current_span() is trace.INVALID_SPAN + """ + ) + + assert results["same_span"] is True + assert results["detached"] is True + + +def test_baggage_and_suppression_are_shared() -> None: + results = _run( + """ + from opentelemetry import baggage, context + from opentelemetry.agent._vendor.opentelemetry import baggage as agent_baggage + from opentelemetry.agent._vendor.opentelemetry import context as agent_context + from opentelemetry.agent._vendor.opentelemetry.instrumentation.utils import is_instrumentation_enabled + + token = context.attach(baggage.set_baggage("app", "1")) + results["agent_baggage"] = agent_baggage.get_baggage("app") + agent_token = agent_context.attach(agent_baggage.set_baggage("agent", "2")) + results["app_baggage"] = dict(baggage.get_all()) + agent_context.detach(agent_token) + context.detach(token) + + token = context.attach(context.set_value(context._SUPPRESS_INSTRUMENTATION_KEY, True)) + results["suppressed"] = not is_instrumentation_enabled() + context.detach(token) + results["enabled"] = is_instrumentation_enabled() + """ + ) + + assert results["agent_baggage"] == "1" + assert results["app_baggage"] == {"app": "1", "agent": "2"} + assert results["suppressed"] is True + assert results["enabled"] is True + + +def test_app_provider_set_first_is_kept() -> None: + results = _run( + """ + from opentelemetry import trace + + trace.set_tracer_provider(trace.NoOpTracerProvider()) + with trace.get_tracer("app").start_as_current_span("not-bridged"): + pass + results["provider"] = type(trace.get_tracer_provider()).__name__ + """ + ) + + assert results["provider"] == "NoOpTracerProvider" + assert results["spans"] == [] + + +def test_environment_is_released_once_loaded() -> None: + results = _run( + f""" + import os + from opentelemetry import context, trace + + context.get_current() + trace.get_tracer_provider() + results["environment"] = [os.environ.get({OTEL_PYTHON_CONTEXT!r}), os.environ.get({OTEL_PYTHON_TRACER_PROVIDER!r})] + """ + ) + + assert results["environment"] == [None, None] + + +def test_vendored_api_ignores_the_bridge_selection() -> None: + results = _run( + """ + import json + from opentelemetry.agent._vendor.opentelemetry import context, trace + + print(json.dumps({ + "runtime_context": type(context._RUNTIME_CONTEXT).__name__, + "provider": type(trace.get_tracer_provider()).__name__, + })) + """, + prelude=False, + ) + + assert results == {"runtime_context": "ContextVarsRuntimeContext", "provider": "ProxyTracerProvider"} + + +_SITECUSTOMIZE_DIR = ( + Path(__file__).parents[1] / "src/opentelemetry/agent/_vendor/opentelemetry/instrumentation/auto_instrumentation" +) + + +@pytest.mark.parametrize( + ("environment", "expected"), + [ + ({}, [_AGENT_BRIDGE, _AGENT_BRIDGE]), + ({OTEL_PYTHON_CONTEXT: "contextvars_context"}, ["contextvars_context", _AGENT_BRIDGE]), + ], +) +def test_sitecustomize_selects_the_bridge(environment: dict[str, str], expected: list[str]) -> None: + results = _run( + f""" + import json, os + + print(json.dumps([os.environ.get({OTEL_PYTHON_CONTEXT!r}), os.environ.get({OTEL_PYTHON_TRACER_PROVIDER!r})])) + """, + environment={ + "PYTHONPATH": str(_SITECUSTOMIZE_DIR), + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + "OTEL_LOGS_EXPORTER": "none", + } + | environment, + prelude=False, + ) + + assert results == expected diff --git a/agent/opentelemetry-agent/tests/test_hatch_build.py b/agent/opentelemetry-agent/tests/test_hatch_build.py new file mode 100644 index 0000000000..6cbd31c2c9 --- /dev/null +++ b/agent/opentelemetry-agent/tests/test_hatch_build.py @@ -0,0 +1,63 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +import sysconfig +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("hatchling") + +_SPEC = importlib.util.spec_from_file_location("hatch_build", Path(__file__).parents[1] / "hatch_build.py") +assert _SPEC is not None and _SPEC.loader is not None +hatch_build = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(hatch_build) + + +def _initialize(root: Path, target_name: str = "wheel") -> dict[str, Any]: + hook = hatch_build.CustomBuildHook(str(root), {"abi3-tag": "cp310"}, None, None, str(root), target_name) + build_data: dict[str, Any] = {"artifacts": [], "pure_python": True} + hook.initialize("1.0", build_data) + return build_data + + +def _vendor_file(root: Path, relative: str) -> None: + path = root / "src/opentelemetry/agent/_vendor" / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + + +def test_pure_without_extensions(tmp_path: Path) -> None: + _vendor_file(tmp_path, "google/protobuf/message.py") + + assert _initialize(tmp_path) == {"artifacts": [], "pure_python": True} + + +def test_abi3_extension_makes_platform_wheel(tmp_path: Path) -> None: + _vendor_file(tmp_path, "google/_upb/_message.abi3.so") + + build_data = _initialize(tmp_path) + + platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + assert build_data == { + "artifacts": ["src/opentelemetry/agent/_vendor/google/_upb/_message.abi3.so"], + "pure_python": False, + "tag": f"cp310-abi3-{platform}", + } + + +def test_non_abi3_extension_is_rejected(tmp_path: Path) -> None: + _vendor_file(tmp_path, "google/_upb/_message.cpython-312-darwin.so") + + with pytest.raises(ValueError, match="must use the stable ABI"): + _initialize(tmp_path) + + +def test_sdist_target_is_untouched(tmp_path: Path) -> None: + _vendor_file(tmp_path, "google/_upb/_message.abi3.so") + + assert _initialize(tmp_path, "sdist") == {"artifacts": [], "pure_python": True} diff --git a/agent/opentelemetry-agent/tests/test_vendored_otel.py b/agent/opentelemetry-agent/tests/test_vendored_otel.py new file mode 100644 index 0000000000..9585046384 --- /dev/null +++ b/agent/opentelemetry-agent/tests/test_vendored_otel.py @@ -0,0 +1,148 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Checks of the vendored OpenTelemetry packages and the patches that make them work under the agent's namespace.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from opentelemetry.agent._vendor.opentelemetry.configuration._exceptions import ConfigurationError +from opentelemetry.agent._vendor.opentelemetry.configuration.file._loader import load_config_file +from opentelemetry.agent._vendor.opentelemetry.distro import OpenTelemetryDistro +from opentelemetry.agent._vendor.opentelemetry.environment_variables import OTEL_TRACES_EXPORTER +from opentelemetry.agent._vendor.opentelemetry.sdk._configuration import _get_exporter_names, _import_exporters +from opentelemetry.agent._vendor.opentelemetry.sdk.environment_variables import OTEL_EXPORTER_OTLP_PROTOCOL +from opentelemetry.agent._vendor.opentelemetry.sdk.trace import TracerProvider +from opentelemetry.agent._vendor.opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.agent._vendor.opentelemetry.util._importlib_metadata import distributions, entry_points + +NAMESPACE = "opentelemetry.agent._vendor" + + +def test_entry_points_are_vendored() -> None: + eps = entry_points() + + assert eps + assert all(entry_point.module.startswith(f"{NAMESPACE}.") for entry_point in eps) + assert {dist.metadata["Name"] for dist in distributions()} >= {"opentelemetry-sdk", "opentelemetry-distro"} + + +def test_instrumentor_entry_point_has_dist_requires() -> None: + [entry_point] = entry_points(group="opentelemetry_instrumentor") + + assert entry_point.name == "aiohttp-client" + assert entry_point.value == f"{NAMESPACE}.opentelemetry.instrumentation.aiohttp_client:AioHttpClientInstrumentor" + assert entry_point.dist is not None + # The dependency check of auto-instrumentation reads the "instruments" extra. + assert any( + "extra ==" in requirement and "aiohttp" in requirement for requirement in entry_point.dist.requires or [] + ) + + +def test_otlp_exporter_defaults_to_http(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(OTEL_TRACES_EXPORTER, "otlp") + monkeypatch.delenv(OTEL_EXPORTER_OTLP_PROTOCOL, raising=False) + + OpenTelemetryDistro().configure() + names = _get_exporter_names("traces") + trace_exporters, _, _ = _import_exporters(names, [], []) + + assert os.environ[OTEL_EXPORTER_OTLP_PROTOCOL] == "http/protobuf" + assert names == ["otlp_proto_http"] + assert trace_exporters["otlp_proto_http"].__module__ == ( + f"{NAMESPACE}.opentelemetry.exporter.otlp.proto.http.trace_exporter" + ) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + pytest.param( + f"{NAMESPACE}.opentelemetry.instrumentation.aiohttp_client", + "opentelemetry.instrumentation.aiohttp_client", + id="vendored", + ), + pytest.param("my.app", "my.app", id="application"), + ], +) +def test_instrumentation_scope_name(name: str, expected: str) -> None: + tracer = TracerProvider().get_tracer(name) + + assert InstrumentationScope(name).name == expected + assert tracer._instrumentation_scope.name == expected # pylint: disable=protected-access + + +def test_load_config_file_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SERVICE", "checkout") + config_file = tmp_path / "otel.yaml" + config_file.write_text( + 'file_format: "1.0"\n' + "disabled: off\n" # a string in YAML 1.2, not a boolean + "resource:\n" + " attributes:\n" + " - name: service.name\n" + " value: ${SERVICE}\n" + ) + + with pytest.raises(ConfigurationError, match=r"Configuration does not match schema: .*disabled.* must be boolean"): + load_config_file(config_file) + + config_file.write_text(config_file.read_text().replace("disabled: off", "disabled: false")) + config = load_config_file(config_file) + + assert config.disabled is False + assert config.resource is not None and config.resource.attributes is not None + [attribute] = config.resource.attributes + assert (attribute.name, attribute.value) == ("service.name", "checkout") + + +def test_load_config_file_invalid_yaml(tmp_path: Path) -> None: + config_file = tmp_path / "otel.yaml" + config_file.write_text("file_format: [\n") + + with pytest.raises(ConfigurationError, match="Failed to parse YAML"): + load_config_file(config_file) + + +def test_imports_stay_in_namespace() -> None: + # In a fresh interpreter: importing the vendored packages must not import the application's copies. + modules = [ + "opentelemetry.sdk._configuration", + "opentelemetry.distro", + "opentelemetry.instrumentation.auto_instrumentation", + "opentelemetry.configuration.file._loader", + "opentelemetry.exporter.otlp.proto.http.trace_exporter", + "opentelemetry.exporter.otlp.json.http.trace_exporter", + "opentelemetry.exporter.otlp.json.file.trace_exporter", + "opentelemetry.exporter.prometheus", + "opentelemetry.propagators.b3", + ] + # Modules loaded at startup (e.g. by *-nspkg.pth files of the environment) don't count. + code = ( + "import json, sys\n" + "startup = set(sys.modules)\n" + f"for module in {modules!r}:\n" + f" __import__({NAMESPACE!r} + '.' + module)\n" + "print(json.dumps(sorted(set(sys.modules) - startup)))\n" + ) + loaded = json.loads(subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True).stdout) + + vendored = {"google", "ruamel", "fastjsonschema", "wrapt", "packaging", "requests", "urllib3", "typing_extensions"} + leaked = [ + module + for module in loaded + if module.split(".", 1)[0] in vendored + or ( + module.startswith("opentelemetry.") + and module not in ("opentelemetry.agent", NAMESPACE) + and not module.startswith(f"{NAMESPACE}.") + ) + ] + assert leaked == [] diff --git a/agent/opentelemetry-agent/vendor-excludes.txt b/agent/opentelemetry-agent/vendor-excludes.txt new file mode 100644 index 0000000000..3bc5529e75 --- /dev/null +++ b/agent/opentelemetry-agent/vendor-excludes.txt @@ -0,0 +1,5 @@ +# Dependencies left out of the vendored tree (passed to uv pip compile --excludes). +# opentelemetry-configuration: no pure-Python wheels (jsonschema needs rpds-py); replaced by +# ruamel.yaml and fastjsonschema, see patches/otel-configuration-loader.patch. +pyyaml +jsonschema diff --git a/agent/opentelemetry-agent/vendor.in b/agent/opentelemetry-agent/vendor.in new file mode 100644 index 0000000000..feeef174f1 --- /dev/null +++ b/agent/opentelemetry-agent/vendor.in @@ -0,0 +1,25 @@ +requests +typing-extensions>=4.6.0 +protobuf<8.0 +opentelemetry-api +opentelemetry-sdk +opentelemetry-semantic-conventions +opentelemetry-proto +opentelemetry-proto-json +opentelemetry-configuration +opentelemetry-exporter-http-transport +opentelemetry-exporter-otlp-common +opentelemetry-exporter-otlp-json-common +opentelemetry-exporter-otlp-json-file +opentelemetry-exporter-otlp-json-http +opentelemetry-exporter-otlp-proto-common +opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-prometheus +opentelemetry-propagator-b3 +opentelemetry-instrumentation +opentelemetry-distro +opentelemetry-instrumentation-aiohttp-client +# Replace pyyaml and jsonschema (excluded in vendor-excludes.txt) in opentelemetry-configuration; +# see patches/otel-configuration-loader.patch. +ruamel.yaml +fastjsonschema diff --git a/agent/opentelemetry-vendor/README.rst b/agent/opentelemetry-vendor/README.rst new file mode 100644 index 0000000000..d2534f758c --- /dev/null +++ b/agent/opentelemetry-vendor/README.rst @@ -0,0 +1,106 @@ +OpenTelemetry Vendor +==================== + +Patches +------- + +Vendored packages can be modified with patch files listed, in order, under the ``patches`` key of +``[tool.opentelemetry-vendor]`` (paths relative to the project directory). ``sync`` applies them with +``git apply`` after imports have been rewritten, so they are written against the rewritten code and the paths +inside them are relative to ``destination``. All patches are applied, or none are. + +Text above the first ``diff --git`` line is ignored by ``git apply``; use it to explain why the patch exists. +Each patch is recorded in the SBOM as the pedigree of the package it modifies. + +To write a patch: + +1. Run ``opentelemetry-vendor sync PROJECT_DIR`` and commit (or stage) the vendored files. +2. Edit the vendored files. +3. Run ``git diff --relative= > patches/.patch`` from the project directory and add an + explanation at the top. +4. Add the patch to ``patches`` and run ``sync`` again to check that it applies. + +Checks +------ + +After rewriting and patching, ``sync`` checks the vendored code for anything that escapes the vendor +namespace and fails on findings that haven't been reviewed: + +* ``imports``: an absolute import of a vendored package (e.g. added by a patch). Always an error. +* ``dynamic-imports``: ``__import__``, ``importlib.import_module``, ``importlib.resources`` lookups and + ``sys.modules`` writes that can load modules by their top-level names. +* ``logger-names``: ``logging.getLogger("")`` calls left after rewriting. +* ``module-names``: vendored module names left in protobuf builder calls of generated ``*_pb2`` code + (``BuildTopDescriptorsAndMessages(DESCRIPTOR, "", ...)``), which set the classes' ``__module__``. +* ``side-effects``: changes to process-global state: warning filters and environment variables. +* ``external-imports``: absolute imports of modules that are neither vendored nor in the standard library, + which load the application's copy. Expected where vendored code patches the application's libraries + (instrumentations), or for optional, guarded integrations. + +Fix a finding with a patch, or, once reviewed, add its file (relative to ``destination``) under the +finding's kind in ``[tool.opentelemetry-vendor.allow]`` with a comment explaining why it's safe. Entries are +``fnmatch`` patterns (``*`` also matches ``/``), e.g. ``opentelemetry/instrumentation/*``. An entry that no +longer matches anything is an error, so the list stays current. + +Requirements must be pinned with hashes (``--generate-hashes``); ``pip`` then verifies every downloaded +wheel. + +If ``sync`` fails, the destination is cleaned back to its protected files, so a half-built tree (unpatched +code, ``*.dist-info`` directories) is never left behind. The ``*.dist-info`` directories are always removed: +their license files and metadata are captured in the copied license files and the SBOM, their ``RECORD`` no +longer matches the rewritten code, and ``importlib.metadata`` can't find them inside a package anyway. + +Entry points +------------ + +The ``*.dist-info`` directories are removed, but vendored code can discover plugins through +``importlib.metadata`` entry points (OpenTelemetry does). With ``metadata-dir`` (relative to +``destination``), ``sync`` keeps each vendored distribution's ``METADATA`` and ``entry_points.txt`` in +``/-.dist-info/``, with entry point modules moved under the vendor namespace +and the ``console_scripts``/``gui_scripts`` groups dropped. An entry point whose module isn't in the vendored +tree (dropped, or its distribution excluded) is an error. The directory isn't on ``sys.path``: vendored code +finds it only through an explicit ``importlib.metadata.distributions(path=[...])``, set up by a patch. + +License files +------------- + +Every vendored wheel must ship a license file. For a wheel that doesn't, map its distribution to a license +file of the project under ``[tool.opentelemetry-vendor.license-files]``; the entry becomes an error once the +wheel ships its own. + +Native extensions +----------------- + +``sync`` vendors pure-Python wheels only; that tree is committed and makes the pure (``py3-none-any``) agent +wheel. For platform wheels, ``opentelemetry-vendor build-native PROJECT_DIR`` builds the native extensions of +the distributions configured under ``[tool.opentelemetry-vendor.native.]`` into the vendor tree: + +1. It downloads the distribution's hash-pinned sdist (from the requirements file) and extracts it. +2. It applies the entry's ``patches`` (paths inside are relative to the sdist root). They must move the + extension, and every module name compiled into it (its own and those it imports by absolute name), under + the vendor namespace, and build it with the stable ABI (``abi3``). +3. It builds a wheel with PEP 517 in a temporary virtual environment holding the hash-pinned + ``build-requirements``, and copies the namespaced ``abi3`` extensions into the vendor tree. +4. It checks the binaries for un-namespaced vendored module names (``native-module-names``; reviewed + exceptions go in ``[tool.opentelemetry-vendor.allow]``) and records the native build in the SBOM. + +It must run on CPython at ``python-version`` (abi3 extensions are compiled against the oldest supported +Python) and refuses free-threaded interpreters, which use the pure wheel. Then build the wheel from the +source tree (not from the sdist, which doesn't contain the compiled extensions); the agent's build hook tags +it ``cp310-abi3-``. ``opentelemetry-vendor build-native --clean PROJECT_DIR`` removes the extensions +and restores the committed SBOM. A failed build cleans up after itself. + +Upgrading vendored packages +--------------------------- + +1. Edit ``vendor.in``, then re-run the ``uv pip compile ... --generate-hashes`` command recorded at the top + of the requirements file to update the pins and hashes. Dependencies that must not be vendored (e.g. + without a pure-Python wheel) are left out of the resolution with ``--excludes ``; ``sync`` downloads + exactly the pinned wheels (``pip download --no-deps``). +2. Run ``opentelemetry-vendor sync PROJECT_DIR``. +3. If a patch no longer applies, re-create it with the patch workflow above against the new version (or + remove it if upstream fixed the issue). +4. Review new check findings: patch them, or add the file to ``[tool.opentelemetry-vendor.allow]``. Remove + allow entries that ``sync`` reports as unused. +5. Review the diff of the vendored files and the SBOM, then run + ``opentelemetry-vendor sync --check PROJECT_DIR`` to confirm the committed files match ``sync``. diff --git a/agent/opentelemetry-vendor/pyproject.toml b/agent/opentelemetry-vendor/pyproject.toml new file mode 100644 index 0000000000..152ec83dc8 --- /dev/null +++ b/agent/opentelemetry-vendor/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-vendor" +version = "0.0.0" # Not published to PyPI +description = "An internal CLI tool for vendoring packages for OpenTelemetry Python" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: OpenTelemetry", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Private :: Do Not Upload", +] +dependencies = [ + "cyclonedx-python-lib[json-validation] >= 11.0, < 12.0", + "libcst >= 1.5, < 2.0", + "packageurl-python >= 0.11, < 1.0", + "pip >= 25.0, < 27.0", + "rich >= 13.0, < 16.0", + "typer >= 0.15, < 1.0", + "tomli >= 2.0, < 3.0; python_version < '3.11'", +] + +[project.scripts] +opentelemetry-vendor = "opentelemetry.vendor.cli:main" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/opentelemetry-vendor" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/__init__.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/checks.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/checks.py new file mode 100644 index 0000000000..9fa34695bd --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/checks.py @@ -0,0 +1,346 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Check the final vendored tree (after rewriting and patching) for code that escapes the vendor namespace. + +Each finding has a kind: + +* ``imports``: an absolute import of a vendored package left over, e.g. added by a + patch. Always an error. +* ``dynamic-imports``: ``__import__``, ``importlib.import_module``, + ``importlib.resources`` lookups and ``sys.modules`` writes that can load or + register modules by their top-level (non-vendored) names. +* ``logger-names``: ``logging.getLogger`` calls with a vendored-name literal left + over, e.g. added by a patch; they log to the application's logger. +* ``module-names``: protobuf builder calls (in generated ``*_pb2`` code) with a + vendored module-name literal left over; the generated classes' ``__module__`` + would point at the application's package. +* ``side-effects``: changes to process-global state shared with the application: + warning filters and environment variables. +* ``external-imports``: absolute imports of modules that are neither vendored nor in the + standard library, which load the application's copy (or fail). Expected in instrumentation + code, which patches the application's libraries. + +Findings of every kind except ``imports`` can be allowed per file (or ``fnmatch`` pattern) after review. +Names are resolved through imports, so aliases such as ``import sys as _sys`` are +recognized. +""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Collection, Mapping +from dataclasses import dataclass +from fnmatch import fnmatchcase +from pathlib import Path + +import libcst as cst +from libcst.helpers import get_full_name_for_node +from libcst.metadata import MetadataWrapper, PositionProvider, QualifiedNameProvider + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.rewrite import BUILD_TOP_DESCRIPTORS, alias_name, is_vendored, parse_file + +IMPORTS = "imports" +DYNAMIC_IMPORTS = "dynamic-imports" +LOGGER_NAMES = "logger-names" +MODULE_NAMES = "module-names" +SIDE_EFFECTS = "side-effects" +NATIVE_MODULE_NAMES = "native-module-names" +EXTERNAL_IMPORTS = "external-imports" +ALLOWABLE_KINDS = (DYNAMIC_IMPORTS, LOGGER_NAMES, MODULE_NAMES, SIDE_EFFECTS, EXTERNAL_IMPORTS, NATIVE_MODULE_NAMES) +"""Finding kinds that can be allowed per file in ``[tool.opentelemetry-vendor.allow]``.""" +_KINDS = (IMPORTS, *ALLOWABLE_KINDS) +SOURCE_KINDS = (IMPORTS, DYNAMIC_IMPORTS, LOGGER_NAMES, MODULE_NAMES, SIDE_EFFECTS, EXTERNAL_IMPORTS) +"""Kinds found by ``find_issues`` in Python sources (checked by ``sync``).""" +EXTENSION_SUFFIXES = (".so", ".pyd") +# Dotted names in a binary: module names in native code are NUL-terminated C string literals. +_DOTTED_NAME_BYTES = re.compile(rb"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+") +ALLOW_TABLE = "[tool.opentelemetry-vendor.allow]" + +_IMPORT_FUNCTIONS = frozenset({"builtins.__import__", "importlib.import_module"}) +# importlib.resources functions taking a package (anchor) as their first argument. +_RESOURCE_FUNCTIONS = frozenset( + f"importlib.resources.{name}" + for name in ("files", "path", "read_text", "read_binary", "open_text", "open_binary", "contents", "is_resource") +) +# Names that resolve to the calling (vendored) module at runtime. +_SELF_ANCHORS = frozenset({"__name__", "__package__"}) +_MAPPING_MUTATORS = ("update", "setdefault", "pop", "popitem", "clear") +_SYS_MODULES = "sys.modules" +_SYS_MODULES_MUTATORS = frozenset(f"{_SYS_MODULES}.{method}" for method in _MAPPING_MUTATORS) +_GET_LOGGER = "logging.getLogger" +_OS_ENVIRON = "os.environ" +# Standard library modules of Python 3.10+ missing from some versions' sys.stdlib_module_names: +# added after 3.10 or removed since (PEP 594), so the check doesn't depend on the running Python. +_VERSIONED_STDLIB_MODULES = frozenset( + { + "annotationlib", + "compression", + "tomllib", + "aifc", + "asynchat", + "asyncore", + "audioop", + "cgi", + "cgitb", + "chunk", + "crypt", + "distutils", + "imghdr", + "imp", + "lib2to3", + "mailcap", + "msilib", + "nis", + "nntplib", + "ossaudiodev", + "pipes", + "smtpd", + "sndhdr", + "spwd", + "sunau", + "telnetlib", + "uu", + "xdrlib", + } +) +_STDLIB_MODULES = sys.stdlib_module_names | _VERSIONED_STDLIB_MODULES +_SIDE_EFFECT_FUNCTIONS = frozenset( + { + "warnings.simplefilter", + "warnings.filterwarnings", + "warnings.resetwarnings", + "os.putenv", + "os.unsetenv", + *(f"{_OS_ENVIRON}.{method}" for method in _MAPPING_MUTATORS), + } +) + + +@dataclass(frozen=True) +class Finding: + kind: str + path: str + """POSIX path relative to the vendor destination.""" + line: int + code: str + + def __str__(self) -> str: + return f"{self.path}:{self.line}: {self.code}" + + +class _TreeChecker(cst.CSTVisitor): + METADATA_DEPENDENCIES = (PositionProvider, QualifiedNameProvider) + + def __init__(self, module: cst.Module, path: str, names: Collection[str], namespace: str) -> None: + super().__init__() + self.module = module + self.path = path + self.names = names + self.namespace = namespace + self.found: list[Finding] = [] + + def visit_Import(self, node: cst.Import) -> None: + modules = [alias_name(alias) for alias in node.names] + if any(is_vendored(module, self.names, self.namespace) for module in modules): + self._add(IMPORTS, node) + if any(self._is_external(module) for module in modules): + self._add(EXTERNAL_IMPORTS, node) + + def visit_ImportFrom(self, node: cst.ImportFrom) -> None: + if node.relative or node.module is None: + return + module = get_full_name_for_node(node.module) + if module is not None and is_vendored(module, self.names, self.namespace): + self._add(IMPORTS, node) + if module is not None and self._is_external(module): + self._add(EXTERNAL_IMPORTS, node) + + def _is_external(self, module: str) -> bool: + top_level = module.split(".", 1)[0] + return not ( + top_level in self.names + or top_level in _STDLIB_MODULES + or top_level == "__future__" + or module == self.namespace + or module.startswith(f"{self.namespace}.") + ) + + def visit_Call(self, node: cst.Call) -> None: + functions = self._qualified_names(node.func) + if ( + functions & _SYS_MODULES_MUTATORS + or (functions & _IMPORT_FUNCTIONS and not self._is_safe_literal(node)) + or (functions & _RESOURCE_FUNCTIONS and not self._is_safe_resource_anchor(node)) + ): + self._add(DYNAMIC_IMPORTS, node) + if _GET_LOGGER in functions and self._is_vendored_literal(node, 0): + self._add(LOGGER_NAMES, node) + # Also matches the builder after its import was rewritten into the vendor namespace. + if any( + function == BUILD_TOP_DESCRIPTORS or function.endswith(f".{BUILD_TOP_DESCRIPTORS}") + for function in functions + ) and self._is_vendored_literal(node, 1): + self._add(MODULE_NAMES, node) + if functions & _SIDE_EFFECT_FUNCTIONS: + self._add(SIDE_EFFECTS, node) + + def visit_Assign(self, node: cst.Assign) -> None: + self._check_targets(node, [target.target for target in node.targets]) + + def visit_AnnAssign(self, node: cst.AnnAssign) -> None: + self._check_targets(node, [node.target]) + + def visit_AugAssign(self, node: cst.AugAssign) -> None: + self._check_targets(node, [node.target]) + + def visit_Del(self, node: cst.Del) -> None: + self._check_targets(node, [node.target]) + + def _check_targets(self, node: cst.CSTNode, targets: Collection[cst.BaseExpression]) -> None: + if any(self._writes_mapping(target, _SYS_MODULES) for target in targets): + self._add(DYNAMIC_IMPORTS, node) + if any(self._writes_mapping(target, _OS_ENVIRON) for target in targets): + self._add(SIDE_EFFECTS, node) + + def _qualified_names(self, node: cst.CSTNode) -> set[str]: + return {name.name for name in self.get_metadata(QualifiedNameProvider, node, set())} + + def _writes_mapping(self, target: cst.BaseExpression, mapping: str) -> bool: + if isinstance(target, (cst.Tuple, cst.List)): + return any(self._writes_mapping(element.value, mapping) for element in target.elements) + return isinstance(target, cst.Subscript) and mapping in self._qualified_names(target.value) + + def _is_safe_literal(self, node: cst.Call) -> bool: + """Whether the first argument is a string literal of a relative or non-vendored module.""" + if not node.args or node.args[0].keyword is not None: + return False + argument = node.args[0].value + if not isinstance(argument, cst.SimpleString): + return False + module = argument.evaluated_value + if not isinstance(module, str): + return False + return module.startswith(".") or not is_vendored(module, self.names, self.namespace) + + def _is_vendored_literal(self, node: cst.Call, position: int) -> bool: + """Whether the positional argument at ``position`` is a string literal naming a vendored module.""" + if len(node.args) <= position or node.args[position].keyword is not None: + return False + argument = node.args[position].value + if not isinstance(argument, cst.SimpleString): + return False + value = argument.evaluated_value + return isinstance(value, str) and not value.startswith(".") and is_vendored(value, self.names, self.namespace) + + def _is_safe_resource_anchor(self, node: cst.Call) -> bool: + """Whether the anchor is omitted, the calling module, or a non-vendored string literal.""" + if not node.args or node.args[0].keyword is not None: + return not node.args + argument = node.args[0].value + if isinstance(argument, cst.Name) and argument.value in _SELF_ANCHORS: + return True + return self._is_safe_literal(node) + + def _add(self, kind: str, node: cst.CSTNode) -> None: + line = self.get_metadata(PositionProvider, node).start.line + code = " ".join(self.module.code_for_node(node).split()) + self.found.append(Finding(kind=kind, path=self.path, line=line, code=code)) + + +def find_issues(destination: Path, names: Collection[str], namespace: str) -> list[Finding]: + """Find code escaping the vendor ``namespace`` in every ``.py`` file under ``destination``.""" + found: list[Finding] = [] + for path in sorted(destination.rglob("*.py")): + wrapper = MetadataWrapper(parse_file(path)) + checker = _TreeChecker(wrapper.module, path.relative_to(destination).as_posix(), names, namespace) + wrapper.visit(checker) + found.extend(checker.found) + return found + + +def find_native_issues( + destination: Path, names: Collection[str], namespace: str, extensions: Collection[Path] +) -> list[Finding]: + """Find un-namespaced vendored module names compiled into native ``extensions``. + + A string is flagged when it is a vendored *leaf* module path (e.g. ``google.protobuf.message``, + ``google._upb._message``), or starts with one followed by ``.`` (e.g. the type name + ``google._upb._message.Message``). Names under ``namespace`` are namespaced already. Package paths alone (``google.protobuf``) aren't flagged: + they also occur as protobuf package names in embedded descriptors. Findings report the + byte offset as their line. + """ + leaves = _leaf_module_paths(destination, names) + found: list[Finding] = [] + for extension in sorted(extensions): + relative = extension.relative_to(destination).as_posix() + for match in _DOTTED_NAME_BYTES.finditer(extension.read_bytes()): + name = match.group().decode() + if name.startswith(f"{namespace}."): + continue + parts = name.split(".") + if any(".".join(parts[:end]) in leaves for end in range(2, len(parts) + 1)): + found.append(Finding(kind=NATIVE_MODULE_NAMES, path=relative, line=match.start(), code=name)) + return found + + +def _leaf_module_paths(destination: Path, names: Collection[str]) -> set[str]: + """Dotted paths of the (un-namespaced) vendored modules that aren't packages.""" + leaves = set() + for path in destination.rglob("*"): + if path.suffix not in (".py", *EXTENSION_SUFFIXES) or path.name.startswith("__init__."): + continue + parts = path.relative_to(destination).parts + if parts[0] not in names and parts[0].split(".", 1)[0] not in names: + continue + module = path.name.split(".", 1)[0] + leaves.add(".".join((*parts[:-1], module))) + return leaves + + +def check_vendored_tree( + destination: Path, names: Collection[str], namespace: str, allow: Mapping[str, Collection[str]] +) -> list[Finding]: + """Check the Python sources; see ``check_findings``.""" + return check_findings(find_issues(destination, names, namespace), allow, SOURCE_KINDS) + + +def check_findings( + found: Collection[Finding], allow: Mapping[str, Collection[str]], kinds: Collection[str] +) -> list[Finding]: + """Raise ``VendorError`` for findings outside the ``allow``-ed files of their kind, or for unused entries. + + ``allow`` maps a kind from ``ALLOWABLE_KINDS`` to destination-relative file paths or ``fnmatch`` + patterns (``*`` also matches ``/``); only + entries of the checked ``kinds`` must match something. Returns the allowed findings. + """ + allowed = [ + finding + for finding in found + if finding.kind in ALLOWABLE_KINDS + and any(fnmatchcase(finding.path, entry) for entry in allow.get(finding.kind, ())) + ] + unallowed = [finding for finding in found if finding not in allowed] + if unallowed: + sections = [] + for kind in _KINDS: + lines = [f" {finding}" for finding in unallowed if finding.kind == kind] + if not lines: + continue + hint = ( + "imports of vendored packages must be rewritten; check the patches" + if kind == IMPORTS + else f"patch them or add the file to {ALLOW_TABLE} {kind}" + ) + sections.append(f" {kind} ({hint}):\n" + "\n".join(lines)) + raise VendorError("vendored code escapes the vendor namespace:\n" + "\n".join(sections)) + for kind, entries in allow.items(): + if kind not in kinds: + continue + paths = {finding.path for finding in found if finding.kind == kind} + for entry in entries: + if not any(fnmatchcase(path, entry) for path in paths): + raise VendorError(f"{ALLOW_TABLE} {kind} entry {entry!r} matched nothing") + return allowed diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/cli.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/cli.py new file mode 100644 index 0000000000..5e7de97950 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/cli.py @@ -0,0 +1,104 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, NoReturn + +import typer + +from opentelemetry.vendor import native as _native +from opentelemetry.vendor import output +from opentelemetry.vendor import sync as _sync +from opentelemetry.vendor.config import load_config +from opentelemetry.vendor.errors import VendorError + +app = typer.Typer(no_args_is_help=True) + +ProjectDir = Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=False, + resolve_path=True, + help="Directory containing the pyproject.toml to vendor for.", + ), +] +Check = Annotated[ + bool, + typer.Option( + "--check", + help="Only check that the vendored files match what sync would produce; change nothing.", + ), +] +Quiet = Annotated[bool, typer.Option("--quiet", "-q", help="Only print errors.")] +Verbose = Annotated[ + bool, + typer.Option("--verbose", "-v", help="Show pip output and every file removed."), +] + + +@app.callback() +def _callback() -> None: + """Vendor third-party packages into an OpenTelemetry Python package.""" + + +@app.command() +def sync(project_dir: ProjectDir, check: Check = False, quiet: Quiet = False, verbose: Verbose = False) -> None: + """Sync the vendored packages of PROJECT_DIR.""" + _configure_output(quiet, verbose) + try: + config = load_config(project_dir) + if check: + _sync.check(config) + else: + _sync.sync(config) + except VendorError as exc: + _fail(exc) + + +@app.command() +def clean(project_dir: ProjectDir, quiet: Quiet = False, verbose: Verbose = False) -> None: + """Remove vendored packages from PROJECT_DIR, keeping protected files.""" + _configure_output(quiet, verbose) + try: + _sync.clean(load_config(project_dir)) + except VendorError as exc: + _fail(exc) + + +@app.command("build-native") +def build_native( + project_dir: ProjectDir, + clean: Annotated[ + bool, typer.Option("--clean", help="Remove built extensions and restore the committed SBOM instead.") + ] = False, + quiet: Quiet = False, + verbose: Verbose = False, +) -> None: + """Build PROJECT_DIR's native extensions from their sdists into the vendor tree (for platform wheels).""" + _configure_output(quiet, verbose) + try: + config = load_config(project_dir) + if clean: + _native.clean_native(config) + else: + _native.build_native(config) + except VendorError as exc: + _fail(exc) + + +def _configure_output(quiet: bool, verbose: bool) -> None: + if quiet and verbose: + raise typer.BadParameter("--quiet and --verbose cannot be used together") + output.configure(quiet=quiet, verbose=verbose) + + +def _fail(exc: VendorError) -> NoReturn: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + +def main() -> None: + app() diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/config.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/config.py new file mode 100644 index 0000000000..d212589fc3 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/config.py @@ -0,0 +1,167 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Loading of the ``[tool.opentelemetry-vendor]`` configuration table.""" + +from __future__ import annotations + +import sys +from collections.abc import Collection, Mapping +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any + +from opentelemetry.vendor.checks import ALLOW_TABLE, ALLOWABLE_KINDS, DYNAMIC_IMPORTS +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import canonicalize_name + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +_PYPROJECT = "pyproject.toml" +_TOOL_NAME = "opentelemetry-vendor" + + +class ConfigError(VendorError): + """Raised when the vendoring configuration is missing or invalid.""" + + +@dataclass(frozen=True) +class NativeConfig: + """How to build a vendored distribution's native extensions from its sdist.""" + + patches: Collection[Path] + """Patches applied, in order, to the extracted sdist (paths inside are relative to its root).""" + build_requirements: Path + """Hash-pinned requirements file of the build-time dependencies (e.g. setuptools).""" + + +@dataclass(frozen=True) +class VendorConfig: + project_dir: Path + destination: Path + requirements: Path + namespace: str + python_version: str + protected_files: Collection[str] + drop: Collection[str] + allow: Mapping[str, Collection[str]] + """Check kind -> files (relative to ``destination``) whose findings of that kind have been reviewed.""" + patches: Collection[Path] + """Patch files, applied in order after imports are rewritten.""" + sbom_file: Path | None + license_files: Mapping[str, Path] = field(default_factory=dict) + """Canonical distribution name -> license file for wheels that don't ship one.""" + metadata_dir: Path | None = None + """Where ``sync`` keeps the vendored distributions' ``METADATA`` and namespaced entry points.""" + native: Mapping[str, NativeConfig] = field(default_factory=dict) + """Canonical distribution name -> native build, from ``[tool.opentelemetry-vendor.native.]``.""" + + +def load_config(project_dir: Path) -> VendorConfig: + """Read and validate ``[tool.opentelemetry-vendor]`` from ``project_dir``.""" + project_dir = project_dir.resolve() + pyproject = project_dir / _PYPROJECT + try: + with pyproject.open("rb") as file: + data = tomllib.load(file) + except FileNotFoundError as exc: + raise ConfigError(f"{pyproject} does not exist") from exc + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"{pyproject} is not valid TOML: {exc}") from exc + + table = data.get("tool", {}).get(_TOOL_NAME) + if not isinstance(table, dict): + raise ConfigError(f"{pyproject} has no [tool.{_TOOL_NAME}] table") + + destination = (project_dir / _require_str(table, "destination")).resolve() + if destination == project_dir or not destination.is_relative_to(project_dir): + raise ConfigError(f"'destination' must be a directory inside {project_dir}, got {destination}") + + sbom_file = _optional_str(table, "sbom-file") + metadata_dir = _optional_str(table, "metadata-dir") + if metadata_dir is not None: + _relative_paths({"metadata-dir": [metadata_dir]}, "metadata-dir") + return VendorConfig( + project_dir=project_dir, + destination=destination, + requirements=project_dir / _require_str(table, "requirements"), + namespace=_require_str(table, "namespace"), + python_version=_optional_str(table, "python-version") or f"{sys.version_info.major}.{sys.version_info.minor}", + protected_files=_str_list(table, "protected-files"), + drop=_relative_paths(table, "drop"), + allow=_allow(table), + patches=tuple(project_dir / patch for patch in _str_list(table, "patches")), + sbom_file=project_dir / sbom_file if sbom_file else None, + metadata_dir=destination / metadata_dir if metadata_dir else None, + license_files=_license_file_overrides(table, project_dir), + native=_native(table, project_dir), + ) + + +def _native(table: Mapping[str, Any], project_dir: Path) -> dict[str, NativeConfig]: + native = table.get("native", {}) + if not isinstance(native, dict): + raise ConfigError("'native' must be a table ([tool.opentelemetry-vendor.native.])") + configs = {} + for name, entry in native.items(): + if not isinstance(entry, dict): + raise ConfigError(f"'native.{name}' must be a table") + configs[canonicalize_name(name)] = NativeConfig( + patches=tuple(project_dir / patch for patch in _str_list(entry, "patches")), + build_requirements=project_dir / _require_str(entry, "build-requirements"), + ) + return configs + + +def _license_file_overrides(table: Mapping[str, Any], project_dir: Path) -> dict[str, Path]: + overrides = table.get("license-files", {}) + if not isinstance(overrides, dict) or not all(isinstance(value, str) and value for value in overrides.values()): + raise ConfigError("'license-files' must be a table of distribution names to file paths") + return {canonicalize_name(name): project_dir / path for name, path in overrides.items()} + + +def _allow(table: Mapping[str, Any]) -> dict[str, tuple[str, ...]]: + if "allow-dynamic-imports" in table: + raise ConfigError(f"'allow-dynamic-imports' moved to {ALLOW_TABLE} {DYNAMIC_IMPORTS}") + allow = table.get("allow", {}) + if not isinstance(allow, dict): + raise ConfigError(f"'allow' must be a table ({ALLOW_TABLE})") + unknown = sorted(set(allow) - set(ALLOWABLE_KINDS)) + if unknown: + raise ConfigError(f"unknown {ALLOW_TABLE} keys {unknown}; expected some of {list(ALLOWABLE_KINDS)}") + return {kind: _relative_paths(allow, kind) for kind in allow} + + +def _require_str(table: Mapping[str, Any], key: str) -> str: + value = _optional_str(table, key) + if value is None: + raise ConfigError(f"missing required key '{key}'") + return value + + +def _optional_str(table: Mapping[str, Any], key: str) -> str | None: + value = table.get(key) + if value is None: + return None + if not isinstance(value, str) or not value: + raise ConfigError(f"'{key}' must be a non-empty string") + return value + + +def _relative_paths(table: Mapping[str, Any], key: str) -> tuple[str, ...]: + paths = _str_list(table, key) + for entry in paths: + path = PurePosixPath(entry) + if not entry or path.is_absolute() or ".." in path.parts: + raise ConfigError(f"'{key}' entry {entry!r} must be relative to destination") + return paths + + +def _str_list(table: Mapping[str, Any], key: str) -> tuple[str, ...]: + value = table.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ConfigError(f"'{key}' must be a list of strings") + return tuple(value) diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/download.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/download.py new file mode 100644 index 0000000000..e31ae4d33a --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/download.py @@ -0,0 +1,133 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Download pure-Python wheels and unpack them into the vendor directory.""" + +from __future__ import annotations + +import subprocess +import sys +import zipfile +from collections.abc import Collection +from pathlib import Path + +from opentelemetry.vendor import output +from opentelemetry.vendor.errors import VendorError + + +class DownloadError(VendorError): + """Raised when downloading or unpacking vendored packages fails.""" + + +def download_wheels(requirements: Path, python_version: str, dest: Path) -> list[Path]: + """Download a pure-Python wheel for each requirement, without dependencies. + + Only ``py3-none-any`` style wheels are accepted so compiled extensions + (which cannot be rewritten into the vendor namespace) are never vendored. + """ + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + "--only-binary=:all:", + "--platform", + "any", + "--implementation", + "py", + "--abi", + "none", + "--python-version", + python_version, + "--disable-pip-version-check", + "--dest", + str(dest), + # sync requires every requirement to carry --hash, which turns on pip's hash-checking mode. + "-r", + str(requirements), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise DownloadError( + f"pip download failed (exit code {result.returncode}). Every " + "requirement must have a pure-Python (py3-none-any) wheel.\n" + f"{result.stderr.strip()}" + ) + output.detail(result.stdout.strip()) + wheels = sorted(dest.glob("*.whl")) + for wheel in wheels: + _check_pure_python(wheel) + return wheels + + +def download_sdist(requirement: str, dest: Path) -> Path: + """Download the source distribution of a single hash-pinned ``requirement`` line.""" + dest.mkdir(parents=True, exist_ok=True) + requirements = dest / "requirement.txt" + requirements.write_text(requirement + "\n") + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + "--no-binary=:all:", + "--disable-pip-version-check", + "--dest", + str(dest), + "-r", + str(requirements), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise DownloadError( + f"pip download of the sdist failed (exit code {result.returncode}).\n{result.stderr.strip()}" + ) + output.detail(result.stdout.strip()) + sdists = [path for path in dest.iterdir() if path.name.endswith((".tar.gz", ".zip"))] + if len(sdists) != 1: + raise DownloadError( + f"expected one sdist for {requirement.split(maxsplit=1)[0]!r}, got {sorted(p.name for p in sdists)}" + ) + return sdists[0] + + +def _check_pure_python(wheel: Path) -> None: + # Wheel filenames end in {python tag}-{abi tag}-{platform tag}.whl + _, abi_tag, platform_tag = wheel.stem.rsplit("-", 3)[-3:] + if abi_tag != "none" or platform_tag != "any": + raise DownloadError(f"{wheel.name} is not a pure-Python wheel") + + +def unpack_wheels(wheels: Collection[Path], destination: Path) -> dict[Path, Path]: + """Extract ``wheels`` into ``destination``, refusing to overwrite files. + + Returns a mapping of each wheel to its unpacked ``*.dist-info`` directory. + """ + destination.mkdir(parents=True, exist_ok=True) + dist_infos: dict[Path, Path] = {} + for wheel in wheels: + with zipfile.ZipFile(wheel) as archive: + dist_info_names = { + top for top in (name.split("/", 1)[0] for name in archive.namelist()) if top.endswith(".dist-info") + } + if len(dist_info_names) != 1: + raise DownloadError( + f"{wheel.name} must contain exactly one .dist-info directory, found {sorted(dist_info_names)}" + ) + dist_infos[wheel] = destination / dist_info_names.pop() + for member in archive.infolist(): + target = destination / member.filename + if not member.is_dir() and target.exists(): + raise DownloadError(f"{wheel.name} would overwrite {target}") + archive.extract(member, destination) + return dist_infos diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/entry_points.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/entry_points.py new file mode 100644 index 0000000000..fe0e5f9c1e --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/entry_points.py @@ -0,0 +1,110 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Keep a trimmed, namespaced copy of the vendored distributions' metadata. + +Vendored code that discovers plugins through ``importlib.metadata`` entry points needs the +metadata of the vendored distributions, but not that of the application's (which point at +un-vendored modules). The copy lives in a directory that isn't on ``sys.path``, so only code +that passes it explicitly (``importlib.metadata.distributions(path=[...])``) sees it. +""" + +from __future__ import annotations + +import configparser +import re +import shutil +from collections.abc import Collection +from pathlib import Path + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage +from opentelemetry.vendor.rewrite import is_vendored + +_METADATA = "METADATA" +_ENTRY_POINTS = "entry_points.txt" +_DROPPED_GROUPS = frozenset({"console_scripts", "gui_scripts"}) +"""Script wrappers are generated at install time from the vendoring project's own metadata.""" +# Same grammar as importlib.metadata.EntryPoint: ``module[:attr] [extras]``. +_ENTRY_POINT_VALUE = re.compile(r"(?P[\w.]+)(?P\s*(?::\s*[\w.]+\s*)?(?:\[.*\]\s*)?)$") + + +def write_metadata( + packages: Collection[VendoredPackage], + destination: Path, + metadata_dir: Path, + namespace: str, + names: Collection[str], +) -> int: + """Write each package's ``METADATA`` and namespaced ``entry_points.txt`` to ``metadata_dir``. + + Raises ``VendorError`` for an entry point whose module isn't in the vendored tree (dropped, + excluded or never vendored). Returns the number of entry points written. + """ + metadata_dir.mkdir(parents=True, exist_ok=True) + count = 0 + for package in sorted(packages, key=lambda package: package.name): + target = metadata_dir / package.dist_info.name + target.mkdir() + shutil.copyfile(package.dist_info / _METADATA, target / _METADATA) + source = package.dist_info / _ENTRY_POINTS + if not source.is_file(): + continue + groups = rewrite_entry_points(source.read_text(), namespace, names) + for group, entries in groups.items(): + for name, value in entries.items(): + module = value.split(":", 1)[0].strip() + if not _module_exists(destination, module.removeprefix(f"{namespace}.")): + raise VendorError( + f"entry point {group}:{name} of {package.name} references {module}, " + "which isn't in the vendored tree" + ) + count += 1 + if groups: + (target / _ENTRY_POINTS).write_text(_format(groups)) + return count + + +def rewrite_entry_points(text: str, namespace: str, names: Collection[str]) -> dict[str, dict[str, str]]: + """Parse ``entry_points.txt`` ``text`` and move vendored modules under ``namespace``. + + Script groups are dropped. Values of non-vendored modules are kept, so ``write_metadata`` + reports them. + """ + parser = _CaseSensitiveParser(delimiters=("=",), interpolation=None, strict=False) + parser.read_string(text) + groups: dict[str, dict[str, str]] = {} + for group in parser.sections(): + if group in _DROPPED_GROUPS: + continue + entries = {} + for name, value in parser.items(group): + match = _ENTRY_POINT_VALUE.match(value.strip()) + if match is None: + raise VendorError(f"invalid entry point {group}:{name} = {value!r}") + module = match.group("module") + if is_vendored(module, names, namespace): + module = f"{namespace}.{module}" + entries[name] = f"{module}{match.group('rest')}".strip() + if entries: + groups[group] = entries + return groups + + +class _CaseSensitiveParser(configparser.ConfigParser): + def optionxform(self, optionstr: str) -> str: + # Entry point names are case-sensitive; ConfigParser lowercases keys by default. + return optionstr + + +def _module_exists(destination: Path, module: str) -> bool: + path = destination.joinpath(*module.split(".")) + return path.with_suffix(".py").is_file() or (path / "__init__.py").is_file() or path.is_dir() + + +def _format(groups: dict[str, dict[str, str]]) -> str: + sections = [ + f"[{group}]\n" + "".join(f"{name} = {value}\n" for name, value in entries.items()) + for group, entries in groups.items() + ] + return "\n".join(sections) diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/errors.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/errors.py new file mode 100644 index 0000000000..d2ea3e1e5f --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/errors.py @@ -0,0 +1,8 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + + +class VendorError(Exception): + """Base class for errors reported to the user by ``opentelemetry-vendor``.""" diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/metadata.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/metadata.py new file mode 100644 index 0000000000..fde9c895d1 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/metadata.py @@ -0,0 +1,143 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Collect metadata from unpacked ``*.dist-info`` directories.""" + +from __future__ import annotations + +import hashlib +import re +import shutil +from collections.abc import Collection, Mapping +from dataclasses import dataclass +from importlib.metadata import PathDistribution +from pathlib import Path + +from opentelemetry.vendor.errors import VendorError + +_LICENSE_FILE_PATTERN = re.compile(r"^(LICEN[CS]E|COPYING|NOTICE|AUTHORS)", re.IGNORECASE) +_LICENSE_CLASSIFIER_PREFIX = "License :: " + + +@dataclass(frozen=True) +class VendoredPackage: + name: str + """Canonical (PEP 503) distribution name.""" + version: str + sha256: str + """SHA-256 of the downloaded wheel.""" + license_expression: str | None + top_level_names: Collection[str] + """Top-level importable module and package names.""" + license_files: Mapping[str, Path] + """Vendored license file name -> source file inside ``dist_info``.""" + dist_info: Path + + +def read_package(wheel: Path, dist_info: Path, license_file: Path | None = None) -> VendoredPackage: + """Read the metadata needed for vendoring from an unpacked dist-info. + + ``license_file`` stands in for a wheel that ships no license file of its own. + """ + distribution = PathDistribution(dist_info) + metadata = distribution.metadata + name = canonicalize_name(metadata["Name"]) + return VendoredPackage( + name=name, + version=metadata["Version"], + sha256=hashlib.sha256(wheel.read_bytes()).hexdigest(), + license_expression=_license_expression( + metadata.get("License-Expression"), + metadata.get("License"), + metadata.get_all("Classifier") or [], + ), + top_level_names=_top_level_names(distribution, dist_info), + license_files=_license_files(name, dist_info, metadata.get_all("License-File") or [], license_file), + dist_info=dist_info, + ) + + +def copy_license_files(packages: Collection[VendoredPackage], destination: Path) -> None: + """Copy every package's license files into ``destination``.""" + for package in packages: + for target_name, source in package.license_files.items(): + target = destination / target_name + if target.exists(): + raise VendorError(f"license file {target} for {package.name} already exists") + shutil.copyfile(source, target) + + +def remove_dist_info(packages: Collection[VendoredPackage]) -> None: + for package in packages: + shutil.rmtree(package.dist_info) + + +def canonicalize_name(name: str) -> str: + """Normalize a distribution name as specified by PEP 503.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _license_expression(expression: str | None, license_field: str | None, classifiers: Collection[str]) -> str | None: + if expression: + return expression.strip() + # The legacy License field sometimes holds the full license text. + if license_field and "\n" not in license_field.strip(): + return license_field.strip() + names = [ + classifier.rsplit(" :: ", 1)[-1] + for classifier in classifiers + if classifier.startswith(_LICENSE_CLASSIFIER_PREFIX) + ] + return " OR ".join(names) if names else None + + +def _top_level_names(distribution: PathDistribution, dist_info: Path) -> tuple[str, ...]: + files = distribution.files + if files is None: + raise VendorError(f"{dist_info.name} has no RECORD file") + names: set[str] = set() + for file in files: + top = file.parts[0] + if top == ".." or top.endswith((".dist-info", ".data")): + continue + if top == "__pycache__": + continue + if len(file.parts) == 1: + if not top.endswith(".py"): + continue + top = top.removesuffix(".py") + if top.isidentifier(): + names.add(top) + return tuple(sorted(names)) + + +def _license_files(name: str, dist_info: Path, declared: Collection[str], license_file: Path | None) -> dict[str, Path]: + sources: dict[str, Path] = {} + if declared: + # PEP 639 puts License-File paths under .dist-info/licenses/; older + # tools put them directly in .dist-info/. + for relative in declared: + for base in (dist_info / "licenses", dist_info): + if (base / relative).is_file(): + sources[relative] = base / relative + break + else: + raise VendorError(f"{dist_info.name} declares missing license file {relative}") + else: + for path in sorted(dist_info.rglob("*")): + if path.is_file() and _LICENSE_FILE_PATTERN.match(path.name): + base = dist_info / "licenses" + if not path.is_relative_to(base): + base = dist_info + sources[path.relative_to(base).as_posix()] = path + if sources and license_file is not None: + raise VendorError(f"{dist_info.name} contains a license file; remove its license-files entry") + if not sources and license_file is not None: + if not license_file.is_file(): + raise VendorError(f"license file {license_file} for {name} does not exist") + sources[license_file.name] = license_file + if not sources: + raise VendorError( + f"{dist_info.name} does not contain a license file; add one under [tool.opentelemetry-vendor.license-files]" + ) + return {f"{name}.{relative.replace('/', '.')}": path for relative, path in sources.items()} diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/native.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/native.py new file mode 100644 index 0000000000..be6fbd8274 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/native.py @@ -0,0 +1,265 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""The ``build-native`` command: build vendored packages' native extensions from their sdists. + +The pure-Python vendor tree (from ``sync``) is committed. For platform-specific wheels, +``build-native`` downloads each configured distribution's hash-pinned sdist, applies its native +patches (which move the extension and the module names compiled into it under the vendor +namespace), builds it with PEP 517 in a temporary virtual environment and copies the resulting +stable-ABI (``abi3``) extensions into the vendor tree. ``build-native --clean`` restores the +committed state. +""" + +from __future__ import annotations + +import hashlib +import json +import platform +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import tempfile +import zipfile +from collections.abc import Collection +from pathlib import Path, PurePosixPath + +from opentelemetry.vendor import output +from opentelemetry.vendor.checks import EXTENSION_SUFFIXES, NATIVE_MODULE_NAMES, check_findings, find_native_issues +from opentelemetry.vendor.config import NativeConfig, VendorConfig +from opentelemetry.vendor.download import download_sdist +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import canonicalize_name +from opentelemetry.vendor.patches import VendorPatch, apply_patches, read_patches +from opentelemetry.vendor.sbom import add_native_build +from opentelemetry.vendor.sync import read_requirements + +_STATE_DIR = Path("build", "opentelemetry-vendor") +_MANIFEST = "native.json" +_SBOM_BACKUP = "sbom.cdx.json" + + +def build_native(config: VendorConfig) -> list[Path]: + """Build the native extensions of every ``config.native`` distribution into the vendor tree. + + Returns the extension files written. On failure, the tree is restored with ``clean_native``. + """ + _check_interpreter(config.python_version) + clean_native(config, quiet=True) + if not config.native: + raise VendorError("no native builds configured ([tool.opentelemetry-vendor.native.])") + names = _vendored_names(config) + requirements = {_requirement_name(line): line for line in read_requirements(config.requirements)} + state = config.project_dir / _STATE_DIR + manifest: dict[str, object] = {"extensions": [], "sbom_backup": None} + try: + state.mkdir(parents=True, exist_ok=True) + if config.sbom_file is not None and config.sbom_file.exists(): + shutil.copy2(config.sbom_file, state / _SBOM_BACKUP) + manifest["sbom_backup"] = _SBOM_BACKUP + _write_manifest(state, manifest) + + written: list[Path] = [] + for name, native in sorted(config.native.items()): + if name not in requirements: + raise VendorError(f"native build {name!r} is not pinned in {config.requirements}") + extensions, sdist_sha256, patches = _build(config, name, native, requirements[name]) + written += extensions + manifest["extensions"] = sorted(path.relative_to(config.destination).as_posix() for path in written) + _write_manifest(state, manifest) + if config.sbom_file is not None: + relative = [path.relative_to(config.destination).as_posix() for path in extensions] + add_native_build(config.sbom_file, name, sdist_sha256, relative, patches) + + for finding in check_findings( + find_native_issues(config.destination, names, config.namespace, written), + config.allow, + (NATIVE_MODULE_NAMES,), + ): + output.detail(f"allowed {finding.kind} {finding.path}@{finding.line}: {finding.code}") + output.step(f"Checked {output.plural(len(written), 'native extension')}") + if config.sbom_file is not None: + output.step("Updated SBOM with the native builds") + return written + except BaseException: + clean_native(config, quiet=True) + raise + + +def clean_native(config: VendorConfig, *, quiet: bool = False) -> None: + """Remove the extensions written by ``build_native`` and restore the committed SBOM.""" + state = config.project_dir / _STATE_DIR + manifest_path = state / _MANIFEST + if not manifest_path.exists(): + if not quiet: + output.step("No native build to clean") + return + manifest = json.loads(manifest_path.read_text()) + for relative in manifest["extensions"]: + extension = config.destination / relative + extension.unlink(missing_ok=True) + output.detail(f"removed {relative}") + # Remove directories that only existed for the extension. + for parent in extension.parents: + if parent == config.destination or any(parent.iterdir()): + break + parent.rmdir() + if manifest["sbom_backup"] and config.sbom_file is not None: + shutil.copy2(state / manifest["sbom_backup"], config.sbom_file) + (state / manifest["sbom_backup"]).unlink() + manifest_path.unlink() + if not any(state.iterdir()): + state.rmdir() + if not quiet: + count = output.plural(len(manifest["extensions"]), "native extension") + output.step(f"Cleaned {count} and restored the SBOM") + + +def _build( + config: VendorConfig, name: str, native: NativeConfig, requirement: str +) -> tuple[list[Path], str, list[VendorPatch]]: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with output.status(f"Downloading the {name} sdist…"): + sdist = download_sdist(requirement, root / "sdist") + sdist_sha256 = hashlib.sha256(sdist.read_bytes()).hexdigest() + output.step(f"Downloaded sdist {sdist.name}") + + source = _extract(sdist, root / "src") + patches = read_patches(native.patches) + if patches: + apply_patches(source, patches) + output.step(f"Applied {output.plural(len(patches), 'native patch', 'native patches')} to {name}") + + with output.status(f"Building {name}…"): + wheel = _build_wheel(source, native.build_requirements, root / "build") + extensions = _install_extensions(wheel, config) + for extension in extensions: + output.detail(f"built {extension.relative_to(config.destination).as_posix()}") + output.step(f"Built {output.plural(len(extensions), 'extension')} for {name}") + return extensions, sdist_sha256, patches + + +def _check_interpreter(python_version: str) -> None: + current = f"{sys.version_info.major}.{sys.version_info.minor}" + if platform.python_implementation() != "CPython" or current != python_version: + raise VendorError( + f"build-native must run on CPython {python_version} (running {platform.python_implementation()} " + f"{current}): stable-ABI (abi3) extensions are compiled against the oldest supported Python" + ) + if sysconfig.get_config_var("Py_GIL_DISABLED"): + raise VendorError( + "build-native can't run on a free-threaded interpreter: abi3 extensions don't support it, " + "and free-threaded Pythons use the pure-Python agent wheel" + ) + + +def _vendored_names(config: VendorConfig) -> set[str]: + """Top-level vendored module and package names, from the synced tree.""" + names = set() + for child in config.destination.iterdir(): + if child.name in config.protected_files or child.name.startswith((".", "__")): + continue + if child.is_dir() and any(child.rglob("*.py")): + names.add(child.name) + elif child.suffix == ".py": + names.add(child.stem) + if not names: + raise VendorError(f"{config.destination} has no vendored packages; run opentelemetry-vendor sync first") + return names + + +def _requirement_name(line: str) -> str: + name = line.split(maxsplit=1)[0] + for separator in "=<>!~;[@ ": + name = name.split(separator, 1)[0] + return canonicalize_name(name) + + +def _extract(sdist: Path, dest: Path) -> Path: + """Safely extract ``sdist`` into ``dest`` and return its single top-level directory.""" + dest.mkdir(parents=True) + if sdist.name.endswith(".zip"): + with zipfile.ZipFile(sdist) as archive: + members = archive.namelist() + _check_members(sdist, members) + archive.extractall(dest) + else: + with tarfile.open(sdist) as archive: + members = archive.getmembers() + _check_members(sdist, [member.name for member in members]) + for member in members: + if not (member.isfile() or member.isdir()): + raise VendorError(f"{sdist.name} contains a link or special file: {member.name}") + if hasattr(tarfile, "data_filter"): + archive.extractall(dest, filter="data") + else: + archive.extractall(dest) # members checked above + tops = list(dest.iterdir()) + if len(tops) != 1 or not tops[0].is_dir(): + raise VendorError(f"{sdist.name} must contain a single top-level directory") + return tops[0] + + +def _check_members(sdist: Path, names: Collection[str]) -> None: + for name in names: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise VendorError(f"{sdist.name} contains an unsafe path: {name}") + + +def _build_wheel(source: Path, build_requirements: Path, work: Path) -> Path: + """Build ``source`` into a wheel with PEP 517, in a venv with the hash-pinned build requirements.""" + venv = work / "venv" + _run([sys.executable, "-m", "venv", str(venv)], "creating the build environment") + python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + pip = [str(python), "-m", "pip", "--disable-pip-version-check"] + _run( + [*pip, "install", "--no-deps", "--require-hashes", "-r", str(build_requirements)], + "installing the build requirements", + ) + wheels = work / "wheels" + _run( + [*pip, "wheel", "--no-deps", "--no-build-isolation", "--wheel-dir", str(wheels), str(source)], + f"building {source.name}", + ) + built = sorted(wheels.glob("*.whl")) + if len(built) != 1: + raise VendorError(f"building {source.name} produced {len(built)} wheels") + return built[0] + + +def _install_extensions(wheel: Path, config: VendorConfig) -> list[Path]: + """Copy the namespaced extensions from ``wheel`` into the vendor tree.""" + prefix = config.namespace.replace(".", "/") + "/" + written = [] + with zipfile.ZipFile(wheel) as archive: + for member in sorted(archive.namelist()): + if not member.startswith(prefix) or not member.endswith(EXTENSION_SUFFIXES): + continue + relative = member[len(prefix) :] + if not (".abi3." in PurePosixPath(relative).name or relative.endswith(".pyd")): + raise VendorError(f"{wheel.name}: {member} isn't a stable-ABI (abi3) extension") + target = config.destination / relative + # e.g. google/_upb only holds the extension: an implicit namespace package. + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + target.chmod(0o755) + written.append(target) + if not written: + raise VendorError(f"{wheel.name} contains no extension under {prefix} (check the native patches)") + return written + + +def _run(command: list[str], action: str) -> None: + result = subprocess.run(command, capture_output=True, text=True, check=False) + output.detail(result.stdout.strip()) + if result.returncode != 0: + tail = "\n".join((result.stdout + result.stderr).strip().splitlines()[-30:]) + raise VendorError(f"{action} failed (exit code {result.returncode}):\n{tail}") + + +def _write_manifest(state: Path, manifest: dict[str, object]) -> None: + (state / _MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n") diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/output.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/output.py new file mode 100644 index 0000000000..99c69f999e --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/output.py @@ -0,0 +1,68 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Progress output for the ``opentelemetry-vendor`` commands.""" + +from __future__ import annotations + +from collections.abc import Collection +from dataclasses import dataclass + +from rich.console import Console +from rich.markup import escape +from rich.status import Status +from rich.table import Table + +from opentelemetry.vendor.metadata import VendoredPackage + +# Progress goes to stderr so stdout stays clean for future machine-readable output. +_console = Console(stderr=True, highlight=False) + + +@dataclass +class _Settings: + verbose: bool = False + + +_settings = _Settings() + + +def configure(*, quiet: bool, verbose: bool) -> None: + _console.quiet = quiet + _settings.verbose = verbose + + +def step(message: str) -> None: + """Report a completed step.""" + _console.print(f"[green]✔[/] {escape(message)}") + + +def detail(message: str) -> None: + """Report a detail, only shown with ``--verbose``.""" + if _settings.verbose: + for line in message.splitlines(): + _console.print(f" [dim]{escape(line)}[/]", soft_wrap=True) + + +def status(message: str) -> Status: + """A spinner shown while a long-running step is in progress.""" + return _console.status(escape(message)) + + +def package_table(packages: Collection[VendoredPackage]) -> None: + table = Table("Package", "Version", "License", "Modules", box=None) + for package in sorted(packages, key=lambda package: package.name): + table.add_row( + package.name, + package.version, + package.license_expression or "-", + ", ".join(package.top_level_names), + ) + _console.print() + _console.print(table) + + +def plural(count: int, singular: str, plural_form: str | None = None) -> str: + if count == 1: + return f"{count} {singular}" + return f"{count} {plural_form or singular + 's'}" diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/patches.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/patches.py new file mode 100644 index 0000000000..29343ff983 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/patches.py @@ -0,0 +1,104 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Apply patch files to the vendored packages with ``git apply``.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from collections.abc import Collection +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage + +_GIT_DIFF_HEADER = re.compile(r"^diff --git a/(\S+) b/(\S+)$", re.MULTILINE) +_NEW_FILE_HEADER = re.compile(r"^\+\+\+ b/(\S+)$", re.MULTILINE) + + +@dataclass(frozen=True) +class VendorPatch: + path: Path + text: str + files: tuple[str, ...] + """POSIX paths, relative to the vendor destination, that the patch touches.""" + + +def read_patches(paths: Collection[Path]) -> list[VendorPatch]: + """Read patch files, in order, and the destination-relative files each one touches.""" + patches = [] + for path in paths: + try: + text = path.read_text() + except FileNotFoundError as exc: + raise VendorError(f"patch {path} does not exist") from exc + files: set[str] = set() + for old, new in _GIT_DIFF_HEADER.findall(text): + files |= {old, new} + if not files: + files = set(_NEW_FILE_HEADER.findall(text)) + if not files: + raise VendorError(f"patch {path} contains no changes") + patches.append(VendorPatch(path=path, text=text, files=tuple(sorted(files)))) + return patches + + +def apply_patches(destination: Path, patches: Collection[VendorPatch]) -> None: + """Apply ``patches`` in order to ``destination``; nothing is applied if any patch fails.""" + git = shutil.which("git") + if git is None: + raise VendorError("applying patches requires git on PATH") + # Feed all patches to git as one input: `git apply` then applies them as a series (later patches see earlier + # changes) and atomically. Given several patch files instead, git writes each file's changes before reading + # the next one, and `--check` checks every file against the unpatched tree. + patches = list(patches) + error = _git_apply(git, destination, patches) + if error is None: + return + # Find the first patch that breaks the series, to name it in the error. + for index, patch in enumerate(patches): + prefix_error = _git_apply(git, destination, patches[: index + 1], "--check") + if prefix_error is not None: + raise VendorError(f"patch {patch.path} does not apply:\n{prefix_error}") + raise VendorError(f"patches do not apply:\n{error}") + + +def _git_apply(git: str, destination: Path, patches: Collection[VendorPatch], *args: str) -> str | None: + """Run ``git apply`` on the concatenated ``patches``; return git's error output on failure.""" + series = "".join(patch.text if patch.text.endswith("\n") else f"{patch.text}\n" for patch in patches) + # Inside a work tree, git reads patch paths relative to the repository root and silently skips files + # outside the current directory. Stop repository discovery at the destination so paths are always + # relative to it. + env = os.environ | {"GIT_CEILING_DIRECTORIES": str(destination.resolve().parent)} + result = subprocess.run( + [git, "apply", *args], + input=series, + cwd=destination, + env=env, + capture_output=True, + text=True, + check=False, + ) + return result.stderr.strip() if result.returncode != 0 else None + + +def patches_by_package( + patches: Collection[VendorPatch], packages: Collection[VendoredPackage] +) -> dict[str, list[VendorPatch]]: + """Map each package name to the patches that touch its files.""" + owners = {top_level: package.name for package in packages for top_level in package.top_level_names} + result: dict[str, list[VendorPatch]] = {} + for patch in patches: + names: set[str] = set() + for file in patch.files: + top = PurePosixPath(file).parts[0].removesuffix(".py") + if top not in owners: + raise VendorError(f"patch {patch.path} touches {file}, which belongs to no vendored package") + names.add(owners[top]) + for name in sorted(names): + result.setdefault(name, []).append(patch) + return result diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/rewrite.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/rewrite.py new file mode 100644 index 0000000000..65166d09cf --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/rewrite.py @@ -0,0 +1,228 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Rewrite imports, logger names and module names of vendored packages to point into the vendor namespace.""" + +from __future__ import annotations + +import re +from collections.abc import Collection +from dataclasses import dataclass +from pathlib import Path + +import libcst as cst +from libcst.helpers import get_full_name_for_node +from libcst.metadata import MetadataWrapper, QualifiedNameProvider + +from opentelemetry.vendor.errors import VendorError + +_GET_LOGGER = "logging.getLogger" +BUILD_TOP_DESCRIPTORS = "google.protobuf.internal.builder.BuildTopDescriptorsAndMessages" +"""Called by protoc-generated ``*_pb2`` modules with their module name, which becomes the classes' ``__module__``.""" +# Callee -> (position of the module-name argument, RewriteResult counter). +_MODULE_NAME_ARGUMENTS = {_GET_LOGGER: (0, "loggers"), BUILD_TOP_DESCRIPTORS: (1, "modules")} +_DOTTED_NAME = re.compile(r"[A-Za-z_]\w*(\.[A-Za-z_]\w*)*") + + +@dataclass(frozen=True) +class RewriteResult: + files: int + """Number of files with at least one rewritten import.""" + imports: int + """Number of rewritten imported module names.""" + loggers: int = 0 + """Number of rewritten logger-name string literals.""" + modules: int = 0 + """Number of rewritten module-name string literals (protobuf generated code).""" + + +class _ImportTransformer(cst.CSTTransformer): + """Move absolute imports of vendored ``names`` under ``namespace``. + + ``from X import Y`` becomes ``from namespace.X import Y``. Plain imports keep + binding the same local name: ``import X`` becomes ``from namespace import X``, + ``import X.Y as Z`` becomes ``from namespace.X import Y as Z`` and ``import X.Y`` + becomes ``import namespace.X.Y`` (to load the submodule) plus ``from namespace import X``. + A line containing an expanded import is split into one statement per line. + + Logger names are namespaced too, so vendored code doesn't log to (or configure) the + application's loggers: a vendored-name string literal passed to ``logging.getLogger`` + or used as a parameter default (e.g. ``name: str = "X"``) becomes ``"namespace.X"``. + So are the module names protoc-generated code passes to protobuf's builder, which + become the generated classes' ``__module__`` (used by pickle, for example). + """ + + METADATA_DEPENDENCIES = (QualifiedNameProvider,) + + def __init__(self, namespace: str, names: Collection[str]) -> None: + super().__init__() + self.namespace = namespace + self.names = names + self.count = 0 + self.loggers = 0 + self.modules = 0 + self._expanded = False + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + qualified_names = {name.name for name in self.get_metadata(QualifiedNameProvider, original_node.func, set())} + for function, (position, counter) in _MODULE_NAME_ARGUMENTS.items(): + if function not in qualified_names or len(updated_node.args) <= position: + continue + arguments = list(updated_node.args) + argument = arguments[position] + if argument.keyword is not None or not isinstance(argument.value, cst.SimpleString): + return updated_node + namespaced = self._namespaced_module_name(argument.value) + if namespaced is None: + return updated_node + setattr(self, counter, getattr(self, counter) + 1) + arguments[position] = argument.with_changes(value=namespaced) + return updated_node.with_changes(args=arguments) + return updated_node + + def leave_Param(self, original_node: cst.Param, updated_node: cst.Param) -> cst.Param: + if not isinstance(updated_node.default, cst.SimpleString): + return updated_node + namespaced = self._namespaced_module_name(updated_node.default) + if namespaced is None: + return updated_node + self.loggers += 1 + return updated_node.with_changes(default=namespaced) + + def _namespaced_module_name(self, node: cst.SimpleString) -> cst.SimpleString | None: + value = node.evaluated_value + if ( + not isinstance(value, str) + or not _DOTTED_NAME.fullmatch(value) + or not is_vendored(value, self.names, self.namespace) + ): + return None + return node.with_changes(value=f"{node.prefix}{node.quote}{self.namespace}.{value}{node.quote}") + + def leave_ImportFrom(self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom) -> cst.ImportFrom: + if updated_node.relative or updated_node.module is None: + return updated_node + module = get_full_name_for_node(updated_node.module) + if module is None or not is_vendored(module, self.names, self.namespace): + return updated_node + self.count += 1 + return updated_node.with_changes(module=_dotted_name(f"{self.namespace}.{module}")) + + def leave_Import( + self, original_node: cst.Import, updated_node: cst.Import + ) -> cst.BaseSmallStatement | cst.FlattenSentinel[cst.BaseSmallStatement]: + if not any(is_vendored(alias_name(alias), self.names, self.namespace) for alias in updated_node.names): + return updated_node + statements: list[cst.BaseSmallStatement] = [] + for alias in updated_node.names: + name = alias_name(alias) + if is_vendored(name, self.names, self.namespace): + statements.extend(self._rewrite_alias(name, alias.asname)) + self.count += 1 + else: + statements.append(cst.Import(names=[alias.with_changes(comma=cst.MaybeSentinel.DEFAULT)])) + statements[-1] = statements[-1].with_changes(semicolon=updated_node.semicolon) + if len(statements) == 1: + return statements[0] + self._expanded = True + return cst.FlattenSentinel(statements) + + def visit_SimpleStatementLine(self, node: cst.SimpleStatementLine) -> None: + self._expanded = False + + def leave_SimpleStatementLine( + self, original_node: cst.SimpleStatementLine, updated_node: cst.SimpleStatementLine + ) -> cst.SimpleStatementLine | cst.FlattenSentinel[cst.SimpleStatementLine]: + if not self._expanded: + return updated_node + lines = [ + cst.SimpleStatementLine(body=[statement.with_changes(semicolon=cst.MaybeSentinel.DEFAULT)]) + for statement in updated_node.body + ] + lines[0] = lines[0].with_changes(leading_lines=updated_node.leading_lines) + lines[-1] = lines[-1].with_changes(trailing_whitespace=updated_node.trailing_whitespace) + return cst.FlattenSentinel(lines) + + def _rewrite_alias(self, name: str, asname: cst.AsName | None) -> list[cst.BaseSmallStatement]: + package, _, attribute = name.rpartition(".") + if asname is not None: + # import X as Z -> from ns import X as Z; import X.Y as Z -> from ns.X import Y as Z + module = f"{self.namespace}.{package}" if package else self.namespace + return [_import_from(module, attribute, asname)] + top_level = name.split(".", 1)[0] + if not package: + return [_import_from(self.namespace, top_level, None)] + # Binding X alone does not load the X.Y submodule, so import namespace.X.Y first. That statement + # also binds the namespace's top-level name (``opentelemetry`` for ``opentelemetry.agent._vendor``), + # so bind X afterwards, and re-bind the top-level name if it is itself vendored. + statements: list[cst.BaseSmallStatement] = [ + cst.Import(names=[cst.ImportAlias(name=_dotted_name(f"{self.namespace}.{name}"))]), + _import_from(self.namespace, top_level, None), + ] + namespace_top_level = self.namespace.split(".", 1)[0] + if namespace_top_level in self.names and namespace_top_level != top_level: + statements.append(_import_from(self.namespace, namespace_top_level, None)) + return statements + + +def rewrite_source(source: str, namespace: str, names: Collection[str]) -> str: + """Return ``source`` with imports and logger names of vendored ``names`` moved under ``namespace``.""" + return MetadataWrapper(cst.parse_module(source)).visit(_ImportTransformer(namespace, names)).code + + +def rewrite_imports(destination: Path, namespace: str, names: Collection[str]) -> RewriteResult: + """Rewrite imports and logger names of vendored ``names`` in every ``.py`` file under ``destination``. + + Raises ``VendorError`` if a file cannot be parsed. Leftover vendored imports are + reported by ``opentelemetry.vendor.checks``, which runs after patches. + """ + files = 0 + imports = 0 + loggers = 0 + modules = 0 + for path in sorted(destination.rglob("*.py")): + transformer = _ImportTransformer(namespace, names) + rewritten = MetadataWrapper(parse_file(path)).visit(transformer) + if transformer.count or transformer.loggers or transformer.modules: + path.write_bytes(rewritten.bytes) + files += 1 + imports += transformer.count + loggers += transformer.loggers + modules += transformer.modules + return RewriteResult(files=files, imports=imports, loggers=loggers, modules=modules) + + +def parse_file(path: Path) -> cst.Module: + try: + return cst.parse_module(path.read_bytes()) + except cst.ParserSyntaxError as exc: + raise VendorError(f"cannot parse {path}: {exc}") from exc + + +def is_vendored(name: str, names: Collection[str], namespace: str) -> bool: + """Whether dotted module ``name`` belongs to one of the vendored top-level ``names``. + + Names already under ``namespace`` aren't: the namespace may start with a vendored + top-level name (``opentelemetry.agent._vendor`` vendoring ``opentelemetry``). + """ + if name == namespace or name.startswith(f"{namespace}."): + return False + return name.split(".", 1)[0] in names + + +def alias_name(alias: cst.ImportAlias) -> str: + """The dotted module name of an ``import`` statement alias.""" + # ImportAlias names in `import` statements are always Name or Attribute nodes. + return get_full_name_for_node(alias.name) or "" + + +def _import_from(module: str, name: str, asname: cst.AsName | None) -> cst.ImportFrom: + return cst.ImportFrom(module=_dotted_name(module), names=[cst.ImportAlias(name=cst.Name(name), asname=asname)]) + + +def _dotted_name(name: str) -> cst.Attribute | cst.Name: + first, *rest = name.split(".") + node: cst.Attribute | cst.Name = cst.Name(first) + for part in rest: + node = cst.Attribute(value=node, attr=cst.Name(part)) + return node diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/sbom.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/sbom.py new file mode 100644 index 0000000000..fe2864528b --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/sbom.py @@ -0,0 +1,137 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Generate a CycloneDX SBOM for the vendored packages.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Collection, Mapping +from pathlib import Path +from typing import Any + +from cyclonedx.contrib.license.factories import LicenseFactory +from cyclonedx.model import AttachedText, HashAlgorithm, HashType +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component, ComponentType, Diff, Patch, PatchClassification, Pedigree +from cyclonedx.output.json import JsonV1Dot6 +from cyclonedx.schema import SchemaVersion +from cyclonedx.validation.json import JsonStrictValidator +from packageurl import PackageURL + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage +from opentelemetry.vendor.patches import VendorPatch + + +def write_sbom( + packages: Collection[VendoredPackage], + path: Path, + patches: Mapping[str, Collection[VendorPatch]] | None = None, +) -> None: + """Write a CycloneDX 1.6 JSON SBOM describing ``packages`` to ``path``. + + ``patches`` maps package names to the patches applied to them, recorded as + the component's pedigree. + + The output is deterministic: syncing the same requirements twice produces + an identical file. + """ + ordered = sorted(packages, key=lambda package: package.name) + license_factory = LicenseFactory() + patches = patches or {} + components = [_component(package, license_factory, patches.get(package.name, ())) for package in ordered] + identity = "\n".join( + " ".join( + [package.name, package.version, package.sha256] + + [hashlib.sha256(patch.text.encode()).hexdigest() for patch in patches.get(package.name, ())] + ) + for package in ordered + ) + bom = Bom( + components=components, + serial_number=uuid.uuid5(uuid.NAMESPACE_URL, identity), + ) + + document = json.loads(JsonV1Dot6(bom).output_as_string()) + # The timestamp is optional; dropping it keeps re-syncs diff-free. + document["metadata"].pop("timestamp", None) + _write(document, path) + + +def _component( + package: VendoredPackage, license_factory: LicenseFactory, patches: Collection[VendorPatch] +) -> Component: + purl = PackageURL(type="pypi", name=package.name, version=package.version) + return Component( + type=ComponentType.LIBRARY, + name=package.name, + version=package.version, + purl=purl, + bom_ref=purl.to_string(), + hashes=[HashType(alg=HashAlgorithm.SHA_256, content=package.sha256)], + licenses=[license_factory.make_from_string(package.license_expression)] if package.license_expression else None, + pedigree=Pedigree( + patches=[ + Patch(type=PatchClassification.UNOFFICIAL, diff=Diff(text=AttachedText(content=patch.text))) + for patch in patches + ] + ) + if patches + else None, + ) + + +NATIVE_SDIST_PROPERTY = "opentelemetry-vendor:native-sdist-sha256" +NATIVE_EXTENSION_PROPERTY = "opentelemetry-vendor:native-extension" + + +def add_native_build( + path: Path, + package: str, + sdist_sha256: str, + extensions: Collection[str], + patches: Collection[VendorPatch], +) -> None: + """Record in the SBOM at ``path`` that ``package``'s native ``extensions`` were built from its sdist. + + The native ``patches`` are added to the component's pedigree, and the sdist hash and + extension paths (relative to the vendor destination) as properties. The serial number + changes accordingly. + """ + document = json.loads(path.read_text()) + components = [component for component in document.get("components", []) if component.get("name") == package] + if len(components) != 1: + raise VendorError(f"SBOM {path} has no component {package!r}") + component = components[0] + pedigree = component.setdefault("pedigree", {}) + pedigree.setdefault("patches", []).extend( + {"type": "unofficial", "diff": {"text": {"content": patch.text, "contentType": "text/plain"}}} + for patch in patches + ) + component.setdefault("properties", []).extend( + [ + {"name": NATIVE_SDIST_PROPERTY, "value": sdist_sha256}, + *({"name": NATIVE_EXTENSION_PROPERTY, "value": extension} for extension in sorted(extensions)), + ] + ) + identity = "\n".join( + [ + document["serialNumber"], + sdist_sha256, + *(hashlib.sha256(patch.text.encode()).hexdigest() for patch in patches), + ] + ) + document["serialNumber"] = uuid.uuid5(uuid.NAMESPACE_URL, identity).urn + _write(document, path) + + +def _write(document: dict[str, Any], path: Path) -> None: + output = json.dumps(document, indent=2) + "\n" + error = JsonStrictValidator(SchemaVersion.V1_6).validate_str(output) + if error is not None: + raise VendorError(f"generated SBOM is invalid: {error}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(output) diff --git a/agent/opentelemetry-vendor/src/opentelemetry/vendor/sync.py b/agent/opentelemetry-vendor/src/opentelemetry/vendor/sync.py new file mode 100644 index 0000000000..16ce639c48 --- /dev/null +++ b/agent/opentelemetry-vendor/src/opentelemetry/vendor/sync.py @@ -0,0 +1,280 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""The ``sync`` command: (re)populate a project's vendor directory.""" + +from __future__ import annotations + +import dataclasses +import re +import shutil +import tempfile +from collections.abc import Collection +from pathlib import Path + +from opentelemetry.vendor import output +from opentelemetry.vendor.checks import check_vendored_tree +from opentelemetry.vendor.config import VendorConfig +from opentelemetry.vendor.download import download_wheels, unpack_wheels +from opentelemetry.vendor.entry_points import write_metadata +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import ( + VendoredPackage, + canonicalize_name, + copy_license_files, + read_package, + remove_dist_info, +) +from opentelemetry.vendor.patches import apply_patches, patches_by_package, read_patches +from opentelemetry.vendor.rewrite import RewriteResult, rewrite_imports +from opentelemetry.vendor.sbom import write_sbom + + +def clean_destination(destination: Path, protected_files: Collection[str]) -> list[Path]: + """Remove everything in ``destination`` except top-level ``protected_files``. + + Returns the removed entries. + """ + destination.mkdir(parents=True, exist_ok=True) + removed = [] + for entry in sorted(destination.iterdir()): + if entry.name in protected_files: + continue + _remove(entry, destination) + removed.append(entry) + return removed + + +def drop_paths(destination: Path, patterns: Collection[str]) -> list[Path]: + """Remove files and directories in ``destination`` matching glob ``patterns``. + + Matches inside top-level ``*.dist-info`` directories are ignored, and a + pattern that matches nothing is an error so stale config is noticed. + Returns the removed paths. + """ + matches: set[Path] = set() + for pattern in patterns: + matched = { + path + for path in destination.glob(pattern) + if not path.relative_to(destination).parts[0].endswith(".dist-info") + } + if not matched: + raise VendorError(f"drop pattern {pattern!r} matched nothing") + matches |= matched + + removed = [] + for path in sorted(matches): + # Already gone when an ancestor directory was dropped. + if any(parent in matches for parent in path.parents): + continue + _remove(path, destination) + removed.append(path) + return removed + + +def clean(config: VendorConfig) -> None: + """Empty ``config.destination``, keeping protected files.""" + removed = clean_destination(config.destination, config.protected_files) + entries = output.plural(len(removed), "entry", "entries") + output.step(f"Cleaned {_display(config.destination, config)} ({entries} removed)") + + +def sync(config: VendorConfig) -> list[VendoredPackage]: + """Re-vendor ``config``'s requirements and return the vendored packages.""" + # Validate requirements before cleaning, so a bad file leaves the vendored tree intact. + requirements = read_requirements(config.requirements) + clean(config) + try: + return _populate(config, requirements) + except BaseException: + # Never leave a half-built tree (unpatched code, *.dist-info, ...) behind: back to the protected files only. + clean_destination(config.destination, config.protected_files) + raise + + +def _populate(config: VendorConfig, requirements: Collection[str]) -> list[VendoredPackage]: + destination = config.destination + with tempfile.TemporaryDirectory() as tmp: + with output.status(f"Downloading {output.plural(len(requirements), 'requirement')}…"): + wheels = download_wheels(config.requirements, config.python_version, Path(tmp)) + output.step(f"Downloaded {output.plural(len(wheels), 'wheel')}") + + dist_infos = unpack_wheels(wheels, destination) + packages = [ + read_package(wheel, dist_info, config.license_files.get(_wheel_name(wheel))) + for wheel, dist_info in dist_infos.items() + ] + unused = sorted(set(config.license_files) - {package.name for package in packages}) + if unused: + raise VendorError(f"license-files entries {unused} match no vendored distribution") + output.step(f"Unpacked {output.plural(len(packages), 'wheel')}") + + if config.drop: + dropped = drop_paths(destination, config.drop) + output.step(f"Dropped {output.plural(len(dropped), 'path')}") + + names = {name for package in packages for name in package.top_level_names} + rewritten = rewrite_imports(destination, config.namespace, names) + output.step(f"Rewrote {_rewrite_summary(rewritten)} in {output.plural(rewritten.files, 'file')}") + patches = read_patches(config.patches) + if patches: + apply_patches(destination, patches) + for patch in patches: + output.detail(f"applied {_display(patch.path, config)}") + output.step(f"Applied {output.plural(len(patches), 'patch', 'patches')}") + + for finding in check_vendored_tree(destination, names, config.namespace, config.allow): + output.detail(f"allowed {finding.kind} {finding}") + + if config.metadata_dir is not None: + entry_points = write_metadata(packages, destination, config.metadata_dir, config.namespace, names) + output.step( + f"Wrote metadata to {_display(config.metadata_dir, config)} ({output.plural(entry_points, 'entry point')})" + ) + + copy_license_files(packages, destination) + license_count = sum(len(package.license_files) for package in packages) + output.step(f"Copied {output.plural(license_count, 'license file')}") + + if config.sbom_file is not None: + write_sbom(packages, config.sbom_file, patches_by_package(patches, packages)) + output.step(f"Wrote SBOM {_display(config.sbom_file, config)}") + + remove_dist_info(packages) + output.package_table(packages) + return packages + + +def _wheel_name(wheel: Path) -> str: + """Canonical distribution name from a wheel file name (``{name}-{version}-...whl``).""" + return canonicalize_name(wheel.name.split("-", 1)[0]) + + +_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") + + +def _rewrite_summary(rewritten: RewriteResult) -> str: + parts = [output.plural(rewritten.imports, "import")] + if rewritten.loggers: + parts.append(output.plural(rewritten.loggers, "logger name")) + if rewritten.modules: + parts.append(output.plural(rewritten.modules, "module name")) + return parts[0] if len(parts) == 1 else f"{', '.join(parts[:-1])} and {parts[-1]}" + + +def check(config: VendorConfig) -> None: + """Raise ``VendorError`` if the vendored files differ from what ``sync`` would produce. + + Syncs into a temporary directory and compares it, file by file, with ``config.destination`` + (and ``config.sbom_file`` when it lives outside the destination). Nothing in the project is changed. + """ + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + expected = root / "destination" + expected.mkdir() + for name in config.protected_files: + source = config.destination / name + if source.is_dir(): + shutil.copytree(source, expected / name) + elif source.exists(): + shutil.copy2(source, expected / name) + sbom_file = None + sbom_outside = config.sbom_file is not None and not config.sbom_file.is_relative_to(config.destination) + if config.sbom_file is not None: + sbom_file = ( + root / "sbom" / config.sbom_file.name + if sbom_outside + else expected / config.sbom_file.relative_to(config.destination) + ) + metadata_dir = ( + expected / config.metadata_dir.relative_to(config.destination) if config.metadata_dir is not None else None + ) + sync(dataclasses.replace(config, destination=expected, sbom_file=sbom_file, metadata_dir=metadata_dir)) + + differences = _diff_trees(config.destination, expected, config.protected_files) + if sbom_outside and config.sbom_file is not None and sbom_file is not None: + display = _display(config.sbom_file, config) + if not config.sbom_file.exists(): + differences.append(f"+ {display}") + elif config.sbom_file.read_bytes() != sbom_file.read_bytes(): + differences.append(f"~ {display}") + + if differences: + lines = "\n".join(f" {difference}" for difference in differences) + raise VendorError(f"vendored files are out of date (run opentelemetry-vendor sync):\n{lines}") + output.step("Vendored files are up to date") + + +def _diff_trees(actual: Path, expected: Path, protected_files: Collection[str]) -> list[str]: + """Describe how ``actual`` must change to match ``expected``: ``+`` added, ``-`` removed, ``~`` changed.""" + actual_files = _tree_files(actual, protected_files) + expected_files = _tree_files(expected, protected_files) + differences = [] + for path in sorted(actual_files | expected_files): + if path not in actual_files: + differences.append(f"+ {path}") + elif path not in expected_files: + differences.append(f"- {path}") + elif (actual / path).read_bytes() != (expected / path).read_bytes(): + differences.append(f"~ {path}") + return differences + + +def _tree_files(root: Path, protected_files: Collection[str]) -> set[str]: + if not root.is_dir(): + return set() + files = set() + for path in root.rglob("*"): + relative = path.relative_to(root) + if relative.parts[0] in protected_files or "__pycache__" in relative.parts or path.is_dir(): + continue + files.add(relative.as_posix()) + return files + + +def _remove(path: Path, destination: Path) -> None: + display = path.relative_to(destination).as_posix() + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + display += "/" + else: + path.unlink() + output.detail(f"removed {display}") + + +def _display(path: Path, config: VendorConfig) -> str: + if path.is_relative_to(config.project_dir): + return path.relative_to(config.project_dir).as_posix() + return str(path) + + +def read_requirements(requirements: Path) -> list[str]: + """Return the logical requirement lines of ``requirements``, each of which must be pinned by hash. + + Continuation lines (ending in ``\\``) are joined; comments and option lines are skipped. + """ + try: + text = requirements.read_text() + except FileNotFoundError as exc: + raise VendorError(f"requirements file {requirements} does not exist") from exc + logical_lines: list[str] = [] + pending = "" + for physical in text.splitlines(): + stripped = physical.split(" #", 1)[0].strip() + # Like pip, a comment line ends a continuation. + if stripped.endswith("\\") and not stripped.startswith("#"): + pending += stripped[:-1] + " " + continue + logical_lines.append(pending + ("" if stripped.startswith("#") else stripped)) + pending = "" + logical_lines.append(pending) + lines = [" ".join(line.split()) for line in logical_lines if line.strip() and not line.strip().startswith("-")] + for line in lines: + if "--hash=" not in line: + name = _REQUIREMENT_NAME.match(line) + label = name.group() if name else line + raise VendorError( + f"requirement {label!r} in {requirements} has no --hash; compile it with --generate-hashes" + ) + return lines diff --git a/agent/opentelemetry-vendor/tests/__init__.py b/agent/opentelemetry-vendor/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agent/opentelemetry-vendor/tests/conftest.py b/agent/opentelemetry-vendor/tests/conftest.py new file mode 100644 index 0000000000..d5742aef5c --- /dev/null +++ b/agent/opentelemetry-vendor/tests/conftest.py @@ -0,0 +1,29 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +VALID_CONFIG = """ +[tool.opentelemetry-vendor] +destination = "src/pkg/_vendor/" +requirements = "src/pkg/_vendor/requirements.txt" +namespace = "pkg._vendor" +python-version = "3.10" +protected-files = ["__init__.py", "requirements.txt"] +sbom-file = "src/pkg/_vendor/bom.cdx.json" +""" + + +@pytest.fixture(name="project_dir") +def fixture_project_dir(tmp_path: Path) -> Path: + (tmp_path / "pyproject.toml").write_text(VALID_CONFIG) + destination = tmp_path / "src/pkg/_vendor" + destination.mkdir(parents=True) + (destination / "requirements.txt").write_text( + "# comment\nsix==1.0 \\\n --hash=sha256:0123456789abcdef\n # via -r vendor.in\n" + ) + return tmp_path diff --git a/agent/opentelemetry-vendor/tests/helpers.py b/agent/opentelemetry-vendor/tests/helpers.py new file mode 100644 index 0000000000..2feca75645 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/helpers.py @@ -0,0 +1,52 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import zipfile +from collections.abc import Collection, Mapping +from pathlib import Path + +DEFAULT_LICENSES: Mapping[str, str] = {"licenses/LICENSE": "license text"} + + +def make_zip(path: Path, members: Collection[str]) -> Path: + """Create a zip archive at ``path`` containing empty ``members``.""" + with zipfile.ZipFile(path, "w") as archive: + for member in members: + archive.writestr(member, "") + return path + + +def make_wheel( + directory: Path, + name: str, + version: str, + members: Collection[str] = (), + *, + metadata: str = "", + licenses: Mapping[str, str] = DEFAULT_LICENSES, + tag: str = "py3-none-any", + entry_points: str | None = None, +) -> Path: + """Create a minimal but valid wheel with METADATA, RECORD and licenses. + + ``metadata`` is appended to the METADATA headers; ``licenses`` maps paths + relative to the dist-info directory to their contents; ``entry_points`` is the content of + ``entry_points.txt``. + """ + dist_info = f"{name.replace('-', '_')}-{version}.dist-info" + files = {member: "" for member in members} + files[f"{dist_info}/METADATA"] = f"Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n{metadata}" + if entry_points is not None: + files[f"{dist_info}/entry_points.txt"] = entry_points + for relative, content in licenses.items(): + files[f"{dist_info}/{relative}"] = content + record = f"{dist_info}/RECORD" + files[record] = "".join(f"{path},,\n" for path in [*files, record]) + + wheel = directory / f"{name.replace('-', '_')}-{version}-{tag}.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + return wheel diff --git a/agent/opentelemetry-vendor/tests/test_checks.py b/agent/opentelemetry-vendor/tests/test_checks.py new file mode 100644 index 0000000000..f0fb145417 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_checks.py @@ -0,0 +1,382 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from opentelemetry.vendor.checks import ( + DYNAMIC_IMPORTS, + EXTERNAL_IMPORTS, + IMPORTS, + LOGGER_NAMES, + MODULE_NAMES, + NATIVE_MODULE_NAMES, + SIDE_EFFECTS, + Finding, + check_vendored_tree, + find_issues, + find_native_issues, +) +from opentelemetry.vendor.errors import VendorError + +NAMESPACE = "opentelemetry.agent._vendor" +NAMES = frozenset({"idna", "urllib3"}) + +# Imports used by the test sources; names are resolved through them. +HEADER = """\ +import importlib +import logging +import os +import warnings +import warnings as w +from os import environ +import importlib.resources +import sys +import sys as _sys +from importlib import import_module +from importlib import import_module as im +from importlib.resources import as_file, files, read_text +from importlib.resources import path as get_path +""" +SOURCE_LINE = HEADER.count("\n") + 1 + + +@pytest.mark.parametrize( + ("source", "code"), + [ + pytest.param("__import__(package)", "__import__(package)", id="import-variable"), + pytest.param("__import__('urllib3')", "__import__('urllib3')", id="import-vendored-literal"), + pytest.param("__import__(name='os')", "__import__(name='os')", id="import-keyword"), + pytest.param( + "importlib.import_module('idna.core')", + "importlib.import_module('idna.core')", + id="import-module-vendored-literal", + ), + pytest.param( + "import_module(f'encodings.{x}')", + "import_module(f'encodings.{x}')", + id="import-module-f-string", + ), + pytest.param( + "import_module('encod' 'ings')", + "import_module('encod' 'ings')", + id="import-module-concatenated", + ), + pytest.param("sys.modules['x'] = m", "sys.modules['x'] = m", id="sys-modules-assign"), + pytest.param("a = sys.modules['x'] = m", "a = sys.modules['x'] = m", id="sys-modules-chained-assign"), + pytest.param("sys.modules['x'], b = m, n", "sys.modules['x'], b = m, n", id="sys-modules-tuple-assign"), + pytest.param("sys.modules['x']: T = m", "sys.modules['x']: T = m", id="sys-modules-ann-assign"), + pytest.param("sys.modules['x'] |= m", "sys.modules['x'] |= m", id="sys-modules-aug-assign"), + pytest.param("del sys.modules['x']", "del sys.modules['x']", id="sys-modules-del"), + pytest.param("del a, sys.modules['x']", "del a, sys.modules['x']", id="sys-modules-del-tuple"), + pytest.param("sys.modules.update({})", "sys.modules.update({})", id="sys-modules-update"), + pytest.param("sys.modules.setdefault('x', m)", "sys.modules.setdefault('x', m)", id="sys-modules-setdefault"), + pytest.param("sys.modules.pop('x')", "sys.modules.pop('x')", id="sys-modules-pop"), + pytest.param( + "sys.modules[\n 'x'\n] = m", + "sys.modules[ 'x' ] = m", + id="multiline-collapsed", + ), + pytest.param("im(package)", "im(package)", id="import-module-alias"), + pytest.param("_sys.modules['x'] = m", "_sys.modules['x'] = m", id="sys-modules-alias"), + pytest.param("_sys.modules.pop('x')", "_sys.modules.pop('x')", id="sys-modules-alias-pop"), + pytest.param("files('urllib3')", "files('urllib3')", id="files-vendored-literal"), + pytest.param( + "importlib.resources.files('idna.core')", + "importlib.resources.files('idna.core')", + id="files-attribute", + ), + pytest.param( + "as_file(files('urllib3').joinpath('x'))", + "files('urllib3')", + id="as-file-wrapping-files", + ), + pytest.param("files(package)", "files(package)", id="files-variable"), + pytest.param("get_path('urllib3', 'x')", "get_path('urllib3', 'x')", id="legacy-path-alias"), + pytest.param("read_text(name, 'x')", "read_text(name, 'x')", id="legacy-read-text-variable"), + pytest.param( + "importlib.resources.is_resource(anchor='idna', name='x')", + "importlib.resources.is_resource(anchor='idna', name='x')", + id="legacy-keyword-anchor", + ), + pytest.param( + "def f():\n from importlib.resources import read_binary\n return read_binary('idna', 'x')", + "read_binary('idna', 'x')", + id="legacy-local-import", + ), + ], +) +def test_find_issues_dynamic_imports(tmp_path: Path, source: str, code: str) -> None: + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "mod.py").write_text(f"{HEADER}{source}\n") + + [finding] = find_issues(tmp_path, NAMES, NAMESPACE) + + assert finding.kind == DYNAMIC_IMPORTS + assert finding.path == "pkg/mod.py" + assert finding.line >= SOURCE_LINE + assert finding.code == code + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("__import__('os')", id="import-stdlib-literal"), + pytest.param("__import__('urllib3x')", id="import-prefix-collision"), + pytest.param("importlib.import_module('.sub', __package__)", id="import-module-relative"), + pytest.param("import_module('encodings.utf_8')", id="import-module-stdlib-literal"), + pytest.param("m = sys.modules[name]", id="sys-modules-read"), + pytest.param("for m in list(sys.modules): pass", id="sys-modules-iterate"), + pytest.param("sys.modules.get('x')", id="sys-modules-get"), + pytest.param("cache['x'] = m", id="other-subscript"), + pytest.param('"""__import__(package)"""\n# sys.modules["x"] = m', id="docstring-and-comment"), + pytest.param("files(__package__)", id="files-package"), + pytest.param("files(__name__)", id="files-name"), + pytest.param("files()", id="files-calling-module"), + pytest.param("files('os')", id="files-stdlib-literal"), + pytest.param("as_file(files(__package__).joinpath('x'))", id="as-file-package"), + pytest.param("get_path(__package__, 'cacert.pem')", id="legacy-path-package"), + pytest.param("self.read_text('urllib3')", id="method-named-like-resource-function"), + pytest.param("import_module_x('urllib3')", id="other-function"), + pytest.param("warnings.warn('x')", id="warnings-warn"), + pytest.param("with warnings.catch_warnings(): pass", id="warnings-catch"), + pytest.param("value = os.environ['x']", id="environ-read"), + pytest.param("os.environ.get('x')", id="environ-get"), + pytest.param("env = dict(os.environ)\nenv['x'] = '1'", id="environ-copy"), + pytest.param("from . import urllib3\nfrom .urllib3 import a", id="relative-imports"), + pytest.param("import os.path, json\nfrom __future__ import annotations", id="stdlib-imports"), + pytest.param("import annotationlib, tomllib, distutils", id="stdlib-imports-other-versions"), + pytest.param("from opentelemetry.agent._vendor import urllib3", id="namespaced-import"), + pytest.param("logging.getLogger(__name__)", id="logger-dunder-name"), + pytest.param("logging.getLogger(name)", id="logger-variable"), + pytest.param("logging.getLogger('opentelemetry.agent._vendor.urllib3')", id="logger-namespaced"), + ], +) +def test_find_issues_ignores(tmp_path: Path, source: str) -> None: + (tmp_path / "mod.py").write_text(f"{HEADER}{source}\n") + + assert find_issues(tmp_path, NAMES, NAMESPACE) == [] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("def files(name): pass\nfiles('urllib3')", id="local-function-named-files"), + pytest.param("import_module('urllib3')\nsys.modules['x'] = m", id="names-not-imported"), + pytest.param("simplefilter('ignore')\nenviron['x'] = '1'", id="side-effect-names-not-imported"), + ], +) +def test_find_issues_ignores_unresolved_names(tmp_path: Path, source: str) -> None: + (tmp_path / "mod.py").write_text(f"{source}\n") + + assert find_issues(tmp_path, NAMES, NAMESPACE) == [] + + +@pytest.mark.parametrize( + ("source", "kind", "code"), + [ + pytest.param("import urllib3", IMPORTS, "import urllib3", id="import"), + pytest.param("import urllib3x", EXTERNAL_IMPORTS, "import urllib3x", id="external-prefix-collision"), + pytest.param("import os, yaml.nodes", EXTERNAL_IMPORTS, "import os, yaml.nodes", id="external-import"), + pytest.param( + "from aiohttp.client import ClientSession", + EXTERNAL_IMPORTS, + "from aiohttp.client import ClientSession", + id="external-import-from", + ), + pytest.param("import os, urllib3.util as u", IMPORTS, "import os, urllib3.util as u", id="import-multiple"), + pytest.param("from idna import core", IMPORTS, "from idna import core", id="from-import"), + pytest.param( + "warnings.simplefilter('ignore', Warning)", + SIDE_EFFECTS, + "warnings.simplefilter('ignore', Warning)", + id="simplefilter", + ), + pytest.param( + "w.filterwarnings('ignore')", SIDE_EFFECTS, "w.filterwarnings('ignore')", id="filterwarnings-alias" + ), + pytest.param("warnings.resetwarnings()", SIDE_EFFECTS, "warnings.resetwarnings()", id="resetwarnings"), + pytest.param("os.environ['x'] = '1'", SIDE_EFFECTS, "os.environ['x'] = '1'", id="environ-assign"), + pytest.param("environ['x'] = '1'", SIDE_EFFECTS, "environ['x'] = '1'", id="environ-from-import"), + pytest.param("del os.environ['x']", SIDE_EFFECTS, "del os.environ['x']", id="environ-del"), + pytest.param("os.environ['x'] += '1'", SIDE_EFFECTS, "os.environ['x'] += '1'", id="environ-aug-assign"), + pytest.param("os.environ.update(x='1')", SIDE_EFFECTS, "os.environ.update(x='1')", id="environ-update"), + pytest.param("environ.pop('x')", SIDE_EFFECTS, "environ.pop('x')", id="environ-pop"), + pytest.param("os.putenv('x', '1')", SIDE_EFFECTS, "os.putenv('x', '1')", id="putenv"), + pytest.param("os.unsetenv('x')", SIDE_EFFECTS, "os.unsetenv('x')", id="unsetenv"), + pytest.param( + "logging.getLogger('urllib3.pool')", + LOGGER_NAMES, + "logging.getLogger('urllib3.pool')", + id="vendored-logger-name", + ), + ], +) +def test_find_issues(tmp_path: Path, source: str, kind: str, code: str) -> None: + (tmp_path / "mod.py").write_text(f"{HEADER}{source}\n") + + assert find_issues(tmp_path, NAMES, NAMESPACE) == [Finding(kind=kind, path="mod.py", line=SOURCE_LINE, code=code)] + + +def test_check_vendored_tree_allowed(tmp_path: Path) -> None: + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "packages.py").write_text( + "import sys, warnings\n__import__(package)\nsys.modules[k] = m\nwarnings.simplefilter('ignore')\n" + ) + (tmp_path / "clean.py").write_text("import os\n") + + allowed = check_vendored_tree( + tmp_path, NAMES, NAMESPACE, {DYNAMIC_IMPORTS: ["pkg/packages.py"], SIDE_EFFECTS: ["pkg/packages.py"]} + ) + + assert [(finding.kind, str(finding)) for finding in allowed] == [ + (DYNAMIC_IMPORTS, "pkg/packages.py:2: __import__(package)"), + (DYNAMIC_IMPORTS, "pkg/packages.py:3: sys.modules[k] = m"), + (SIDE_EFFECTS, "pkg/packages.py:4: warnings.simplefilter('ignore')"), + ] + + +def test_check_vendored_tree_allow_is_per_kind(tmp_path: Path) -> None: + (tmp_path / "a.py").write_text("import os, sys\nsys.modules['x'] = m\nos.environ['x'] = '1'\n") + + with pytest.raises(VendorError) as exc_info: + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {DYNAMIC_IMPORTS: ["a.py"]}) + + assert str(exc_info.value) == ( + "vendored code escapes the vendor namespace:\n" + " side-effects (patch them or add the file to [tool.opentelemetry-vendor.allow] side-effects):\n" + " a.py:3: os.environ['x'] = '1'" + ) + + +def test_check_vendored_tree_unallowed(tmp_path: Path) -> None: + (tmp_path / "a.py").write_text("__import__(package)\nimport urllib3\n") + (tmp_path / "b.py").write_text("import importlib\nimportlib.import_module(name)\n") + + with pytest.raises(VendorError) as exc_info: + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {IMPORTS: ["a.py"]}) + + assert str(exc_info.value) == ( + "vendored code escapes the vendor namespace:\n" + " imports (imports of vendored packages must be rewritten; check the patches):\n" + " a.py:2: import urllib3\n" + " dynamic-imports (patch them or add the file to [tool.opentelemetry-vendor.allow] dynamic-imports):\n" + " a.py:1: __import__(package)\n" + " b.py:2: importlib.import_module(name)" + ) + + +def test_check_vendored_tree_unused_entry(tmp_path: Path) -> None: + (tmp_path / "a.py").write_text("import sys\nsys.modules['x'] = m\n") + + with pytest.raises( + VendorError, match=r"\[tool.opentelemetry-vendor.allow\] side-effects entry 'a.py' matched nothing" + ): + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {DYNAMIC_IMPORTS: ["a.py"], SIDE_EFFECTS: ["a.py"]}) + + +def test_check_vendored_tree_allow_patterns(tmp_path: Path) -> None: + (tmp_path / "pkg/instrumentation/aiohttp").mkdir(parents=True) + (tmp_path / "pkg/instrumentation/aiohttp/__init__.py").write_text("import aiohttp\n") + (tmp_path / "pkg/instrumentation/utils.py").write_text("from gevent import monkey\n") + (tmp_path / "pkg/loader.py").write_text("import yaml\n") + + with pytest.raises(VendorError) as exc_info: + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {EXTERNAL_IMPORTS: ["pkg/instrumentation/*"]}) + assert str(exc_info.value).endswith(" pkg/loader.py:1: import yaml") + + with pytest.raises(VendorError, match=r"external-imports entry 'other/\*' matched nothing"): + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {EXTERNAL_IMPORTS: ["pkg/*", "other/*"]}) + + +def test_check_vendored_tree_unparsable_file(tmp_path: Path) -> None: + (tmp_path / "broken.py").write_text("__import__(\n") + + with pytest.raises(VendorError, match=r"cannot parse .*broken\.py"): + check_vendored_tree(tmp_path, NAMES, NAMESPACE, {}) + + +@pytest.mark.parametrize( + ("builder_import", "module_name", "flagged"), + [ + pytest.param("from google.protobuf.internal import builder", "google.protobuf.any_pb2", True, id="leftover"), + pytest.param( + "from opentelemetry.agent._vendor.google.protobuf.internal import builder", + "google.protobuf.any_pb2", + True, + id="leftover-after-import-rewrite", + ), + pytest.param( + "from opentelemetry.agent._vendor.google.protobuf.internal import builder", + "opentelemetry.agent._vendor.google.protobuf.any_pb2", + False, + id="namespaced", + ), + ], +) +def test_find_issues_protobuf_module_names( + tmp_path: Path, builder_import: str, module_name: str, flagged: bool +) -> None: + call = f"builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, {module_name!r}, _globals)" + (tmp_path / "x_pb2.py").write_text(f"{builder_import}\n{call}\n") + + findings = [finding for finding in find_issues(tmp_path, NAMES | {"google"}, NAMESPACE) if finding.kind != IMPORTS] + + assert findings == ([Finding(kind=MODULE_NAMES, path="x_pb2.py", line=2, code=call)] if flagged else []) + + +def test_find_native_issues(tmp_path: Path) -> None: + for file in ("google/protobuf/__init__.py", "google/protobuf/message.py", "google/protobuf/internal/wkt.py"): + (tmp_path / file).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / file).write_text("") + (tmp_path / "google/_upb").mkdir() + extension = tmp_path / "google/_upb/_message.abi3.so" + extension.write_bytes( + b"\x00google._upb._message\x00" # the extension's own (leaf) module name + b"google._upb._message.Message\x00" # a type name under it + b"google.protobuf.message\x00" # a C-level import of a vendored module + b"\x0fgoogle.protobufB\x07" # a protobuf package name in an embedded descriptor + b"google.protobuf.Any\x00" # a protobuf type name + b"google.protobuf.internal\x00" # a package path alone + b"opentelemetry.agent._vendor.google.protobuf.message\x00" # namespaced + b"other.module\x00" + ) + + findings = find_native_issues(tmp_path, {"google"}, NAMESPACE, [extension]) + + assert [(finding.kind, finding.path, finding.code) for finding in findings] == [ + (NATIVE_MODULE_NAMES, "google/_upb/_message.abi3.so", "google._upb._message"), + (NATIVE_MODULE_NAMES, "google/_upb/_message.abi3.so", "google._upb._message.Message"), + (NATIVE_MODULE_NAMES, "google/_upb/_message.abi3.so", "google.protobuf.message"), + ] + assert findings[0].line == 1 # byte offset + + +def test_find_issues_namespace_shares_top_level_name(tmp_path: Path) -> None: + # Vendoring ``opentelemetry`` into ``opentelemetry.agent._vendor``: rewritten names aren't leftovers. + (tmp_path / "mod.py").write_text( + "import logging\n" + "import opentelemetry.agent._vendor.opentelemetry.trace\n" + "from opentelemetry.agent._vendor.opentelemetry import trace\n" + "logging.getLogger('opentelemetry.agent._vendor.opentelemetry.sdk')\n" + "import opentelemetry.trace\n" + ) + + findings = find_issues(tmp_path, NAMES | {"opentelemetry"}, NAMESPACE) + + assert findings == [Finding(kind=IMPORTS, path="mod.py", line=5, code="import opentelemetry.trace")] + + +def test_find_native_issues_namespace_shares_top_level_name(tmp_path: Path) -> None: + for file in ("opentelemetry/trace/__init__.py", "opentelemetry/trace/span.py"): + (tmp_path / file).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / file).write_text("") + extension = tmp_path / "opentelemetry/_ext.abi3.so" + extension.write_bytes(b"\x00opentelemetry.agent._vendor.opentelemetry.trace.span\x00opentelemetry.trace.span\x00") + + findings = find_native_issues(tmp_path, {"opentelemetry"}, NAMESPACE, [extension]) + + assert [finding.code for finding in findings] == ["opentelemetry.trace.span"] diff --git a/agent/opentelemetry-vendor/tests/test_cli.py b/agent/opentelemetry-vendor/tests/test_cli.py new file mode 100644 index 0000000000..ab0ba600d8 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_cli.py @@ -0,0 +1,389 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from opentelemetry.vendor import native as native_module +from opentelemetry.vendor import sync as sync_module +from opentelemetry.vendor.cli import app +from opentelemetry.vendor.download import DownloadError + +from .helpers import make_wheel + +# A fixed width keeps rich from wrapping lines in the captured output. +runner = CliRunner(env={"COLUMNS": "200"}) + + +def _fake_download(monkeypatch: pytest.MonkeyPatch, members: list[str] | None = None) -> None: + def fake_download_wheels(requirements: Path, python_version: str, dest: Path) -> list[Path]: + assert requirements.name == "requirements.txt" + assert python_version == "3.10" + return [ + make_wheel( + dest, + "six", + "1.0", + members or ["six.py"], + metadata="License-Expression: MIT\n", + ) + ] + + monkeypatch.setattr(sync_module, "download_wheels", fake_download_wheels) + + +def test_sync(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + destination = project_dir / "src/pkg/_vendor" + (destination / "stale.py").write_text("") + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert sorted(p.name for p in destination.iterdir()) == [ + "bom.cdx.json", + "requirements.txt", + "six.LICENSE", + "six.py", + ] + bom = json.loads((destination / "bom.cdx.json").read_text()) + assert [c["purl"] for c in bom["components"]] == ["pkg:pypi/six@1.0"] + lines = result.output.splitlines() + assert lines[:6] == [ + "✔ Cleaned src/pkg/_vendor (1 entry removed)", + "✔ Downloaded 1 wheel", + "✔ Unpacked 1 wheel", + "✔ Rewrote 0 imports in 0 files", + "✔ Copied 1 license file", + "✔ Wrote SBOM src/pkg/_vendor/bom.cdx.json", + ] + assert lines[-1].split() == ["six", "1.0", "MIT", "six"] + + +def test_sync_drop(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'drop = ["*-stubs", "**/py.typed"]\n') + _fake_download(monkeypatch, ["six/__init__.py", "six/py.typed", "six-stubs/__init__.pyi"]) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 0, result.output + destination = project_dir / "src/pkg/_vendor" + assert not (destination / "six-stubs").exists() + assert not (destination / "six/py.typed").exists() + assert (destination / "six/__init__.py").exists() + assert "✔ Dropped 2 paths" in result.output + + +def test_sync_verbose(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project_dir / "src/pkg/_vendor/stale.py").write_text("") + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir), "--verbose"]) + + assert result.exit_code == 0, result.output + assert "removed " in result.output + assert "stale.py" in result.output + + +def test_sync_quiet(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir), "-q"]) + + assert result.exit_code == 0, result.output + assert result.output == "" + + +def test_sync_quiet_and_verbose(project_dir: Path) -> None: + result = runner.invoke(app, ["sync", str(project_dir), "-q", "-v"]) + + assert result.exit_code == 2 + assert "cannot be used together" in result.output + + +def test_sync_download_error(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def fake_download_wheels(requirements: Path, python_version: str, dest: Path) -> list[Path]: + raise DownloadError("no wheel for foo") + + monkeypatch.setattr(sync_module, "download_wheels", fake_download_wheels) + + result = runner.invoke(app, ["sync", str(project_dir), "--quiet"]) + + assert result.exit_code == 1 + # Errors are printed even with --quiet. + assert result.output == "error: no wheel for foo\n" + + +def test_sync_missing_requirements(project_dir: Path) -> None: + (project_dir / "src/pkg/_vendor/requirements.txt").unlink() + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "requirements.txt does not exist" in result.output + + +def test_sync_invalid_config(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n") + + result = runner.invoke(app, ["sync", str(tmp_path)]) + + assert result.exit_code == 1 + assert "no [tool.opentelemetry-vendor] table" in result.output + + +def test_sync_missing_directory(tmp_path: Path) -> None: + result = runner.invoke(app, ["sync", str(tmp_path / "missing")]) + + assert result.exit_code != 0 + + +def test_clean(project_dir: Path) -> None: + destination = project_dir / "src/pkg/_vendor" + (destination / "requests").mkdir() + (destination / "requests/__init__.py").write_text("") + (destination / "requests.LICENSE").write_text("") + (destination / "__init__.py").write_text("") + (destination / "requirements.txt").write_text("requests==2.0\n") + + result = runner.invoke(app, ["clean", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert sorted(p.name for p in destination.iterdir()) == [ + "__init__.py", + "requirements.txt", + ] + assert (destination / "requirements.txt").read_text() == "requests==2.0\n" + assert result.output == "✔ Cleaned src/pkg/_vendor (2 entries removed)\n" + + +def test_clean_invalid_config(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n") + + result = runner.invoke(app, ["clean", str(tmp_path)]) + + assert result.exit_code == 1 + assert "error: " in result.output + assert "no [tool.opentelemetry-vendor] table" in result.output + + +def test_sync_patches(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project_dir / "patches").mkdir() + (project_dir / "patches/six.patch").write_text( + "diff --git a/six/__init__.py b/six/__init__.py\n" + "--- a/six/__init__.py\n" + "+++ b/six/__init__.py\n" + "@@ -0,0 +1 @@\n" + "+patched = True\n" + ) + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'patches = ["patches/six.patch"]\n') + _fake_download(monkeypatch, ["six/__init__.py"]) + + result = runner.invoke(app, ["sync", str(project_dir), "--verbose"]) + + assert result.exit_code == 0, result.output + destination = project_dir / "src/pkg/_vendor" + assert (destination / "six/__init__.py").read_text() == "patched = True\n" + assert "applied patches/six.patch" in result.output + assert "✔ Applied 1 patch" in result.output + bom = json.loads((destination / "bom.cdx.json").read_text()) + assert bom["components"][0]["pedigree"]["patches"][0]["type"] == "unofficial" + + +def test_sync_patch_does_not_apply(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project_dir / "patches").mkdir() + (project_dir / "patches/six.patch").write_text( + "diff --git a/six.py b/six.py\n--- a/six.py\n+++ b/six.py\n@@ -1 +1 @@\n-old\n+new\n" + ) + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'patches = ["patches/six.patch"]\n') + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "six.patch does not apply:" in result.output + + +def test_sync_patch_adding_vendored_import(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project_dir / "patches").mkdir() + (project_dir / "patches/six.patch").write_text( + "diff --git a/six/__init__.py b/six/__init__.py\n" + "--- a/six/__init__.py\n" + "+++ b/six/__init__.py\n" + "@@ -0,0 +1 @@\n" + "+import six.moves\n" + ) + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'patches = ["patches/six.patch"]\n') + _fake_download(monkeypatch, ["six/__init__.py"]) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "imports of vendored packages must be rewritten" in result.output + assert "six/__init__.py:1: import six.moves" in result.output + + +def test_sync_requirement_without_hash_keeps_tree(project_dir: Path) -> None: + destination = project_dir / "src/pkg/_vendor" + (destination / "requirements.txt").write_text("six==1.0\n") + (destination / "six.py").write_text("vendored = True\n") + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "requirement 'six'" in result.output + assert "has no --hash" in result.output + assert (destination / "six.py").read_text() == "vendored = True\n" + + +def _fake_stable_download(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # Build the wheel once: like a real index, every download returns identical bytes. + cache = tmp_path / "wheel-cache" + cache.mkdir() + wheel = make_wheel(cache, "six", "1.0", ["six/__init__.py"], metadata="License-Expression: MIT\n") + + def fake_download_wheels(requirements: Path, python_version: str, dest: Path) -> list[Path]: + return [Path(shutil.copy(wheel, dest))] + + monkeypatch.setattr(sync_module, "download_wheels", fake_download_wheels) + + +def test_sync_check_up_to_date(project_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _fake_stable_download(monkeypatch, tmp_path) + assert runner.invoke(app, ["sync", str(project_dir)]).exit_code == 0 + + result = runner.invoke(app, ["sync", "--check", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert result.output.splitlines()[-1] == "✔ Vendored files are up to date" + + +def test_sync_check_out_of_date(project_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _fake_stable_download(monkeypatch, tmp_path) + assert runner.invoke(app, ["sync", str(project_dir)]).exit_code == 0 + destination = project_dir / "src/pkg/_vendor" + (destination / "six/__init__.py").write_text("edited = True\n") + (destination / "six.LICENSE").unlink() + (destination / "extra.py").write_text("") + (destination / "six/__pycache__").mkdir() + (destination / "six/__pycache__/x.pyc").write_text("") + + result = runner.invoke(app, ["sync", "--check", str(project_dir)]) + + assert result.exit_code == 1 + assert result.output.endswith( + "error: vendored files are out of date (run opentelemetry-vendor sync):\n" + " - extra.py\n" + " + six.LICENSE\n" + " ~ six/__init__.py\n" + ) + # --check never changes the project. + assert (destination / "six/__init__.py").read_text() == "edited = True\n" + assert (destination / "extra.py").exists() + assert not (destination / "six.LICENSE").exists() + + +def test_sync_unused_license_files_entry(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + '[tool.opentelemetry-vendor.license-files]\nother = "LICENSE"\n') + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "license-files entries ['other'] match no vendored distribution" in result.output + + +def test_sync_metadata_dir(project_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'metadata-dir = "_metadata"\n') + cache = tmp_path / "wheel-cache" + cache.mkdir() + wheel = make_wheel( + cache, + "six", + "1.0", + ["six/__init__.py", "six/plugin.py"], + metadata="License-Expression: MIT\n", + entry_points="[console_scripts]\nsix = six:main\n\n[six_plugins]\nplugin = six.plugin:Plugin\n", + ) + + def fake_download_wheels(requirements: Path, python_version: str, dest: Path) -> list[Path]: + return [Path(shutil.copy(wheel, dest))] + + monkeypatch.setattr(sync_module, "download_wheels", fake_download_wheels) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert "✔ Wrote metadata to src/pkg/_vendor/_metadata (1 entry point)" in result.output.splitlines() + dist_info = project_dir / "src/pkg/_vendor/_metadata/six-1.0.dist-info" + assert (dist_info / "entry_points.txt").read_text() == "[six_plugins]\nplugin = pkg._vendor.six.plugin:Plugin\n" + assert not (project_dir / "src/pkg/_vendor/six-1.0.dist-info").exists() + + assert runner.invoke(app, ["sync", "--check", str(project_dir)]).exit_code == 0 + (dist_info / "entry_points.txt").write_text("") + result = runner.invoke(app, ["sync", "--check", str(project_dir)]) + assert result.exit_code == 1 + assert result.output.endswith(" ~ _metadata/six-1.0.dist-info/entry_points.txt\n") + + +def test_sync_check_sbom_outside_destination( + project_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("src/pkg/_vendor/bom.cdx.json", "bom.cdx.json")) + _fake_stable_download(monkeypatch, tmp_path) + assert runner.invoke(app, ["sync", str(project_dir)]).exit_code == 0 + assert runner.invoke(app, ["sync", "--check", str(project_dir)]).exit_code == 0 + (project_dir / "bom.cdx.json").write_text("{}") + + result = runner.invoke(app, ["sync", "--check", str(project_dir)]) + + assert result.exit_code == 1 + assert " ~ bom.cdx.json\n" in result.output + + +def test_sync_failure_cleans_destination(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project_dir / "patches").mkdir() + (project_dir / "patches/six.patch").write_text( + "diff --git a/six.py b/six.py\n--- a/six.py\n+++ b/six.py\n@@ -1 +1 @@\n-old\n+new\n" + ) + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'patches = ["patches/six.patch"]\n') + _fake_download(monkeypatch) + + result = runner.invoke(app, ["sync", str(project_dir)]) + + assert result.exit_code == 1 + assert "does not apply" in result.output + # Neither the unpacked package nor its *.dist-info is left behind. + assert sorted(p.name for p in (project_dir / "src/pkg/_vendor").iterdir()) == ["requirements.txt"] + + +def test_build_native_cli(project_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls = [] + monkeypatch.setattr(native_module, "build_native", lambda config: calls.append(("build", config.project_dir))) + monkeypatch.setattr(native_module, "clean_native", lambda config: calls.append(("clean", config.project_dir))) + + assert runner.invoke(app, ["build-native", str(project_dir)]).exit_code == 0 + assert runner.invoke(app, ["build-native", "--clean", str(project_dir)]).exit_code == 0 + + assert calls == [("build", project_dir.resolve()), ("clean", project_dir.resolve())] + + +def test_build_native_cli_error(project_dir: Path) -> None: + result = runner.invoke(app, ["build-native", str(project_dir)]) + + assert result.exit_code == 1 + assert result.output.startswith("error: ") diff --git a/agent/opentelemetry-vendor/tests/test_config.py b/agent/opentelemetry-vendor/tests/test_config.py new file mode 100644 index 0000000000..60906eaae5 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_config.py @@ -0,0 +1,237 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from opentelemetry.vendor.config import ConfigError, load_config + + +def test_load_config(project_dir: Path) -> None: + config = load_config(project_dir) + + root = project_dir.resolve() + assert config.project_dir == root + assert config.destination == root / "src/pkg/_vendor" + assert config.requirements == root / "src/pkg/_vendor/requirements.txt" + assert config.namespace == "pkg._vendor" + assert config.python_version == "3.10" + assert tuple(config.protected_files) == ("__init__.py", "requirements.txt") + assert config.sbom_file == root / "src/pkg/_vendor/bom.cdx.json" + assert config.metadata_dir is None + + +def test_load_config_license_files(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + pyproject.read_text() + '[tool.opentelemetry-vendor.license-files]\nOpenTelemetry_Distro = "../LICENSE"\n' + ) + + config = load_config(project_dir) + + assert config.license_files == {"opentelemetry-distro": project_dir.resolve() / "../LICENSE"} + + +@pytest.mark.parametrize("content", ['license-files = ["LICENSE"]\n', 'license-files = { pkg = "" }\n']) +def test_load_config_invalid_license_files(project_dir: Path, content: str) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + content) + + with pytest.raises(ConfigError, match="'license-files' must be a table"): + load_config(project_dir) + + +def test_load_config_metadata_dir(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'metadata-dir = "_metadata"\n') + + config = load_config(project_dir) + + assert config.metadata_dir == project_dir.resolve() / "src/pkg/_vendor/_metadata" + + +@pytest.mark.parametrize("entry", ["/abs", "../outside", ""]) +def test_load_config_invalid_metadata_dir(project_dir: Path, entry: str) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + f"metadata-dir = {entry!r}\n") + + with pytest.raises(ConfigError, match="metadata-dir"): + load_config(project_dir) + + +def test_load_config_optional_keys(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.opentelemetry-vendor]\n" + 'destination = "_vendor"\n' + 'requirements = "requirements.txt"\n' + 'namespace = "_vendor"\n' + ) + + config = load_config(tmp_path) + + assert tuple(config.protected_files) == () + assert tuple(config.drop) == () + assert config.allow == {} + assert config.python_version == (f"{sys.version_info.major}.{sys.version_info.minor}") + assert tuple(config.patches) == () + assert config.sbom_file is None + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("[project]\nname = 'x'\n", r"no \[tool.opentelemetry-vendor\] table"), + ( + '[tool.opentelemetry-vendor]\nrequirements = "r.txt"\nnamespace = "v"\n', + "missing required key 'destination'", + ), + ( + '[tool.opentelemetry-vendor]\ndestination = 1\nrequirements = "r.txt"\nnamespace = "v"\n', + "'destination' must be a non-empty string", + ), + ( + '[tool.opentelemetry-vendor]\ndestination = "v"\n' + 'requirements = "r.txt"\nnamespace = "v"\nprotected-files = "x"\n', + "'protected-files' must be a list of strings", + ), + ( + '[tool.opentelemetry-vendor]\ndestination = "v"\n' + 'requirements = "r.txt"\nnamespace = "v"\npatches = "a.patch"\n', + "'patches' must be a list of strings", + ), + ( + '[tool.opentelemetry-vendor]\ndestination = "../v"\nrequirements = "r.txt"\nnamespace = "v"\n', + "'destination' must be a directory inside", + ), + ( + '[tool.opentelemetry-vendor]\ndestination = "."\nrequirements = "r.txt"\nnamespace = "v"\n', + "'destination' must be a directory inside", + ), + ("not toml [", "is not valid TOML"), + ], +) +def test_load_config_invalid(tmp_path: Path, content: str, message: str) -> None: + (tmp_path / "pyproject.toml").write_text(content) + + with pytest.raises(ConfigError, match=message): + load_config(tmp_path) + + +def test_load_config_patches(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'patches = ["patches/b.patch", "patches/a.patch"]\n') + + root = project_dir.resolve() + assert tuple(load_config(project_dir).patches) == (root / "patches/b.patch", root / "patches/a.patch") + + +def test_load_config_missing_pyproject(tmp_path: Path) -> None: + with pytest.raises(ConfigError, match="does not exist"): + load_config(tmp_path) + + +def test_load_config_drop(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'drop = ["wrapt-stubs", "**/py.typed"]\n') + + assert tuple(load_config(project_dir).drop) == ("wrapt-stubs", "**/py.typed") + + +@pytest.mark.parametrize("entry", ["", "/abs", "../outside", "a/../../b"]) +def test_load_config_invalid_drop(project_dir: Path, entry: str) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + f"drop = [{entry!r}]\n") + + with pytest.raises(ConfigError, match="'drop' entry .* must be relative to destination"): + load_config(project_dir) + + +def test_load_config_allow(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + pyproject.read_text() + + '[tool.opentelemetry-vendor.allow]\ndynamic-imports = ["a.py"]\nside-effects = ["b/c.py", "d.py"]\n' + + 'module-names = ["e_pb2.py"]\n' + ) + + assert load_config(project_dir).allow == { + "dynamic-imports": ("a.py",), + "side-effects": ("b/c.py", "d.py"), + "module-names": ("e_pb2.py",), + } + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ( + '[tool.opentelemetry-vendor.allow]\ndynamic-imports = ["../a.py"]\n', + "'dynamic-imports' entry '../a.py' must be relative", + ), + ('[tool.opentelemetry-vendor.allow]\nside-effects = "a.py"\n', "'side-effects' must be a list of strings"), + ( + '[tool.opentelemetry-vendor.allow]\nimports = ["a.py"]\n', + r"unknown \[tool.opentelemetry-vendor.allow\] keys \['imports'\]", + ), + ('allow = ["a.py"]\n', r"'allow' must be a table"), + ], +) +def test_load_config_invalid_allow(project_dir: Path, content: str, message: str) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + content) + + with pytest.raises(ConfigError, match=message): + load_config(project_dir) + + +def test_load_config_moved_allow_dynamic_imports(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'allow-dynamic-imports = ["a.py"]\n') + + with pytest.raises( + ConfigError, match=r"'allow-dynamic-imports' moved to \[tool.opentelemetry-vendor.allow\] dynamic-imports" + ): + load_config(project_dir) + + +def test_load_config_native(project_dir: Path) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + pyproject.read_text() + + '[tool.opentelemetry-vendor.native.Google_Protobuf]\npatches = ["patches/native/a.patch"]\n' + 'build-requirements = "native-build-requirements.txt"\n' + ) + + config = load_config(project_dir) + + root = project_dir.resolve() + assert list(config.native) == ["google-protobuf"] + assert tuple(config.native["google-protobuf"].patches) == (root / "patches/native/a.patch",) + assert config.native["google-protobuf"].build_requirements == root / "native-build-requirements.txt" + assert load_config(project_dir).allow == {} + + +@pytest.mark.parametrize( + ("content", "message"), + [ + pytest.param('native = ["protobuf"]\n', r"'native' must be a table", id="not-a-table"), + pytest.param( + "[tool.opentelemetry-vendor.native]\nprotobuf = 1\n", r"'native.protobuf' must be a table", id="entry" + ), + pytest.param( + "[tool.opentelemetry-vendor.native.protobuf]\npatches = []\n", + "missing required key 'build-requirements'", + id="missing-build-requirements", + ), + ], +) +def test_load_config_invalid_native(project_dir: Path, content: str, message: str) -> None: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + content) + + with pytest.raises(ConfigError, match=message): + load_config(project_dir) diff --git a/agent/opentelemetry-vendor/tests/test_download.py b/agent/opentelemetry-vendor/tests/test_download.py new file mode 100644 index 0000000000..fd8eb70708 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_download.py @@ -0,0 +1,128 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +import pytest + +from opentelemetry.vendor.download import ( + DownloadError, + download_wheels, + unpack_wheels, +) + +from .helpers import make_wheel, make_zip + + +def _fake_run( + returncode: int, wheel_names: Sequence[str], stderr: str = "" +) -> tuple[list[list[str]], Callable[..., subprocess.CompletedProcess[str]]]: + calls: list[list[str]] = [] + + def run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(args) + dest = Path(args[args.index("--dest") + 1]) + for name in wheel_names: + make_zip(dest / name, []) + return subprocess.CompletedProcess(args, returncode, "", stderr) + + return calls, run + + +def test_download_wheels(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + requirements = tmp_path / "requirements.txt" + calls, run = _fake_run(0, ["requests-2.0-py3-none-any.whl", "six-1.0-py2.py3-none-any.whl"]) + monkeypatch.setattr(subprocess, "run", run) + + wheels = download_wheels(requirements, "3.10", tmp_path) + + assert [w.name for w in wheels] == [ + "requests-2.0-py3-none-any.whl", + "six-1.0-py2.py3-none-any.whl", + ] + assert calls == [ + [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + "--only-binary=:all:", + "--platform", + "any", + "--implementation", + "py", + "--abi", + "none", + "--python-version", + "3.10", + "--disable-pip-version-check", + "--dest", + str(tmp_path), + "-r", + str(requirements), + ] + ] + + +def test_download_wheels_pip_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _, run = _fake_run(1, [], stderr="No matching distribution found for foo") + monkeypatch.setattr(subprocess, "run", run) + + with pytest.raises(DownloadError, match="No matching distribution found"): + download_wheels(tmp_path / "requirements.txt", "3.10", tmp_path) + + +def test_download_wheels_rejects_platform_wheel(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _, run = _fake_run(0, ["wrapt-2.0-cp312-cp312-linux_x86_64.whl"]) + monkeypatch.setattr(subprocess, "run", run) + + with pytest.raises(DownloadError, match="not a pure-Python wheel"): + download_wheels(tmp_path / "requirements.txt", "3.10", tmp_path) + + +def test_unpack_wheels_shared_namespace(tmp_path: Path) -> None: + destination = tmp_path / "_vendor" + protobuf = make_wheel(tmp_path, "protobuf", "7.0", ["google/protobuf/__init__.py"]) + common_protos = make_wheel(tmp_path, "googleapis-common-protos", "1.0", ["google/api/__init__.py"]) + + dist_infos = unpack_wheels([protobuf, common_protos], destination) + + assert dist_infos == { + protobuf: destination / "protobuf-7.0.dist-info", + common_protos: destination / "googleapis_common_protos-1.0.dist-info", + } + assert (destination / "google/protobuf/__init__.py").is_file() + assert (destination / "google/api/__init__.py").is_file() + assert (destination / "protobuf-7.0.dist-info/METADATA").is_file() + + +def test_unpack_wheels_refuses_to_overwrite(tmp_path: Path) -> None: + destination = tmp_path / "_vendor" + destination.mkdir() + (destination / "requirements.txt").write_text("keep") + wheel = make_wheel(tmp_path, "bad", "1.0", ["requirements.txt"]) + + with pytest.raises(DownloadError, match="would overwrite"): + unpack_wheels([wheel], destination) + + assert (destination / "requirements.txt").read_text() == "keep" + + +@pytest.mark.parametrize( + "members", + [ + ["bad/__init__.py"], + ["a-1.0.dist-info/METADATA", "b-1.0.dist-info/METADATA"], + ], +) +def test_unpack_wheels_requires_one_dist_info(tmp_path: Path, members: list[str]) -> None: + wheel = make_zip(tmp_path / "bad-1.0-py3-none-any.whl", members) + + with pytest.raises(DownloadError, match="exactly one .dist-info"): + unpack_wheels([wheel], tmp_path / "_vendor") diff --git a/agent/opentelemetry-vendor/tests/test_entry_points.py b/agent/opentelemetry-vendor/tests/test_entry_points.py new file mode 100644 index 0000000000..06393cb973 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_entry_points.py @@ -0,0 +1,152 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from importlib.metadata import distributions +from pathlib import Path + +import pytest + +from opentelemetry.vendor.entry_points import rewrite_entry_points, write_metadata +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage + +NAMESPACE = "opentelemetry.agent._vendor" +NAMES = frozenset({"opentelemetry", "wrapt"}) + + +def _package(tmp_path: Path, entry_points: str | None) -> VendoredPackage: + dist_info = tmp_path / "unpacked" / "opentelemetry_sdk-1.0.dist-info" + dist_info.mkdir(parents=True) + (dist_info / "METADATA").write_text( + 'Metadata-Version: 2.4\nName: opentelemetry-sdk\nVersion: 1.0\nRequires-Dist: wrapt; extra == "instruments"\n' + ) + (dist_info / "RECORD").write_text("") + if entry_points is not None: + (dist_info / "entry_points.txt").write_text(entry_points) + return VendoredPackage( + name="opentelemetry-sdk", + version="1.0", + sha256="0" * 64, + license_expression=None, + top_level_names=("opentelemetry",), + license_files={}, + dist_info=dist_info, + ) + + +def _vendored_tree(tmp_path: Path, *modules: str) -> Path: + destination = tmp_path / "_vendor" + for module in modules: + path = destination / module + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return destination + + +def test_rewrite_entry_points() -> None: + text = ( + "[console_scripts]\n" + "opentelemetry-instrument = opentelemetry.instrumentation.auto_instrumentation:run\n" + "\n" + "[opentelemetry_traces_exporter]\n" + "Console = opentelemetry.sdk.trace.export:ConsoleSpanExporter\n" + "extras = opentelemetry.sdk.trace : Tracer [grpc]\n" + "module-only = opentelemetry.sdk.environment_variables\n" + "already = opentelemetry.agent._vendor.opentelemetry.sdk:X\n" + "external = aiohttp.client:ClientSession\n" + "\n" + "[gui_scripts]\n" + "gui = opentelemetry.gui:main\n" + ) + + assert rewrite_entry_points(text, NAMESPACE, NAMES) == { + "opentelemetry_traces_exporter": { + "Console": "opentelemetry.agent._vendor.opentelemetry.sdk.trace.export:ConsoleSpanExporter", + "extras": "opentelemetry.agent._vendor.opentelemetry.sdk.trace : Tracer [grpc]", + "module-only": "opentelemetry.agent._vendor.opentelemetry.sdk.environment_variables", + "already": "opentelemetry.agent._vendor.opentelemetry.sdk:X", + "external": "aiohttp.client:ClientSession", + } + } + + +def test_rewrite_entry_points_invalid_value() -> None: + with pytest.raises(VendorError, match=r"invalid entry point group:name = 'not a module'"): + rewrite_entry_points("[group]\nname = not a module\n", NAMESPACE, NAMES) + + +def test_write_metadata(tmp_path: Path) -> None: + destination = _vendored_tree( + tmp_path, "opentelemetry/sdk/trace/export/__init__.py", "opentelemetry/sdk/environment_variables.py" + ) + package = _package( + tmp_path, + "[console_scripts]\nscript = opentelemetry.sdk:main\n\n" + "[opentelemetry_traces_exporter]\nconsole = opentelemetry.sdk.trace.export:ConsoleSpanExporter\n\n" + "[opentelemetry_environment_variables]\nsdk = opentelemetry.sdk.environment_variables\n", + ) + metadata_dir = destination / "_metadata" + + count = write_metadata([package], destination, metadata_dir, NAMESPACE, NAMES) + + assert count == 2 + dist_info = metadata_dir / "opentelemetry_sdk-1.0.dist-info" + assert sorted(path.name for path in dist_info.iterdir()) == ["METADATA", "entry_points.txt"] + assert (dist_info / "entry_points.txt").read_text() == ( + "[opentelemetry_traces_exporter]\n" + "console = opentelemetry.agent._vendor.opentelemetry.sdk.trace.export:ConsoleSpanExporter\n" + "\n" + "[opentelemetry_environment_variables]\n" + "sdk = opentelemetry.agent._vendor.opentelemetry.sdk.environment_variables\n" + ) + # importlib.metadata finds the copy only through an explicit path. + [distribution] = distributions(path=[str(metadata_dir)]) + assert distribution.metadata["Name"] == "opentelemetry-sdk" + assert distribution.requires == ['wrapt; extra == "instruments"'] + [entry_point] = distribution.entry_points.select(group="opentelemetry_traces_exporter") + assert entry_point.module == "opentelemetry.agent._vendor.opentelemetry.sdk.trace.export" + assert entry_point.dist is not None + + +def test_write_metadata_without_entry_points(tmp_path: Path) -> None: + destination = _vendored_tree(tmp_path) + + assert write_metadata([_package(tmp_path, None)], destination, destination / "_metadata", NAMESPACE, NAMES) == 0 + + dist_info = destination / "_metadata" / "opentelemetry_sdk-1.0.dist-info" + assert [path.name for path in dist_info.iterdir()] == ["METADATA"] + + +def test_write_metadata_only_script_entry_points(tmp_path: Path) -> None: + destination = _vendored_tree(tmp_path) + package = _package(tmp_path, "[console_scripts]\nscript = opentelemetry.sdk:main\n") + + write_metadata([package], destination, destination / "_metadata", NAMESPACE, NAMES) + + assert not (destination / "_metadata" / "opentelemetry_sdk-1.0.dist-info" / "entry_points.txt").exists() + + +@pytest.mark.parametrize( + ("value", "module"), + [ + pytest.param( + "opentelemetry.exporter.otlp.proto.grpc:OTLPSpanExporter", + "opentelemetry.agent._vendor.opentelemetry.exporter.otlp.proto.grpc", + id="missing-vendored-module", + ), + pytest.param("aiohttp.client:ClientSession", "aiohttp.client", id="non-vendored-module"), + ], +) +def test_write_metadata_missing_module(tmp_path: Path, value: str, module: str) -> None: + destination = _vendored_tree(tmp_path, "opentelemetry/sdk/__init__.py") + package = _package(tmp_path, f"[opentelemetry_traces_exporter]\notlp = {value}\n") + + with pytest.raises(VendorError) as exc_info: + write_metadata([package], destination, destination / "_metadata", NAMESPACE, NAMES) + + assert str(exc_info.value) == ( + f"entry point opentelemetry_traces_exporter:otlp of opentelemetry-sdk references {module}, " + "which isn't in the vendored tree" + ) diff --git a/agent/opentelemetry-vendor/tests/test_metadata.py b/agent/opentelemetry-vendor/tests/test_metadata.py new file mode 100644 index 0000000000..ce7eb42117 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_metadata.py @@ -0,0 +1,181 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from opentelemetry.vendor.download import unpack_wheels +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import ( + VendoredPackage, + copy_license_files, + read_package, + remove_dist_info, +) + +from .helpers import make_wheel + + +def _read(tmp_path: Path, wheel: Path) -> VendoredPackage: + destination = tmp_path / "_vendor" + ((wheel, dist_info),) = unpack_wheels([wheel], destination).items() + return read_package(wheel, dist_info) + + +def test_read_package(tmp_path: Path) -> None: + wheel = make_wheel( + tmp_path, + "Charset_Normalizer", + "3.5.2", + [ + "charset_normalizer/__init__.py", + "charset_normalizer/cli/__init__.py", + "six.py", + "README.md", + "wrapt-stubs/__init__.pyi", + "__pycache__/six.cpython-312.pyc", + "charset_normalizer-3.5.2.data/scripts/normalizer", + ], + metadata="License-Expression: MIT\nLicense-File: LICENSE\n", + ) + + package = _read(tmp_path, wheel) + + dist_info = tmp_path / "_vendor/Charset_Normalizer-3.5.2.dist-info" + assert package == VendoredPackage( + name="charset-normalizer", + version="3.5.2", + sha256=hashlib.sha256(wheel.read_bytes()).hexdigest(), + license_expression="MIT", + top_level_names=("charset_normalizer", "six"), + license_files={"charset-normalizer.LICENSE": dist_info / "licenses/LICENSE"}, + dist_info=dist_info, + ) + + +@pytest.mark.parametrize( + ("metadata", "expected"), + [ + ("License-Expression: Apache-2.0\nLicense: MIT\n", "Apache-2.0"), + ("License: MPL-2.0\n", "MPL-2.0"), + ( + "License: line one\n line two\nClassifier: License :: OSI Approved :: MIT License\n", + "MIT License", + ), + ( + "Classifier: License :: OSI Approved :: MIT License\n" + "Classifier: Programming Language :: Python\n" + "Classifier: License :: OSI Approved :: BSD License\n", + "MIT License OR BSD License", + ), + ("", None), + ], +) +def test_read_package_license_expression(tmp_path: Path, metadata: str, expected: str | None) -> None: + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], metadata=metadata) + + assert _read(tmp_path, wheel).license_expression == expected + + +def test_read_package_legacy_license_files(tmp_path: Path) -> None: + wheel = make_wheel( + tmp_path, + "pkg", + "1.0", + ["pkg.py"], + licenses={ + "LICENSE.txt": "", + "NOTICE": "", + "licenses/vendor/COPYING": "", + "entry_points.txt": "", + }, + ) + + package = _read(tmp_path, wheel) + + assert sorted(package.license_files) == [ + "pkg.LICENSE.txt", + "pkg.NOTICE", + "pkg.vendor.COPYING", + ] + + +def test_read_package_license_file_in_dist_info_root(tmp_path: Path) -> None: + wheel = make_wheel( + tmp_path, + "pkg", + "1.0", + ["pkg.py"], + metadata="License-File: LICENSE.md\n", + licenses={"LICENSE.md": ""}, + ) + + assert sorted(_read(tmp_path, wheel).license_files) == ["pkg.LICENSE.md"] + + +def test_read_package_missing_declared_license_file(tmp_path: Path) -> None: + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], metadata="License-File: LICENSE\n", licenses={}) + + with pytest.raises(VendorError, match="declares missing license file LICENSE"): + _read(tmp_path, wheel) + + +def test_read_package_without_license_file(tmp_path: Path) -> None: + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], licenses={}) + + with pytest.raises(VendorError, match="does not contain a license file"): + _read(tmp_path, wheel) + + +def test_read_package_license_file_override(tmp_path: Path) -> None: + license_file = tmp_path / "LICENSE" + license_file.write_text("Apache License") + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], licenses={}) + ((wheel, dist_info),) = unpack_wheels([wheel], tmp_path / "_vendor").items() + + package = read_package(wheel, dist_info, license_file) + + assert package.license_files == {"pkg.LICENSE": license_file} + + +def test_read_package_license_file_override_unneeded(tmp_path: Path) -> None: + license_file = tmp_path / "LICENSE" + license_file.write_text("Apache License") + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"]) + ((wheel, dist_info),) = unpack_wheels([wheel], tmp_path / "_vendor").items() + + with pytest.raises(VendorError, match="contains a license file; remove its license-files entry"): + read_package(wheel, dist_info, license_file) + + +def test_read_package_license_file_override_missing(tmp_path: Path) -> None: + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], licenses={}) + ((wheel, dist_info),) = unpack_wheels([wheel], tmp_path / "_vendor").items() + + with pytest.raises(VendorError, match="license file .*LICENSE for pkg does not exist"): + read_package(wheel, dist_info, tmp_path / "LICENSE") + + +def test_copy_license_files_and_remove_dist_info(tmp_path: Path) -> None: + destination = tmp_path / "_vendor" + wheel = make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"], licenses={"licenses/LICENSE": "text"}) + package = _read(tmp_path, wheel) + + copy_license_files([package], destination) + remove_dist_info([package]) + + assert (destination / "pkg.LICENSE").read_text() == "text" + assert sorted(p.name for p in destination.iterdir()) == ["pkg.LICENSE", "pkg.py"] + + +def test_copy_license_files_collision(tmp_path: Path) -> None: + destination = tmp_path / "_vendor" + package = _read(tmp_path, make_wheel(tmp_path, "pkg", "1.0", ["pkg.py"])) + (destination / "pkg.LICENSE").write_text("existing") + + with pytest.raises(VendorError, match="already exists"): + copy_license_files([package], destination) diff --git a/agent/opentelemetry-vendor/tests/test_native.py b/agent/opentelemetry-vendor/tests/test_native.py new file mode 100644 index 0000000000..523b173624 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_native.py @@ -0,0 +1,234 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +import io +import json +import sys +import tarfile +import zipfile +from collections.abc import Mapping +from pathlib import Path + +import pytest + +from opentelemetry.vendor import native +from opentelemetry.vendor.config import load_config +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage +from opentelemetry.vendor.sbom import write_sbom + +CURRENT_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}" +NATIVE_PATCH = """\ +Rename the module. + +diff --git a/setup.py b/setup.py +--- a/setup.py ++++ b/setup.py +@@ -1 +1 @@ +-NAME = 'six._ext' ++NAME = 'pkg._vendor.six._ext' +""" +EXTENSION = "pkg/_vendor/six/_ext.abi3.so" + + +def _sdist(directory: Path, files: Mapping[str, str] | None = None) -> Path: + path = directory / "six-1.0.tar.gz" + with tarfile.open(path, "w:gz") as archive: + for name, content in (files or {"six-1.0/setup.py": "NAME = 'six._ext'\n"}).items(): + data = content.encode() + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return path + + +def _wheel(directory: Path, members: Mapping[str, bytes]) -> Path: + path = directory / "six-1.0-cp310-abi3-plat.whl" + with zipfile.ZipFile(path, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + return path + + +@pytest.fixture(name="native_project") +def fixture_native_project(project_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + pyproject = project_dir / "pyproject.toml" + pyproject.write_text( + pyproject.read_text().replace('python-version = "3.10"', f'python-version = "{CURRENT_PYTHON}"') + + '\n[tool.opentelemetry-vendor.native.six]\npatches = ["patches/native/six.patch"]\n' + 'build-requirements = "native-build-requirements.txt"\n' + ) + (project_dir / "patches/native").mkdir(parents=True) + (project_dir / "patches/native/six.patch").write_text(NATIVE_PATCH) + (project_dir / "native-build-requirements.txt").write_text("setuptools==1.0 --hash=sha256:00\n") + destination = project_dir / "src/pkg/_vendor" + (destination / "six").mkdir() + (destination / "six/__init__.py").write_text("") + (destination / "six/moves.py").write_text("") + package = VendoredPackage( + name="six", + version="1.0", + sha256=hashlib.sha256(b"six").hexdigest(), + license_expression="MIT", + top_level_names=("six",), + license_files={}, + dist_info=Path("six-1.0.dist-info"), + ) + write_sbom([package], destination / "bom.cdx.json") + + sdists = tmp_path / "sdists" + sdists.mkdir() + monkeypatch.setattr(native, "download_sdist", lambda requirement, dest: _sdist(sdists)) + set_built_wheel(monkeypatch, tmp_path, {EXTENSION: b"\x00pkg._vendor.six._ext\x00google.protobuf.Any\x00"}) + return project_dir + + +def set_built_wheel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, members: Mapping[str, bytes]) -> None: + wheels = tmp_path / "wheels" + wheels.mkdir(exist_ok=True) + + def fake_build_wheel(source: Path, build_requirements: Path, work: Path) -> Path: + # The native patch was applied to the extracted sdist before building. + assert (source / "setup.py").read_text() == "NAME = 'pkg._vendor.six._ext'\n" + assert build_requirements.name == "native-build-requirements.txt" + return _wheel(wheels, members) + + monkeypatch.setattr(native, "_build_wheel", fake_build_wheel) + + +def test_build_native_and_clean(native_project: Path) -> None: + config = load_config(native_project) + sbom = config.destination / "bom.cdx.json" + committed_sbom = sbom.read_bytes() + + written = native.build_native(config) + + extension = config.destination / "six/_ext.abi3.so" + assert written == [extension] + assert extension.read_bytes().startswith(b"\x00pkg._vendor.six._ext") + [component] = json.loads(sbom.read_text())["components"] + assert [patch["diff"]["text"]["content"] for patch in component["pedigree"]["patches"]] == [NATIVE_PATCH] + assert component["properties"] == [ + { + "name": "opentelemetry-vendor:native-sdist-sha256", + "value": hashlib.sha256((native_project / "sdists/six-1.0.tar.gz").read_bytes()).hexdigest(), + }, + {"name": "opentelemetry-vendor:native-extension", "value": "six/_ext.abi3.so"}, + ] + assert json.loads(sbom.read_text())["serialNumber"] != json.loads(committed_sbom)["serialNumber"] + + native.clean_native(config) + + assert not extension.exists() + assert sbom.read_bytes() == committed_sbom + assert not (native_project / "build/opentelemetry-vendor").exists() + + +def test_build_native_creates_namespace_package_directories( + native_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + set_built_wheel(monkeypatch, tmp_path, {"pkg/_vendor/six/_native/_ext.abi3.so": b""}) + config = load_config(native_project) + + native.build_native(config) + assert (config.destination / "six/_native/_ext.abi3.so").exists() + native.clean_native(config) + + assert not (config.destination / "six/_native").exists() + assert (config.destination / "six/__init__.py").exists() + + +@pytest.mark.parametrize( + ("members", "message"), + [ + pytest.param({"pkg/_vendor/six/__init__.py": b""}, r"contains no extension under pkg/_vendor/", id="none"), + pytest.param( + {"pkg/_vendor/six/_ext.cpython-310-darwin.so": b""}, r"isn't a stable-ABI \(abi3\) extension", id="not-abi3" + ), + pytest.param( + {EXTENSION: b"\x00six.moves\x00six.moves.urllib\x00"}, + r"native-module-names.*\n.*six/_ext\.abi3\.so:1: six\.moves", + id="un-namespaced-module-name", + ), + ], +) +def test_build_native_errors_restore_tree( + native_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, members: Mapping[str, bytes], message: str +) -> None: + set_built_wheel(monkeypatch, tmp_path, members) + config = load_config(native_project) + committed_sbom = (config.destination / "bom.cdx.json").read_bytes() + + with pytest.raises(VendorError, match=message): + native.build_native(config) + + assert not list(config.destination.rglob("*.so")) + assert (config.destination / "bom.cdx.json").read_bytes() == committed_sbom + assert not (native_project / "build/opentelemetry-vendor").exists() + + +def test_build_native_allowed_native_finding( + native_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + set_built_wheel(monkeypatch, tmp_path, {EXTENSION: b"\x00six.moves\x00"}) + pyproject = native_project / "pyproject.toml" + pyproject.write_text( + pyproject.read_text() + '\n[tool.opentelemetry-vendor.allow]\nnative-module-names = ["six/_ext.abi3.so"]\n' + ) + + assert native.build_native(load_config(native_project)) + + +def test_build_native_patch_does_not_apply(native_project: Path) -> None: + (native_project / "patches/native/six.patch").write_text(NATIVE_PATCH.replace("-NAME = 'six._ext'", "-NAME = 'x'")) + + with pytest.raises(VendorError, match=r"six\.patch does not apply"): + native.build_native(load_config(native_project)) + + +def test_build_native_requirement_not_pinned(native_project: Path) -> None: + pyproject = native_project / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("native.six]", "native.Other_Pkg]")) + + with pytest.raises(VendorError, match=r"native build 'other-pkg' is not pinned in"): + native.build_native(load_config(native_project)) + + +def test_build_native_requires_synced_tree(native_project: Path) -> None: + config = load_config(native_project) + for path in sorted(config.destination.rglob("*"), reverse=True): + if path.name not in ("requirements.txt", "bom.cdx.json"): + path.unlink() if path.is_file() else path.rmdir() + + with pytest.raises(VendorError, match="has no vendored packages; run opentelemetry-vendor sync first"): + native.build_native(config) + + +def test_build_native_unsafe_sdist(native_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + unsafe = tmp_path / "unsafe" + unsafe.mkdir() + monkeypatch.setattr( + native, "download_sdist", lambda requirement, dest: _sdist(unsafe, {"six-1.0/../../evil.py": "x"}) + ) + + with pytest.raises(VendorError, match=r"contains an unsafe path: six-1\.0/\.\./\.\./evil\.py"): + native.build_native(load_config(native_project)) + + +def test_check_interpreter_version() -> None: + with pytest.raises(VendorError, match=r"must run on CPython 2\.7 \(running CPython"): + native._check_interpreter("2.7") + + +def test_check_interpreter_free_threaded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(native.sysconfig, "get_config_var", lambda name: 1 if name == "Py_GIL_DISABLED" else None) + + with pytest.raises(VendorError, match="can't run on a free-threaded interpreter"): + native._check_interpreter(CURRENT_PYTHON) + + +def test_clean_native_without_build(native_project: Path) -> None: + native.clean_native(load_config(native_project)) diff --git a/agent/opentelemetry-vendor/tests/test_patches.py b/agent/opentelemetry-vendor/tests/test_patches.py new file mode 100644 index 0000000000..99d6c2aed2 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_patches.py @@ -0,0 +1,169 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path + +import pytest + +from opentelemetry.vendor import patches as patches_module +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.metadata import VendoredPackage +from opentelemetry.vendor.patches import VendorPatch, apply_patches, patches_by_package, read_patches + +FIRST = """\ +Use the vendored module. + +diff --git a/pkg/mod.py b/pkg/mod.py +--- a/pkg/mod.py ++++ b/pkg/mod.py +@@ -1,2 +1,2 @@ +-a = 1 ++a = 2 + b = 1 +""" + +SECOND = """\ +diff --git a/pkg/mod.py b/pkg/mod.py +--- a/pkg/mod.py ++++ b/pkg/mod.py +@@ -1,2 +1,2 @@ + a = 2 +-b = 1 ++b = 2 +""" + +NEW_FILE = """\ +diff --git a/six.py b/six.py +new file mode 100644 +--- /dev/null ++++ b/six.py +@@ -0,0 +1 @@ ++x = 1 +""" + +NOT_APPLICABLE = """\ +diff --git a/pkg/mod.py b/pkg/mod.py +--- a/pkg/mod.py ++++ b/pkg/mod.py +@@ -1,2 +1,2 @@ +-a = 3 ++a = 4 + b = 1 +""" + + +def _write_patches(directory: Path, *texts: str) -> list[Path]: + paths = [] + for index, text in enumerate(texts): + path = directory / f"{index}.patch" + path.write_text(text) + paths.append(path) + return paths + + +@pytest.fixture(name="destination") +def fixture_destination(tmp_path: Path) -> Path: + destination = tmp_path / "_vendor" + (destination / "pkg").mkdir(parents=True) + (destination / "pkg/mod.py").write_text("a = 1\nb = 1\n") + return destination + + +def test_read_patches(tmp_path: Path) -> None: + paths = _write_patches(tmp_path, FIRST, NEW_FILE) + + patches = read_patches(paths) + + assert patches == [ + VendorPatch(path=paths[0], text=FIRST, files=("pkg/mod.py",)), + VendorPatch(path=paths[1], text=NEW_FILE, files=("six.py",)), + ] + + +def test_read_patches_plain_unified_diff(tmp_path: Path) -> None: + paths = _write_patches(tmp_path, FIRST.split("diff --git a/pkg/mod.py b/pkg/mod.py\n", 1)[1]) + + assert read_patches(paths)[0].files == ("pkg/mod.py",) + + +def test_read_patches_missing(tmp_path: Path) -> None: + with pytest.raises(VendorError, match=r"patch .*missing\.patch does not exist"): + read_patches([tmp_path / "missing.patch"]) + + +@pytest.mark.parametrize("text", ["", "Only a description.\n"]) +def test_read_patches_without_changes(tmp_path: Path, text: str) -> None: + with pytest.raises(VendorError, match=r"patch .*0\.patch contains no changes"): + read_patches(_write_patches(tmp_path, text)) + + +def test_apply_patches_in_order(tmp_path: Path, destination: Path) -> None: + patches = read_patches(_write_patches(tmp_path, FIRST, SECOND, NEW_FILE)) + + apply_patches(destination, patches) + + assert (destination / "pkg/mod.py").read_text() == "a = 2\nb = 2\n" + assert (destination / "six.py").read_text() == "x = 1\n" + + +def test_apply_patches_is_atomic(tmp_path: Path, destination: Path) -> None: + patches = read_patches(_write_patches(tmp_path, NEW_FILE, NOT_APPLICABLE)) + + with pytest.raises(VendorError, match=r"patch .*1\.patch does not apply:\n.*pkg/mod\.py"): + apply_patches(destination, patches) + + assert (destination / "pkg/mod.py").read_text() == "a = 1\nb = 1\n" + assert not (destination / "six.py").exists() + + +def test_apply_patches_requires_git(tmp_path: Path, destination: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(patches_module.shutil, "which", lambda name: None) + + with pytest.raises(VendorError, match="applying patches requires git on PATH"): + apply_patches(destination, read_patches(_write_patches(tmp_path, FIRST))) + + +def _package(name: str, *top_level_names: str) -> VendoredPackage: + return VendoredPackage( + name=name, + version="1.0", + sha256=hashlib.sha256(name.encode()).hexdigest(), + license_expression=None, + top_level_names=top_level_names, + license_files={}, + dist_info=Path(f"{name}-1.0.dist-info"), + ) + + +def test_patches_by_package() -> None: + package_patch = VendorPatch(path=Path("a.patch"), text="", files=("pkg/mod.py",)) + module_patch = VendorPatch(path=Path("b.patch"), text="", files=("six.py",)) + spanning_patch = VendorPatch(path=Path("c.patch"), text="", files=("pkg/other.py", "six.py")) + packages = [_package("my-pkg", "pkg"), _package("six", "six"), _package("unpatched", "unpatched")] + + result = patches_by_package([package_patch, module_patch, spanning_patch], packages) + + assert result == {"my-pkg": [package_patch, spanning_patch], "six": [module_patch, spanning_patch]} + + +def test_patches_by_package_unknown_file() -> None: + patch = VendorPatch(path=Path("a.patch"), text="", files=("other/mod.py",)) + + with pytest.raises( + VendorError, match=r"patch a\.patch touches other/mod\.py, which belongs to no vendored package" + ): + patches_by_package([patch], [_package("six", "six")]) + + +def test_apply_patches_inside_git_work_tree(tmp_path: Path, destination: Path) -> None: + # git would otherwise read patch paths relative to the repository root and silently skip them. + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + patches = read_patches(_write_patches(tmp_path, FIRST)) + + apply_patches(destination, patches) + + assert (destination / "pkg/mod.py").read_text() == "a = 2\nb = 1\n" diff --git a/agent/opentelemetry-vendor/tests/test_rewrite.py b/agent/opentelemetry-vendor/tests/test_rewrite.py new file mode 100644 index 0000000000..e0b5bd421c --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_rewrite.py @@ -0,0 +1,375 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.rewrite import RewriteResult, rewrite_imports, rewrite_source + +NAMESPACE = "opentelemetry.agent._vendor" +NAMES = frozenset({"idna", "requests", "urllib3"}) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param( + "from urllib3 import PoolManager\n", + "from opentelemetry.agent._vendor.urllib3 import PoolManager\n", + id="package", + ), + pytest.param( + "from urllib3.util.retry import Retry\n", + "from opentelemetry.agent._vendor.urllib3.util.retry import Retry\n", + id="submodule", + ), + pytest.param( + "from requests import Session as S, get\n", + "from opentelemetry.agent._vendor.requests import Session as S, get\n", + id="aliases", + ), + pytest.param( + "from urllib3.exceptions import *\n", + "from opentelemetry.agent._vendor.urllib3.exceptions import *\n", + id="star", + ), + pytest.param( + "from urllib3 import ( # comment\n PoolManager, # pool\n Timeout,\n)\n", + "from opentelemetry.agent._vendor.urllib3 import ( # comment\n PoolManager, # pool\n Timeout,\n)\n", + id="parenthesized-multiline", + ), + pytest.param( + "def f():\n from urllib3 import Timeout\n", + "def f():\n from opentelemetry.agent._vendor.urllib3 import Timeout\n", + id="function", + ), + pytest.param( + "try:\n from urllib3 import a\nexcept ImportError:\n pass\n", + "try:\n from opentelemetry.agent._vendor.urllib3 import a\nexcept ImportError:\n pass\n", + id="try", + ), + pytest.param( + "if TYPE_CHECKING:\n from requests import Session\n", + "if TYPE_CHECKING:\n from opentelemetry.agent._vendor.requests import Session\n", + id="type-checking", + ), + pytest.param( + "from os import path\nfrom urllib3 import a\n", + "from os import path\nfrom opentelemetry.agent._vendor.urllib3 import a\n", + id="mixed", + ), + pytest.param( + "import urllib3\n", + "from opentelemetry.agent._vendor import urllib3\n", + id="import", + ), + pytest.param( + "import urllib3 as u3\n", + "from opentelemetry.agent._vendor import urllib3 as u3\n", + id="import-as", + ), + pytest.param( + "import urllib3.contrib.socks\n", + "import opentelemetry.agent._vendor.urllib3.contrib.socks\nfrom opentelemetry.agent._vendor import urllib3\n", + id="import-submodule", + ), + pytest.param( + "import urllib3.contrib.socks as socks\n", + "from opentelemetry.agent._vendor.urllib3.contrib import socks as socks\n", + id="import-submodule-as", + ), + pytest.param( + "import urllib3, os\n", + "from opentelemetry.agent._vendor import urllib3\nimport os\n", + id="import-multiple", + ), + pytest.param( + "import urllib3.util\nimport urllib3.exceptions\n", + "import opentelemetry.agent._vendor.urllib3.util\n" + "from opentelemetry.agent._vendor import urllib3\n" + "import opentelemetry.agent._vendor.urllib3.exceptions\n" + "from opentelemetry.agent._vendor import urllib3\n", + id="import-submodules-repeat-binding", + ), + pytest.param( + "def f():\n import idna\n return idna\n", + "def f():\n from opentelemetry.agent._vendor import idna\n return idna\n", + id="import-function", + ), + pytest.param( + "def f():\n try:\n import idna\n except ImportError:\n pass\n", + "def f():\n try:\n from opentelemetry.agent._vendor import idna\n except ImportError:\n pass\n", + id="import-try", + ), + pytest.param( + "try: import idna, urllib3.util\nexcept ImportError: pass\n", + "try: from opentelemetry.agent._vendor import idna; import opentelemetry.agent._vendor.urllib3.util; " + "from opentelemetry.agent._vendor import urllib3\nexcept ImportError: pass\n", + id="import-one-line-suite", + ), + pytest.param( + "import urllib3.util; x = 1\n", + "import opentelemetry.agent._vendor.urllib3.util\nfrom opentelemetry.agent._vendor import urllib3\nx = 1\n", + id="import-semicolon-line-split", + ), + pytest.param( + "x = 1\n\n# leading\nimport os, idna # trailing\n", + "x = 1\n\n# leading\nimport os\nfrom opentelemetry.agent._vendor import idna # trailing\n", + id="import-comments", + ), + pytest.param( + "if True:\n import urllib3.util # trailing\n y = 2\n", + "if True:\n import opentelemetry.agent._vendor.urllib3.util\n" + " from opentelemetry.agent._vendor import urllib3 # trailing\n y = 2\n", + id="import-indented-split", + ), + ], +) +def test_rewrite_source(source: str, expected: str) -> None: + assert rewrite_source(source, NAMESPACE, NAMES) == expected + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from . import util\n", id="relative-package"), + pytest.param("from .urllib3 import a\n", id="relative-vendored-name"), + pytest.param("from ..requests import a\n", id="relative-parent"), + pytest.param("from os import path\n", id="unrelated"), + pytest.param("from requestsx import a\n", id="prefix-collision"), + pytest.param("from __future__ import annotations\n", id="future"), + pytest.param("import os, sys\n", id="import-unrelated"), + pytest.param("import requestsx\nimport requestsx.a\n", id="import-prefix-collision"), + pytest.param("import opentelemetry.agent._vendor.urllib3.util\n", id="import-already-vendored"), + pytest.param('"""\nimport urllib3\n"""\n# import urllib3\n', id="docstring-and-comment"), + pytest.param("from opentelemetry.agent._vendor.urllib3 import a\n", id="already-vendored"), + ], +) +def test_rewrite_source_unchanged(source: str) -> None: + assert rewrite_source(source, NAMESPACE, NAMES) == source + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param( + "from opentelemetry import trace\n", + "from opentelemetry.agent._vendor.opentelemetry import trace\n", + id="from-import", + ), + pytest.param( + "import opentelemetry.trace\n", + "import opentelemetry.agent._vendor.opentelemetry.trace\n" + "from opentelemetry.agent._vendor import opentelemetry\n", + id="import-submodule", + ), + pytest.param( + # ``import opentelemetry.agent._vendor.urllib3.util`` re-binds ``opentelemetry`` to the real package. + "import urllib3.util\n", + "import opentelemetry.agent._vendor.urllib3.util\n" + "from opentelemetry.agent._vendor import urllib3\n" + "from opentelemetry.agent._vendor import opentelemetry\n", + id="import-other-submodule-rebinds-top-level", + ), + pytest.param( + "from opentelemetry.agent._vendor.opentelemetry import trace\n", + "from opentelemetry.agent._vendor.opentelemetry import trace\n", + id="already-vendored", + ), + pytest.param( + "import logging\nlogging.getLogger('opentelemetry.agent._vendor.opentelemetry.sdk')\n", + "import logging\nlogging.getLogger('opentelemetry.agent._vendor.opentelemetry.sdk')\n", + id="logger-already-vendored", + ), + ], +) +def test_rewrite_source_namespace_shares_top_level_name(source: str, expected: str) -> None: + # The namespace starts with the vendored top-level name ``opentelemetry``. + assert rewrite_source(source, NAMESPACE, NAMES | {"opentelemetry"}) == expected + + +def test_rewritten_submodule_import_binds_vendored_package(tmp_path: Path) -> None: + # A vendored tree whose namespace starts with the vendored top-level name, as for the agent. + for file in ("ns/__init__.py", "ns/_vendor/__init__.py", "ns/_vendor/ns/__init__.py", "ns/_vendor/ns/sub.py"): + (tmp_path / file).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / file).write_text("") + (tmp_path / "ns/_vendor/ns/sub.py").write_text("VALUE = 1\n") + source = rewrite_source("import ns.sub\nresult = ns.sub.VALUE\n", "ns._vendor", {"ns"}) + (tmp_path / "mod.py").write_text(source) + + namespace: dict[str, object] = {} + sys.path.insert(0, str(tmp_path)) + try: + exec(compile(source, str(tmp_path / "mod.py"), "exec"), namespace) + finally: + sys.path.remove(str(tmp_path)) + for module in [name for name in sys.modules if name == "ns" or name.startswith("ns.")]: + del sys.modules[module] + + assert namespace["result"] == 1 + assert getattr(namespace["ns"], "__name__", None) == "ns._vendor.ns" + + +def test_rewrite_imports(tmp_path: Path) -> None: + (tmp_path / "urllib3" / "util").mkdir(parents=True) + (tmp_path / "__init__.py").write_text("") + (tmp_path / "urllib3" / "__init__.py").write_text("from .util import a\n") + (tmp_path / "urllib3" / "util" / "retry.py").write_bytes( + b"# -*- coding: latin-1 -*-\r\nfrom urllib3 import a\r\nfrom urllib3.exceptions import b\r\ns = '\xe9'\r\n" + ) + (tmp_path / "requests.py").write_text("from urllib3 import c\n") + (tmp_path / "notes.txt").write_text("from urllib3 import d\n") + + result = rewrite_imports(tmp_path, NAMESPACE, NAMES) + + assert result == RewriteResult(files=2, imports=3) + assert (tmp_path / "urllib3" / "util" / "retry.py").read_bytes() == ( + b"# -*- coding: latin-1 -*-\r\n" + b"from opentelemetry.agent._vendor.urllib3 import a\r\n" + b"from opentelemetry.agent._vendor.urllib3.exceptions import b\r\n" + b"s = '\xe9'\r\n" + ) + assert (tmp_path / "requests.py").read_text() == "from opentelemetry.agent._vendor.urllib3 import c\n" + assert (tmp_path / "urllib3" / "__init__.py").read_text() == "from .util import a\n" + assert (tmp_path / "notes.txt").read_text() == "from urllib3 import d\n" + + +def test_rewrite_imports_leaves_unchanged_files_untouched(tmp_path: Path) -> None: + path = tmp_path / "module.py" + path.write_text("from os import path\n") + mtime = path.stat().st_mtime_ns + + result = rewrite_imports(tmp_path, NAMESPACE, NAMES) + + assert result == RewriteResult(files=0, imports=0) + assert path.stat().st_mtime_ns == mtime + + +def test_rewrite_imports_counts_module_names(tmp_path: Path) -> None: + (tmp_path / "module.py").write_text("import urllib3, idna, os\nfrom urllib3 import a, b\n") + + result = rewrite_imports(tmp_path, NAMESPACE, NAMES) + + assert result == RewriteResult(files=1, imports=3) + + +def test_rewrite_imports_reports_unparsable_file(tmp_path: Path) -> None: + path = tmp_path / "broken.py" + path.write_text("from urllib3 import (\n") + + with pytest.raises(VendorError, match="cannot parse .*broken.py"): + rewrite_imports(tmp_path, NAMESPACE, NAMES) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param( + 'import logging\nlogger = logging.getLogger("urllib3")\n', + 'import logging\nlogger = logging.getLogger("opentelemetry.agent._vendor.urllib3")\n', + id="get-logger", + ), + pytest.param( + "import logging\nlogging.getLogger('idna.core').addHandler(h)\n", + "import logging\nlogging.getLogger('opentelemetry.agent._vendor.idna.core').addHandler(h)\n", + id="get-logger-submodule-quote-style", + ), + pytest.param( + 'from logging import getLogger as gl\ngl("requests", x)\n', + 'from logging import getLogger as gl\ngl("opentelemetry.agent._vendor.requests", x)\n', + id="get-logger-alias", + ), + pytest.param( + 'def f(name: str = "urllib3", level=1):\n pass\n', + 'def f(name: str = "opentelemetry.agent._vendor.urllib3", level=1):\n pass\n', + id="parameter-default", + ), + ], +) +def test_rewrite_source_logger_names(source: str, expected: str) -> None: + assert rewrite_source(source, NAMESPACE, NAMES) == expected + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("import logging\nlogging.getLogger(__name__)\n", id="dunder-name"), + pytest.param('import logging\nlogging.getLogger("urllib3x")\n', id="prefix-collision"), + pytest.param('import logging\nlogging.getLogger("urllib3 pool")\n', id="not-a-dotted-name"), + pytest.param('import logging\nlogging.getLogger(b"urllib3")\n', id="bytes"), + pytest.param('import logging\nlogging.getLogger(name="urllib3")\n', id="keyword"), + pytest.param('getLogger("urllib3")\n', id="get-logger-not-imported"), + pytest.param('print("urllib3")\nx = {"urllib3": 1}\n', id="other-strings"), + pytest.param('def f(x="urllib3 is great"):\n pass\n', id="parameter-default-sentence"), + pytest.param('"""urllib3 docs"""\n', id="docstring"), + ], +) +def test_rewrite_source_logger_names_unchanged(source: str) -> None: + assert rewrite_source(source, NAMESPACE, NAMES) == source + + +def test_rewrite_imports_counts_logger_names(tmp_path: Path) -> None: + (tmp_path / "module.py").write_text( + 'import logging\nimport urllib3\nlogging.getLogger("urllib3")\ndef f(name="idna"): pass\n' + ) + (tmp_path / "loggers_only.py").write_text('import logging\nlogging.getLogger("requests")\n') + + result = rewrite_imports(tmp_path, NAMESPACE, NAMES) + + assert result == RewriteResult(files=2, imports=1, loggers=3) + + +PB2_HEADER = "from google.protobuf.internal import builder as _builder\n" + + +@pytest.mark.parametrize( + ("call", "expected"), + [ + pytest.param( + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.any_pb2', _globals)", + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'opentelemetry.agent._vendor.google.protobuf.any_pb2', _globals)", + id="generated-module-name", + ), + pytest.param( + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'my_protos.trace_pb2', _globals)", + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'my_protos.trace_pb2', _globals)", + id="non-vendored-module", + ), + pytest.param( + "_builder.BuildTopDescriptorsAndMessages('google.protobuf', DESCRIPTOR, _globals)", + "_builder.BuildTopDescriptorsAndMessages('google.protobuf', DESCRIPTOR, _globals)", + id="first-argument", + ), + pytest.param( + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, module_name, _globals)", + "_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, module_name, _globals)", + id="variable", + ), + ], +) +def test_rewrite_source_protobuf_module_names(call: str, expected: str) -> None: + names = NAMES | {"google"} + expected_header = "from opentelemetry.agent._vendor.google.protobuf.internal import builder as _builder\n" + + assert rewrite_source(f"{PB2_HEADER}{call}\n", NAMESPACE, names) == f"{expected_header}{expected}\n" + + +def test_rewrite_source_protobuf_builder_not_imported() -> None: + source = "def BuildTopDescriptorsAndMessages(*args): pass\nBuildTopDescriptorsAndMessages(D, 'google.x_pb2', g)\n" + + assert rewrite_source(source, NAMESPACE, NAMES | {"google"}) == source + + +def test_rewrite_imports_counts_protobuf_module_names(tmp_path: Path) -> None: + (tmp_path / "any_pb2.py").write_text( + f"{PB2_HEADER}_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.any_pb2', _globals)\n" + ) + + result = rewrite_imports(tmp_path, NAMESPACE, NAMES | {"google"}) + + assert result == RewriteResult(files=1, imports=1, modules=1) diff --git a/agent/opentelemetry-vendor/tests/test_sbom.py b/agent/opentelemetry-vendor/tests/test_sbom.py new file mode 100644 index 0000000000..01e2316c11 --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_sbom.py @@ -0,0 +1,81 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from opentelemetry.vendor.metadata import VendoredPackage +from opentelemetry.vendor.patches import VendorPatch +from opentelemetry.vendor.sbom import write_sbom + + +def _package(name: str, version: str, license_expression: str | None) -> VendoredPackage: + return VendoredPackage( + name=name, + version=version, + sha256=hashlib.sha256(name.encode()).hexdigest(), + license_expression=license_expression, + top_level_names=(name,), + license_files={}, + dist_info=Path(f"{name}-{version}.dist-info"), + ) + + +PACKAGES = [ + _package("urllib3", "2.8.0", "MIT"), + _package("certifi", "2026.10.23", "MPL-2.0"), + _package("idna", "3.20", "BSD-3-Clause OR MIT"), + _package("legacy", "1.0", "MIT License"), + _package("unknown", "0.1", None), +] + + +def test_write_sbom(tmp_path: Path) -> None: + path = tmp_path / "nested/bom.cdx.json" + + write_sbom(PACKAGES, path) + + bom = json.loads(path.read_text()) + assert bom["bomFormat"] == "CycloneDX" + assert bom["specVersion"] == "1.6" + assert "timestamp" not in bom["metadata"] + components = {c["name"]: c for c in bom["components"]} + assert list(components) == ["certifi", "idna", "legacy", "unknown", "urllib3"] + assert components["urllib3"]["purl"] == "pkg:pypi/urllib3@2.8.0" + assert components["urllib3"]["version"] == "2.8.0" + assert components["urllib3"]["type"] == "library" + assert components["urllib3"]["hashes"] == [{"alg": "SHA-256", "content": hashlib.sha256(b"urllib3").hexdigest()}] + assert components["urllib3"]["licenses"] == [{"license": {"id": "MIT"}}] + assert components["idna"]["licenses"] == [{"expression": "BSD-3-Clause OR MIT"}] + assert components["legacy"]["licenses"] == [{"license": {"name": "MIT License"}}] + assert "licenses" not in components["unknown"] + + +def test_write_sbom_is_deterministic(tmp_path: Path) -> None: + first, second = tmp_path / "first.json", tmp_path / "second.json" + + write_sbom(PACKAGES, first) + write_sbom(list(reversed(PACKAGES)), second) + + assert first.read_bytes() == second.read_bytes() + + +def test_write_sbom_records_patches(tmp_path: Path) -> None: + unpatched, patched = tmp_path / "unpatched.json", tmp_path / "patched.json" + text = "Disable aliases.\n\ndiff --git a/urllib3/a.py b/urllib3/a.py\n" + patch = VendorPatch(path=Path("urllib3.patch"), text=text, files=("urllib3/a.py",)) + + write_sbom(PACKAGES, unpatched) + write_sbom(PACKAGES, patched, {"urllib3": [patch]}) + + unpatched_bom, patched_bom = json.loads(unpatched.read_text()), json.loads(patched.read_text()) + components = {c["name"]: c for c in patched_bom["components"]} + assert components["urllib3"]["pedigree"] == { + "patches": [{"type": "unofficial", "diff": {"text": {"content": text, "contentType": "text/plain"}}}] + } + assert "pedigree" not in components["idna"] + assert all("pedigree" not in c for c in unpatched_bom["components"]) + assert patched_bom["serialNumber"] != unpatched_bom["serialNumber"] diff --git a/agent/opentelemetry-vendor/tests/test_sync.py b/agent/opentelemetry-vendor/tests/test_sync.py new file mode 100644 index 0000000000..8826dc5e6e --- /dev/null +++ b/agent/opentelemetry-vendor/tests/test_sync.py @@ -0,0 +1,126 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from opentelemetry.vendor.errors import VendorError +from opentelemetry.vendor.sync import clean_destination, drop_paths, read_requirements + + +def test_clean_destination_keeps_protected_files(tmp_path: Path) -> None: + destination = tmp_path / "_vendor" + destination.mkdir() + (destination / "__init__.py").write_text("") + (destination / "requirements.txt").write_text("requests==1.0\n") + (destination / "six.py").write_text("") + package = destination / "requests" + package.mkdir() + (package / "__init__.py").write_text("") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("") + (destination / "link").symlink_to(outside, target_is_directory=True) + + removed = clean_destination(destination, ["__init__.py", "requirements.txt"]) + + assert sorted(p.name for p in destination.iterdir()) == [ + "__init__.py", + "requirements.txt", + ] + assert (destination / "requirements.txt").read_text() == "requests==1.0\n" + assert [p.name for p in removed] == ["link", "requests", "six.py"] + # The symlink is removed without following it. + assert (outside / "keep.txt").exists() + + +def test_clean_destination_creates_missing_directory(tmp_path: Path) -> None: + destination = tmp_path / "a" / "_vendor" + + clean_destination(destination, []) + + assert destination.is_dir() + + +def _make_tree(root: Path, files: list[str]) -> None: + for file in files: + (root / file).parent.mkdir(parents=True, exist_ok=True) + (root / file).write_text("") + + +def test_drop_paths(tmp_path: Path) -> None: + _make_tree( + tmp_path, + [ + "wrapt/__init__.py", + "wrapt/py.typed", + "wrapt-stubs/__init__.pyi", + "wrapt-stubs/py.typed", + "google/protobuf/py.typed", + "wrapt-2.0.dist-info/METADATA", + ], + ) + + removed = drop_paths(tmp_path, ["*-stubs", "**/py.typed"]) + + # wrapt-stubs/py.typed matches too, but is removed with its parent. + assert [p.relative_to(tmp_path).as_posix() for p in removed] == [ + "google/protobuf/py.typed", + "wrapt/py.typed", + "wrapt-stubs", + ] + assert (tmp_path / "wrapt/__init__.py").exists() + assert (tmp_path / "wrapt-2.0.dist-info/METADATA").exists() + + +def test_drop_paths_unmatched_pattern(tmp_path: Path) -> None: + _make_tree(tmp_path, ["wrapt/__init__.py"]) + + with pytest.raises(VendorError, match="'missing' matched nothing"): + drop_paths(tmp_path, ["missing"]) + + +def test_drop_paths_ignores_dist_info(tmp_path: Path) -> None: + _make_tree(tmp_path, ["wrapt-2.0.dist-info/METADATA"]) + + with pytest.raises(VendorError, match="matched nothing"): + drop_paths(tmp_path, ["**/METADATA"]) + + assert (tmp_path / "wrapt-2.0.dist-info/METADATA").exists() + + +def test_read_requirements(tmp_path: Path) -> None: + requirements = tmp_path / "requirements.txt" + requirements.write_text( + "# This file was autogenerated by uv\n" + "--index-url https://example.invalid/simple\n" + "certifi==2026.10.23 \\\n" + " --hash=sha256:aaa \\\n" + " --hash=sha256:bbb\n" + " # via requests\n" + "\n" + "idna==3.20 --hash=sha256:ccc # trailing comment\n" + ) + + assert read_requirements(requirements) == [ + "certifi==2026.10.23 --hash=sha256:aaa --hash=sha256:bbb", + "idna==3.20 --hash=sha256:ccc", + ] + + +def test_read_requirements_requires_hashes(tmp_path: Path) -> None: + requirements = tmp_path / "requirements.txt" + requirements.write_text("certifi==1.0 --hash=sha256:aaa\ncharset-normalizer==3.5.2 ; python_version >= '3.10'\n") + + with pytest.raises( + VendorError, match=r"requirement 'charset-normalizer' in .* has no --hash; compile it with --generate-hashes" + ): + read_requirements(requirements) + + +def test_read_requirements_missing(tmp_path: Path) -> None: + with pytest.raises(VendorError, match="does not exist"): + read_requirements(tmp_path / "requirements.txt") diff --git a/pyproject.toml b/pyproject.toml index eb2b5d5f46..c3d911c381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ "opentelemetry-util-genai", "opentelemetry-instrumentation-vertexai[instruments]", "opentelemetry-instrumentation-openai-v2[instruments]", + "opentelemetry-vendor", ] @@ -138,6 +139,8 @@ opentelemetry-util-http = { workspace = true } opentelemetry-util-genai = { workspace = true } opentelemetry-instrumentation-vertexai = { workspace = true } opentelemetry-instrumentation-openai-v2 = { workspace = true } +opentelemetry-vendor = { workspace = true } +opentelemetry-agent = { workspace = true } # https://docs.astral.sh/uv/reference/settings/#workspace [tool.uv.workspace] @@ -149,6 +152,7 @@ members = [ "opentelemetry-distro", "propagator/*", "util/*", + "agent/*", ] [tool.ruff] diff --git a/tox.ini b/tox.ini index a9726bc083..b89b87dffb 100644 --- a/tox.ini +++ b/tox.ini @@ -355,6 +355,9 @@ envlist = pypy3-test-util-http lint-util-http + ; opentelemetry-agent + py3{10,11,12,13,14}-test-agent-{oldest,latest} + ; opentelemetry-exporter-credential-provider-gcp py3{10,11,12,13,14}-test-exporter-credential-provider-gcp lint-exporter-credential-provider-gcp @@ -759,6 +762,12 @@ deps = util-http: -r {toxinidir}/util/opentelemetry-util-http/test-requirements.txt util-http: {toxinidir}/util/opentelemetry-util-http + ; The application's opentelemetry-api, bridged to the agent's vendored SDK. + agent-oldest: -r {toxinidir}/agent/opentelemetry-agent/tests/requirements.oldest.txt + agent-latest: {[testenv]test_deps} + agent-latest: -r {toxinidir}/agent/opentelemetry-agent/tests/requirements.latest.txt + agent: {toxinidir}/agent/opentelemetry-agent + exporter-credential-provider-gcp: -r {toxinidir}/exporter/opentelemetry-exporter-credential-provider-gcp/test-requirements.txt exporter-credential-provider-gcp: {toxinidir}/exporter/opentelemetry-exporter-credential-provider-gcp @@ -968,6 +977,8 @@ commands = test-util-http: pytest {toxinidir}/util/opentelemetry-util-http/tests {posargs} lint-util-http: sh -c "cd util && pylint --rcfile ../.pylintrc opentelemetry-util-http" + test-agent: pytest {toxinidir}/agent/opentelemetry-agent/tests {posargs} + test-exporter-credential-provider-gcp: pytest {toxinidir}/exporter/opentelemetry-exporter-credential-provider-gcp/tests {posargs} lint-exporter-credential-provider-gcp: sh -c "cd exporter && pylint --rcfile ../.pylintrc opentelemetry-exporter-credential-provider-gcp" diff --git a/uv.lock b/uv.lock index 516cff18b1..c517e223b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -11,6 +11,7 @@ resolution-markers = [ [manifest] members = [ + "opentelemetry-agent", "opentelemetry-distro", "opentelemetry-exporter-credential-provider-gcp", "opentelemetry-exporter-prometheus-remote-write", @@ -76,6 +77,7 @@ members = [ "opentelemetry-python-contrib", "opentelemetry-util-genai", "opentelemetry-util-http", + "opentelemetry-vendor", ] [[package]] @@ -443,6 +445,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -592,6 +607,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + [[package]] name = "boto3" version = "1.43.0" @@ -1169,6 +1193,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[package.optional-dependencies] +json-validation = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "referencing" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1383,6 +1438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "fqdn" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/01/248d44fbda9e78fa1a7b5a475194bef4c4a3e285db286419f3bb42f2efdf/fqdn-1.6.0.tar.gz", hash = "sha256:e39bf62e2a9481aa2cb780554fdfd944dd8ee5f230eb831684d539cbafed8de2", size = 130737, upload-time = "2026-09-28T05:43:35.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/d5/a8f9149b97cced9e1f4bff92ff0b6206120e2c853ae774d7020d6ecd8fa8/fqdn-1.6.0-py3-none-any.whl", hash = "sha256:6c793c312ccfa29981e1135b3ef5e1277ebe639731a6b0f7350d90e96e63f0b4", size = 12569, upload-time = "2026-09-28T05:43:33.951Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -2082,6 +2146,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, ] +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -2258,6 +2334,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonpointer" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/f6/d1109cbb2c7cb692b432071802028052ba8d275c613c1a1cce427bc62c97/jsonpointer-3.10.tar.gz", hash = "sha256:69349c0e738c93e9efa501397091f49ad302ae686293effd05d7755c06f88ef0", size = 19950, upload-time = "2026-11-06T11:39:07.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/66/25cbb926e981a849d1cfcd36817fba6bfa5e9b6a2944c41230fe83ff25d2/jsonpointer-3.10-py3-none-any.whl", hash = "sha256:3b08b9e5239f1b683e31c9561c350d75ce3fa8cdd407130ca8491fdb0c9e56f3", size = 10376, upload-time = "2026-11-06T11:39:06.069Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -2274,6 +2359,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -2329,6 +2427,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "legacy-cgi" version = "2.6.4" @@ -2338,6 +2445,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, ] +[[package]] +name = "libcst" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b3/23144dcd9f9cd6f6150058653ecd0357c6b09361656ebd286165a5d76d34/libcst-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5705f664266742b8d9323db714f58cf06a6cde739eb09fd9ca31cd233f9806e5", size = 2055037, upload-time = "2026-07-29T19:24:27.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/87/164a9bac39a817b505d27cf4490e6f66b2a1b8adb6b4e022b1ef4613bcfe/libcst-1.9.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7dcddfb4905c5ef90a6fdc58dccbc049faf0056b68161042fc1e0526b20cba05", size = 2210166, upload-time = "2026-07-29T19:24:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/b5aeaefbe471c4225c145c15c3bac96d67b0641a5e3ec9b871c62858c88c/libcst-1.9.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cc84aee3c293a012db722aa2d6ea87be524646852684a5a4e54c1fb98ea9072f", size = 2260568, upload-time = "2026-07-29T19:24:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0f/351dcd23629732e35e716e13ad536a193124cda075ac5e59dc866503447b/libcst-1.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:329ffda6b0ef708ff22876f982ec886a509c8f73ac1ccb0190693d35cb09aa0f", size = 2276024, upload-time = "2026-07-29T19:24:32.393Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/0da2b60137a6290ba7dda902b32da64d46c9a5107aa002da578f8b1ff8c8/libcst-1.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5302fc698ef863e1a2fb540e95793b3d5a96abc89aa6423ee9c389d7d7ec5eab", size = 2384413, upload-time = "2026-07-29T19:24:33.983Z" }, + { url = "https://files.pythonhosted.org/packages/8f/80/7e48d2fc1afda13a6de50ab6f8340dbd104fd6c9bcd2ff11dec66e320f2c/libcst-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6615b036694f6906e30dc018b5b34898cff7ff760ab549f9dea1ada4f53495b", size = 2110204, upload-time = "2026-07-29T19:24:35.491Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/52c88c7ba873011526aa35b8fa0cf55d50effe967c999e7cb688155c5691/libcst-1.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:dacc583bcd8db56e457ae559b910673c6b5df66f694d1f1154ce0b3982158d17", size = 1988757, upload-time = "2026-07-29T19:24:37.053Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/b45e6950878d9143f95db609432031ac688b311aa14450a8df239c648c45/libcst-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cdbafbc992b38ea005ef37e20243c4177879020c82d414789fbb7fa22d6d3a9", size = 2054911, upload-time = "2026-07-29T19:24:38.542Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d8/1c1ae22e05fbc763dc5a4e4c3cab356166913ef19d2c2da42e6f1be5c98c/libcst-1.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3f735e3acb3afc75d50803fde1598b467bf05d12d6854025df4398aebd75a14c", size = 2209147, upload-time = "2026-07-29T19:24:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/84/e1/0d5eaeac4dadd34fcd9ea154d0a6e5ce10c9fec0e02c73f807a16475b8b0/libcst-1.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:951f13c193e590fc88c9582a5341a130fe53ecece51043cff4bd54e25a5e022d", size = 2260355, upload-time = "2026-07-29T19:24:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/45ce33c6bca5c1961a90f2999138741daa0dc8cf6fe8b122c0ca2502632d/libcst-1.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc6aa3e7f21315dbe9868c0d13ffaac309514e8c816320c5f91230fa5b21df92", size = 2275659, upload-time = "2026-07-29T19:24:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b1/936d2f17d93fb160579ff56ca671d26154209bbc07ea8645eb11db1f9633/libcst-1.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f101e0bf8638eba80694ecc3c3abb5309eb488f5271427536cb379c5c1e0ba9e", size = 2383467, upload-time = "2026-07-29T19:24:44.397Z" }, + { url = "https://files.pythonhosted.org/packages/e7/59/db667f15d62cb22c75b24f6b13c4151292aa1889a64ff9abce92f60d5a97/libcst-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:77d9303572cf0fbd0a2eb71aff167a4246541f43bf58b781b35b9d0bbab80ddc", size = 2109985, upload-time = "2026-07-29T19:24:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a8/99926865cd3f97c0cffc71cd38152c883b7ae666eb8327d355f01bddb33a/libcst-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:4090105fa4ad114766d0f8b34418d9a3b359bc8645da860a447e19770b5705d1", size = 1988559, upload-time = "2026-07-29T19:24:47.528Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7a/a4dba5f11faf12a12ffba06d18019987851aab33b590242a602ffd4fb1bd/libcst-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b755ed4a4bc2faee849b54820023137d60d4c199e0a0822f0ff0c1bc49e49b48", size = 2103462, upload-time = "2026-07-29T19:24:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/57cb129093e0d6744b3c914ba6cbbdf51ed1b2c823882936c553a3a0cf1b/libcst-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e50576bad7d56459d9792cd0b0dfe5469dad13646e9a9c0a8b1ba20b269f332", size = 1979825, upload-time = "2026-07-29T19:24:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b1/befc0544283bb3923a928accf79ed685e5a725524bdb3491826670affc07/libcst-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8b9df30f524317b097dc53065b25dda33d6a4cc3c7c8bf4fc83ca7559c58cc0", size = 2043754, upload-time = "2026-07-29T19:24:59.885Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/fef7c172a8457c95894edf5fb04805024899cdfea41fa01b0636587b79e1/libcst-1.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e465a7bc9c2b9533eb9e06d2391f8819f811b5112919d4064c9fb8565aaafa08", size = 2203548, upload-time = "2026-07-29T19:25:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/764cd2be1fd99d774fc44039c319dc0ed1d9d9afeaa02759a05121cccd4b/libcst-1.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8504b422c95676a8c27b517e1ac01413ece91bf356865c587ca9bdcd5708a2f7", size = 2255274, upload-time = "2026-07-29T19:25:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/34/a7/474748a27a02fa83e3556b260d5f5236ca48164fa7beffecf3b2cad24dca/libcst-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bcb9f9d4fcfe2ec7a40d2c26e03a538d5d9dc189c38551eb3f94ab661afee7c0", size = 2269126, upload-time = "2026-07-29T19:25:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/655e45363b8cf87b91e41e060b21c89e5316c7ef36eb14a1a498b27bb71e/libcst-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8d671c39a431c309476099b8ec811e412503ec0f4465f6fc907cb51c70e8e6b", size = 2378602, upload-time = "2026-07-29T19:25:06.424Z" }, + { url = "https://files.pythonhosted.org/packages/db/13/6da63f0902ece43bf9d737017251ad7ad06edd9d3c4e1856450403cb4473/libcst-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:4d382fba04077eb556a1ea4295a4482e192aa93639c5a07ba0885841965ea0c0", size = 2103486, upload-time = "2026-07-29T19:25:07.979Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/dccb1a55e38b4e47256a97242d61d2bef5366c744faf88fb087d4fa0f995/libcst-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:a621e261990148c1cfbe26c1798bd2375c131cf358a44a7a4659fc41c1a336e1", size = 1979907, upload-time = "2026-07-29T19:25:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/4943c71d90975bc59034a057dea346b365c276f308c1d31f5cf2bd85492d/libcst-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eccf4c57d273cdd3fe1c67b72cf9bb1bbd4547aa011824e96ccf5b7136057aa4", size = 2044529, upload-time = "2026-07-29T19:25:11.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1168b98c2a0f338be3a44753647baab051feaf6167cc887bf4447f8fd920/libcst-1.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32395244edfe6538e0ea2bf82051d60103d3f54861274805c4fb3745efb70a85", size = 2203519, upload-time = "2026-07-29T19:25:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/2eaa697a80f899bcf2245680bbfcc1e478eb3b3328c21d4b2880bdf00dac/libcst-1.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:444e84c76cd035cd2fe136838c1a524d34b08216521f6f5093df8a6f6cfa5799", size = 2255879, upload-time = "2026-07-29T19:25:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/793b9fd96dd52d202f5f2b88d7e54f05ebed767d1bb3b32395a1817bf6ab/libcst-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb5d0946f2b4c6711b5d69fe4f833b364f9e7a1a2b08f88b98619dc18975099a", size = 2269807, upload-time = "2026-07-29T19:25:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/1bc7aaea03971c45e8a885fed9fc1c73176dbbd00b0296a3cde9529bafd6/libcst-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45808c03528b3ad40b14095348a918e08d98c47b4a125d631cb78e817c0a5b16", size = 2378171, upload-time = "2026-07-29T19:25:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f7/bc49e367d2bc8817213594dd52cf6b2da2dbbda90b5382d60653999274ac/libcst-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:568288cdbfe3b4ca3ae4852cb0a439ff053dd54c841bc4995bf2b71238b5de40", size = 2177847, upload-time = "2026-07-29T19:25:19.35Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/4d81577a22e6d535d1a3409f3a1c6903e09036f0e17fc47f92169dbc3501/libcst-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:107593af46945593e7825821793393262bc4fa1d3ea24c3ed487b61269bbbdf8", size = 2058134, upload-time = "2026-07-29T19:25:20.769Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/c3f3a0e7a1a2adaa76e815e82a7814d6bfe7d49432276bba652248c68d0b/libcst-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6248cb07444ab9a6733a855737a9febed8b9adca51347019348b09a3ac7dfe9", size = 2036102, upload-time = "2026-07-29T19:25:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/af/2f5543255b2c7d749b966adeccc6bfb1652cf8b425afb913d0f3f025a865/libcst-1.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:496c24e0d3240bc7da45dae543aff3f5b6509978c39262d6b84ed2fb999dded2", size = 2194806, upload-time = "2026-07-29T19:25:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/03f4cddce426d005e208b39ea7b2d456e667cdee0f1891360f0fc8430f20/libcst-1.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ea490fa8540503db5f321f0268becab46eb50f710e8cec8041e241fd66f6874f", size = 2246762, upload-time = "2026-07-29T19:25:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/31/9d5fe1e43dc3dbcc74f70f3e0e73fcdd8d84effc1059d8b45974f0d0d2eb/libcst-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50ab94bb2524b419056d4003032b8c67102ac800f8d85b3c4260a01746d94dbb", size = 2259633, upload-time = "2026-07-29T19:25:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6752b28d88c3a19b3bc0b9e1838443b6760d5c862b4f4b37955402659e24/libcst-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a2faaf92500d0226358125630f5aab4758e8aad3f2d70a10892ec3c700781a54", size = 2368043, upload-time = "2026-07-29T19:25:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/e2/94/775825b2637f8ab05694b6a4b3802ae6783b4e799f9b58d2400c7e2d4369/libcst-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c7b548512db25af9c2997a95fa731bd6b6928ecbad6c0915d7482d8bb42d34f", size = 2176432, upload-time = "2026-07-29T19:25:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/88ad67427c6fd9db929e087912b0a540e5140e5cb77e7ca4170edaac8531/libcst-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:497d5329345f1f5df84e41b0bbd00204b64a2fd30dfe3cfaeaebca633a31e877", size = 2052931, upload-time = "2026-07-29T19:25:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/ad790b043a38b10cea13166c8dd716654ad8b227c20f12eebf6326191cd9/libcst-1.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a5068bf6114f6f4d79af7a6c80a28d1deb50441150ea1db13b5458ab34bec159", size = 2043987, upload-time = "2026-08-11T05:54:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/d3cd8275cc54ffc9817ade91442b3e6e9edd1a4518bd4e6dda13e3152dbe/libcst-1.9.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:7acfd18adcdd32dcf41ce676cf121002afcbe9ad2c72dc0c18d78465beedc228", size = 2204003, upload-time = "2026-08-11T05:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/87eddccb11d5d221d50ac4fff4b6e9b8d48c547028d01f7d0fbc5c8a1d75/libcst-1.9.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:86361e2b426bd1b403e52375703c8b764e226e571adcb30442510710681edbf0", size = 2256053, upload-time = "2026-08-11T05:54:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/a44126aeb9fca8b4e342e0cc803ea00ca8f6cd5712619a7897b3eba13797/libcst-1.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8c14abe844bec8b021111b3985474e49f5af799c020e8e40dedcac87c97a40c5", size = 2270576, upload-time = "2026-08-11T05:54:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/bb/51/3af3dd44b117d66486e0ed61e43a16b7fc8cc5c372e1dd4ca0e70b888d46/libcst-1.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8930971d2299b7006bb5d10c10038f44efb6da89d5bca2823595e31a9cdb96af", size = 2378573, upload-time = "2026-08-11T05:54:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e8/e545087d298dbf6494e84a8092f0f5dbd62eedacabd4ecd6b364367b4804/libcst-1.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:5891ce9cff815077614f509e3a23888c5ddb8081d6188e8ba1172c0ccc369022", size = 2177937, upload-time = "2026-08-11T05:54:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/93a00a8e03494a84db102dd0e3d35b8876b1084ca2c194b331a168d86eb1/libcst-1.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1260b5d070a3324447a53a00387225a55472a62077ce01922d30a2a78cc4b138", size = 2058633, upload-time = "2026-08-11T05:54:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/d7/00/c3751b12eb81822ea0b6131d374a98bc6c024c4b9ec5f6ea8f53dc23e967/libcst-1.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02ee2dbdb5c218116f16a021350fc267a14be205b50d81c33f5d73857ab6fbf7", size = 2036178, upload-time = "2026-08-11T05:54:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/48ff486eb5adc593f4818569e8896b74b672414ab4722a74b17e4c4cf31e/libcst-1.9.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:3e5b684191a0462b2d40a73261ea7f4da6a49e7a030b7e0fceca46bebb51dfe5", size = 2195496, upload-time = "2026-08-11T05:54:17.625Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/b13a4669864d41a4801fed7b86ede1049dc9a1e1dde250a7ee1f9c3b1285/libcst-1.9.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:2b150c4f298fe54eb0fe73abf71f57db74d070796140a8c206c9615b6861f01e", size = 2245769, upload-time = "2026-08-11T05:54:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c7/cf2d46744825e84afbd7228fdeeea8d23cf6c4b2074253c27efb7705c653/libcst-1.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:50b913e37187f00a6fb88962009364e1cf1b1284aa1762304f34b52b972f2218", size = 2260576, upload-time = "2026-08-11T05:54:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/5433d3d62250b2e4325938db101766bcab2a006819684713bcccb6259a88/libcst-1.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9f92c75283030fd58fb7d6e560168a00381e0e3368ec8a026a3ec8a828a05f93", size = 2368709, upload-time = "2026-08-11T05:54:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/87/39/9f0e1690727623f99489895db0e42048a3e1e8cea0627517c593e437fd83/libcst-1.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4adf97bb1aff8039b0bd4991e2f838be6e4fad552821b26b71a5d1a653c2582f", size = 2177866, upload-time = "2026-08-11T05:54:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/cd/79/9dc7811883e67ea771057e8937bc536eb3e77f3635fc5a93cd85b6fe1ea8/libcst-1.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8f0dd08a5773d7051e105250b2a2c73d8065ea9b2d68e7e1bdc5244660e75f93", size = 2052908, upload-time = "2026-08-11T05:54:26.835Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2804,6 +2991,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/2e/2e96ca6928951fe1d16744c22dc1355eda1dd5b0dd920ca1d3ab602929f8/openai_agents-0.17.7-py3-none-any.whl", hash = "sha256:51b5ae43756eea37032e430f95979ba3999af6b1ade397df6c0ffeaf1939646a", size = 856074, upload-time = "2026-06-24T05:15:31.741Z" }, ] +[[package]] +name = "opentelemetry-agent" +source = { editable = "agent/opentelemetry-agent" } + [[package]] name = "opentelemetry-api" version = "1.44.0.dev0" @@ -4392,6 +4583,7 @@ dependencies = [ { name = "opentelemetry-test-utils" }, { name = "opentelemetry-util-genai" }, { name = "opentelemetry-util-http" }, + { name = "opentelemetry-vendor" }, ] [package.dev-dependencies] @@ -4485,6 +4677,7 @@ requires-dist = [ { name = "opentelemetry-test-utils", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=tests%2Fopentelemetry-test-utils&branch=main" }, { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, { name = "opentelemetry-util-http", editable = "util/opentelemetry-util-http" }, + { name = "opentelemetry-vendor", editable = "agent/opentelemetry-vendor" }, ] [package.metadata.requires-dev] @@ -4571,6 +4764,40 @@ provides-extras = ["test", "upload"] name = "opentelemetry-util-http" source = { editable = "util/opentelemetry-util-http" } +[[package]] +name = "opentelemetry-vendor" +version = "0.0.0" +source = { editable = "agent/opentelemetry-vendor" } +dependencies = [ + { name = "cyclonedx-python-lib", extra = ["json-validation"] }, + { name = "libcst" }, + { name = "packageurl-python" }, + { name = "pip" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "cyclonedx-python-lib", extras = ["json-validation"], specifier = ">=11.0,<12.0" }, + { name = "libcst", specifier = ">=1.5,<2.0" }, + { name = "packageurl-python", specifier = ">=0.11,<1.0" }, + { name = "pip", specifier = ">=25.0,<27.0" }, + { name = "rich", specifier = ">=13.0,<16.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0,<3.0" }, + { name = "typer", specifier = ">=0.15,<1.0" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -4983,6 +5210,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] +[[package]] +name = "py-serializable" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/8c/cad9596066de835b304be48b0eefa30dae1834afae6e670805831cc05ea2/py_serializable-2.2.0.tar.gz", hash = "sha256:8e4fa02e8de819c0488ccdb69c2d3109274adcf224c02357fd8729cb12727aab", size = 52608, upload-time = "2026-11-06T13:43:39.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/56/94f6a9ac81193713e6be0d41c62d6b916b53c814c05d8b3a0d90976db0d6/py_serializable-2.2.0-py3-none-any.whl", hash = "sha256:2cce775abc5aac12115f9ff572a145c7276d04a770090334bd4e51ed9c782415", size = 23101, upload-time = "2026-11-06T13:43:38.593Z" }, +] + [[package]] name = "pyasn1" version = "0.6.5" @@ -5650,6 +5889,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "readme-renderer" version = "46.0" @@ -5757,6 +6020,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + [[package]] name = "rfc3986" version = "2.0.0" @@ -5766,6 +6041,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, ] +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -6113,6 +6409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -6131,6 +6436,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.51" @@ -6549,6 +6863,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] +[[package]] +name = "typer" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/8f/21021859316f44289101638dc74d82e13d722d8533e4b8708a3ab7c8461a/typer-0.28.0.tar.gz", hash = "sha256:69ab84ef79fd601cff071bbd2566d855ecadfd93adbc4b3360b9924359bdd857", size = 205415, upload-time = "2026-11-06T08:37:19.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/98/8ef60c7cd773dbab9d276a4c7bff505913a25b61acd8a9ef01ceeb70f554/typer-0.28.0-py3-none-any.whl", hash = "sha256:afd18929180b706c5e9a239f53c3c8136903553c0c2d460598bbaa924191d4ab", size = 123253, upload-time = "2026-11-06T08:37:18.611Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -6591,6 +6920,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" @@ -6698,6 +7036,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + [[package]] name = "webob" version = "1.8.11"