diff --git a/CHANGELOG.md b/CHANGELOG.md index c35a9411..f8ed60a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - The `config show` command now outputs to stdout instead of stderr - The error message when running an outdated version now outputs to stderr instead of stdout - The `app.subprocess.capture` and `app.subprocess.redirect` methods no longer include the standard error stream by default +- Ignore rate limiting errors when checking for new releases ## 0.28.0 - 2025-09-22 diff --git a/docs/reference/api/app.md b/docs/reference/api/app.md index 38029dfe..970531c6 100644 --- a/docs/reference/api/app.md +++ b/docs/reference/api/app.md @@ -8,9 +8,10 @@ members: - abort - config + - tools - subprocess - http - - tools + - github - telemetry - last_error - display diff --git a/docs/reference/api/github.md b/docs/reference/api/github.md new file mode 100644 index 00000000..d398f38f --- /dev/null +++ b/docs/reference/api/github.md @@ -0,0 +1,12 @@ +# GitHub utilities reference + +----- + +::: dda.github.core.GitHub + options: + members: + - http + +::: dda.github.http.GitHubHTTPClientManager + options: + members: [] diff --git a/mkdocs.yml b/mkdocs.yml index 57ce8324..dcac59e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Retries: reference/api/retry.md - Platform: reference/api/platform.md - Git: reference/api/git.md + - GitHub: reference/api/github.md - Date: reference/api/date.md - Tools: reference/api/tools.md - CI: reference/api/ci.md diff --git a/src/dda/cli/application.py b/src/dda/cli/application.py index c9a966a1..f5e52323 100644 --- a/src/dda/cli/application.py +++ b/src/dda/cli/application.py @@ -16,6 +16,7 @@ from dda.config.file import ConfigFile from dda.config.model import RootConfig + from dda.github.core import GitHub from dda.telemetry.manager import TelemetryManager from dda.tools import Tools from dda.utils.network.http.manager import HTTPClientManager @@ -95,6 +96,12 @@ def config_file(self) -> ConfigFile: def config(self) -> RootConfig: return self.__config_file.model + @cached_property + def tools(self) -> Tools: + from dda.tools import Tools + + return Tools(self) + @cached_property def subprocess(self) -> SubprocessRunner: from dda.utils.process import SubprocessRunner @@ -108,10 +115,10 @@ def http(self) -> HTTPClientManager: return HTTPClientManager(self) @cached_property - def tools(self) -> Tools: - from dda.tools import Tools + def github(self) -> GitHub: + from dda.github.core import GitHub - return Tools(self) + return GitHub(self) @cached_property def telemetry(self) -> TelemetryManager: @@ -177,14 +184,31 @@ 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 from packaging.version import Version from dda._version import __version__ current_version = Version(__version__) - with self.__app.http.client() as client: - response = client.get("https://api.github.com/repos/DataDog/datadog-agent-dev/releases/latest") - response.raise_for_status() + 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: + # Rate limiting + if e.response.headers.get("Retry-After") is not None: + github_auth = self.__app.config.github.auth + if not (github_auth.user and github_auth.token): + self.__app.display_warning( + "The GitHub API rate limit was exceeded while checking for new releases." + ) + self.__app.display_info("Run the following commands to authenticate:") + self.__app.display_info("dda config set github.auth.user ") + self.__app.display_info("dda config set github.auth.token") + + return None + + raise + release = response.json() latest_version = Version(release["tag_name"].lstrip("v")) diff --git a/src/dda/config/file.py b/src/dda/config/file.py index b7d89487..74fa5a90 100644 --- a/src/dda/config/file.py +++ b/src/dda/config/file.py @@ -35,12 +35,21 @@ def model(self) -> RootConfig: return construct_model(self.data) def save(self, data: dict[str, Any] | None = None) -> None: + from contextlib import suppress + import tomlkit content = tomlkit.dumps(self.data if data is None else data) self.path.parent.ensure_dir() self.path.write_atomic(content, "w", encoding="utf-8") + with suppress(AttributeError): + del self.model + + if data is not None: + with suppress(AttributeError): + del self.data + def read(self) -> str: return self.path.read_text(encoding="utf-8") diff --git a/src/dda/github/__init__.py b/src/dda/github/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/github/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/github/core.py b/src/dda/github/core.py new file mode 100644 index 00000000..da319c06 --- /dev/null +++ b/src/dda/github/core.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dda.cli.application import Application + from dda.github.http import GitHubHTTPClientManager + + +class GitHub: + """ + This is available as the [`Application.github`][dda.cli.application.Application.github] property. + + Example usage: + + ```python + with app.github.http.client() as client: + client.get("https://api.github.com") + ``` + """ + + def __init__(self, app: Application) -> None: + self.__app = app + + @cached_property + def http(self) -> GitHubHTTPClientManager: + from dda.github.http import GitHubHTTPClientManager + + return GitHubHTTPClientManager(self.__app) diff --git a/src/dda/github/http.py b/src/dda/github/http.py new file mode 100644 index 00000000..d6d58430 --- /dev/null +++ b/src/dda/github/http.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import Any + +from dda.utils.network.http.manager import HTTPClientManager + + +class GitHubHTTPClientManager(HTTPClientManager): + """ + A subclass of [`HTTPClientManager`][dda.utils.network.http.manager.HTTPClientManager] for GitHub API requests. + + Authentication will use the [`GitHubAuth`][dda.config.model.github.GitHubAuth] configuration if both the + user and token are set. + """ + + def set_default_client_config(self, config: dict[str, Any]) -> None: + if "auth" not in config: + github_auth = self.app.config.github.auth + if github_auth.user and github_auth.token: + config["auth"] = (github_auth.user, github_auth.token) + + super().set_default_client_config(config) diff --git a/src/dda/tools/__init__.py b/src/dda/tools/__init__.py index 2e6d8531..260f1317 100644 --- a/src/dda/tools/__init__.py +++ b/src/dda/tools/__init__.py @@ -11,6 +11,7 @@ from dda.tools.bazel import Bazel from dda.tools.docker import Docker from dda.tools.git import Git + from dda.tools.github.core import GitHub from dda.tools.go import Go from dda.tools.uv import UV @@ -35,6 +36,18 @@ def docker(self) -> Docker: return Docker(self.__app) + @cached_property + def git(self) -> Git: + from dda.tools.git import Git + + return Git(self.__app) + + @cached_property + def github(self) -> GitHub: + from dda.tools.github.core import GitHub + + return GitHub(self.__app) + @cached_property def go(self) -> Go: from dda.tools.go import Go @@ -46,9 +59,3 @@ def uv(self) -> UV: from dda.tools.uv import UV return UV(self.__app) - - @cached_property - def git(self) -> Git: - from dda.tools.git import Git - - return Git(self.__app) diff --git a/src/dda/utils/network/http/manager.py b/src/dda/utils/network/http/manager.py index a192a625..9eed481d 100644 --- a/src/dda/utils/network/http/manager.py +++ b/src/dda/utils/network/http/manager.py @@ -20,7 +20,11 @@ class HTTPClientManager: def __init__(self, app: Application): self.__app = app - def client(self, **kwargs: Any) -> HTTPClient: # noqa: PLR6301 + @property + def app(self) -> Application: + return self.__app + + def client(self, **kwargs: Any) -> HTTPClient: """ Returns: An [`HTTPClient`][dda.utils.network.http.client.HTTPClient] instance with proper default configuration. @@ -31,6 +35,7 @@ def client(self, **kwargs: Any) -> HTTPClient: # noqa: PLR6301 """ from dda.utils.network.http.client import get_http_client + self.set_default_client_config(kwargs) return get_http_client(**kwargs) def download(self, url: str, *, path: Path) -> None: @@ -41,3 +46,12 @@ def download(self, url: str, *, path: Path) -> None: ): for chunk in response.iter_bytes(): f.write(chunk) + + def set_default_client_config(self, config: dict[str, Any]) -> None: + """ + This is called by subclasses to set the default configuration for HTTP clients. + + Parameters: + config: Keyword arguments to pass to the [`get_http_client`][dda.utils.network.http.client.get_http_client] + function. + """ diff --git a/tests/tools/github/__init__.py b/tests/tools/github/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/tools/github/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/tools/github/test_github.py b/tests/tools/github/test_github.py new file mode 100644 index 00000000..4e8bae93 --- /dev/null +++ b/tests/tools/github/test_github.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import ssl +from typing import TYPE_CHECKING + +import truststore + +from dda.utils.network.http.client import DEFAULT_TIMEOUT + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + from dda.cli.application import Application + from dda.config.file import ConfigFile + + +class TestHTTP: + def test_no_auth(self, app: Application, mocker: MockerFixture) -> None: + truststore_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + mock_truststore_context = mocker.patch("truststore.SSLContext", return_value=truststore_context) + mock_client = mocker.patch("dda.utils.network.http.client.HTTPClient") + + app.github.http.client() + mock_truststore_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT) + mock_client.assert_called_once_with( + http2=True, + timeout=DEFAULT_TIMEOUT, + verify=truststore_context, + ) + + def test_auth(self, app: Application, config_file: ConfigFile, mocker: MockerFixture) -> None: + config_file.data["github"]["auth"] = {"user": "foo", "token": "bar"} + config_file.save() + + truststore_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + mock_truststore_context = mocker.patch("truststore.SSLContext", return_value=truststore_context) + mock_client = mocker.patch("dda.utils.network.http.client.HTTPClient") + + app.github.http.client() + mock_truststore_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT) + mock_client.assert_called_once_with( + http2=True, + timeout=DEFAULT_TIMEOUT, + verify=truststore_context, + auth=("foo", "bar"), + )