diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index d9f90d57d8..4e693695eb 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -1707,6 +1707,36 @@ jobs: - name: Run tests run: tox -e py310-test-instrumentation-requests -- -ra + py310-test-instrumentation-niquests: + name: instrumentation-niquests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-niquests -- -ra + py310-test-instrumentation-starlette-oldest: name: instrumentation-starlette-oldest runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6bf3c631cd..52459264f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -556,6 +556,25 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-requests + lint-instrumentation-niquests: + name: instrumentation-niquests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-instrumentation-niquests + lint-instrumentation-starlette: name: instrumentation-starlette runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c694007ea..0b32111474 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5686,6 +5686,101 @@ jobs: - name: Run tests run: tox -e py314-test-instrumentation-requests -- -ra + py310-test-instrumentation-niquests_ubuntu-latest: + name: instrumentation-niquests 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-niquests -- -ra + + py311-test-instrumentation-niquests_ubuntu-latest: + name: instrumentation-niquests 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-niquests -- -ra + + py312-test-instrumentation-niquests_ubuntu-latest: + name: instrumentation-niquests 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-niquests -- -ra + + py313-test-instrumentation-niquests_ubuntu-latest: + name: instrumentation-niquests 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-niquests -- -ra + + py314-test-instrumentation-niquests_ubuntu-latest: + name: instrumentation-niquests 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-niquests -- -ra + py310-test-instrumentation-starlette-oldest_ubuntu-latest: name: instrumentation-starlette-oldest 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 64da93791c..010ff04580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `opentelemetry-instrumentation-niquests`: Add instrumentation for the niquests HTTP client library with sync/async support, TLS span attributes, OCSP revocation status, and connection sub-duration histogram metrics + ([#4459](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4459)) - Bump `pylint` to `4.0.5` ([#4244](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4244)) - `opentelemetry-instrumentation-sqlite3`: Add uninstrument, error status, suppress, and no-op tests diff --git a/docs-requirements.txt b/docs-requirements.txt index 9d0be53704..3e98dbc123 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -32,6 +32,7 @@ flask~=2.0 falcon~=2.0 grpcio~=1.27 httpx>=0.18.0 +niquests>=3,<4 kafka-python>=2.0,<3.0 mysql-connector-python~=8.0 mysqlclient~=2.1.1 diff --git a/docs/instrumentation/niquests/niquests.rst b/docs/instrumentation/niquests/niquests.rst new file mode 100644 index 0000000000..c5bd37db42 --- /dev/null +++ b/docs/instrumentation/niquests/niquests.rst @@ -0,0 +1,7 @@ +OpenTelemetry niquests Instrumentation +====================================== + +.. automodule:: opentelemetry.instrumentation.niquests + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/README.md b/instrumentation/README.md index 8d9a247945..d169db5114 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -30,6 +30,7 @@ | [opentelemetry-instrumentation-logging](./opentelemetry-instrumentation-logging) | logging | No | development | [opentelemetry-instrumentation-mysql](./opentelemetry-instrumentation-mysql) | mysql-connector-python >= 8.0, < 10.0 | No | development | [opentelemetry-instrumentation-mysqlclient](./opentelemetry-instrumentation-mysqlclient) | mysqlclient < 3 | No | development +| [opentelemetry-instrumentation-niquests](./opentelemetry-instrumentation-niquests) | niquests >= 3.0, < 4 | Yes | migration | [opentelemetry-instrumentation-pika](./opentelemetry-instrumentation-pika) | pika >= 0.12.0 | No | development | [opentelemetry-instrumentation-psycopg](./opentelemetry-instrumentation-psycopg) | psycopg >= 3.1.0 | No | development | [opentelemetry-instrumentation-psycopg2](./opentelemetry-instrumentation-psycopg2) | psycopg2 >= 2.7.3.1,psycopg2-binary >= 2.7.3.1 | No | development diff --git a/instrumentation/opentelemetry-instrumentation-niquests/LICENSE b/instrumentation/opentelemetry-instrumentation-niquests/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-niquests/README.rst b/instrumentation/opentelemetry-instrumentation-niquests/README.rst new file mode 100644 index 0000000000..31ece0cfd2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/README.rst @@ -0,0 +1,48 @@ +OpenTelemetry Niquests Instrumentation +====================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-niquests.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-niquests/ + +This library allows tracing HTTP requests made by the +`niquests `_ library. + +Niquests is a drop-in replacement for the ``requests`` library with +native async support, HTTP/2, and HTTP/3 capabilities. Both synchronous +and asynchronous interfaces are instrumented. + +Lazy responses (e.g. ``Session(multiplexed=True)``) receive a span and +connection-level attributes (IP, TLS) but **status code and response headers +are not captured** because they are not yet available. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-niquests + +Configuration +------------- + +Exclude lists +************* +To exclude certain URLs from being tracked, set the environment variable ``OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS`` +(or ``OTEL_PYTHON_EXCLUDED_URLS`` as fallback) with comma delimited regexes representing which URLs to exclude. + +For example, + +:: + + export OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS="client/.*/info,healthcheck" + +will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``. + +References +---------- + +* `OpenTelemetry niquests Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml b/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml new file mode 100644 index 0000000000..8dc6c7ac94 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-niquests" +dynamic = ["version"] +description = "OpenTelemetry niquests instrumentation" +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", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "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 = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation == 0.63b0.dev", + "opentelemetry-semantic-conventions == 0.63b0.dev", + "opentelemetry-util-http == 0.63b0.dev", +] + +[project.optional-dependencies] +instruments = [ + "niquests >= 3.0, < 4", +] + +[project.entry-points.opentelemetry_instrumentor] +niquests = "opentelemetry.instrumentation.niquests:NiquestsInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-niquests" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/niquests/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py new file mode 100644 index 0000000000..add1f63f20 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/__init__.py @@ -0,0 +1,994 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This library allows tracing HTTP requests made by the +`niquests `_ library. + +Niquests is a drop-in replacement for the ``requests`` library with +native async support, HTTP/2, and HTTP/3 capabilities. + +Usage +----- + +.. code-block:: python + + import niquests + from opentelemetry.instrumentation.niquests import NiquestsInstrumentor + + # You can optionally pass a custom TracerProvider to instrument(). + NiquestsInstrumentor().instrument() + response = niquests.get(url="https://www.example.org/") + +Async usage: + +.. code-block:: python + + import niquests + from opentelemetry.instrumentation.niquests import NiquestsInstrumentor + + NiquestsInstrumentor().instrument() + + async with niquests.AsyncSession() as session: + response = await session.get("https://www.example.org/") + +Configuration +------------- + +Request/Response hooks +********************** + +The niquests instrumentation supports extending tracing behavior with the help of +request and response hooks. These are functions that are called back by the instrumentation +right after a Span is created for a request and right before the span is finished processing a response respectively. +The hooks can be configured as follows: + +.. code:: python + + import niquests + from opentelemetry.instrumentation.niquests import NiquestsInstrumentor + + # `request_obj` is an instance of niquests.PreparedRequest + def request_hook(span, request_obj): + pass + + # `request_obj` is an instance of niquests.PreparedRequest + # `response` is an instance of niquests.Response + def response_hook(span, request_obj, response): + pass + + NiquestsInstrumentor().instrument( + request_hook=request_hook, response_hook=response_hook + ) + +Capture HTTP request and response headers +***************************************** +You can configure the agent to capture specified HTTP headers as span attributes, according to the +`semantic conventions `_. + +Request headers +*************** +To capture HTTP request headers as span attributes, set the environment variable +``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to a comma delimited list of HTTP header names. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="content-type,custom_request_header" + +will extract ``content-type`` and ``custom_request_header`` from the request headers and add them as span attributes. + +Request header names in Niquests are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment +variable will capture the header named ``custom-header``. + +Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example: +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="Accept.*,X-.*" + +Would match all request headers that start with ``Accept`` and ``X-``. + +To capture all request headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to ``".*"``. +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*" + +The name of the added span attribute will follow the format ``http.request.header.`` where ```` +is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a +single item list containing all the header values. + +For example: +``http.request.header.custom_request_header = ["", ""]`` + +Response headers +**************** +To capture HTTP response headers as span attributes, set the environment variable +``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to a comma delimited list of HTTP header names. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="content-type,custom_response_header" + +will extract ``content-type`` and ``custom_response_header`` from the response headers and add them as span attributes. + +Response header names in Niquests are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment +variable will capture the header named ``custom-header``. + +Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example: +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="Content.*,X-.*" + +Would match all response headers that start with ``Content`` and ``X-``. + +To capture all response headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to ``".*"``. +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*" + +The name of the added span attribute will follow the format ``http.response.header.`` where ```` +is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a +list containing the header values. + +For example: +``http.response.header.custom_response_header = ["", ""]`` + +Sanitizing headers +****************** +In order to prevent storing sensitive data such as personally identifiable information (PII), session keys, passwords, +etc, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS`` +to a comma delimited list of HTTP header names to be sanitized. + +Regexes may be used, and all header names will be matched in a case-insensitive manner. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=".*session.*,set-cookie" + +will replace the value of headers such as ``session-id`` and ``set-cookie`` with ``[REDACTED]`` in the span. + +Note: + The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. + +Niquests-specific attributes +**************************** + +This instrumentation leverages niquests' ``conn_info`` metadata to provide +richer telemetry than what is available with plain ``requests``: + +Span attributes + ``tls.protocol.version`` -- TLS version (e.g. ``"1.3"``), from ``conn_info.tls_version``. + ``tls.cipher`` -- TLS cipher suite, from ``conn_info.cipher``. + ``revocation_verified`` -- boolean OCSP/CRL verification status, from ``response.ocsp_verified``. + +Connection sub-duration histogram metrics (seconds) + ``http.client.connection.dns.duration`` -- from ``conn_info.resolution_latency``. + ``http.client.connection.tcp.duration`` -- from ``conn_info.established_latency``. + ``http.client.connection.tls.duration`` -- from ``conn_info.tls_handshake_latency``. + ``http.client.request.send.duration`` -- from ``conn_info.request_sent_latency``. + +Custom Duration Histogram Boundaries +************************************ +To customize the duration histogram bucket boundaries used for HTTP client request duration metrics, +you can provide a list of values when instrumenting: + +.. code:: python + + import niquests + from opentelemetry.instrumentation.niquests import NiquestsInstrumentor + + custom_boundaries = [0.0, 5.0, 10.0, 25.0, 50.0, 100.0] + + NiquestsInstrumentor().instrument( + duration_histogram_boundaries=custom_boundaries + ) + +Exclude lists +************* +To exclude certain URLs from being tracked, set the environment variable ``OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS`` +(or ``OTEL_PYTHON_EXCLUDED_URLS`` as fallback) with comma delimited regexes representing which URLs to exclude. + +For example, + +:: + + export OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS="client/.*/info,healthcheck" + +will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``. + +API +--- +""" + +from __future__ import annotations + +import functools +import types +from timeit import default_timer +from typing import Any, Callable, Collection, Optional +from urllib.parse import urlparse + +from niquests import AsyncSession +from niquests.models import PreparedRequest, Response +from niquests.sessions import Session +from niquests.structures import CaseInsensitiveDict + +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + _client_duration_attrs_new, + _client_duration_attrs_old, + _filter_semconv_duration_attrs, + _get_schema_url, + _OpenTelemetrySemanticConventionStability, + _OpenTelemetryStabilitySignalType, + _report_new, + _report_old, + _set_http_host_client, + _set_http_method, + _set_http_net_peer_name_client, + _set_http_network_protocol_version, + _set_http_peer_port_client, + _set_http_scheme, + _set_http_url, + _set_status, + _StabilityMode, +) +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.niquests.package import _instruments +from opentelemetry.instrumentation.niquests.version import __version__ +from opentelemetry.instrumentation.utils import ( + is_http_instrumentation_enabled, + suppress_http_instrumentation, +) +from opentelemetry.metrics import Histogram, get_meter +from opentelemetry.propagate import inject +from opentelemetry.semconv._incubating.attributes.net_attributes import ( + NET_PEER_IP, +) +from opentelemetry.semconv._incubating.attributes.tls_attributes import ( + TLS_CIPHER, + TLS_PROTOCOL_VERSION, +) +from opentelemetry.semconv._incubating.attributes.user_agent_attributes import ( + USER_AGENT_SYNTHETIC_TYPE, +) +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, +) +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, +) +from opentelemetry.semconv.metrics import MetricInstruments +from opentelemetry.semconv.metrics.http_metrics import ( + HTTP_CLIENT_REQUEST_DURATION, +) +from opentelemetry.trace import SpanKind, Tracer, get_tracer +from opentelemetry.trace.span import Span +from opentelemetry.util.http import ( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, + ExcludeList, + detect_synthetic_user_agent, + get_custom_header_attributes, + get_custom_headers, + get_excluded_urls, + normalise_request_header_name, + normalise_response_header_name, + normalize_user_agent, + parse_excluded_urls, + redact_url, + sanitize_method, +) + +_excluded_urls_from_env = get_excluded_urls("NIQUESTS") + +_RequestHookT = Optional[Callable[[Span, PreparedRequest], None]] +_ResponseHookT = Optional[Callable[[Span, PreparedRequest, Response], None]] + + +def _set_http_status_code_attribute( + span, + status_code, + metric_attributes=None, + sem_conv_opt_in_mode=_StabilityMode.DEFAULT, +): + status_code_str = str(status_code) + try: + status_code = int(status_code) + except ValueError: + status_code = -1 + if metric_attributes is None: + metric_attributes = {} + # When we have durations we should set metrics only once + # Also the decision to include status code on a histogram should + # not be dependent on tracing decisions. + _set_status( + span, + metric_attributes, + status_code, + status_code_str, + server_span=False, + sem_conv_opt_in_mode=sem_conv_opt_in_mode, + ) + + +def _extract_http_version(result: Response) -> str | None: + """Extract the HTTP protocol version from a niquests Response. + + Niquests supports HTTP/1.1, HTTP/2, and HTTP/3. + Returns the version string without the ``HTTP/`` prefix, matching + the format expected by OpenTelemetry semantic conventions + (e.g. ``"1.1"``, ``"2"``, ``"3"``). + """ + raw_version_map = {11: "1.1", 20: "2", 30: "3"} + try: + return raw_version_map.get(result.http_version, None) + except ( + AttributeError + ): # Defensive: mocking utilities may omit http_version + return None + + +def _extract_tls_version(result: Response) -> str | None: + """Extract the TLS protocol version from a niquests Response via conn_info. + + Converts from Python's ``ssl.TLSVersion`` enum (e.g. ``TLSv1_3``) to the + OpenTelemetry semconv format (e.g. ``"1.3"``). + + Returns ``None`` when TLS was not used or the value is unavailable. + """ + if result.conn_info is None or result.conn_info.tls_version is None: + return None + name = result.conn_info.tls_version.name # e.g. "TLSv1_2", "TLSv1_3" + if name.startswith("TLSv"): + return name[4:].replace("_", ".") # "1_2" -> "1.2" + return None + + +def _extract_tls_cipher(result: Response) -> str | None: + """Extract the TLS cipher suite name from a niquests Response via conn_info. + + Returns ``None`` when TLS was not used or the value is unavailable. + """ + if result.conn_info is None: + return None + return result.conn_info.cipher + + +def _extract_ip_from_response(result: Response) -> tuple[str, int] | None: + """Extract destination IP and port from a niquests Response via conn_info. + + Returns (ip, port) tuple or None if not available. + """ + return result.conn_info.destination_address if result.conn_info else None + + +def _prepare_span_and_metric_attributes( + request: PreparedRequest, + sem_conv_opt_in_mode: _StabilityMode, + captured_request_headers: list[str] | None, + sensitive_headers: list[str] | None, +): + """Build span attributes and metric labels common to both sync and async paths.""" + method = request.method + span_name = get_default_span_name(method) + url = redact_url(request.url) + + span_attributes = {} + _set_http_method( + span_attributes, + method, + sanitize_method(method), + sem_conv_opt_in_mode, + ) + _set_http_url(span_attributes, url, sem_conv_opt_in_mode) + + # Check for synthetic user agent type + headers = ( + request.headers + if request.headers is not None + else CaseInsensitiveDict() + ) + request.headers = headers + user_agent_value = headers.get("User-Agent") + user_agent = normalize_user_agent(user_agent_value) + synthetic_type = detect_synthetic_user_agent(user_agent) + if synthetic_type: + span_attributes[USER_AGENT_SYNTHETIC_TYPE] = synthetic_type + if user_agent: + span_attributes[USER_AGENT_ORIGINAL] = user_agent + span_attributes.update( + get_custom_header_attributes( + headers, + captured_request_headers, + sensitive_headers, + normalise_request_header_name, + ) + ) + + metric_labels = {} + _set_http_method( + metric_labels, + method, + sanitize_method(method), + sem_conv_opt_in_mode, + ) + + try: + parsed_url = urlparse(url) + if parsed_url.scheme: + if _report_old(sem_conv_opt_in_mode): + _set_http_scheme( + metric_labels, parsed_url.scheme, sem_conv_opt_in_mode + ) + if parsed_url.hostname: + _set_http_host_client( + metric_labels, parsed_url.hostname, sem_conv_opt_in_mode + ) + _set_http_net_peer_name_client( + metric_labels, parsed_url.hostname, sem_conv_opt_in_mode + ) + if _report_new(sem_conv_opt_in_mode): + _set_http_host_client( + span_attributes, + parsed_url.hostname, + sem_conv_opt_in_mode, + ) + span_attributes[NETWORK_PEER_ADDRESS] = parsed_url.hostname + if parsed_url.port: + _set_http_peer_port_client( + metric_labels, parsed_url.port, sem_conv_opt_in_mode + ) + if _report_new(sem_conv_opt_in_mode): + _set_http_peer_port_client( + span_attributes, parsed_url.port, sem_conv_opt_in_mode + ) + span_attributes[NETWORK_PEER_PORT] = parsed_url.port + except ValueError: + pass + + return span_name, span_attributes, metric_labels, headers + + +def _apply_conn_info_attributes( + span: Span, + result: Response, + sem_conv_opt_in_mode: _StabilityMode, +): + """Set IP, TLS, and revocation span attributes from conn_info.""" + ip_info = _extract_ip_from_response(result) + if ip_info is not None: + ip_addr, ip_port = ip_info + if _report_old(sem_conv_opt_in_mode): + span.set_attribute(NET_PEER_IP, ip_addr) + if _report_new(sem_conv_opt_in_mode): + span.set_attribute(NETWORK_PEER_ADDRESS, ip_addr) + if ip_port: + span.set_attribute(NETWORK_PEER_PORT, ip_port) + + tls_version = _extract_tls_version(result) + if tls_version is not None: + span.set_attribute(TLS_PROTOCOL_VERSION, tls_version) + tls_cipher = _extract_tls_cipher(result) + if tls_cipher is not None: + span.set_attribute(TLS_CIPHER, tls_cipher) + + if result.ocsp_verified is not None: + span.set_attribute("tls.revocation.verified", result.ocsp_verified) + + +def _apply_response_attributes( + span: Span, + result: Response, + metric_labels: dict, + sem_conv_opt_in_mode: _StabilityMode, + captured_response_headers: list[str] | None, + sensitive_headers: list[str] | None, + response_hook: _ResponseHookT, + request: PreparedRequest, +): + """Apply response attributes to span and metric labels.""" + if isinstance(result, Response): + resp_span_attributes = {} + + if not result.lazy: + _set_http_status_code_attribute( + span, + result.status_code, + metric_labels, + sem_conv_opt_in_mode, + ) + + version_text = _extract_http_version(result) + + if version_text: + _set_http_network_protocol_version( + metric_labels, version_text, sem_conv_opt_in_mode + ) + if _report_new(sem_conv_opt_in_mode): + _set_http_network_protocol_version( + resp_span_attributes, + version_text, + sem_conv_opt_in_mode, + ) + + _apply_conn_info_attributes(span, result, sem_conv_opt_in_mode) + + if not result.lazy: + resp_span_attributes.update( + get_custom_header_attributes( + result.headers, + captured_response_headers, + sensitive_headers, + normalise_response_header_name, + ) + ) + + for key, val in resp_span_attributes.items(): + span.set_attribute(key, val) + + if callable(response_hook): + response_hook(span, request, result) + + +def _record_duration_metrics( + duration_histogram_old: Histogram | None, + duration_histogram_new: Histogram | None, + elapsed_time: float, + metric_labels: dict, +): + """Record duration metrics on the appropriate histograms.""" + if duration_histogram_old is not None: + duration_attrs_old = _filter_semconv_duration_attrs( + metric_labels, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.DEFAULT, + ) + duration_histogram_old.record( + max(round(elapsed_time * 1000), 0), + attributes=duration_attrs_old, + ) + if duration_histogram_new is not None: + duration_attrs_new = _filter_semconv_duration_attrs( + metric_labels, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + duration_histogram_new.record( + elapsed_time, attributes=duration_attrs_new + ) + + +# Metric names for niquests-specific connection sub-duration histograms. +# These are not part of the OTel HTTP semantic conventions; they are +# custom metrics that leverage niquests' conn_info timing data. +# NOTE: These use the ``http.client.`` prefix for discoverability. If OTel +# semconv later defines metrics with these exact names, this instrumentation +# must be updated to align with the official definitions. +_CONN_METRIC_DNS_RESOLUTION = "http.client.connection.dns.duration" +_CONN_METRIC_TCP_ESTABLISHMENT = "http.client.connection.tcp.duration" +_CONN_METRIC_TLS_HANDSHAKE = "http.client.connection.tls.duration" +_CONN_METRIC_REQUEST_SEND = "http.client.request.send.duration" + +# Maps conn_info attribute names to histogram metric names. +_CONN_INFO_LATENCY_FIELDS = { + "resolution_latency": _CONN_METRIC_DNS_RESOLUTION, + "established_latency": _CONN_METRIC_TCP_ESTABLISHMENT, + "tls_handshake_latency": _CONN_METRIC_TLS_HANDSHAKE, + "request_sent_latency": _CONN_METRIC_REQUEST_SEND, +} + +# Descriptions for each connection sub-duration histogram. +_CONN_HISTOGRAM_DESCRIPTIONS = { + _CONN_METRIC_DNS_RESOLUTION: "Duration of DNS resolution for HTTP client requests.", + _CONN_METRIC_TCP_ESTABLISHMENT: "Duration of TCP connection establishment for HTTP client requests.", + _CONN_METRIC_TLS_HANDSHAKE: "Duration of TLS handshake for HTTP client requests.", + _CONN_METRIC_REQUEST_SEND: "Duration of sending the HTTP request through the socket.", +} + + +def _record_connection_metrics( + connection_histograms: dict[str, Histogram] | None, + result: Response | None, + metric_labels: dict, +): + """Record connection sub-duration metrics from niquests conn_info. + + Each conn_info latency field is a ``datetime.timedelta``; we convert to + seconds (float) for the histograms. Fields that are ``None`` + (unavailable) or zero-duration (the phase did not occur, e.g. DNS + resolution on a reused connection) are silently skipped to avoid + polluting the histogram with meaningless data points. + """ + if connection_histograms is None or result is None: + return + if not isinstance(result, Response) or result.lazy is not False: + return + conn_info = result.conn_info + if conn_info is None: + return + + # Use the same metric labels as the main duration histograms. + # Filter to new-semconv attributes since these are new custom metrics. + attrs = _filter_semconv_duration_attrs( + metric_labels, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + + for field, metric_name in _CONN_INFO_LATENCY_FIELDS.items(): + histogram = connection_histograms.get(metric_name) + if histogram is None: + continue + latency = getattr(conn_info, field, None) + # Skip None (unavailable) and zero-duration (phase did not occur, + # e.g. DNS resolution on a reused connection). + if latency is not None and latency.total_seconds() > 0: + histogram.record(latency.total_seconds(), attributes=attrs) + + +# pylint: disable=unused-argument +# pylint: disable=R0915 +def _instrument( + tracer: Tracer, + duration_histogram_old: Histogram, + duration_histogram_new: Histogram, + request_hook: _RequestHookT = None, + response_hook: _ResponseHookT = None, + excluded_urls: ExcludeList | None = None, + sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, + captured_request_headers: list[str] | None = None, + captured_response_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, + connection_histograms: dict[str, Histogram] | None = None, +): + """Enables tracing of all niquests calls that go through + :code:`niquests.sessions.Session.send` and + :code:`niquests.async_session.AsyncSession.send`.""" + + wrapped_send = Session.send + + # pylint: disable-msg=too-many-locals,too-many-branches + @functools.wraps(wrapped_send) + def instrumented_send( + self: Session, request: PreparedRequest, **kwargs: Any + ): + if excluded_urls and excluded_urls.url_disabled(request.url): + return wrapped_send(self, request, **kwargs) + + if not is_http_instrumentation_enabled(): + return wrapped_send(self, request, **kwargs) + + span_name, span_attributes, metric_labels, headers = ( + _prepare_span_and_metric_attributes( + request, + sem_conv_opt_in_mode, + captured_request_headers, + sensitive_headers, + ) + ) + + with tracer.start_as_current_span( + span_name, kind=SpanKind.CLIENT, attributes=span_attributes + ) as span: + exception = None + if callable(request_hook): + request_hook(span, request) + + inject(headers) + + with suppress_http_instrumentation(): + start_time = default_timer() + try: + result = wrapped_send( + self, request, **kwargs + ) # *** PROCEED + except Exception as exc: # pylint: disable=W0703 + exception = exc + result = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) + + _apply_response_attributes( + span, + result, + metric_labels, + sem_conv_opt_in_mode, + captured_response_headers, + sensitive_headers, + response_hook, + request, + ) + + if exception is not None and _report_new(sem_conv_opt_in_mode): + span.set_attribute(ERROR_TYPE, type(exception).__qualname__) + metric_labels[ERROR_TYPE] = type(exception).__qualname__ + + _record_duration_metrics( + duration_histogram_old, + duration_histogram_new, + elapsed_time, + metric_labels, + ) + + _record_connection_metrics( + connection_histograms, + result, + metric_labels, + ) + + if exception is not None: + raise exception.with_traceback(exception.__traceback__) + + return result + + instrumented_send.opentelemetry_instrumentation_niquests_applied = True + Session.send = instrumented_send + + # Also instrument AsyncSession if available + _instrument_async( + tracer, + duration_histogram_old, + duration_histogram_new, + request_hook=request_hook, + response_hook=response_hook, + excluded_urls=excluded_urls, + sem_conv_opt_in_mode=sem_conv_opt_in_mode, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, + connection_histograms=connection_histograms, + ) + + +def _instrument_async( + tracer: Tracer, + duration_histogram_old: Histogram, + duration_histogram_new: Histogram, + request_hook: _RequestHookT = None, + response_hook: _ResponseHookT = None, + excluded_urls: ExcludeList | None = None, + sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, + captured_request_headers: list[str] | None = None, + captured_response_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, + connection_histograms: dict[str, Histogram] | None = None, +): + """Instruments the AsyncSession.send method.""" + wrapped_async_send = AsyncSession.send + + @functools.wraps(wrapped_async_send) + async def instrumented_async_send( + self, request: PreparedRequest, **kwargs: Any + ): + if excluded_urls and excluded_urls.url_disabled(request.url): + return await wrapped_async_send(self, request, **kwargs) + + if not is_http_instrumentation_enabled(): + return await wrapped_async_send(self, request, **kwargs) + + span_name, span_attributes, metric_labels, headers = ( + _prepare_span_and_metric_attributes( + request, + sem_conv_opt_in_mode, + captured_request_headers, + sensitive_headers, + ) + ) + + with tracer.start_as_current_span( + span_name, kind=SpanKind.CLIENT, attributes=span_attributes + ) as span: + exception = None + if callable(request_hook): + request_hook(span, request) + + inject(headers) + + with suppress_http_instrumentation(): + start_time = default_timer() + try: + result = await wrapped_async_send( + self, request, **kwargs + ) # *** PROCEED + except Exception as exc: # pylint: disable=W0703 + exception = exc + result = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) + + _apply_response_attributes( + span, + result, + metric_labels, + sem_conv_opt_in_mode, + captured_response_headers, + sensitive_headers, + response_hook, + request, + ) + + if exception is not None and _report_new(sem_conv_opt_in_mode): + span.set_attribute(ERROR_TYPE, type(exception).__qualname__) + metric_labels[ERROR_TYPE] = type(exception).__qualname__ + + _record_duration_metrics( + duration_histogram_old, + duration_histogram_new, + elapsed_time, + metric_labels, + ) + + _record_connection_metrics( + connection_histograms, + result, + metric_labels, + ) + + if exception is not None: + raise exception.with_traceback(exception.__traceback__) + + return result + + instrumented_async_send.opentelemetry_instrumentation_niquests_applied = ( + True + ) + AsyncSession.send = instrumented_async_send + + +def _uninstrument(): + """Disables instrumentation of :code:`niquests` through this module. + + Note that this only works if no other module also patches niquests.""" + _uninstrument_from(Session) + _uninstrument_from(AsyncSession) + + +def _uninstrument_from(instr_root, restore_as_bound_func: bool = False): + instr_func = getattr(instr_root, "send") + if not getattr( + instr_func, + "opentelemetry_instrumentation_niquests_applied", + False, + ): + return + + original = instr_func.__wrapped__ # pylint:disable=no-member + if restore_as_bound_func: + original = types.MethodType(original, instr_root) + setattr(instr_root, "send", original) + + +def get_default_span_name(method: str) -> str: + """ + Default implementation for name_callback, returns HTTP {method_name}. + https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/http/#name + + Args: + method: string representing HTTP method + Returns: + span name + """ + method = sanitize_method(method.strip()) + if method == "_OTHER": + return "HTTP" + return method + + +class NiquestsInstrumentor(BaseInstrumentor): + """An instrumentor for niquests + See `BaseInstrumentor` + """ + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any): + """Instruments niquests module + + Args: + **kwargs: Optional arguments + ``tracer_provider``: a TracerProvider, defaults to global + ``request_hook``: An optional callback that is invoked right after a span is created. + ``response_hook``: An optional callback which is invoked right before the span is finished processing a response. + ``excluded_urls``: A string containing a comma-delimited list of regexes used to exclude URLs from tracking + ``duration_histogram_boundaries``: A list of float values representing the explicit bucket boundaries for the duration histogram. + """ + semconv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + schema_url = _get_schema_url(semconv_opt_in_mode) + tracer = get_tracer( + __name__, + __version__, + kwargs.get("tracer_provider"), + schema_url=schema_url, + ) + excluded_urls = kwargs.get("excluded_urls") + duration_histogram_boundaries = kwargs.get( + "duration_histogram_boundaries" + ) + meter = get_meter( + __name__, + __version__, + kwargs.get("meter_provider"), + schema_url=schema_url, + ) + duration_histogram_old = None + if _report_old(semconv_opt_in_mode): + duration_histogram_old = meter.create_histogram( + name=MetricInstruments.HTTP_CLIENT_DURATION, + unit="ms", + description="measures the duration of the outbound HTTP request", + explicit_bucket_boundaries_advisory=duration_histogram_boundaries + or HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + ) + duration_histogram_new = None + if _report_new(semconv_opt_in_mode): + duration_histogram_new = meter.create_histogram( + name=HTTP_CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + explicit_bucket_boundaries_advisory=duration_histogram_boundaries + or HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + + # Niquests-specific connection sub-duration histograms. + # These leverage niquests' conn_info timing data to give + # visibility into DNS, TCP, TLS, and request-send phases. + connection_histograms = { + name: meter.create_histogram(name=name, unit="s", description=desc) + for name, desc in _CONN_HISTOGRAM_DESCRIPTIONS.items() + } + + _instrument( + tracer, + duration_histogram_old, + duration_histogram_new, + request_hook=kwargs.get("request_hook"), + response_hook=kwargs.get("response_hook"), + excluded_urls=( + _excluded_urls_from_env + if excluded_urls is None + else parse_excluded_urls(excluded_urls) + ), + sem_conv_opt_in_mode=semconv_opt_in_mode, + captured_request_headers=get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ), + captured_response_headers=get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ), + sensitive_headers=get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ), + connection_histograms=connection_histograms, + ) + + def _uninstrument(self, **kwargs: Any): + _uninstrument() + + @staticmethod + def uninstrument_session(session: Session): + """Disables instrumentation on the session object.""" + _uninstrument_from(session, restore_as_bound_func=True) diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py new file mode 100644 index 0000000000..82c67be991 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/package.py @@ -0,0 +1,20 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +_instruments = ("niquests >= 3.0, < 4",) + +_supports_metrics = True + +_semconv_status = "migration" diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/py.typed b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/version.py b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/version.py new file mode 100644 index 0000000000..a07bc2663e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/src/opentelemetry/instrumentation/niquests/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.63b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt new file mode 100644 index 0000000000..887d9c5b04 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt @@ -0,0 +1,16 @@ +asgiref==3.8.1 +Deprecated==1.2.14 +iniconfig==2.0.0 +niquests +packaging==24.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pytest==7.4.4 +responses +tomli==2.0.1 +typing_extensions==4.12.2 +wrapt==1.16.0 +zipp==3.19.2 +-e opentelemetry-instrumentation +-e util/opentelemetry-util-http +-e instrumentation/opentelemetry-instrumentation-niquests diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py new file mode 100644 index 0000000000..fc24d22c89 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/conftest.py @@ -0,0 +1,186 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""conftest.py – make the ``responses`` library work with Niquests. + +The ``responses`` library is designed for ``requests``. Since Niquests is a +drop-in replacement we can redirect the relevant ``sys.modules`` entries so +that ``responses`` patches Niquests adapters instead. + +The async extension (``NiquestsMock``) additionally patches +``AsyncHTTPAdapter.send`` so that ``await session.get(...)`` also hits the +mock registry. + +See https://niquests.readthedocs.io/en/latest/community/extensions.html#responses +""" + +from __future__ import annotations + +import typing +from sys import modules +from unittest import mock as std_mock + +import niquests +from niquests.packages import urllib3 + +# responses is tied to Requests +# and Niquests is entirely compatible with it. +# we can fool it without effort. +modules["requests"] = niquests +modules["requests.adapters"] = niquests.adapters +modules["requests.models"] = niquests.models +modules["requests.exceptions"] = niquests.exceptions +modules["requests.packages.urllib3"] = urllib3 + +# make 'responses' mock both sync and async +# 'Requests' ever only supported sync +# Fortunately interfaces are mirrored in 'Niquests' +import responses # noqa: E402 pylint: disable=wrong-import-position + + +class NiquestsMock(responses.RequestsMock): + """Asynchronous support for responses""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__( + *args, + target="niquests.adapters.HTTPAdapter.send", + **kwargs, + ) + + self._patcher_async = None + + def unbound_on_async_send(self): + async def send( + adapter: "niquests.adapters.AsyncHTTPAdapter", + request: "niquests.PreparedRequest", + *args: typing.Any, + **kwargs: typing.Any, + ) -> "niquests.Response": + if args: + try: + kwargs["stream"] = args[0] + kwargs["timeout"] = args[1] + kwargs["verify"] = args[2] + kwargs["cert"] = args[3] + kwargs["proxies"] = args[4] + except IndexError: + pass + + resp = self._on_request(adapter, request, **kwargs) + + if kwargs.get("stream"): + return resp + + resp.__class__ = niquests.Response + return resp + + return send + + def unbound_on_send(self): + def send( + adapter: "niquests.adapters.HTTPAdapter", + request: "niquests.PreparedRequest", + *args: typing.Any, + **kwargs: typing.Any, + ) -> "niquests.Response": + if args: + try: + kwargs["stream"] = args[0] + kwargs["timeout"] = args[1] + kwargs["verify"] = args[2] + kwargs["cert"] = args[3] + kwargs["proxies"] = args[4] + except IndexError: + pass + + return self._on_request(adapter, request, **kwargs) + + return send + + def start(self) -> None: + if self._patcher: + return + + self._patcher = std_mock.patch( + target=self.target, new=self.unbound_on_send() + ) + self._patcher_async = std_mock.patch( + target=self.target.replace("HTTPAdapter", "AsyncHTTPAdapter"), + new=self.unbound_on_async_send(), + ) + + self._patcher.start() + self._patcher_async.start() + + def stop(self, allow_assert: bool = True) -> None: + if self._patcher: + self._patcher.stop() + self._patcher_async.stop() + + self._patcher = None + self._patcher_async = None + + if not self.assert_all_requests_are_fired: + return + + if not allow_assert: + return + + not_called = [m for m in self.registered() if m.call_count == 0] + if not_called: + raise AssertionError( + f"Not all requests have been executed {[(match.method, match.url) for match in not_called]!r}" + ) + + +mock = _default_mock = NiquestsMock(assert_all_requests_are_fired=False) + +setattr(responses, "mock", mock) +setattr(responses, "_default_mock", _default_mock) + +for kw in [ + "activate", + "add", + "_add_from_file", + "add_callback", + "add_passthru", + "assert_call_count", + "calls", + "delete", + "DELETE", + "get", + "GET", + "head", + "HEAD", + "options", + "OPTIONS", + "patch", + "PATCH", + "post", + "POST", + "put", + "PUT", + "registered", + "remove", + "replace", + "reset", + "response_callback", + "start", + "stop", + "upsert", +]: + if not hasattr(responses, kw): + continue + setattr(responses, kw, getattr(mock, kw)) diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py new file mode 100644 index 0000000000..b374e84018 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_async.py @@ -0,0 +1,1170 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-many-lines + +"""Tests for async instrumentation of niquests (AsyncSession). + +Follows the httpx pattern: a ``BaseTestCases`` wrapper class contains an +abstract ``BaseManualTest`` that is concretely subclassed by +``TestAsyncIntegration``. Async calls are bridged into synchronous +``unittest.TestCase`` methods via ``asyncio.run()``. +""" + +import abc +import asyncio +import re +import ssl +from datetime import timedelta +from unittest import mock + +import niquests +import responses +from niquests.adapters import AsyncBaseAdapter +from niquests.async_session import AsyncSession +from niquests.models import ConnectionInfo, Response + +import opentelemetry.instrumentation.niquests +from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _get_schema_url, + _OpenTelemetrySemanticConventionStability, + _StabilityMode, +) +from opentelemetry.instrumentation.niquests import NiquestsInstrumentor +from opentelemetry.instrumentation.utils import ( + suppress_http_instrumentation, + suppress_instrumentation, +) +from opentelemetry.propagate import get_global_textmap, set_global_textmap +from opentelemetry.sdk import resources +from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_FLAVOR, + HTTP_HOST, + HTTP_METHOD, + HTTP_SCHEME, + HTTP_STATUS_CODE, + HTTP_URL, +) +from opentelemetry.semconv._incubating.attributes.net_attributes import ( + NET_PEER_NAME, + NET_PEER_PORT, +) +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE +from opentelemetry.semconv.attributes.http_attributes import ( + HTTP_REQUEST_METHOD, + HTTP_REQUEST_METHOD_ORIGINAL, + HTTP_RESPONSE_STATUS_CODE, +) +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_VERSION, +) +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.attributes.url_attributes import URL_FULL +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, +) +from opentelemetry.test.mock_textmap import MockTextMapPropagator +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import StatusCode +from opentelemetry.util.http import ( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, + get_excluded_urls, +) + +_NIQUESTS_USER_AGENT = f"niquests/{niquests.__version__}" + + +def _async_call(coro): + """Bridge an awaitable into a synchronous unittest method.""" + return asyncio.run(coro) + + +# Using this wrapper class to have a base class for the tests while also not +# angering pylint or mypy when calling methods not in the class when only +# subclassing abc.ABC. +class BaseTestCases: + class BaseManualTest(TestBase, metaclass=abc.ABCMeta): + # pylint: disable=no-member + # pylint: disable=too-many-public-methods + + URL = "http://mock/status/200" + + # pylint: disable=invalid-name + def setUp(self): + super().setUp() + + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + "OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS": "http://localhost/env_excluded_arg/123,env_excluded_noarg", + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + + _OpenTelemetrySemanticConventionStability._initialized = False + + self.env_patch.start() + + self.exclude_patch = mock.patch( + "opentelemetry.instrumentation.niquests._excluded_urls_from_env", + get_excluded_urls("NIQUESTS"), + ) + self.exclude_patch.start() + + NiquestsInstrumentor().instrument() + responses.start() + responses.add(responses.GET, self.URL, body="Hello!", status=200) + + # pylint: disable=invalid-name + def tearDown(self): + super().tearDown() + self.env_patch.stop() + self.exclude_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + responses.stop() + responses.reset() + + def assert_span(self, exporter=None, num_spans=1): + if exporter is None: + exporter = self.memory_exporter + span_list = exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + if num_spans == 1: + return span_list[0] + return span_list + + @abc.abstractmethod + def perform_request( + self, + url: str, + method: str = "GET", + headers: dict = None, + ): + pass + + def test_basic(self): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.DEFAULT), + ) + + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 200, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_basic_new_semconv(self): + url_with_port = "http://mock:80/status/200" + responses.add( + responses.GET, url_with_port, status=200, body="Hello!" + ) + result = self.perform_request(url_with_port) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.HTTP), + ) + expected = { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url_with_port, + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + # responses mock does not provide raw.version, so + # NETWORK_PROTOCOL_VERSION is only present on real connections. + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_basic_both_semconv(self): + url_with_port = "http://mock:80/status/200" + responses.add( + responses.GET, url_with_port, status=200, body="Hello!" + ) + result = self.perform_request(url_with_port) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.HTTP), + ) + expected = { + HTTP_METHOD: "GET", + HTTP_REQUEST_METHOD: "GET", + HTTP_URL: url_with_port, + URL_FULL: url_with_port, + HTTP_HOST: "mock", + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + NET_PEER_PORT: 80, + HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + # responses mock does not provide raw.version, so + # protocol version attributes are only present on real connections. + if HTTP_FLAVOR in span.attributes: + expected[HTTP_FLAVOR] = span.attributes[HTTP_FLAVOR] + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_not_found_basic(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404) + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_not_found_basic_new_semconv(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual( + span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404 + ) + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_not_found_basic_both_semconv(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404) + self.assertEqual( + span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404 + ) + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_nonstandard_http_method(self): + responses.add("NONSTANDARD", self.URL, status=405) + result = self.perform_request(self.URL, method="NONSTANDARD") + self.assertEqual(result.status_code, 405) + span = self.assert_span() + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "HTTP") + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "_OTHER", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 405, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertIs(span.status.status_code, trace.StatusCode.ERROR) + + def test_nonstandard_http_method_new_semconv(self): + responses.add("NONSTANDARD", self.URL, status=405) + result = self.perform_request(self.URL, method="NONSTANDARD") + self.assertEqual(result.status_code, 405) + span = self.assert_span() + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "HTTP") + expected = { + HTTP_REQUEST_METHOD: "_OTHER", + URL_FULL: self.URL, + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 405, + ERROR_TYPE: "405", + HTTP_REQUEST_METHOD_ORIGINAL: "NONSTANDARD", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + self.assertIs(span.status.status_code, trace.StatusCode.ERROR) + + def test_excluded_urls_from_env(self): + url = "http://localhost/env_excluded_arg/123" + responses.add(responses.GET, url, status=200) + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + self.perform_request(self.URL) + self.perform_request(url) + + self.assert_span(num_spans=1) + + def test_name_callback_default(self): + def name_callback(method, url): + return 123 + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(name_callback=name_callback) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertEqual(span.name, "GET") + + def test_hooks(self): + def request_hook(span, request_obj): + span.update_name("name set from hook") + + def response_hook(span, request_obj, response): + span.set_attribute("response_hook_attr", "value") + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument( + request_hook=request_hook, response_hook=response_hook + ) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertEqual(span.name, "name set from hook") + self.assertEqual(span.attributes["response_hook_attr"], "value") + + def test_excluded_urls_explicit(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(excluded_urls=".*/404") + self.perform_request(self.URL) + self.perform_request(url_404) + + self.assert_span(num_spans=1) + + def test_uninstrument(self): + NiquestsInstrumentor().uninstrument() + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + # instrument again to avoid annoying warning message + NiquestsInstrumentor().instrument() + + def test_suppress_instrumentation(self): + with suppress_instrumentation(): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + self.assert_span(num_spans=0) + + def test_suppress_http_instrumentation(self): + with suppress_http_instrumentation(): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + self.assert_span(num_spans=0) + + def test_not_recording(self): + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument( + tracer_provider=trace.NoOpTracerProvider() + ) + mock_span.is_recording.return_value = False + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span(None, 0) + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + + def test_distributed_context(self): + previous_propagator = get_global_textmap() + try: + set_global_textmap(MockTextMapPropagator()) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + + headers = dict(responses.calls[-1].request.headers) + self.assertIn(MockTextMapPropagator.TRACE_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().trace_id), + headers[MockTextMapPropagator.TRACE_ID_KEY], + ) + self.assertIn(MockTextMapPropagator.SPAN_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().span_id), + headers[MockTextMapPropagator.SPAN_ID_KEY], + ) + + finally: + set_global_textmap(previous_propagator) + + def test_response_hook(self): + NiquestsInstrumentor().uninstrument() + + def response_hook( + span, + request: niquests.PreparedRequest, + response: niquests.Response, + ): + span.set_attribute( + "http.response.body", + response.content.decode("utf-8"), + ) + + NiquestsInstrumentor().instrument( + tracer_provider=self.tracer_provider, + response_hook=response_hook, + ) + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 200, + "http.response.body": "Hello!", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + def test_custom_tracer_provider(self): + resource = resources.Resource.create({}) + result = self.create_tracer_provider(resource=resource) + tracer_provider, exporter = result + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(tracer_provider=tracer_provider) + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span(exporter=exporter) + self.assertIs(span.resource, resource) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Header", + }, + ) + def test_custom_request_headers_captured(self): + """Test that specified request headers are captured as span attributes.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + responses.add(responses.GET, self.URL, body="Hello!") + result = self.perform_request( + self.URL, + headers={"X-Custom-Header": "custom-value"}, + ) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_header"], + ("custom-value",), + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Header", + }, + ) + def test_custom_response_headers_captured(self): + """Test that specified response headers are captured as span attributes.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + responses.replace( + responses.GET, + self.URL, + body="Hello!", + headers={"X-Custom-Header": "custom-value"}, + ) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.response.header.x_custom_header"], + ("custom-value",), + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "Authorization", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: "Authorization", + }, + ) + def test_sensitive_headers_sanitized(self): + """Test that sensitive header values are redacted.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + responses.add(responses.GET, self.URL, body="Hello!") + result = self.perform_request( + self.URL, + headers={"Authorization": "Bearer secret-token"}, + ) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.authorization"], + ("[REDACTED]",), + ) + + +class InvalidResponseObjectException(Exception): + def __init__(self): + super().__init__() + self.response = {} + + +class TestAsyncIntegration(BaseTestCases.BaseManualTest): + """Async tests for ``AsyncSession`` – mirrors the httpx pattern.""" + + def perform_request( + self, + url: str, + method: str = "GET", + headers: dict = None, + ): + async def _perform_request(): + async with AsyncSession() as session: + return await session.request(method, url, headers=headers) + + return _async_call(_perform_request()) + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", + side_effect=niquests.RequestException, + ) + def test_exception_without_response(self, *_, **__): + with self.assertRaises(niquests.RequestException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", + side_effect=niquests.RequestException, + ) + def test_exception_new_semconv(self, *_, **__): + url_with_port = "http://mock:80/status/200" + responses.add(responses.GET, url_with_port, status=200, body="Hello!") + with self.assertRaises(niquests.RequestException): + self.perform_request(url_with_port) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url_with_port, + SERVER_ADDRESS: "mock", + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + NETWORK_PEER_ADDRESS: "mock", + ERROR_TYPE: "RequestException", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", + side_effect=niquests.Timeout, + ) + def test_timeout_exception(self, *_, **__): + with self.assertRaises(Exception): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", + side_effect=InvalidResponseObjectException, + ) + def test_exception_without_proper_response_type(self, *_, **__): + with self.assertRaises(InvalidResponseObjectException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + mocked_response = niquests.Response() + mocked_response.status_code = 500 + mocked_response.reason = "Internal Server Error" + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", + side_effect=niquests.RequestException(response=mocked_response), + ) + def test_exception_with_response(self, *_, **__): + with self.assertRaises(niquests.RequestException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 500, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.AsyncHTTPAdapter.send", side_effect=Exception + ) + def test_basic_exception(self, *_, **__): + with self.assertRaises(Exception): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_remove_sensitive_params(self): + new_url = ( + "http://username:password@mock/status/200?AWSAccessKeyId=secret" + ) + responses.add( + responses.GET, + re.compile(r"http://.*mock/status/200"), + body="Hello!", + status=200, + ) + self.perform_request(new_url) + span = self.assert_span() + + self.assertEqual( + span.attributes[HTTP_URL], + "http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED", + ) + + @mock.patch.dict("os.environ", {}) + def test_custom_headers_not_captured_when_not_configured(self): + """Test that headers are not captured when env vars are not set.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + responses.add(responses.GET, self.URL, body="Hello!") + result = self.perform_request( + self.URL, + headers={"X-Request-Header": "request-value"}, + ) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertNotIn( + "http.request.header.x_request_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Request-.*", + }, + ) + def test_custom_headers_with_regex(self): + """Test that header capture works with regex patterns.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + responses.add(responses.GET, self.URL, body="Hello!") + result = self.perform_request( + self.URL, + headers={ + "X-Custom-Request-One": "value-one", + "X-Custom-Request-Two": "value-two", + "X-Other-Request-Header": "other-value", + }, + ) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_request_one"], + ("value-one",), + ) + self.assertEqual( + span.attributes["http.request.header.x_custom_request_two"], + ("value-two",), + ) + self.assertNotIn( + "http.request.header.x_other_request_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "x-request-header", + }, + ) + def test_custom_headers_case_insensitive(self): + """Test that header capture is case-insensitive.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + responses.add(responses.GET, self.URL, body="Hello!") + result = self.perform_request( + self.URL, + headers={"X-ReQuESt-HeaDER": "custom-value"}, + ) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_request_header"], + ("custom-value",), + ) + + +SCOPE = "opentelemetry.instrumentation.niquests" + + +class TestAsyncIntegrationMetric(TestBase): + """Metric tests for the async path (AsyncSession).""" + + URL = "http://examplehost:8000/status/200" + + def setUp(self): + super().setUp() + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().instrument(meter_provider=self.meter_provider) + + responses.start() + responses.add(responses.GET, self.URL, body="Hello!") + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + responses.stop() + responses.reset() + + def perform_request(self, url: str) -> niquests.Response: # pylint: disable=no-self-use + async def _perform_request(): + async with AsyncSession() as session: + return await session.get(url) + + return _async_call(_perform_request()) + + def test_basic_metric_success(self): + self.perform_request(self.URL) + + expected_attributes = { + HTTP_STATUS_CODE: 200, + HTTP_HOST: "examplehost", + NET_PEER_PORT: 8000, + NET_PEER_NAME: "examplehost", + HTTP_METHOD: "GET", + HTTP_SCHEME: "http", + } + + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 1) + for metric in metrics: + self.assertEqual(metric.unit, "ms") + self.assertEqual( + metric.description, + "measures the duration of the outbound HTTP request", + ) + for data_point in metric.data.data_points: + actual = dict(data_point.attributes) + # responses mock does not provide http_version + actual.pop(HTTP_FLAVOR, None) + self.assertDictEqual(expected_attributes, actual) + self.assertEqual(data_point.count, 1) + + def test_basic_metric_new_semconv(self): + self.perform_request(self.URL) + + expected_attributes = { + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_ADDRESS: "examplehost", + SERVER_PORT: 8000, + HTTP_REQUEST_METHOD: "GET", + } + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 1) + for metric in metrics: + self.assertEqual(metric.unit, "s") + self.assertEqual( + metric.description, "Duration of HTTP client requests." + ) + for data_point in metric.data.data_points: + actual = dict(data_point.attributes) + # responses mock does not provide http_version + actual.pop(NETWORK_PROTOCOL_VERSION, None) + self.assertDictEqual(expected_attributes, actual) + self.assertEqual(data_point.count, 1) + + def test_basic_metric_both_semconv(self): + self.perform_request(self.URL) + + expected_attributes_old = { + HTTP_STATUS_CODE: 200, + HTTP_HOST: "examplehost", + NET_PEER_PORT: 8000, + NET_PEER_NAME: "examplehost", + HTTP_METHOD: "GET", + HTTP_SCHEME: "http", + } + + expected_attributes_new = { + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_ADDRESS: "examplehost", + SERVER_PORT: 8000, + HTTP_REQUEST_METHOD: "GET", + } + + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 2) + for metric in metrics: + for data_point in metric.data.data_points: + actual = dict(data_point.attributes) + if metric.unit == "ms": + actual.pop(HTTP_FLAVOR, None) + self.assertDictEqual( + expected_attributes_old, + actual, + ) + else: + actual.pop(NETWORK_PROTOCOL_VERSION, None) + self.assertDictEqual( + expected_attributes_new, + actual, + ) + self.assertEqual(data_point.count, 1) + + +class TransportMock: + def read(self, *args, **kwargs): + pass + + +class AsyncMyAdapter(AsyncBaseAdapter): + """Async adapter that returns a pre-built Response.""" + + def __init__(self, response): + super().__init__() + self._response = response + + async def send(self, *args, **kwargs): # pylint:disable=signature-differs + return self._response + + async def close(self): + pass + + +def _make_async_response(ocsp_verified=None, **conn_kwargs): + """Build a Response with a mocked conn_info for async adapter tests.""" + resp = Response() + resp.status_code = 200 + resp.reason = "OK" + resp.raw = TransportMock() + ci = ConnectionInfo() + for key, val in conn_kwargs.items(): + setattr(ci, key, val) + resp.request = type( + "StubRequest", (), {"conn_info": ci, "ocsp_verified": ocsp_verified} + )() + return resp + + +def _async_perform(url, session): + """Execute an async GET and return the response.""" + + async def _run(): + async with session: + return await session.get(url) + + return _async_call(_run()) + + +class TestAsyncConnInfoAndMetrics(TestBase): + """Tests for TLS/revocation span attributes and connection sub-duration + histograms via the async path (AsyncSession).""" + + URL = "http://mock/status/200" + METRIC_URL = "http://examplehost:8000/status/200" + + def setUp(self): + super().setUp() + self.env_patch = mock.patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: "http"}, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + + def test_tls_attributes_on_span(self): + resp = _make_async_response( + tls_version=ssl.TLSVersion.TLSv1_3, + cipher="TLS_AES_256_GCM_SHA384", + ) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertEqual(span.attributes.get("tls.protocol.version"), "1.3") + self.assertEqual( + span.attributes.get("tls.cipher"), "TLS_AES_256_GCM_SHA384" + ) + + def test_tls_version_1_2(self): + resp = _make_async_response( + tls_version=ssl.TLSVersion.TLSv1_2, + cipher="ECDHE-RSA-AES128-GCM-SHA256", + ) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertEqual(span.attributes.get("tls.protocol.version"), "1.2") + + def test_no_tls_attributes_for_plain_http(self): + resp = _make_async_response(destination_address=("127.0.0.1", 8000)) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertIsNone(span.attributes.get("tls.protocol.version")) + self.assertIsNone(span.attributes.get("tls.cipher")) + + def test_revocation_verified_true(self): + resp = _make_async_response(ocsp_verified=True) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertTrue(span.attributes.get("tls.revocation.verified")) + + def test_revocation_verified_false(self): + resp = _make_async_response(ocsp_verified=False) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertFalse(span.attributes.get("tls.revocation.verified")) + + def test_revocation_verified_not_set_when_none(self): + resp = _make_async_response(ocsp_verified=None) + session = AsyncSession() + session.mount(self.URL, AsyncMyAdapter(resp)) + _async_perform(self.URL, session) + span = self.memory_exporter.get_finished_spans()[0] + self.assertNotIn("tls.revocation.verified", span.attributes) + + def test_connection_latency_metrics(self): + resp = _make_async_response( + resolution_latency=timedelta(milliseconds=5), + established_latency=timedelta(milliseconds=10), + tls_handshake_latency=timedelta(milliseconds=15), + request_sent_latency=timedelta(milliseconds=2), + ) + session = AsyncSession() + session.mount(self.METRIC_URL, AsyncMyAdapter(resp)) + _async_perform(self.METRIC_URL, session) + + metrics = self.get_sorted_metrics(SCOPE) + metric_names = {m.name for m in metrics} + expected_metrics = { + "http.client.connection.dns.duration", + "http.client.connection.tcp.duration", + "http.client.connection.tls.duration", + "http.client.request.send.duration", + } + self.assertTrue(expected_metrics.issubset(metric_names)) + expected_values = { + "http.client.connection.dns.duration": 0.005, + "http.client.connection.tcp.duration": 0.010, + "http.client.connection.tls.duration": 0.015, + "http.client.request.send.duration": 0.002, + } + for metric in metrics: + if metric.name in expected_values: + self.assertEqual(metric.unit, "s") + dp = metric.data.data_points[0] + self.assertEqual(dp.count, 1) + self.assertAlmostEqual( + dp.sum, expected_values[metric.name], places=6 + ) + + def test_connection_metrics_zero_latencies_skipped(self): + resp = _make_async_response( + resolution_latency=timedelta(0), + established_latency=timedelta(0), + tls_handshake_latency=timedelta(0), + request_sent_latency=timedelta(milliseconds=3), + ) + session = AsyncSession() + session.mount(self.METRIC_URL, AsyncMyAdapter(resp)) + _async_perform(self.METRIC_URL, session) + + metrics = self.get_sorted_metrics(SCOPE) + metrics_by_name = {m.name: m for m in metrics} + for name in [ + "http.client.connection.dns.duration", + "http.client.connection.tcp.duration", + "http.client.connection.tls.duration", + ]: + metric = metrics_by_name.get(name) + if metric is not None: + for dp in metric.data.data_points: + self.assertEqual( + dp.count, 0, f"{name} must skip zero durations" + ) + send_metric = metrics_by_name.get("http.client.request.send.duration") + self.assertIsNotNone(send_metric) + dp = send_metric.data.data_points[0] + self.assertEqual(dp.count, 1) + self.assertAlmostEqual(dp.sum, 0.003, places=6) diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py new file mode 100644 index 0000000000..a961179636 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_integration.py @@ -0,0 +1,1486 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-many-lines + +import abc +import re +import ssl +from datetime import timedelta +from unittest import mock + +import niquests +import responses +from niquests.adapters import BaseAdapter +from niquests.models import ConnectionInfo, Response + +import opentelemetry.instrumentation.niquests +from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + OTEL_SEMCONV_STABILITY_OPT_IN, + _get_schema_url, + _OpenTelemetrySemanticConventionStability, + _StabilityMode, +) +from opentelemetry.instrumentation.niquests import NiquestsInstrumentor +from opentelemetry.instrumentation.utils import ( + suppress_http_instrumentation, + suppress_instrumentation, +) +from opentelemetry.propagate import get_global_textmap, set_global_textmap +from opentelemetry.sdk import resources +from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_FLAVOR, + HTTP_HOST, + HTTP_METHOD, + HTTP_SCHEME, + HTTP_STATUS_CODE, + HTTP_URL, +) +from opentelemetry.semconv._incubating.attributes.net_attributes import ( + NET_PEER_NAME, + NET_PEER_PORT, +) +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE +from opentelemetry.semconv.attributes.http_attributes import ( + HTTP_REQUEST_METHOD, + HTTP_REQUEST_METHOD_ORIGINAL, + HTTP_RESPONSE_STATUS_CODE, +) +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_VERSION, +) +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.attributes.url_attributes import URL_FULL +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, +) +from opentelemetry.test.mock_textmap import MockTextMapPropagator +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import StatusCode +from opentelemetry.util.http import ( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, + get_excluded_urls, +) + +_NIQUESTS_USER_AGENT = f"niquests/{niquests.__version__}" + + +class TransportMock: + def read(self, *args, **kwargs): + pass + + +class MyAdapter(BaseAdapter): + def __init__(self, response): + super().__init__() + self._response = response + + def send(self, *args, **kwargs): # pylint:disable=signature-differs + return self._response + + def close(self): + pass + + +class InvalidResponseObjectException(Exception): + def __init__(self): + super().__init__() + self.response = {} + + +SCOPE = "opentelemetry.instrumentation.niquests" + + +class NiquestsIntegrationTestBase(abc.ABC): + # pylint: disable=no-member + # pylint: disable=too-many-public-methods + + URL = "http://mock/status/200" + + # pylint: disable=invalid-name + def setUp(self): + super().setUp() + + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + "OTEL_PYTHON_NIQUESTS_EXCLUDED_URLS": "http://localhost/env_excluded_arg/123,env_excluded_noarg", + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + + _OpenTelemetrySemanticConventionStability._initialized = False + + self.env_patch.start() + + self.exclude_patch = mock.patch( + "opentelemetry.instrumentation.niquests._excluded_urls_from_env", + get_excluded_urls("NIQUESTS"), + ) + self.exclude_patch.start() + + NiquestsInstrumentor().instrument() + responses.start() + responses.add(responses.GET, self.URL, body="Hello!", status=200) + + # pylint: disable=invalid-name + def tearDown(self): + super().tearDown() + self.env_patch.stop() + self.exclude_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + responses.stop() + responses.reset() + + def assert_span(self, exporter=None, num_spans=1): + if exporter is None: + exporter = self.memory_exporter + span_list = exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + if num_spans == 1: + return span_list[0] + return span_list + + @staticmethod + @abc.abstractmethod + def perform_request(url: str, session: niquests.Session = None): + pass + + def test_basic(self): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.DEFAULT), + ) + + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 200, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_basic_new_semconv(self): + url_with_port = "http://mock:80/status/200" + responses.add(responses.GET, url_with_port, status=200, body="Hello!") + result = self.perform_request(url_with_port) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.HTTP), + ) + expected = { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url_with_port, + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + # responses mock does not provide raw.version, so + # NETWORK_PROTOCOL_VERSION is only present on real connections. + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_basic_both_semconv(self): + url_with_port = "http://mock:80/status/200" + responses.add(responses.GET, url_with_port, status=200, body="Hello!") + result = self.perform_request(url_with_port) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + _get_schema_url(_StabilityMode.HTTP), + ) + expected = { + HTTP_METHOD: "GET", + HTTP_REQUEST_METHOD: "GET", + HTTP_URL: url_with_port, + URL_FULL: url_with_port, + HTTP_HOST: "mock", + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + NET_PEER_PORT: 80, + HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + # responses mock does not provide raw.version, so + # protocol version attributes are only present on real connections. + if HTTP_FLAVOR in span.attributes: + expected[HTTP_FLAVOR] = span.attributes[HTTP_FLAVOR] + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationScope( + span, opentelemetry.instrumentation.niquests + ) + + def test_nonstandard_http_method(self): + responses.add("NONSTANDARD", self.URL, status=405) + session = niquests.Session() + session.request("NONSTANDARD", self.URL) + span = self.assert_span() + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "HTTP") + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "_OTHER", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 405, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.ERROR) + + def test_nonstandard_http_method_new_semconv(self): + responses.add("NONSTANDARD", self.URL, status=405) + session = niquests.Session() + session.request("NONSTANDARD", self.URL) + span = self.assert_span() + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "HTTP") + expected = { + HTTP_REQUEST_METHOD: "_OTHER", + URL_FULL: self.URL, + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 405, + ERROR_TYPE: "405", + HTTP_REQUEST_METHOD_ORIGINAL: "NONSTANDARD", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + } + if NETWORK_PROTOCOL_VERSION in span.attributes: + expected[NETWORK_PROTOCOL_VERSION] = span.attributes[ + NETWORK_PROTOCOL_VERSION + ] + self.assertEqual(span.attributes, expected) + self.assertIs(span.status.status_code, trace.StatusCode.ERROR) + + def test_hooks(self): + def request_hook(span, request_obj): + span.update_name("name set from hook") + + def response_hook(span, request_obj, response): + span.set_attribute("response_hook_attr", "value") + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument( + request_hook=request_hook, response_hook=response_hook + ) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertEqual(span.name, "name set from hook") + self.assertEqual(span.attributes["response_hook_attr"], "value") + + def test_excluded_urls_explicit(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(excluded_urls=".*/404") + self.perform_request(self.URL) + self.perform_request(url_404) + + self.assert_span(num_spans=1) + + def test_excluded_urls_from_env(self): + url = "http://localhost/env_excluded_arg/123" + responses.add(responses.GET, url, status=200) + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + self.perform_request(self.URL) + self.perform_request(url) + + self.assert_span(num_spans=1) + + def test_name_callback_default(self): + def name_callback(method, url): + return 123 + + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(name_callback=name_callback) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertEqual(span.name, "GET") + + def test_not_found_basic(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404) + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_not_found_basic_new_semconv(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual(span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404) + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_not_found_basic_both_semconv(self): + url_404 = "http://mock/status/404" + responses.add(responses.GET, url_404, status=404) + result = self.perform_request(url_404) + self.assertEqual(result.status_code, 404) + + span = self.assert_span() + + self.assertEqual(span.attributes.get(HTTP_STATUS_CODE), 404) + self.assertEqual(span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404) + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_uninstrument(self): + NiquestsInstrumentor().uninstrument() + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + # instrument again to avoid annoying warning message + NiquestsInstrumentor().instrument() + + def test_uninstrument_session(self): + session1 = niquests.Session() + NiquestsInstrumentor().uninstrument_session(session1) + + result = self.perform_request(self.URL, session1) + self.assertEqual(result.text, "Hello!") + self.assert_span(num_spans=0) + + # Test that other sessions as well as global niquests is still + # instrumented + session2 = niquests.Session() + result = self.perform_request(self.URL, session2) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + self.memory_exporter.clear() + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + def test_suppress_instrumentation(self): + with suppress_instrumentation(): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + self.assert_span(num_spans=0) + + def test_suppress_http_instrumentation(self): + with suppress_http_instrumentation(): + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + self.assert_span(num_spans=0) + + def test_not_recording(self): + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument( + tracer_provider=trace.NoOpTracerProvider() + ) + mock_span.is_recording.return_value = False + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assert_span(None, 0) + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + + def test_distributed_context(self): + previous_propagator = get_global_textmap() + try: + set_global_textmap(MockTextMapPropagator()) + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + + headers = dict(responses.calls[-1].request.headers) + self.assertIn(MockTextMapPropagator.TRACE_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().trace_id), + headers[MockTextMapPropagator.TRACE_ID_KEY], + ) + self.assertIn(MockTextMapPropagator.SPAN_ID_KEY, headers) + self.assertEqual( + str(span.get_span_context().span_id), + headers[MockTextMapPropagator.SPAN_ID_KEY], + ) + + finally: + set_global_textmap(previous_propagator) + + def test_response_hook(self): + NiquestsInstrumentor().uninstrument() + + def response_hook( + span, + request: niquests.PreparedRequest, + response: niquests.Response, + ): + span.set_attribute( + "http.response.body", response.content.decode("utf-8") + ) + + NiquestsInstrumentor().instrument( + tracer_provider=self.tracer_provider, + response_hook=response_hook, + ) + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 200, + "http.response.body": "Hello!", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + def test_custom_tracer_provider(self): + resource = resources.Resource.create({}) + result = self.create_tracer_provider(resource=resource) + tracer_provider, exporter = result + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument(tracer_provider=tracer_provider) + + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span(exporter=exporter) + self.assertIs(span.resource, resource) + + @mock.patch( + "niquests.adapters.HTTPAdapter.send", + side_effect=niquests.RequestException, + ) + def test_requests_exception_without_response(self, *_, **__): + with self.assertRaises(niquests.RequestException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.HTTPAdapter.send", + side_effect=niquests.RequestException, + ) + def test_requests_exception_new_semconv(self, *_, **__): + url_with_port = "http://mock:80/status/200" + responses.add(responses.GET, url_with_port, status=200, body="Hello!") + with self.assertRaises(niquests.RequestException): + self.perform_request(url_with_port) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url_with_port, + SERVER_ADDRESS: "mock", + SERVER_PORT: 80, + NETWORK_PEER_PORT: 80, + NETWORK_PEER_ADDRESS: "mock", + ERROR_TYPE: "RequestException", + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.HTTPAdapter.send", + side_effect=InvalidResponseObjectException, + ) + def test_requests_exception_without_proper_response_type(self, *_, **__): + with self.assertRaises(InvalidResponseObjectException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + mocked_response = niquests.Response() + mocked_response.status_code = 500 + mocked_response.reason = "Internal Server Error" + + @mock.patch( + "niquests.adapters.HTTPAdapter.send", + side_effect=niquests.RequestException(response=mocked_response), + ) + def test_requests_exception_with_response(self, *_, **__): + with self.assertRaises(niquests.RequestException): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_METHOD: "GET", + HTTP_URL: self.URL, + HTTP_STATUS_CODE: 500, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch("niquests.adapters.HTTPAdapter.send", side_effect=Exception) + def test_requests_basic_exception(self, *_, **__): + with self.assertRaises(Exception): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + @mock.patch( + "niquests.adapters.HTTPAdapter.send", side_effect=niquests.Timeout + ) + def test_requests_timeout_exception(self, *_, **__): + with self.assertRaises(Exception): + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_adapter_with_custom_response(self): + response = Response() + response.status_code = 210 + response.reason = "hello adapter" + response.raw = TransportMock() + + session = niquests.Session() + session.mount(self.URL, MyAdapter(response)) + + self.perform_request(self.URL, session) + span = self.assert_span() + self.assertEqual( + span.attributes, + { + "http.method": "GET", + "http.url": self.URL, + "http.status_code": 210, + USER_AGENT_ORIGINAL: _NIQUESTS_USER_AGENT, + }, + ) + + +class TestNiquestsIntegration(NiquestsIntegrationTestBase, TestBase): + @staticmethod + def perform_request(url: str, session: niquests.Session = None): + if session is None: + return niquests.get(url, timeout=5) + return session.get(url) + + def test_remove_sensitive_params(self): + new_url = ( + "http://username:password@mock/status/200?AWSAccessKeyId=secret" + ) + responses.add( + responses.GET, + re.compile(r"http://.*mock/status/200"), + body="Hello!", + status=200, + ) + self.perform_request(new_url) + span = self.assert_span() + + self.assertEqual( + span.attributes[HTTP_URL], + "http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED", + ) + + def test_if_headers_equals_none(self): + result = niquests.get(self.URL, headers=None, timeout=5) + self.assertEqual(result.text, "Hello!") + self.assert_span() + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Header,X-Another-Header", + }, + ) + def test_custom_request_headers_captured(self): + """Test that specified request headers are captured as span attributes.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + headers = { + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "X-Excluded-Header": "excluded-value", + } + responses.add(responses.GET, self.URL, body="Hello!") + result = niquests.get(self.URL, headers=headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.request.header.x_another_header"], + ("another-value",), + ) + self.assertNotIn("http.request.x_excluded_header", span.attributes) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Header,X-Another-Header", + }, + ) + def test_custom_response_headers_captured(self): + """Test that specified response headers are captured as span attributes.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + resp_headers = { + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "X-Excluded-Header": "excluded-value", + } + responses.replace( + responses.GET, self.URL, body="Hello!", headers=resp_headers + ) + result = niquests.get(self.URL, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.response.header.x_custom_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.response.header.x_another_header"], + ("another-value",), + ) + self.assertNotIn("http.response.x_excluded_header", span.attributes) + + @mock.patch.dict("os.environ", {}) + def test_custom_headers_not_captured_when_not_configured(self): + """Test that headers are not captured when env vars are not set.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + headers = {"X-Request-Header": "request-value"} + responses.replace( + responses.GET, + self.URL, + body="Hello!", + headers={"X-Response-Header": "response-value"}, + ) + result = niquests.get(self.URL, headers=headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertNotIn( + "http.request.header.x_request_header", span.attributes + ) + self.assertNotIn( + "http.response.header.x_response_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "Set-Cookie,X-Secret", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "Authorization,X-Api-Key", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: "Authorization,X-Api-Key,Set-Cookie,X-Secret", + }, + ) + def test_sensitive_headers_sanitized(self): + """Test that sensitive header values are redacted.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + + request_headers = { + "Authorization": "Bearer secret-token", + "X-Api-Key": "secret-key", + } + response_headers = { + "Set-Cookie": "session=abc123", + "X-Secret": "secret", + } + responses.replace( + responses.GET, + self.URL, + body="Hello!", + headers=response_headers, + ) + result = niquests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.authorization"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.request.header.x_api_key"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.response.header.set_cookie"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.response.header.x_secret"], + ("[REDACTED]",), + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Response-.*", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Request-.*", + }, + ) + def test_custom_headers_with_regex(self): + """Test that header capture works with regex patterns.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + request_headers = { + "X-Custom-Request-One": "value-one", + "X-Custom-Request-Two": "value-two", + "X-Other-Request-Header": "other-value", + } + response_headers = { + "X-Custom-Response-A": "value-A", + "X-Custom-Response-B": "value-B", + "X-Other-Response-Header": "other-value", + } + responses.replace( + responses.GET, + self.URL, + body="Hello!", + headers=response_headers, + ) + result = niquests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_request_one"], + ("value-one",), + ) + self.assertEqual( + span.attributes["http.request.header.x_custom_request_two"], + ("value-two",), + ) + self.assertNotIn( + "http.request.header.x_other_request_header", span.attributes + ) + self.assertEqual( + span.attributes["http.response.header.x_custom_response_a"], + ("value-A",), + ) + self.assertEqual( + span.attributes["http.response.header.x_custom_response_b"], + ("value-B",), + ) + self.assertNotIn( + "http.response.header.x_other_response_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "x-response-header", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "x-request-header", + }, + ) + def test_custom_headers_case_insensitive(self): + """Test that header capture is case-insensitive.""" + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument() + request_headers = {"X-ReQuESt-HeaDER": "custom-value"} + response_headers = {"X-ReSPoNse-HeaDER": "custom-value"} + responses.replace( + responses.GET, + self.URL, + body="Hello!", + headers=response_headers, + ) + result = niquests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_request_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.response.header.x_response_header"], + ("custom-value",), + ) + + +class TestNiquestsIntegrationPreparedRequest( + NiquestsIntegrationTestBase, TestBase +): + @staticmethod + def perform_request(url: str, session: niquests.Session = None): + if session is None: + session = niquests.Session() + request = niquests.Request("GET", url) + prepared_request = session.prepare_request(request) + return session.send(prepared_request) + + +class TestNiquestsIntegrationMetric(TestBase): + URL = "http://examplehost:8000/status/200" + + def setUp(self): + super().setUp() + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().instrument(meter_provider=self.meter_provider) + + responses.start() + responses.add(responses.GET, self.URL, body="Hello!") + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + responses.stop() + responses.reset() + + @staticmethod + def perform_request(url: str) -> niquests.Response: + return niquests.get(url, timeout=5) + + def test_basic_metric_success(self): + self.perform_request(self.URL) + + expected_attributes = { + HTTP_STATUS_CODE: 200, + HTTP_HOST: "examplehost", + NET_PEER_PORT: 8000, + NET_PEER_NAME: "examplehost", + HTTP_METHOD: "GET", + HTTP_SCHEME: "http", + } + + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 1) + for metric in metrics: + self.assertEqual(metric.unit, "ms") + self.assertEqual( + metric.description, + "measures the duration of the outbound HTTP request", + ) + for data_point in metric.data.data_points: + self.assertEqual( + data_point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + ) + actual = dict(data_point.attributes) + # responses mock does not provide raw.version + actual.pop(HTTP_FLAVOR, None) + self.assertDictEqual(expected_attributes, actual) + self.assertEqual(data_point.count, 1) + + def test_basic_metric_new_semconv(self): + self.perform_request(self.URL) + + expected_attributes = { + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_ADDRESS: "examplehost", + SERVER_PORT: 8000, + HTTP_REQUEST_METHOD: "GET", + } + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 1) + for metric in metrics: + self.assertEqual(metric.unit, "s") + self.assertEqual( + metric.description, "Duration of HTTP client requests." + ) + for data_point in metric.data.data_points: + self.assertEqual( + data_point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + actual = dict(data_point.attributes) + # responses mock does not provide raw.version + actual.pop(NETWORK_PROTOCOL_VERSION, None) + self.assertDictEqual(expected_attributes, actual) + self.assertEqual(data_point.count, 1) + + def test_basic_metric_both_semconv(self): + self.perform_request(self.URL) + + expected_attributes_old = { + HTTP_STATUS_CODE: 200, + HTTP_HOST: "examplehost", + NET_PEER_PORT: 8000, + NET_PEER_NAME: "examplehost", + HTTP_METHOD: "GET", + HTTP_SCHEME: "http", + } + + expected_attributes_new = { + HTTP_RESPONSE_STATUS_CODE: 200, + SERVER_ADDRESS: "examplehost", + SERVER_PORT: 8000, + HTTP_REQUEST_METHOD: "GET", + } + + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 2) + for metric in metrics: + for data_point in metric.data.data_points: + actual = dict(data_point.attributes) + if metric.unit == "ms": + # responses mock does not provide raw.version + actual.pop(HTTP_FLAVOR, None) + self.assertDictEqual( + expected_attributes_old, + actual, + ) + else: + actual.pop(NETWORK_PROTOCOL_VERSION, None) + self.assertDictEqual( + expected_attributes_new, + actual, + ) + self.assertEqual(data_point.count, 1) + + def test_custom_histogram_boundaries(self): + NiquestsInstrumentor().uninstrument() + custom_boundaries = (0.0, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0) + meter_provider, memory_reader = self.create_meter_provider() + NiquestsInstrumentor().instrument( + meter_provider=meter_provider, + duration_histogram_boundaries=custom_boundaries, + ) + + self.perform_request(self.URL) + metrics = memory_reader.get_metrics_data().resource_metrics[0] + self.assertEqual(len(metrics.scope_metrics), 1) + data_point = metrics.scope_metrics[0].metrics[0].data.data_points[0] + self.assertEqual(data_point.explicit_bounds, custom_boundaries) + self.assertEqual(data_point.count, 1) + + def test_custom_histogram_boundaries_new_semconv(self): + NiquestsInstrumentor().uninstrument() + custom_boundaries = (0.0, 5.0, 10.0, 25.0, 50.0, 100.0) + meter_provider, memory_reader = self.create_meter_provider() + NiquestsInstrumentor().instrument( + meter_provider=meter_provider, + duration_histogram_boundaries=custom_boundaries, + ) + + self.perform_request(self.URL) + metrics = memory_reader.get_metrics_data().resource_metrics[0] + self.assertEqual(len(metrics.scope_metrics), 1) + data_point = metrics.scope_metrics[0].metrics[0].data.data_points[0] + self.assertEqual(data_point.explicit_bounds, custom_boundaries) + self.assertEqual(data_point.count, 1) + + def test_basic_metric_non_recording_span(self): + expected_attributes = { + HTTP_STATUS_CODE: 200, + HTTP_HOST: "examplehost", + NET_PEER_PORT: 8000, + NET_PEER_NAME: "examplehost", + HTTP_METHOD: "GET", + HTTP_SCHEME: "http", + } + + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + NiquestsInstrumentor().uninstrument() + NiquestsInstrumentor().instrument( + tracer_provider=trace.NoOpTracerProvider() + ) + mock_span.is_recording.return_value = False + result = self.perform_request(self.URL) + self.assertEqual(result.text, "Hello!") + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + metrics = self.get_sorted_metrics(SCOPE) + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + actual = dict(duration_data_point.attributes) + # responses mock does not provide raw.version + actual.pop(HTTP_FLAVOR, None) + self.assertDictEqual(expected_attributes, actual) + self.assertEqual(duration_data_point.count, 1) + + +class TestNiquestsConnInfoAttributes(TestBase): + """Tests for niquests-specific span attributes derived from conn_info. + + Uses a custom adapter so we can inject a Response with realistic + ``conn_info`` fields that the ``responses`` mock library does not provide. + """ + + URL = "http://examplehost:8000/status/200" + + def setUp(self): + super().setUp() + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: "default", + }, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().instrument() + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + + @staticmethod + def perform_request(url: str, session: niquests.Session = None): + if session is None: + return niquests.get(url, timeout=5) + return session.get(url) + + def assert_span(self, num_spans=1): + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + if num_spans == 1: + return span_list[0] + return span_list + + @staticmethod + def _make_response_with_conn_info(ocsp_verified=None, **conn_kwargs): + """Build a Response with a mocked conn_info. + + The niquests Response.conn_info property delegates to + ``self.request.conn_info``, and Response.ocsp_verified delegates to + ``self.request.ocsp_verified``. We therefore attach these to a + stub request object. + """ + response = Response() + response.status_code = 200 + response.reason = "OK" + response.raw = TransportMock() + + ci = ConnectionInfo() + for key, val in conn_kwargs.items(): + setattr(ci, key, val) + + # Attach conn_info and ocsp_verified via a stub request. + stub_request = type( + "StubRequest", + (), + { + "conn_info": ci, + "ocsp_verified": ocsp_verified, + }, + )() + response.request = stub_request + return response + + def test_tls_attributes_on_span(self): + """tls.protocol.version and tls.cipher are set on the span when + conn_info has tls_version and cipher.""" + resp = self._make_response_with_conn_info( + tls_version=ssl.TLSVersion.TLSv1_3, + cipher="TLS_AES_256_GCM_SHA384", + ) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertEqual(span.attributes.get("tls.protocol.version"), "1.3") + self.assertEqual( + span.attributes.get("tls.cipher"), "TLS_AES_256_GCM_SHA384" + ) + + def test_tls_version_1_2(self): + """tls.protocol.version is correctly formatted for TLS 1.2.""" + resp = self._make_response_with_conn_info( + tls_version=ssl.TLSVersion.TLSv1_2, + cipher="ECDHE-RSA-AES128-GCM-SHA256", + ) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertEqual(span.attributes.get("tls.protocol.version"), "1.2") + + def test_no_tls_attributes_for_plain_http(self): + """When conn_info has no TLS fields, no tls.* attributes are set.""" + resp = self._make_response_with_conn_info( + destination_address=("127.0.0.1", 8000), + ) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertIsNone(span.attributes.get("tls.protocol.version")) + self.assertIsNone(span.attributes.get("tls.cipher")) + + def test_revocation_verified_true(self): + """revocation_verified is set on the span when response.ocsp_verified is True.""" + resp = self._make_response_with_conn_info(ocsp_verified=True) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertTrue(span.attributes.get("tls.revocation.verified")) + + def test_revocation_verified_false(self): + """revocation_verified is set on the span when response.ocsp_verified is False.""" + resp = self._make_response_with_conn_info(ocsp_verified=False) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertFalse(span.attributes.get("tls.revocation.verified")) + + def test_revocation_verified_not_set_when_none(self): + """revocation_verified is NOT set on the span when response.ocsp_verified is None.""" + resp = self._make_response_with_conn_info(ocsp_verified=None) + session = niquests.Session() + session.mount(self.URL, MyAdapter(resp)) + self.perform_request(self.URL, session) + + span = self.assert_span() + self.assertNotIn("tls.revocation.verified", span.attributes) + + +class TestNiquestsConnectionMetrics(TestBase): + """Tests for niquests-specific connection sub-duration histogram metrics. + + Uses ``MyAdapter`` to inject a Response with realistic ``conn_info`` + latency fields, then asserts the histograms are recorded correctly. + """ + + URL = "http://examplehost:8000/status/200" + + def setUp(self): + super().setUp() + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: "http", + }, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().instrument(meter_provider=self.meter_provider) + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + _OpenTelemetrySemanticConventionStability._initialized = False + NiquestsInstrumentor().uninstrument() + + @staticmethod + def perform_request(url: str, session: niquests.Session = None): + if session is None: + return niquests.get(url, timeout=5) + return session.get(url) + + def test_connection_latency_metrics(self): + """All four connection sub-duration histograms are recorded from conn_info.""" + ci = ConnectionInfo() + ci.resolution_latency = timedelta(milliseconds=5) + ci.established_latency = timedelta(milliseconds=10) + ci.tls_handshake_latency = timedelta(milliseconds=15) + ci.request_sent_latency = timedelta(milliseconds=2) + + response = Response() + response.status_code = 200 + response.reason = "OK" + response.raw = TransportMock() + response.request = type( + "StubRequest", + (), + { + "conn_info": ci, + "ocsp_verified": None, + }, + )() + + session = niquests.Session() + session.mount(self.URL, MyAdapter(response)) + self.perform_request(self.URL, session) + + metrics = self.get_sorted_metrics(SCOPE) + metric_names = {m.name for m in metrics} + + expected_metrics = { + "http.client.connection.dns.duration", + "http.client.connection.tcp.duration", + "http.client.connection.tls.duration", + "http.client.request.send.duration", + } + # The main HTTP duration histogram is also present. + self.assertTrue( + expected_metrics.issubset(metric_names), + f"Missing metrics: {expected_metrics - metric_names}", + ) + + # Verify each connection metric was recorded once with correct value. + expected_values = { + "http.client.connection.dns.duration": 0.005, + "http.client.connection.tcp.duration": 0.010, + "http.client.connection.tls.duration": 0.015, + "http.client.request.send.duration": 0.002, + } + for metric in metrics: + if metric.name in expected_values: + self.assertEqual(metric.unit, "s") + dp = metric.data.data_points[0] + self.assertEqual(dp.count, 1) + self.assertAlmostEqual( + dp.sum, expected_values[metric.name], places=6 + ) + + def test_connection_metrics_missing_latencies(self): + """When conn_info latencies are None, no connection metrics are recorded.""" + ci = ConnectionInfo() + # All latency fields default to None + + response = Response() + response.status_code = 200 + response.reason = "OK" + response.raw = TransportMock() + response.request = type( + "StubRequest", + (), + { + "conn_info": ci, + "ocsp_verified": None, + }, + )() + + session = niquests.Session() + session.mount(self.URL, MyAdapter(response)) + self.perform_request(self.URL, session) + + metrics = self.get_sorted_metrics(SCOPE) + conn_metrics = [ + metric + for metric in metrics + if metric.name.startswith("http.client.connection.") + or metric.name == "http.client.request.send.duration" + ] + + # Connection metrics should exist (histograms are created) but have + # zero data points since no latencies were available. + for metric in conn_metrics: + for dp in metric.data.data_points: + self.assertEqual(dp.count, 0) + + def test_connection_metrics_zero_latencies_skipped(self): + """Zero-duration latencies (e.g. DNS on a reused connection) must not + be recorded so they don't pollute the histogram.""" + ci = ConnectionInfo() + ci.resolution_latency = timedelta(0) # no DNS (reused conn) + ci.established_latency = timedelta(0) # no TCP (reused conn) + ci.tls_handshake_latency = timedelta(0) # no TLS (reused conn) + ci.request_sent_latency = timedelta(milliseconds=3) # always > 0 + + response = Response() + response.status_code = 200 + response.reason = "OK" + response.raw = TransportMock() + response.request = type( + "StubRequest", + (), + { + "conn_info": ci, + "ocsp_verified": None, + }, + )() + + session = niquests.Session() + session.mount(self.URL, MyAdapter(response)) + self.perform_request(self.URL, session) + + metrics = self.get_sorted_metrics(SCOPE) + metrics_by_name = {m.name: m for m in metrics} + + # The three zero-duration phases must NOT have been recorded. + for name in [ + "http.client.connection.dns.duration", + "http.client.connection.tcp.duration", + "http.client.connection.tls.duration", + ]: + metric = metrics_by_name.get(name) + if metric is not None: + for dp in metric.data.data_points: + self.assertEqual( + dp.count, + 0, + f"{name} should not record zero-duration latencies", + ) + + # request_sent_latency was > 0, so it SHOULD have been recorded. + send_metric = metrics_by_name.get("http.client.request.send.duration") + self.assertIsNotNone(send_metric) + dp = send_metric.data.data_points[0] + self.assertEqual(dp.count, 1) + self.assertAlmostEqual(dp.sum, 0.003, places=6) + + def test_connection_metrics_no_conn_info(self): + """When conn_info is None, no connection metrics are recorded.""" + response = Response() + response.status_code = 200 + response.reason = "OK" + response.raw = TransportMock() + # response.request left as None => conn_info returns None + + session = niquests.Session() + session.mount(self.URL, MyAdapter(response)) + self.perform_request(self.URL, session) diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py new file mode 100644 index 0000000000..db0a0faa83 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_niquests_ip_support.py @@ -0,0 +1,151 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import niquests + +from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.instrumentation.niquests import NiquestsInstrumentor +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_VERSION, +) +from opentelemetry.test.httptest import HttpTestBase +from opentelemetry.test.test_base import TestBase + + +class TestNiquestsInstrumentorWithRealSocket(HttpTestBase, TestBase): + def setUp(self): + super().setUp() + self.assert_ip = self.server.server_address[0] + self.assert_port = self.server.server_address[1] + self.http_host = ":".join(map(str, self.server.server_address[:2])) + self.http_url_base = "http://" + self.http_host + self.http_url = self.http_url_base + "/status/200" + NiquestsInstrumentor().instrument() + + def tearDown(self): + super().tearDown() + NiquestsInstrumentor().uninstrument() + + @staticmethod + def perform_request(url: str) -> niquests.Response: + return niquests.get(url, timeout=5) + + def test_basic_http_success(self): + response = self.perform_request(self.http_url) + self.assert_success_span(response) + + def test_basic_http_success_using_connection_pool(self): + with niquests.Session() as session: + response = session.get(self.http_url) + + self.assert_success_span(response) + + # Test that when re-using an existing connection, everything still works. + # Especially relevant for IP capturing. + response = session.get(self.http_url) + + self.assert_success_span(response) + + def test_http_version_format(self): + """Verify the HTTP version attribute uses the correct semconv format. + + The ``network.protocol.version`` attribute must be a bare version + string such as ``"1.1"`` – NOT ``"HTTP/1.1"``. + """ + response = self.perform_request(self.http_url) + self.assertEqual("Hello!", response.text) + + span = self.assert_span() + # The test HTTP server speaks HTTP/1.1 + version = span.attributes.get("http.flavor") + if version is not None: + self.assertNotIn("HTTP/", version) + self.assertEqual("1.1", version) + + def test_http_version_new_semconv(self): + """Verify network.protocol.version is set in new semconv mode.""" + NiquestsInstrumentor().uninstrument() + _OpenTelemetrySemanticConventionStability._initialized = False + with mock.patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: "http"}, + ): + NiquestsInstrumentor().instrument() + response = self.perform_request(self.http_url) + self.assertEqual("Hello!", response.text) + + span = self.assert_span() + version = span.attributes.get(NETWORK_PROTOCOL_VERSION) + self.assertIsNotNone(version) + self.assertNotIn("HTTP/", version) + self.assertEqual("1.1", version) + + _OpenTelemetrySemanticConventionStability._initialized = False + + def test_ip_new_semconv_attributes(self): + """Verify network.peer.address and network.peer.port are set from + conn_info when using the new semantic conventions.""" + NiquestsInstrumentor().uninstrument() + _OpenTelemetrySemanticConventionStability._initialized = False + with mock.patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: "http"}, + ): + NiquestsInstrumentor().instrument() + response = self.perform_request(self.http_url) + self.assertEqual("Hello!", response.text) + + span = self.assert_span() + # network.peer.address should be the actual IP, not the hostname + self.assertEqual( + span.attributes.get(NETWORK_PEER_ADDRESS), self.assert_ip + ) + self.assertEqual( + span.attributes.get(NETWORK_PEER_PORT), self.assert_port + ) + # In new-only mode, old semconv net.peer.ip must NOT be set. + self.assertIsNone(span.attributes.get("net.peer.ip")) + + _OpenTelemetrySemanticConventionStability._initialized = False + + def assert_span(self, num_spans=1): # TODO: Move this to TestBase + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + self.memory_exporter.clear() + if num_spans == 1: + return span_list[0] + return span_list + + def assert_success_span(self, response: niquests.Response): + self.assertEqual("Hello!", response.text) + + span = self.assert_span() + self.assertIs(trace.SpanKind.CLIENT, span.kind) + self.assertEqual("GET", span.name) + + attributes = { + "http.status_code": 200, + "net.peer.ip": self.assert_ip, + } + self.assertGreaterEqual(span.attributes.items(), attributes.items()) diff --git a/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py new file mode 100644 index 0000000000..37c9888e57 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-niquests/tests/test_user_agent_synthetic.py @@ -0,0 +1,202 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import niquests +import responses + +from opentelemetry.instrumentation.niquests import NiquestsInstrumentor +from opentelemetry.semconv._incubating.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, + USER_AGENT_SYNTHETIC_TYPE, + UserAgentSyntheticTypeValues, +) +from opentelemetry.test.test_base import TestBase + + +class TestUserAgentSynthetic(TestBase): + URL = "http://mock/status/200" + + def setUp(self): + super().setUp() + NiquestsInstrumentor().instrument() + responses.start() + responses.add(responses.GET, self.URL, body="Hello!") + + def tearDown(self): + super().tearDown() + NiquestsInstrumentor().uninstrument() + responses.stop() + responses.reset() + + def assert_span(self, num_spans=1): + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(num_spans, len(span_list)) + if num_spans == 0: + return None + if num_spans == 1: + return span_list[0] + return span_list + + def test_user_agent_bot_googlebot(self): + """Test that googlebot user agent is marked as 'bot'""" + headers = { + "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + } + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.BOT.value, + ) + + def test_user_agent_bot_bingbot(self): + """Test that bingbot user agent is marked as 'bot'""" + headers = { + "User-Agent": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" + } + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.BOT.value, + ) + + def test_user_agent_test_alwayson(self): + """Test that alwayson user agent is marked as 'test'""" + headers = {"User-Agent": "AlwaysOn-Monitor/1.0"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.TEST.value, + ) + + def test_user_agent_case_insensitive(self): + """Test that detection is case insensitive""" + headers = {"User-Agent": "GOOGLEBOT/2.1"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.BOT.value, + ) + + self.memory_exporter.clear() + + headers = {"User-Agent": "ALWAYSON-Monitor/1.0"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.TEST.value, + ) + + def test_user_agent_normal_browser(self): + """Test that normal browser user agents don't get synthetic type""" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes) + + def test_no_user_agent_header(self): + """Test that requests without user agent don't get synthetic type""" + niquests.get(self.URL, timeout=5) + + span = self.assert_span() + self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes) + + def test_empty_user_agent_header(self): + """Test that empty user agent doesn't get synthetic type""" + headers = {"User-Agent": ""} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertNotIn(USER_AGENT_SYNTHETIC_TYPE, span.attributes) + + def test_user_agent_substring_match(self): + """Test that substrings are detected correctly""" + # Test googlebot in middle of string + headers = {"User-Agent": "MyApp/1.0 googlebot crawler"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.BOT.value, + ) + + self.memory_exporter.clear() + + # Test alwayson in middle of string + headers = {"User-Agent": "TestFramework/1.0 alwayson monitoring"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.TEST.value, + ) + + def test_user_agent_priority_alwayson_over_bot(self): + """Test that alwayson takes priority if both patterns match""" + headers = {"User-Agent": "alwayson-googlebot/1.0"} + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + # alwayson should be checked first and return 'test' + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.TEST.value, + ) + + def test_user_agent_bytes_like_header(self): + """Test that bytes-like user agent headers are handled.""" + + original_prepare_headers = ( + niquests.models.PreparedRequest.prepare_headers + ) + + def prepare_headers_bytes(self, headers): + original_prepare_headers(self, headers) + if "User-Agent" in self.headers: + value = self.headers["User-Agent"] + if isinstance(value, str): + self.headers["User-Agent"] = value.encode("utf-8") + + headers = {"User-Agent": "AlwaysOn-Monitor/1.0"} + with mock.patch( + "niquests.models.PreparedRequest.prepare_headers", + new=prepare_headers_bytes, + ): + niquests.get(self.URL, headers=headers, timeout=5) + + span = self.assert_span() + self.assertEqual( + span.attributes.get(USER_AGENT_SYNTHETIC_TYPE), + UserAgentSyntheticTypeValues.TEST.value, + ) + self.assertEqual( + span.attributes.get(USER_AGENT_ORIGINAL), + "AlwaysOn-Monitor/1.0", + ) diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 630509a4b2..4c9941e029 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ "opentelemetry-instrumentation-logging==0.63b0.dev", "opentelemetry-instrumentation-mysql==0.63b0.dev", "opentelemetry-instrumentation-mysqlclient==0.63b0.dev", + "opentelemetry-instrumentation-niquests==0.63b0.dev", "opentelemetry-instrumentation-pika==0.63b0.dev", "opentelemetry-instrumentation-psycopg==0.63b0.dev", "opentelemetry-instrumentation-psycopg2==0.63b0.dev", diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 999181bd9f..db6de0aff2 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -136,6 +136,10 @@ "library": "mysqlclient < 3", "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.63b0.dev", }, + { + "library": "niquests >= 3.0, < 4", + "instrumentation": "opentelemetry-instrumentation-niquests==0.63b0.dev", + }, { "library": "pika >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-pika==0.63b0.dev", diff --git a/pyproject.toml b/pyproject.toml index 92cfe8b5d2..d02bc29e64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "opentelemetry-instrumentation-redis[instruments]", "opentelemetry-instrumentation-remoulade[instruments]", "opentelemetry-instrumentation-requests[instruments]", + "opentelemetry-instrumentation-niquests[instruments]", "opentelemetry-instrumentation-sqlalchemy[instruments]", "opentelemetry-instrumentation-sqlite3", "opentelemetry-instrumentation-system-metrics", @@ -124,6 +125,7 @@ opentelemetry-instrumentation-pyramid = { workspace = true } opentelemetry-instrumentation-redis = { workspace = true } opentelemetry-instrumentation-remoulade = { workspace = true } opentelemetry-instrumentation-requests = { workspace = true } +opentelemetry-instrumentation-niquests = { workspace = true } opentelemetry-instrumentation-sqlalchemy = { workspace = true } opentelemetry-instrumentation-sqlite3 = { workspace = true } opentelemetry-instrumentation-system-metrics = { workspace = true } diff --git a/tox.ini b/tox.ini index 1f2ca7f8e9..a522ea5bc8 100644 --- a/tox.ini +++ b/tox.ini @@ -189,6 +189,10 @@ envlist = ;pypy3-test-instrumentation-requests lint-instrumentation-requests + ; opentelemetry-instrumentation-niquests + py3{10,11,12,13,14}-test-instrumentation-niquests + lint-instrumentation-niquests + ; opentelemetry-instrumentation-starlette py3{10,11,12,13,14}-test-instrumentation-starlette-{oldest,latest} pypy3-test-instrumentation-starlette-{oldest,latest} @@ -672,6 +676,9 @@ deps = requests: {[testenv]test_deps} requests: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-requests/test-requirements.txt + niquests: {[testenv]test_deps} + niquests: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-niquests/test-requirements.txt + starlette: {[testenv]test_deps} starlette-{oldest,lint}: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-starlette/test-requirements.oldest.txt starlette-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-starlette/test-requirements.latest.txt @@ -959,6 +966,9 @@ commands = test-instrumentation-requests: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-requests/tests {posargs} lint-instrumentation-requests: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-requests" + test-instrumentation-niquests: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-niquests/tests {posargs} + lint-instrumentation-niquests: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-niquests" + test-instrumentation-sqlalchemy: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests {posargs} lint-instrumentation-sqlalchemy: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-sqlalchemy" diff --git a/uv.lock b/uv.lock index 14d5ea35b6..9a0c984f51 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ members = [ "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-mysql", "opentelemetry-instrumentation-mysqlclient", + "opentelemetry-instrumentation-niquests", "opentelemetry-instrumentation-openai-agents-v2", "opentelemetry-instrumentation-openai-v2", "opentelemetry-instrumentation-pika", @@ -1967,6 +1968,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "jh2" +version = "5.0.11" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/ea/ebd7cbba422317fdd4be5e04a5aa9a54192b8c483f20eb38c85cf9fb8adc/jh2-5.0.11.tar.gz", hash = "sha256:6c835b0b38d795dde7aaa4581626490ca5fcfbd4eefe9572ac18d9eb2427d215", size = 7320877, upload-time = "2026-04-05T07:33:51.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d8/4f885e71a3dd9a89b92d7db1888ab1c0ddb1fc85d733b593fdef50fcc090/jh2-5.0.11-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cf85910f5d8506467e9a6fc9be3140f4ffe49e2baa973b71c83822c6e6e88480", size = 606617, upload-time = "2026-04-05T07:30:50.965Z" }, + { url = "https://files.pythonhosted.org/packages/e3/55/cba74b4b3093504f7c50424a5decd0ce27a8e86669d8006c4facbedde5ad/jh2-5.0.11-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86d1bd875161ce4d5303e667ad19fb7436476d1610aa04b21c14838c1669f32a", size = 384519, upload-time = "2026-04-05T07:30:53.05Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7e/c34f56f9d1d68e5aa96ddcf1ad4b74701f1d8318a4fafe6dd4e512f6eda2/jh2-5.0.11-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e20f3bcf50192caea969b4bb674c8f6dc607fb5f8abe6b76248f698e9e4cab84", size = 392005, upload-time = "2026-04-05T07:30:54.81Z" }, + { url = "https://files.pythonhosted.org/packages/1b/19/a2840d299c85ae861ed1020f37865913853963ccd49b8a723d09270bd7e8/jh2-5.0.11-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8afe44228388f9282b4e3804e0212fc7f000ede156e73b2068f61fb821598c9f", size = 512189, upload-time = "2026-04-05T07:30:56.633Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7c/c11a478e6ba7243f2617cfdba868970acaef1f64d523659cccc2eeb67519/jh2-5.0.11-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176f4de35aef5f3eef38d6ae785bb530f911af1fc6a21512da620250cde95a94", size = 505287, upload-time = "2026-04-05T07:30:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/c8b6803f4d6cf30b47d40d093099eea0c06cdce82af4f2632480d284a9ba/jh2-5.0.11-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa8c32df2426f7a9d8633c2c8b5555edcace6e703640cb50f7ecb5732d9b50c", size = 405622, upload-time = "2026-04-05T07:30:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/95e4111fa3da434e41f71bf3d57faa5c2989f3f99d263bb572552bbf502e/jh2-5.0.11-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af8d4f64794823fcdaa1ab4d01e40361e0dc0ddda9a6523e96a72b47a9e96e7f", size = 389889, upload-time = "2026-04-05T07:31:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/aacbd532a1bb67a253d9b75c7b3b8351c24142b7a88ba87cdb96d3459ea4/jh2-5.0.11-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:611c474b2c998fb09f5825dbe88626cc86c991a6d7dbc4c0d2a0848fa2fa437d", size = 408073, upload-time = "2026-04-05T07:31:03.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/12/2bfb901198043395f56c5b10cd573b55ae490a3c670bb29294bd2d5e67ec/jh2-5.0.11-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c39968cf5547d68f97a893c518f02f6caca94942206a958d8aa9325f8c3e330", size = 560987, upload-time = "2026-04-05T07:31:04.507Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/96fdd7285dec74e6e3daab7983cd03053046ccaaa9b767ff12064b0b525b/jh2-5.0.11-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:7c19511733a8ccf998042b64ac2077c334d73f2d0df4ce80b158694191a1f707", size = 666856, upload-time = "2026-04-05T07:31:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/2ea20b5282b6fff062902b203cbaf6e5e2315ef9aac881635c025fd49bf5/jh2-5.0.11-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:fa18a2886a229a0d53a2c6c3d109079cdd2550466f2ae2286e43a1c66d47d627", size = 625983, upload-time = "2026-04-05T07:31:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/d3/06/fbaf3037c9b2e909a66334e5d342eb0ac0179963d33b757ba104e1efe619/jh2-5.0.11-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:5d6a1000872f99a2d50316bdab7dcb8a9eccebd1c7ca4ba2e656a74ee48015ac", size = 593492, upload-time = "2026-04-05T07:31:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/c35622ed1cbdda8fe8677a1bcff090f95db9f85dfbb9de1aa2fdbf7db02c/jh2-5.0.11-cp313-cp313t-win32.whl", hash = "sha256:89c46416ccf0f457bfd4df67670c79052116f07ffb3951c5103d178c6bf372ec", size = 237224, upload-time = "2026-04-05T07:31:12.409Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5f/4296893bef63687498722638dfcef85610cfd232e97088dc067ee874c331/jh2-5.0.11-cp313-cp313t-win_amd64.whl", hash = "sha256:cd4187891ebc44e782c5606393e16818d63bc1dbd3a0028bafed62e2d0fdd3f2", size = 244265, upload-time = "2026-04-05T07:31:14.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cc/48e8b6220eb17bec051cccc262935a1389a4e09557168c9a9a15bd378672/jh2-5.0.11-cp313-cp313t-win_arm64.whl", hash = "sha256:dfb99fe1bd951d2da7d5dd90325c8a3c3834dd614339f536a45cbd1bc1335f1e", size = 240543, upload-time = "2026-04-05T07:31:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/e9/51/f8dd078ba26c709cb89b48eef0259c714956e74bc5be1a1db48e40eaebbb/jh2-5.0.11-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:92ff21001d59d47f929418d0dae55a97be16221c13e1f7ed134bdc79189475fb", size = 606275, upload-time = "2026-04-05T07:31:17.228Z" }, + { url = "https://files.pythonhosted.org/packages/b0/09/f94b94f2a7683c6a5c1a1bcfbc34bb7fbcb09e16014dd7b8172bc7a365f1/jh2-5.0.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc22823c633e95c6b5298f9ffe2d77f0f1787f2d03c47ccb7dff006e6c30fac3", size = 384500, upload-time = "2026-04-05T07:31:19.083Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e8/b0994158d6181be0c8e5674b6a988db0712f1afd1e9f8df2c2ae6faf90fb/jh2-5.0.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08392b71819ef4dec683010b0366b15da8ed495250110c6009833f25855ab6a4", size = 392121, upload-time = "2026-04-05T07:31:20.966Z" }, + { url = "https://files.pythonhosted.org/packages/61/8a/d2c2352bb21d69b6a8c678c21d8ca54d00474f80e3273151575eb9342a5a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3ebfcd80cfcaa17bbb5733871953d1df79e1cc8bdc0f22d7372d9f2ef3524008", size = 511896, upload-time = "2026-04-05T07:31:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/e7/94/20522d75f8beaf036541b012947ddc834a649bd1be6b4fc6126fe9225aa4/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f26cdbf79bd0792bc65b7825b356040c56c365041a6ae7c44e5655f8fa173fe6", size = 505414, upload-time = "2026-04-05T07:31:25.104Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e7/df5ddd84b1d0c2e70c244a9a51b31d93f77ebd7f7eddfc79a3a3d66f5e3e/jh2-5.0.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d7e43c6248e3a091e9f6c5aac23236bd7ba0e30d240f4017b644bd3da049688", size = 405467, upload-time = "2026-04-05T07:31:27Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8d/c3f00aaeb516926d35482d0b5f62dde8bfa66b8b1e89107df4e1c3699e1a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34c0bbf4688917a3a1b1dba176bc49bb5b1ad4b75765431b989f7767061df432", size = 390021, upload-time = "2026-04-05T07:31:28.585Z" }, + { url = "https://files.pythonhosted.org/packages/40/dc/8f3e7d0eeb6bbd7ef2c9fb186f6a38e1b883e9db7622a17050594be82b66/jh2-5.0.11-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:740e4e489759b749aaed695e8430d28a039c11765fc5e4d1b20bfad9c7e192f1", size = 408069, upload-time = "2026-04-05T07:31:30.186Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/820f50745957e491e081b39b1333a6bf0893a9528002133951b9f6fe9fc9/jh2-5.0.11-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:57601fd1c5f6fce9e63ea1f2a61f83784478cf4d58e8491a7c18cc05abdb8e96", size = 560975, upload-time = "2026-04-05T07:31:31.64Z" }, + { url = "https://files.pythonhosted.org/packages/00/c2/a658a73429bd96cf3c12dbfa9270e5ffb117d1d3207fbe0e72ebaff1fdb5/jh2-5.0.11-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:c7834d1000ac856234e7b574ed2ccf2136aab325d84051edb1db06c17e295df4", size = 667148, upload-time = "2026-04-05T07:31:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/be/a3/d5a789af902526265143631d14ad07f9dda88afa71e99b4f31495d7a0f53/jh2-5.0.11-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:985a9eb136e7897bcedba873cf30b51c19481d94ab31a391d05eeecf27c390ba", size = 625865, upload-time = "2026-04-05T07:31:34.912Z" }, + { url = "https://files.pythonhosted.org/packages/18/97/129ec3c3ca5d46d4eff1b230623a98c0cc93d85663850533ba4b416eead8/jh2-5.0.11-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3c3b06db73cde4e350e8acd5960e6bd9880e512cc8ab9c28003c74414261382b", size = 593557, upload-time = "2026-04-05T07:31:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/e8eb939e21b3d963206081d0bc9eb17b494b4747ea537e70337f5c5ae7d3/jh2-5.0.11-cp314-cp314t-win32.whl", hash = "sha256:ebe5ec3b51704119ca66717828631a777bc64132517f445d0b9ac2f30dd38264", size = 237324, upload-time = "2026-04-05T07:31:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/fb/57/298d81b6477bfe573b4a02234c39c06ddcbb906114f339d119253bef4a3b/jh2-5.0.11-cp314-cp314t-win_amd64.whl", hash = "sha256:05102a4610dde1dc59c630e64ca34a74076d1afd275dbeac954b230a605788b9", size = 244785, upload-time = "2026-04-05T07:31:39.411Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a5/3f6477c76630134ac3db8093b64b07fa3c15f03d7274836656dce53d7585/jh2-5.0.11-cp314-cp314t-win_arm64.whl", hash = "sha256:7a1388738fcce0ddc8e742d2d1c0619911299f339d54a19496bcbecfb4d7e775", size = 240967, upload-time = "2026-04-05T07:31:40.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f0/0363ffb6ed11a76d47c6a543f16c3c3e6efedc27853fa1ef95df557c0724/jh2-5.0.11-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4dc82aee3ab2c4103f3d9092f4463dd6cc4a248ab6a27a4acab79bef0d3ac8dd", size = 622317, upload-time = "2026-04-05T07:31:42.411Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc161568713450214d3c48db614d871f9393bd88db471e10ac868f5a7214/jh2-5.0.11-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85cf4f09f7159c29967212af685d2819f960d9136d931420fef107683d121f56", size = 393320, upload-time = "2026-04-05T07:31:43.814Z" }, + { url = "https://files.pythonhosted.org/packages/68/0e/f300ee75f1bd3c8212d084d4e52a83e81fca2dbfe4c10c4b97bb4a8b9743/jh2-5.0.11-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daadac34cefe67ea03a7d2324e03fc9b37ec8820604f1563e7d424471bee29b6", size = 399898, upload-time = "2026-04-05T07:31:45.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/74/cd7e97f87e85ddfb0449ff50d23c0ed3967348fd4c79917972ac0e277e84/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:558d4c15bc42419262ef15595d9c488ae53276b562397314e1cc934f4c7e4bdf", size = 520449, upload-time = "2026-04-05T07:31:47.158Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9b/b94e76661c2ade180654687cb7819106427a711e6b40c31cb5c41cac33fa/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c61f837b3e5c897bd2a90149afd615f59d24c72c893526485bca1b40f6ec49", size = 512366, upload-time = "2026-04-05T07:31:48.644Z" }, + { url = "https://files.pythonhosted.org/packages/fe/de/ddba9eb5ca665bcf557cb28b99e96a06df1b570bcfc086ab0bd21b1232f0/jh2-5.0.11-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a433f014c207ffc4b3eda0165fbd4d7d978b53cbdd6e71d441531221b2b1b879", size = 414743, upload-time = "2026-04-05T07:31:50.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/b80fd188fa006a144cd4f280fdfd3808e3715aaa805fa830e828406080ed/jh2-5.0.11-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d15672b32f0891940691bac16a854af164e694f0b9d21bebfddd13e3c7d2f03", size = 399371, upload-time = "2026-04-05T07:31:51.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/d5ead4a7379bcc0386baed535183f8168b2bda0cf3e368586f6d4ab44024/jh2-5.0.11-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbf08eead0483ecaf275c2f447b704d1583278f7abd6f0e945fccd6a581c7df4", size = 418311, upload-time = "2026-04-05T07:31:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8d/59dd21f1ed6f451f60581522e08e42f3e74275b1568ef9c7936a06445108/jh2-5.0.11-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:6cd51dd02943b703e10eb536722c5fd205b6084333dac5b9c114bdbbc2c46b3a", size = 569633, upload-time = "2026-04-05T07:31:55.228Z" }, + { url = "https://files.pythonhosted.org/packages/28/0a/e53f7b8c13638c712d7ad1b8f0af29ec97a46ae67d912e501ce0a606f869/jh2-5.0.11-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:58f4c9da6555923f731e358d975e40d4ae6241c05b29e5a0f4dc8c91781cc229", size = 675463, upload-time = "2026-04-05T07:31:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5a/e02ad465d53f144a3d5a7b52cf57e6f14800ec65ac6c90d081dbdf9c507d/jh2-5.0.11-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6ba33ff1d1275586bb4d83687c59783dad60b66ef3d420c04982bae7e0d75f9b", size = 636580, upload-time = "2026-04-05T07:31:59.03Z" }, + { url = "https://files.pythonhosted.org/packages/82/55/db34614186693ce66c69af384659c5f37fa79699c81c8867cec2fe4dc566/jh2-5.0.11-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b0ad821964a7701e2b80c6f8b424b6d4ca575fefb1aa04227967ef78fa15fcd5", size = 603360, upload-time = "2026-04-05T07:32:00.681Z" }, + { url = "https://files.pythonhosted.org/packages/e7/07/1490e419fe03ec9afb7c2571cfd3410d34bd5367dc36bbbc9d52d21f44ae/jh2-5.0.11-cp37-abi3-win32.whl", hash = "sha256:18f10dcf0aa9f19833ac0f4d58b195af2d0b056423d428f74bf03f7839db8055", size = 245065, upload-time = "2026-04-05T07:32:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/5a/60/69a4fcc00a01fa65bbf67279e21886325087491250bc54af3e12e86c8532/jh2-5.0.11-cp37-abi3-win_amd64.whl", hash = "sha256:e28dabffcbd5525bf5f36d482764e3e56b513bce06a75b2fb4b540bedad80348", size = 251415, upload-time = "2026-04-05T07:32:04.021Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9e/33ba56e964b58b056a4c17003c0be245be3dcfda17e9f1df95cd5209ece8/jh2-5.0.11-cp37-abi3-win_arm64.whl", hash = "sha256:dfbb07be66cb96a289c876aaab7ac46da4fb70f6526298f1fda60076b971d5f0", size = 247273, upload-time = "2026-04-05T07:32:05.771Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/e6d5f717a5383c0384882add7410060694533efcfe7051c26ce21301d6d0/jh2-5.0.11-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:842f27673350dc22659cc0dfba035bf610927810fcfb6a9ddea594dcb3cfe774", size = 619041, upload-time = "2026-04-05T07:32:07.241Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/3df905164fde3793d579bcb46e9cf1d246d902a6c7e6c19db49a99166aec/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdb4bc4cb82e66a41d4f4ec9fb80f7ed7981cf7786efb6a94cf47ea27ec90e28", size = 390780, upload-time = "2026-04-05T07:32:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/92/88/db049e5bf4e1514c5328e1c5ce3caa6ab081c56c12617061827d9fa9ef72/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31ddace327ac78b3137d79c4ce1a64fb8d5b1256a88078e7806b20280a22ed1c", size = 398254, upload-time = "2026-04-05T07:32:10.756Z" }, + { url = "https://files.pythonhosted.org/packages/8a/79/74501282f0482105dc752230ecb9bec865ea07d8344cd81b87860805090c/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fc2bdcc2fecb3e3382bbf2834d81b981c3b8978be67c54c2aeeba01dee911161", size = 517176, upload-time = "2026-04-05T07:32:12.825Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d2/a261ad53dc5f7b52066d33fd7eb58119285382bacd786002be2b7ddf6d14/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da8fbddf3e00a9e8e18afda67721e71cc69f1a81ba16a4a7b50f57efa47d2991", size = 509867, upload-time = "2026-04-05T07:32:14.71Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a8/c1d72032e4d4ef05146f12496b91037ad785f0c281a80eaf20fe40909bb6/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97da940c5bc7f9ca1ddde294fac46f75ca4be2a7b7be5a32e87b6195f2f4203d", size = 411500, upload-time = "2026-04-05T07:32:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/cb7a4469d1738aea67f7786164b5d63b36d78fc185a6036ce7f582dae51a/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73db6c7374aebb94e2758e1c34c0090b1dee39f13a28b812387c8e9478cdbee1", size = 397270, upload-time = "2026-04-05T07:32:17.515Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6d/3059c02b3b1193fdf87fb65b7b4dc4562028992ed2cd14cc3a67ca8fcd71/jh2-5.0.11-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf2a83fcbcd3dd53b80574655a54459e7ddd591d936ac67e636330764c75907", size = 415937, upload-time = "2026-04-05T07:32:19.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7c/d8672da7971dec40cca96ea6a6f8574bc4d13958f427d5eda7ee9c92ea9e/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1517147850dd3bcf8e3204e7e4b4016e47440a889accfd6b055734dd2686bc89", size = 567045, upload-time = "2026-04-05T07:32:20.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5c/5c3b2d7def23f4c4c53b7af7723a6086de6e77169ab14a96dc3ef37c1ee6/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c0693e9efcb492f48b61453b6fca3cee60c544c494fa1eb7ab63dbe493189db4", size = 673554, upload-time = "2026-04-05T07:32:22.508Z" }, + { url = "https://files.pythonhosted.org/packages/71/91/c1cf8168392aedb0eade45df2b99c1239bfd31f52542f527ad472e2ad0b8/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:e5616ffeb5b173b540e2db230546a476c5618cf25dac5bb9149c06fc6b0a9e4f", size = 634024, upload-time = "2026-04-05T07:32:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/5f/69/6b33f1b2ff6ff77add2f09580d7a5499f0f1d402ce4b8d45bb267394b61b/jh2-5.0.11-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f88407c7d2de429346e589ede8f0eaa594d6a8a3e388b658bbf4828998a53a0", size = 600998, upload-time = "2026-04-05T07:32:25.922Z" }, + { url = "https://files.pythonhosted.org/packages/c5/45/10a5d15a93d15aae67b6dd15042b248ed1230c0dbeddce4c897f8cda4849/jh2-5.0.11-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd19d6e0f8b82dd92e9e9836baf8c5d3a18d15d06c7838c64dae0f45b0cad24d", size = 613531, upload-time = "2026-04-05T07:32:27.445Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/c97912c0c1ec27f128c0a64e9c61fdcc695512f81c7bd6b40af9b774a891/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31d5c24cc3c20b49ad00e25e2d429d51b240a7f7fc8910c48a1fa11cf84f0c71", size = 388839, upload-time = "2026-04-05T07:32:29.197Z" }, + { url = "https://files.pythonhosted.org/packages/03/d3/7c690b4d93a838581a4179f981f562e3987d2813275cd9f1b6b4e886ce5a/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:264b93edeb9368cd4ad8b8ba4a23e9404ba6a449ded00f6c1e62b259fabf43c8", size = 394652, upload-time = "2026-04-05T07:32:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/3ea94d6b30f7d1987b0ec1b4579f74e9878b63b61290ecd63610ebb639ce/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6dc37f78187655cf95032bee0e9578ff89d39734def8e27a6bf930d4caa08042", size = 513824, upload-time = "2026-04-05T07:32:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/c92cd7ac9a831319d9b73746a59590aaa2a795b3e5c9fa25302296414486/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d4c26ef61db31c6f33a572b40c6eff312131cac83300bbf6d75fbed1e5f073e", size = 509687, upload-time = "2026-04-05T07:32:33.936Z" }, + { url = "https://files.pythonhosted.org/packages/8a/20/7c897e440bb082468412bc0a8b11507d5268e1c29145ed31c0a47d7dae67/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:070adb3943f306257fff6dff4cbdcb5324afd78cbfa624f6686f198e6381d707", size = 408435, upload-time = "2026-04-05T07:32:37.212Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/75a88341a7a4d03ddb400062c0240bae5f41a64df68c83b2195029c83188/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6c5421e4eb59f9f15822b9002b26a78a8f9d4e507e4f79d6f3d5f992db4be0a", size = 394025, upload-time = "2026-04-05T07:32:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c9/abe96cc1d6c3e7d7e1154f26c72217ed0f0700fe42f60b9b02ba508ecc2a/jh2-5.0.11-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f37f6497f8dbb1c1e254c77224ad06cfde22c1337230d308aeaab043eea27", size = 412872, upload-time = "2026-04-05T07:32:40.155Z" }, + { url = "https://files.pythonhosted.org/packages/40/ce/d199ea2f446ec54d8aa1ab7671b6d1990bb9a00b6f17e3525ea628c8ca8a/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:559c5ff8034ce36aa7c24c6acb80d4dc2a377ff552ff5c58be6d8762ed8ee048", size = 564809, upload-time = "2026-04-05T07:32:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/1a/56/c3e04580552c734b8d494f25d75c6c9a22f8ed93cf52ba01973e7d379bda/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:148195763588b0b8003fc365783838fabdc8450346b7c8df0c7945b80f252fab", size = 670095, upload-time = "2026-04-05T07:32:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/dfd0d84f7a3acb9e4b4707aa503de01350ab23fd4699fe7ff4b6c4059393/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:f639bbe255623af299f75b2ef8e98b0c88ead8b9f420d20abe487fb8a33238b6", size = 631038, upload-time = "2026-04-05T07:32:45.002Z" }, + { url = "https://files.pythonhosted.org/packages/67/ab/72eef3a401485d76c3123d871429a02d68f705632c3c89eb55e032c5aa7f/jh2-5.0.11-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3dea67e8ae492168e4271351dca8869e4bf79f3bb45d301d54f9639e8cb345ae", size = 596729, upload-time = "2026-04-05T07:32:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/6a/17/512d0ac0484aca136e698498d6441b6072156fb2d618d8096a07578f67ad/jh2-5.0.11-py3-none-any.whl", hash = "sha256:aafd357af8d0de5267d3bc88e2384da30f05c38446a61425ae565925bc2ca9ba", size = 98207, upload-time = "2026-04-05T07:33:49.43Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -2601,6 +2680,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/e5/037d55623be9f681236e04abe12e1290847c06bd48270c3f19ac33493cbf/mysqlclient-2.2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:260cce0e81446c83bf0a389e0fae38d68547d9f8fc0833bc733014e10ce28a99", size = 213067, upload-time = "2026-02-10T10:58:43.389Z" }, ] +[[package]] +name = "niquests" +version = "3.18.6" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "urllib3-future" }, + { name = "wassima", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/02/ff649fdaf4a9019c4ba71c038ec8355a1404538eb8bde2028a3956003c39/niquests-3.18.6.tar.gz", hash = "sha256:bdf8e945241670608552e8b3c41556aaf532705e3c8f1ed197a102d017f0a54a", size = 1022813, upload-time = "2026-04-13T06:32:38.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/3c/78f9a77e4fdd4456088bb7f0a5b2e99ba5d6cbf41fc36f5cbb3f3103a42f/niquests-3.18.6-py3-none-any.whl", hash = "sha256:cb415e31ca18ec69e88f393dcc339bce8baa38d50910e696fd0712d0b9480551", size = 208665, upload-time = "2026-04-13T06:32:37.223Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -3605,6 +3698,31 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-niquests" +source = { editable = "instrumentation/opentelemetry-instrumentation-niquests" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "niquests" }, +] + +[package.metadata] +requires-dist = [ + { name = "niquests", marker = "extra == 'instruments'", specifier = ">=3.0,<4" }, + { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, + { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, + { name = "opentelemetry-util-http", editable = "util/opentelemetry-util-http" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-openai-agents-v2" source = { editable = "instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2" } @@ -4269,6 +4387,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-mysql", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-mysqlclient", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-niquests", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-openai-v2", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-pika", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-psycopg", extra = ["instruments"] }, @@ -4342,6 +4461,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-logging", editable = "instrumentation/opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-mysql", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysql" }, { name = "opentelemetry-instrumentation-mysqlclient", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysqlclient" }, + { name = "opentelemetry-instrumentation-niquests", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-niquests" }, { name = "opentelemetry-instrumentation-openai-v2", extras = ["instruments"], editable = "instrumentation-genai/opentelemetry-instrumentation-openai-v2" }, { name = "opentelemetry-instrumentation-pika", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pika" }, { name = "opentelemetry-instrumentation-psycopg", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-psycopg" }, @@ -5477,6 +5597,91 @@ 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 = "qh3" +version = "1.7.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/93/2d/fe3fb2cb618191dcaa0f9fbeb98498641a8148cfa0a5086b7298d9b7b7ac/qh3-1.7.1.tar.gz", hash = "sha256:4ce90c54ab94521840248522de2b6620f302cde8ff317333f40f405907e6b6ad", size = 285895, upload-time = "2026-03-30T07:16:06.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/12/3d525052d5a162844224257e25fe92404b8f6cc23b4af0a63ca8a2c42516/qh3-1.7.1-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9187a6bc52bcc51ed5a68b19049c9d1650d418ddfc09a626c07a7f6b564690", size = 4163347, upload-time = "2026-03-30T07:12:50.882Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/b07ec4f6f0d50c6d66397c3339b1749db11e6c8ae6dfe95a87a5bbada62b/qh3-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc581f04e89a1f6203d591627fd95e4e3065d0c1c4b420d522f3870a70672b38", size = 2024849, upload-time = "2026-03-30T07:12:52.511Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/717041af707b817d25c3849fa81cc2470feec68b72eaafcb5dc844072dc7/qh3-1.7.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d81637c8f9535afcc54c1c5207b39bd6101ed6ad6b75e46c5fa33d6857dba4bb", size = 1740965, upload-time = "2026-03-30T07:12:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/25/79/71fa2989aec7f2a7d332d764f99f45c5be14f5f460875c8758476574d222/qh3-1.7.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1f41cc6c352ffe710577a6cd815e3d3d54e2c5eecfa2d6f4d79cd8c58e958e4", size = 1906267, upload-time = "2026-03-30T07:12:55.879Z" }, + { url = "https://files.pythonhosted.org/packages/4e/70/e7616fa858cae162071fa5653530eb525de6796d24bfd859dc57a6a89972/qh3-1.7.1-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7a1e6eda1e07b4d84ab1f4c1a800e038be3d186efd8baf8ff8f82d232cc8619c", size = 1890175, upload-time = "2026-03-30T07:12:57.317Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/27ba052c101ad53861177b5412e890d2c1ec51c43fa994f2875745355321/qh3-1.7.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81ae86a1d6ccaf2ede39c923458e95e797a2dc2d07177dca9e9a43d59b8adb2", size = 1892092, upload-time = "2026-03-30T07:12:58.831Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/dcc22baf27e0a32bd6701a0bbf5429ff89956c535c62e5276c1e90eb3974/qh3-1.7.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36021bfc874156084b0e4f8bc06a88101702177fb62c865d34a7ea160f430ca", size = 1963167, upload-time = "2026-03-30T07:13:00.886Z" }, + { url = "https://files.pythonhosted.org/packages/df/25/dd8f9bc6f33aa5503b723bbff4759bf919ea45bd7d14b1c79e095342c180/qh3-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:719ae1dabc838e5007a5017c7b5bc6fd7cc3f4f66239f730eb7a3eef9774dbcf", size = 2248764, upload-time = "2026-03-30T07:13:02.443Z" }, + { url = "https://files.pythonhosted.org/packages/a3/26/6a3cf0c7864d89f27c824f5f98e5b6d13d8a4c7d9bcc1dc3d896f6e39bd4/qh3-1.7.1-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:46b1971681a6df9afafb62fe80d9e13841a25b1393ab7e52716b7651d117d3af", size = 1897795, upload-time = "2026-03-30T07:13:03.96Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bf/4827edc3d764bb0936f731c48621fc39d4d17b6e70a8ea7157210fe7d11e/qh3-1.7.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:97003692047afe27ea8e5406200c2222f4a2e5ef798e8b569658ded1098013af", size = 2204417, upload-time = "2026-03-30T07:13:05.45Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f2/ff372f2e771f15507ea2f3a221e3f595484231b300d9a19b165f9bc45c63/qh3-1.7.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:c5da8f36885e3bce9772bff5fa6174c342f8cb6d154bfdde35202ea4e153d266", size = 1993585, upload-time = "2026-03-30T07:13:06.802Z" }, + { url = "https://files.pythonhosted.org/packages/c6/41/915e63657c989ef8fd4a4f136fc9aca04ebcfb9ea4368b8906ab6f42a977/qh3-1.7.1-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:ffb7d4e5f0a60543d02921b3339032105c22ec555419fd02e239cf462ce788c5", size = 2094312, upload-time = "2026-03-30T07:13:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/8e/ced596a5d2108ba8cc767788376d63a6c27896672cfbb9fc8d2480646f50/qh3-1.7.1-cp313-cp313t-musllinux_1_1_riscv64.whl", hash = "sha256:ef32df648cfe6e39a9254610ffd7106329b3d3d60641d82b74fb7806b56653ba", size = 2008494, upload-time = "2026-03-30T07:13:10.494Z" }, + { url = "https://files.pythonhosted.org/packages/c0/99/bf9d5e2c23aeede12e890f3281ec441caeb8708e26b73e7e5efd17b976e2/qh3-1.7.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:22245715a045db9eb2a6a78febdb1c1c223662219b640dc04e5a2ef48d923af3", size = 2459464, upload-time = "2026-03-30T07:13:11.936Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/84c7bc3858b0cc41b9b4466fc8c19af4e806a20791b9c2db9f216c0f5e69/qh3-1.7.1-cp313-cp313t-win32.whl", hash = "sha256:8e6ff471d92ed428d888767395f38bc37ae6ae6f72e8e5cf433b3f7310627b92", size = 1755710, upload-time = "2026-03-30T07:13:13.456Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/a8ba7940a51e51b2ed4e49944c3f18531bfb41b53d1aefe92b41b972d9a0/qh3-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:de62e1831d9239229507d24c8c09713766f9ca39ed0614a71aa994df38da73d5", size = 2003211, upload-time = "2026-03-30T07:13:14.865Z" }, + { url = "https://files.pythonhosted.org/packages/6c/76/e0db34a6e80a588dc52b156078ba6dd52c98202d9186a5a9c23d074536a1/qh3-1.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:1f6adecb0607ee2dd634e6af6d7676dda7fa2ba1fb1b4c6ae25c861d0fe064c1", size = 1840827, upload-time = "2026-03-30T07:13:16.489Z" }, + { url = "https://files.pythonhosted.org/packages/79/49/73e7b33f12d9ba318933dd83e7f2e596f1ac7e175f62daa91e2ca5f54b07/qh3-1.7.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d3f305f3d55a7bd1d235537829d3e4704bf15478657434fc29809e1130aa00d3", size = 4163579, upload-time = "2026-03-30T07:13:18.14Z" }, + { url = "https://files.pythonhosted.org/packages/68/38/4912c46758c13f2dd7f93a58be9b22ac87ef0e47f18645046edf2a7d6f7f/qh3-1.7.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad639b734f8e91fc1c97801fb16e504c08e3ad1bad3f18b1e71e5736cf079872", size = 2025078, upload-time = "2026-03-30T07:13:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/68bc9c9470f51a261e0528864c70e2019b150fc3ba076ea5dfe766823788/qh3-1.7.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9777367bee6bb7500faf4cfacf2741464ea4f30221e8d6298899a398452de2dc", size = 1741439, upload-time = "2026-03-30T07:13:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fa/d682a279da99ca3978922a247a70e8471850d5095fd3a1f9497d77262f91/qh3-1.7.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29ae0d8c96b9bd3e6c1bf107aba402d0a140117ca38d05e65ce97cd66a8766b", size = 1906206, upload-time = "2026-03-30T07:13:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/95/17/45101128d70dca398fe7668bbde603aba7808f0bc216d799918752a35421/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cfe638402e76d7f54183a5e3b365f9fcfc6a5e367556f827ac124c6f1ce26b7e", size = 1890431, upload-time = "2026-03-30T07:13:23.896Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b7/9aa1beff114488278a155af6b1b8258ac0a8ef0a98e1107c93c2b4ab75cf/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cc90002b5581580526d7d7f11b3359693cc09f43dc28a4433daab2194149ad8", size = 1892506, upload-time = "2026-03-30T07:13:25.549Z" }, + { url = "https://files.pythonhosted.org/packages/17/e3/bceaa72e9a95d18df5874f85d5e7889ae3362250327b24bc16c571a57fba/qh3-1.7.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0bb89e1afe81ddf2226baa2b837cec0773fe0b099a3145ebbfbc520e46f1da30", size = 1963256, upload-time = "2026-03-30T07:13:27.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bf/6006185e904523d006b9e11be1b9a0ad5b1adcea9f1872f77271462c1388/qh3-1.7.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3a32657aea7e48baf590b81f030325be6f69d7cb771893302abee915cfe3be", size = 2249446, upload-time = "2026-03-30T07:13:28.616Z" }, + { url = "https://files.pythonhosted.org/packages/6f/42/67405ee7031e86ad99379f6c7421fa2ae1893c330800ba24b7bdf4b798f0/qh3-1.7.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:c0da3ddf00b978dc8b9d8fc550498839d4e4afbf59538837dc6b4209b90ec281", size = 1898225, upload-time = "2026-03-30T07:13:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/ce/7713db24b6624e0c60f5c3895750f4c57b288a889ea7adcd3a80cea2908f/qh3-1.7.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec10cd0caf4938e63bf90fffcc6485eedccb77df449d7631f6bbcbaf8688ff84", size = 2204645, upload-time = "2026-03-30T07:13:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/6056ab22b239d3edeb6e6acd25e43f9479d2acc50531eeaaa4d1769124a1/qh3-1.7.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a8f1cb3e1ac71dd221feb4f5feeeb5171a2d1f16e531ef53b8a9a0b2dd2c275c", size = 1994040, upload-time = "2026-03-30T07:13:33.362Z" }, + { url = "https://files.pythonhosted.org/packages/24/81/d9fdf1a1cbe86d49202375f69ce2e923cd7274a7dc39b71422ce72072c43/qh3-1.7.1-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:59e0ba0db9e68cfeba5ea8a0c119777f92c6fb5850307ab7e98eb8c388bedf7f", size = 2095538, upload-time = "2026-03-30T07:13:34.923Z" }, + { url = "https://files.pythonhosted.org/packages/74/d9/99f75449f021c3c5bc0f215503158591a1f8794bd435f5a92f86b0941fff/qh3-1.7.1-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:eaff3423e2145c41db0e914f0e624c4f01e443533f4ca849c289cb0018f0b800", size = 2008870, upload-time = "2026-03-30T07:13:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/35/66/4b963671fde5b4b1721e3285ec65dae9b79897d3284bc71d0844edf28f5d/qh3-1.7.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0c6b008df6aed81f71daf1e44f56e96e73b6b9c5df8b018af0cc98e196f3bbd0", size = 2459870, upload-time = "2026-03-30T07:13:37.958Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d6/327ad6aac7b5c8b74cfd70e875791c868cd41d175cd871a7ef206491b1f2/qh3-1.7.1-cp314-cp314t-win32.whl", hash = "sha256:be271db1d7df7bf56dd7c2b1ddb37a679de913c96ab3f2e9be85de96d973bfd7", size = 1755903, upload-time = "2026-03-30T07:13:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/57/e4/2a5551af4f44aa5b150f4b0d8a86b974d8f42fe567645b2ad1b1ab056714/qh3-1.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb1c03009fefe3b3d89de18d81edea934d276d8d0817fedd9ab34226e9d92cda", size = 2003442, upload-time = "2026-03-30T07:13:40.748Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/62238dc67a0c99b330d48f8642b23ce9bc7a8c5705fbb1406d609cc68348/qh3-1.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:60a9044c3f758d4b8abc9f414bae29cce986fdae12d2ace6b733c2306423cddc", size = 1841401, upload-time = "2026-03-30T07:13:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9d/37c127a2fde22787c8a265cb3f3541cfaa3599725e9667a4c8a4672cfa92/qh3-1.7.1-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bb58ce71850c302f35cf961a5f218e562a934fdead0ae32883b3d2bf632d5d42", size = 4174574, upload-time = "2026-03-30T07:13:43.939Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f6/cfe4a6d8cd45aeb4d472154e4f2776a9574df78d612bb275cbac19dc47a6/qh3-1.7.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca2b3ed1407d2e0898369b7ebf2075b4e856d89c9fdabd0b93943b6350a3378b", size = 2028163, upload-time = "2026-03-30T07:13:45.371Z" }, + { url = "https://files.pythonhosted.org/packages/89/11/1871830752405356b25e283d8eb09579e663a3869330d7b940510cee110e/qh3-1.7.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4276453d9887326d55ecc2fed07d1569c7ecc8d31b40ff0d2488b7587b340f94", size = 1743072, upload-time = "2026-03-30T07:13:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/34/0a/187a271b4d5f8bfeee2b7149efb504897e583e87c37e8f11bb5bc2c1d0ef/qh3-1.7.1-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee7375faf82ae7acb32cf238b551886bf58b4456152b7c0c478c0d44b34165b3", size = 1911137, upload-time = "2026-03-30T07:13:48.719Z" }, + { url = "https://files.pythonhosted.org/packages/c0/46/164affc8b9c785fffb10a4dffed0c543a95422fdf80c6a8823a8603092dc/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df1d99471f5074faadea2cee7ebbd4ccd44e9b234c8350fb56003227a7890b9b", size = 1893576, upload-time = "2026-03-30T07:13:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1661d594e1fb3fdfef5c062e49c4e79e24336585a3668a88fefb10615284/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8c37aff831446f8dc7e131c52cd89f6f5b6ac03b0e7adf9d35818c014c51884", size = 1898396, upload-time = "2026-03-30T07:13:52.285Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/adba3ec305e2240dc7c3795cceac8fd1cbb2ca802bf3e6e7d16cfed644fb/qh3-1.7.1-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47a457645ab86d41d06ef7e3486bdf419b06f1392ba20858ec9d492b2862ce0f", size = 1965969, upload-time = "2026-03-30T07:13:53.798Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/67b3b59025aada229faec97de60df31ce765bf42dd3adcce9a638150f6e7/qh3-1.7.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dab8eb5b4bd3777f3f7a823f57e759e65f558c239c32030918dcd078c01f8734", size = 2252690, upload-time = "2026-03-30T07:13:55.325Z" }, + { url = "https://files.pythonhosted.org/packages/02/be/a190715ceb0890de6a7693a4c34ae1a559cb74031db30a2c50d6c8d2abee/qh3-1.7.1-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:cc9beb80baddcc1755869e0da07c25dbb0515d41d8e53b6a6984d0ea769f066e", size = 1899680, upload-time = "2026-03-30T07:13:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/95cdcb0e2b76808813a86d520ceb30171f343555e922a627d58b015ce0c1/qh3-1.7.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3bc0f9714f5a5a08a3bd80d9597e59358cbbe1333661feda29e249689ad1fd19", size = 2207162, upload-time = "2026-03-30T07:13:58.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/9186a4c96e3a319ae37fef801663667b0e9ce5c1881b8c4f1d6cb8c610b5/qh3-1.7.1-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:e2b5f2d607f8a4982f540a2f3de9477b8e5c2c8cd5557dad2ed80cd33cb58b4c", size = 1995378, upload-time = "2026-03-30T07:14:00.264Z" }, + { url = "https://files.pythonhosted.org/packages/32/27/ac4d939baa3d9c11aaa37856ccd4e776c1329bb9fc58214741e1344c4f8f/qh3-1.7.1-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:5a478b4733dbb7f37694e2045ae706542f734d9033438e55bd38bf93eda0f093", size = 2099056, upload-time = "2026-03-30T07:14:02.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ab/81c4e026c1cf81f8988f5a178d05ce56c7819b24e52cc913a3db6ecf3cac/qh3-1.7.1-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:5cd1f93e905ceec99217ceb1c2a971b430d69101e74423d271616173ab738dab", size = 2010716, upload-time = "2026-03-30T07:14:03.461Z" }, + { url = "https://files.pythonhosted.org/packages/35/eb/951451ae4a7a899a7cf3ae2d796178f814ce27f423a97ca29314a33d1ce7/qh3-1.7.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:23ed67a177ec6b8453a23bcff7fdad47307f54edaf32f968f95adb665cc04ae3", size = 2463359, upload-time = "2026-03-30T07:14:05.371Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dd/1ed9e4fe7d277981b5d169b506b63007c22d73151497875b7414611f568e/qh3-1.7.1-cp37-abi3-win32.whl", hash = "sha256:591e3a2801973b88705cff3624b24436ec16d61f55bc37ed6ce417e998a77f30", size = 1759471, upload-time = "2026-03-30T07:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4d/75efaefbf24cdb13fcee77f77ac6ba6ac51a6428995386962b9d21768307/qh3-1.7.1-cp37-abi3-win_amd64.whl", hash = "sha256:2e1a36665f93f50ff70dfdc6c4d4873bc7fb99210caf8806a52452fa813fea8f", size = 2009871, upload-time = "2026-03-30T07:14:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ac/2e962684bc88a3fb1ffc40e9e058e7d5d9c1e88390f260e49e58f72cea06/qh3-1.7.1-cp37-abi3-win_arm64.whl", hash = "sha256:f07701c40d6b012b91756cc1c59805f72dd4706159fde66e0759cff411705d41", size = 1846336, upload-time = "2026-03-30T07:14:09.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/3c0988d3e15c303a32850d8a28410f29c80468367d18b7ae51e0e981c700/qh3-1.7.1-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:17cbd39d2742e9364519e1de1f88ba5d604e6f3e54f67911378f866098c97dd9", size = 4173089, upload-time = "2026-03-30T07:14:11.429Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0d/545fa1d86354af756ef1368ab22bf231391bc7dafa050efa9faaac40c51e/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a13155b0052925aef2ed676f16307d6f96837532701d21d45ad905963010fc7", size = 2027416, upload-time = "2026-03-30T07:14:13.277Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/fcd8077a44f4dd740a7b77063984cb0167bd334fc7279c20cad1905eabca/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50cad9d0cbb6e1c594ba53aab02739cde78f2ba4bc9bfce4112ca825f10d3f87", size = 1743241, upload-time = "2026-03-30T07:14:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/f312ba38cebb8115dfb918851964b30ce9028f6aa0c14b5eb9fcc3e16a25/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6baa755b7c6613db9c0b6e9127b9ffb461e3a49942b7749be77be1563946bf5b", size = 1911117, upload-time = "2026-03-30T07:14:16.83Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9825d63418ad350c6ec1b09df9e7054cd3ea38cc0f13f0358dd0c0f997ad/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8ed56f58cb5ff28b09a6fd0cc9120354c5bd4d9e21c5d4e3bc187df998730233", size = 1892355, upload-time = "2026-03-30T07:14:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/821daf3fd1597fc07c2ab89ba4918b5f6164fd5072e5c20d4792139028a4/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:665e2f4ffcce119574b5c3fe9b8b074f555af378abb89f87edf28be2527b1a5a", size = 1897754, upload-time = "2026-03-30T07:14:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/57175cc7912615c887e455354d056a58cc317123b64ccba3cec244f025d8/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28e74a7f3aafcf4fd73958d43fc9d4f179eb8df5640e7f305cbe8e2feb398a66", size = 1965297, upload-time = "2026-03-30T07:14:21.394Z" }, + { url = "https://files.pythonhosted.org/packages/40/6d/1a0ff86c2aae4d4a6c92289ae25db78b27b619aae636a76c6b7093cfc11a/qh3-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d08119d6f2e7169fc9f489cbb970899cf8b327702c0d9c4f361ce57fb9c5d838", size = 2252148, upload-time = "2026-03-30T07:14:22.982Z" }, + { url = "https://files.pythonhosted.org/packages/1b/25/df01c673117b9b22ecf027e394f9d2e74717c8b025a222d47fd177aac359/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d076bc6f45b0819f9c03f172c4f99e4be179f675683fb9d716a89a29c4705fd8", size = 2206735, upload-time = "2026-03-30T07:14:24.801Z" }, + { url = "https://files.pythonhosted.org/packages/6a/21/db6fc62041d388f28b2434c47570ae5b75cc41905b4abd2a849974401a56/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:60b79ecf604ae91ce272abeaf78d5363997e3491d0d7628fd343a945c49f9dd0", size = 1995569, upload-time = "2026-03-30T07:14:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/24/cb5a5120ec011ecda6344eb2ab118264a93813df3d1a4bcd6de17c8f3517/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:1012236eb0728584c523d1af1929b5fd2eaee39b2c685d1eb750a3139121e8bf", size = 2098538, upload-time = "2026-03-30T07:14:28.54Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/477d6d3d93818394554bab2bb2e2597f411044dddfedd615c6dd9e6e1d00/qh3-1.7.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:87b83bdf37a1984cf755fde3996aeeea59ae9391669e587f20ce4814202de513", size = 2462757, upload-time = "2026-03-30T07:14:30.061Z" }, + { url = "https://files.pythonhosted.org/packages/5d/26/b8a0e5d5b4a7ae3158eab9e00b6d99725bcf567fc5fd3aec14d85f63504b/qh3-1.7.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ed790ec68c91919612844ffb79a0a5338d9b63dc89fa797251cdd0228d49b19a", size = 2008275, upload-time = "2026-03-30T07:14:32.019Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/b6822439b7b2f0efa34227305ec37ed6ff556636a724b29376430e7a8065/qh3-1.7.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04a794aba81d5690cdc21353e7972883e38cb01633d266b58efebd2332c0bfd0", size = 4168204, upload-time = "2026-03-30T07:14:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/51/08/ef1dfaa7f0accb364a951119dfc31e2b67d33f2bada8ea12fa4588cc7561/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff747f7bc8e89ace9af99fb87f1f21ac390d1ce69e427c51884400157d75af89", size = 2025003, upload-time = "2026-03-30T07:14:35.278Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/3f82686147ab3b46edcfffcf275e5e0c845f1a447063ef3e5f88b64c9635/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb264eb9734cba1df742619ff579d43e929b46c2d381709b9b941dc5470e6f34", size = 1740716, upload-time = "2026-03-30T07:14:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/a185b7358068b26ccc10f50eeead8c2cd5cbbd374b888c7fd6bddeccdea7/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7de1c807d42cc9c9ec2e22c3154a1f06541096daf49a8f5834c304266ad7e2e3", size = 1907838, upload-time = "2026-03-30T07:14:38.668Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/6877b0050830baca67b4a2999fabc6b501ffc6fb719f666a43b877478f13/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:daeda5590afda408a1fb843003b25e8f9586ec66aab83531303b65cb1862fab9", size = 1890962, upload-time = "2026-03-30T07:14:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/d3349b04185e3cf467c0628ce5f739ec19412eeb92180c00e8f884297ca5/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200098eb39c9c76a6461b2b96e868fdfe64a808c4ea34c285dfda28985da75ff", size = 1893298, upload-time = "2026-03-30T07:14:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/95/56/c36a3ab985218a73e91ae991a6e7d876838bb4e27acae1e7e6c32ad65701/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcedba51fa9cc3560c3448202b7eda3e9902e738dadda9428155f308b6036e22", size = 1961984, upload-time = "2026-03-30T07:14:43.752Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/2f2c806bc9f0b674264ceba5460ff99a6ef5455c03abba050d90b1a0a968/qh3-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a5b4e137ac65c388789aaf68627d49fe38138d81513e52e509c4b4d317f8678", size = 2249372, upload-time = "2026-03-30T07:14:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e1/146505848fb86dc42add9ba26f6ce6caec9e37f28307e0fd17d3aa8beab3/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7625f0611499304b79136f16fb0cd9975812f49e26b8ddc2f28afb604c9bb6e3", size = 2204802, upload-time = "2026-03-30T07:14:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8f/78ca340a78ff3eb7ebcde89e618754e355a6d1e7096a72bb90ee593ed5a1/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c57d693e12222994f65c4ecd1e449abfc28cfb448244d163cd670014048191c", size = 1993394, upload-time = "2026-03-30T07:14:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6c/e55b24d2ce596162ffdd0ca028ee95c85d04a92c3d8db090d7385809a07b/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:4ad0a568e441a171c0c9a52dfbe036fcec8b6e7883327a71d8d27def431a4219", size = 2095120, upload-time = "2026-03-30T07:14:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/c7/66/262c6cc9d8e7494e303a0e00ef69ed4df298909072adbac8ee0db1635ecc/qh3-1.7.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ce9c6e6248e002ebb18b7fb30648db84c274fdd1ed6eba006e412cf0aff903f0", size = 2459723, upload-time = "2026-03-30T07:14:52.23Z" }, + { url = "https://files.pythonhosted.org/packages/f0/11/23a39aec759cab56919e06251659cc4f47685709aab12017c123656e86ce/qh3-1.7.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:df103e5d396420756eea86b8c7bd19ad1828c384db02569f8fba52b9fbe561d4", size = 2007184, upload-time = "2026-03-30T07:14:54.162Z" }, +] + [[package]] name = "redis" version = "7.2.0" @@ -6121,6 +6326,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "urllib3-future" +version = "2.19.908" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "h11" }, + { name = "jh2" }, + { name = "qh3", marker = "(python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/0c/f0cb307d8d74d072db53256a9138872e6f0d47c2af5090478dce3466d28c/urllib3_future-2.19.908.tar.gz", hash = "sha256:0c149fbb186341a8ecdb53a005d01be342d87cf15fab28095be28955eb61c54f", size = 1157374, upload-time = "2026-04-14T04:55:01.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4c/0a67028a0fece4e3f8bef29c93d0e0a293fab4fff56289111f9e7710410a/urllib3_future-2.19.908-py3-none-any.whl", hash = "sha256:6491641ff988db90aad3d376a32e7b4559e2cdd4932fd31a55a7cb0f66b19a4b", size = 723457, upload-time = "2026-04-14T04:55:00.291Z" }, +] + [[package]] name = "uuid-utils" version = "0.14.0" @@ -6231,6 +6450,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, ] +[[package]] +name = "wassima" +version = "2.0.6" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/96/1d/e27f3b2730e1964e819d47ad1c7ffde135a3658b120a441ecc40be0c627d/wassima-2.0.6.tar.gz", hash = "sha256:7c7fa67161ebe0c0ffbbc4c648186de80124f62474682b57c3ac60520d5c471b", size = 145426, upload-time = "2026-04-07T01:52:02.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/10/bd9b185b33ecd2b2523eb83bb1088e6dfdc1dad19136d91312dce9996c37/wassima-2.0.6-py3-none-any.whl", hash = "sha256:24c327cfce58e36b1e554feb809a12cd6677e39158dee419deb0d16b8f648f0d", size = 140613, upload-time = "2026-04-07T01:52:00.835Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0"