diff --git a/CHANGELOG.md b/CHANGELOG.md index 562806f2..ca8a519f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Changed:*** + +- HTTP requests verify certificates against the operating system trust store by default +- The `http` feature available to commands now provides the `httpx2` dependency and will remove the `httpx` dependency in a future minor release + ## 0.37.0 - 2026-07-21 ***Added:*** diff --git a/docs/tutorials/cli/create-command.md b/docs/tutorials/cli/create-command.md index f9def833..7583fb3d 100644 --- a/docs/tutorials/cli/create-command.md +++ b/docs/tutorials/cli/create-command.md @@ -84,7 +84,7 @@ Agent release data ## Requiring dependencies -Fetching the Agent's [`release.json`](https://github.com/DataDog/datadog-agent/blob/main/release.json) file requires using an HTTP client. Add the `http` [feature][dda.cli.base.DynamicCommand] to the command to make sure dependencies such as `httpx` are available: +Fetching the Agent's [`release.json`](https://github.com/DataDog/datadog-agent/blob/main/release.json) file requires using an HTTP client. Add the `http` [feature][dda.cli.base.DynamicCommand] to the command to make sure dependencies such as `httpx2` are available: /// tab | :octicons-file-code-16: src/dda/cli/agent_release/data/\_\_init\_\_.py ```python hl_lines="13 20-30" @@ -107,14 +107,14 @@ def cmd(app: Application) -> None: """ Show Agent release data. """ - import httpx + import httpx2 base = "https://raw.githubusercontent.com" repo = "DataDog/datadog-agent" branch = "main" path = "release.json" with app.status("Fetching Agent release data"): - response = httpx.get(f"{base}/{repo}/{branch}/{path}") + response = httpx2.get(f"{base}/{repo}/{branch}/{path}") response.raise_for_status() app.display_table(response.json()) @@ -174,14 +174,14 @@ def cmd(app: Application) -> None: app.display_warning("This command is currently disabled by feature flag.") return - import httpx + import httpx2 base = "https://raw.githubusercontent.com" repo = "DataDog/datadog-agent" branch = "main" path = "release.json" with app.status("Fetching Agent release data"): - response = httpx.get(f"{base}/{repo}/{branch}/{path}") + response = httpx2.get(f"{base}/{repo}/{branch}/{path}") response.raise_for_status() app.display_table(response.json()) diff --git a/pyproject.toml b/pyproject.toml index 4b1bc41f..4897e76a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,9 @@ dependencies = [ "dep-sync~=0.1", "filelock~=3.18", "find-exe~=0.1", + # Retained only for local commands in other repositories that import it. "httpx[http2]~=0.28.1", + "httpx2[http2]~=2.9.1", "hvac~=2.3.0", "keyring~=25.6.0", "msgspec~=0.18", @@ -64,6 +66,7 @@ dotslash = [ ] http = [ "httpx[zstd]", + "httpx2[zstd]", ] gcp = [ "google-api-python-client~=2.160.0", diff --git a/src/dda/cli/application.py b/src/dda/cli/application.py index fbd62a2f..d2b820c7 100644 --- a/src/dda/cli/application.py +++ b/src/dda/cli/application.py @@ -196,7 +196,7 @@ def ready(self) -> bool: return now - last_check >= self.__app.config.update.check.get_period_seconds() def new_release(self) -> tuple[str, str] | None: - import httpx + import httpx2 from packaging.version import Version from dda._version import __version__ @@ -205,7 +205,7 @@ def new_release(self) -> tuple[str, str] | None: with self.__app.github.http.client(timeout=5) as client: try: response = client.get("https://api.github.com/repos/DataDog/datadog-agent-dev/releases/latest") - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: # Rate limiting if e.response.headers.get("Retry-After") is not None: github_auth = self.__app.config.github.auth diff --git a/src/dda/feature_flags/client.py b/src/dda/feature_flags/client.py index 0f3e9173..8c18801c 100644 --- a/src/dda/feature_flags/client.py +++ b/src/dda/feature_flags/client.py @@ -44,13 +44,13 @@ def _fetch_flags( Dictionary containing the flag configuration response Raises: - httpx.HTTPError: If the request fails + httpx2.HTTPError: If the request fails RuntimeError: If an unexpected error occurs """ if not self.__client_token: return {} - from httpx import HTTPError + from httpx2 import HTTPError # Build headers headers = { @@ -103,7 +103,7 @@ def get_flag_value(self, flag: str, targeting_key: str, targeting_attributes: di The flag value or None if the flag is not found Raises: - httpx.HTTPError: If the request fails + httpx2.HTTPError: If the request fails ValueError: If the flag is not found RuntimeError: If an unexpected error occurs """ diff --git a/src/dda/utils/network/http/client.py b/src/dda/utils/network/http/client.py index 12fee93d..9af1a3a5 100644 --- a/src/dda/utils/network/http/client.py +++ b/src/dda/utils/network/http/client.py @@ -6,7 +6,7 @@ import time from typing import TYPE_CHECKING, Any -import httpx +import httpx2 from dda.utils.retry import DelayedError, FailFastError, wait_for @@ -46,9 +46,9 @@ def get_http_client(**kwargs: Any) -> HTTPClient: return HTTPClient(**kwargs) -class HTTPClient(httpx.Client): +class HTTPClient(httpx2.Client): """ - A subclass of [`httpx.Client`](https://www.python-httpx.org/api/#client) that intelligently retries requests. + A subclass of [`httpx2.Client`](https://httpx2.pydantic.dev/api/#client) that intelligently retries requests. /// warning This class should never be used directly. Instead, use the @@ -64,21 +64,21 @@ def __init__(self, **kwargs: Any) -> None: # connection errors self.timeout.connect = None - def send(self, *args: Any, **kwargs: Any) -> httpx.Response: + def send(self, *args: Any, **kwargs: Any) -> httpx2.Response: return wait_for( lambda: _get_response(lambda: super(HTTPClient, self).send(*args, **kwargs)), timeout=self.__timeout, ) -def _get_response(sender: Callable[[], httpx.Response]) -> httpx.Response: +def _get_response(sender: Callable[[], httpx2.Response]) -> httpx2.Response: try: response = sender() - except httpx.ConnectError as e: + except httpx2.ConnectError as e: if (cause := getattr(e, "__cause__", None)) is not None: - import httpcore + import httpcore2 - if isinstance(cause, httpcore.ConnectError): + if isinstance(cause, httpcore2.ConnectError): import ssl internal_error = cause.args[0] @@ -90,7 +90,7 @@ def _get_response(sender: Callable[[], httpx.Response]) -> httpx.Response: try: response.raise_for_status() - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: # Not idempotent if e.request.method == "POST": raise FailFastError(e) from None diff --git a/tests/utils/git/test_commit.py b/tests/utils/git/test_commit.py index 1c5e83ba..105b6846 100644 --- a/tests/utils/git/test_commit.py +++ b/tests/utils/git/test_commit.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime -from httpx import Response +from httpx2 import Response from dda.utils.fs import Path from dda.utils.git.commit import Commit, GitPersonDetails diff --git a/tests/utils/git/test_github.py b/tests/utils/git/test_github.py index 3720e4ec..4f922fa4 100644 --- a/tests/utils/git/test_github.py +++ b/tests/utils/git/test_github.py @@ -5,7 +5,7 @@ from datetime import datetime import pytest -from httpx import Response +from httpx2 import Response from dda.utils.fs import Path from dda.utils.git.changeset import ChangedFile, ChangeSet diff --git a/tests/utils/network/http/test_client.py b/tests/utils/network/http/test_client.py index d36c58d8..d1dfce5e 100644 --- a/tests/utils/network/http/test_client.py +++ b/tests/utils/network/http/test_client.py @@ -5,7 +5,7 @@ import ssl -import httpx +import httpx2 import truststore from dda.utils.network.http.client import DEFAULT_TIMEOUT, HTTPClient, get_http_client @@ -15,7 +15,7 @@ class TestGetHTTPClient: def test_types(self): client = get_http_client() assert isinstance(client, HTTPClient) - assert isinstance(client, httpx.Client) + assert isinstance(client, httpx2.Client) def test_defaults(self, mocker): truststore_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) diff --git a/uv.lock b/uv.lock index a52829f2..d1f2c1ea 100644 --- a/uv.lock +++ b/uv.lock @@ -118,16 +118,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.9.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -535,6 +534,7 @@ dependencies = [ { name = "filelock" }, { name = "find-exe" }, { name = "httpx", extra = ["http2"] }, + { name = "httpx2", extra = ["http2"] }, { name = "hvac" }, { name = "keyring" }, { name = "msgspec" }, @@ -573,6 +573,7 @@ gitlab = [ ] http = [ { name = "httpx", extra = ["zstd"] }, + { name = "httpx2", extra = ["zstd"] }, ] legacy-agent-deploy = [ { name = "azure-identity" }, @@ -750,6 +751,7 @@ requires-dist = [ { name = "filelock", specifier = "~=3.18" }, { name = "find-exe", specifier = "~=0.1" }, { name = "httpx", extras = ["http2"], specifier = "~=0.28.1" }, + { name = "httpx2", extras = ["http2"], specifier = "~=2.9.1" }, { name = "hvac", specifier = "~=2.3.0" }, { name = "keyring", specifier = "~=25.6.0" }, { name = "msgspec", specifier = "~=0.18" }, @@ -780,7 +782,10 @@ gcp = [ ] github = [{ name = "pygithub" }] gitlab = [{ name = "python-gitlab" }] -http = [{ name = "httpx", extras = ["zstd"] }] +http = [ + { name = "httpx", extras = ["zstd"] }, + { name = "httpx2", extras = ["zstd"] }, +] legacy-agent-deploy = [ { name = "azure-identity", specifier = "==1.14.1" }, { name = "azure-mgmt-resource", specifier = "==23.0.1" }, @@ -1360,6 +1365,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httplib2" version = "0.22.0" @@ -1404,6 +1422,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, ] +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] +zstd = [ + { name = "zstandard", marker = "python_full_version < '3.14'" }, +] + [[package]] name = "hvac" version = "2.3.0" @@ -1439,11 +1481,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -2967,15 +3009,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "soupsieve" version = "2.6"