From 1893ffa597649c2a86abe3a577863513d28055bf Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Thu, 11 Jan 2024 00:13:45 +0200 Subject: [PATCH 1/7] add aiohttp,requests clients --- .dockerignore | 1 - docker-compose.yaml | 10 +- examples/http/aiohttp_client.py | 70 +++++ examples/http/httpx_client.py | 4 +- examples/http/requests_client.py | 61 +++++ featureflags_client/grpc/managers/sync.py | 2 +- featureflags_client/http/client.py | 8 +- featureflags_client/http/flags.py | 4 +- featureflags_client/http/managers/aiohttp.py | 63 +++++ featureflags_client/http/managers/base.py | 245 +++++++++++++++++- featureflags_client/http/managers/httpx.py | 118 +-------- featureflags_client/http/managers/requests.py | 62 +++++ pdm.lock | 175 +++---------- pyproject.toml | 6 +- 14 files changed, 553 insertions(+), 276 deletions(-) create mode 100644 examples/http/aiohttp_client.py create mode 100644 examples/http/requests_client.py create mode 100644 featureflags_client/http/managers/aiohttp.py create mode 100644 featureflags_client/http/managers/requests.py diff --git a/.dockerignore b/.dockerignore index 7cfd59d..158d498 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,7 +30,6 @@ README.md .lets helm .ipython -.ptpython .secrets # Ignore IDE settings diff --git a/docker-compose.yaml b/docker-compose.yaml index 891e9f9..05e2f02 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,17 +9,19 @@ services: LC_ALL: C.UTF-8 PYTHONIOENCODING: UTF-8 PYTHONUNBUFFERED: 1 + networks: + - main volumes: - ./featureflags_client:/app/featureflags_client - ./examples:/app/examples - - ./.ipython:/app/.ipython + - ./.ptpython:/app/.ptpython # Uncomment to mount local build of `featureflags_protobuf` - ./featureflags_protobuf:/app/featureflags_protobuf ishell: <<: *base image: featureflags-client-examples - command: pdm run ipython --ipython-dir=/app/.ipython + command: pdm run ishell test: <<: *base @@ -30,3 +32,7 @@ services: <<: *base image: featureflags-client-docs command: sphinx-build -a -b html docs public + +networks: + main: + driver: bridge diff --git a/examples/http/aiohttp_client.py b/examples/http/aiohttp_client.py new file mode 100644 index 0000000..6f57f8e --- /dev/null +++ b/examples/http/aiohttp_client.py @@ -0,0 +1,70 @@ +import logging + +import config +import flags +from aiohttp import web + +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.aiohttp import AiohttpManager + +log = logging.getLogger(__name__) + + +async def on_start(app): + app["ff_manager"] = AiohttpManager( + url=config.FF_URL, + project=config.FF_PROJECT, + variables=[flags.REQUEST_QUERY], + defaults=flags.Defaults, + ) + app["ff_client"] = FeatureFlagsClient(app["ff_manager"]) + + try: + await app["ff_client"].preload_async(timeout=5) + except Exception: + log.exception( + "Unable to preload feature flags, application will " + "start working with defaults and retry later" + ) + + # Async managers need to `start` and `wait_closed` to be able to + # run flags update loop + app["ff_manager"].start() + + +async def on_stop(app): + await app["ff_manager"].wait_closed() + + +@web.middleware +async def middleware(request, handler): + ctx = {flags.REQUEST_QUERY.name: request.query_string} + with request.app["ff_client"].flags(ctx) as ff: + request["ff"] = ff + return await handler(request) + + +async def index(request): + if request["ff"].TEST: + return web.Response(text="TEST: True") + else: + return web.Response(text="TEST: False") + + +def create_app(): + app = web.Application(middlewares=[middleware]) + + app.router.add_get("/", index) + app.on_startup.append(on_start) + app.on_cleanup.append(on_stop) + + app["config"] = config + + return app + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + logging.getLogger("featureflags").setLevel(logging.DEBUG) + + web.run_app(create_app(), port=5000) diff --git a/examples/http/httpx_client.py b/examples/http/httpx_client.py index 9ebcda9..5df7c33 100644 --- a/examples/http/httpx_client.py +++ b/examples/http/httpx_client.py @@ -5,13 +5,13 @@ from aiohttp import web from featureflags_client.http.client import FeatureFlagsClient -from featureflags_client.http.managers.httpx import AsyncHttpManager +from featureflags_client.http.managers.httpx import HttpxManager log = logging.getLogger(__name__) async def on_start(app): - app["ff_manager"] = AsyncHttpManager( + app["ff_manager"] = HttpxManager( url=config.FF_URL, project=config.FF_PROJECT, variables=[flags.REQUEST_QUERY], diff --git a/examples/http/requests_client.py b/examples/http/requests_client.py new file mode 100644 index 0000000..eb3e625 --- /dev/null +++ b/examples/http/requests_client.py @@ -0,0 +1,61 @@ +import logging + +import config +import flags +from flask import Flask, g, request +from werkzeug.local import LocalProxy + +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.requests import RequestsManager + +app = Flask(__name__) + + +def get_ff_client(): + ff_client = getattr(g, "_ff_client", None) + if ff_client is None: + manager = RequestsManager( + url=config.FF_URL, + project=config.FF_PROJECT, + variables=[flags.REQUEST_QUERY], + defaults=flags.Defaults, + ) + ff_client = g._ff_client = FeatureFlagsClient(manager) + return ff_client + + +def get_ff(): + if "_ff" not in g: + g._ff_ctx = get_ff_client().flags( + { + flags.REQUEST_QUERY.name: request.query_string, + } + ) + g._ff = g._ff_ctx.__enter__() + return g._ff + + +@app.teardown_request +def teardown_request(exception=None): + if "_ff" in g: + g._ff_ctx.__exit__(None, None, None) + del g._ff_ctx + del g._ff + + +ff = LocalProxy(get_ff) + + +@app.route("/") +def index(): + if ff.TEST: + return "TEST: True" + else: + return "TEST: False" + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + logging.getLogger("featureflags").setLevel(logging.DEBUG) + + app.run(port=5000) diff --git a/featureflags_client/grpc/managers/sync.py b/featureflags_client/grpc/managers/sync.py index 6fc846f..df8fd2a 100644 --- a/featureflags_client/grpc/managers/sync.py +++ b/featureflags_client/grpc/managers/sync.py @@ -98,7 +98,7 @@ def _exchange( log.debug("Exchange reply: %r", reply) self._state.apply_reply(reply) - def get(self, name: str) -> Callable[[Dict], bool] | None: + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: if datetime.utcnow() >= self._next_exchange: try: self._exchange(self._exchange_timeout) diff --git a/featureflags_client/http/client.py b/featureflags_client/http/client.py index c03cef1..2865c45 100644 --- a/featureflags_client/http/client.py +++ b/featureflags_client/http/client.py @@ -3,8 +3,8 @@ from featureflags_client.http.flags import Flags from featureflags_client.http.managers.base import ( - AbstractManager, - AsyncAbstractManager, + AsyncBaseManager, + BaseManager, ) @@ -13,7 +13,7 @@ class FeatureFlagsClient: Feature flags http based client. """ - def __init__(self, manager: AbstractManager) -> None: + def __init__(self, manager: BaseManager) -> None: self._manager = manager @contextmanager @@ -37,4 +37,4 @@ def preload(self) -> None: async def preload_async(self) -> None: """Async version of `preload` method""" - await cast(AsyncAbstractManager, self._manager).preload() + await cast(AsyncBaseManager, self._manager).preload() diff --git a/featureflags_client/http/flags.py b/featureflags_client/http/flags.py index f09c2a6..cfb46b1 100644 --- a/featureflags_client/http/flags.py +++ b/featureflags_client/http/flags.py @@ -1,6 +1,6 @@ from typing import Any, Dict, Optional -from featureflags_client.http.managers.base import AbstractManager +from featureflags_client.http.managers.base import BaseManager class Flags: @@ -10,7 +10,7 @@ class Flags: def __init__( self, - manager: AbstractManager, + manager: BaseManager, ctx: Optional[Dict[str, Any]] = None, overrides: Optional[Dict[str, bool]] = None, ) -> None: diff --git a/featureflags_client/http/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py new file mode 100644 index 0000000..a75f32f --- /dev/null +++ b/featureflags_client/http/managers/aiohttp.py @@ -0,0 +1,63 @@ +import logging +from enum import EnumMeta +from typing import Any, Dict, List, Mapping, Type, Union + +from featureflags_client.http.constants import Endpoints +from featureflags_client.http.managers.base import ( + AsyncBaseManager, +) +from featureflags_client.http.types import ( + Variable, +) + +try: + import aiohttp +except ImportError: + raise ImportError( + "`aiohttp` is not installed, please install it to use AiohttpManager " + "like this `pip install 'featureflags-client[aiohttp]'`" + ) from None + +log = logging.getLogger(__name__) + + +class AiohttpManager(AsyncBaseManager): + """Feature flags manager for asyncio apps with `aiohttp` client.""" + + def __init__( # noqa: PLR0913 + self, + url: str, + project: str, + variables: List[Variable], + defaults: Union[EnumMeta, Type, Mapping[str, bool]], + request_timeout: int = 5, + refresh_interval: int = 10, + ) -> None: + super().__init__( + url, + project, + variables, + defaults, + request_timeout, + refresh_interval, + ) + self._session = aiohttp.ClientSession(base_url=url) + + async def close_client(self) -> None: + await self._session.close() + + async def _post( + self, + url: Endpoints, + payload: Dict[str, Any], + timeout: int, + ) -> Dict[str, Any]: + async with self._session.post( + url=url.value, + json=payload, + timeout=timeout, + ) as response: + response.raise_for_status() + response_data = await response.json() + + return response_data diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index 17f7c90..73a9e00 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -1,14 +1,34 @@ +import asyncio +import logging from abc import ABC, abstractmethod +from dataclasses import asdict +from datetime import datetime, timedelta from enum import EnumMeta -from typing import Callable, Dict, List, Mapping, Optional, Type, Union +from typing import Any, Callable, Dict, List, Mapping, Optional, Type, Union from featureflags_client.grpc.utils import intervals_gen +from featureflags_client.http.constants import Endpoints from featureflags_client.http.state import HttpState -from featureflags_client.http.types import Variable -from featureflags_client.http.utils import coerce_defaults +from featureflags_client.http.types import ( + PreloadFlagsRequest, + PreloadFlagsResponse, + SyncFlagsRequest, + SyncFlagsResponse, + Variable, +) +from featureflags_client.http.utils import ( + coerce_defaults, + custom_asdict_factory, +) +log = logging.getLogger(__name__) + + +class BaseManager(ABC): + """ + Base manager for using with sync http clients. + """ -class AbstractManager(ABC): def __init__( # noqa: PLR0913 self, url: str, @@ -21,28 +41,231 @@ def __init__( # noqa: PLR0913 self.url = url self.defaults = coerce_defaults(defaults) - self._refresh_interval = refresh_interval self._request_timeout = request_timeout - self._state = HttpState( project=project, variables=variables, flags=list(self.defaults.keys()), ) - self._int_gen = intervals_gen(interval=self._refresh_interval) + self._int_gen = intervals_gen(interval=refresh_interval) self._int_gen.send(None) + self._next_sync = datetime.utcnow() + @abstractmethod + def _post( + self, + url: Endpoints, + payload: Dict[str, Any], + timeout: int, + ) -> Dict[str, Any]: + pass + + def _check_sync(self) -> None: + if datetime.utcnow() >= self._next_sync: + try: + self.sync() + except Exception as exc: + self._next_sync = datetime.utcnow() + timedelta( + seconds=self._int_gen.send(False) + ) + log.error( + "Failed to exchange: %r, retry after %s", + exc, + self._next_sync, + ) + else: + self._next_sync = datetime.utcnow() + timedelta( + seconds=self._int_gen.send(True) + ) + log.debug( + "Exchange complete, next will be after %s", + self._next_sync, + ) + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + self._check_sync() + return self._state.get(name) + + def preload(self) -> None: + payload = PreloadFlagsRequest( + project=self._state.project, + variables=self._state.variables, + flags=self._state.flags, + version=self._state.version, + ) + log.debug( + "Exchange request, project: %s, version: %s, flags: %s", + payload.project, + payload.version, + payload.flags, + ) + + response_raw = self._post( + url=Endpoints.PRELOAD, + payload=asdict(payload, dict_factory=custom_asdict_factory), + timeout=self._request_timeout, + ) + log.debug("Preload response: %s", response_raw) + + response = PreloadFlagsResponse.from_dict(response_raw) + self._state.update(response.flags, response.version) + + def sync(self) -> None: + payload = SyncFlagsRequest( + project=self._state.project, + flags=self._state.flags, + version=self._state.version, + ) + log.debug( + "Sync request, project: %s, version: %s, flags: %s", + payload.project, + payload.version, + payload.flags, + ) + + response_raw = self._post( + url=Endpoints.SYNC, + payload=asdict(payload, dict_factory=custom_asdict_factory), + timeout=self._request_timeout, + ) + log.debug("Sync reply: %s", response_raw) + + response = SyncFlagsResponse.from_dict(response_raw) + self._state.update(response.flags, response.version) + + +class AsyncBaseManager(BaseManager): + """ + Base async manager for using with async http clients. + """ + + def __init__( # noqa: PLR0913 + self, + url: str, + project: str, + variables: List[Variable], + defaults: Union[EnumMeta, Type, Mapping[str, bool]], + request_timeout: int = 5, + refresh_interval: int = 10, + ) -> None: + super().__init__( + url, + project, + variables, + defaults, + request_timeout, + refresh_interval, + ) + self._refresh_task: Optional[asyncio.Task] = None + + @abstractmethod + async def _post( + self, + url: Endpoints, + payload: Dict[str, Any], + timeout: int, + ) -> Dict[str, Any]: pass @abstractmethod - def preload(self) -> None: + async def close_client(self) -> None: pass + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + return self._state.get(name) -class AsyncAbstractManager(AbstractManager): - @abstractmethod async def preload(self) -> None: - pass + """ + Preload flags from the server. + """ + + payload = PreloadFlagsRequest( + project=self._state.project, + variables=self._state.variables, + flags=self._state.flags, + version=self._state.version, + ) + log.debug( + "Exchange request, project: %s, version: %s, flags: %s", + payload.project, + payload.version, + payload.flags, + ) + + response_raw = await self._post( + url=Endpoints.PRELOAD, + payload=asdict(payload, dict_factory=custom_asdict_factory), + timeout=self._request_timeout, + ) + log.debug("Preload response: %s", response_raw) + + response = PreloadFlagsResponse.from_dict(response_raw) + self._state.update(response.flags, response.version) + + async def sync(self) -> None: + payload = SyncFlagsRequest( + project=self._state.project, + flags=self._state.flags, + version=self._state.version, + ) + log.debug( + "Sync request, project: %s, version: %s, flags: %s", + payload.project, + payload.version, + payload.flags, + ) + + response_raw = await self._post( + url=Endpoints.SYNC, + payload=asdict(payload, dict_factory=custom_asdict_factory), + timeout=self._request_timeout, + ) + log.debug("Sync reply: %s", response_raw) + + response = SyncFlagsResponse.from_dict(response_raw) + self._state.update(response.flags, response.version) + + def start(self) -> None: + if self._refresh_task is not None: + raise RuntimeError("Manager is already started") + + self._refresh_task = asyncio.create_task(self._refresh_loop()) + + async def wait_closed(self) -> None: + self._refresh_task.cancel() + await asyncio.wait([self._refresh_task]) + + if self._refresh_task.done(): + try: + error = self._refresh_task.exception() + except asyncio.CancelledError: + pass + else: + if error is not None: + log.error("Flags refresh task exited with error: %r", error) + + await self.close_client() + + async def _refresh_loop(self) -> None: + log.info("Flags refresh task started") + + while True: + try: + await self.sync() + interval = self._int_gen.send(True) + log.debug( + "Flags refresh complete, next will be in %ss", + interval, + ) + await asyncio.sleep(interval) + except asyncio.CancelledError: + log.info("Flags refresh task already exits") + break + except Exception as exc: + interval = self._int_gen.send(False) + log.error( + "Failed to refresh flags: %s, retry in %ss", exc, interval + ) + await asyncio.sleep(interval) diff --git a/featureflags_client/http/managers/httpx.py b/featureflags_client/http/managers/httpx.py index 7a5759b..47b45fb 100644 --- a/featureflags_client/http/managers/httpx.py +++ b/featureflags_client/http/managers/httpx.py @@ -1,34 +1,27 @@ -import asyncio import logging -from dataclasses import asdict from enum import EnumMeta -from typing import Any, Callable, Dict, List, Mapping, Optional, Type, Union +from typing import Any, Dict, List, Mapping, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( - AsyncAbstractManager, + AsyncBaseManager, ) from featureflags_client.http.types import ( - PreloadFlagsRequest, - PreloadFlagsResponse, - SyncFlagsRequest, - SyncFlagsResponse, Variable, ) -from featureflags_client.http.utils import custom_asdict_factory try: import httpx except ImportError: raise ImportError( - "httpx is not installed, please install it to use AsyncHttpManager " + "`httpx` is not installed, please install it to use HttpxManager " "like this `pip install 'featureflags-client[httpx]'`" ) from None log = logging.getLogger(__name__) -class AsyncHttpManager(AsyncAbstractManager): +class HttpxManager(AsyncBaseManager): """Feature flags manager for asyncio apps with `httpx` client.""" def __init__( # noqa: PLR0913 @@ -37,7 +30,6 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Mapping[str, bool]], - *, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -49,118 +41,22 @@ def __init__( # noqa: PLR0913 request_timeout, refresh_interval, ) - self._session = httpx.AsyncClient(base_url=url) - self._refresh_task: Optional[asyncio.Task] = None - - async def preload(self) -> None: - """ - Preload flags from the server. - """ - - payload = PreloadFlagsRequest( - project=self._state.project, - variables=self._state.variables, - flags=self._state.flags, - version=self._state.version, - ) - log.debug( - "Exchange request, project: %s, version: %s, flags: %s", - payload.project, - payload.version, - payload.flags, - ) - - response_raw = await self._post( - url=httpx.URL(Endpoints.PRELOAD.value), - payload=asdict(payload, dict_factory=custom_asdict_factory), - timeout=self._request_timeout, - ) - log.debug("Preload response: %s", response_raw) - - response = PreloadFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) - - async def sync(self) -> None: - payload = SyncFlagsRequest( - project=self._state.project, - flags=self._state.flags, - version=self._state.version, - ) - log.debug( - "Sync request, project: %s, version: %s, flags: %s", - payload.project, - payload.version, - payload.flags, - ) - - response_raw = await self._post( - url=httpx.URL(Endpoints.SYNC.value), - payload=asdict(payload, dict_factory=custom_asdict_factory), - timeout=self._request_timeout, - ) - log.debug("Sync reply: %s", response_raw) - - response = SyncFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) - - def get(self, name: str) -> Callable[[Dict], bool] | None: - return self._state.get(name) - - def start(self) -> None: - if self._refresh_task is not None: - raise RuntimeError("Manager is already started") - - self._refresh_task = asyncio.create_task(self._refresh_loop()) - - async def wait_closed(self) -> None: - self._refresh_task.cancel() - await asyncio.wait([self._refresh_task]) - - if self._refresh_task.done(): - try: - error = self._refresh_task.exception() - except asyncio.CancelledError: - pass - else: - if error is not None: - log.error("Flags refresh task exited with error: %r", error) + async def close_client(self) -> None: await self._session.aclose() async def _post( self, - url: httpx.URL, + url: Endpoints, payload: Dict[str, Any], timeout: int, ) -> Dict[str, Any]: response = await self._session.post( - url=url, + url=httpx.URL(url.value), json=payload, timeout=timeout, ) response.raise_for_status() response_data = response.json() return response_data - - async def _refresh_loop(self) -> None: - log.info("Flags refresh task started") - - while True: - try: - await self.sync() - interval = self._int_gen.send(True) - log.debug( - "Flags refresh complete, next will be in %ss", - interval, - ) - await asyncio.sleep(interval) - except asyncio.CancelledError: - log.info("Flags refresh task already exits") - break - except Exception as exc: - interval = self._int_gen.send(False) - log.error( - "Failed to refresh flags: %s, retry in %ss", exc, interval - ) - await asyncio.sleep(interval) diff --git a/featureflags_client/http/managers/requests.py b/featureflags_client/http/managers/requests.py new file mode 100644 index 0000000..16b177e --- /dev/null +++ b/featureflags_client/http/managers/requests.py @@ -0,0 +1,62 @@ +import logging +from enum import EnumMeta +from typing import Any, Dict, List, Mapping, Type, Union + +from featureflags_client.http.constants import Endpoints +from featureflags_client.http.managers.base import ( + BaseManager, +) +from featureflags_client.http.types import ( + Variable, +) + +try: + from urllib.parse import urljoin + + import requests +except ImportError: + raise ImportError( + "`requests` is not installed, please install it to use RequestsManager " + "like this `pip install 'featureflags-client[requests]'`" + ) from None + +log = logging.getLogger(__name__) + + +class RequestsManager(BaseManager): + """Feature flags manager for sync apps with `requests` client.""" + + def __init__( # noqa: PLR0913 + self, + url: str, + project: str, + variables: List[Variable], + defaults: Union[EnumMeta, Type, Mapping[str, bool]], + request_timeout: int = 5, + refresh_interval: int = 10, + ) -> None: + super().__init__( + url, + project, + variables, + defaults, + request_timeout, + refresh_interval, + ) + self._session = requests.Session() + self._session.headers.update({"Content-Type": "application/json"}) + + def _post( + self, + url: Endpoints, + payload: Dict[str, Any], + timeout: int, + ) -> Dict[str, Any]: + response = self._session.post( + url=urljoin(self.url, url.value), + json=payload, + timeout=timeout, + ) + response.raise_for_status() + response_data = response.json() + return response_data diff --git a/pdm.lock b/pdm.lock index 9a18bab..44a9418 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "dev", "docs", "examples", "lint", "test"] +groups = ["default", "dev", "docs", "examples", "lint", "test", "aiohttp", "requests", "ptpython"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:0ab344e48407eb361187e10adfa1da426aef1763b8512d61f0a91be5a9ad0b94" +content_hash = "sha256:b9466c77ac89895f60710d5a7a0756b66e3deb5e8d1ff3c08692edef829c3899" [[package]] name = "aiofiles" @@ -23,7 +23,7 @@ name = "aiohttp" version = "3.9.1" requires_python = ">=3.8" summary = "Async http client/server framework (asyncio)" -groups = ["examples"] +groups = ["aiohttp", "examples"] dependencies = [ "aiosignal>=1.1.2", "async-timeout<5.0,>=4.0; python_version < \"3.11\"", @@ -101,7 +101,7 @@ name = "aiosignal" version = "1.3.1" requires_python = ">=3.7" summary = "aiosignal: a list of registered asynchronous callbacks" -groups = ["examples"] +groups = ["aiohttp", "examples"] dependencies = [ "frozenlist>=1.1.0", ] @@ -139,16 +139,13 @@ files = [ ] [[package]] -name = "asttokens" -version = "2.4.1" -summary = "Annotate AST trees with source code positions" +name = "appdirs" +version = "1.4.4" +summary = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." groups = ["dev"] -dependencies = [ - "six>=1.12.0", -] files = [ - {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, - {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] [[package]] @@ -156,7 +153,7 @@ name = "async-timeout" version = "4.0.3" requires_python = ">=3.7" summary = "Timeout context manager for asyncio programs" -groups = ["examples"] +groups = ["aiohttp", "examples"] marker = "python_version < \"3.11\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, @@ -168,7 +165,7 @@ name = "attrs" version = "23.1.0" requires_python = ">=3.7" summary = "Classes Without Boilerplate" -groups = ["examples"] +groups = ["aiohttp", "examples"] files = [ {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, @@ -248,7 +245,7 @@ name = "certifi" version = "2023.11.17" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["docs", "examples"] +groups = ["docs", "examples", "requests"] files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, @@ -270,7 +267,7 @@ name = "charset-normalizer" version = "3.3.2" requires_python = ">=3.7.0" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["docs"] +groups = ["docs", "examples", "requests"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -355,7 +352,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["dev", "docs", "examples", "lint", "test"] +groups = ["docs", "examples", "lint", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -374,17 +371,6 @@ files = [ {file = "dataclass_wizard-0.22.2-py2.py3-none-any.whl", hash = "sha256:49be36ecc64bc5a1e9a35a6bad1d71d33b6b9b06877404931a17c6a3a6dfbb10"}, ] -[[package]] -name = "decorator" -version = "5.1.1" -requires_python = ">=3.5" -summary = "Decorators for Humans" -groups = ["dev"] -files = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] - [[package]] name = "distlib" version = "0.3.8" @@ -421,24 +407,13 @@ name = "exceptiongroup" version = "1.2.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" -groups = ["dev", "examples", "test"] +groups = ["examples", "test"] marker = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] -[[package]] -name = "executing" -version = "2.0.1" -requires_python = ">=3.5" -summary = "Get the currently executing AST node of a frame, and other information" -groups = ["dev"] -files = [ - {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, - {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, -] - [[package]] name = "faker" version = "18.13.0" @@ -488,7 +463,7 @@ name = "frozenlist" version = "1.4.1" requires_python = ">=3.8" summary = "A list-like structure which implements collections.abc.MutableSequence" -groups = ["examples"] +groups = ["aiohttp", "examples"] files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -760,7 +735,7 @@ name = "idna" version = "3.6" requires_python = ">=3.5" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["docs", "examples"] +groups = ["aiohttp", "docs", "examples", "requests"] files = [ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, @@ -803,30 +778,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "ipython" -version = "8.18.1" -requires_python = ">=3.9" -summary = "IPython: Productive Interactive Computing" -groups = ["dev"] -dependencies = [ - "colorama; sys_platform == \"win32\"", - "decorator", - "exceptiongroup; python_version < \"3.11\"", - "jedi>=0.16", - "matplotlib-inline", - "pexpect>4.3; sys_platform != \"win32\"", - "prompt-toolkit<3.1.0,>=3.0.41", - "pygments>=2.4.0", - "stack-data", - "traitlets>=5", - "typing-extensions; python_version < \"3.10\"", -] -files = [ - {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, - {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, -] - [[package]] name = "itsdangerous" version = "2.1.2" @@ -916,26 +867,12 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] -[[package]] -name = "matplotlib-inline" -version = "0.1.6" -requires_python = ">=3.5" -summary = "Inline Matplotlib backend for Jupyter" -groups = ["dev"] -dependencies = [ - "traitlets", -] -files = [ - {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, - {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, -] - [[package]] name = "multidict" version = "6.0.4" requires_python = ">=3.7" summary = "multidict implementation" -groups = ["examples", "test"] +groups = ["aiohttp", "examples", "test"] files = [ {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, @@ -1065,20 +1002,6 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] -[[package]] -name = "pexpect" -version = "4.9.0" -summary = "Pexpect allows easy control of interactive console applications." -groups = ["dev"] -marker = "sys_platform != \"win32\"" -dependencies = [ - "ptyprocess>=0.5", -] -files = [ - {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, - {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, -] - [[package]] name = "platformdirs" version = "4.1.0" @@ -1136,24 +1059,20 @@ files = [ ] [[package]] -name = "ptyprocess" -version = "0.7.0" -summary = "Run a subprocess in a pseudo terminal" +name = "ptpython" +version = "3.0.25" +requires_python = ">=3.7" +summary = "Python REPL build on top of prompt_toolkit" groups = ["dev"] -marker = "sys_platform != \"win32\"" -files = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +dependencies = [ + "appdirs", + "jedi>=0.16.0", + "prompt-toolkit<3.1.0,>=3.0.34", + "pygments", ] - -[[package]] -name = "pure-eval" -version = "0.2.2" -summary = "Safely evaluate AST nodes without side effects" -groups = ["dev"] files = [ - {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, - {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, + {file = "ptpython-3.0.25-py2.py3-none-any.whl", hash = "sha256:16654143dea960dcefb9d6e69af5f92f01c7a783dd28ff99e78bc7449fba805c"}, + {file = "ptpython-3.0.25.tar.gz", hash = "sha256:887f0a91a576bc26585a0dcec41cd03f004ac7c46a2c88576c87fc51d6c06cd7"}, ] [[package]] @@ -1234,7 +1153,7 @@ name = "requests" version = "2.31.0" requires_python = ">=3.7" summary = "Python HTTP for Humans." -groups = ["docs"] +groups = ["docs", "examples", "requests"] dependencies = [ "certifi>=2017.4.17", "charset-normalizer<4,>=2", @@ -1310,7 +1229,7 @@ name = "six" version = "1.16.0" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" summary = "Python 2 and 3 compatibility utilities" -groups = ["dev", "test"] +groups = ["test"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1491,21 +1410,6 @@ files = [ {file = "sphinxcontrib_serializinghtml-1.1.9.tar.gz", hash = "sha256:0c64ff898339e1fac29abd2bf5f11078f3ec413cfe9c046d3120d7ca65530b54"}, ] -[[package]] -name = "stack-data" -version = "0.6.3" -summary = "Extract data from python stack frames and tracebacks for informative displays" -groups = ["dev"] -dependencies = [ - "asttokens>=2.1.0", - "executing>=1.2.0", - "pure-eval", -] -files = [ - {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, - {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -1569,23 +1473,12 @@ files = [ {file = "tracerite-1.1.1.tar.gz", hash = "sha256:6400a35a187747189e4bb8d4a8e471bd86d14dbdcc94bcad23f4eda023f41356"}, ] -[[package]] -name = "traitlets" -version = "5.14.0" -requires_python = ">=3.8" -summary = "Traitlets Python configuration system" -groups = ["dev"] -files = [ - {file = "traitlets-5.14.0-py3-none-any.whl", hash = "sha256:f14949d23829023013c47df20b4a76ccd1a85effb786dc060f34de7948361b33"}, - {file = "traitlets-5.14.0.tar.gz", hash = "sha256:fcdaa8ac49c04dfa0ed3ee3384ef6dfdb5d6f3741502be247279407679296772"}, -] - [[package]] name = "typing-extensions" version = "4.9.0" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" -groups = ["default", "dev", "examples", "lint"] +groups = ["default", "examples", "lint"] files = [ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, @@ -1661,7 +1554,7 @@ name = "urllib3" version = "2.1.0" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["docs"] +groups = ["docs", "examples", "requests"] files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, @@ -1817,7 +1710,7 @@ name = "yarl" version = "1.9.4" requires_python = ">=3.7" summary = "Yet another URL library" -groups = ["examples"] +groups = ["aiohttp", "examples"] dependencies = [ "idna>=2.0", "multidict>=4.0", diff --git a/pyproject.toml b/pyproject.toml index bbb4a29..d0da2ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ license = { text = "MIT" } httpx = ["httpx>=0.24.1"] grpclib = ["grpclib>=0.4.6", "hiku==0.7.1"] grpcio = ["grpcio==1.59.0", "hiku==0.7.1"] +aiohttp = ["aiohttp>=3.9.1"] +requests = ["requests>=2.31.0"] [build-system] requires = ["pdm-backend"] @@ -26,6 +28,7 @@ build-backend = "pdm.backend" [tool.pdm] [tool.pdm.scripts] +ishell = "ptpython --asyncio --dark-bg --history-file=.ptpython {args}" test = "python -m pytest {args}" docs = "sphinx-build -a -b html docs public" ruff = "ruff check featureflags_client examples {args} --fix" @@ -36,7 +39,7 @@ fmt = { composite = ["black", "ruff"] } [tool.pdm.dev-dependencies] dev = [ - "ipython>=7.34.0", + "ptpython>=3.0.25", ] test = [ "pytest~=7.1", @@ -66,6 +69,7 @@ examples = [ "protobuf<4.0.0", "grpcio==1.59.0", "httpx>=0.24.1", + "requests>=2.31.0", "hiku==0.7.1", ] From 66d8737514c9122a2c031f628db83cebe468176e Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Thu, 11 Jan 2024 00:40:13 +0200 Subject: [PATCH 2/7] add pre-commit --- .hooks/pre-commit | 27 +++++++++++++++++++++++++++ README.md | 6 ++++++ lets.yaml | 9 +++++++++ pyproject.toml | 5 ++++- scripts/disable-hooks.sh | 2 ++ scripts/enable-hooks.sh | 2 ++ 6 files changed, 50 insertions(+), 1 deletion(-) create mode 100755 .hooks/pre-commit create mode 100755 scripts/disable-hooks.sh create mode 100755 scripts/enable-hooks.sh diff --git a/.hooks/pre-commit b/.hooks/pre-commit new file mode 100755 index 0000000..b643c14 --- /dev/null +++ b/.hooks/pre-commit @@ -0,0 +1,27 @@ +#!/bin/bash + +HAS_STAGED_PY=$(git diff --staged --diff-filter=d --name-only '*.py') + +if [ -n "$HAS_STAGED_PY" ]; then + + echo "Running mypy ..." + lets mypy + if [[ $? -ne 0 ]]; then + exit 1 + fi + + echo "Running black ..." + lets black --diff --check + if [[ $? -ne 0 ]]; then + exit 1 + fi + + echo "Running ruff ..." + lets ruff-diff + if [[ $? -ne 0 ]]; then + exit 1 + fi + +fi + +exit 0 diff --git a/README.md b/README.md index 60e3970..56f3c13 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,9 @@ Development Install dependencies: - ``pdm install -d`` + +Pre-commit + +``./scripts/enable-hooks.sh`` + +``./scripts/disable-hooks.sh`` diff --git a/lets.yaml b/lets.yaml index 464a75d..d73321a 100644 --- a/lets.yaml +++ b/lets.yaml @@ -81,6 +81,15 @@ commands: docker-compose run -T --rm featureflags-client \ pdm run mypy ${LETS_COMMAND_ARGS} + mypy-staged: + description: Run mypy for staged files + depends: [ build-dev ] + cmd: | + STAGED_PY=$(git diff --staged --diff-filter=d --name-only '*.py' | tr '\r\n' ' ') + echo $STAGED_PY + docker-compose run -T --rm featureflags-client \ + pdm run mypy ${STAGED_PY} ${LETS_COMMAND_ARGS} + black: description: Run black depends: [ build-dev ] diff --git a/pyproject.toml b/pyproject.toml index d0da2ca..0dfd576 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ test = "python -m pytest {args}" docs = "sphinx-build -a -b html docs public" ruff = "ruff check featureflags_client examples {args} --fix" ruff-diff = "ruff check featureflags_client examples {args}" -mypy = "mypy featureflags_client examples {args}" +mypy = "mypy featureflags_client {args}" black = "black featureflags_client examples {args}" fmt = { composite = ["black", "ruff"] } @@ -195,4 +195,7 @@ exclude = [ ".venv", "venv", ".ve", + "featureflags_client/tests", + "featureflags_protobuf", + "examples", ] diff --git a/scripts/disable-hooks.sh b/scripts/disable-hooks.sh new file mode 100755 index 0000000..a9bf24d --- /dev/null +++ b/scripts/disable-hooks.sh @@ -0,0 +1,2 @@ +#!/bin/bash +git config --local --unset core.hooksPath \ No newline at end of file diff --git a/scripts/enable-hooks.sh b/scripts/enable-hooks.sh new file mode 100755 index 0000000..88d9250 --- /dev/null +++ b/scripts/enable-hooks.sh @@ -0,0 +1,2 @@ +#!/bin/bash +git config --local core.hooksPath .hooks From 550516b9174656ece9d60beff6525a88b61cf7c9 Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Thu, 11 Jan 2024 13:42:14 +0200 Subject: [PATCH 3/7] fix old tests, mypy issues, add tests workflow --- .github/workflows/test.yaml | 26 +++++---- Dockerfile | 2 +- README.md | 8 +++ examples/README.md | 55 ++++--------------- featureflags_client/grpc/client.py | 16 ++++-- featureflags_client/grpc/conditions.py | 4 +- featureflags_client/grpc/flags.py | 2 +- featureflags_client/grpc/managers/asyncio.py | 4 +- featureflags_client/grpc/managers/base.py | 10 +++- featureflags_client/grpc/managers/dummy.py | 2 +- featureflags_client/grpc/managers/sync.py | 2 +- featureflags_client/grpc/state.py | 5 +- featureflags_client/grpc/stats_collector.py | 8 ++- featureflags_client/grpc/tracer.py | 2 +- featureflags_client/http/client.py | 4 +- featureflags_client/http/conditions.py | 4 +- featureflags_client/http/managers/aiohttp.py | 6 +- featureflags_client/http/managers/base.py | 12 ++-- featureflags_client/http/managers/httpx.py | 6 +- featureflags_client/http/managers/requests.py | 4 +- featureflags_client/http/utils.py | 2 +- featureflags_client/tests/grpc/test_flags.py | 48 ++++++++-------- .../tests/grpc/test_managers_asyncio.py | 5 +- .../tests/grpc/test_managers_dummy.py | 4 +- featureflags_client/tests/grpc/test_utils.py | 2 + lets.yaml | 9 --- pdm.lock | 29 +++++++++- pyproject.toml | 23 +++++--- 28 files changed, 163 insertions(+), 141 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e9ae89..9acd19b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,30 +1,32 @@ name: Test -# TODO: enable after test fixes -on: workflow_dispatch -# pull_request: -# branches: -# - main -# types: -# - assigned -# - opened -# - synchronize -# - reopened +on: + pull_request: + branches: + - main + types: + - assigned + - opened + - synchronize + - reopened jobs: - test-client: + test-clients: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", 3.11, 3.12] + python-version: [3.8, 3.9, "3.10", 3.11, 3.12] steps: - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: python -m pip install tox tox-gh-actions tox-pdm + - name: Test with tox run: | tox --version diff --git a/Dockerfile b/Dockerfile index 56c47fd..9c3f3c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ RUN pdm install --no-lock -G dev -G lint --no-editable FROM dev as examples RUN pdm install --no-lock -G examples -FROM base as test +FROM dev as test RUN pdm install --no-lock -G test FROM base as docs diff --git a/README.md b/README.md index 56f3c13..38baa42 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,11 @@ Pre-commit ``./scripts/enable-hooks.sh`` ``./scripts/disable-hooks.sh`` + + +TODO: +- add docs, automate docs build +- add tests +- add `tracer` / `stats_collector` for http manager +- rm old grpc client +- add publish workflow diff --git a/examples/README.md b/examples/README.md index 8aefe46..1e8fb94 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,11 +1,9 @@ Examples ======== -TODO: refactor examples - Here you can find examples for gRPC and HTTP clients with: -- `AIOHTTP` +- `aiohttp` - `Sanic` - `Flask` - `WSGI` @@ -13,53 +11,22 @@ Here you can find examples for gRPC and HTTP clients with: Prerequisites: -.. code-block:: shell - - $ pip install featureflags-client - -If you're using AsyncIO: - -.. code-block:: shell - - $ pip install grpclib - -else: - -.. code-block:: shell - - $ pip install grpcio - -Configuration for all examples located in ``config.py`` module. - -Feature flags and variables are defined in ``flags.py`` module. - -Every example starts a HTTP server and available on http://localhost:5000 - -AIOHTTP: - -.. code-block:: shell - - $ PYTHONPATH=../client:../protobuf python aiohttp_app.py - -Sanic: +- sync + grpc: -.. code-block:: shell + > pip install featureflags-client[grpclib] - $ PYTHONPATH=../client:../protobuf python sanic_app.py +- async + grpc: -Flask: + > pip install featureflags-client[grpcio] -.. code-block:: shell +- async + http: - $ PYTHONPATH=../client:../protobuf python flask_app.py + > pip install featureflags-client[httpx] -WSGI: + or -.. code-block:: shell + > pip install featureflags-client[aiohttp] - $ PYTHONPATH=../client:../protobuf python wsgi_app.py +- sync + http: -.. _AIOHTTP: https://aiohttp.readthedocs.io/ -.. _Sanic: https://sanic.readthedocs.io/ -.. _Flask: http://flask.pocoo.org -.. _WSGI: https://www.python.org/dev/peps/pep-0333/ + > pip install featureflags-client[requests] diff --git a/featureflags_client/grpc/client.py b/featureflags_client/grpc/client.py index 81822e7..4537d5f 100644 --- a/featureflags_client/grpc/client.py +++ b/featureflags_client/grpc/client.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from contextlib import contextmanager from enum import EnumMeta -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Generator, Optional, Union from featureflags_client.grpc.flags import Flags from featureflags_client.grpc.managers.base import AbstractManager @@ -18,11 +18,13 @@ class FeatureFlagsClient: def __init__( self, - defaults: Union[EnumMeta, type, Mapping[str, bool]], + defaults: Union[EnumMeta, type, Dict[str, bool]], manager: AbstractManager, ) -> None: if isinstance(defaults, EnumMeta): # deprecated - defaults = {k: v.value for k, v in defaults.__members__.items()} + defaults = { # type: ignore + k: v.value for k, v in defaults.__members__.items() + } elif inspect.isclass(defaults): defaults = { k: getattr(defaults, k) @@ -51,9 +53,8 @@ def __init__( def flags( self, ctx: Optional[Dict[str, Any]] = None, - *, overrides: Optional[Dict[str, bool]] = None, - ) -> Flags: + ) -> Generator[Flags, None, None]: """Context manager to wrap your request handling code and get actual flags values @@ -88,4 +89,7 @@ def preload(self, timeout: Optional[int] = None) -> None: async def preload_async(self, timeout: Optional[int] = None) -> None: """Async version of `preload` method""" - await self._manager.preload(timeout=timeout, defaults=self._defaults) + await self._manager.preload( # type: ignore + timeout=timeout, + defaults=self._defaults, + ) diff --git a/featureflags_client/grpc/conditions.py b/featureflags_client/grpc/conditions.py index 8b0ab98..9ee952d 100644 --- a/featureflags_client/grpc/conditions.py +++ b/featureflags_client/grpc/conditions.py @@ -7,7 +7,7 @@ _undefined = object() -def false(ctx: Dict[str, Any]): +def false(ctx: Dict[str, Any]) -> bool: return False @@ -213,7 +213,7 @@ def proc(ctx: Dict[str, Any]) -> bool: else: - def proc(_: Dict[str, Any]) -> bool: + def proc(ctx: Dict[str, Any]) -> bool: return flag.enabled.value return proc diff --git a/featureflags_client/grpc/flags.py b/featureflags_client/grpc/flags.py index 88fed0f..e76631e 100644 --- a/featureflags_client/grpc/flags.py +++ b/featureflags_client/grpc/flags.py @@ -47,7 +47,7 @@ def __getattr__(self, name: str) -> bool: def __history__(self) -> List[Tuple[str, bool]]: """Returns an ordered history for flags that were checked""" - return list(self._tracer.values.items()) + return list(self._tracer.values.items()) # type: ignore def add_trace(self) -> None: self._manager.add_trace(self._tracer) diff --git a/featureflags_client/grpc/managers/asyncio.py b/featureflags_client/grpc/managers/asyncio.py index 1fd5966..98ef02f 100644 --- a/featureflags_client/grpc/managers/asyncio.py +++ b/featureflags_client/grpc/managers/asyncio.py @@ -86,7 +86,7 @@ def __init__( stacklevel=2, ) - async def preload( + async def preload( # type: ignore self, *, timeout: Optional[int] = None, @@ -163,7 +163,7 @@ async def _exchange( log.debug("Exchange reply: %r", reply) self._state.apply_reply(reply) - def get(self, name: str) -> Callable[[Dict], bool] | None: + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: return self._state.get(name) def add_trace(self, tracer: Tracer) -> None: diff --git a/featureflags_client/grpc/managers/base.py b/featureflags_client/grpc/managers/base.py index 240c16f..00d33a6 100644 --- a/featureflags_client/grpc/managers/base.py +++ b/featureflags_client/grpc/managers/base.py @@ -7,9 +7,17 @@ class AbstractManager(ABC): @abstractmethod - def get(self, name: str) -> Callable[[Dict], bool] | None: + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: pass @abstractmethod def add_trace(self, tracer: Optional["Tracer"]) -> None: pass + + @abstractmethod + def preload( + self, + timeout: Optional[int] = None, + defaults: Optional[Dict] = None, + ) -> None: + pass diff --git a/featureflags_client/grpc/managers/dummy.py b/featureflags_client/grpc/managers/dummy.py index 2d5d9c6..7a32a6c 100644 --- a/featureflags_client/grpc/managers/dummy.py +++ b/featureflags_client/grpc/managers/dummy.py @@ -23,7 +23,7 @@ class Defaults: """ - def get(self, name: str) -> Callable[[Dict], bool] | None: + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: return None def add_trace(self, tracer: Optional[Tracer]) -> None: diff --git a/featureflags_client/grpc/managers/sync.py b/featureflags_client/grpc/managers/sync.py index df8fd2a..2c67d0b 100644 --- a/featureflags_client/grpc/managers/sync.py +++ b/featureflags_client/grpc/managers/sync.py @@ -47,7 +47,7 @@ class SyncManager(AbstractManager): _exchange_timeout = 5 def __init__( - self, project: str, variables: list[Variable], channel: grpc.Channel + self, project: str, variables: List[Variable], channel: grpc.Channel ) -> None: self._state = GrpcState(project, variables) self._channel = channel diff --git a/featureflags_client/grpc/state.py b/featureflags_client/grpc/state.py index bda41d8..198a29d 100644 --- a/featureflags_client/grpc/state.py +++ b/featureflags_client/grpc/state.py @@ -2,7 +2,8 @@ from typing import Any, Callable, Dict, List, Optional from hiku.builder import Q, build -from hiku.export.protobuf import export, query_pb2 +from hiku.export.protobuf import export +from hiku.query import Node as QueryNode from featureflags_client.grpc.conditions import load_flags from featureflags_client.grpc.types import Variable @@ -10,7 +11,7 @@ from featureflags_protobuf.service_pb2 import FlagUsage as FlagUsageProto -def get_grpc_graph_query(project_name: str) -> query_pb2.Node: +def get_grpc_graph_query(project_name: str) -> QueryNode: return export( build( [ diff --git a/featureflags_client/grpc/stats_collector.py b/featureflags_client/grpc/stats_collector.py index 9560b18..c2a111c 100644 --- a/featureflags_client/grpc/stats_collector.py +++ b/featureflags_client/grpc/stats_collector.py @@ -1,6 +1,6 @@ from collections import OrderedDict, defaultdict from datetime import datetime, timedelta -from typing import Dict, List +from typing import DefaultDict, Dict, List from google.protobuf.timestamp_pb2 import Timestamp as TimestampProto @@ -13,7 +13,9 @@ class StatsCollector: """ def __init__(self) -> None: - self._acc = defaultdict(lambda: defaultdict(lambda: [0, 0])) + self._acc: DefaultDict = defaultdict( + lambda: defaultdict(lambda: [0, 0]) + ) def update( self, @@ -36,7 +38,7 @@ def flush( interval_pb = TimestampProto() interval_pb.FromDatetime(interval) stats.append( - FlagUsageProto( + FlagUsageProto( # type: ignore name=flag_name, interval=interval_pb, negative_count=neg_count, diff --git a/featureflags_client/grpc/tracer.py b/featureflags_client/grpc/tracer.py index 2a3cca9..e54c729 100644 --- a/featureflags_client/grpc/tracer.py +++ b/featureflags_client/grpc/tracer.py @@ -8,7 +8,7 @@ class Tracer: """ def __init__(self) -> None: - self.values = OrderedDict() + self.values: OrderedDict[str, int] = OrderedDict() self.interval = datetime.utcnow().replace(second=0, microsecond=0) def inc(self, name: str, value: int) -> None: diff --git a/featureflags_client/http/client.py b/featureflags_client/http/client.py index 2865c45..de1ad3d 100644 --- a/featureflags_client/http/client.py +++ b/featureflags_client/http/client.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Generator, Optional, cast from featureflags_client.http.flags import Flags from featureflags_client.http.managers.base import ( @@ -22,7 +22,7 @@ def flags( ctx: Optional[Dict[str, Any]] = None, *, overrides: Optional[Dict[str, bool]] = None, - ) -> Flags: + ) -> Generator[Flags, None, None]: """ Context manager to wrap your request handling code and get actual flags values. diff --git a/featureflags_client/http/conditions.py b/featureflags_client/http/conditions.py index 3049cc8..14706a3 100644 --- a/featureflags_client/http/conditions.py +++ b/featureflags_client/http/conditions.py @@ -6,7 +6,7 @@ _UNDEFINED = object() -def false(_ctx: Dict[str, Any]): +def false(_ctx: Dict[str, Any]) -> bool: return False @@ -173,7 +173,7 @@ def proc(ctx: Dict[str, Any]) -> bool: else: - def proc(_: Dict[str, Any]) -> bool: + def proc(ctx: Dict[str, Any]) -> bool: return flag.enabled return proc diff --git a/featureflags_client/http/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py index a75f32f..6645a6f 100644 --- a/featureflags_client/http/managers/aiohttp.py +++ b/featureflags_client/http/managers/aiohttp.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Mapping, Type, Union +from typing import Any, Dict, List, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -29,7 +29,7 @@ def __init__( # noqa: PLR0913 url: str, project: str, variables: List[Variable], - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -46,7 +46,7 @@ def __init__( # noqa: PLR0913 async def close_client(self) -> None: await self._session.close() - async def _post( + async def _post( # type: ignore self, url: Endpoints, payload: Dict[str, Any], diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index 73a9e00..9c8075f 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -4,7 +4,7 @@ from dataclasses import asdict from datetime import datetime, timedelta from enum import EnumMeta -from typing import Any, Callable, Dict, List, Mapping, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Type, Union from featureflags_client.grpc.utils import intervals_gen from featureflags_client.http.constants import Endpoints @@ -34,7 +34,7 @@ def __init__( # noqa: PLR0913 url: str, project: str, variables: List[Variable], - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], request_timeout: int = 5, refresh_interval: int = 60, # 1 minute. ) -> None: @@ -146,7 +146,7 @@ def __init__( # noqa: PLR0913 url: str, project: str, variables: List[Variable], - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -161,7 +161,7 @@ def __init__( # noqa: PLR0913 self._refresh_task: Optional[asyncio.Task] = None @abstractmethod - async def _post( + async def _post( # type: ignore self, url: Endpoints, payload: Dict[str, Any], @@ -176,7 +176,7 @@ async def close_client(self) -> None: def get(self, name: str) -> Optional[Callable[[Dict], bool]]: return self._state.get(name) - async def preload(self) -> None: + async def preload(self) -> None: # type: ignore """ Preload flags from the server. """ @@ -204,7 +204,7 @@ async def preload(self) -> None: response = PreloadFlagsResponse.from_dict(response_raw) self._state.update(response.flags, response.version) - async def sync(self) -> None: + async def sync(self) -> None: # type: ignore payload = SyncFlagsRequest( project=self._state.project, flags=self._state.flags, diff --git a/featureflags_client/http/managers/httpx.py b/featureflags_client/http/managers/httpx.py index 47b45fb..374d44a 100644 --- a/featureflags_client/http/managers/httpx.py +++ b/featureflags_client/http/managers/httpx.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Mapping, Type, Union +from typing import Any, Dict, List, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -29,7 +29,7 @@ def __init__( # noqa: PLR0913 url: str, project: str, variables: List[Variable], - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -46,7 +46,7 @@ def __init__( # noqa: PLR0913 async def close_client(self) -> None: await self._session.aclose() - async def _post( + async def _post( # type: ignore self, url: Endpoints, payload: Dict[str, Any], diff --git a/featureflags_client/http/managers/requests.py b/featureflags_client/http/managers/requests.py index 16b177e..6ff7520 100644 --- a/featureflags_client/http/managers/requests.py +++ b/featureflags_client/http/managers/requests.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Mapping, Type, Union +from typing import Any, Dict, List, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -31,7 +31,7 @@ def __init__( # noqa: PLR0913 url: str, project: str, variables: List[Variable], - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], request_timeout: int = 5, refresh_interval: int = 10, ) -> None: diff --git a/featureflags_client/http/utils.py b/featureflags_client/http/utils.py index 6b590bc..05f001a 100644 --- a/featureflags_client/http/utils.py +++ b/featureflags_client/http/utils.py @@ -13,7 +13,7 @@ def convert_value(obj: Any) -> Any: def coerce_defaults( - defaults: Union[EnumMeta, Type, Mapping[str, bool]], + defaults: Union[EnumMeta, Type, Dict[str, bool]], ) -> Dict[str, bool]: if isinstance(defaults, EnumMeta): # deprecated defaults = {k: v.value for k, v in defaults.__members__.items()} diff --git a/featureflags_client/tests/grpc/test_flags.py b/featureflags_client/tests/grpc/test_flags.py index 10e4ab7..943af7a 100644 --- a/featureflags_client/tests/grpc/test_flags.py +++ b/featureflags_client/tests/grpc/test_flags.py @@ -5,8 +5,10 @@ import pytest from google.protobuf.timestamp_pb2 import Timestamp -from featureflags_client.grpc.flags import Client, Flags, StatsCollector, Tracer +from featureflags_client.grpc.client import FeatureFlagsClient +from featureflags_client.grpc.flags import Flags from featureflags_client.grpc.managers.dummy import DummyManager +from featureflags_client.grpc.stats_collector import StatsCollector from featureflags_protobuf.graph_pb2 import Check, Result, Variable from featureflags_protobuf.service_pb2 import FlagUsage @@ -25,23 +27,22 @@ def test_tracing(manager): result.Flag[flag_id].overridden.value = True manager.load(result) - with Tracer() as tracer: - flags = Flags(defaults, manager, tracer, {}) - assert flags.TEST is True + flags = Flags(defaults, manager, {}) + assert flags.TEST is True - result.Flag[flag_id].enabled.value = False - manager.load(result) - assert flags.TEST is True + result.Flag[flag_id].enabled.value = False + manager.load(result) + assert flags.TEST is True - assert tracer.values == {"TEST": True} - assert tracer.interval.second == 0 - assert tracer.interval.microsecond == 0 + assert flags._tracer.values == {"TEST": True} + assert flags._tracer.interval.second == 0 + assert flags._tracer.interval.microsecond == 0 interval_pb = Timestamp() - interval_pb.FromDatetime(tracer.interval) + interval_pb.FromDatetime(flags._tracer.interval) stats = StatsCollector() - stats.update_flags_state(tracer.interval, tracer.values) + stats.update(flags._tracer.interval, flags._tracer.values) assert stats.flush(timedelta(0)) == [ FlagUsage( @@ -54,7 +55,9 @@ def test_tracing(manager): def test_tracing_history(): - client = Client({"FOO": True, "BAR": False, "BAZ": True}, DummyManager()) + client = FeatureFlagsClient( + {"FOO": True, "BAR": False, "BAZ": True}, DummyManager() + ) with client.flags() as f1: print(f1.FOO) @@ -143,13 +146,12 @@ def test_conditions(ctx, expected, manager): defaults = {"TEST": False} manager.load(result) - with Tracer() as tracer: - flags = Flags(defaults, manager, tracer, ctx) - assert flags.TEST is expected + flags = Flags(defaults, manager, ctx) + assert flags.TEST is expected def test_py2_defaults(manager): - client = Client({"TEST": False, "TEST_UNICODE": True}, manager) + client = FeatureFlagsClient({"TEST": False, "TEST_UNICODE": True}, manager) with client.flags() as flags: assert flags.TEST is False assert flags.TEST_UNICODE is True @@ -159,7 +161,7 @@ def test_deprecated_defaults(manager): class Defaults(Enum): TEST = False - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags() as flags: assert flags.TEST is Defaults.TEST.value @@ -170,7 +172,7 @@ class Defaults: TEST = False test = True - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags() as flags: assert not hasattr(flags, "_TEST") assert flags.TEST is Defaults.TEST @@ -179,14 +181,14 @@ class Defaults: def test_invalid_defaults_type(manager): with pytest.raises(TypeError) as exc: - Client(object(), manager) + FeatureFlagsClient(object(), manager) exc.match("Invalid defaults type") @pytest.mark.parametrize("key, value", [("TEST", 1), (2, "TEST")]) def test_invalid_flag_definition_types(key, value, manager): with pytest.raises(TypeError) as exc: - Client({key: value}, manager) + FeatureFlagsClient({key: value}, manager) exc.match(f"Invalid flag definition: {key!r}") @@ -194,7 +196,7 @@ def test_overrides(manager): class Defaults(Enum): TEST = False - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags() as flags: assert flags.TEST is False @@ -216,6 +218,6 @@ def test_default_true(manager): class Defaults: TEST = True - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags() as flags: assert flags.TEST is Defaults.TEST diff --git a/featureflags_client/tests/grpc/test_managers_asyncio.py b/featureflags_client/tests/grpc/test_managers_asyncio.py index 2e7307e..ae41766 100644 --- a/featureflags_client/tests/grpc/test_managers_asyncio.py +++ b/featureflags_client/tests/grpc/test_managers_asyncio.py @@ -5,9 +5,10 @@ from google.protobuf import wrappers_pb2 from grpclib.client import Channel -from featureflags_client.grpc.flags import Client, Types, Variable +from featureflags_client.grpc.client import FeatureFlagsClient from featureflags_client.grpc.managers.asyncio import AsyncIOManager from featureflags_client.grpc.state import get_grpc_graph_query +from featureflags_client.grpc.types import Types, Variable from featureflags_protobuf import graph_pb2, service_pb2 f = faker.Faker() @@ -90,7 +91,7 @@ async def reply(): timeout=None, ) - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags({variable.name: check.value_string}) as flags: assert flags.TEST is True with client.flags({variable.name: f.pystr()}) as flags: diff --git a/featureflags_client/tests/grpc/test_managers_dummy.py b/featureflags_client/tests/grpc/test_managers_dummy.py index 6d8c0e4..a9a900d 100644 --- a/featureflags_client/tests/grpc/test_managers_dummy.py +++ b/featureflags_client/tests/grpc/test_managers_dummy.py @@ -1,4 +1,4 @@ -from featureflags_client.grpc.flags import Client +from featureflags_client.grpc.client import FeatureFlagsClient from featureflags_client.grpc.managers.dummy import DummyManager @@ -8,7 +8,7 @@ def test(): class Defaults: FOO_FEATURE = False - client = Client(Defaults, manager) + client = FeatureFlagsClient(Defaults, manager) with client.flags() as flags: assert flags.FOO_FEATURE is False diff --git a/featureflags_client/tests/grpc/test_utils.py b/featureflags_client/tests/grpc/test_utils.py index 6ec1598..de6f766 100644 --- a/featureflags_client/tests/grpc/test_utils.py +++ b/featureflags_client/tests/grpc/test_utils.py @@ -4,6 +4,7 @@ def test_intervals_gen_from_success(): int_gen = intervals_gen() int_gen.send(None) + assert int_gen.send(True) == 10 assert int_gen.send(True) == 10 assert int_gen.send(False) == 1 @@ -26,6 +27,7 @@ def test_intervals_gen_from_success(): def test_intervals_gen_from_error(): int_gen = intervals_gen() int_gen.send(None) + assert int_gen.send(False) == 1 assert int_gen.send(False) == 2 assert int_gen.send(False) == 4 diff --git a/lets.yaml b/lets.yaml index d73321a..464a75d 100644 --- a/lets.yaml +++ b/lets.yaml @@ -81,15 +81,6 @@ commands: docker-compose run -T --rm featureflags-client \ pdm run mypy ${LETS_COMMAND_ARGS} - mypy-staged: - description: Run mypy for staged files - depends: [ build-dev ] - cmd: | - STAGED_PY=$(git diff --staged --diff-filter=d --name-only '*.py' | tr '\r\n' ' ') - echo $STAGED_PY - docker-compose run -T --rm featureflags-client \ - pdm run mypy ${STAGED_PY} ${LETS_COMMAND_ARGS} - black: description: Run black depends: [ build-dev ] diff --git a/pdm.lock b/pdm.lock index 44a9418..1d09052 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "docs", "examples", "lint", "test", "aiohttp", "requests", "ptpython"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:b9466c77ac89895f60710d5a7a0756b66e3deb5e8d1ff3c08692edef829c3899" +content_hash = "sha256:a6fdccb22a846505eb04fe3cadea791761c649387b6caf5adfcb2787a7f7cc39" [[package]] name = "aiofiles" @@ -1473,6 +1473,31 @@ files = [ {file = "tracerite-1.1.1.tar.gz", hash = "sha256:6400a35a187747189e4bb8d4a8e471bd86d14dbdcc94bcad23f4eda023f41356"}, ] +[[package]] +name = "types-protobuf" +version = "4.24.0.20240106" +requires_python = ">=3.8" +summary = "Typing stubs for protobuf" +groups = ["dev"] +files = [ + {file = "types-protobuf-4.24.0.20240106.tar.gz", hash = "sha256:024f034f3b5e2bb2bbff55ebc4d591ed0d2280d90faceedcb148b9e714a3f3ee"}, + {file = "types_protobuf-4.24.0.20240106-py3-none-any.whl", hash = "sha256:0612ef3156bd80567460a15ac7c109b313f6022f1fee04b4d922ab2789baab79"}, +] + +[[package]] +name = "types-requests" +version = "2.31.0.20240106" +requires_python = ">=3.8" +summary = "Typing stubs for requests" +groups = ["dev"] +dependencies = [ + "urllib3>=2", +] +files = [ + {file = "types-requests-2.31.0.20240106.tar.gz", hash = "sha256:0e1c731c17f33618ec58e022b614a1a2ecc25f7dc86800b36ef341380402c612"}, + {file = "types_requests-2.31.0.20240106-py3-none-any.whl", hash = "sha256:da997b3b6a72cc08d09f4dba9802fdbabc89104b35fe24ee588e674037689354"}, +] + [[package]] name = "typing-extensions" version = "4.9.0" @@ -1554,7 +1579,7 @@ name = "urllib3" version = "2.1.0" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["docs", "examples", "requests"] +groups = ["dev", "docs", "examples", "requests"] files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, diff --git a/pyproject.toml b/pyproject.toml index 0dfd576..fd8748b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,9 @@ requires-python = ">=3.9" license = { text = "MIT" } [project.optional-dependencies] -httpx = ["httpx>=0.24.1"] grpclib = ["grpclib>=0.4.6", "hiku==0.7.1"] grpcio = ["grpcio==1.59.0", "hiku==0.7.1"] +httpx = ["httpx>=0.24.1"] aiohttp = ["aiohttp>=3.9.1"] requests = ["requests>=2.31.0"] @@ -55,6 +55,8 @@ lint = [ "black>=23.3.0", "ruff>=0.1.0", "mypy>=1.4.1", + "types-requests>=2.31.0.20240106", + "types-protobuf>=4.24.0.20240106", ] docs = [ "sphinx==5.3.0", @@ -86,7 +88,7 @@ asyncio_mode = "auto" [tool.black] line-length = 80 -target-version = ['py37'] +target-version = ['py38'] extend-exclude = ''' /( | .git @@ -157,7 +159,7 @@ exclude = [ line-length = 80 # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py37" +target-version = "py38" [tool.ruff.per-file-ignores] "featureflags_client/tests/*" = [ @@ -180,8 +182,8 @@ max-complexity = 10 known-first-party = ["featureflags_client"] [tool.mypy] -python_version = "3.7" -follow_imports = "normal" +python_version = "3.8" +follow_imports = "skip" pretty = true strict_optional = false warn_no_return = true @@ -196,6 +198,13 @@ exclude = [ "venv", ".ve", "featureflags_client/tests", - "featureflags_protobuf", - "examples", +] + +[[tool.mypy.overrides]] +module = "featureflags_protobuf.*" +follow_imports = "skip" +disallow_untyped_decorators = false +disable_error_code = [ + "no-untyped-def", + "attr-defined", ] From 4820bbdf5fe1825c027f05ba369de5a6893193ed Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Thu, 11 Jan 2024 13:45:00 +0200 Subject: [PATCH 4/7] refactor test workflow --- .github/workflows/test.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9acd19b..d478c0e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,10 +17,11 @@ jobs: matrix: python-version: [3.8, 3.9, "3.10", 3.11, 3.12] steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: pdm-project/setup-pdm@v3 + uses: pdm-project/setup-pdm@v3.3 with: python-version: ${{ matrix.python-version }} From a26af5e446c44cb43947b0794e60744c73dac0a5 Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Tue, 16 Jan 2024 21:18:59 +0200 Subject: [PATCH 5/7] refactor naming --- featureflags_client/http/managers/aiohttp.py | 2 +- featureflags_client/http/managers/base.py | 4 ++-- featureflags_client/http/managers/httpx.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/featureflags_client/http/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py index 6645a6f..313e8a9 100644 --- a/featureflags_client/http/managers/aiohttp.py +++ b/featureflags_client/http/managers/aiohttp.py @@ -43,7 +43,7 @@ def __init__( # noqa: PLR0913 ) self._session = aiohttp.ClientSession(base_url=url) - async def close_client(self) -> None: + async def close(self) -> None: await self._session.close() async def _post( # type: ignore diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index 9c8075f..3652bdf 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -170,7 +170,7 @@ async def _post( # type: ignore pass @abstractmethod - async def close_client(self) -> None: + async def close(self) -> None: pass def get(self, name: str) -> Optional[Callable[[Dict], bool]]: @@ -246,7 +246,7 @@ async def wait_closed(self) -> None: if error is not None: log.error("Flags refresh task exited with error: %r", error) - await self.close_client() + await self.close() async def _refresh_loop(self) -> None: log.info("Flags refresh task started") diff --git a/featureflags_client/http/managers/httpx.py b/featureflags_client/http/managers/httpx.py index 374d44a..4b7ceed 100644 --- a/featureflags_client/http/managers/httpx.py +++ b/featureflags_client/http/managers/httpx.py @@ -43,7 +43,7 @@ def __init__( # noqa: PLR0913 ) self._session = httpx.AsyncClient(base_url=url) - async def close_client(self) -> None: + async def close(self) -> None: await self._session.aclose() async def _post( # type: ignore From a4b75cf329b7ef0f02a5a2a155d95e7c05864ec4 Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Tue, 16 Jan 2024 21:40:57 +0200 Subject: [PATCH 6/7] update endpoints --- featureflags_client/http/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/featureflags_client/http/constants.py b/featureflags_client/http/constants.py index 20caafd..f8f7638 100644 --- a/featureflags_client/http/constants.py +++ b/featureflags_client/http/constants.py @@ -2,5 +2,5 @@ class Endpoints(Enum): - PRELOAD = "/flags/preload" + PRELOAD = "/flags/load" SYNC = "/flags/sync" From f1113d2e06df14110076faca3c933d8445b33ed6 Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Wed, 17 Jan 2024 10:42:09 +0200 Subject: [PATCH 7/7] fix tests --- featureflags_client/grpc/managers/dummy.py | 7 +++ featureflags_client/tests/conftest.py | 10 ++-- featureflags_client/tests/grpc/test_flags.py | 46 ++++++++++--------- .../tests/grpc/test_managers_asyncio.py | 6 +-- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/featureflags_client/grpc/managers/dummy.py b/featureflags_client/grpc/managers/dummy.py index 7a32a6c..ab8fc26 100644 --- a/featureflags_client/grpc/managers/dummy.py +++ b/featureflags_client/grpc/managers/dummy.py @@ -23,6 +23,13 @@ class Defaults: """ + def preload( + self, + timeout: Optional[int] = None, + defaults: Optional[Dict] = None, + ) -> None: + pass + def get(self, name: str) -> Optional[Callable[[Dict], bool]]: return None diff --git a/featureflags_client/tests/conftest.py b/featureflags_client/tests/conftest.py index 55ab1da..db3c452 100644 --- a/featureflags_client/tests/conftest.py +++ b/featureflags_client/tests/conftest.py @@ -8,6 +8,9 @@ class SimpleManager(AbstractManager): def __init__(self): self.checks = {} + def preload(self, timeout=None, defaults=None) -> None: + pass + def load(self, result): self.checks = load_flags(result) @@ -19,10 +22,5 @@ def add_trace(self, tracer): @pytest.fixture() -def loop(event_loop): - return event_loop - - -@pytest.fixture() -def manager(): +def simple_manager(): return SimpleManager() diff --git a/featureflags_client/tests/grpc/test_flags.py b/featureflags_client/tests/grpc/test_flags.py index 943af7a..7afcdec 100644 --- a/featureflags_client/tests/grpc/test_flags.py +++ b/featureflags_client/tests/grpc/test_flags.py @@ -15,7 +15,7 @@ f = faker.Faker() -def test_tracing(manager): +def test_tracing(simple_manager): defaults = {"TEST": False} result = Result() @@ -25,13 +25,13 @@ def test_tracing(manager): result.Flag[flag_id].name = "TEST" result.Flag[flag_id].enabled.value = True result.Flag[flag_id].overridden.value = True - manager.load(result) + simple_manager.load(result) - flags = Flags(defaults, manager, {}) + flags = Flags(defaults, simple_manager, {}) assert flags.TEST is True result.Flag[flag_id].enabled.value = False - manager.load(result) + simple_manager.load(result) assert flags.TEST is True assert flags._tracer.values == {"TEST": True} @@ -91,7 +91,7 @@ def test_tracing_history(): ({"v.str": "aleph+no", "v.int": 49}, False), ], ) -def test_conditions(ctx, expected, manager): +def test_conditions(ctx, expected, simple_manager): f1 = f.pystr() c1, c2 = f.pystr(), f.pystr() ch1, ch2, ch3, ch4 = f.pystr(), f.pystr(), f.pystr(), f.pystr() @@ -145,58 +145,60 @@ def test_conditions(ctx, expected, manager): defaults = {"TEST": False} - manager.load(result) - flags = Flags(defaults, manager, ctx) + simple_manager.load(result) + flags = Flags(defaults, simple_manager, ctx) assert flags.TEST is expected -def test_py2_defaults(manager): - client = FeatureFlagsClient({"TEST": False, "TEST_UNICODE": True}, manager) +def test_py2_defaults(simple_manager): + client = FeatureFlagsClient( + {"TEST": False, "TEST_UNICODE": True}, simple_manager + ) with client.flags() as flags: assert flags.TEST is False assert flags.TEST_UNICODE is True -def test_deprecated_defaults(manager): +def test_deprecated_defaults(simple_manager): class Defaults(Enum): TEST = False - client = FeatureFlagsClient(Defaults, manager) + client = FeatureFlagsClient(Defaults, simple_manager) with client.flags() as flags: assert flags.TEST is Defaults.TEST.value -def test_declarative_defaults(manager): +def test_declarative_defaults(simple_manager): class Defaults: _TEST = True TEST = False test = True - client = FeatureFlagsClient(Defaults, manager) + client = FeatureFlagsClient(Defaults, simple_manager) with client.flags() as flags: assert not hasattr(flags, "_TEST") assert flags.TEST is Defaults.TEST assert not hasattr(flags, "test") -def test_invalid_defaults_type(manager): +def test_invalid_defaults_type(simple_manager): with pytest.raises(TypeError) as exc: - FeatureFlagsClient(object(), manager) + FeatureFlagsClient(object(), simple_manager) exc.match("Invalid defaults type") @pytest.mark.parametrize("key, value", [("TEST", 1), (2, "TEST")]) -def test_invalid_flag_definition_types(key, value, manager): +def test_invalid_flag_definition_types(key, value, simple_manager): with pytest.raises(TypeError) as exc: - FeatureFlagsClient({key: value}, manager) + FeatureFlagsClient({key: value}, simple_manager) exc.match(f"Invalid flag definition: {key!r}") -def test_overrides(manager): +def test_overrides(simple_manager): class Defaults(Enum): TEST = False - client = FeatureFlagsClient(Defaults, manager) + client = FeatureFlagsClient(Defaults, simple_manager) with client.flags() as flags: assert flags.TEST is False @@ -205,7 +207,7 @@ class Defaults(Enum): assert flags.TEST is True -def test_default_true(manager): +def test_default_true(simple_manager): result = Result() flag_id = f.pystr() result.Root.flags.add().Flag = flag_id @@ -213,11 +215,11 @@ def test_default_true(manager): result.Flag[flag_id].name = "TEST" result.Flag[flag_id].enabled.value = False result.Flag[flag_id].overridden.value = False - manager.load(result) + simple_manager.load(result) class Defaults: TEST = True - client = FeatureFlagsClient(Defaults, manager) + client = FeatureFlagsClient(Defaults, simple_manager) with client.flags() as flags: assert flags.TEST is Defaults.TEST diff --git a/featureflags_client/tests/grpc/test_managers_asyncio.py b/featureflags_client/tests/grpc/test_managers_asyncio.py index ae41766..36895bc 100644 --- a/featureflags_client/tests/grpc/test_managers_asyncio.py +++ b/featureflags_client/tests/grpc/test_managers_asyncio.py @@ -65,12 +65,10 @@ def fixture_result(variable, flag, condition, check): @pytest.mark.asyncio -async def test(loop, result, flag, variable, check): +async def test(result, flag, variable, check): result.Flag[flag.id].name = "TEST" variables = [Variable(variable.name, Types.STRING)] - manager = AsyncIOManager( - "aginst", variables, Channel(port=-1, loop=loop), loop=loop - ) + manager = AsyncIOManager("aginst", variables, Channel(port=-1)) async def reply(): return service_pb2.ExchangeReply(version=1, result=result)