From 85afdc6eda977674595924b805506d381645c052 Mon Sep 17 00:00:00 2001 From: "d.zakharchuk" Date: Mon, 19 Feb 2024 15:21:17 +0200 Subject: [PATCH] rm grpc code, fix tests for http, update examples, docs --- README.md | 1 - examples/README.md | 14 +- examples/grpc/config.py | 4 - examples/grpc/flags.py | 8 - examples/grpc/sanic_app.py | 78 ------ examples/grpc/wsgi_app.py | 34 --- .../dummy_async_client.py} | 31 ++- .../dummy_sync_client.py} | 18 +- featureflags_client/grpc/client.py | 95 ------- featureflags_client/grpc/conditions.py | 235 ----------------- featureflags_client/grpc/flags.py | 53 ---- featureflags_client/grpc/managers/__init__.py | 0 featureflags_client/grpc/managers/asyncio.py | 171 ------------ featureflags_client/grpc/managers/base.py | 23 -- featureflags_client/grpc/managers/dummy.py | 37 --- featureflags_client/grpc/managers/sync.py | 126 --------- featureflags_client/grpc/state.py | 97 ------- featureflags_client/grpc/stats_collector.py | 61 ----- featureflags_client/grpc/tracer.py | 15 -- featureflags_client/grpc/types.py | 49 ---- featureflags_client/grpc/utils.py | 18 -- featureflags_client/http/conditions.py | 19 +- featureflags_client/http/managers/base.py | 2 +- featureflags_client/http/types.py | 6 +- featureflags_client/tests/conftest.py | 48 ++-- featureflags_client/tests/grpc/__init__.py | 0 .../tests/grpc/test_conditions.py | 243 ------------------ featureflags_client/tests/grpc/test_flags.py | 225 ---------------- .../tests/grpc/test_managers_asyncio.py | 98 ------- .../tests/grpc/test_managers_dummy.py | 14 - .../tests/http}/__init__.py | 0 .../{grpc => tests/http/managers}/__init__.py | 0 .../tests/http/managers/test_async.py | 64 +++++ .../tests/http/managers/test_dummy.py | 51 ++++ .../tests/http/managers/test_sync.py | 63 +++++ .../tests/http/test_conditions.py | 154 +++++++++++ .../tests/{grpc => http}/test_utils.py | 2 +- pdm.lock | 36 +-- pyproject.toml | 3 + 39 files changed, 435 insertions(+), 1761 deletions(-) delete mode 100644 examples/grpc/config.py delete mode 100644 examples/grpc/flags.py delete mode 100644 examples/grpc/sanic_app.py delete mode 100644 examples/grpc/wsgi_app.py rename examples/{grpc/aiohttp_app.py => http/dummy_async_client.py} (64%) rename examples/{grpc/flask_app.py => http/dummy_sync_client.py} (64%) delete mode 100644 featureflags_client/grpc/client.py delete mode 100644 featureflags_client/grpc/conditions.py delete mode 100644 featureflags_client/grpc/flags.py delete mode 100644 featureflags_client/grpc/managers/__init__.py delete mode 100644 featureflags_client/grpc/managers/asyncio.py delete mode 100644 featureflags_client/grpc/managers/base.py delete mode 100644 featureflags_client/grpc/managers/dummy.py delete mode 100644 featureflags_client/grpc/managers/sync.py delete mode 100644 featureflags_client/grpc/state.py delete mode 100644 featureflags_client/grpc/stats_collector.py delete mode 100644 featureflags_client/grpc/tracer.py delete mode 100644 featureflags_client/grpc/types.py delete mode 100644 featureflags_client/grpc/utils.py delete mode 100644 featureflags_client/tests/grpc/__init__.py delete mode 100644 featureflags_client/tests/grpc/test_conditions.py delete mode 100644 featureflags_client/tests/grpc/test_flags.py delete mode 100644 featureflags_client/tests/grpc/test_managers_asyncio.py delete mode 100644 featureflags_client/tests/grpc/test_managers_dummy.py rename {examples/grpc => featureflags_client/tests/http}/__init__.py (100%) rename featureflags_client/{grpc => tests/http/managers}/__init__.py (100%) create mode 100644 featureflags_client/tests/http/managers/test_async.py create mode 100644 featureflags_client/tests/http/managers/test_dummy.py create mode 100644 featureflags_client/tests/http/managers/test_sync.py create mode 100644 featureflags_client/tests/http/test_conditions.py rename featureflags_client/tests/{grpc => http}/test_utils.py (95%) diff --git a/README.md b/README.md index 42fed52..efccf54 100644 --- a/README.md +++ b/README.md @@ -37,4 +37,3 @@ TODO: - add docs, automate docs build - add tests - add `tracer` / `stats_collector` for http manager -- rm old grpc client diff --git a/examples/README.md b/examples/README.md index 1e8fb94..57bdac6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,24 +1,14 @@ Examples ======== -Here you can find examples for gRPC and HTTP clients with: +Here you can find examples for HTTP clients with: - `aiohttp` -- `Sanic` -- `Flask` -- `WSGI` - `httpx` +- `requests` Prerequisites: -- sync + grpc: - - > pip install featureflags-client[grpclib] - -- async + grpc: - - > pip install featureflags-client[grpcio] - - async + http: > pip install featureflags-client[httpx] diff --git a/examples/grpc/config.py b/examples/grpc/config.py deleted file mode 100644 index 57495ef..0000000 --- a/examples/grpc/config.py +++ /dev/null @@ -1,4 +0,0 @@ -FF_PROJECT = "test.test" - -FF_HOST = "grpc.featureflags.svc" -FF_PORT = 50051 diff --git a/examples/grpc/flags.py b/examples/grpc/flags.py deleted file mode 100644 index a8899a0..0000000 --- a/examples/grpc/flags.py +++ /dev/null @@ -1,8 +0,0 @@ -from featureflags_client.grpc.types import Types, Variable - -REQUEST_QUERY = Variable("request.query", Types.STRING) - - -class Defaults: - TEST = False - SOME_USELESS_FLAG = False diff --git a/examples/grpc/sanic_app.py b/examples/grpc/sanic_app.py deleted file mode 100644 index 08bd078..0000000 --- a/examples/grpc/sanic_app.py +++ /dev/null @@ -1,78 +0,0 @@ -import logging.config - -import flags -from grpclib.client import Channel -from sanic import Sanic -from sanic.log import LOGGING_CONFIG_DEFAULTS -from sanic.response import text - -from featureflags_client.grpc.client import FeatureFlagsClient -from featureflags_client.grpc.managers.asyncio import AsyncIOManager - -app = Sanic(configure_logging=False) - -log = logging.getLogger(__name__) - - -@app.listener("before_server_start") -async def on_start(sanic_app, loop): - sanic_app.ff_manager = AsyncIOManager( - app.config.FF_PROJECT, - [flags.REQUEST_QUERY], - Channel(app.config.FF_HOST, app.config.FF_PORT, loop=loop), - loop=loop, - ) - sanic_app.flags_client = FeatureFlagsClient( - flags.Defaults, - sanic_app.ff_manager, - ) - try: - await sanic_app.ff_manager.preload(timeout=5) - except Exception: - log.exception( - "Unable to preload feature flags, application will " - "start working with defaults and retry later" - ) - sanic_app.ff_manager.start() - - -@app.listener("after_server_stop") -async def on_stop(sanic_app, _): - sanic_app.ff_manager.close() - await sanic_app.ff_manager.wait_closed() - - -@app.middleware("request") -async def flags_ctx_enter(request): - request["_ff_ctx"] = ctx = request.app.flags_client.flags( - { - flags.REQUEST_QUERY.name: request.query_string, - } - ) - request["ff"] = ctx.__enter__() - - -@app.middleware("response") -async def flags_ctx_enter_exit(request, _): - request["_ff_ctx"].__exit__(None, None, None) - - -@app.route("/") -async def index(request): - if request["ff"].TEST: - return text("TEST: True") - else: - return text("TEST: False") - - -if __name__ == "__main__": - LOGGING_CONFIG_DEFAULTS["loggers"]["featureflags"] = { - "level": "DEBUG", - "handlers": ["console"], - } - logging.config.dictConfig(LOGGING_CONFIG_DEFAULTS) - - import config - - app.config.from_object(config) - app.run(host="0.0.0.0", port=5000) diff --git a/examples/grpc/wsgi_app.py b/examples/grpc/wsgi_app.py deleted file mode 100644 index fe65532..0000000 --- a/examples/grpc/wsgi_app.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -import config -import flags -from grpc import insecure_channel - -from featureflags_client.grpc.client import FeatureFlagsClient -from featureflags_client.grpc.managers.sync import SyncManager - - -def make_app(): - channel = insecure_channel(f"{config.FF_HOST}:{config.FF_PORT}") - manager = SyncManager(config.FF_PROJECT, [flags.REQUEST_QUERY], channel) - client = FeatureFlagsClient(flags.Defaults, manager) - - def application(environ, start_response): - ctx = {flags.REQUEST_QUERY.name: environ["QUERY_STRING"]} - with client.flags(ctx) as ff: - content = b"TEST: True" if ff.TEST else b"TEST: False" - - start_response("200 OK", [("Content-Length", str(len(content)))]) - return [content] - - return application - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - logging.getLogger("featureflags").setLevel(logging.DEBUG) - - from wsgiref.simple_server import make_server - - with make_server("", 5000, make_app()) as server: - server.serve_forever() diff --git a/examples/grpc/aiohttp_app.py b/examples/http/dummy_async_client.py similarity index 64% rename from examples/grpc/aiohttp_app.py rename to examples/http/dummy_async_client.py index e74d9e1..db73646 100644 --- a/examples/grpc/aiohttp_app.py +++ b/examples/http/dummy_async_client.py @@ -1,34 +1,41 @@ import logging +import config import flags from aiohttp import web -from grpclib.client import Channel -from featureflags_client.grpc.client import FeatureFlagsClient -from featureflags_client.grpc.managers.asyncio import AsyncIOManager +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.dummy import AsyncDummyManager log = logging.getLogger(__name__) async def on_start(app): - app["ff_manager"] = AsyncIOManager( - app["config"].FF_PROJECT, - [flags.REQUEST_QUERY], - Channel(app["config"].FF_HOST, app["config"].FF_PORT), + # Dummy manager just uses Defaults values for flags, mainly for tests. + app["ff_manager"] = AsyncDummyManager( + url=config.FF_URL, + project=config.FF_PROJECT, + variables=[flags.REQUEST_QUERY], + defaults=flags.Defaults, + request_timeout=5, + refresh_interval=10, ) - app["ff_client"] = FeatureFlagsClient(flags.Defaults, app["ff_manager"]) + app["ff_client"] = FeatureFlagsClient(app["ff_manager"]) + try: - await app["ff_manager"].preload(timeout=5) + await app["ff_client"].preload_async() 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): - app["ff_manager"].close() await app["ff_manager"].wait_closed() @@ -49,13 +56,13 @@ async def index(request): 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) - import config - app["config"] = config + return app diff --git a/examples/grpc/flask_app.py b/examples/http/dummy_sync_client.py similarity index 64% rename from examples/grpc/flask_app.py rename to examples/http/dummy_sync_client.py index 23815d7..ead6b9f 100644 --- a/examples/grpc/flask_app.py +++ b/examples/http/dummy_sync_client.py @@ -3,11 +3,10 @@ import config import flags from flask import Flask, g, request -from grpc import insecure_channel from werkzeug.local import LocalProxy -from featureflags_client.grpc.client import FeatureFlagsClient -from featureflags_client.grpc.managers.sync import SyncManager +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.dummy import DummyManager app = Flask(__name__) @@ -15,9 +14,16 @@ def get_ff_client(): ff_client = getattr(g, "_ff_client", None) if ff_client is None: - channel = insecure_channel(f"{config.FF_HOST}:{config.FF_PORT}") - manager = SyncManager(config.FF_PROJECT, [flags.REQUEST_QUERY], channel) - ff_client = g._ff_client = FeatureFlagsClient(flags.Defaults, manager) + # Dummy manager just uses Defaults values for flags, mainly for tests. + manager = DummyManager( + url=config.FF_URL, + project=config.FF_PROJECT, + variables=[flags.REQUEST_QUERY], + defaults=flags.Defaults, + request_timeout=5, + refresh_interval=10, + ) + ff_client = g._ff_client = FeatureFlagsClient(manager) return ff_client diff --git a/featureflags_client/grpc/client.py b/featureflags_client/grpc/client.py deleted file mode 100644 index 4537d5f..0000000 --- a/featureflags_client/grpc/client.py +++ /dev/null @@ -1,95 +0,0 @@ -import inspect -from collections.abc import Mapping -from contextlib import contextmanager -from enum import EnumMeta -from typing import Any, Dict, Generator, Optional, Union - -from featureflags_client.grpc.flags import Flags -from featureflags_client.grpc.managers.base import AbstractManager - - -class FeatureFlagsClient: - """Feature flags client - - :param defaults: flags are defined together with their default values, - defaults can be provided as dict or class object with attributes - :param manager: flags manager - """ - - def __init__( - self, - defaults: Union[EnumMeta, type, Dict[str, bool]], - manager: AbstractManager, - ) -> None: - if isinstance(defaults, EnumMeta): # deprecated - defaults = { # type: ignore - k: v.value for k, v in defaults.__members__.items() - } - elif inspect.isclass(defaults): - defaults = { - k: getattr(defaults, k) - for k in dir(defaults) - if k.isupper() and not k.startswith("_") - } - elif not isinstance(defaults, Mapping): - raise TypeError(f"Invalid defaults type: {type(defaults)!r}") - - invalid = [ - k - for k, v in defaults.items() - if not isinstance(k, str) or not isinstance(v, bool) - ] - if invalid: - raise TypeError( - "Invalid flag definition: {}".format( - ", ".join(map(repr, invalid)) - ) - ) - - self._defaults = defaults - self._manager = manager - - @contextmanager - def flags( - self, - ctx: Optional[Dict[str, Any]] = None, - overrides: Optional[Dict[str, bool]] = None, - ) -> Generator[Flags, None, None]: - """Context manager to wrap your request handling code and get actual - flags values - - Example: - - .. code-block:: python - - with client.flags() as flags: - print(flags.FOO_FEATURE) - - :param ctx: current variable values - :param overrides: flags to override - :return: :py:class:`Flags` object - """ - flags = None - try: - flags = Flags( - self._defaults, - self._manager, - ctx, - overrides, - ) - yield flags - finally: - if flags is not None: - flags.add_trace() - - def preload(self, timeout: Optional[int] = None) -> None: - """Preload flags from featureflags.server. - This method syncs all flags with server""" - self._manager.preload(timeout=timeout, defaults=self._defaults) - - async def preload_async(self, timeout: Optional[int] = None) -> None: - """Async version of `preload` method""" - 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 deleted file mode 100644 index 9ee952d..0000000 --- a/featureflags_client/grpc/conditions.py +++ /dev/null @@ -1,235 +0,0 @@ -import re -from typing import Any, Callable, Dict, Optional, Set - -from featureflags_protobuf.graph_pb2 import Check as CheckProto -from featureflags_protobuf.graph_pb2 import Result as ResultProto - -_undefined = object() - - -def false(ctx: Dict[str, Any]) -> bool: - return False - - -def except_false(func: Callable) -> Callable: - def wrapper(ctx: Dict[str, Any]) -> Any: - try: - return func(ctx) - except TypeError: - return False - - return wrapper - - -def equal(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - return ctx.get(name, _undefined) == value - - return proc - - -def less_than(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - ctx_val = ctx.get(name, _undefined) - ctx_val + 0 # quick type checking in Python 2 - return ctx_val is not _undefined and ctx_val < value - - return proc - - -def less_or_equal(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - ctx_val = ctx.get(name, _undefined) - ctx_val + 0 # quick type checking in Python 2 - return ctx_val is not _undefined and ctx_val <= value - - return proc - - -def greater_than(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - ctx_val = ctx.get(name, _undefined) - ctx_val + 0 # quick type checking in Python 2 - return ctx_val is not _undefined and ctx_val > value - - return proc - - -def greater_or_equal(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - ctx_val = ctx.get(name, _undefined) - ctx_val + 0 # quick type checking in Python 2 - return ctx_val is not _undefined and ctx_val >= value - - return proc - - -def contains(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - return value in ctx.get(name, "") - - return proc - - -def percent(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any]) -> bool: - ctx_val = ctx.get(name, _undefined) - return ctx_val is not _undefined and hash(ctx_val) % 100 < value - - return proc - - -def regexp(name: str, value: Any) -> Callable: - @except_false - def proc(ctx: Dict[str, Any], _re: re.Pattern = re.compile(value)) -> bool: - return _re.match(ctx.get(name, "")) is not None - - return proc - - -def wildcard(name: str, value: Any) -> Callable: - re_ = "^" + "(?:.*)".join(map(re.escape, value.split("*"))) + "$" - return regexp(name, re_) - - -def subset(name: str, value: Any) -> Callable: - if value: - - @except_false - def proc(ctx: Dict[str, Any], _value: Optional[Set] = None) -> bool: - _value = _value or set(value) - ctx_val = ctx.get(name) - return bool(ctx_val) and _value.issuperset(ctx_val) - - else: - proc = false - - return proc - - -def superset(name: str, value: Any) -> Callable: - if value: - - @except_false - def proc(ctx: Dict[str, Any], _value: Optional[Set] = None) -> bool: - _value = _value or set(value) - ctx_val = ctx.get(name) - return bool(ctx_val) and _value.issubset(ctx_val) - - else: - proc = false - - return proc - - -OPS = { - CheckProto.EQUAL: equal, - CheckProto.LESS_THAN: less_than, - CheckProto.LESS_OR_EQUAL: less_or_equal, - CheckProto.GREATER_THAN: greater_than, - CheckProto.GREATER_OR_EQUAL: greater_or_equal, - CheckProto.CONTAINS: contains, - CheckProto.PERCENT: percent, - CheckProto.REGEXP: regexp, - CheckProto.WILDCARD: wildcard, - CheckProto.SUBSET: subset, - CheckProto.SUPERSET: superset, -} - - -class DummyReport: - def add(self, error: str) -> None: - pass - - -def check_proc( - result: ResultProto, - check_id: int, - report: DummyReport = DummyReport(), -) -> Callable: - check = result.Check[check_id] - if not check.variable.Variable: - report.add(f"Check[{check_id}].variable is unset") - return false - if check.operator == CheckProto.__DEFAULT__: - report.add(f"Check[{check_id}].operator is unset") - return false - kind = check.WhichOneof("kind") - if not kind: - report.add(f"Check[{check_id}].kind is unset") - return false - variable = result.Variable[check.variable.Variable] - if not variable.name: - report.add(f"Variable[{check.variable}].name is unset") - return false - value = getattr(check, check.WhichOneof("kind")) - # TODO: check value type and if operator is supported - return OPS[check.operator](variable.name, value) - - -def flag_proc( - result: ResultProto, - flag_id: int, - report: DummyReport = DummyReport(), -) -> Optional[Callable]: - flag = result.Flag[flag_id] - if not flag.HasField("overridden"): - report.add(f"Flag[{flag_id}].overridden is unset") - return None - if not flag.HasField("enabled"): - report.add(f"Flag[{flag_id}].enabled is unset") - return false - if not flag.overridden.value: - return None - - conditions = [] - for condition_ref in flag.conditions: - condition = result.Condition[condition_ref.Condition] - checks = [ - check_proc(result, check_ref.Check, report) - for check_ref in condition.checks - ] - if checks: - conditions.append(checks) - else: - report.add(f"Condition[{condition_ref.Condition}].checks is empty") - # in case of invalid condition it would be safe to replace it - # with a falsish condition - conditions.append([false]) - - if flag.enabled.value and conditions: - - def proc(ctx: Dict[str, Any]) -> bool: - return any( - all(check(ctx) for check in checks_) for checks_ in conditions - ) - - else: - - def proc(ctx: Dict[str, Any]) -> bool: - return flag.enabled.value - - return proc - - -def load_flags( - result: ResultProto, - report: DummyReport = DummyReport(), -) -> Dict[str, Callable]: - procs = {} - for flag_ref in result.Root.flags: - flag = result.Flag[flag_ref.Flag] - if not flag.name: - report.add(f"Flag[{flag_ref.Flag}].name is not set") - continue - proc = flag_proc(result, flag_ref.Flag, report) - if proc is not None: - procs[flag.name] = proc - return procs diff --git a/featureflags_client/grpc/flags.py b/featureflags_client/grpc/flags.py deleted file mode 100644 index e76631e..0000000 --- a/featureflags_client/grpc/flags.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any, Dict, List, Optional, Tuple - -from featureflags_client.grpc.managers.base import AbstractManager -from featureflags_client.grpc.tracer import Tracer - - -class Flags: - """Flags object to access current flags' state - - Flag values on this object can't change. So even if flag's state is changed - during request, your application will see the same value, and only for next - requests your application will see new value. - - This object is returned from :py:meth:`Client.flags` context manager. No - need to instantiate it directly. - """ - - def __init__( - self, - defaults: Dict[str, bool], - manager: AbstractManager, - ctx: Optional[Dict[str, Any]] = None, - overrides: Optional[Dict[str, bool]] = None, - ) -> None: - self._defaults = defaults - self._manager = manager - self._tracer = Tracer() - self._ctx = ctx or {} - self._overrides = overrides or {} - - def __getattr__(self, name: str) -> bool: - try: - default = self._defaults[name] - except KeyError as exc: - raise AttributeError(f"Flag {name} is not defined") from exc - - try: - value = self._overrides[name] - except KeyError: - check = self._manager.get(name) - value = check(self._ctx) if check is not None else default - - self._tracer.inc(name, value) - # caching/snapshotting - setattr(self, name, value) - return value - - def __history__(self) -> List[Tuple[str, bool]]: - """Returns an ordered history for flags that were checked""" - 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/__init__.py b/featureflags_client/grpc/managers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags_client/grpc/managers/asyncio.py b/featureflags_client/grpc/managers/asyncio.py deleted file mode 100644 index 97f9c0f..0000000 --- a/featureflags_client/grpc/managers/asyncio.py +++ /dev/null @@ -1,171 +0,0 @@ -import asyncio -import logging -import warnings -from asyncio import AbstractEventLoop -from typing import Callable, Dict, List, Optional - -from featureflags_protobuf.service_grpc import FeatureFlagsStub -from featureflags_protobuf.service_pb2 import FlagUsage as FlagUsageProto - -from featureflags_client.grpc.managers.base import AbstractManager -from featureflags_client.grpc.state import GrpcState -from featureflags_client.grpc.stats_collector import StatsCollector -from featureflags_client.grpc.tracer import Tracer -from featureflags_client.grpc.types import Variable -from featureflags_client.grpc.utils import intervals_gen - -try: - import grpclib.client -except ImportError: - raise ImportError( - "grpclib is not installed, please install it to use AsyncIOManager " - "like this `pip install 'featureflags-client[grpclib]'`" - ) from None - -log = logging.getLogger(__name__) - - -class AsyncIOManager(AbstractManager): - """Feature flags manager for asyncio apps - - Example: - - .. code-block:: python - - from grpclib.client import Channel - - manager = AsyncIOManager( - 'project.name', - [], # variables - Channel('grpc.featureflags.svc', 50051) - ) - try: - await manager.preload(timeout=5) - except Exception: - log.exception('Unable to preload feature flags, application will ' - 'start working with defaults and retry later') - manager.start() - try: - pass # run your application - finally: - manager.close() - await manager.wait_closed() - - :param project: project name - :param variables: list of :py:class:`~featureflags.client.flags.Variable` - definitions - :param channel: instance of :py:class:`grpclib.client.Channel` class, - pointing to the feature flags gRPC server - :param loop: asyncio event loop - """ - - _exchange_task = None - _exchange_timeout = 5 - - def __init__( - self, - project: str, - variables: List[Variable], - channel: grpclib.client.Channel, - *, - loop: Optional[AbstractEventLoop] = None, - ) -> None: - self._state = GrpcState(project, variables) - self._channel = channel - self._loop = loop or asyncio.get_event_loop() - - self._stats = StatsCollector() - self._stub = FeatureFlagsStub(self._channel) - - self._int_gen = intervals_gen() - self._int_gen.send(None) - - if loop: - warnings.warn( - "The loop arguments is deprecated because it's not necessary.", - DeprecationWarning, - stacklevel=2, - ) - - async def preload( # type: ignore - self, - *, - timeout: Optional[int] = None, - defaults: Optional[Dict] = None, - ) -> None: - """ - Preload flags from the server. - :param timeout: timeout in seconds (for grpclib) - :param defaults: dict with default values for feature flags. - If passed, all feature flags will be synced with - server, - otherwise flags will be synced only when they are - accessed - for the first time. - """ - stats = None - if defaults is not None: - stats = self._stats.from_defaults(defaults) - - await self._exchange(timeout, stats) - - def start(self) -> None: - if self._exchange_task is not None: - raise RuntimeError("Manager is already started") - self._exchange_task = self._loop.create_task(self._exchange_coro()) - - def close(self) -> None: - self._exchange_task.cancel() - - async def wait_closed(self) -> None: - await asyncio.wait([self._exchange_task]) - if self._exchange_task.done(): - try: - error = self._exchange_task.exception() - except asyncio.CancelledError: - pass - else: - if error is not None: - log.error("Exchange task exited with error: %r", error) - - async def _exchange_coro(self) -> None: - log.info("Exchange task started") - while True: - try: - await self._exchange(self._exchange_timeout) - interval = self._int_gen.send(True) - log.debug("Exchange complete, next will be in %ss", interval) - await asyncio.sleep(interval) - except asyncio.CancelledError: - log.info("Exchange task exits") - break - except Exception as exc: - interval = self._int_gen.send(False) - log.error("Failed to exchange: %r, retry in %ss", exc, interval) - await asyncio.sleep(interval) - continue - - async def _exchange( - self, - timeout: int, - flags_usage: Optional[List[FlagUsageProto]] = None, - ) -> None: - if flags_usage is None: - flags_usage = self._stats.flush() - - request = self._state.get_request(flags_usage) - log.debug( - "Exchange request, project: %r, version: %r, stats: %r", - request.project, - request.version, - request.flags_usage, - ) - reply = await self._stub.Exchange(request, timeout=timeout) - log.debug("Exchange reply: %r", reply) - self._state.apply_reply(reply) - - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: - return self._state.get(name) - - def add_trace(self, tracer: Tracer) -> None: - self._stats.update(tracer.interval, tracer.values) diff --git a/featureflags_client/grpc/managers/base.py b/featureflags_client/grpc/managers/base.py deleted file mode 100644 index 00d33a6..0000000 --- a/featureflags_client/grpc/managers/base.py +++ /dev/null @@ -1,23 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Callable, Dict, Optional - -if TYPE_CHECKING: - from featureflags_client.grpc.flags import Tracer - - -class AbstractManager(ABC): - @abstractmethod - 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 deleted file mode 100644 index ab8fc26..0000000 --- a/featureflags_client/grpc/managers/dummy.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Callable, Dict, Optional - -from featureflags_client.grpc.managers.base import AbstractManager -from featureflags_client.grpc.tracer import Tracer - - -class DummyManager(AbstractManager): - """Dummy feature flags manager - - It can be helpful when you want to use flags with their default values. - - Example: - - .. code-block:: python - - class Defaults: - FOO_FEATURE = False - - client = Client(Defaults, DummyManager()) - - with client.flags() as flags: - assert flags.FOO_FEATURE is False - - """ - - def preload( - self, - timeout: Optional[int] = None, - defaults: Optional[Dict] = None, - ) -> None: - pass - - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: - return None - - def add_trace(self, tracer: Optional[Tracer]) -> None: - pass diff --git a/featureflags_client/grpc/managers/sync.py b/featureflags_client/grpc/managers/sync.py deleted file mode 100644 index 6d84e10..0000000 --- a/featureflags_client/grpc/managers/sync.py +++ /dev/null @@ -1,126 +0,0 @@ -import logging -from datetime import datetime, timedelta -from typing import Callable, Dict, List, Optional - -from featureflags_protobuf.service_pb2 import FlagUsage as FlagUsageProto -from featureflags_protobuf.service_pb2_grpc import FeatureFlagsStub - -from featureflags_client.grpc.managers.base import AbstractManager -from featureflags_client.grpc.state import GrpcState -from featureflags_client.grpc.stats_collector import StatsCollector -from featureflags_client.grpc.tracer import Tracer -from featureflags_client.grpc.types import Variable -from featureflags_client.grpc.utils import intervals_gen - -try: - import grpc -except ImportError: - raise ImportError( - "grpcio is not installed, please install it to use SyncManager " - "like this `pip install 'featureflags-client[grpcio]'`" - ) from None - -log = logging.getLogger(__name__) - - -class SyncManager(AbstractManager): - """Feature flags manager for synchronous apps - - Example: - - .. code-block:: python - - from grpc import insecure_channel - - manager = SyncManager( - 'project.name', - [], # variables - insecure_channel('grpc.featureflags.svc:50051'), - ) - - :param project: project name - :param variables: list of :py:class:`~featureflags.client.flags.Variable` - definitions - :param channel: instance of :py:class:`grpc.Channel` class, pointing to the - feature flags gRPC server - """ - - _exchange_timeout = 5 - - def __init__( - self, project: str, variables: List[Variable], channel: grpc.Channel - ) -> None: - self._state = GrpcState(project, variables) - self._channel = channel - - self._stats = StatsCollector() - self._stub = FeatureFlagsStub(channel) - - self._int_gen = intervals_gen() - self._int_gen.send(None) - self._next_exchange = datetime.utcnow() - - def preload( - self, - timeout: Optional[int] = None, - defaults: Optional[Dict] = None, - ) -> None: - """ - Preload flags from the server. - :param timeout: timeout in seconds (for grpcio) - :param defaults: dict with default values for feature flags. - If passed, all feature flags will be synced with - server, - otherwise flags will be synced only when they are - accessed - for the first time. - """ - flags_usage = ( - None if defaults is None else self._stats.from_defaults(defaults) - ) - self._exchange(timeout, flags_usage) - - def _exchange( - self, - timeout: int, - flags_usage: Optional[List[FlagUsageProto]] = None, - ) -> None: - if flags_usage is None: - flags_usage = self._stats.flush() - - request = self._state.get_request(flags_usage) - log.debug( - "Exchange request, project: %r, version: %r, stats: %r", - request.project, - request.version, - request.flags_usage, - ) - reply = self._stub.Exchange(request, timeout=timeout) - log.debug("Exchange reply: %r", reply) - self._state.apply_reply(reply) - - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: - if datetime.utcnow() >= self._next_exchange: - try: - self._exchange(self._exchange_timeout) - except Exception as exc: - self._next_exchange = datetime.utcnow() + timedelta( - seconds=self._int_gen.send(False) - ) - log.error( - "Failed to exchange: %r, retry after %s", - exc, - self._next_exchange, - ) - else: - self._next_exchange = datetime.utcnow() + timedelta( - seconds=self._int_gen.send(True) - ) - log.debug( - "Exchange complete, next will be after %s", - self._next_exchange, - ) - return self._state.get(name) - - def add_trace(self, tracer: Optional[Tracer]) -> None: - self._stats.update(tracer.interval, tracer.values) diff --git a/featureflags_client/grpc/state.py b/featureflags_client/grpc/state.py deleted file mode 100644 index 6c1fe0b..0000000 --- a/featureflags_client/grpc/state.py +++ /dev/null @@ -1,97 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, List, Optional - -from featureflags_protobuf import service_pb2 -from featureflags_protobuf.service_pb2 import FlagUsage as FlagUsageProto -from hiku.builder import Q, build -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 - - -def get_grpc_graph_query(project_name: str) -> QueryNode: - return export( - build( - [ - Q.flags(project_name=project_name)[ - Q.id, - Q.name, - Q.enabled, - Q.overridden, - Q.conditions[ - Q.id, - Q.checks[ - Q.id, - Q.variable[ - Q.id, - Q.name, - Q.type, - ], - Q.operator, - Q.value_string, - Q.value_number, - Q.value_timestamp, - Q.value_set, - ], - ], - ], - ] - ) - ) - - -class BaseState(ABC): - variables: List[Variable] - project: str - version: int - - _state: Dict[str, Callable[[Dict], bool]] - - def __init__(self, project: str, variables: List[Variable]) -> None: - self.project = project - self.variables = variables - self.version = 0 - - self._state = {} - - def get(self, flag_name: str) -> Optional[Callable[[Dict], bool]]: - return self._state.get(flag_name) - - @abstractmethod - def get_request(self, flags_usage: List[FlagUsageProto]) -> Any: - pass - - @abstractmethod - def apply_reply(self, reply: Any) -> None: - pass - - -class GrpcState(BaseState): - def __init__(self, project: str, variables: List[Variable]) -> None: - super().__init__(project, variables) - self._variables_sent = False - self._exchange_query = get_grpc_graph_query(project) - - def get_request( - self, flags_usage: List[FlagUsageProto] - ) -> service_pb2.ExchangeRequest: - request = service_pb2.ExchangeRequest( - project=self.project, - version=self.version, - ) - request.query.CopyFrom(self._exchange_query) - - if not self._variables_sent: - for var in self.variables: - request.variables.add(name=var.name, type=var.type) - - request.flags_usage.extend(flags_usage) - return request - - def apply_reply(self, reply: service_pb2.ExchangeReply) -> None: - self._variables_sent = True - if self.version != reply.version: - self._state = load_flags(reply.result) - self.version = reply.version diff --git a/featureflags_client/grpc/stats_collector.py b/featureflags_client/grpc/stats_collector.py deleted file mode 100644 index 1b1e260..0000000 --- a/featureflags_client/grpc/stats_collector.py +++ /dev/null @@ -1,61 +0,0 @@ -from collections import OrderedDict, defaultdict -from datetime import datetime, timedelta -from typing import DefaultDict, Dict, List - -from featureflags_protobuf.service_pb2 import FlagUsage as FlagUsageProto -from google.protobuf.timestamp_pb2 import Timestamp as TimestampProto - - -class StatsCollector: - """ - Accumulates interval/flag/requests count - """ - - def __init__(self) -> None: - self._acc: DefaultDict = defaultdict( - lambda: defaultdict(lambda: [0, 0]) - ) - - def update( - self, - interval: datetime, - values: OrderedDict, - ) -> None: - for name, value in values.items(): - self._acc[interval][name][bool(value)] += 1 - - def flush( - self, - delta: timedelta = timedelta(minutes=1), - ) -> List[FlagUsageProto]: - now = datetime.utcnow() - to_flush = [i for i in self._acc if now - i > delta] - stats = [] - for interval in to_flush: - acc = self._acc.pop(interval) - for flag_name, (neg_count, pos_count) in acc.items(): - interval_pb = TimestampProto() - interval_pb.FromDatetime(interval) - stats.append( - FlagUsageProto( # type: ignore - name=flag_name, - interval=interval_pb, - negative_count=neg_count, - positive_count=pos_count, - ) - ) - return stats - - @staticmethod - def from_defaults(defaults: Dict) -> List[FlagUsageProto]: - interval_pb = TimestampProto() - interval_pb.FromDatetime(datetime.utcnow()) - return [ - FlagUsageProto( - name=name, - interval=interval_pb, - positive_count=0, - negative_count=0, - ) - for name in defaults - ] diff --git a/featureflags_client/grpc/tracer.py b/featureflags_client/grpc/tracer.py deleted file mode 100644 index e54c729..0000000 --- a/featureflags_client/grpc/tracer.py +++ /dev/null @@ -1,15 +0,0 @@ -from collections import OrderedDict -from datetime import datetime - - -class Tracer: - """ - Accumulates request/flag/values - """ - - def __init__(self) -> None: - self.values: OrderedDict[str, int] = OrderedDict() - self.interval = datetime.utcnow().replace(second=0, microsecond=0) - - def inc(self, name: str, value: int) -> None: - self.values[name] = value diff --git a/featureflags_client/grpc/types.py b/featureflags_client/grpc/types.py deleted file mode 100644 index 838aa2a..0000000 --- a/featureflags_client/grpc/types.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass - -from featureflags_protobuf.graph_pb2 import Variable as VariableProto - - -class Types: - """Enumerates possible variable types, e.g. ``Types.STRING`` - - .. py:attribute:: STRING - - String type - - .. py:attribute:: NUMBER - - Number type, represented as ``float`` in Python, ``double`` in ProtoBuf - - .. py:attribute:: TIMESTAMP - - Timestamp type, represented as ``datetime`` in Python, - ``google.protobuf.Timestamp`` in ProtoBuf - - .. py:attribute:: SET - - Set of strings type - - """ - - STRING = VariableProto.STRING - NUMBER = VariableProto.NUMBER - TIMESTAMP = VariableProto.TIMESTAMP - SET = VariableProto.SET - - -@dataclass -class Variable: - """Variable definition - - Example: - - .. code-block:: python - - USER_ID = Variable('user.id', Types.STRING) - - :param name: variable's name - :param type: variable's type, one of :py:class:`Types` - """ - - name: int - type: VariableProto.Type diff --git a/featureflags_client/grpc/utils.py b/featureflags_client/grpc/utils.py deleted file mode 100644 index 3e7dfb1..0000000 --- a/featureflags_client/grpc/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Generator - - -def intervals_gen( - interval: int = 10, - retry_interval_min: int = 1, - retry_interval_max: int = 32, -) -> Generator[int, bool, None]: - success = True - retry_interval = retry_interval_min - - while True: - if success: - success = yield interval - retry_interval = retry_interval_min - else: - success = yield retry_interval - retry_interval = min(retry_interval * 2, retry_interval_max) diff --git a/featureflags_client/http/conditions.py b/featureflags_client/http/conditions.py index 14706a3..2f17bf4 100644 --- a/featureflags_client/http/conditions.py +++ b/featureflags_client/http/conditions.py @@ -1,8 +1,11 @@ +import logging import re from typing import Any, Callable, Dict, List, Optional, Set from featureflags_client.http.types import Check, Flag, Operator +log = logging.getLogger(__name__) + _UNDEFINED = object() @@ -140,19 +143,17 @@ def proc(ctx: Dict[str, Any], _value: Optional[Set] = None) -> bool: def check_proc(check: Check) -> Callable: - operator = check.operator - value = check.value - variable = check.variable - - if variable is None or operator is None or value is None: + if check.value is None: + log.info(f"Check[{check}].value is None") return false - return OPERATIONS_MAP[operator](variable.name, value) + return OPERATIONS_MAP[check.operator](check.variable.name, check.value) def flag_proc(flag: Flag) -> Optional[Callable]: if not flag.overridden: # Flag was not overridden on server, use value from defaults. + log.info(f"Flag[{flag.name}] is not overriden yet, using default value") return None conditions = [] @@ -161,7 +162,10 @@ def flag_proc(flag: Flag) -> Optional[Callable]: # in case of invalid condition it would be safe to replace it # with a falsish condition - checks_procs = checks_procs or [false] + if not checks_procs: + log.info("Condition has empty checks") + checks_procs = [false] + conditions.append(checks_procs) if flag.enabled and conditions: @@ -172,6 +176,7 @@ def proc(ctx: Dict[str, Any]) -> bool: ) else: + log.info(f"Flag[{flag.name}] is disabled or do not have any conditions") def proc(ctx: Dict[str, Any]) -> bool: return flag.enabled diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index 3652bdf..f995100 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -6,7 +6,6 @@ from enum import EnumMeta 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 from featureflags_client.http.state import HttpState from featureflags_client.http.types import ( @@ -19,6 +18,7 @@ from featureflags_client.http.utils import ( coerce_defaults, custom_asdict_factory, + intervals_gen, ) log = logging.getLogger(__name__) diff --git a/featureflags_client/http/types.py b/featureflags_client/http/types.py index ac3d243..933c46d 100644 --- a/featureflags_client/http/types.py +++ b/featureflags_client/http/types.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional, Union +from typing import List, Union from dataclass_wizard import JSONWizard @@ -34,8 +34,8 @@ class CheckVariable: @dataclass class Check: - operator: Optional[Operator] = None - variable: Optional[CheckVariable] = None + operator: Operator + variable: CheckVariable value: Union[str, float, List[str], None] = None diff --git a/featureflags_client/tests/conftest.py b/featureflags_client/tests/conftest.py index db3c452..b0f396c 100644 --- a/featureflags_client/tests/conftest.py +++ b/featureflags_client/tests/conftest.py @@ -1,26 +1,42 @@ +import faker import pytest -from featureflags_client.grpc.conditions import load_flags -from featureflags_client.grpc.flags import AbstractManager +from featureflags_client.http.types import ( + Check, + CheckVariable, + Condition, + Flag, + Operator, + VariableType, +) +f = faker.Faker() -class SimpleManager(AbstractManager): - def __init__(self): - self.checks = {} - def preload(self, timeout=None, defaults=None) -> None: - pass +@pytest.fixture +def variable(): + return CheckVariable(name=f.pystr(), type=VariableType.STRING) - def load(self, result): - self.checks = load_flags(result) - def get(self, name): - return self.checks.get(name) +@pytest.fixture +def check(variable): + return Check( + operator=Operator.EQUAL, + variable=variable, + value=f.pystr(), + ) - def add_trace(self, tracer): - pass +@pytest.fixture +def condition(check): + return Condition(checks=[check]) -@pytest.fixture() -def simple_manager(): - return SimpleManager() + +@pytest.fixture +def flag(condition): + return Flag( + name=f.pystr(), + enabled=True, + overridden=True, + conditions=[condition], + ) diff --git a/featureflags_client/tests/grpc/__init__.py b/featureflags_client/tests/grpc/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags_client/tests/grpc/test_conditions.py b/featureflags_client/tests/grpc/test_conditions.py deleted file mode 100644 index a0c7f52..0000000 --- a/featureflags_client/tests/grpc/test_conditions.py +++ /dev/null @@ -1,243 +0,0 @@ -import faker -import pytest -from featureflags_protobuf.graph_pb2 import ( - Check, - Condition, - Flag, - Ref, - Result, - Variable, -) -from google.protobuf.wrappers_pb2 import BoolValue - -from featureflags_client.grpc.conditions import ( - OPS, - check_proc, - contains, - equal, - false, - flag_proc, - greater_or_equal, - greater_than, - less_or_equal, - less_than, - percent, - regexp, - subset, - superset, - wildcard, -) - -f = faker.Faker() -undefined = object() - - -def check_op(left, op, right): - return op("var", right)({"var": left} if left is not undefined else {}) - - -def test_false(): - assert false({}) is False - - -def test_equal(): - assert check_op(1, equal, 1) is True - assert check_op(2, equal, 1) is False - assert check_op(1, equal, 2) is False - assert check_op(1, equal, "1") is False - assert check_op("1", equal, 1) is False - assert check_op(undefined, equal, 1) is False - - -def test_less_than(): - assert check_op(1, less_than, 2) is True - assert check_op(1, less_than, 1) is False - assert check_op(2, less_than, 1) is False - assert check_op(undefined, less_than, 1) is False - assert check_op("1", less_than, 2) is False - - -def test_less_or_equal(): - assert check_op(1, less_or_equal, 2) is True - assert check_op(1, less_or_equal, 1) is True - assert check_op(2, less_or_equal, 1) is False - assert check_op(undefined, less_or_equal, 1) is False - assert check_op("1", less_or_equal, 2) is False - - -def test_greater_than(): - assert check_op(2, greater_than, 1) is True - assert check_op(1, greater_than, 1) is False - assert check_op(1, greater_than, 2) is False - assert check_op(undefined, greater_than, 1) is False - assert check_op("2", greater_than, 1) is False - - -def test_greater_or_equal(): - assert check_op(2, greater_or_equal, 1) is True - assert check_op(1, greater_or_equal, 1) is True - assert check_op(1, greater_or_equal, 2) is False - assert check_op(undefined, greater_or_equal, 1) is False - assert check_op("2", greater_or_equal, 1) is False - - -def test_contains(): - assert check_op("aaa", contains, "a") is True - assert check_op("aaa", contains, "aa") is True - assert check_op("aaa", contains, "aaa") is True - assert check_op("a", contains, "aaa") is False - assert check_op("aaa", contains, "b") is False - assert check_op(undefined, contains, "a") is False - assert check_op(1, contains, "a") is False - assert check_op("a", contains, 1) is False - - -def test_percent(): - assert check_op(0, percent, 1) is True - assert check_op(1, percent, 1) is False - assert check_op(1, percent, 2) is True - - for i in range(-150, 150): - assert check_op(i, percent, 0) is False - for i in range(-150, 150): - assert check_op(i, percent, 100) is True - - assert check_op("foo", percent, 100) is True - assert check_op("foo", percent, 0) is False - assert check_op("foo", percent, hash("foo") % 100 + 1) is True - assert check_op("foo", percent, hash("foo") % 100 - 1) is False - - assert check_op(undefined, percent, 100) is False - - -def test_regexp(): - assert check_op("anything", regexp, ".") is True - assert check_op("kebab-style", regexp, r"\w+-\w+") is True - assert check_op("snake_style", regexp, r"\w+-\w+") is False - assert check_op(undefined, regexp, ".") is False - assert check_op(1, regexp, ".") is False - - -def test_wildcard(): - assert check_op("foo-value", wildcard, "foo-*") is True - assert check_op("value-foo", wildcard, "*-foo") is True - assert check_op("foo-value-bar", wildcard, "foo-*-bar") is True - assert check_op("value", wildcard, "foo-*") is False - assert check_op(undefined, wildcard, "foo-*") is False - assert check_op(1, wildcard, "foo-*") is False - - -def test_subset(): - assert check_op(set("ab"), subset, set("abc")) is True - assert check_op(set("bc"), subset, set("abc")) is True - assert check_op(set("ac"), subset, set("abc")) is True - assert check_op(set("ae"), subset, set("abc")) is False - assert check_op(undefined, subset, set("abc")) is False - assert check_op(1, subset, set("abc")) is False - - -def test_superset(): - assert check_op(set("abc"), superset, set("ab")) is True - assert check_op(set("abc"), superset, set("bc")) is True - assert check_op(set("abc"), superset, set("ac")) is True - assert check_op(set("abc"), superset, set("ae")) is False - assert check_op(undefined, superset, set("abc")) is False - assert check_op(1, superset, set("abc")) is False - - -class Report(list): - def add(self, error): - self.append(error) - - -@pytest.fixture(name="variable") -def fixture_variable(): - return Variable(id=f.pystr(), name=f.pystr(), type=Variable.STRING) - - -@pytest.fixture(name="check") -def fixture_check(variable): - return Check( - id=f.pystr(), - variable=Ref(Variable=variable.id), - operator=Check.EQUAL, - value_string=f.pystr(), - ) - - -@pytest.fixture(name="condition") -def fixture_condition(check): - return Condition(id=f.pystr(), checks=[Ref(Check=check.id)]) - - -@pytest.fixture(name="flag") -def fixture_flag(condition): - return Flag( - id=f.pystr(), - name=f.pystr(), - enabled=BoolValue(value=True), - overridden=BoolValue(value=True), - conditions=[Ref(Condition=condition.id)], - ) - - -@pytest.fixture(name="result") -def fixture_result(variable, flag, condition, check): - return Result( - Variable={variable.id: variable}, - Flag={flag.id: flag}, - Condition={condition.id: condition}, - Check={check.id: check}, - ) - - -class TestCheckProc: - def _get_error(self, result, check_id): - report = Report() - assert check_proc(result, check_id, report) is false - (error,) = report - return error - - def test_valid(self, result, check, variable): - proc = check_proc(result, check.id, Report()) - assert proc({variable.name: check.value_string}) is True - assert proc({variable.name: ""}) is False - - def test_supported_ops(self): - assert (set(OPS) | {0}) == set(Check.Operator.values()) - - def test_no_variable(self, result, check): - result.Check[check.id].ClearField("variable") - assert "variable is unset" in self._get_error(result, check.id) - - def test_no_operator(self, result, check): - result.Check[check.id].ClearField("operator") - assert "operator is unset" in self._get_error(result, check.id) - - def test_no_value(self, result, check): - result.Check[check.id].ClearField("value_string") - assert "kind is unset" in self._get_error(result, check.id) - - def test_invalid_var(self, result, check, variable): - result.Variable[variable.id].ClearField("name") - assert "name is unset" in self._get_error(result, check.id) - - -class TestFlagProc: - def _get_error(self, result, flag_id): - report = Report() - flag_proc(result, flag_id, report) - (error,) = report - return error - - def test_valid(self, result, flag, check, variable): - proc = flag_proc(result, flag.id, Report()) - assert proc({variable.name: check.value_string}) is True - - def test_no_enabled(self, result, flag): - result.Flag[flag.id].ClearField("enabled") - assert "enabled is unset" in self._get_error(result, flag.id) - - def test_empty_condition(self, result, flag, condition): - del result.Condition[condition.id].checks[:] - assert "checks is empty" in self._get_error(result, flag.id) diff --git a/featureflags_client/tests/grpc/test_flags.py b/featureflags_client/tests/grpc/test_flags.py deleted file mode 100644 index 5fe73bc..0000000 --- a/featureflags_client/tests/grpc/test_flags.py +++ /dev/null @@ -1,225 +0,0 @@ -from datetime import timedelta -from enum import Enum - -import faker -import pytest -from featureflags_protobuf.graph_pb2 import Check, Result, Variable -from featureflags_protobuf.service_pb2 import FlagUsage -from google.protobuf.timestamp_pb2 import Timestamp - -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 - -f = faker.Faker() - - -def test_tracing(simple_manager): - defaults = {"TEST": False} - - result = Result() - flag_id = f.pystr() - result.Root.flags.add().Flag = flag_id - result.Flag[flag_id].id = flag_id - result.Flag[flag_id].name = "TEST" - result.Flag[flag_id].enabled.value = True - result.Flag[flag_id].overridden.value = True - simple_manager.load(result) - - flags = Flags(defaults, simple_manager, {}) - assert flags.TEST is True - - result.Flag[flag_id].enabled.value = False - simple_manager.load(result) - assert flags.TEST is True - - assert flags._tracer.values == {"TEST": True} - assert flags._tracer.interval.second == 0 - assert flags._tracer.interval.microsecond == 0 - - interval_pb = Timestamp() - interval_pb.FromDatetime(flags._tracer.interval) - - stats = StatsCollector() - stats.update(flags._tracer.interval, flags._tracer.values) - - assert stats.flush(timedelta(0)) == [ - FlagUsage( - name="TEST", - interval=interval_pb, - positive_count=1, - negative_count=0, - ), - ] - - -def test_tracing_history(): - client = FeatureFlagsClient( - {"FOO": True, "BAR": False, "BAZ": True}, DummyManager() - ) - - with client.flags() as f1: - print(f1.FOO) - print(f1.BAR) - print(f1.FOO) - assert f1.__history__() == [ - ("FOO", True), - ("BAR", False), - ] - - with client.flags() as f2: - print(f2.BAR) - print(f2.FOO) - print(f2.BAR) - assert f2.__history__() == [ - ("BAR", False), - ("FOO", True), - ] - - -@pytest.mark.parametrize( - "ctx, expected", - [ - # check 1 condition - ({"v.str": "durango", "v.int": 1001}, True), - ({"v.str": "durango1", "v.int": 1001}, False), - ({"v.str": "durango", "v.int": 999}, False), - # check 2 condition - ({"v.str": "aleph-yes", "v.int": 49}, True), - ({"v.str": "aleph-yes", "v.int": 50}, False), - ({"v.str": "aleph+no", "v.int": 49}, False), - ], -) -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() - v1, v2 = f.pystr(), f.pystr() - - result = Result() - result.Root.flags.add().Flag = f1 - - result.Variable[v1].id = v1 - result.Variable[v1].name = "v.str" - result.Variable[v1].type = Variable.STRING - - result.Variable[v2].id = v2 - result.Variable[v2].name = "v.int" - result.Variable[v2].type = Variable.NUMBER - - result.Flag[f1].id = f1 - result.Flag[f1].name = "TEST" - result.Flag[f1].enabled.value = True - result.Flag[f1].overridden.value = True - result.Flag[f1].conditions.add().Condition = c1 - result.Flag[f1].conditions.add().Condition = c2 - - result.Condition[c1].id = c1 - result.Condition[c1].checks.add().Check = ch1 - result.Condition[c1].checks.add().Check = ch2 - - result.Condition[c2].id = c2 - result.Condition[c2].checks.add().Check = ch3 - result.Condition[c2].checks.add().Check = ch4 - - result.Check[ch1].id = ch1 - result.Check[ch1].variable.Variable = v1 - result.Check[ch1].operator = Check.EQUAL - result.Check[ch1].value_string = "durango" - - result.Check[ch2].id = ch2 - result.Check[ch2].variable.Variable = v2 - result.Check[ch2].operator = Check.GREATER_THAN - result.Check[ch2].value_number = 1000 - - result.Check[ch3].id = ch3 - result.Check[ch3].variable.Variable = v1 - result.Check[ch3].operator = Check.WILDCARD - result.Check[ch3].value_string = "aleph-*" - - result.Check[ch4].id = ch4 - result.Check[ch4].variable.Variable = v2 - result.Check[ch4].operator = Check.PERCENT - result.Check[ch4].value_number = 50 - - defaults = {"TEST": False} - - simple_manager.load(result) - flags = Flags(defaults, simple_manager, ctx) - assert flags.TEST is expected - - -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(simple_manager): - class Defaults(Enum): - TEST = False - - client = FeatureFlagsClient(Defaults, simple_manager) - with client.flags() as flags: - assert flags.TEST is Defaults.TEST.value - - -def test_declarative_defaults(simple_manager): - class Defaults: - _TEST = True - TEST = False - test = True - - 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(simple_manager): - with pytest.raises(TypeError) as exc: - 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, simple_manager): - with pytest.raises(TypeError) as exc: - FeatureFlagsClient({key: value}, simple_manager) - exc.match(f"Invalid flag definition: {key!r}") - - -def test_overrides(simple_manager): - class Defaults(Enum): - TEST = False - - client = FeatureFlagsClient(Defaults, simple_manager) - - with client.flags() as flags: - assert flags.TEST is False - - with client.flags(overrides={"TEST": True}) as flags: - assert flags.TEST is True - - -def test_default_true(simple_manager): - result = Result() - flag_id = f.pystr() - result.Root.flags.add().Flag = flag_id - result.Flag[flag_id].id = flag_id - result.Flag[flag_id].name = "TEST" - result.Flag[flag_id].enabled.value = False - result.Flag[flag_id].overridden.value = False - simple_manager.load(result) - - class Defaults: - TEST = True - - 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 deleted file mode 100644 index 962f94c..0000000 --- a/featureflags_client/tests/grpc/test_managers_asyncio.py +++ /dev/null @@ -1,98 +0,0 @@ -from unittest.mock import patch - -import faker -import pytest -from featureflags_protobuf import graph_pb2, service_pb2 -from google.protobuf import wrappers_pb2 -from grpclib.client import Channel - -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 - -f = faker.Faker() - - -class Defaults: - TEST = False - - -@pytest.fixture(name="variable") -def fixture_variable(): - return graph_pb2.Variable( - id=f.pystr(), name=f.pystr(), type=graph_pb2.Variable.STRING - ) - - -@pytest.fixture(name="check") -def fixture_check(variable): - return graph_pb2.Check( - id=f.pystr(), - variable=graph_pb2.Ref(Variable=variable.id), - operator=graph_pb2.Check.EQUAL, - value_string=f.pystr(), - ) - - -@pytest.fixture(name="condition") -def fixture_condition(check): - return graph_pb2.Condition( - id=f.pystr(), checks=[graph_pb2.Ref(Check=check.id)] - ) - - -@pytest.fixture(name="flag") -def fixture_flag(condition): - return graph_pb2.Flag( - id=f.pystr(), - name=f.pystr(), - enabled=wrappers_pb2.BoolValue(value=True), - overridden=wrappers_pb2.BoolValue(value=True), - conditions=[graph_pb2.Ref(Condition=condition.id)], - ) - - -@pytest.fixture(name="result") -def fixture_result(variable, flag, condition, check): - result = graph_pb2.Result() - result.Root.flags.extend([graph_pb2.Ref(Flag=flag.id)]) - result.Variable[variable.id].CopyFrom(variable) - result.Flag[flag.id].CopyFrom(flag) - result.Condition[condition.id].CopyFrom(condition) - result.Check[check.id].CopyFrom(check) - return result - - -@pytest.mark.asyncio -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)) - - async def reply(): - return service_pb2.ExchangeReply(version=1, result=result) - - with patch.object(manager._stub, "Exchange") as exchange: - exchange.return_value = reply() - await manager.preload() - exchange.assert_called_once_with( - service_pb2.ExchangeRequest( - project="aginst", - variables=[ - service_pb2.Variable( - name=variable.name, type=graph_pb2.Variable.STRING - ), - ], - query=get_grpc_graph_query("aginst"), - ), - timeout=None, - ) - - 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: - assert flags.TEST is False - with client.flags({variable.name: check.value_string}) as flags: - assert flags.TEST is True diff --git a/featureflags_client/tests/grpc/test_managers_dummy.py b/featureflags_client/tests/grpc/test_managers_dummy.py deleted file mode 100644 index a9a900d..0000000 --- a/featureflags_client/tests/grpc/test_managers_dummy.py +++ /dev/null @@ -1,14 +0,0 @@ -from featureflags_client.grpc.client import FeatureFlagsClient -from featureflags_client.grpc.managers.dummy import DummyManager - - -def test(): - manager = DummyManager() - - class Defaults: - FOO_FEATURE = False - - client = FeatureFlagsClient(Defaults, manager) - - with client.flags() as flags: - assert flags.FOO_FEATURE is False diff --git a/examples/grpc/__init__.py b/featureflags_client/tests/http/__init__.py similarity index 100% rename from examples/grpc/__init__.py rename to featureflags_client/tests/http/__init__.py diff --git a/featureflags_client/grpc/__init__.py b/featureflags_client/tests/http/managers/__init__.py similarity index 100% rename from featureflags_client/grpc/__init__.py rename to featureflags_client/tests/http/managers/__init__.py diff --git a/featureflags_client/tests/http/managers/test_async.py b/featureflags_client/tests/http/managers/test_async.py new file mode 100644 index 0000000..55db4e5 --- /dev/null +++ b/featureflags_client/tests/http/managers/test_async.py @@ -0,0 +1,64 @@ +from unittest.mock import patch + +import faker +import pytest + +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.aiohttp import AiohttpManager +from featureflags_client.http.managers.httpx import HttpxManager +from featureflags_client.http.types import Flag, PreloadFlagsResponse, Variable + +f = faker.Faker() + + +class Defaults: + TEST = False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "async_manager_class", + [ + AiohttpManager, + HttpxManager, + ], +) +async def test_manager(async_manager_class, flag, variable, check, condition): + manager = async_manager_class( + url="http://flags.server.example", + project="test", + variables=[Variable(variable.name, variable.type)], + defaults=Defaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + mock_preload_response = PreloadFlagsResponse( + version=1, + flags=[ + Flag( + name="TEST", + enabled=True, + overridden=True, + conditions=[condition], + ), + ], + ) + with patch.object(manager, "_post") as mock_post: + mock_post.return_value = mock_preload_response.to_dict() + + await client.preload_async() + mock_post.assert_called_once() + + with client.flags({variable.name: check.value}) as flags: + assert flags.TEST is True + + with client.flags({variable.name: f.pystr()}) as flags: + assert flags.TEST is False + + with client.flags({variable.name: check.value}) as flags: + assert flags.TEST is True + + # close client connection. + await manager.close() diff --git a/featureflags_client/tests/http/managers/test_dummy.py b/featureflags_client/tests/http/managers/test_dummy.py new file mode 100644 index 0000000..0515035 --- /dev/null +++ b/featureflags_client/tests/http/managers/test_dummy.py @@ -0,0 +1,51 @@ +import pytest + +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.dummy import ( + AsyncDummyManager, + DummyManager, +) + + +class Defaults: + FOO_FEATURE = False + BAR_FEATURE = True + + +def test_sync(): + manager = DummyManager( + url="", + project="test", + variables=[], + defaults=Defaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + with client.flags() as flags: + assert flags.FOO_FEATURE is False + assert flags.BAR_FEATURE is True + + +@pytest.mark.asyncio +async def test_async(): + manager = AsyncDummyManager( + url="", + project="test", + variables=[], + defaults=Defaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + await client.preload_async() + + manager.start() + + with client.flags() as flags: + assert flags.FOO_FEATURE is False + assert flags.BAR_FEATURE is True + + await manager.wait_closed() diff --git a/featureflags_client/tests/http/managers/test_sync.py b/featureflags_client/tests/http/managers/test_sync.py new file mode 100644 index 0000000..bcda3e1 --- /dev/null +++ b/featureflags_client/tests/http/managers/test_sync.py @@ -0,0 +1,63 @@ +from datetime import datetime, timedelta +from unittest.mock import patch + +import faker +import pytest + +from featureflags_client.http.client import FeatureFlagsClient +from featureflags_client.http.managers.requests import RequestsManager +from featureflags_client.http.types import Flag, PreloadFlagsResponse, Variable + +f = faker.Faker() + + +class Defaults: + TEST = False + + +@pytest.mark.parametrize( + "manager_class", + [ + RequestsManager, + ], +) +def test_manager(manager_class, flag, variable, check, condition): + manager = manager_class( + url="http://flags.server.example", + project="test", + variables=[Variable(variable.name, variable.type)], + defaults=Defaults, + request_timeout=1, + refresh_interval=1, + ) + + # Disable auto sync. + manager._next_sync = datetime.utcnow() + timedelta(hours=1) + + client = FeatureFlagsClient(manager) + + mock_preload_response = PreloadFlagsResponse( + version=1, + flags=[ + Flag( + name="TEST", + enabled=True, + overridden=True, + conditions=[condition], + ), + ], + ) + with patch.object(manager, "_post") as mock_post: + mock_post.return_value = mock_preload_response.to_dict() + + client.preload() + mock_post.assert_called_once() + + with client.flags({variable.name: check.value}) as flags: + assert flags.TEST is True + + with client.flags({variable.name: f.pystr()}) as flags: + assert flags.TEST is False + + with client.flags({variable.name: check.value}) as flags: + assert flags.TEST is True diff --git a/featureflags_client/tests/http/test_conditions.py b/featureflags_client/tests/http/test_conditions.py new file mode 100644 index 0000000..287e245 --- /dev/null +++ b/featureflags_client/tests/http/test_conditions.py @@ -0,0 +1,154 @@ +from typing import Any, Callable + +from featureflags_client.http.conditions import ( + _UNDEFINED, + OPERATIONS_MAP, + check_proc, + contains, + equal, + false, + flag_proc, + greater_or_equal, + greater_than, + less_or_equal, + less_than, + percent, + regexp, + subset, + superset, + wildcard, +) +from featureflags_client.http.types import Operator + + +def check_op(left: Any, op: Callable, right: Any) -> bool: + return op("var", right)({"var": left} if left is not _UNDEFINED else {}) + + +def test_false(): + assert false({}) is False + + +def test_equal(): + assert check_op(1, equal, 1) is True + assert check_op(2, equal, 1) is False + assert check_op(1, equal, 2) is False + assert check_op(1, equal, "1") is False + assert check_op("1", equal, 1) is False + assert check_op(_UNDEFINED, equal, 1) is False + + +def test_less_than(): + assert check_op(1, less_than, 2) is True + assert check_op(1, less_than, 1) is False + assert check_op(2, less_than, 1) is False + assert check_op(_UNDEFINED, less_than, 1) is False + assert check_op("1", less_than, 2) is False + + +def test_less_or_equal(): + assert check_op(1, less_or_equal, 2) is True + assert check_op(1, less_or_equal, 1) is True + assert check_op(2, less_or_equal, 1) is False + assert check_op(_UNDEFINED, less_or_equal, 1) is False + assert check_op("1", less_or_equal, 2) is False + + +def test_greater_than(): + assert check_op(2, greater_than, 1) is True + assert check_op(1, greater_than, 1) is False + assert check_op(1, greater_than, 2) is False + assert check_op(_UNDEFINED, greater_than, 1) is False + assert check_op("2", greater_than, 1) is False + + +def test_greater_or_equal(): + assert check_op(2, greater_or_equal, 1) is True + assert check_op(1, greater_or_equal, 1) is True + assert check_op(1, greater_or_equal, 2) is False + assert check_op(_UNDEFINED, greater_or_equal, 1) is False + assert check_op("2", greater_or_equal, 1) is False + + +def test_contains(): + assert check_op("aaa", contains, "a") is True + assert check_op("aaa", contains, "aa") is True + assert check_op("aaa", contains, "aaa") is True + assert check_op("a", contains, "aaa") is False + assert check_op("aaa", contains, "b") is False + assert check_op(_UNDEFINED, contains, "a") is False + assert check_op(1, contains, "a") is False + assert check_op("a", contains, 1) is False + + +def test_percent(): + assert check_op(0, percent, 1) is True + assert check_op(1, percent, 1) is False + assert check_op(1, percent, 2) is True + + for i in range(-150, 150): + assert check_op(i, percent, 0) is False + for i in range(-150, 150): + assert check_op(i, percent, 100) is True + + assert check_op("foo", percent, 100) is True + assert check_op("foo", percent, 0) is False + assert check_op("foo", percent, hash("foo") % 100 + 1) is True + assert check_op("foo", percent, hash("foo") % 100 - 1) is False + + assert check_op(_UNDEFINED, percent, 100) is False + + +def test_regexp(): + assert check_op("anything", regexp, ".") is True + assert check_op("kebab-style", regexp, r"\w+-\w+") is True + assert check_op("snake_style", regexp, r"\w+-\w+") is False + assert check_op(_UNDEFINED, regexp, ".") is False + assert check_op(1, regexp, ".") is False + + +def test_wildcard(): + assert check_op("foo-value", wildcard, "foo-*") is True + assert check_op("value-foo", wildcard, "*-foo") is True + assert check_op("foo-value-bar", wildcard, "foo-*-bar") is True + assert check_op("value", wildcard, "foo-*") is False + assert check_op(_UNDEFINED, wildcard, "foo-*") is False + assert check_op(1, wildcard, "foo-*") is False + + +def test_subset(): + assert check_op(set("ab"), subset, set("abc")) is True + assert check_op(set("bc"), subset, set("abc")) is True + assert check_op(set("ac"), subset, set("abc")) is True + assert check_op(set("ae"), subset, set("abc")) is False + assert check_op(_UNDEFINED, subset, set("abc")) is False + assert check_op(1, subset, set("abc")) is False + + +def test_superset(): + assert check_op(set("abc"), superset, set("ab")) is True + assert check_op(set("abc"), superset, set("bc")) is True + assert check_op(set("abc"), superset, set("ac")) is True + assert check_op(set("abc"), superset, set("ae")) is False + assert check_op(_UNDEFINED, superset, set("abc")) is False + assert check_op(1, superset, set("abc")) is False + + +def test_valid_check_proc(check, variable): + proc = check_proc(check) + assert proc({variable.name: check.value}) is True + assert proc({variable.name: ""}) is False + + +def test_supported_check_proc_ops(): + assert set(OPERATIONS_MAP) == set(Operator) + + +def test_check_proc_no_value(check): + check.value = None + assert check_proc(check) is false + + +def test_valid_flag_proc(flag, check, variable): + proc = flag_proc(flag) + assert proc({variable.name: check.value}) is True diff --git a/featureflags_client/tests/grpc/test_utils.py b/featureflags_client/tests/http/test_utils.py similarity index 95% rename from featureflags_client/tests/grpc/test_utils.py rename to featureflags_client/tests/http/test_utils.py index de6f766..dfdf289 100644 --- a/featureflags_client/tests/grpc/test_utils.py +++ b/featureflags_client/tests/http/test_utils.py @@ -1,4 +1,4 @@ -from featureflags_client.grpc.utils import intervals_gen +from featureflags_client.http.utils import intervals_gen def test_intervals_gen_from_success(): diff --git a/pdm.lock b/pdm.lock index 8d52130..b78ed94 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "docs", "examples", "lint", "test", "aiohttp", "requests"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:c7bbb6cf01030ad7838b479d4786a3836b5c8e5e1c146cfcd53743604cbc0a0b" +content_hash = "sha256:413e59de0e7c93e9e7f1dd1334cde8b2d155bdaad973e9b4be05d9e0835f4790" [[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 = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] 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 = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] dependencies = [ "frozenlist>=1.1.0", ] @@ -126,7 +126,7 @@ name = "anyio" version = "4.2.0" requires_python = ">=3.8" summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["examples"] +groups = ["examples", "test"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -153,7 +153,7 @@ name = "async-timeout" version = "4.0.3" requires_python = ">=3.7" summary = "Timeout context manager for asyncio programs" -groups = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] marker = "python_version < \"3.11\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, @@ -165,7 +165,7 @@ name = "attrs" version = "23.1.0" requires_python = ">=3.7" summary = "Classes Without Boilerplate" -groups = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] files = [ {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, @@ -245,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", "requests"] +groups = ["docs", "examples", "requests", "test"] files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, @@ -267,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", "examples", "requests"] +groups = ["docs", "examples", "requests", "test"] 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"}, @@ -464,7 +464,7 @@ name = "frozenlist" version = "1.4.1" requires_python = ">=3.8" summary = "A list-like structure which implements collections.abc.MutableSequence" -groups = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] 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"}, @@ -595,7 +595,7 @@ name = "h11" version = "0.14.0" requires_python = ">=3.7" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["examples"] +groups = ["examples", "test"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -654,7 +654,7 @@ name = "httpcore" version = "1.0.2" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["examples"] +groups = ["examples", "test"] dependencies = [ "certifi", "h11<0.15,>=0.13", @@ -707,7 +707,7 @@ name = "httpx" version = "0.26.0" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["examples"] +groups = ["examples", "test"] dependencies = [ "anyio", "certifi", @@ -736,7 +736,7 @@ name = "idna" version = "3.6" requires_python = ">=3.5" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["aiohttp", "docs", "examples", "requests"] +groups = ["aiohttp", "docs", "examples", "requests", "test"] files = [ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, @@ -1154,7 +1154,7 @@ name = "requests" version = "2.31.0" requires_python = ">=3.7" summary = "Python HTTP for Humans." -groups = ["docs", "examples", "requests"] +groups = ["docs", "examples", "requests", "test"] dependencies = [ "certifi>=2017.4.17", "charset-normalizer<4,>=2", @@ -1241,7 +1241,7 @@ name = "sniffio" version = "1.3.0" requires_python = ">=3.7" summary = "Sniff out which async library your code is running under" -groups = ["examples"] +groups = ["examples", "test"] files = [ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, @@ -1504,7 +1504,7 @@ name = "typing-extensions" version = "4.9.0" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" -groups = ["default", "examples", "lint"] +groups = ["default", "examples", "lint", "test"] 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"}, @@ -1580,7 +1580,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", "lint", "requests"] +groups = ["docs", "examples", "lint", "requests", "test"] files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, @@ -1736,7 +1736,7 @@ name = "yarl" version = "1.9.4" requires_python = ">=3.7" summary = "Yet another URL library" -groups = ["aiohttp", "examples"] +groups = ["aiohttp", "examples", "test"] dependencies = [ "idna>=2.0", "multidict>=4.0", diff --git a/pyproject.toml b/pyproject.toml index 4872a0e..4f6e4c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ test = [ "protobuf<4.0.0", "grpcio==1.59.0", "hiku==0.7.1", + "httpx>=0.24.1", + "aiohttp>=3.9.1", + "requests>=2.31.0", ] lint = [ "black>=23.3.0",