Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
members:
- abort
- config
- tools
- subprocess
- http
- tools
- github
- telemetry
- last_error
- display
Expand Down
12 changes: 12 additions & 0 deletions docs/reference/api/github.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# GitHub utilities reference

-----

::: dda.github.core.GitHub
options:
members:
- http

::: dda.github.http.GitHubHTTPClientManager
options:
members: []
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 30 additions & 6 deletions src/dda/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 <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"))
Expand Down
9 changes: 9 additions & 0 deletions src/dda/config/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
3 changes: 3 additions & 0 deletions src/dda/github/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
33 changes: 33 additions & 0 deletions src/dda/github/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# 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)
25 changes: 25 additions & 0 deletions src/dda/github/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# 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)
19 changes: 13 additions & 6 deletions src/dda/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
16 changes: 15 additions & 1 deletion src/dda/utils/network/http/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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.
"""
3 changes: 3 additions & 0 deletions tests/tools/github/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
49 changes: 49 additions & 0 deletions tests/tools/github/test_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# 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"),
)