From 64f3df8b08d5aa48d7f01dfa17d8801cbf8d8791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Fri, 14 Nov 2025 17:35:12 +0000 Subject: [PATCH 01/14] first steps into EVP --- ddtestpy/internal/api_client.py | 5 ++-- ddtestpy/internal/http.py | 41 +++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/ddtestpy/internal/api_client.py b/ddtestpy/internal/api_client.py index f963e43..5ebe5f9 100644 --- a/ddtestpy/internal/api_client.py +++ b/ddtestpy/internal/api_client.py @@ -40,9 +40,8 @@ def __init__( self.itr_skipping_level = itr_skipping_level self.configurations = configurations - self.base_url = f"https://api.{self.site}" - - self.connector = BackendConnector(host=f"api.{self.site}", default_headers={"dd-api-key": self.api_key}) + # self.connector = BackendConnector(host=f"api.{self.site}", default_headers={"dd-api-key": self.api_key}) + self.connector = BackendConnector.make_evp_proxy_connector("localhost") # ꙮꙮꙮ def close(self) -> None: self.connector.close() diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index b83b9d1..88add84 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -17,18 +17,31 @@ log = logging.getLogger(__name__) +@dataclass +class FileAttachment: + name: str + filename: t.Optional[str] + content_type: str + data: bytes + + class BackendConnector(threading.local): def __init__( self, host: str, port: int = 443, + http_class: t.type[http.client.HTTPSConnection] = http.client.HTTPSConnection, default_headers: t.Optional[t.Dict[str, str]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - accept_gzip: bool = True, + backend_supports_gzip_requests: bool = True, + accept_gzip_responses: bool = True, + base_path: str = "", ): - self.conn = http.client.HTTPSConnection(host=host, port=port, timeout=timeout_seconds) + self.conn = http_class(host=host, port=port, timeout=timeout_seconds) self.default_headers = default_headers or {} - if accept_gzip: + self.base_path = base_path + self.backend_supports_gzip_requests = backend_supports_gzip_requests + if accept_gzip_responses: self.default_headers["Accept-Encoding"] = "gzip" def close(self) -> None: @@ -45,13 +58,13 @@ def request( ) -> t.Tuple[http.client.HTTPResponse, bytes]: full_headers = self.default_headers | (headers or {}) - if send_gzip: + if send_gzip and self.backend_supports_gzip_requests: data = gzip.compress(data, compresslevel=6) full_headers["Content-Encoding"] = "gzip" start_time = time.time() - self.conn.request(method, path, body=data, headers=full_headers) + self.conn.request(method, self.base_path + path, body=data, headers=full_headers) response = self.conn.getresponse() if response.headers.get("Content-Encoding") == "gzip": @@ -104,10 +117,14 @@ def post_files( return self.request("POST", path=path, data=body.getvalue(), headers=headers, send_gzip=send_gzip) - -@dataclass -class FileAttachment: - name: str - filename: t.Optional[str] - content_type: str - data: bytes + @classmethod + def make_evp_proxy_connector(cls, host: str, port: int = 8126) -> BackendConnector: + return cls( + host=host, + port=port, + http_class=http.client.HTTPConnection, + default_headers={"X-Datadog-EVP-Subdomain": "api"}, + backend_supports_gzip_requests=False, + accept_gzip_responses=False, + base_path="/evp_proxy/v4", + ) From b70c3fb72d4c89b89d02fdd7f8b15cc70b95813c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Sun, 16 Nov 2025 21:34:40 +0000 Subject: [PATCH 02/14] looking good --- ddtestpy/internal/api_client.py | 11 ++--- ddtestpy/internal/http.py | 70 ++++++++++++++++++++++------ ddtestpy/internal/session_manager.py | 15 ++---- ddtestpy/internal/writer.py | 25 ++++------ 4 files changed, 73 insertions(+), 48 deletions(-) diff --git a/ddtestpy/internal/api_client.py b/ddtestpy/internal/api_client.py index 5ebe5f9..e5f0f6b 100644 --- a/ddtestpy/internal/api_client.py +++ b/ddtestpy/internal/api_client.py @@ -10,7 +10,7 @@ from ddtestpy.internal.constants import EMPTY_NAME from ddtestpy.internal.git import GitTag -from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment from ddtestpy.internal.test_data import ITRSkippingLevel from ddtestpy.internal.test_data import ModuleRef @@ -24,24 +24,19 @@ class APIClient: def __init__( self, - site: str, - api_key: str, service: str, env: str, env_tags: t.Dict[str, str], itr_skipping_level: ITRSkippingLevel, configurations: t.Dict[str, str], + connector_setup: BackendConnectorSetup, ) -> None: - self.site = site - self.api_key = api_key self.service = service self.env = env self.env_tags = env_tags self.itr_skipping_level = itr_skipping_level self.configurations = configurations - - # self.connector = BackendConnector(host=f"api.{self.site}", default_headers={"dd-api-key": self.api_key}) - self.connector = BackendConnector.make_evp_proxy_connector("localhost") # ꙮꙮꙮ + self.connector = connector_setup.get_connector_for_subdomain("api") def close(self) -> None: self.connector.close() diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index 88add84..749b977 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -1,16 +1,21 @@ from __future__ import annotations +from abc import abstractmethod from dataclasses import dataclass import gzip import http.client import io import json import logging +import os import threading import time import typing as t import uuid +from ddtestpy.internal.constants import DEFAULT_SITE +from ddtestpy.internal.utils import asbool + DEFAULT_TIMEOUT_SECONDS = 15.0 @@ -25,12 +30,63 @@ class FileAttachment: data: bytes +class BackendConnectorSetup: + @abstractmethod + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: ... + + @staticmethod + def detect_setup() -> BackendConnectorSetup: + site = os.environ.get("DD_SITE") or DEFAULT_SITE + + if asbool(os.environ.get("DD_CIVISIBILITY_AGENTLESS_ENABLED")): + api_key = os.environ.get("DD_API_KEY") + if not api_key: + raise RuntimeError("DD_API_KEY environment variable is not set") + log.debug("Connecting to backend in agentless mode") + return BackendConnectorAgentlessSetup(host=site, port=443, api_key=api_key) + + log.debug("Connecting to backend through agent in EVP proxy mode") + return BackendConnectorEVPProxySetup(host="localhost", port=8126) + + +class BackendConnectorAgentlessSetup(BackendConnectorSetup): + def __init__(self, host: str, port: int, api_key: str) -> None: + self.host = host + self.port = port + self.api_key = api_key + + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + return BackendConnector( + host=f"{subdomain}.{self.host}", + port=self.port, + http_class=http.client.HTTPSConnection, + default_headers={"dd-api-key": self.api_key}, + ) + + +class BackendConnectorEVPProxySetup(BackendConnectorSetup): + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + return BackendConnector( + host=self.host, + port=self.port, + http_class=http.client.HTTPConnection, + default_headers={"X-Datadog-EVP-Subdomain": subdomain}, + backend_supports_gzip_requests=True, + accept_gzip_responses=True, + base_path="/evp_proxy/v4", + ) + + class BackendConnector(threading.local): def __init__( self, host: str, port: int = 443, - http_class: t.type[http.client.HTTPSConnection] = http.client.HTTPSConnection, + http_class: t.Type[http.client.HTTPConnection] = http.client.HTTPSConnection, default_headers: t.Optional[t.Dict[str, str]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, backend_supports_gzip_requests: bool = True, @@ -116,15 +172,3 @@ def post_files( body.write(b"--%s--\r\n" % boundary_bytes) return self.request("POST", path=path, data=body.getvalue(), headers=headers, send_gzip=send_gzip) - - @classmethod - def make_evp_proxy_connector(cls, host: str, port: int = 8126) -> BackendConnector: - return cls( - host=host, - port=port, - http_class=http.client.HTTPConnection, - default_headers={"X-Datadog-EVP-Subdomain": "api"}, - backend_supports_gzip_requests=False, - accept_gzip_responses=False, - base_path="/evp_proxy/v4", - ) diff --git a/ddtestpy/internal/session_manager.py b/ddtestpy/internal/session_manager.py index b7d1cf3..aade1b8 100644 --- a/ddtestpy/internal/session_manager.py +++ b/ddtestpy/internal/session_manager.py @@ -11,10 +11,10 @@ from ddtestpy.internal.codeowners import Codeowners from ddtestpy.internal.constants import DEFAULT_ENV_NAME from ddtestpy.internal.constants import DEFAULT_SERVICE_NAME -from ddtestpy.internal.constants import DEFAULT_SITE from ddtestpy.internal.env_tags import get_env_tags from ddtestpy.internal.git import Git from ddtestpy.internal.git import GitTag +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.platform import get_platform_tags from ddtestpy.internal.retry_handlers import AttemptToFixHandler from ddtestpy.internal.retry_handlers import AutoTestRetriesHandler @@ -61,20 +61,15 @@ def __init__(self, session: TestSession) -> None: self.service = _get_service_name_from_git_repo(self.env_tags) or DEFAULT_SERVICE_NAME self.env = os.environ.get("DD_ENV") or DEFAULT_ENV_NAME - self.site = os.environ.get("DD_SITE") or DEFAULT_SITE - self.api_key = os.environ.get("DD_API_KEY") - - if not self.api_key: - raise RuntimeError("DD_API_KEY environment variable is not set") + self.connector_setup = BackendConnectorSetup.detect_setup() self.api_client = APIClient( - site=self.site, - api_key=self.api_key, service=self.service, env=self.env, env_tags=self.env_tags, itr_skipping_level=self.itr_skipping_level, configurations=self.platform_tags, + connector_setup=self.connector_setup, ) self.settings = self.api_client.get_settings() self.known_tests = self.api_client.get_known_tests() if self.settings.known_tests_enabled else set() @@ -93,8 +88,8 @@ def __init__(self, session: TestSession) -> None: # Retry handlers must be set up after collection phase for EFD faulty session logic to work. self.retry_handlers: t.List[RetryHandler] = [] - self.writer = TestOptWriter(site=self.site, api_key=self.api_key) - self.coverage_writer = TestCoverageWriter(site=self.site, api_key=self.api_key) + self.writer = TestOptWriter(connector_setup=self.connector_setup) + self.coverage_writer = TestCoverageWriter(connector_setup=self.connector_setup) self.session = session self.session.set_service(self.service) diff --git a/ddtestpy/internal/writer.py b/ddtestpy/internal/writer.py index 5864f5b..d960f93 100644 --- a/ddtestpy/internal/writer.py +++ b/ddtestpy/internal/writer.py @@ -8,7 +8,7 @@ import msgpack # type: ignore import ddtestpy -from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment from ddtestpy.internal.test_data import TestItem from ddtestpy.internal.test_data import TestModule @@ -31,10 +31,7 @@ class Event(dict[str, t.Any]): class BaseWriter(ABC): - def __init__(self, site: str, api_key: str) -> None: - self.site = site - self.api_key = api_key - + def __init__(self) -> None: self.lock = threading.RLock() self.should_finish = threading.Event() self.flush_interval_seconds = 60 @@ -86,8 +83,8 @@ def _send_events(self, events: t.List[Event]) -> None: class TestOptWriter(BaseWriter): __test__ = False - def __init__(self, site: str, api_key: str) -> None: - super().__init__(site=site, api_key=api_key) + def __init__(self, connector_setup: BackendConnectorSetup) -> None: + super().__init__() self.metadata: t.Dict[str, t.Dict[str, str]] = { "*": { @@ -108,10 +105,7 @@ def __init__(self, site: str, api_key: str) -> None: }, } - self.connector = BackendConnector( - host=f"citestcycle-intake.{self.site}", - default_headers={"dd-api-key": self.api_key}, - ) + self.connector = connector_setup.get_connector_for_subdomain("citestcycle-intake") self.serializers: t.Dict[t.Type[TestItem[t.Any, t.Any]], EventSerializer[t.Any]] = { TestRun: serialize_test_run, @@ -142,13 +136,10 @@ def _send_events(self, events: t.List[Event]) -> None: class TestCoverageWriter(BaseWriter): __test__ = False - def __init__(self, site: str, api_key: str) -> None: - super().__init__(site=site, api_key=api_key) + def __init__(self, connector_setup: BackendConnectorSetup) -> None: + super().__init__() - self.connector = BackendConnector( - host=f"citestcov-intake.{self.site}", - default_headers={"dd-api-key": self.api_key}, - ) + self.connector = connector_setup.get_connector_for_subdomain("citestcov-intake") def put_coverage(self, test_run: TestRun, coverage_bitmaps: t.Iterable[t.Tuple[str, bytes]]) -> None: files = [{"filename": pathname, "bitmap": bitmap} for pathname, bitmap in coverage_bitmaps] From d15d0cd9c21adc48afb375aad36a4edc555447dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 13:36:04 +0000 Subject: [PATCH 03/14] gzip --- ddtestpy/internal/http.py | 84 ++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index 749b977..d8ea7f8 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -11,6 +11,7 @@ import threading import time import typing as t +import urllib.parse import uuid from ddtestpy.internal.constants import DEFAULT_SITE @@ -34,30 +35,75 @@ class BackendConnectorSetup: @abstractmethod def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: ... - @staticmethod - def detect_setup() -> BackendConnectorSetup: - site = os.environ.get("DD_SITE") or DEFAULT_SITE - + @classmethod + def detect_setup(cls) -> BackendConnectorSetup: if asbool(os.environ.get("DD_CIVISIBILITY_AGENTLESS_ENABLED")): - api_key = os.environ.get("DD_API_KEY") - if not api_key: - raise RuntimeError("DD_API_KEY environment variable is not set") log.debug("Connecting to backend in agentless mode") - return BackendConnectorAgentlessSetup(host=site, port=443, api_key=api_key) + return cls._detect_agentless_setup() + + else: + log.debug("Connecting to backend through agent in EVP proxy mode") + return cls._detect_evp_proxy_setup() + + @classmethod + def _detect_agentless_setup(cls) -> BackendConnectorSetup: + site = os.environ.get("DD_SITE") or DEFAULT_SITE + api_key = os.environ.get("DD_API_KEY") + + if not api_key: + raise RuntimeError("DD_API_KEY environment variable is not set") + + return BackendConnectorAgentlessSetup(site=site, api_key=api_key) + + @classmethod + def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: + agent_url = os.environ.get("DD_TRACE_AGENT_URL") + if not agent_url: + agent_host = os.environ.get("DD_TRACE_AGENT_HOSTNAME") or os.environ.get("DD_AGENT_HOST") or "localhost" + agent_port = os.environ.get("DD_TRACE_AGENT_PORT") or os.environ.get("DD_AGENT_PORT") or "8126" + agent_url = f"http://{agent_host}:{agent_port}" - log.debug("Connecting to backend through agent in EVP proxy mode") - return BackendConnectorEVPProxySetup(host="localhost", port=8126) + try: + url = urllib.parse.urlparse(agent_url) + conn = http.client.HTTPConnection(host=url.hostname, port=url.port) + conn.request("GET", "/info") + response = conn.getresponse() + response_body = response.read() + response.close() + except Exception as e: + raise RuntimeError(f"Error connecting to Datadog agent at {agent_url}: {e}") + + if response.status != 200: + raise RuntimeError( + f"Error connecting to Datadog agent at {agent_url}: status {response.status}, " + f"response {response_body!r}" + ) + + response_data = json.loads(response_body) + endpoints = response_data.get("endpoints", []) + + if "/evp_proxy/v4/" in endpoints: + return BackendConnectorEVPProxySetup( + host=url.hostname, port=url.port, base_path="/evp_proxy/v4/", use_gzip=True + ) + + if "/evp_proxy/v2/" in endpoints: + return BackendConnectorEVPProxySetup( + host=url.hostname, port=url.port, base_path="/evp_proxy/v2/", use_gzip=False + ) + + raise RuntimeError(f"Datadog agent at {agent_url} does not support EVP proxy mode") class BackendConnectorAgentlessSetup(BackendConnectorSetup): - def __init__(self, host: str, port: int, api_key: str) -> None: - self.host = host - self.port = port + def __init__(self, site: str, api_key: str) -> None: + self.site = site + self.port = 443 self.api_key = api_key def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( - host=f"{subdomain}.{self.host}", + host=f"{subdomain}.{self.site}", port=self.port, http_class=http.client.HTTPSConnection, default_headers={"dd-api-key": self.api_key}, @@ -65,9 +111,11 @@ def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: class BackendConnectorEVPProxySetup(BackendConnectorSetup): - def __init__(self, host: str, port: int) -> None: + def __init__(self, host: str, port: int, base_path: str, use_gzip: bool) -> None: self.host = host self.port = port + self.base_path = base_path + self.use_gzip = use_gzip def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( @@ -75,9 +123,9 @@ def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: port=self.port, http_class=http.client.HTTPConnection, default_headers={"X-Datadog-EVP-Subdomain": subdomain}, - backend_supports_gzip_requests=True, - accept_gzip_responses=True, - base_path="/evp_proxy/v4", + backend_supports_gzip_requests=self.use_gzip, + accept_gzip_responses=self.use_gzip, + base_path=self.base_path, ) From 3caf5802cc50c79fbb46901dec8e914ac82a4c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 13:43:16 +0000 Subject: [PATCH 04/14] =?UTF-8?q?extra=20slash=20=EA=99=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ddtestpy/internal/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index d8ea7f8..1aa71f8 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -84,12 +84,12 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: if "/evp_proxy/v4/" in endpoints: return BackendConnectorEVPProxySetup( - host=url.hostname, port=url.port, base_path="/evp_proxy/v4/", use_gzip=True + host=url.hostname, port=url.port, base_path="/evp_proxy/v4", use_gzip=True ) if "/evp_proxy/v2/" in endpoints: return BackendConnectorEVPProxySetup( - host=url.hostname, port=url.port, base_path="/evp_proxy/v2/", use_gzip=False + host=url.hostname, port=url.port, base_path="/evp_proxy/v2", use_gzip=False ) raise RuntimeError(f"Datadog agent at {agent_url} does not support EVP proxy mode") From 93c865ba7463776b7ee6bfb2a3571ade8f41b112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 14:26:20 +0000 Subject: [PATCH 05/14] move re move --- ddtestpy/internal/http.py | 29 +++++++++++++++-------------- ddtestpy/internal/pytest/plugin.py | 9 ++++++++- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index 1aa71f8..2f9090d 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -15,6 +15,7 @@ import uuid from ddtestpy.internal.constants import DEFAULT_SITE +from ddtestpy.internal.errors import SetupError from ddtestpy.internal.utils import asbool @@ -23,14 +24,6 @@ log = logging.getLogger(__name__) -@dataclass -class FileAttachment: - name: str - filename: t.Optional[str] - content_type: str - data: bytes - - class BackendConnectorSetup: @abstractmethod def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: ... @@ -38,11 +31,11 @@ def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: ... @classmethod def detect_setup(cls) -> BackendConnectorSetup: if asbool(os.environ.get("DD_CIVISIBILITY_AGENTLESS_ENABLED")): - log.debug("Connecting to backend in agentless mode") + log.info("Connecting to backend in agentless mode") return cls._detect_agentless_setup() else: - log.debug("Connecting to backend through agent in EVP proxy mode") + log.info("Connecting to backend through agent in EVP proxy mode") return cls._detect_evp_proxy_setup() @classmethod @@ -51,7 +44,7 @@ def _detect_agentless_setup(cls) -> BackendConnectorSetup: api_key = os.environ.get("DD_API_KEY") if not api_key: - raise RuntimeError("DD_API_KEY environment variable is not set") + raise SetupError("DD_API_KEY environment variable is not set") return BackendConnectorAgentlessSetup(site=site, api_key=api_key) @@ -71,10 +64,10 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: response_body = response.read() response.close() except Exception as e: - raise RuntimeError(f"Error connecting to Datadog agent at {agent_url}: {e}") + raise SetupError(f"Error connecting to Datadog agent at {agent_url}: {e}") if response.status != 200: - raise RuntimeError( + raise SetupError( f"Error connecting to Datadog agent at {agent_url}: status {response.status}, " f"response {response_body!r}" ) @@ -92,7 +85,7 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: host=url.hostname, port=url.port, base_path="/evp_proxy/v2", use_gzip=False ) - raise RuntimeError(f"Datadog agent at {agent_url} does not support EVP proxy mode") + raise SetupError(f"Datadog agent at {agent_url} does not support EVP proxy mode") class BackendConnectorAgentlessSetup(BackendConnectorSetup): @@ -220,3 +213,11 @@ def post_files( body.write(b"--%s--\r\n" % boundary_bytes) return self.request("POST", path=path, data=body.getvalue(), headers=headers, send_gzip=send_gzip) + + +@dataclass +class FileAttachment: + name: str + filename: t.Optional[str] + content_type: str + data: bytes diff --git a/ddtestpy/internal/pytest/plugin.py b/ddtestpy/internal/pytest/plugin.py index 323a6cd..bb1f876 100644 --- a/ddtestpy/internal/pytest/plugin.py +++ b/ddtestpy/internal/pytest/plugin.py @@ -17,6 +17,7 @@ from ddtestpy.internal.coverage_api import install_coverage from ddtestpy.internal.ddtrace import install_global_trace_filter from ddtestpy.internal.ddtrace import trace_context +from ddtestpy.internal.errors import SetupError from ddtestpy.internal.git import get_workspace_path from ddtestpy.internal.logging import catch_and_log_exceptions from ddtestpy.internal.logging import setup_logging @@ -628,7 +629,13 @@ def pytest_load_initial_conftests( test_framework="pytest", test_framework_version=pytest.__version__, ) - session_manager = SessionManager(session=session) + + try: + session_manager = SessionManager(session=session) + except SetupError as e: + log.error("%s", e) + yield + return early_config.stash[SESSION_MANAGER_STASH_KEY] = session_manager From c73313ca3303a083a52c719f41491cf34ce51288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 19:29:59 +0000 Subject: [PATCH 06/14] change url handling --- ddtestpy/internal/constants.py | 3 + ddtestpy/internal/errors.py | 2 + ddtestpy/internal/http.py | 110 ++++++++++++++++++++++----------- 3 files changed, 78 insertions(+), 37 deletions(-) create mode 100644 ddtestpy/internal/errors.py diff --git a/ddtestpy/internal/constants.py b/ddtestpy/internal/constants.py index b4bf577..1725551 100644 --- a/ddtestpy/internal/constants.py +++ b/ddtestpy/internal/constants.py @@ -2,6 +2,9 @@ DEFAULT_ENV_NAME = "none" DEFAULT_SITE = "datadoghq.com" +DEFAULT_AGENT_HOSTNAME = "localhost" +DEFAULT_AGENT_PORT = 8126 + TAG_TRUE = "true" TAG_FALSE = "false" diff --git a/ddtestpy/internal/errors.py b/ddtestpy/internal/errors.py new file mode 100644 index 0000000..af00673 --- /dev/null +++ b/ddtestpy/internal/errors.py @@ -0,0 +1,2 @@ +class SetupError(Exception): + pass diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index 2f9090d..f8c8101 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -11,9 +11,12 @@ import threading import time import typing as t -import urllib.parse +from urllib.parse import ParseResult +from urllib.parse import urlparse import uuid +from ddtestpy.internal.constants import DEFAULT_AGENT_HOSTNAME +from ddtestpy.internal.constants import DEFAULT_AGENT_PORT from ddtestpy.internal.constants import DEFAULT_SITE from ddtestpy.internal.errors import SetupError from ddtestpy.internal.utils import asbool @@ -25,11 +28,24 @@ class BackendConnectorSetup: + """ + Logic for detecting the backend connection mode (agentless or EVP) and creating new connectors. + """ + @abstractmethod - def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: ... + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + """ + Return a backend connector for the given subdomain (e.g., api, citestcov-intake, citestcycle-intake). + + This method must be implemented for each backend connection mode subclass. + """ + pass @classmethod def detect_setup(cls) -> BackendConnectorSetup: + """ + Detect which backend connection mode to use and return a configured instance of the corresponding subclass. + """ if asbool(os.environ.get("DD_CIVISIBILITY_AGENTLESS_ENABLED")): log.info("Connecting to backend in agentless mode") return cls._detect_agentless_setup() @@ -40,6 +56,9 @@ def detect_setup(cls) -> BackendConnectorSetup: @classmethod def _detect_agentless_setup(cls) -> BackendConnectorSetup: + """ + Detect settings for agentless backend connection mode. + """ site = os.environ.get("DD_SITE") or DEFAULT_SITE api_key = os.environ.get("DD_API_KEY") @@ -50,40 +69,42 @@ def _detect_agentless_setup(cls) -> BackendConnectorSetup: @classmethod def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: + """ + Detect settings for EVP proxy mode backend connection mode. + """ agent_url = os.environ.get("DD_TRACE_AGENT_URL") if not agent_url: - agent_host = os.environ.get("DD_TRACE_AGENT_HOSTNAME") or os.environ.get("DD_AGENT_HOST") or "localhost" - agent_port = os.environ.get("DD_TRACE_AGENT_PORT") or os.environ.get("DD_AGENT_PORT") or "8126" + agent_host = ( + os.environ.get("DD_TRACE_AGENT_HOSTNAME") or os.environ.get("DD_AGENT_HOST") or DEFAULT_AGENT_HOSTNAME + ) + agent_port = ( + os.environ.get("DD_TRACE_AGENT_PORT") or os.environ.get("DD_AGENT_PORT") or str(DEFAULT_AGENT_PORT) + ) agent_url = f"http://{agent_host}:{agent_port}" + agent_url = agent_url.rstrip("/") # Avoid an extra / when concatenating with the base path + + # Get info from agent to check if the agent is there, and which EVP proxy version it supports. try: - url = urllib.parse.urlparse(agent_url) - conn = http.client.HTTPConnection(host=url.hostname, port=url.port) - conn.request("GET", "/info") - response = conn.getresponse() - response_body = response.read() - response.close() + connector = BackendConnector(agent_url) + response, response_data = connector.get_json("/info") + connector.close() except Exception as e: raise SetupError(f"Error connecting to Datadog agent at {agent_url}: {e}") if response.status != 200: raise SetupError( f"Error connecting to Datadog agent at {agent_url}: status {response.status}, " - f"response {response_body!r}" + f"response {response_data!r}" ) - response_data = json.loads(response_body) endpoints = response_data.get("endpoints", []) if "/evp_proxy/v4/" in endpoints: - return BackendConnectorEVPProxySetup( - host=url.hostname, port=url.port, base_path="/evp_proxy/v4", use_gzip=True - ) + return BackendConnectorEVPProxySetup(url=f"{agent_url}/evp_proxy/v4", use_gzip=True) if "/evp_proxy/v2/" in endpoints: - return BackendConnectorEVPProxySetup( - host=url.hostname, port=url.port, base_path="/evp_proxy/v2", use_gzip=False - ) + return BackendConnectorEVPProxySetup(url=f"{agent_url}/evp_proxy/v2", use_gzip=False) raise SetupError(f"Datadog agent at {agent_url} does not support EVP proxy mode") @@ -96,47 +117,38 @@ def __init__(self, site: str, api_key: str) -> None: def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( - host=f"{subdomain}.{self.site}", - port=self.port, - http_class=http.client.HTTPSConnection, + url=f"https://{subdomain}.{self.site}:{self.port}", default_headers={"dd-api-key": self.api_key}, ) class BackendConnectorEVPProxySetup(BackendConnectorSetup): - def __init__(self, host: str, port: int, base_path: str, use_gzip: bool) -> None: - self.host = host - self.port = port - self.base_path = base_path + def __init__(self, url: str, use_gzip: bool) -> None: + self.url = url self.use_gzip = use_gzip def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( - host=self.host, - port=self.port, - http_class=http.client.HTTPConnection, + url=self.url, default_headers={"X-Datadog-EVP-Subdomain": subdomain}, backend_supports_gzip_requests=self.use_gzip, accept_gzip_responses=self.use_gzip, - base_path=self.base_path, ) class BackendConnector(threading.local): def __init__( self, - host: str, - port: int = 443, - http_class: t.Type[http.client.HTTPConnection] = http.client.HTTPSConnection, + url: str, default_headers: t.Optional[t.Dict[str, str]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, backend_supports_gzip_requests: bool = True, accept_gzip_responses: bool = True, - base_path: str = "", ): - self.conn = http_class(host=host, port=port, timeout=timeout_seconds) + parsed_url = urlparse(url) + self.conn = self._make_connection(parsed_url, timeout_seconds) self.default_headers = default_headers or {} - self.base_path = base_path + self.base_path = parsed_url.path.rstrip("/") self.backend_supports_gzip_requests = backend_supports_gzip_requests if accept_gzip_responses: self.default_headers["Accept-Encoding"] = "gzip" @@ -144,18 +156,37 @@ def __init__( def close(self) -> None: self.conn.close() + def _make_connection(self, parsed_url: ParseResult, timeout_seconds: float) -> http.client.HTTPConnection: + if parsed_url.scheme == "http": + if not parsed_url.hostname: + raise SetupError(f"No hostname provided in {parsed_url.geturl()}") + + return http.client.HTTPConnection( + host=parsed_url.hostname, port=parsed_url.port or 80, timeout=timeout_seconds + ) + + if parsed_url.scheme == "https": + if not parsed_url.hostname: + raise SetupError(f"No hostname provided in {parsed_url.geturl()}") + + return http.client.HTTPSConnection( + host=parsed_url.hostname, port=parsed_url.port or 443, timeout=timeout_seconds + ) + + raise SetupError(f"Unknown scheme {parsed_url.scheme} in {parsed_url.geturl()}") + # TODO: handle retries def request( self, method: str, path: str, - data: bytes, + data: t.Optional[bytes] = None, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False, ) -> t.Tuple[http.client.HTTPResponse, bytes]: full_headers = self.default_headers | (headers or {}) - if send_gzip and self.backend_supports_gzip_requests: + if send_gzip and self.backend_supports_gzip_requests and data is not None: data = gzip.compress(data, compresslevel=6) full_headers["Content-Encoding"] = "gzip" @@ -177,6 +208,11 @@ def request( return response, response_data + def get_json(self, path: str, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False) -> t.Any: + headers = {"Content-Type": "application/json"} | (headers or {}) + response, response_data = self.request("GET", path=path, headers=headers, send_gzip=send_gzip) + return response, json.loads(response_data) + def post_json( self, path: str, data: t.Any, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False ) -> t.Any: From 4c13ee9c7d83a48901142101701c59bd6b5d48ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 19:35:21 +0000 Subject: [PATCH 07/14] cleanup gzip --- ddtestpy/internal/http.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index f8c8101..9245c2d 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -119,6 +119,7 @@ def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( url=f"https://{subdomain}.{self.site}:{self.port}", default_headers={"dd-api-key": self.api_key}, + use_gzip=True, ) @@ -131,8 +132,7 @@ def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( url=self.url, default_headers={"X-Datadog-EVP-Subdomain": subdomain}, - backend_supports_gzip_requests=self.use_gzip, - accept_gzip_responses=self.use_gzip, + use_gzip=self.use_gzip, ) @@ -142,15 +142,14 @@ def __init__( url: str, default_headers: t.Optional[t.Dict[str, str]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - backend_supports_gzip_requests: bool = True, - accept_gzip_responses: bool = True, + use_gzip: bool = False, ): parsed_url = urlparse(url) self.conn = self._make_connection(parsed_url, timeout_seconds) self.default_headers = default_headers or {} self.base_path = parsed_url.path.rstrip("/") - self.backend_supports_gzip_requests = backend_supports_gzip_requests - if accept_gzip_responses: + self.use_gzip = use_gzip + if self.use_gzip: self.default_headers["Accept-Encoding"] = "gzip" def close(self) -> None: @@ -186,7 +185,7 @@ def request( ) -> t.Tuple[http.client.HTTPResponse, bytes]: full_headers = self.default_headers | (headers or {}) - if send_gzip and self.backend_supports_gzip_requests and data is not None: + if send_gzip and self.use_gzip and data is not None: data = gzip.compress(data, compresslevel=6) full_headers["Content-Encoding"] = "gzip" From a431cf3a98690441b48ac9a9ed581ff148a7973d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 19:40:30 +0000 Subject: [PATCH 08/14] a --- ddtestpy/internal/http.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index 9245c2d..f87a0c5 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -88,6 +88,7 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: try: connector = BackendConnector(agent_url) response, response_data = connector.get_json("/info") + endpoints = response_data.get("endpoints", []) connector.close() except Exception as e: raise SetupError(f"Error connecting to Datadog agent at {agent_url}: {e}") @@ -98,8 +99,6 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: f"response {response_data!r}" ) - endpoints = response_data.get("endpoints", []) - if "/evp_proxy/v4/" in endpoints: return BackendConnectorEVPProxySetup(url=f"{agent_url}/evp_proxy/v4", use_gzip=True) @@ -172,6 +171,8 @@ def _make_connection(self, parsed_url: ParseResult, timeout_seconds: float) -> h host=parsed_url.hostname, port=parsed_url.port or 443, timeout=timeout_seconds ) + # TODO: Unix domain socket support. + raise SetupError(f"Unknown scheme {parsed_url.scheme} in {parsed_url.geturl()}") # TODO: handle retries From 3e4f6110568c30fe9fd3f36f03b529d6d4c1fe33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 19:51:36 +0000 Subject: [PATCH 09/14] type errors --- ddtestpy/internal/session_manager.py | 1 + tests/internal/test_http.py | 4 ++-- tests/internal/test_writer.py | 15 ++++++--------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/ddtestpy/internal/session_manager.py b/ddtestpy/internal/session_manager.py index aade1b8..469826a 100644 --- a/ddtestpy/internal/session_manager.py +++ b/ddtestpy/internal/session_manager.py @@ -61,6 +61,7 @@ def __init__(self, session: TestSession) -> None: self.service = _get_service_name_from_git_repo(self.env_tags) or DEFAULT_SERVICE_NAME self.env = os.environ.get("DD_ENV") or DEFAULT_ENV_NAME + self.connector_setup = BackendConnectorSetup.detect_setup() self.api_client = APIClient( diff --git a/tests/internal/test_http.py b/tests/internal/test_http.py index ffde698..b631ff7 100644 --- a/tests/internal/test_http.py +++ b/tests/internal/test_http.py @@ -18,7 +18,7 @@ def test_constants(self) -> None: @patch("http.client.HTTPSConnection") def test_init_default_parameters(self, mock_https_connection: Mock) -> None: """Test BackendConnector initialization with default parameters.""" - connector = BackendConnector(host="api.example.com") + connector = BackendConnector(url="https://api.example.com", use_gzip=True) mock_https_connection.assert_called_once_with(host="api.example.com", port=443, timeout=DEFAULT_TIMEOUT_SECONDS) assert connector.default_headers == {"Accept-Encoding": "gzip"} @@ -41,7 +41,7 @@ def test_post_files_multiple_files(self, mock_uuid: Mock, mock_https_connection: mock_https_connection.return_value = mock_conn # Test post_files with multiple files - connector = BackendConnector(host="api.example.com") + connector = BackendConnector(url="https://api.example.com") files = [ FileAttachment("file1", "doc1.txt", "text/plain", b"content1"), FileAttachment("file2", "doc2.json", "application/json", b"content2"), diff --git a/tests/internal/test_writer.py b/tests/internal/test_writer.py index 8f89437..f0e00ef 100644 --- a/tests/internal/test_writer.py +++ b/tests/internal/test_writer.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from unittest.mock import patch +from ddtestpy.internal.http import BackendConnectorAgentlessSetup from ddtestpy.internal.test_data import TestModule from ddtestpy.internal.test_data import TestRun from ddtestpy.internal.test_data import TestSession @@ -48,10 +49,8 @@ def test_testopt_writer_initialization(self, mock_backend_connector: Mock) -> No mock_connector = Mock() mock_backend_connector.return_value = mock_connector - writer = TestOptWriter(site="datadoghq.com", api_key="test_key") + writer = TestOptWriter(BackendConnectorAgentlessSetup(site="datadoghq.com", api_key="test_key")) - assert writer.site == "datadoghq.com" - assert writer.api_key == "test_key" assert writer.connector == mock_connector # Check metadata structure @@ -78,7 +77,7 @@ def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> No mock_connector.request.return_value = (Mock(), {}) mock_packb.return_value = b"packed_data" - writer = TestOptWriter(site="test", api_key="key") + writer = TestOptWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) events = [Event(type="test1"), Event(type="test2")] writer._send_events(events) @@ -110,10 +109,8 @@ def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> N mock_connector = Mock() mock_backend_connector.return_value = mock_connector - writer = TestCoverageWriter(site="datadoghq.com", api_key="test_key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="datadoghq.com", api_key="test_key")) - assert writer.site == "datadoghq.com" - assert writer.api_key == "test_key" assert writer.connector == mock_connector # Check connector initialization @@ -124,7 +121,7 @@ def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> N @patch("ddtestpy.internal.writer.BackendConnector") def test_put_coverage(self, mock_backend_connector: Mock) -> None: """Test putting coverage data.""" - writer = TestCoverageWriter(site="test", api_key="key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) # Mock test run test_run = Mock() @@ -157,7 +154,7 @@ def test_send_coverage_events(self, mock_packb: Mock, mock_backend_connector: Mo mock_connector.post_files.return_value = (Mock(), {}) mock_packb.return_value = b"packed_coverage_data" - writer = TestCoverageWriter(site="test", api_key="key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) events = [Event(type="coverage1"), Event(type="coverage2")] writer._send_events(events) From 978aea794bac101a8fec6b069e7feaa7587796a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 21:07:46 +0000 Subject: [PATCH 10/14] fix mocks --- pyproject.toml | 1 + tests/internal/test_session_manager.py | 6 +++--- tests/internal/test_writer.py | 12 +++++------ tests/mocks.py | 29 +++++++++++++++++++------- tests/test_integration.py | 2 ++ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b270cd4..37d7788 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ python = ["3.9", "3.10", "3.11", "3.12", "3.13"] [tool.hatch.envs.int_test.env-vars] DD_API_KEY = "foobar" +DD_CIVISIBILITY_AGENTLESS_ENABLED = "true" [tool.hatch.envs.int_test.scripts] test = "pytest {args:test_fixtures}" diff --git a/tests/internal/test_session_manager.py b/tests/internal/test_session_manager.py index 3fa3de2..deb9831 100644 --- a/tests/internal/test_session_manager.py +++ b/tests/internal/test_session_manager.py @@ -175,7 +175,7 @@ def test_multiple_tests_same_skippable_suite(self) -> None: class TestSessionNameTest: def test_session_name_explicitly_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_TEST_SESSION_NAME": "the_name", "DD_API_KEY": "somekey"} + env = {"DD_TEST_SESSION_NAME": "the_name", "DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({CITag.JOB_NAME: "the_job"}).build_real_with_mocks(env) @@ -184,7 +184,7 @@ def test_session_name_explicitly_from_env_var(self, monkeypatch: pytest.MonkeyPa assert session_manager.writer.metadata["*"]["test_session.name"] == expected_name def test_session_name_from_job_name(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_API_KEY": "somekey"} + env = {"DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({CITag.JOB_NAME: "the_job"}).build_real_with_mocks(env) @@ -193,7 +193,7 @@ def test_session_name_from_job_name(self, monkeypatch: pytest.MonkeyPatch) -> No assert session_manager.writer.metadata["*"]["test_session.name"] == expected_name def test_session_name_from_test_command(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_API_KEY": "somekey"} + env = {"DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({}).build_real_with_mocks(env) diff --git a/tests/internal/test_writer.py b/tests/internal/test_writer.py index f0e00ef..6556c30 100644 --- a/tests/internal/test_writer.py +++ b/tests/internal/test_writer.py @@ -43,7 +43,7 @@ def test_event_dict_operations(self) -> None: class TestTestOptWriter: """Tests for TestOptWriter class.""" - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_testopt_writer_initialization(self, mock_backend_connector: Mock) -> None: """Test TestOptWriter initialization.""" mock_connector = Mock() @@ -67,7 +67,7 @@ def test_testopt_writer_initialization(self, mock_backend_connector: Mock) -> No assert TestModule in writer.serializers assert TestSession in writer.serializers - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") @patch("msgpack.packb") def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> None: """Test sending events to backend.""" @@ -103,7 +103,7 @@ def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> No class TestTestCoverageWriter: """Tests for TestCoverageWriter class.""" - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> None: """Test TestCoverageWriter initialization.""" mock_connector = Mock() @@ -115,10 +115,10 @@ def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> N # Check connector initialization mock_backend_connector.assert_called_once_with( - host="citestcov-intake.datadoghq.com", default_headers={"dd-api-key": "test_key"} + url="https://citestcov-intake.datadoghq.com:443", default_headers={"dd-api-key": "test_key"}, use_gzip=True ) - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_put_coverage(self, mock_backend_connector: Mock) -> None: """Test putting coverage data.""" writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) @@ -144,7 +144,7 @@ def test_put_coverage(self, mock_backend_connector: Mock) -> None: assert event["span_id"] == 789 assert len(event["files"]) == 2 - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") @patch("msgpack.packb") def test_send_coverage_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> None: """Test sending coverage events.""" diff --git a/tests/mocks.py b/tests/mocks.py index 00edd6b..6e839ff 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -22,6 +22,7 @@ from ddtestpy.internal.api_client import Settings from ddtestpy.internal.api_client import TestManagementSettings from ddtestpy.internal.api_client import TestProperties +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.session_manager import SessionManager from ddtestpy.internal.test_data import ModuleRef from ddtestpy.internal.test_data import SuiteRef @@ -72,7 +73,12 @@ def settings( @staticmethod def test_environment() -> t.Dict[str, str]: """Create default test environment variables.""" - return {"DD_API_KEY": "test-api-key", "DD_SERVICE": "test-service", "DD_ENV": "test-env"} + return { + "DD_API_KEY": "test-api-key", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", + "DD_SERVICE": "test-service", + "DD_ENV": "test-env", + } @staticmethod def test_session(name: str = "test") -> TestSession: @@ -432,9 +438,7 @@ def with_request_response(self, method: str, path: str, response_data: t.Any) -> def build(self) -> Mock: """Build the BackendConnector mock.""" - from ddtestpy.internal.http import BackendConnector - - mock_connector = Mock(spec=BackendConnector) + mock_connector = Mock() # Mock methods to prevent real HTTP calls def mock_post_json(endpoint: str, data: t.Any) -> t.Tuple[Mock, t.Any]: @@ -537,14 +541,21 @@ def mock_backend_connector() -> "BackendConnectorMockBuilder": return BackendConnectorMockBuilder() -def setup_standard_mocks() -> t.ContextManager[t.Any]: +class BackendConnectorMockSetup: + def get_connector_for_subdomain(self, subdomain: str) -> Mock: + return mock_backend_connector().build() + + +@contextlib.contextmanager +def setup_standard_mocks() -> t.Generator[None, None, None]: """Mock calls used by the session manager to get git and platform tags.""" - return patch.multiple( + with patch.multiple( "ddtestpy.internal.session_manager", get_env_tags=Mock(return_value={}), get_platform_tags=Mock(return_value={}), Git=Mock(return_value=get_mock_git_instance()), - ) + ), patch.object(BackendConnectorSetup, "detect_setup", return_value=BackendConnectorMockSetup()): + yield def network_mocks() -> t.ContextManager[t.Any]: @@ -564,6 +575,10 @@ def _create_stack() -> t.ContextManager[t.Any]: ) ) + stack.enter_context( + patch.object(BackendConnectorSetup, "detect_setup", return_value=BackendConnectorMockSetup()) + ) + # Mock the HTTP connector to prevent any real HTTP calls mock_connector = mock_backend_connector().build() stack.enter_context(patch("ddtestpy.internal.http.BackendConnector", return_value=mock_connector)) diff --git a/tests/test_integration.py b/tests/test_integration.py index 96a6e4f..adb02dd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -428,6 +428,7 @@ def test_retry_handler_configuration(self) -> None: os.environ, # Mock environment variables { "DD_API_KEY": "test-key", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_COUNT": "3", "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT": "10", @@ -468,6 +469,7 @@ def test_retry_handler_logic(self) -> None: os.environ, { "DD_API_KEY": "foobar", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_COUNT": "2", "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT": "5", }, From 732d292e080c42ca4983ab1568688148d87ae949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Mon, 17 Nov 2025 21:11:20 +0000 Subject: [PATCH 11/14] remove port --- ddtestpy/internal/http.py | 3 +-- tests/internal/test_writer.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index f87a0c5..3de9cb4 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -111,12 +111,11 @@ def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: class BackendConnectorAgentlessSetup(BackendConnectorSetup): def __init__(self, site: str, api_key: str) -> None: self.site = site - self.port = 443 self.api_key = api_key def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: return BackendConnector( - url=f"https://{subdomain}.{self.site}:{self.port}", + url=f"https://{subdomain}.{self.site}", default_headers={"dd-api-key": self.api_key}, use_gzip=True, ) diff --git a/tests/internal/test_writer.py b/tests/internal/test_writer.py index 6556c30..35c40ad 100644 --- a/tests/internal/test_writer.py +++ b/tests/internal/test_writer.py @@ -115,7 +115,7 @@ def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> N # Check connector initialization mock_backend_connector.assert_called_once_with( - url="https://citestcov-intake.datadoghq.com:443", default_headers={"dd-api-key": "test_key"}, use_gzip=True + url="https://citestcov-intake.datadoghq.com", default_headers={"dd-api-key": "test_key"}, use_gzip=True ) @patch("ddtestpy.internal.http.BackendConnector") From 32fa2b305987b9b9b16f055e47557ac5f52f807c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Tue, 18 Nov 2025 14:46:50 +0000 Subject: [PATCH 12/14] some tests --- tests/internal/test_http.py | 75 +++++++++++++++++++++++++++++++++++++ tests/mocks.py | 22 ++++++++--- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/tests/internal/test_http.py b/tests/internal/test_http.py index b631ff7..ba82a62 100644 --- a/tests/internal/test_http.py +++ b/tests/internal/test_http.py @@ -1,11 +1,20 @@ """Tests for ddtestpy.internal.http module.""" +import http.client +import os from unittest.mock import Mock from unittest.mock import patch +import pytest + +from ddtestpy.internal.errors import SetupError from ddtestpy.internal.http import DEFAULT_TIMEOUT_SECONDS from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorAgentlessSetup +from ddtestpy.internal.http import BackendConnectorEVPProxySetup +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment +from tests.mocks import mock_backend_connector class TestBackendConnector: @@ -59,3 +68,69 @@ def test_post_files_multiple_files(self, mock_uuid: Mock, mock_https_connection: assert b"content1" in body assert b"content2" in body assert body.count(b"--boundary123") == 3 # 2 file separators + 1 end + + +class TestBackendConnectorSetup: + def test_detect_agentless_setup_ok(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_API_KEY": "the-key"}) + + connector_setup = BackendConnectorSetup.detect_setup() + assert isinstance(connector_setup, BackendConnectorAgentlessSetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPSConnection) + assert connector.conn.host == "api.datadoghq.com" + assert connector.conn.port == 443 + assert connector.use_gzip is True + assert connector.default_headers["dd-api-key"] == "the-key" + + def test_detect_agentless_setup_ok_with_site(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + os, + "environ", + {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_API_KEY": "the-key", "DD_SITE": "datadoghq.eu"}, + ) + + connector_setup = BackendConnectorSetup.detect_setup() + assert isinstance(connector_setup, BackendConnectorAgentlessSetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPSConnection) + assert connector.conn.host == "api.datadoghq.eu" + assert connector.conn.port == 443 + assert connector.use_gzip is True + assert connector.default_headers["dd-api-key"] == "the-key" + + def test_detect_agentless_setup_no_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + os, + "environ", + {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"}, + ) + + with pytest.raises(SetupError) as error: + BackendConnectorSetup.detect_setup() + + assert str(error.value) == "DD_API_KEY environment variable is not set" + + def test_detect_evp_proxy_mode_v4(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "localhost" + assert connector.conn.port == 8126 + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v2(self) -> None: ... + + def test_detect_evp_proxy_mode_no_agent(self) -> None: ... diff --git a/tests/mocks.py b/tests/mocks.py index 6e839ff..7d53102 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -423,6 +423,7 @@ class BackendConnectorMockBuilder: def __init__(self) -> None: self._post_json_responses: t.Dict[str, t.Any] = {} + self._get_json_responses: t.Dict[str, t.Any] = {} self._request_responses: t.Dict[str, t.Any] = {} self._post_files_responses: t.Dict[str, t.Any] = {} @@ -431,6 +432,11 @@ def with_post_json_response(self, endpoint: str, response_data: t.Any) -> "Backe self._post_json_responses[endpoint] = response_data return self + def with_get_json_response(self, endpoint: str, response_data: t.Any) -> "BackendConnectorMockBuilder": + """Mock a specific POST JSON endpoint response.""" + self._get_json_responses[endpoint] = response_data + return self + def with_request_response(self, method: str, path: str, response_data: t.Any) -> "BackendConnectorMockBuilder": """Mock a specific HTTP request response.""" self._request_responses[f"{method}:{path}"] = response_data @@ -443,19 +449,25 @@ def build(self) -> Mock: # Mock methods to prevent real HTTP calls def mock_post_json(endpoint: str, data: t.Any) -> t.Tuple[Mock, t.Any]: if endpoint in self._post_json_responses: - return Mock(), self._post_json_responses[endpoint] - return Mock(), {} + return Mock(status=200), self._post_json_responses[endpoint] + return Mock(status=404), {} + + def mock_get_json(endpoint: str) -> t.Tuple[Mock, t.Any]: + if endpoint in self._get_json_responses: + return Mock(status=200), self._get_json_responses[endpoint] + return Mock(status=404), {} def mock_request(method: str, path: str, **kwargs: t.Any) -> t.Tuple[Mock, t.Any]: key = f"{method}:{path}" if key in self._request_responses: - return Mock(), self._request_responses[key] - return Mock(), {} + return Mock(status=200), self._request_responses[key] + return Mock(status=404), {} def mock_post_files(path: str, files: t.Any, **kwargs: t.Any) -> t.Tuple[Mock, t.Dict[str, t.Any]]: - return Mock(), {} + return Mock(status=200), {} mock_connector.post_json.side_effect = mock_post_json + mock_connector.get_json.side_effect = mock_get_json mock_connector.request.side_effect = mock_request mock_connector.post_files.side_effect = mock_post_files From 2157978c0f1ef138efea0614d976b7413ee7ddab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Tue, 18 Nov 2025 15:01:50 +0000 Subject: [PATCH 13/14] moar tests --- tests/internal/test_http.py | 39 ++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/internal/test_http.py b/tests/internal/test_http.py index ba82a62..8fae164 100644 --- a/tests/internal/test_http.py +++ b/tests/internal/test_http.py @@ -108,11 +108,9 @@ def test_detect_agentless_setup_no_api_key(self, monkeypatch: pytest.MonkeyPatch {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"}, ) - with pytest.raises(SetupError) as error: + with pytest.raises(SetupError, match="DD_API_KEY environment variable is not set"): BackendConnectorSetup.detect_setup() - assert str(error.value) == "DD_API_KEY environment variable is not set" - def test_detect_evp_proxy_mode_v4(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(os, "environ", {}) @@ -128,9 +126,40 @@ def test_detect_evp_proxy_mode_v4(self, monkeypatch: pytest.MonkeyPatch) -> None assert isinstance(connector.conn, http.client.HTTPConnection) assert connector.conn.host == "localhost" assert connector.conn.port == 8126 + assert connector.base_path == "/evp_proxy/v4" assert connector.use_gzip is True assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" - def test_detect_evp_proxy_mode_v2(self) -> None: ... + def test_detect_evp_proxy_mode_v2(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v2/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "localhost" + assert connector.conn.port == 8126 + assert connector.base_path == "/evp_proxy/v2" + assert connector.use_gzip is False + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_no_evp_support(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = mock_backend_connector().with_get_json_response("/info", {"endpoints": []}).build() + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + with pytest.raises(SetupError, match="Datadog agent .* does not support EVP proxy mode"): + BackendConnectorSetup.detect_setup() + + def test_detect_evp_proxy_mode_no_agent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) - def test_detect_evp_proxy_mode_no_agent(self) -> None: ... + with patch("ddtestpy.internal.http.BackendConnector.get_json", side_effect=ConnectionRefusedError("no bueno")): + with pytest.raises(SetupError, match="Error connecting to Datadog agent.*no bueno"): + BackendConnectorSetup.detect_setup() From 9d2cbe6f939a9621c1ea0007ca5995a6167a5a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20De=20Ara=C3=BAjo?= Date: Tue, 18 Nov 2025 15:06:35 +0000 Subject: [PATCH 14/14] moar tests --- tests/internal/test_http.py | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/internal/test_http.py b/tests/internal/test_http.py index 8fae164..92e40f4 100644 --- a/tests/internal/test_http.py +++ b/tests/internal/test_http.py @@ -31,6 +31,16 @@ def test_init_default_parameters(self, mock_https_connection: Mock) -> None: mock_https_connection.assert_called_once_with(host="api.example.com", port=443, timeout=DEFAULT_TIMEOUT_SECONDS) assert connector.default_headers == {"Accept-Encoding": "gzip"} + assert connector.base_path == "" + + @patch("http.client.HTTPSConnection") + def test_init_custom_parameters(self, mock_https_connection: Mock) -> None: + """Test BackendConnector initialization with default parameters.""" + connector = BackendConnector(url="https://api.example.com/some-path", use_gzip=False) + + mock_https_connection.assert_called_once_with(host="api.example.com", port=443, timeout=DEFAULT_TIMEOUT_SECONDS) + assert connector.default_headers == {} + assert connector.base_path == "/some-path" @patch("http.client.HTTPSConnection") @patch("uuid.uuid4") @@ -163,3 +173,60 @@ def test_detect_evp_proxy_mode_no_agent(self, monkeypatch: pytest.MonkeyPatch) - with patch("ddtestpy.internal.http.BackendConnector.get_json", side_effect=ConnectionRefusedError("no bueno")): with pytest.raises(SetupError, match="Error connecting to Datadog agent.*no bueno"): BackendConnectorSetup.detect_setup() + + def test_detect_evp_proxy_mode_v4_custom_dd_trace_agent_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_TRACE_AGENT_URL": "http://somehost:1234"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 1234 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v4_custom_dd_trace_agent_hostname(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_TRACE_AGENT_HOSTNAME": "somehost", "DD_TRACE_AGENT_PORT": "5678"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 5678 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v4_custom_dd_agent_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_AGENT_HOST": "somehost", "DD_AGENT_PORT": "5678"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 5678 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api"