From 76a0deb87f5086ccbc3a84c43853cd0f9711ca36 Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Thu, 12 Nov 2026 09:44:52 +0100 Subject: [PATCH 1/2] refactor: group api clients --- UnleashClient/_async_metrics.py | 2 +- UnleashClient/_metrics.py | 2 +- UnleashClient/api_client/__init__.py | 1 + .../_async.py} | 4 +- UnleashClient/api_client/_shared.py | 92 ++++++++++++++++++ .../_sync.py} | 97 +++---------------- UnleashClient/clients/async_unleash_client.py | 2 +- UnleashClient/clients/unleash_client.py | 2 +- UnleashClient/connectors/_async_connector.py | 2 +- UnleashClient/connectors/polling_connector.py | 2 +- .../connectors/test_async_connector.py | 2 +- .../connectors/test_polling_connector.py | 2 +- tests/unit_tests/test_async_metrics.py | 2 +- .../test_async_unleash_api_client.py | 2 +- tests/unit_tests/test_metrics.py | 2 +- tests/unit_tests/test_unleash_api_client.py | 3 +- 16 files changed, 121 insertions(+), 98 deletions(-) create mode 100644 UnleashClient/api_client/__init__.py rename UnleashClient/{_async_unleash_api_client.py => api_client/_async.py} (98%) create mode 100644 UnleashClient/api_client/_shared.py rename UnleashClient/{_unleash_api_client.py => api_client/_sync.py} (75%) diff --git a/UnleashClient/_async_metrics.py b/UnleashClient/_async_metrics.py index 6eb8cf20..8ffb256d 100644 --- a/UnleashClient/_async_metrics.py +++ b/UnleashClient/_async_metrics.py @@ -5,8 +5,8 @@ from yggdrasil_engine.engine import UnleashEngine from UnleashClient._async_scheduler import _AsyncJob, _AsyncScheduler -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._payloads import _build_metrics_payload +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import UnleashConfig from UnleashClient.impact_metrics import ImpactMetrics from UnleashClient.utils import LOGGER diff --git a/UnleashClient/_metrics.py b/UnleashClient/_metrics.py index aa5c5f12..d9af3cc2 100644 --- a/UnleashClient/_metrics.py +++ b/UnleashClient/_metrics.py @@ -4,7 +4,7 @@ from UnleashClient._payloads import _build_metrics_payload from UnleashClient._scheduler import _ScheduledJob, _Scheduler -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.config import UnleashConfig from UnleashClient.impact_metrics import ImpactMetrics from UnleashClient.utils import LOGGER diff --git a/UnleashClient/api_client/__init__.py b/UnleashClient/api_client/__init__.py new file mode 100644 index 00000000..5ecd3e63 --- /dev/null +++ b/UnleashClient/api_client/__init__.py @@ -0,0 +1 @@ +"""Talks to the Unleash API: feature fetches, registration and metrics.""" diff --git a/UnleashClient/_async_unleash_api_client.py b/UnleashClient/api_client/_async.py similarity index 98% rename from UnleashClient/_async_unleash_api_client.py rename to UnleashClient/api_client/_async.py index 1676378a..515d2f92 100644 --- a/UnleashClient/_async_unleash_api_client.py +++ b/UnleashClient/api_client/_async.py @@ -9,7 +9,7 @@ from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory from UnleashClient._retry import _send_with_retries_async -from UnleashClient._unleash_api_client import ( +from UnleashClient.api_client._shared import ( FetchResult, _emit_fetch_completed, _failure_status, @@ -28,7 +28,7 @@ class _AsyncUnleashApiClient: - """The asyncio twin of :class:`UnleashClient._unleash_api_client._UnleashApiClient`.""" + """The asyncio twin of :class:`UnleashClient.api_client._sync._UnleashApiClient`.""" def __init__( self, diff --git a/UnleashClient/api_client/_shared.py b/UnleashClient/api_client/_shared.py new file mode 100644 index 00000000..35801cd0 --- /dev/null +++ b/UnleashClient/api_client/_shared.py @@ -0,0 +1,92 @@ +""" +What both API clients share: the fetch result, how a fetch outcome is +classified and reported, and reading a response. Names no HTTP library. +""" + +import uuid +from typing import NamedTuple, Optional + +from UnleashClient._event_dispatcher import _EventDispatcher +from UnleashClient.events import ( + FetchStatus, + UnleashEventType, + UnleashFetchCompletedEvent, +) +from UnleashClient.transport import Response +from UnleashClient.utils import LOGGER + + +class FetchResult(NamedTuple): + """ + The outcome of one feature fetch. + + :param raw_state: Feature state body, or None on a 304 and on failure. + :param etag: ETag returned by the server, or "" when the response had none. + :param not_modified: True when the server answered 304. + """ + + raw_state: Optional[str] + etag: str + not_modified: bool = False + + +def _failure_status(http_status_code: Optional[int]) -> FetchStatus: + """Classifies a failed fetch by the response it got, if any.""" + if http_status_code is None: + return FetchStatus.UNAVAILABLE + if http_status_code in (401, 403): + return FetchStatus.UNAUTHORIZED + if http_status_code == 429: + return FetchStatus.RATE_LIMITED + if http_status_code >= 500: + return FetchStatus.UNAVAILABLE + return FetchStatus.INVALID_RESPONSE + + +def _fetched_status(raw_state: str) -> FetchStatus: + """An empty 200 is not usable: there is no state to apply.""" + return FetchStatus.CHANGED if raw_state else FetchStatus.INVALID_RESPONSE + + +def _emit_fetch_completed( + events: Optional[_EventDispatcher], status: FetchStatus +) -> None: + """ + Reports how a fetch ended. Never raises: a broken listener must not break + the fetch. + """ + if events is None: + return + + try: + events.emit_event( + UnleashFetchCompletedEvent( + event_type=UnleashEventType.FETCH_COMPLETED, + event_id=uuid.uuid4(), + status=status, + ) + ) + except Exception: # pylint: disable=broad-except + LOGGER.debug("Failed to emit the fetch completed event", exc_info=True) + + +def _normalized_url(url: str, path: str) -> str: + return f"{url}{path}" + + +def _header(resp: Response, name: str) -> str: + name = name.lower() + for key, value in resp.headers.items(): + if key.lower() == name: + return value + return "" + + +def _text(resp: Response) -> str: + return resp.body.decode("utf-8", errors="replace") + + +def _log_resp_info(resp: Response) -> None: + LOGGER.debug("HTTP status code: %s", resp.status) + LOGGER.debug("HTTP headers: %s", resp.headers) + LOGGER.debug("HTTP content: %s", _text(resp)) diff --git a/UnleashClient/_unleash_api_client.py b/UnleashClient/api_client/_sync.py similarity index 75% rename from UnleashClient/_unleash_api_client.py rename to UnleashClient/api_client/_sync.py index c3c6330a..aa33e0e9 100644 --- a/UnleashClient/_unleash_api_client.py +++ b/UnleashClient/api_client/_sync.py @@ -1,98 +1,27 @@ import json -import uuid -from typing import Any, Dict, NamedTuple, Optional, Tuple +from typing import Any, Dict, Optional, Tuple from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory from UnleashClient._requests_transport import RequestsTransport from UnleashClient._retry import _send_with_retries +from UnleashClient.api_client._shared import ( + FetchResult, + _emit_fetch_completed, + _failure_status, + _fetched_status, + _header, + _log_resp_info, + _normalized_url, + _text, +) from UnleashClient.config import PollingOptions, UnleashConfig from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL -from UnleashClient.events import ( - FetchStatus, - UnleashEventType, - UnleashFetchCompletedEvent, -) -from UnleashClient.transport import Method, Request, Response, Transport +from UnleashClient.events import FetchStatus +from UnleashClient.transport import Method, Request, Transport from UnleashClient.utils import LOGGER -class FetchResult(NamedTuple): - """ - The outcome of one feature fetch. - - :param raw_state: Feature state body, or None on a 304 and on failure. - :param etag: ETag returned by the server, or "" when the response had none. - :param not_modified: True when the server answered 304. - """ - - raw_state: Optional[str] - etag: str - not_modified: bool = False - - -def _failure_status(http_status_code: Optional[int]) -> FetchStatus: - """Classifies a failed fetch by the response it got, if any.""" - if http_status_code is None: - return FetchStatus.UNAVAILABLE - if http_status_code in (401, 403): - return FetchStatus.UNAUTHORIZED - if http_status_code == 429: - return FetchStatus.RATE_LIMITED - if http_status_code >= 500: - return FetchStatus.UNAVAILABLE - return FetchStatus.INVALID_RESPONSE - - -def _fetched_status(raw_state: str) -> FetchStatus: - """An empty 200 is not usable: there is no state to apply.""" - return FetchStatus.CHANGED if raw_state else FetchStatus.INVALID_RESPONSE - - -def _emit_fetch_completed( - events: Optional[_EventDispatcher], status: FetchStatus -) -> None: - """ - Reports how a fetch ended. Never raises: a broken listener must not break - the fetch. - """ - if events is None: - return - - try: - events.emit_event( - UnleashFetchCompletedEvent( - event_type=UnleashEventType.FETCH_COMPLETED, - event_id=uuid.uuid4(), - status=status, - ) - ) - except Exception: # pylint: disable=broad-except - LOGGER.debug("Failed to emit the fetch completed event", exc_info=True) - - -def _normalized_url(url: str, path: str) -> str: - return f"{url}{path}" - - -def _header(resp: Response, name: str) -> str: - name = name.lower() - for key, value in resp.headers.items(): - if key.lower() == name: - return value - return "" - - -def _text(resp: Response) -> str: - return resp.body.decode("utf-8", errors="replace") - - -def _log_resp_info(resp: Response) -> None: - LOGGER.debug("HTTP status code: %s", resp.status) - LOGGER.debug("HTTP headers: %s", resp.headers) - LOGGER.debug("HTTP content: %s", _text(resp)) - - class _UnleashApiClient: """ Talks to the Unleash API: feature fetches, client registration and metrics diff --git a/UnleashClient/clients/async_unleash_client.py b/UnleashClient/clients/async_unleash_client.py index 5c8dfbc1..51800f82 100644 --- a/UnleashClient/clients/async_unleash_client.py +++ b/UnleashClient/clients/async_unleash_client.py @@ -13,7 +13,6 @@ from UnleashClient._async_metrics import _AsyncMetricsReporter from UnleashClient._async_scheduler import _AsyncScheduler -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._context import _ContextEnricher from UnleashClient._evaluator import _Evaluator from UnleashClient._event_dispatcher import _EventDispatcher @@ -24,6 +23,7 @@ _instance_identifier, ) from UnleashClient._payloads import _build_register_payload +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.cache import BaseCache, FileCache from UnleashClient.clients.unleash_client import _RunState from UnleashClient.config import ( diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index 10497284..7922bf54 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -22,7 +22,7 @@ from UnleashClient._metrics import _MetricsReporter from UnleashClient._payloads import _build_register_payload from UnleashClient._scheduler import _ScheduledJob, _Scheduler -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.cache import BaseCache, FileCache from UnleashClient.config import ( IdentityOptions, diff --git a/UnleashClient/connectors/_async_connector.py b/UnleashClient/connectors/_async_connector.py index 592e9c59..5c4adcd3 100644 --- a/UnleashClient/connectors/_async_connector.py +++ b/UnleashClient/connectors/_async_connector.py @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod from UnleashClient._async_scheduler import _AsyncScheduler -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._feature_store import _FeatureStore +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import PollingOptions diff --git a/UnleashClient/connectors/polling_connector.py b/UnleashClient/connectors/polling_connector.py index 3e7372de..ee4a7f5d 100644 --- a/UnleashClient/connectors/polling_connector.py +++ b/UnleashClient/connectors/polling_connector.py @@ -1,6 +1,6 @@ from UnleashClient._feature_store import _FeatureStore from UnleashClient._scheduler import _ScheduledJob, _Scheduler -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.config import PollingOptions from .base_connector import BaseConnector diff --git a/tests/unit_tests/connectors/test_async_connector.py b/tests/unit_tests/connectors/test_async_connector.py index bf342aa2..e4b0424c 100644 --- a/tests/unit_tests/connectors/test_async_connector.py +++ b/tests/unit_tests/connectors/test_async_connector.py @@ -10,10 +10,10 @@ from tests.utilities.fake_unleash_server import FakeUnleash from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE from tests.utilities.testing_constants import APP_NAME, ETAG_VALUE -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._feature_store import _FeatureStore from UnleashClient._headers import _HeaderFactory +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import PollingOptions, UnleashConfig from UnleashClient.connectors._async_connector import _AsyncPollingConnector from UnleashClient.constants import ETAG, FEATURES_URL diff --git a/tests/unit_tests/connectors/test_polling_connector.py b/tests/unit_tests/connectors/test_polling_connector.py index 77935b09..7ccf4b63 100644 --- a/tests/unit_tests/connectors/test_polling_connector.py +++ b/tests/unit_tests/connectors/test_polling_connector.py @@ -18,7 +18,7 @@ from UnleashClient._feature_store import _FeatureStore from UnleashClient._headers import _HeaderFactory from UnleashClient._scheduler import _Scheduler -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.config import ( IdentityOptions, PollingOptions, diff --git a/tests/unit_tests/test_async_metrics.py b/tests/unit_tests/test_async_metrics.py index ab174c87..db7ed03a 100644 --- a/tests/unit_tests/test_async_metrics.py +++ b/tests/unit_tests/test_async_metrics.py @@ -10,8 +10,8 @@ from tests.utilities.fake_unleash_server import FakeUnleash from UnleashClient._async_metrics import _AsyncMetricsReporter from UnleashClient._async_scheduler import _AsyncJob, _AsyncJobFn, _AsyncScheduler -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._headers import _HeaderFactory +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import IdentityOptions, MetricsOptions, UnleashConfig from UnleashClient.constants import CLIENT_SPEC_VERSION, METRICS_URL from UnleashClient.impact_metrics import ImpactMetrics diff --git a/tests/unit_tests/test_async_unleash_api_client.py b/tests/unit_tests/test_async_unleash_api_client.py index 68152914..6d3b3b33 100644 --- a/tests/unit_tests/test_async_unleash_api_client.py +++ b/tests/unit_tests/test_async_unleash_api_client.py @@ -21,8 +21,8 @@ URL, ) from UnleashClient._aiohttp_transport import AiohttpTransport -from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._headers import _HeaderFactory +from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import ( IdentityOptions, MetricsOptions, diff --git a/tests/unit_tests/test_metrics.py b/tests/unit_tests/test_metrics.py index 5dcc800e..d6283740 100644 --- a/tests/unit_tests/test_metrics.py +++ b/tests/unit_tests/test_metrics.py @@ -7,7 +7,7 @@ from UnleashClient._headers import _HeaderFactory from UnleashClient._metrics import _MetricsReporter from UnleashClient._scheduler import _Scheduler -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.config import IdentityOptions, MetricsOptions, UnleashConfig from UnleashClient.constants import CLIENT_SPEC_VERSION, METRICS_URL from UnleashClient.impact_metrics import ImpactMetrics diff --git a/tests/unit_tests/test_unleash_api_client.py b/tests/unit_tests/test_unleash_api_client.py index 3a67ed69..ee9ffb1a 100644 --- a/tests/unit_tests/test_unleash_api_client.py +++ b/tests/unit_tests/test_unleash_api_client.py @@ -20,7 +20,8 @@ URL, ) from UnleashClient._headers import _HeaderFactory -from UnleashClient._unleash_api_client import _header, _UnleashApiClient +from UnleashClient.api_client._shared import _header +from UnleashClient.api_client._sync import _UnleashApiClient from UnleashClient.config import ( IdentityOptions, MetricsOptions, From 5c9fbed82762893933db2d02f8ee25e348d3574d Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Thu, 12 Nov 2026 09:59:27 +0100 Subject: [PATCH 2/2] refactor: group transports --- UnleashClient/__init__.py | 6 +++--- UnleashClient/api_client/_async.py | 2 +- UnleashClient/api_client/_sync.py | 2 +- UnleashClient/{transport.py => transport/__init__.py} | 5 ----- .../{_aiohttp_transport.py => transport/_aiohttp.py} | 0 .../{_requests_transport.py => transport/_requests.py} | 0 tests/unit_tests/test_aiohttp_transport.py | 2 +- tests/unit_tests/test_async_unleash_api_client.py | 2 +- 8 files changed, 7 insertions(+), 12 deletions(-) rename UnleashClient/{transport.py => transport/__init__.py} (91%) rename UnleashClient/{_aiohttp_transport.py => transport/_aiohttp.py} (100%) rename UnleashClient/{_requests_transport.py => transport/_requests.py} (100%) diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index f0b30a35..22e93287 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -1,7 +1,6 @@ # ruff: noqa: F401 from typing import TYPE_CHECKING -from ._requests_transport import RequestsTransport from .clients import ( INSTANCES, UnleashClient, @@ -14,10 +13,11 @@ StreamingOptions, ) from .transport import AsyncTransport, Method, Request, Response, Transport +from .transport._requests import RequestsTransport if TYPE_CHECKING: - from ._aiohttp_transport import AiohttpTransport from .clients.async_unleash_client import AsyncUnleashClient + from .transport._aiohttp import AiohttpTransport def __getattr__(name: str): @@ -26,7 +26,7 @@ def __getattr__(name: str): return AsyncUnleashClient if name == "AiohttpTransport": - from ._aiohttp_transport import AiohttpTransport # noqa: PLC0415 + from .transport._aiohttp import AiohttpTransport # noqa: PLC0415 return AiohttpTransport raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/UnleashClient/api_client/_async.py b/UnleashClient/api_client/_async.py index 515d2f92..0312463c 100644 --- a/UnleashClient/api_client/_async.py +++ b/UnleashClient/api_client/_async.py @@ -5,7 +5,6 @@ import threading from typing import Any, Dict, Optional, Tuple -from UnleashClient._aiohttp_transport import AiohttpTransport from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory from UnleashClient._retry import _send_with_retries_async @@ -24,6 +23,7 @@ from UnleashClient.errors import AlreadyClosedError from UnleashClient.events import FetchStatus from UnleashClient.transport import AsyncTransport, Method, Request +from UnleashClient.transport._aiohttp import AiohttpTransport from UnleashClient.utils import LOGGER diff --git a/UnleashClient/api_client/_sync.py b/UnleashClient/api_client/_sync.py index aa33e0e9..0e7f93aa 100644 --- a/UnleashClient/api_client/_sync.py +++ b/UnleashClient/api_client/_sync.py @@ -3,7 +3,6 @@ from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory -from UnleashClient._requests_transport import RequestsTransport from UnleashClient._retry import _send_with_retries from UnleashClient.api_client._shared import ( FetchResult, @@ -19,6 +18,7 @@ from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL from UnleashClient.events import FetchStatus from UnleashClient.transport import Method, Request, Transport +from UnleashClient.transport._requests import RequestsTransport from UnleashClient.utils import LOGGER diff --git a/UnleashClient/transport.py b/UnleashClient/transport/__init__.py similarity index 91% rename from UnleashClient/transport.py rename to UnleashClient/transport/__init__.py index b49d7a1a..a1468faa 100644 --- a/UnleashClient/transport.py +++ b/UnleashClient/transport/__init__.py @@ -1,11 +1,6 @@ """ HTTP layer under the Unleash client: sends one request and returns the response. - -Pass a transport to the client to choose how requests are sent: -``RequestsTransport`` with your own ``requests.Session``, -``AiohttpTransport`` with your own ``aiohttp.ClientSession`` for the async -client, or any object with an ``execute(Request) -> Response`` method. """ from dataclasses import dataclass, field diff --git a/UnleashClient/_aiohttp_transport.py b/UnleashClient/transport/_aiohttp.py similarity index 100% rename from UnleashClient/_aiohttp_transport.py rename to UnleashClient/transport/_aiohttp.py diff --git a/UnleashClient/_requests_transport.py b/UnleashClient/transport/_requests.py similarity index 100% rename from UnleashClient/_requests_transport.py rename to UnleashClient/transport/_requests.py diff --git a/tests/unit_tests/test_aiohttp_transport.py b/tests/unit_tests/test_aiohttp_transport.py index 6525f7b9..6cf8957e 100644 --- a/tests/unit_tests/test_aiohttp_transport.py +++ b/tests/unit_tests/test_aiohttp_transport.py @@ -7,9 +7,9 @@ from pytest import mark from tests.utilities.fake_unleash_server import FakeUnleash -from UnleashClient._aiohttp_transport import AiohttpTransport from UnleashClient.errors import AlreadyClosedError from UnleashClient.transport import AsyncTransport, Method, Request +from UnleashClient.transport._aiohttp import AiohttpTransport PATH = "/client/features" diff --git a/tests/unit_tests/test_async_unleash_api_client.py b/tests/unit_tests/test_async_unleash_api_client.py index 6d3b3b33..eafde511 100644 --- a/tests/unit_tests/test_async_unleash_api_client.py +++ b/tests/unit_tests/test_async_unleash_api_client.py @@ -20,7 +20,6 @@ PROJECT_NAME, URL, ) -from UnleashClient._aiohttp_transport import AiohttpTransport from UnleashClient._headers import _HeaderFactory from UnleashClient.api_client._async import _AsyncUnleashApiClient from UnleashClient.config import ( @@ -38,6 +37,7 @@ from UnleashClient.errors import AlreadyClosedError from UnleashClient.events import FetchStatus, UnleashEventType from UnleashClient.transport import Method, Request, Response +from UnleashClient.transport._aiohttp import AiohttpTransport API_PREFIX = "/api" FEATURES_PATH = API_PREFIX + FEATURES_URL