From 7a6645647d57435a9ff41b30acfb55f60c8c3595 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Mon, 12 Aug 2024 12:59:29 -0700 Subject: [PATCH 01/41] Add methods to get all feature flags. Moved over existing code from CAP and abstracted feature flag classes into generics. This allows us to get all, or filtered, feature flags in a type-safe way. --- .../caching_feature_flag_router.py | 31 +++++++- .../feature_flag/db_feature_flag_router.py | 76 ++++++++++++++++++- .../feature_flag/feature_flag_router.py | 36 ++++++++- .../feature_flags/test_feature_flag_router.py | 18 ++++- 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index bf86cb84..6393be14 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -1,11 +1,12 @@ from logging import Logger +from typing import Generic, Sequence, cast from typing_extensions import override -from .feature_flag_router import FeatureFlagRouter +from .feature_flag_router import FeatureFlag, FeatureFlagRouter, TFeatureFlag -class CachingFeatureFlagRouter(FeatureFlagRouter): +class CachingFeatureFlagRouter(Generic[TFeatureFlag], FeatureFlagRouter[TFeatureFlag]): def __init__(self, logger: Logger) -> None: self._logger: Logger = logger self._feature_flags: dict[str, bool] = {} @@ -79,3 +80,29 @@ def feature_is_cached(self, name: str): self._validate_name(name) return name in self._feature_flags + + @override + def _create_feature_flag(self, name: str, enabled: bool) -> TFeatureFlag: + return cast(TFeatureFlag, FeatureFlag(name, enabled)) + + @override + def get_feature_flags( + self, names: list[str] | None = None + ) -> Sequence[TFeatureFlag]: + """ + Get all feature flags and their status. + names: Get only the flags contained in this list. + """ + if names is None: + return tuple( + self._create_feature_flag(name=key, enabled=value) + for key, value in self._feature_flags.items() + ) + else: + return tuple( + ( + self._create_feature_flag(name=key, enabled=value) + for key, value in self._feature_flags.items() + if key in names + ) + ) diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index a3aa493d..f00fbb56 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -1,15 +1,25 @@ from abc import ABC +from dataclasses import dataclass from logging import Logger -from typing import Type, cast, overload +from typing import Sequence, Type, TypeVar, cast, overload from injector import inject -from sqlalchemy import Boolean, Column, Unicode +from sqlalchemy import Boolean, Column, String, Unicode from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.session import Session from typing_extensions import override from .caching_feature_flag_router import CachingFeatureFlagRouter +from .feature_flag_router import FeatureFlag as FeatureFlagBaseData + + +@dataclass(frozen=True) +class FeatureFlagData(FeatureFlagBaseData): + description: str | None + + +TFeatureFlagData = TypeVar("TFeatureFlagData", bound=FeatureFlagData, covariant=True) class FeatureFlag(ABC): @@ -56,8 +66,12 @@ def __repr__(self) -> str: return cast(type[FeatureFlag], _FeatureFlag) -class DBFeatureFlagRouter(CachingFeatureFlagRouter): +class DBFeatureFlagRouter( + CachingFeatureFlagRouter[TFeatureFlagData] +): # [FeatureFlagData]): + # The SQLAlchemy table type used for querying from the type[FeatureFlag] database table _feature_flag: type[FeatureFlag] + # The SQLAlchemy session used for connecting to and querying the database _session: Session @inject @@ -146,3 +160,59 @@ def feature_is_enabled( super().set_feature_is_enabled(name, is_enabled) return is_enabled + + @override + def _create_feature_flag( + self, name: str, enabled: bool, description: str | None = None + ) -> TFeatureFlagData: + parent_feature_flag = super()._create_feature_flag(name, enabled) + return cast( + TFeatureFlagData, + FeatureFlagData( + parent_feature_flag.name, parent_feature_flag.enabled, description + ), + ) + + @override + def get_feature_flags( + self, names: list[str] | None = None + ) -> Sequence[TFeatureFlagData]: + """ + Get all feature flags and their status from the database. + This methods updates the cache to the values retrieved from the database. + names: Get only the flags contained in this list. + """ + if names is None: + all_feature_flags = self._session.query(self._feature_flag).all() + + # needed to circumvent list comprehension scope + # https://docs.python.org/3/reference/datamodel.html#class-object-creation + _super = super() + [ + _super.set_feature_is_enabled( + cast(str, feature_flag.name), cast(bool, feature_flag.enabled) + ) + for feature_flag in all_feature_flags + ] + + return super().get_feature_flags() + else: + db_feature_flags = ( + self._session.query(self._feature_flag) + .filter(cast(Column[String], self._feature_flag.name).in_(names)) + .all() + ) + + feature_flags = tuple( + self._create_feature_flag( + name=cast(str, db_feature_flag.name), + enabled=cast(bool, db_feature_flag.enabled), + description=cast(str, db_feature_flag.description), + ) + for db_feature_flag in db_feature_flags + ) + + for feature_flag in feature_flags: + super().set_feature_is_enabled(feature_flag.name, feature_flag.enabled) + + return feature_flags diff --git a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py index d66ac562..2082a91e 100644 --- a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py @@ -1,7 +1,18 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Generic, Sequence, TypeVar -class FeatureFlagRouter(ABC): +@dataclass(frozen=True) +class FeatureFlag: + name: str + enabled: bool + + +TFeatureFlag = TypeVar("TFeatureFlag", bound=FeatureFlag, covariant=True) + + +class FeatureFlagRouter(Generic[TFeatureFlag], ABC): """ The base feature flag router. All feature flag routers should extend this class. @@ -38,3 +49,26 @@ def feature_is_enabled(self, name: str, default: bool = False) -> bool: :param bool default: A default value to return for cases where a feature flag may not exist. Defaults to False. :return bool: If `True`, the feature is enabled. If `False`, the feature is disabled. """ + + @abstractmethod + def _create_feature_flag(self, name: str, enabled: bool) -> FeatureFlag: + """ + Subclasses should override this in order to instantiate type-safe + instances of `TFeatureFlag` to any other `FeatureFlag` subclasses + in the type hierarchy. + + :param str name: _description_ + :param bool enabled: _description_ + :return TFeatureFlag: An instance of `TFeatureFlag` + """ + + @abstractmethod + def get_feature_flags( + self, names: list[str] | None = None + ) -> Sequence[TFeatureFlag]: + """ + Get all feature flags and whether they are enabled. + If `names` is not `None`, this only returns the enabled state of the flags in the list. + + :param list[str] | None names: Get only the flags contained in this list. + """ diff --git a/src/platform/test/unit/feature_flags/test_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_feature_flag_router.py index f6e92d5e..8697c8a3 100644 --- a/src/platform/test/unit/feature_flags/test_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_feature_flag_router.py @@ -1,13 +1,17 @@ import ast import inspect +from typing import Sequence -from Ligare.platform.feature_flag.feature_flag_router import FeatureFlagRouter +from Ligare.platform.feature_flag.feature_flag_router import ( + FeatureFlag, + FeatureFlagRouter, +) from typing_extensions import override _FEATURE_FLAG_TEST_NAME = "foo_feature" -class TestFeatureFlagRouter(FeatureFlagRouter): +class TestFeatureFlagRouter(FeatureFlagRouter[FeatureFlag]): @override def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: return super().set_feature_is_enabled(name, is_enabled) @@ -16,6 +20,16 @@ def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: def feature_is_enabled(self, name: str, default: bool = False) -> bool: return super().feature_is_enabled(name, default) + @override + def _create_feature_flag(self, name: str, enabled: bool) -> FeatureFlag: + return super()._create_feature_flag(name, enabled) + + @override + def get_feature_flags( + self, names: list[str] | None = None + ) -> Sequence[FeatureFlag]: + return super().get_feature_flags(names) + class NotifyingFeatureFlagRouter(TestFeatureFlagRouter): def __init__(self) -> None: From 9b6d7885a51658d27b9c9a339a8fcfe5ba4d7048 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Mon, 12 Aug 2024 15:14:12 -0700 Subject: [PATCH 02/41] Add tests and update const names. --- .../feature_flag/db_feature_flag_router.py | 28 ++-- .../test_caching_feature_flag_router.py | 135 ++++++++++++++---- .../test_db_feature_flag_router.py | 78 +++++----- 3 files changed, 169 insertions(+), 72 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index f00fbb56..859c9807 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -15,14 +15,14 @@ @dataclass(frozen=True) -class FeatureFlagData(FeatureFlagBaseData): +class FeatureFlag(FeatureFlagBaseData): description: str | None -TFeatureFlagData = TypeVar("TFeatureFlagData", bound=FeatureFlagData, covariant=True) +TFeatureFlag = TypeVar("TFeatureFlag", bound=FeatureFlag, covariant=True) -class FeatureFlag(ABC): +class FeatureFlagTableBase(ABC): def __init__( # pyright: ignore[reportMissingSuperCall] self, /, @@ -31,7 +31,7 @@ def __init__( # pyright: ignore[reportMissingSuperCall] enabled: bool | None = False, ) -> None: raise NotImplementedError( - f"`{FeatureFlag.__class__.__name__}` should only be used for type checking." + f"`{FeatureFlagTableBase.__class__.__name__}` should only be used for type checking." ) __tablename__: str @@ -41,7 +41,7 @@ def __init__( # pyright: ignore[reportMissingSuperCall] class FeatureFlagTable: - def __new__(cls, base: Type[DeclarativeMeta]) -> type[FeatureFlag]: + def __new__(cls, base: Type[DeclarativeMeta]) -> type[FeatureFlagTableBase]: class _FeatureFlag(base): """ A feature flag. @@ -63,20 +63,20 @@ class _FeatureFlag(base): def __repr__(self) -> str: return "" % (self.name) - return cast(type[FeatureFlag], _FeatureFlag) + return cast(type[FeatureFlagTableBase], _FeatureFlag) class DBFeatureFlagRouter( - CachingFeatureFlagRouter[TFeatureFlagData] + CachingFeatureFlagRouter[TFeatureFlag] ): # [FeatureFlagData]): # The SQLAlchemy table type used for querying from the type[FeatureFlag] database table - _feature_flag: type[FeatureFlag] + _feature_flag: type[FeatureFlagTableBase] # The SQLAlchemy session used for connecting to and querying the database _session: Session @inject def __init__( - self, feature_flag: type[FeatureFlag], session: Session, logger: Logger + self, feature_flag: type[FeatureFlagTableBase], session: Session, logger: Logger ) -> None: self._feature_flag = feature_flag self._session = session @@ -101,7 +101,7 @@ def set_feature_is_enabled(self, name: str, is_enabled: bool): if not name: raise ValueError("`name` parameter is required and cannot be empty.") - feature_flag: FeatureFlag + feature_flag: FeatureFlagTableBase try: feature_flag = ( self._session.query(self._feature_flag) @@ -164,11 +164,11 @@ def feature_is_enabled( @override def _create_feature_flag( self, name: str, enabled: bool, description: str | None = None - ) -> TFeatureFlagData: + ) -> TFeatureFlag: parent_feature_flag = super()._create_feature_flag(name, enabled) return cast( - TFeatureFlagData, - FeatureFlagData( + TFeatureFlag, + FeatureFlag( parent_feature_flag.name, parent_feature_flag.enabled, description ), ) @@ -176,7 +176,7 @@ def _create_feature_flag( @override def get_feature_flags( self, names: list[str] | None = None - ) -> Sequence[TFeatureFlagData]: + ) -> Sequence[TFeatureFlag]: """ Get all feature flags and their status from the database. This methods updates the cache to the values retrieved from the database. diff --git a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py index 14e708f7..3b25d9ca 100644 --- a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py @@ -5,15 +5,17 @@ from Ligare.platform.feature_flag.caching_feature_flag_router import ( CachingFeatureFlagRouter, ) +from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlag from mock import MagicMock from pytest import LogCaptureFixture _FEATURE_FLAG_TEST_NAME = "foo_feature" +_FEATURE_FLAG_LOGGER_NAME = "FeatureFlagLogger" def test__feature_is_enabled__disallows_empty_name(): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(ValueError): _ = caching_feature_flag_router.feature_is_enabled("") @@ -21,16 +23,16 @@ def test__feature_is_enabled__disallows_empty_name(): @pytest.mark.parametrize("name", [0, False, True, {}, [], (0,)]) def test__feature_is_enabled__disallows_non_string_names(name: Any): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(TypeError): _ = caching_feature_flag_router.feature_is_enabled(name) def test__set_feature_is_enabled__disallows_empty_name(): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(ValueError): caching_feature_flag_router.set_feature_is_enabled("", False) @@ -38,8 +40,8 @@ def test__set_feature_is_enabled__disallows_empty_name(): @pytest.mark.parametrize("name", [0, False, True, {}, [], (0,)]) def test__set_feature_is_enabled__disallows_non_string_names(name: Any): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(TypeError): caching_feature_flag_router.set_feature_is_enabled(name, False) @@ -47,8 +49,8 @@ def test__set_feature_is_enabled__disallows_non_string_names(name: Any): @pytest.mark.parametrize("value", [None, "", "False", "True", 0, 1, -1, {}, [], (0,)]) def test__set_feature_is_enabled__disallows_non_bool_values(value: Any): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(TypeError) as e: _ = caching_feature_flag_router.set_feature_is_enabled( @@ -60,8 +62,8 @@ def test__set_feature_is_enabled__disallows_non_bool_values(value: Any): @pytest.mark.parametrize("value", [True, False]) def test__set_feature_is_enabled__sets_correct_value(value: bool): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) is_enabled = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -70,8 +72,8 @@ def test__set_feature_is_enabled__sets_correct_value(value: bool): def test__feature_is_enabled__defaults_to_false_when_flag_does_not_exist(): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) is_enabled = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -80,8 +82,8 @@ def test__feature_is_enabled__defaults_to_false_when_flag_does_not_exist(): @pytest.mark.parametrize("value", [True, False]) def test__set_feature_is_enabled__caches_new_flag(value: bool): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) assert _FEATURE_FLAG_TEST_NAME in caching_feature_flag_router._feature_flags # pyright: ignore[reportPrivateUsage] @@ -91,8 +93,8 @@ def test__set_feature_is_enabled__caches_new_flag(value: bool): @pytest.mark.parametrize("value", [True, False]) def test__feature_is_enabled__uses_cache(value: bool): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) mock_dict = MagicMock() caching_feature_flag_router._feature_flags = mock_dict # pyright: ignore[reportPrivateUsage] @@ -106,8 +108,8 @@ def test__feature_is_enabled__uses_cache(value: bool): @pytest.mark.parametrize("enable", [True, False]) def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set(enable: bool): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) _ = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -132,8 +134,8 @@ def test__set_feature_is_enabled__notifies_when_setting_new_flag( value: bool, caplog: LogCaptureFixture, ): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) @@ -173,8 +175,8 @@ def test__set_feature_is_enabled__notifies_when_changing_flag( expected_log_msg: str, caplog: LogCaptureFixture, ): - logger = logging.getLogger("FeatureFlagLogger") - caching_feature_flag_router = CachingFeatureFlagRouter(logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) caching_feature_flag_router.set_feature_is_enabled( _FEATURE_FLAG_TEST_NAME, first_value @@ -184,3 +186,88 @@ def test__set_feature_is_enabled__notifies_when_changing_flag( ) assert expected_log_msg in {record.msg for record in caplog.records} + + +@pytest.mark.parametrize("value", [True, False]) +def test__feature_is_cached__correctly_determines_whether_value_is_cached(value: bool): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + + feature_is_cached = caching_feature_flag_router.feature_is_cached( + _FEATURE_FLAG_TEST_NAME + ) + + assert feature_is_cached + + +def test___create_feature_flag__returns_correct_TFeatureFlag(): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + feature_flag = caching_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] + _FEATURE_FLAG_TEST_NAME, True + ) + + assert isinstance(feature_flag, FeatureFlag) + + +@pytest.mark.parametrize("value", [True, False]) +def test___create_feature_flag__creates_correct_TFeatureFlag(value: bool): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + feature_flag = caching_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] + _FEATURE_FLAG_TEST_NAME, value + ) + + assert feature_flag.name == _FEATURE_FLAG_TEST_NAME + assert feature_flag.enabled == value + + +def test__get_feature_flags__returns_empty_sequence_when_no_flags_exist(): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + feature_flags = caching_feature_flag_router.get_feature_flags() + + assert isinstance(feature_flags, tuple) + assert not feature_flags + + +def test__get_feature_flags__returns_all_existing_flags(): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + FLAG_COUNT = 4 + + for i in range(FLAG_COUNT): + caching_feature_flag_router.set_feature_is_enabled( + f"{_FEATURE_FLAG_TEST_NAME}{i}", (i % 2) == 0 + ) + + feature_flags = caching_feature_flag_router.get_feature_flags() + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == FLAG_COUNT + + +def test__get_feature_flags__returns_filtered_flags(): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + FLAG_COUNT = 4 + FILTERED_FLAG_NAME = f"{_FEATURE_FLAG_TEST_NAME}2" + + for i in range(FLAG_COUNT): + caching_feature_flag_router.set_feature_is_enabled( + f"{_FEATURE_FLAG_TEST_NAME}{i}", (i % 2) == 0 + ) + + feature_flags = caching_feature_flag_router.get_feature_flags([FILTERED_FLAG_NAME]) + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == 1 + assert feature_flags[0].name == FILTERED_FLAG_NAME + assert feature_flags[0].enabled # (2 % 2) == 0 ## - True diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index 7288fbeb..a517a51b 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -6,6 +6,7 @@ from Ligare.database.dependency_injection import ScopedSessionModule from Ligare.platform.feature_flag.db_feature_flag_router import ( DBFeatureFlagRouter, + FeatureFlag, FeatureFlagTable, ) from Ligare.programming.dependency_injection import ConfigModule @@ -15,6 +16,7 @@ _FEATURE_FLAG_TEST_NAME = "foo_feature" _FEATURE_FLAG_TEST_DESCRIPTION = "foo description" +_FEATURE_FLAG_LOGGER_NAME = "FeatureFlagLogger" class PlatformMetaBase(DeclarativeMeta): @@ -28,7 +30,7 @@ class PlatformBase(object): from Ligare.database.testing.config import inmemory_database_config PlatformBase = declarative_base(cls=PlatformBase, metaclass=PlatformMetaBase) -FeatureFlag = FeatureFlagTable(PlatformBase) +FeatureFlagTableBase = FeatureFlagTable(PlatformBase) @pytest.fixture() @@ -47,7 +49,7 @@ def feature_flag_session(): def _create_feature_flag(session: Session): session.add( - FeatureFlag( + FeatureFlagTableBase( name=_FEATURE_FLAG_TEST_NAME, description=_FEATURE_FLAG_TEST_DESCRIPTION ) ) @@ -55,9 +57,9 @@ def _create_feature_flag(session: Session): def test__feature_is_enabled__defaults_to_false(feature_flag_session: Session): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) _create_feature_flag(feature_flag_session) @@ -72,9 +74,9 @@ def test__feature_is_enabled__uses_default_when_flag_does_not_exist( default: bool, feature_flag_session: Session, ): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) is_enabled = db_feature_flag_router.feature_is_enabled( @@ -85,9 +87,9 @@ def test__feature_is_enabled__uses_default_when_flag_does_not_exist( def test__feature_is_enabled__disallows_empty_name(feature_flag_session: Session): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) with pytest.raises(ValueError): @@ -98,9 +100,9 @@ def test__feature_is_enabled__disallows_empty_name(feature_flag_session: Session def test__feature_is_enabled__disallows_non_string_names( name: Any, feature_flag_session: Session ): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) with pytest.raises(TypeError): @@ -110,9 +112,9 @@ def test__feature_is_enabled__disallows_non_string_names( def test__set_feature_is_enabled__fails_when_flag_does_not_exist( feature_flag_session: Session, ): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) with pytest.raises(LookupError): @@ -120,9 +122,9 @@ def test__set_feature_is_enabled__fails_when_flag_does_not_exist( def test__set_feature_is_enabled__disallows_empty_name(feature_flag_session: Session): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) _create_feature_flag(feature_flag_session) @@ -134,9 +136,9 @@ def test__set_feature_is_enabled__disallows_empty_name(feature_flag_session: Ses def test__set_feature_is_enabled__disallows_non_string_names( name: Any, feature_flag_session: Session ): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) with pytest.raises(TypeError): @@ -147,9 +149,9 @@ def test__set_feature_is_enabled__disallows_non_string_names( def test__set_feature_is_enabled__sets_correct_value( enable: bool, feature_flag_session: Session ): - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter( - FeatureFlag, feature_flag_session, logger + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger ) _create_feature_flag(feature_flag_session) @@ -165,8 +167,10 @@ def test__set_feature_is_enabled__caches_flags(enable: bool, mocker: MockerFixtu session_query_mock = mocker.patch("sqlalchemy.orm.session.Session.query") session_mock.query = session_query_mock - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter(FeatureFlag, session_mock, logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, session_mock, logger + ) db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -187,8 +191,10 @@ def test__feature_is_enabled__checks_cache( "Ligare.platform.feature_flag.caching_feature_flag_router.CachingFeatureFlagRouter.set_feature_is_enabled" ) - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter(FeatureFlag, session_mock, logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, session_mock, logger + ) _ = db_feature_flag_router.feature_is_enabled( _FEATURE_FLAG_TEST_NAME, False, check_cache[0] @@ -210,8 +216,10 @@ def test__feature_is_enabled__sets_cache( ) feature_is_enabled_mock.return_value = True - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter(FeatureFlag, session_mock, logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, session_mock, logger + ) _ = db_feature_flag_router.feature_is_enabled( _FEATURE_FLAG_TEST_NAME, False, check_cache[0] @@ -228,8 +236,10 @@ def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set( session_query_mock = mocker.patch("sqlalchemy.orm.session.Session.query") session_mock.query = session_query_mock - logger = logging.getLogger("FeatureFlagLogger") - db_feature_flag_router = DBFeatureFlagRouter(FeatureFlag, session_mock, logger) + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, session_mock, logger + ) db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) From d9ae007d7d2c036d912a7c773a9f78b9d3bec838 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 13 Aug 2024 10:19:12 -0700 Subject: [PATCH 03/41] Add tests for feature flag filtering from getter. --- .../feature_flag/db_feature_flag_router.py | 4 +- .../test_db_feature_flag_router.py | 180 +++++++++++++++++- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index 859c9807..945f7080 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -66,9 +66,7 @@ def __repr__(self) -> str: return cast(type[FeatureFlagTableBase], _FeatureFlag) -class DBFeatureFlagRouter( - CachingFeatureFlagRouter[TFeatureFlag] -): # [FeatureFlagData]): +class DBFeatureFlagRouter(CachingFeatureFlagRouter[TFeatureFlag]): # The SQLAlchemy table type used for querying from the type[FeatureFlag] database table _feature_flag: type[FeatureFlagTableBase] # The SQLAlchemy session used for connecting to and querying the database diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index a517a51b..00fcd64f 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -47,10 +47,15 @@ def feature_flag_session(): return session -def _create_feature_flag(session: Session): +def _create_feature_flag( + session: Session, name: str | None = None, description: str | None = None +): session.add( FeatureFlagTableBase( - name=_FEATURE_FLAG_TEST_NAME, description=_FEATURE_FLAG_TEST_DESCRIPTION + name=_FEATURE_FLAG_TEST_NAME if name is None else name, + description=_FEATURE_FLAG_TEST_DESCRIPTION + if description is None + else description, ) ) session.commit() @@ -252,3 +257,174 @@ def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set( assert session_query_mock.call_count == 2 assert first_value == enable assert second_value == (not enable) + + +def test___create_feature_flag__returns_correct_TFeatureFlag( + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + feature_flag = db_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] + _FEATURE_FLAG_TEST_NAME, True + ) + + assert isinstance(feature_flag, FeatureFlag) + + +@pytest.mark.parametrize("value", [True, False]) +def test___create_feature_flag__creates_correct_TFeatureFlag( + value: bool, + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + feature_flag = db_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] + _FEATURE_FLAG_TEST_NAME, value + ) + + assert feature_flag.name == _FEATURE_FLAG_TEST_NAME + assert feature_flag.enabled == value + + +def test__get_feature_flags__returns_empty_sequence_when_no_flags_exist( + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + feature_flags = db_feature_flag_router.get_feature_flags() + + assert isinstance(feature_flags, tuple) + assert not feature_flags + + +@pytest.mark.parametrize( + "added_flags,filtered_flags", + [ + [{f"{_FEATURE_FLAG_TEST_NAME}2": True}, [f"{_FEATURE_FLAG_TEST_NAME}1"]], + [{f"{_FEATURE_FLAG_TEST_NAME}2": False}, [f"{_FEATURE_FLAG_TEST_NAME}3"]], + [ + { + f"{_FEATURE_FLAG_TEST_NAME}1": True, + f"{_FEATURE_FLAG_TEST_NAME}1": False, + f"{_FEATURE_FLAG_TEST_NAME}2": True, + f"{_FEATURE_FLAG_TEST_NAME}2": False, + f"{_FEATURE_FLAG_TEST_NAME}3": True, + f"{_FEATURE_FLAG_TEST_NAME}3": False, + }, + [f"{_FEATURE_FLAG_TEST_NAME}4", f"{_FEATURE_FLAG_TEST_NAME}5"], + ], + ], +) +def test__get_feature_flags__returns_empty_sequence_when_flags_exist_but_filtered_list_does_not_exist( + added_flags: dict[str, bool], + filtered_flags: list[str], + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + for flag_name, enabled in added_flags.items(): + _create_feature_flag(feature_flag_session, flag_name) + db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + + feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == 0 + + +@pytest.mark.parametrize( + "added_flags", + [ + {f"{_FEATURE_FLAG_TEST_NAME}2": True}, + {f"{_FEATURE_FLAG_TEST_NAME}2": False}, + { + f"{_FEATURE_FLAG_TEST_NAME}1": True, + f"{_FEATURE_FLAG_TEST_NAME}1": False, + f"{_FEATURE_FLAG_TEST_NAME}2": True, + f"{_FEATURE_FLAG_TEST_NAME}2": False, + f"{_FEATURE_FLAG_TEST_NAME}3": True, + f"{_FEATURE_FLAG_TEST_NAME}3": False, + }, + ], +) +def test__get_feature_flags__returns_all_existing_flags( + added_flags: dict[str, bool], + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + for flag_name, enabled in added_flags.items(): + _create_feature_flag(feature_flag_session, flag_name) + db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + + feature_flags = db_feature_flag_router.get_feature_flags() + feature_flags_dict = { + feature_flag.name: feature_flag.enabled for feature_flag in feature_flags + } + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == len(added_flags) + + for filtered_flag in feature_flags_dict: + assert filtered_flag in feature_flags_dict + assert feature_flags_dict[filtered_flag] == added_flags[filtered_flag] + + +@pytest.mark.parametrize( + "added_flags,filtered_flags", + [ + [{f"{_FEATURE_FLAG_TEST_NAME}2": True}, [f"{_FEATURE_FLAG_TEST_NAME}2"]], + [{f"{_FEATURE_FLAG_TEST_NAME}2": False}, [f"{_FEATURE_FLAG_TEST_NAME}2"]], + [ + { + f"{_FEATURE_FLAG_TEST_NAME}1": True, + f"{_FEATURE_FLAG_TEST_NAME}1": False, + f"{_FEATURE_FLAG_TEST_NAME}2": True, + f"{_FEATURE_FLAG_TEST_NAME}2": False, + f"{_FEATURE_FLAG_TEST_NAME}3": True, + f"{_FEATURE_FLAG_TEST_NAME}3": False, + }, + [f"{_FEATURE_FLAG_TEST_NAME}1", f"{_FEATURE_FLAG_TEST_NAME}2"], + ], + ], +) +def test__get_feature_flags__returns_filtered_flags( + added_flags: dict[str, bool], + filtered_flags: list[str], + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + for flag_name, enabled in added_flags.items(): + _create_feature_flag(feature_flag_session, flag_name) + db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + + feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) + feature_flags_dict = { + feature_flag.name: feature_flag.enabled for feature_flag in feature_flags + } + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == len(filtered_flags) + + for filtered_flag in filtered_flags: + assert filtered_flag in feature_flags_dict + assert feature_flags_dict[filtered_flag] == added_flags[filtered_flag] From f0d5a6ebf2c5a70a64e55e4408012abfeb05c01f Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 13 Aug 2024 11:21:05 -0700 Subject: [PATCH 04/41] Fix error with database feature flags not returning descriptions correctly in `get_feature_flags`. Also added clearer docblocks to feature flag functions. --- .../caching_feature_flag_router.py | 5 +- .../feature_flag/db_feature_flag_router.py | 41 ++++---- .../feature_flag/feature_flag_router.py | 2 + .../test_db_feature_flag_router.py | 93 ++++++++++--------- 4 files changed, 72 insertions(+), 69 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index 6393be14..8b68898b 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -91,7 +91,10 @@ def get_feature_flags( ) -> Sequence[TFeatureFlag]: """ Get all feature flags and their status. - names: Get only the flags contained in this list. + + :params list[str] | None names: Get only the flags contained in this list. + :return tuple[TFeatureFlag]: An immutable sequence (a tuple) of feature flags. + If `names` is `None` this sequence contains _all_ feature flags in the cache. Otherwise, the list is filtered. """ if names is None: return tuple( diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index 945f7080..2a296140 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -178,22 +178,14 @@ def get_feature_flags( """ Get all feature flags and their status from the database. This methods updates the cache to the values retrieved from the database. - names: Get only the flags contained in this list. + + :param list[str] | None names: Get only the flags contained in this list. + :return tuple[TFeatureFlag]: An immutable sequence (a tuple) of feature flags. + If `names` is `None` this sequence contains _all_ feature flags in the database. Otherwise, the list is filtered. """ + db_feature_flags: list[FeatureFlagTableBase] if names is None: - all_feature_flags = self._session.query(self._feature_flag).all() - - # needed to circumvent list comprehension scope - # https://docs.python.org/3/reference/datamodel.html#class-object-creation - _super = super() - [ - _super.set_feature_is_enabled( - cast(str, feature_flag.name), cast(bool, feature_flag.enabled) - ) - for feature_flag in all_feature_flags - ] - - return super().get_feature_flags() + db_feature_flags = self._session.query(self._feature_flag).all() else: db_feature_flags = ( self._session.query(self._feature_flag) @@ -201,16 +193,17 @@ def get_feature_flags( .all() ) - feature_flags = tuple( - self._create_feature_flag( - name=cast(str, db_feature_flag.name), - enabled=cast(bool, db_feature_flag.enabled), - description=cast(str, db_feature_flag.description), - ) - for db_feature_flag in db_feature_flags + feature_flags = tuple( + self._create_feature_flag( + name=cast(str, feature_flag.name), + enabled=cast(bool, feature_flag.enabled), + description=cast(str, feature_flag.description), ) + for feature_flag in db_feature_flags + ) - for feature_flag in feature_flags: - super().set_feature_is_enabled(feature_flag.name, feature_flag.enabled) + # cache the feature flags + for feature_flag in feature_flags: + super().set_feature_is_enabled(feature_flag.name, feature_flag.enabled) - return feature_flags + return feature_flags diff --git a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py index 2082a91e..d931e79a 100644 --- a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py @@ -71,4 +71,6 @@ def get_feature_flags( If `names` is not `None`, this only returns the enabled state of the flags in the list. :param list[str] | None names: Get only the flags contained in this list. + :return tuple[TFeatureFlag]: An immutable sequence (a tuple) of feature flags. + If `names` is `None` this sequence contains _all_ feature flags. Otherwise, the list is filtered. """ diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index 00fcd64f..53f0a86b 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -268,7 +268,7 @@ def test___create_feature_flag__returns_correct_TFeatureFlag( ) feature_flag = db_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] - _FEATURE_FLAG_TEST_NAME, True + _FEATURE_FLAG_TEST_NAME, True, _FEATURE_FLAG_TEST_DESCRIPTION ) assert isinstance(feature_flag, FeatureFlag) @@ -285,10 +285,11 @@ def test___create_feature_flag__creates_correct_TFeatureFlag( ) feature_flag = db_feature_flag_router._create_feature_flag( # pyright: ignore[reportPrivateUsage] - _FEATURE_FLAG_TEST_NAME, value + _FEATURE_FLAG_TEST_NAME, value, _FEATURE_FLAG_TEST_DESCRIPTION ) assert feature_flag.name == _FEATURE_FLAG_TEST_NAME + assert feature_flag.description == _FEATURE_FLAG_TEST_DESCRIPTION assert feature_flag.enabled == value @@ -306,44 +307,6 @@ def test__get_feature_flags__returns_empty_sequence_when_no_flags_exist( assert not feature_flags -@pytest.mark.parametrize( - "added_flags,filtered_flags", - [ - [{f"{_FEATURE_FLAG_TEST_NAME}2": True}, [f"{_FEATURE_FLAG_TEST_NAME}1"]], - [{f"{_FEATURE_FLAG_TEST_NAME}2": False}, [f"{_FEATURE_FLAG_TEST_NAME}3"]], - [ - { - f"{_FEATURE_FLAG_TEST_NAME}1": True, - f"{_FEATURE_FLAG_TEST_NAME}1": False, - f"{_FEATURE_FLAG_TEST_NAME}2": True, - f"{_FEATURE_FLAG_TEST_NAME}2": False, - f"{_FEATURE_FLAG_TEST_NAME}3": True, - f"{_FEATURE_FLAG_TEST_NAME}3": False, - }, - [f"{_FEATURE_FLAG_TEST_NAME}4", f"{_FEATURE_FLAG_TEST_NAME}5"], - ], - ], -) -def test__get_feature_flags__returns_empty_sequence_when_flags_exist_but_filtered_list_does_not_exist( - added_flags: dict[str, bool], - filtered_flags: list[str], - feature_flag_session: Session, -): - logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) - db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( - FeatureFlagTableBase, feature_flag_session, logger - ) - - for flag_name, enabled in added_flags.items(): - _create_feature_flag(feature_flag_session, flag_name) - db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) - - feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) - - assert isinstance(feature_flags, tuple) - assert len(feature_flags) == 0 - - @pytest.mark.parametrize( "added_flags", [ @@ -374,7 +337,8 @@ def test__get_feature_flags__returns_all_existing_flags( feature_flags = db_feature_flag_router.get_feature_flags() feature_flags_dict = { - feature_flag.name: feature_flag.enabled for feature_flag in feature_flags + feature_flag.name: (feature_flag.enabled, feature_flag.description) + for feature_flag in feature_flags } assert isinstance(feature_flags, tuple) @@ -382,7 +346,8 @@ def test__get_feature_flags__returns_all_existing_flags( for filtered_flag in feature_flags_dict: assert filtered_flag in feature_flags_dict - assert feature_flags_dict[filtered_flag] == added_flags[filtered_flag] + assert feature_flags_dict[filtered_flag][0] == added_flags[filtered_flag] + assert feature_flags_dict[filtered_flag][1] == _FEATURE_FLAG_TEST_DESCRIPTION @pytest.mark.parametrize( @@ -419,7 +384,8 @@ def test__get_feature_flags__returns_filtered_flags( feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) feature_flags_dict = { - feature_flag.name: feature_flag.enabled for feature_flag in feature_flags + feature_flag.name: (feature_flag.enabled, feature_flag.description) + for feature_flag in feature_flags } assert isinstance(feature_flags, tuple) @@ -427,4 +393,43 @@ def test__get_feature_flags__returns_filtered_flags( for filtered_flag in filtered_flags: assert filtered_flag in feature_flags_dict - assert feature_flags_dict[filtered_flag] == added_flags[filtered_flag] + assert feature_flags_dict[filtered_flag][0] == added_flags[filtered_flag] + assert feature_flags_dict[filtered_flag][1] == _FEATURE_FLAG_TEST_DESCRIPTION + + +@pytest.mark.parametrize( + "added_flags,filtered_flags", + [ + [{f"{_FEATURE_FLAG_TEST_NAME}2": True}, [f"{_FEATURE_FLAG_TEST_NAME}1"]], + [{f"{_FEATURE_FLAG_TEST_NAME}2": False}, [f"{_FEATURE_FLAG_TEST_NAME}3"]], + [ + { + f"{_FEATURE_FLAG_TEST_NAME}1": True, + f"{_FEATURE_FLAG_TEST_NAME}1": False, + f"{_FEATURE_FLAG_TEST_NAME}2": True, + f"{_FEATURE_FLAG_TEST_NAME}2": False, + f"{_FEATURE_FLAG_TEST_NAME}3": True, + f"{_FEATURE_FLAG_TEST_NAME}3": False, + }, + [f"{_FEATURE_FLAG_TEST_NAME}4", f"{_FEATURE_FLAG_TEST_NAME}5"], + ], + ], +) +def test__get_feature_flags__returns_empty_sequence_when_flags_exist_but_filtered_list_items_do_not_exist( + added_flags: dict[str, bool], + filtered_flags: list[str], + feature_flag_session: Session, +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + for flag_name, enabled in added_flags.items(): + _create_feature_flag(feature_flag_session, flag_name) + db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + + feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) + + assert isinstance(feature_flags, tuple) + assert len(feature_flags) == 0 From a38123dee138dcbdd1ef4edb1eaa0b5706ce7a32 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 13 Aug 2024 13:47:09 -0700 Subject: [PATCH 05/41] Ensure `get_feature_flags` in DB updates cache. --- .../test_caching_feature_flag_router.py | 2 +- .../test_db_feature_flag_router.py | 74 ++++++++++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py index 3b25d9ca..5847c1c6 100644 --- a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py @@ -5,7 +5,7 @@ from Ligare.platform.feature_flag.caching_feature_flag_router import ( CachingFeatureFlagRouter, ) -from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlag +from Ligare.platform.feature_flag.feature_flag_router import FeatureFlag from mock import MagicMock from pytest import LogCaptureFixture diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index 53f0a86b..cd0afce2 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -1,11 +1,16 @@ import logging -from typing import Any, Tuple +from typing import Any, Tuple, cast import pytest from Ligare.database.config import DatabaseConfig from Ligare.database.dependency_injection import ScopedSessionModule +from Ligare.platform.feature_flag import caching_feature_flag_router +from Ligare.platform.feature_flag.caching_feature_flag_router import ( + CachingFeatureFlagRouter, +) from Ligare.platform.feature_flag.db_feature_flag_router import ( - DBFeatureFlagRouter, +from Ligare.database.config import DatabaseConfig +from LigarereFlagRouter, FeatureFlag, FeatureFlagTable, ) @@ -314,11 +319,11 @@ def test__get_feature_flags__returns_empty_sequence_when_no_flags_exist( {f"{_FEATURE_FLAG_TEST_NAME}2": False}, { f"{_FEATURE_FLAG_TEST_NAME}1": True, - f"{_FEATURE_FLAG_TEST_NAME}1": False, - f"{_FEATURE_FLAG_TEST_NAME}2": True, f"{_FEATURE_FLAG_TEST_NAME}2": False, f"{_FEATURE_FLAG_TEST_NAME}3": True, - f"{_FEATURE_FLAG_TEST_NAME}3": False, + f"{_FEATURE_FLAG_TEST_NAME}4": False, + f"{_FEATURE_FLAG_TEST_NAME}5": True, + f"{_FEATURE_FLAG_TEST_NAME}6": False, }, ], ) @@ -358,11 +363,11 @@ def test__get_feature_flags__returns_all_existing_flags( [ { f"{_FEATURE_FLAG_TEST_NAME}1": True, - f"{_FEATURE_FLAG_TEST_NAME}1": False, - f"{_FEATURE_FLAG_TEST_NAME}2": True, f"{_FEATURE_FLAG_TEST_NAME}2": False, f"{_FEATURE_FLAG_TEST_NAME}3": True, - f"{_FEATURE_FLAG_TEST_NAME}3": False, + f"{_FEATURE_FLAG_TEST_NAME}4": False, + f"{_FEATURE_FLAG_TEST_NAME}5": True, + f"{_FEATURE_FLAG_TEST_NAME}6": False, }, [f"{_FEATURE_FLAG_TEST_NAME}1", f"{_FEATURE_FLAG_TEST_NAME}2"], ], @@ -405,13 +410,13 @@ def test__get_feature_flags__returns_filtered_flags( [ { f"{_FEATURE_FLAG_TEST_NAME}1": True, - f"{_FEATURE_FLAG_TEST_NAME}1": False, - f"{_FEATURE_FLAG_TEST_NAME}2": True, f"{_FEATURE_FLAG_TEST_NAME}2": False, f"{_FEATURE_FLAG_TEST_NAME}3": True, - f"{_FEATURE_FLAG_TEST_NAME}3": False, + f"{_FEATURE_FLAG_TEST_NAME}4": False, + f"{_FEATURE_FLAG_TEST_NAME}5": True, + f"{_FEATURE_FLAG_TEST_NAME}6": False, }, - [f"{_FEATURE_FLAG_TEST_NAME}4", f"{_FEATURE_FLAG_TEST_NAME}5"], + [f"{_FEATURE_FLAG_TEST_NAME}7", f"{_FEATURE_FLAG_TEST_NAME}8"], ], ], ) @@ -433,3 +438,48 @@ def test__get_feature_flags__returns_empty_sequence_when_flags_exist_but_filtere assert isinstance(feature_flags, tuple) assert len(feature_flags) == 0 + + +@pytest.mark.parametrize( + "added_flags", + [ + {f"{_FEATURE_FLAG_TEST_NAME}2": True}, + {f"{_FEATURE_FLAG_TEST_NAME}2": False}, + { + f"{_FEATURE_FLAG_TEST_NAME}1": True, + f"{_FEATURE_FLAG_TEST_NAME}2": False, + f"{_FEATURE_FLAG_TEST_NAME}3": True, + f"{_FEATURE_FLAG_TEST_NAME}4": False, + f"{_FEATURE_FLAG_TEST_NAME}5": True, + f"{_FEATURE_FLAG_TEST_NAME}6": False, + }, + ], +) +def test__get_feature_flags__caches_all_existing_flags_when_queried( + added_flags: dict[str, bool], feature_flag_session: Session, mocker: MockerFixture +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + + for flag_name, enabled in added_flags.items(): + _create_feature_flag(feature_flag_session, flag_name) + db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + + cache_mock = mocker.patch( + "Ligare.platform.feature_flag.caching_feature_flag_router.CachingFeatureFlagRouter.set_feature_is_enabled", + autospec=True, + ) + + _ = db_feature_flag_router.get_feature_flags() + + call_args_dict: dict[str, bool] = { + call.args[1]: call.args[2] for call in cache_mock.call_args_list + } + + # CachingFeatureFlagRouter.set_feature_is_enabled should be called + # once for every feature flag retrieved from the database + assert cache_mock.call_count == len(added_flags) + for flag_name, enabled in added_flags.items(): + assert call_args_dict[flag_name] == enabled From 620121289edfac01e335016d6905996cce5dd755 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 13 Aug 2024 14:42:21 -0700 Subject: [PATCH 06/41] Return old/new state when feature value changes. --- .../Ligare/platform/feature_flag/__init__.py | 14 +++- .../caching_feature_flag_router.py | 19 +++-- .../feature_flag/db_feature_flag_router.py | 24 ++++--- .../feature_flag/feature_flag_router.py | 10 ++- .../test_caching_feature_flag_router.py | 69 +++++++++++++++---- .../test_db_feature_flag_router.py | 68 ++++++++++++++---- .../feature_flags/test_feature_flag_router.py | 11 ++- 7 files changed, 170 insertions(+), 45 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/__init__.py b/src/platform/Ligare/platform/feature_flag/__init__.py index 082c8fe8..3e8ec3d2 100644 --- a/src/platform/Ligare/platform/feature_flag/__init__.py +++ b/src/platform/Ligare/platform/feature_flag/__init__.py @@ -1,5 +1,15 @@ from .caching_feature_flag_router import CachingFeatureFlagRouter +from .caching_feature_flag_router import FeatureFlag as CacheFeatureFlag from .db_feature_flag_router import DBFeatureFlagRouter -from .feature_flag_router import FeatureFlagRouter +from .db_feature_flag_router import FeatureFlag as DBFeatureFlag +from .feature_flag_router import FeatureFlag, FeatureFlagChange, FeatureFlagRouter -__all__ = ("FeatureFlagRouter", "CachingFeatureFlagRouter", "DBFeatureFlagRouter") +__all__ = ( + "FeatureFlagRouter", + "CachingFeatureFlagRouter", + "DBFeatureFlagRouter", + "FeatureFlag", + "CacheFeatureFlag", + "DBFeatureFlag", + "FeatureFlagChange", +) diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index 8b68898b..4202595e 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -3,7 +3,12 @@ from typing_extensions import override -from .feature_flag_router import FeatureFlag, FeatureFlagRouter, TFeatureFlag +from .feature_flag_router import ( + FeatureFlag, + FeatureFlagChange, + FeatureFlagRouter, + TFeatureFlag, +) class CachingFeatureFlagRouter(Generic[TFeatureFlag], FeatureFlagRouter[TFeatureFlag]): @@ -36,7 +41,7 @@ def _validate_name(self, name: str): raise ValueError("`name` parameter is required and cannot be empty.") @override - def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: + def set_feature_is_enabled(self, name: str, is_enabled: bool) -> FeatureFlagChange: """ Enables or disables a feature flag in the in-memory dictionary of feature flags. @@ -44,17 +49,23 @@ def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: :param str name: The feature flag to check. :param bool is_enabled: Whether the feature flag is to be enabled or disabled. + :return FeatureFlagChange: An object representing the previous and new values of the changed feature flag. """ self._validate_name(name) if type(is_enabled) != bool: raise TypeError("`is_enabled` must be a boolean.") - self._notify_change(name, is_enabled, self._feature_flags.get(name)) + old_enabled_value = self._feature_flags.get(name) + self._notify_change(name, is_enabled, old_enabled_value) self._feature_flags[name] = is_enabled - return super().set_feature_is_enabled(name, is_enabled) + _ = super().set_feature_is_enabled(name, is_enabled) + + return FeatureFlagChange( + name=name, old_value=old_enabled_value, new_value=is_enabled + ) @override def feature_is_enabled(self, name: str, default: bool = False) -> bool: diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index 2a296140..525d250d 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -12,6 +12,7 @@ from .caching_feature_flag_router import CachingFeatureFlagRouter from .feature_flag_router import FeatureFlag as FeatureFlagBaseData +from .feature_flag_router import FeatureFlagChange @dataclass(frozen=True) @@ -81,16 +82,16 @@ def __init__( super().__init__(logger) @override - def set_feature_is_enabled(self, name: str, is_enabled: bool): + def set_feature_is_enabled(self, name: str, is_enabled: bool) -> FeatureFlagChange: """ Enable or disable a feature flag in the database. This method caches the value of `is_enabled` for the specified feature flag unless saving to the database fails. - name: The feature flag to check. - - is_enabled: Whether the feature flag is to be enabled or disabled. + :param str name: The feature flag to check. + :param bool is_enabled: Whether the feature flag is to be enabled or disabled. + :return FeatureFlagChange: An object representing the previous and new values of the changed feature flag. """ if type(name) != str: @@ -103,7 +104,7 @@ def set_feature_is_enabled(self, name: str, is_enabled: bool): try: feature_flag = ( self._session.query(self._feature_flag) - .filter(cast(Column[Unicode], self._feature_flag.name) == name) + .filter(self._feature_flag.name == name) .one() ) except NoResultFound as e: @@ -111,9 +112,14 @@ def set_feature_is_enabled(self, name: str, is_enabled: bool): f"The feature flag `{name}` does not exist. It must be created before being accessed." ) from e + old_enabled_value = cast(bool | None, feature_flag.enabled) feature_flag.enabled = is_enabled self._session.commit() - super().set_feature_is_enabled(name, is_enabled) + _ = super().set_feature_is_enabled(name, is_enabled) + + return FeatureFlagChange( + name=name, old_value=old_enabled_value, new_value=is_enabled + ) @overload def feature_is_enabled(self, name: str, default: bool = False) -> bool: ... @@ -143,7 +149,7 @@ def feature_is_enabled( feature_flag = ( self._session.query(self._feature_flag) - .filter(cast(Column[Unicode], self._feature_flag.name) == name) + .filter(self._feature_flag.name == name) .one_or_none() ) @@ -155,7 +161,7 @@ def feature_is_enabled( is_enabled = cast(bool, feature_flag.enabled) - super().set_feature_is_enabled(name, is_enabled) + _ = super().set_feature_is_enabled(name, is_enabled) return is_enabled @@ -204,6 +210,6 @@ def get_feature_flags( # cache the feature flags for feature_flag in feature_flags: - super().set_feature_is_enabled(feature_flag.name, feature_flag.enabled) + _ = super().set_feature_is_enabled(feature_flag.name, feature_flag.enabled) return feature_flags diff --git a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py index d931e79a..71f368b2 100644 --- a/src/platform/Ligare/platform/feature_flag/feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/feature_flag_router.py @@ -9,6 +9,13 @@ class FeatureFlag: enabled: bool +@dataclass(frozen=True) +class FeatureFlagChange: + name: str + old_value: bool | None + new_value: bool | None + + TFeatureFlag = TypeVar("TFeatureFlag", bound=FeatureFlag, covariant=True) @@ -32,12 +39,13 @@ def _notify_change( """ @abstractmethod - def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: + def set_feature_is_enabled(self, name: str, is_enabled: bool) -> FeatureFlagChange: """ Enable or disable a feature flag. :param str name: The name of the feature flag. :param bool is_enabled: If `True`, the feature is enabled. If `False`, the feature is disabled. + :return FeatureFlagChange: An object representing the previous and new values of the changed feature flag. """ @abstractmethod diff --git a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py index 5847c1c6..952c6dab 100644 --- a/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_caching_feature_flag_router.py @@ -35,7 +35,7 @@ def test__set_feature_is_enabled__disallows_empty_name(): caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(ValueError): - caching_feature_flag_router.set_feature_is_enabled("", False) + _ = caching_feature_flag_router.set_feature_is_enabled("", False) @pytest.mark.parametrize("name", [0, False, True, {}, [], (0,)]) @@ -44,7 +44,7 @@ def test__set_feature_is_enabled__disallows_non_string_names(name: Any): caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) with pytest.raises(TypeError): - caching_feature_flag_router.set_feature_is_enabled(name, False) + _ = caching_feature_flag_router.set_feature_is_enabled(name, False) @pytest.mark.parametrize("value", [None, "", "False", "True", 0, 1, -1, {}, [], (0,)]) @@ -65,12 +65,45 @@ def test__set_feature_is_enabled__sets_correct_value(value: bool): logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) is_enabled = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) assert is_enabled == value +@pytest.mark.parametrize("value", [True, False]) +def test__set_feature_is_enabled__returns_correct_initial_state(value: bool): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + initial_change = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) + + assert initial_change.name == _FEATURE_FLAG_TEST_NAME + assert initial_change.old_value is None + assert initial_change.new_value == value + + +@pytest.mark.parametrize("value", [True, False]) +def test__set_feature_is_enabled__returns_correct_new_and_old_state(value: bool): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) + + initial_change = caching_feature_flag_router.set_feature_is_enabled( # pyright: ignore[reportUnusedVariable] + _FEATURE_FLAG_TEST_NAME, value + ) + change = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, not value + ) + + assert change.name == _FEATURE_FLAG_TEST_NAME + assert change.old_value == value + assert change.new_value == (not value) + + def test__feature_is_enabled__defaults_to_false_when_flag_does_not_exist(): logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) @@ -85,7 +118,9 @@ def test__set_feature_is_enabled__caches_new_flag(value: bool): logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) assert _FEATURE_FLAG_TEST_NAME in caching_feature_flag_router._feature_flags # pyright: ignore[reportPrivateUsage] is_enabled = caching_feature_flag_router._feature_flags.get(_FEATURE_FLAG_TEST_NAME) # pyright: ignore[reportPrivateUsage] assert is_enabled == value @@ -98,7 +133,9 @@ def test__feature_is_enabled__uses_cache(value: bool): mock_dict = MagicMock() caching_feature_flag_router._feature_flags = mock_dict # pyright: ignore[reportPrivateUsage] - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) _ = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) _ = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -111,13 +148,15 @@ def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set(enable: b logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, enable + ) _ = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) first_value = caching_feature_flag_router.feature_is_enabled( _FEATURE_FLAG_TEST_NAME ) - caching_feature_flag_router.set_feature_is_enabled( + _ = caching_feature_flag_router.set_feature_is_enabled( _FEATURE_FLAG_TEST_NAME, not enable ) _ = caching_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -137,7 +176,9 @@ def test__set_feature_is_enabled__notifies_when_setting_new_flag( logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) assert f"Setting new feature flag '{_FEATURE_FLAG_TEST_NAME}' to `{value}`." in { record.msg for record in caplog.records @@ -178,10 +219,10 @@ def test__set_feature_is_enabled__notifies_when_changing_flag( logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled( + _ = caching_feature_flag_router.set_feature_is_enabled( _FEATURE_FLAG_TEST_NAME, first_value ) - caching_feature_flag_router.set_feature_is_enabled( + _ = caching_feature_flag_router.set_feature_is_enabled( _FEATURE_FLAG_TEST_NAME, second_value ) @@ -193,7 +234,9 @@ def test__feature_is_cached__correctly_determines_whether_value_is_cached(value: logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) caching_feature_flag_router = CachingFeatureFlagRouter[FeatureFlag](logger) - caching_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, value) + _ = caching_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) feature_is_cached = caching_feature_flag_router.feature_is_cached( _FEATURE_FLAG_TEST_NAME @@ -243,7 +286,7 @@ def test__get_feature_flags__returns_all_existing_flags(): FLAG_COUNT = 4 for i in range(FLAG_COUNT): - caching_feature_flag_router.set_feature_is_enabled( + _ = caching_feature_flag_router.set_feature_is_enabled( f"{_FEATURE_FLAG_TEST_NAME}{i}", (i % 2) == 0 ) @@ -261,7 +304,7 @@ def test__get_feature_flags__returns_filtered_flags(): FILTERED_FLAG_NAME = f"{_FEATURE_FLAG_TEST_NAME}2" for i in range(FLAG_COUNT): - caching_feature_flag_router.set_feature_is_enabled( + _ = caching_feature_flag_router.set_feature_is_enabled( f"{_FEATURE_FLAG_TEST_NAME}{i}", (i % 2) == 0 ) diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index cd0afce2..28eac030 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Tuple, cast +from typing import Any, Tuple import pytest from Ligare.database.config import DatabaseConfig @@ -9,8 +9,7 @@ CachingFeatureFlagRouter, ) from Ligare.platform.feature_flag.db_feature_flag_router import ( -from Ligare.database.config import DatabaseConfig -from LigarereFlagRouter, + DBFeatureFlagRouter, FeatureFlag, FeatureFlagTable, ) @@ -128,7 +127,7 @@ def test__set_feature_is_enabled__fails_when_flag_does_not_exist( ) with pytest.raises(LookupError): - db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, True) + _ = db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, True) def test__set_feature_is_enabled__disallows_empty_name(feature_flag_session: Session): @@ -139,7 +138,7 @@ def test__set_feature_is_enabled__disallows_empty_name(feature_flag_session: Ses _create_feature_flag(feature_flag_session) with pytest.raises(ValueError): - db_feature_flag_router.set_feature_is_enabled("", False) + _ = db_feature_flag_router.set_feature_is_enabled("", False) @pytest.mark.parametrize("name", [0, False, True, {}, [], (0,)]) @@ -152,7 +151,7 @@ def test__set_feature_is_enabled__disallows_non_string_names( ) with pytest.raises(TypeError): - db_feature_flag_router.set_feature_is_enabled(name, False) + _ = db_feature_flag_router.set_feature_is_enabled(name, False) @pytest.mark.parametrize("enable", [True, False]) @@ -165,7 +164,7 @@ def test__set_feature_is_enabled__sets_correct_value( ) _create_feature_flag(feature_flag_session) - db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) + _ = db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) is_enabled = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) assert is_enabled == enable @@ -182,7 +181,7 @@ def test__set_feature_is_enabled__caches_flags(enable: bool, mocker: MockerFixtu FeatureFlagTableBase, session_mock, logger ) - db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) + _ = db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -251,11 +250,13 @@ def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set( FeatureFlagTableBase, session_mock, logger ) - db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) + _ = db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, enable) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) first_value = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) - db_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, not enable) + _ = db_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, not enable + ) _ = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) second_value = db_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) @@ -264,6 +265,47 @@ def test__set_feature_is_enabled__resets_cache_when_flag_enable_is_set( assert second_value == (not enable) +@pytest.mark.parametrize("value", [True, False]) +def test__set_feature_is_enabled__returns_correct_initial_state( + value: bool, feature_flag_session: Session +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + _create_feature_flag(feature_flag_session) + + initial_change = db_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, value + ) + + assert initial_change.name == _FEATURE_FLAG_TEST_NAME + assert initial_change.old_value == False + assert initial_change.new_value == value + + +@pytest.mark.parametrize("value", [True, False]) +def test__set_feature_is_enabled__returns_correct_new_and_old_state( + value: bool, feature_flag_session: Session +): + logger = logging.getLogger(_FEATURE_FLAG_LOGGER_NAME) + db_feature_flag_router = DBFeatureFlagRouter[FeatureFlag]( + FeatureFlagTableBase, feature_flag_session, logger + ) + _create_feature_flag(feature_flag_session) + + initial_change = db_feature_flag_router.set_feature_is_enabled( # pyright: ignore[reportUnusedVariable] + _FEATURE_FLAG_TEST_NAME, value + ) + change = db_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, not value + ) + + assert change.name == _FEATURE_FLAG_TEST_NAME + assert change.old_value == value + assert change.new_value == (not value) + + def test___create_feature_flag__returns_correct_TFeatureFlag( feature_flag_session: Session, ): @@ -338,7 +380,7 @@ def test__get_feature_flags__returns_all_existing_flags( for flag_name, enabled in added_flags.items(): _create_feature_flag(feature_flag_session, flag_name) - db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + _ = db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) feature_flags = db_feature_flag_router.get_feature_flags() feature_flags_dict = { @@ -385,7 +427,7 @@ def test__get_feature_flags__returns_filtered_flags( for flag_name, enabled in added_flags.items(): _create_feature_flag(feature_flag_session, flag_name) - db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + _ = db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) feature_flags_dict = { @@ -432,7 +474,7 @@ def test__get_feature_flags__returns_empty_sequence_when_flags_exist_but_filtere for flag_name, enabled in added_flags.items(): _create_feature_flag(feature_flag_session, flag_name) - db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + _ = db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) feature_flags = db_feature_flag_router.get_feature_flags(filtered_flags) diff --git a/src/platform/test/unit/feature_flags/test_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_feature_flag_router.py index 8697c8a3..be89c1ce 100644 --- a/src/platform/test/unit/feature_flags/test_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_feature_flag_router.py @@ -4,6 +4,7 @@ from Ligare.platform.feature_flag.feature_flag_router import ( FeatureFlag, + FeatureFlagChange, FeatureFlagRouter, ) from typing_extensions import override @@ -13,7 +14,7 @@ class TestFeatureFlagRouter(FeatureFlagRouter[FeatureFlag]): @override - def set_feature_is_enabled(self, name: str, is_enabled: bool) -> None: + def set_feature_is_enabled(self, name: str, is_enabled: bool) -> FeatureFlagChange: return super().set_feature_is_enabled(name, is_enabled) @override @@ -61,7 +62,9 @@ def test___notify_change__is_a_noop(): def test__set_feature_is_enabled__does_not_notify(): notifying_feature_flag_router = NotifyingFeatureFlagRouter() - notifying_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, True) + _ = notifying_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, True + ) assert notifying_feature_flag_router.notification_count == 0 @@ -77,7 +80,9 @@ def test__feature_is_enabled__does_not_notify(): def test__feature_is_enabled__does_not_notify_after_flag_is_set(): notifying_feature_flag_router = NotifyingFeatureFlagRouter() - notifying_feature_flag_router.set_feature_is_enabled(_FEATURE_FLAG_TEST_NAME, True) + _ = notifying_feature_flag_router.set_feature_is_enabled( + _FEATURE_FLAG_TEST_NAME, True + ) notifying_feature_flag_router.notification_count = 0 _ = notifying_feature_flag_router.feature_is_enabled(_FEATURE_FLAG_TEST_NAME) From 2b1bbc8541ea78bf768c8b7b0244d0d70ef8bac3 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 20 Aug 2024 16:43:28 -0700 Subject: [PATCH 07/41] Initial FeatureFlag modules for API and DI. --- .../caching_feature_flag_router.py | 14 +- .../web/middleware/feature_flags/__init__.py | 276 ++++++++++++++++++ .../middleware/feature_flags/old-server.py | 128 ++++++++ .../test_feature_flags_middleware.py | 82 ++++++ 4 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 src/web/BL_Python/web/middleware/feature_flags/__init__.py create mode 100644 src/web/BL_Python/web/middleware/feature_flags/old-server.py create mode 100644 src/web/test/unit/middleware/test_feature_flags_middleware.py diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index 4202595e..36890cdd 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -1,14 +1,16 @@ from logging import Logger from typing import Generic, Sequence, cast +from attr import dataclass from typing_extensions import override -from .feature_flag_router import ( - FeatureFlag, - FeatureFlagChange, - FeatureFlagRouter, - TFeatureFlag, -) +from .feature_flag_router import FeatureFlag as FeatureFlagBaseData +from .feature_flag_router import FeatureFlagChange, FeatureFlagRouter, TFeatureFlag + + +@dataclass(frozen=True) +class FeatureFlag(FeatureFlagBaseData): + pass class CachingFeatureFlagRouter(Generic[TFeatureFlag], FeatureFlagRouter[TFeatureFlag]): diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py new file mode 100644 index 00000000..49c7a5f6 --- /dev/null +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -0,0 +1,276 @@ +# """ +# Server blueprint +# Non-API specific endpoints for application management +# """ +# +# from typing import Any, Sequence, cast +# +# import flask +# from BL_Python.platform.feature_flag import FeatureFlagChange, FeatureFlagRouter +# from BL_Python.web.middleware.sso import login_required +# from flask import request +# from injector import inject +# +# from CAP import __version__ +# from CAP.app.models.user.role import Role as UserRole +# from CAP.app.schemas.platform.get_request_feature_flag_schema import ( +# GetResponseFeatureFlagSchema, +# ) +# from CAP.app.schemas.platform.patch_request_feature_flag_schema import ( +# PatchRequestFeatureFlag, +# PatchRequestFeatureFlagSchema, +# PatchResponseFeatureFlagSchema, +# ) +# from CAP.app.schemas.platform.response_problem_schema import ( +# ResponseProblem, +# ResponseProblemSchema, +# ) +# +# _FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE = "Feature Flag Not Found" +# _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS = 404 +############ +from logging import Logger +from typing import Any, Callable, Generic, Sequence + +from BL_Python.platform.feature_flag.caching_feature_flag_router import ( + FeatureFlag as CachingFeatureFlag, +) +from BL_Python.platform.feature_flag.db_feature_flag_router import DBFeatureFlagRouter +from BL_Python.platform.feature_flag.db_feature_flag_router import ( + FeatureFlag as DBFeatureFlag, +) +from BL_Python.platform.feature_flag.db_feature_flag_router import ( + FeatureFlagTable, + FeatureFlagTableBase, +) +from BL_Python.platform.feature_flag.feature_flag_router import ( + FeatureFlag, + FeatureFlagRouter, + TFeatureFlag, +) +from BL_Python.platform.identity.user_loader import Role, UserId, UserLoader, UserMixin +from BL_Python.programming.config import AbstractConfig +from BL_Python.web.middleware.sso import login_required +from connexion import FlaskApp +from flask import Blueprint, Flask, request +from injector import Binder, Injector, Module, inject, provider +from pydantic import BaseModel + +# from sqlalchemy.orm.scoping import ScopedSession +from starlette.types import ASGIApp, Receive, Scope, Send +from typing_extensions import override + + +class FeatureFlagConfig(BaseModel): + api_base_url: str = "/server" + access_role_name: str | None = None # "Operator" + + +class Config(BaseModel, AbstractConfig): + feature_flag: FeatureFlagConfig + + +class FeatureFlagRouterModule(Module, Generic[TFeatureFlag]): + def __init__(self, t_feature_flag: type[FeatureFlagRouter[TFeatureFlag]]) -> None: + self._t_feature_flag = t_feature_flag + super().__init__() + + @provider + def _provide_feature_flag_router( + self, injector: Injector + ) -> FeatureFlagRouter[FeatureFlag]: + return injector.get(self._t_feature_flag) + + +class DBFeatureFlagRouterModule(FeatureFlagRouterModule[DBFeatureFlag]): + def __init__(self) -> None: + super().__init__(DBFeatureFlagRouter) + + @provider + def _provide_db_feature_flag_router( + self, injector: Injector + ) -> FeatureFlagRouter[DBFeatureFlag]: + return injector.get(self._t_feature_flag) + + @provider + def _provide_db_feature_flag_router_table_base(self) -> type[FeatureFlagTableBase]: + return FeatureFlagTable + + +class CachingFeatureFlagRouterModule(FeatureFlagRouterModule[CachingFeatureFlag]): + @provider + def _provide_caching_feature_flag_router( + self, injector: Injector + ) -> FeatureFlagRouter[CachingFeatureFlag]: + return injector.get(self._t_feature_flag) + + +def get_feature_flag_blueprint( + config: FeatureFlagConfig, access_roles: list[Role] | bool = True +): + feature_flag_blueprint = Blueprint( + "feature_flag", __name__, url_prefix=f"{config.api_base_url}" + ) + + # access_role = config.feature_flag.access_role_name + # convert this enum somehow + + def _login_required(fn: Callable[..., Any]): + if access_roles is False: + return fn + + if access_roles is True: + return login_required(fn) + + return login_required(access_roles)(fn) + + @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) + @_login_required + @inject + def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): + request_query_names: list[str] | None = request.args.to_dict(flat=False).get( + "name" + ) + + feature_flags: Sequence[FeatureFlag] + missing_flags: set[str] | None = None + if request_query_names is None: + feature_flags = feature_flag_router.get_feature_flags() + elif isinstance(request_query_names, list): # pyright: ignore[reportUnnecessaryIsInstance] + feature_flags = feature_flag_router.get_feature_flags(request_query_names) + missing_flags = set(request_query_names).difference( + set([feature_flag.name for feature_flag in feature_flags]) + ) + else: + raise ValueError("Unexpected type from Flask query parameters.") + + response: dict[str, Any] = {} + + if missing_flags: + # problems: list[ResponseProblem] = [] + problems: list[Any] = [] + for missing_flag in missing_flags: + problems.append( + # ResponseProblem( + { + "title": "feature flag not found", # _FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, + "detail": "Queried feature flag does not exist.", + "instance": missing_flag, + "status": 404, # _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, + "type": None, + } + # ) + ) + response["problems"] = ( + problems # ResponseProblemSchema().dump(problems, many=True) + ) + + if feature_flags: + response["data"] = feature_flags # GetResponseFeatureFlagSchema().dump( + # feature_flags, many=True + # ) + return response + else: + return response, 404 # _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS + + # + # + ## @server_blueprint.route("/server/feature_flag", methods=("PATCH",)) + # @login_required([UserRole.Operator]) + # @inject + # def feature_flag_patch(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): + # post_request_feature_flag_schema = PatchRequestFeatureFlagSchema() + # + # feature_flags: list[PatchRequestFeatureFlag] = cast( + # list[PatchRequestFeatureFlag], + # post_request_feature_flag_schema.load( + # flask.request.json, # pyright: ignore[reportArgumentType] why is `flask.request.json` wrong here? + # many=True, + # ), + # ) + # + # changes: list[FeatureFlagChange] = [] + # problems: list[ResponseProblem] = [] + # for flag in feature_flags: + # try: + # change = feature_flag_router.set_feature_is_enabled(flag.name, flag.enabled) + # changes.append(change) + # except LookupError: + # problems.append( + # ResponseProblem( + # title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, + # detail="Feature flag to PATCH does not exist. It must be created first.", + # instance=flag.name, + # status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, + # type=None, + # ) + # ) + # + # response: dict[str, Any] = {} + # + # if problems: + # response["problems"] = ResponseProblemSchema().dump(problems, many=True) + # + # if changes: + # response["data"] = PatchResponseFeatureFlagSchema().dump(changes, many=True) + # return response + # else: + # return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS + # + return feature_flag_blueprint + + +# class FeatureFlagModule(Module): +# def __init__(self): +# """ """ +# super().__init__() + + +class FeatureFlagMiddlewareModule(Module): + @override + def __init__(self, access_roles: list[Role] | bool = True) -> None: + self._access_roles = access_roles + super().__init__() + + @override + def configure(self, binder: Binder) -> None: + super().configure(binder) + + def register_middleware(self, app: FlaskApp): + app.add_middleware(FeatureFlagMiddlewareModule.FeatureFlagMiddleware) + + class FeatureFlagMiddleware: + _app: ASGIApp + + def __init__(self, app: ASGIApp): + super().__init__() + self._app = app + + @inject + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + app: Flask, + injector: Injector, + log: Logger, + ) -> None: + async def wrapped_send(message: Any) -> None: + # Only run during startup of the application + if ( + scope["type"] != "lifespan" + or message["type"] != "lifespan.startup.complete" + or not scope["app"] + ): + return await send(message) + + log.debug("Registering FeatureFlag blueprint.") + app.register_blueprint( + get_feature_flag_blueprint(injector.get(FeatureFlagConfig)) + ) + log.debug("FeatureFlag blueprint registered.") + + return await send(message) + + await self._app(scope, receive, wrapped_send) diff --git a/src/web/BL_Python/web/middleware/feature_flags/old-server.py b/src/web/BL_Python/web/middleware/feature_flags/old-server.py new file mode 100644 index 00000000..b1f016f2 --- /dev/null +++ b/src/web/BL_Python/web/middleware/feature_flags/old-server.py @@ -0,0 +1,128 @@ +""" +Server blueprint +Non-API specific endpoints for application management +""" + +from typing import Any, Sequence, cast + +import flask +from BL_Python.platform.feature_flag import FeatureFlagChange, FeatureFlagRouter +from BL_Python.platform.feature_flag.db_feature_flag_router import ( + FeatureFlag as DBFeatureFlag, +) +from BL_Python.web.middleware.sso import login_required +from flask import request +from injector import inject + +from CAP import __version__ +from CAP.app.models.user.role import Role as UserRole +from CAP.app.schemas.platform.get_request_feature_flag_schema import ( + GetResponseFeatureFlagSchema, +) +from CAP.app.schemas.platform.patch_request_feature_flag_schema import ( + PatchRequestFeatureFlag, + PatchRequestFeatureFlagSchema, + PatchResponseFeatureFlagSchema, +) +from CAP.app.schemas.platform.response_problem_schema import ( + ResponseProblem, + ResponseProblemSchema, +) + +_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE = "Feature Flag Not Found" +_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS = 404 + + +def healthcheck(): + return "healthcheck: flask app is running" + + +@login_required([UserRole.Administrator]) +def server_meta(): + return {"CAP": {"version": __version__}} + + +# @server_blueprint.route("/server/feature_flag", methods=("GET",)) +@login_required([UserRole.Operator]) +@inject +def feature_flag(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): + request_query_names: list[str] | None = request.args.to_dict(flat=False).get("name") + + feature_flags: Sequence[DBFeatureFlag] + missing_flags: set[str] | None = None + if request_query_names is None: + feature_flags = feature_flag_router.get_feature_flags() + elif isinstance( + request_query_names, list + ): # pyright: ignore[reportUnnecessaryIsInstance] + feature_flags = feature_flag_router.get_feature_flags(request_query_names) + missing_flags = set(request_query_names).difference( + set([feature_flag.name for feature_flag in feature_flags]) + ) + else: + raise ValueError("Unexpected type from Flask query parameters.") + + response: dict[str, Any] = {} + + if missing_flags: + problems: list[ResponseProblem] = [] + for missing_flag in missing_flags: + problems.append( + ResponseProblem( + title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, + detail="Queried feature flag does not exist.", + instance=missing_flag, + status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, + type=None, + ) + ) + response["problems"] = ResponseProblemSchema().dump(problems, many=True) + + if feature_flags: + response["data"] = GetResponseFeatureFlagSchema().dump(feature_flags, many=True) + return response + else: + return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS + + +# @server_blueprint.route("/server/feature_flag", methods=("PATCH",)) +@login_required([UserRole.Operator]) +@inject +def feature_flag_patch(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): + post_request_feature_flag_schema = PatchRequestFeatureFlagSchema() + + feature_flags: list[PatchRequestFeatureFlag] = cast( + list[PatchRequestFeatureFlag], + post_request_feature_flag_schema.load( + flask.request.json, # pyright: ignore[reportArgumentType] why is `flask.request.json` wrong here? + many=True, + ), + ) + + changes: list[FeatureFlagChange] = [] + problems: list[ResponseProblem] = [] + for flag in feature_flags: + try: + change = feature_flag_router.set_feature_is_enabled(flag.name, flag.enabled) + changes.append(change) + except LookupError: + problems.append( + ResponseProblem( + title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, + detail="Feature flag to PATCH does not exist. It must be created first.", + instance=flag.name, + status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, + type=None, + ) + ) + + response: dict[str, Any] = {} + + if problems: + response["problems"] = ResponseProblemSchema().dump(problems, many=True) + + if changes: + response["data"] = PatchResponseFeatureFlagSchema().dump(changes, many=True) + return response + else: + return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py new file mode 100644 index 00000000..954c8595 --- /dev/null +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -0,0 +1,82 @@ +import uuid +from typing import Literal + +import pytest +from BL_Python.platform.dependency_injection import UserLoaderModule +from BL_Python.platform.feature_flag.db_feature_flag_router import DBFeatureFlagRouter +from BL_Python.platform.feature_flag.db_feature_flag_router import ( + FeatureFlag as DBFeatureFlag, +) +from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlagRouter +from BL_Python.platform.identity import Role, User +from BL_Python.programming.config import AbstractConfig +from BL_Python.web.application import OpenAPIAppResult +from BL_Python.web.config import Config +from BL_Python.web.middleware import bind_errorhandler +from BL_Python.web.middleware.consts import CORRELATION_ID_HEADER +from BL_Python.web.middleware.feature_flags import Config as RootFeatureFlagConfig +from BL_Python.web.middleware.feature_flags import ( + DBFeatureFlagRouterModule, + FeatureFlagConfig, + FeatureFlagMiddlewareModule, + FeatureFlagRouterModule, +) +from BL_Python.web.middleware.flask import ( + _get_correlation_id, # pyright: ignore[reportPrivateUsage] +) +from BL_Python.web.middleware.flask import bind_requesthandler +from BL_Python.web.testing.create_app import ( + CreateOpenAPIApp, + OpenAPIClientInjectorConfigurable, + OpenAPIMockController, + RequestConfigurable, +) +from connexion import FlaskApp +from flask import Flask, abort +from injector import Module +from mock import MagicMock +from pytest_mock import MockerFixture +from werkzeug.exceptions import BadRequest, HTTPException, Unauthorized + + +class TestFeatureFlagsMiddleware(CreateOpenAPIApp): + def test__FeatureFlagMiddleware__feature_flag_api_get_requires_user_session( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + application_modules.append(DBFeatureFlagRouterModule) + application_configs.append(RootFeatureFlagConfig) + application_modules.append(FeatureFlagMiddlewareModule()) + + def client_init_hook(app: OpenAPIAppResult): + feature_flag_config = FeatureFlagConfig( + access_role_name="Operator", + api_base_url="/server", # the default + ) + root_feature_flag_config = RootFeatureFlagConfig( + feature_flag=feature_flag_config + ) + app.app_injector.flask_injector.injector.binder.bind( + FeatureFlagConfig, to=feature_flag_config + ) + app.app_injector.flask_injector.injector.binder.bind( + RootFeatureFlagConfig, to=root_feature_flag_config + ) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable(openapi_config, client_init_hook, app_init_hook) + ) + + response = app.client.get("/server/feature_flag") + + # 401 for now because no real auth is configured. + # if SSO was broken, 500 would return + assert response.status_code == 401 From 0cc2bc7e9ef96f2c8d4605e1d3fdccbe6d2a4cde Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Wed, 21 Aug 2024 14:47:24 -0700 Subject: [PATCH 08/41] Fix a type issue, add some TODOs --- .../web/middleware/feature_flags/__init__.py | 8 ++- src/web/Ligare/web/testing/create_app.py | 2 + .../test_feature_flags_middleware.py | 50 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 49c7a5f6..ae62ede5 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -30,7 +30,7 @@ # _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS = 404 ############ from logging import Logger -from typing import Any, Callable, Generic, Sequence +from typing import Any, Callable, Generic, Sequence, cast from BL_Python.platform.feature_flag.caching_feature_flag_router import ( FeatureFlag as CachingFeatureFlag, @@ -70,6 +70,8 @@ class Config(BaseModel, AbstractConfig): feature_flag: FeatureFlagConfig +# TODO consider having the DI registration log a warning if +# an interface is being overwritten class FeatureFlagRouterModule(Module, Generic[TFeatureFlag]): def __init__(self, t_feature_flag: type[FeatureFlagRouter[TFeatureFlag]]) -> None: self._t_feature_flag = t_feature_flag @@ -94,7 +96,9 @@ def _provide_db_feature_flag_router( @provider def _provide_db_feature_flag_router_table_base(self) -> type[FeatureFlagTableBase]: - return FeatureFlagTable + # FeatureFlagTable is a FeatureFlagTableBase provided through + # SQLAlchemy's declarative meta API + return cast(type[FeatureFlagTableBase], FeatureFlagTable) class CachingFeatureFlagRouterModule(FeatureFlagRouterModule[CachingFeatureFlag]): diff --git a/src/web/Ligare/web/testing/create_app.py b/src/web/Ligare/web/testing/create_app.py index 5e821823..12e60b2b 100644 --- a/src/web/Ligare/web/testing/create_app.py +++ b/src/web/Ligare/web/testing/create_app.py @@ -775,6 +775,8 @@ def end(): mock_controller = MockController(begin=begin, end=end) try: + # TODO can this be a context manager instead of requiring + # the explicit begin() call? yield mock_controller finally: mock_controller.end() diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 954c8595..175b0adb 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -80,3 +80,53 @@ def client_init_hook(app: OpenAPIAppResult): # 401 for now because no real auth is configured. # if SSO was broken, 500 would return assert response.status_code == 401 + + def test__FeatureFlagMiddleware__something( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + application_modules.append( + UserLoaderModule( + loader=User, # pyright: ignore[reportArgumentType] + roles=Role, # pyright: ignore[reportArgumentType] + user_table=MagicMock(), # pyright: ignore[reportArgumentType] + role_table=MagicMock(), # pyright: ignore[reportArgumentType] + bases=[], + ) + ) + application_modules.append(DBFeatureFlagRouterModule) + application_configs.append(RootFeatureFlagConfig) + application_modules.append(FeatureFlagMiddlewareModule()) + + def client_init_hook(app: OpenAPIAppResult): + feature_flag_config = FeatureFlagConfig( + access_role_name="Operator", + api_base_url="/server", # the default + ) + root_feature_flag_config = RootFeatureFlagConfig( + feature_flag=feature_flag_config + ) + app.app_injector.flask_injector.injector.binder.bind( + FeatureFlagConfig, to=feature_flag_config + ) + app.app_injector.flask_injector.injector.binder.bind( + RootFeatureFlagConfig, to=root_feature_flag_config + ) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable(openapi_config, client_init_hook, app_init_hook) + ) + + response = app.client.get("/server/feature_flag") + + # 401 for now because no real auth is configured. + # if SSO was broken, 500 would return + assert response.status_code == 401 From 83af0ae71190b7c6129ddebcc2d5105711370331 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 6 Sep 2024 15:10:53 -0700 Subject: [PATCH 09/41] Make ApplicationBuilder work. --- src/database/Ligare/database/config.py | 9 + src/identity/Ligare/identity/config.py | 9 + .../Ligare/programming/config/__init__.py | 14 +- src/programming/test/unit/test_config.py | 13 + .../test/unit/test_dependency_injection.py | 10 +- src/web/BL_Python/web/exception.py | 6 + .../web/middleware/feature_flags/__init__.py | 4 + src/web/Ligare/web/application.py | 203 ++++++++++++++- src/web/Ligare/web/config.py | 10 +- .../unit/application/test_create_flask_app.py | 5 + .../application/test_create_openapi_app.py | 5 + src/web/test/unit/test_config.py | 232 +++++++++++++++++- 12 files changed, 506 insertions(+), 14 deletions(-) create mode 100644 src/web/BL_Python/web/exception.py diff --git a/src/database/Ligare/database/config.py b/src/database/Ligare/database/config.py index 7b55aee3..e9855faa 100644 --- a/src/database/Ligare/database/config.py +++ b/src/database/Ligare/database/config.py @@ -3,6 +3,7 @@ from Ligare.programming.config import AbstractConfig from pydantic import BaseModel from pydantic.config import ConfigDict +from typing_extensions import override class DatabaseConnectArgsConfig(BaseModel): @@ -36,6 +37,10 @@ def __init__(self, **data: Any): elif self.connection_string.startswith("postgresql://"): self.connect_args = PostgreSQLDatabaseConnectArgsConfig(**model_data) + @override + def post_load(self) -> None: + return super().post_load() + connection_string: str = "sqlite:///:memory:" sqlalchemy_echo: bool = False # the static field allows Pydantic to store @@ -44,4 +49,8 @@ def __init__(self, **data: Any): class Config(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + database: DatabaseConfig diff --git a/src/identity/Ligare/identity/config.py b/src/identity/Ligare/identity/config.py index a4b94f39..b5ef49d1 100644 --- a/src/identity/Ligare/identity/config.py +++ b/src/identity/Ligare/identity/config.py @@ -3,6 +3,7 @@ from Ligare.programming.config import AbstractConfig from pydantic import BaseModel from pydantic.config import ConfigDict +from typing_extensions import override class SSOSettingsConfig(BaseModel): @@ -33,6 +34,10 @@ def __init__(self, **data: Any): if self.protocol == "SAML2": self.settings = SAML2Config(**model_data) + @override + def post_load(self) -> None: + return super().post_load() + protocol: str = "SAML2" # the static field allows Pydantic to store # values from a dictionary @@ -40,4 +45,8 @@ def __init__(self, **data: Any): class Config(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + sso: SSOConfig diff --git a/src/programming/Ligare/programming/config/__init__.py b/src/programming/Ligare/programming/config/__init__.py index 85557827..6beb773d 100644 --- a/src/programming/Ligare/programming/config/__init__.py +++ b/src/programming/Ligare/programming/config/__init__.py @@ -9,18 +9,21 @@ NotEndsWithConfigError, ) -TConfig = TypeVar("TConfig") - class AbstractConfig(abc.ABC): - pass + @abc.abstractmethod + def post_load(self) -> None: + pass + + +TConfig = TypeVar("TConfig", bound=AbstractConfig) class ConfigBuilder(Generic[TConfig]): _root_config: type[TConfig] | None = None _configs: list[type[AbstractConfig]] | None = None - def with_root_config(self, config: "type[TConfig]"): + def with_root_config(self, config: type[TConfig]): self._root_config = config return self @@ -73,4 +76,7 @@ def load_config( config_dict = merge(config_dict, config_overrides) config = config_type(**config_dict) + + config.post_load() + return config diff --git a/src/programming/test/unit/test_config.py b/src/programming/test/unit/test_config.py index 998e0fa0..c1f7bc9d 100644 --- a/src/programming/test/unit/test_config.py +++ b/src/programming/test/unit/test_config.py @@ -6,6 +6,7 @@ ) from pydantic import BaseModel from pytest_mock import MockerFixture +from typing_extensions import override class FooConfig(BaseModel): @@ -18,15 +19,27 @@ class BarConfig(BaseModel): class BazConfig(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + value: str class TestConfig(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + foo: FooConfig = FooConfig(value="xyz") bar: BarConfig | None = None class InvalidConfigClass(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + pass diff --git a/src/programming/test/unit/test_dependency_injection.py b/src/programming/test/unit/test_dependency_injection.py index 3597d5e2..47fd7298 100644 --- a/src/programming/test/unit/test_dependency_injection.py +++ b/src/programming/test/unit/test_dependency_injection.py @@ -1,10 +1,14 @@ from injector import Injector from Ligare.programming.config import AbstractConfig from Ligare.programming.dependency_injection import ConfigModule +from typing_extensions import override def test__ConfigModule__injector_binds_Config_module_to_AbstractConfig_by_default(): - class FooConfig(AbstractConfig): ... + class FooConfig(AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() foo_config = FooConfig() config_module = ConfigModule(foo_config) @@ -15,6 +19,10 @@ class FooConfig(AbstractConfig): ... def test__ConfigModule__injector_binds_configured_Config_module(): class FooConfig(AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + x: int = 123 foo_config = FooConfig() diff --git a/src/web/BL_Python/web/exception.py b/src/web/BL_Python/web/exception.py new file mode 100644 index 00000000..a70953e1 --- /dev/null +++ b/src/web/BL_Python/web/exception.py @@ -0,0 +1,6 @@ +class InvalidBuilderStateError(Exception): + """The builder's state is invalid and the builder cannot execute `build()`.""" + + +class BuilderBuildError(Exception): + """The builder failed during execution of `build()`.""" diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index ae62ede5..9832183e 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -67,6 +67,10 @@ class FeatureFlagConfig(BaseModel): class Config(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + feature_flag: FeatureFlagConfig diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index 3a237d6a..7e5d639d 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -7,7 +7,18 @@ import logging from dataclasses import dataclass from os import environ, path -from typing import Generator, Generic, Optional, TypeVar, cast +from typing import ( + Any, + Callable, + Generator, + Generic, + Optional, + Protocol, + TypeVar, + cast, + final, + overload, +) import json_logging from connexion import FlaskApp @@ -16,10 +27,18 @@ from injector import Module from lib_programname import get_path_executed_script from Ligare.AWS.ssm import SSMParameters -from Ligare.programming.config import AbstractConfig, ConfigBuilder, load_config +from Ligare.programming.config import ( + AbstractConfig, + ConfigBuilder, + TConfig, + load_config, +) +from Ligare.programming.config.exceptions import ConfigBuilderStateError from Ligare.programming.dependency_injection import ConfigModule +from Ligare.web.exception import BuilderBuildError, InvalidBuilderStateError +from typing_extensions import Self -from .config import Config +from .config import Config, FlaskConfig from .middleware import ( register_api_request_handlers, register_api_response_handlers, @@ -34,6 +53,8 @@ TApp = Flask | FlaskApp T_app = TypeVar("T_app", bound=TApp) +TAppConfig = TypeVar("TAppConfig", bound=Config) + @dataclass class AppInjector(Generic[T_app]): @@ -83,6 +104,182 @@ def create( ) +class UseConfigurationCallback(Protocol): + def __call__( + self, + config_builder: ConfigBuilder[TConfig], + config_overrides: dict[str, Any], + ) -> None: ... + + +@final +class ApplicationConfigBuilder(Generic[TConfig]): + def __init__(self) -> None: + self._config_overrides: dict[str, Any] = {} + self._config_builder: ConfigBuilder[TConfig] = ConfigBuilder[TConfig]() + self._config_filename: str + self._use_filename: bool = False + self._use_ssm: bool = False + + def use_configuration(self, builder_callback: UseConfigurationCallback) -> Self: + builder_callback(self._config_builder, self._config_overrides) + return self + + def use_filename(self, filename: str) -> Self: + self._config_filename = filename + self._use_filename = True + return self + + def use_ssm(self, value: bool) -> Self: + """ + Try to load config from AWS SSM. If `use_filename` was configured, + a failed attempt to load from SSM will instead attempt to load from + the configured filename. If `use_filename` is not configured and SSM + fails, an exception is raised. If SSM succeeds, `build` will not + load from the configured filename. + + :param bool value: Whether to use SSM + :return Self: + """ + self._use_ssm = value + return self + + def build(self) -> TConfig | None | AbstractConfig: + if not (self._use_ssm or self._use_filename): + raise InvalidBuilderStateError( + "Cannot build the application config without either `use_ssm` or `use_filename` having been configured." + ) + + try: + config_type = self._config_builder.build() + except ConfigBuilderStateError as e: + raise BuilderBuildError( + "`use_configuration` must be called on `ApplicationConfigBuilder`, and a root config must be specified, before calling `build()`." + ) from e + + full_config: TConfig | None = None + SSM_FAIL_ERROR_MSG = "Unable to load configuration. SSM parameter load failed and the builder is configured not to load from a file." + if self._use_ssm: + try: + # requires that aws-ssm.ini exists and is correctly configured + ssm_parameters = SSMParameters() + full_config = ssm_parameters.load_config(config_type) + + if not self._use_filename and full_config is None: + raise BuilderBuildError(SSM_FAIL_ERROR_MSG) + except Exception as e: + if self._use_filename: + logging.getLogger().info("SSM parameter load failed.", exc_info=e) + else: + raise BuilderBuildError(SSM_FAIL_ERROR_MSG) from e + + if self._use_filename and full_config is None: + if self._config_overrides: + full_config = load_config( + config_type, self._config_filename, self._config_overrides + ) + else: + full_config = load_config(config_type, self._config_filename) + + return full_config + + +TAA = TypeVar("TAA") + + +@final +class ApplicationBuilder(Generic[T_app, TAppConfig]): + def __init__(self) -> None: + self._modules: list[Module | type[Module]] = [] + self._configs: list[type[AbstractConfig]] = [] + self._config_overrides: dict[str, Any] = {} + self._application_config_builder: ApplicationConfigBuilder[TAppConfig] + + @overload + def with_module(self, module: Module) -> Self: ... + @overload + def with_module(self, module: type[Module]) -> Self: ... + def with_module(self, module: Module | type[Module]) -> Self: + self._modules.append(module) + return self + + def use_configuration( + self, + config_builder_callback: Callable[[ApplicationConfigBuilder[TAppConfig]], None], + ) -> Self: + self._application_config_builder = ApplicationConfigBuilder[TAppConfig]() + config_builder_callback(self._application_config_builder) + return self + + def with_flask_app_name(self, value: str) -> Self: + self._config_overrides["app_name"] = value + return self + + def with_flask_env(self, value: str) -> Self: + self._config_overrides["env"] = value + return self + + def build(self) -> CreateAppResult[T_app]: + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + if "flask" not in config_overrides: + config_overrides["flask"] = {} + + if "app_name" in self._config_overrides: + config_overrides["flask"]["app_name"] = self._config_overrides[ + "app_name" + ] + + if "env" in self._config_overrides: + config_overrides["flask"]["env"] = self._config_overrides["env"] + + config = cast( + TAppConfig, + self._application_config_builder.use_configuration( + use_configuration + ).build(), + ) + + if config.flask is None: + raise Exception("You must set [flask] in the application configuration.") + + if not config.flask.app_name: + raise Exception( + "You must set the Flask application name in the [flask.app_name] config or FLASK_APP envvar." + ) + + app: T_app + + if config.flask.openapi is not None: + openapi = configure_openapi(config) + app = cast(T_app, openapi) + else: + app = cast(T_app, configure_blueprint_routes(config)) + + register_error_handlers(app) + _ = register_api_request_handlers(app) + _ = register_api_response_handlers(app) + _ = register_context_middleware(app) + + application_modules = [ + ConfigModule(config, type(config)) + for (_, config) in cast( + Generator[tuple[str, AbstractConfig], None, None], config + ) + ] + (self._modules if self._modules else []) + # The `config` module cannot be overridden unless the application + # IoC container is fiddled with. `config` is the instance registered + # to `AbstractConfig`. + modules = application_modules + [ConfigModule(config, Config)] + flask_injector = configure_dependencies(app, application_modules=modules) + + flask_app = app.app if isinstance(app, FlaskApp) else app + return CreateAppResult[T_app]( + flask_app, AppInjector[T_app](app, flask_injector) + ) + + def create_app( config_filename: str = "config.toml", # FIXME should be a list of PydanticDataclass diff --git a/src/web/Ligare/web/config.py b/src/web/Ligare/web/config.py index e92696c5..b5de5ece 100644 --- a/src/web/Ligare/web/config.py +++ b/src/web/Ligare/web/config.py @@ -2,8 +2,11 @@ from os import environ from typing import Literal +from BL_Python.programming.config import AbstractConfig from flask.config import Config as FlaskAppConfig +from Ligare.programming.config import AbstractConfig from pydantic import BaseModel +from typing_extensions import override class LoggingConfig(BaseModel): @@ -141,9 +144,6 @@ class ConfigObject: flask_app_config.from_object(ConfigObject) -from Ligare.programming.config import AbstractConfig - - class Config(BaseModel, AbstractConfig): logging: LoggingConfig = LoggingConfig() web: WebConfig = WebConfig() @@ -158,3 +158,7 @@ def update_flask_config(self, flask_app_config: FlaskAppConfig): self.flask._update_flask_config( # pyright: ignore[reportPrivateUsage] flask_app_config ) + + @override + def post_load(self) -> None: + self.prepare_env_for_flask() diff --git a/src/web/test/unit/application/test_create_flask_app.py b/src/web/test/unit/application/test_create_flask_app.py index b5dfae26..580d58c8 100644 --- a/src/web/test/unit/application/test_create_flask_app.py +++ b/src/web/test/unit/application/test_create_flask_app.py @@ -16,6 +16,7 @@ from mock import MagicMock from pydantic import BaseModel from pytest_mock import MockerFixture +from typing_extensions import override class TestCreateFlaskApp(CreateFlaskApp): @@ -281,6 +282,10 @@ def test__CreateFlaskApp__create_app__uses_custom_config_types( _ = mocker.patch("toml.load", return_value=toml_load_result) class CustomConfig(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + foo: str = get_random_str(k=26) app = App[Flask].create( diff --git a/src/web/test/unit/application/test_create_openapi_app.py b/src/web/test/unit/application/test_create_openapi_app.py index 15c2f384..1f4d85a8 100644 --- a/src/web/test/unit/application/test_create_openapi_app.py +++ b/src/web/test/unit/application/test_create_openapi_app.py @@ -11,6 +11,7 @@ from mock import MagicMock from pydantic import BaseModel from pytest_mock import MockerFixture +from typing_extensions import override class TestCreateOpenAPIApp(CreateOpenAPIApp): @@ -76,6 +77,10 @@ def test__CreateOpenAPIApp__create_app__uses_custom_config_types( _ = mocker.patch("toml.load", return_value=toml_load_result) class CustomConfig(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + foo: str = get_random_str(k=26) app = App[Flask].create( diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index ae36a857..8107fc86 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -1,8 +1,17 @@ +from typing import Any + import pytest +from flask import Flask from Ligare.programming.collections.dict import AnyDict -from Ligare.programming.config import load_config +from Ligare.programming.config import AbstractConfig, ConfigBuilder, load_config +from Ligare.programming.config.exceptions import ConfigBuilderStateError +from Ligare.web.application import ApplicationBuilder, ApplicationConfigBuilder from Ligare.web.config import Config +from Ligare.web.exception import BuilderBuildError, InvalidBuilderStateError +from mock import MagicMock +from pydantic import BaseModel from pytest_mock import MockerFixture +from typing_extensions import override def test__Config__load_config__reads_toml_file(mocker: MockerFixture): @@ -37,9 +46,226 @@ def test__Config__prepare_env_for_flask__requires_flask_secret_key_when_sessions } _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) - config = load_config(Config, "foo.toml") with pytest.raises( Exception, match=r"^`flask.session.cookie.secret_key` must be set in config.$" ): - config.prepare_env_for_flask() + config = load_config(Config, "foo.toml") + + +@pytest.mark.parametrize("mode", ["ssm", "filename"]) +def test__ApplicationConfigBuilder__build__succeeds_with_either_ssm_or_filename( + mode: str, mocker: MockerFixture +): + fake_config_dict = {"logging": {"log_level": "DEBUG"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + _ = mocker.patch("Ligare.AWS.ssm.SSMParameters.load_config") + + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + _ = config_builder.with_root_config(Config) + + application_config_builder = ApplicationConfigBuilder[Config]().use_configuration( + use_configuration + ) + + if mode == "ssm": + _ = application_config_builder.use_ssm(True) + else: + _ = application_config_builder.use_filename("foo.toml") + + _ = application_config_builder.build() + + +def test__ApplicationConfigBuilder__build__raises_InvalidBuilderStateError_without_ssm_or_filename( + mocker: MockerFixture, +): + fake_config_dict = {"logging": {"log_level": "DEBUG"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + _ = mocker.patch("Ligare.AWS.ssm.SSMParameters.load_config") + + application_config_builder = ApplicationConfigBuilder[Config]() + + with pytest.raises(InvalidBuilderStateError): + _ = application_config_builder.build() + + +def test__ApplicationConfigBuilder__build__raises_BuilderBuildError_when_ssm_fails_and_filename_not_configured( + mocker: MockerFixture, +): + fake_config_dict = {"logging": {"log_level": "DEBUG"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + _ = mocker.patch( + "Ligare.AWS.ssm.SSMParameters.load_config", + side_effect=Exception("Test mode failure."), + ) + + application_config_builder = ApplicationConfigBuilder[Config]().use_ssm(True) + + with pytest.raises(BuilderBuildError): + _ = application_config_builder.build() + + +def test__ApplicationConfigBuilder__build__uses_filename_when_ssm_fails( + mocker: MockerFixture, +): + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads") + _ = mocker.patch( + "Ligare.AWS.ssm.SSMParameters.load_config", + side_effect=Exception("Test mode failure."), + ) + toml_mock = mocker.patch("toml.load") + + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + _ = config_builder.with_root_config(Config) + + application_config_builder = ( + ApplicationConfigBuilder[Config]() + .use_configuration(use_configuration) + .use_filename("foo.toml") + ) + + _ = application_config_builder.build() + + assert toml_mock.called + + +def test__ApplicationConfigBuilder__build__calls_configuration_callback( + mocker: MockerFixture, +): + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads") + _ = mocker.patch("toml.load") + + use_configuration_mock = MagicMock() + + application_config_builder = ( + ApplicationConfigBuilder[Config]() + .use_configuration(use_configuration_mock) + .use_filename("foo.toml") + ) + + with pytest.raises(Exception): + _ = application_config_builder.build() + + use_configuration_mock.assert_called_once() + call_args = use_configuration_mock.call_args.args + assert isinstance(call_args[0], ConfigBuilder) + assert isinstance(call_args[1], dict) + + +def test__ApplicationConfigBuilder__build__requires_root_config( + mocker: MockerFixture, +): + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads") + + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + pass + + application_config_builder = ( + ApplicationConfigBuilder[Config]() + .use_configuration(use_configuration) + .use_filename("foo.toml") + ) + + with pytest.raises( + BuilderBuildError, match="`use_configuration` must be called" + ) as e: + _ = application_config_builder.build() + + assert isinstance(e.value.__cause__, ConfigBuilderStateError) + + +def test__ApplicationConfigBuilder__build__applies_additional_configs( + mocker: MockerFixture, +): + fake_config_dict = {"logging": {"log_level": "DEBUG"}, "test": {"foo": "bar"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + + class TestConfig(BaseModel, AbstractConfig): + @override + def post_load(self) -> None: + return super().post_load() + + foo: str + + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + _ = config_builder.with_root_config(Config).with_configs([TestConfig]) + + application_config_builder = ( + ApplicationConfigBuilder[Config]() + .use_configuration(use_configuration) + .use_filename("foo.toml") + ) + config = application_config_builder.build() + + assert config is not None + assert hasattr(config, "test") + assert hasattr(getattr(config, "test"), "foo") + assert getattr(getattr(config, "test"), "foo") == "bar" + + +def test__ApplicationConfigBuilder__build__applies_config_overrides( + mocker: MockerFixture, +): + fake_config_dict = {"logging": {"log_level": "DEBUG"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + _ = config_builder.with_root_config(Config) + config_overrides["logging"] = {"log_level": "INFO"} + + application_config_builder = ( + ApplicationConfigBuilder[Config]() + .use_configuration(use_configuration) + .use_filename("foo.toml") + ) + config = application_config_builder.build() + + assert config is not None + assert hasattr(config, "logging") + assert hasattr(getattr(config, "logging"), "log_level") + assert getattr(getattr(config, "logging"), "log_level") == "INFO" + + +# FIXME move to application tests +def test__ApplicationBuilder__build__something(mocker: MockerFixture): + fake_config_dict = {"logging": {"log_level": "DEBUG"}, "flask": {"app_name": "app"}} + _ = mocker.patch("io.open") + _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) + + def use_configuration(application_config_builder: ApplicationConfigBuilder[Config]): + def use_configuration( + config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + ) -> None: + _ = config_builder.with_root_config(Config) + + _ = application_config_builder.use_configuration( + use_configuration + ).use_filename("foo.toml") + + application_builder = ApplicationBuilder[Flask, Config]().use_configuration( + use_configuration + ) + + _ = ( + application_builder.with_flask_app_name("overridden_app") + .with_flask_env("overridden_dev") + .build() + ) From f10e79638ab512ad99fa753f4daa169f7f65815f Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Mon, 9 Sep 2024 14:43:06 -0700 Subject: [PATCH 10/41] Move old application bootstrapping into builders. --- .../Ligare/database/dependency_injection.py | 9 +- .../Ligare/identity/dependency_injection.py | 6 +- .../Ligare/programming/config/__init__.py | 17 +- .../patterns/dependency_injection.py | 10 + src/web/Ligare/web/application.py | 181 ++++++++---------- src/web/Ligare/web/middleware/sso.py | 9 +- src/web/test/unit/test_config.py | 2 +- 7 files changed, 119 insertions(+), 115 deletions(-) diff --git a/src/database/Ligare/database/dependency_injection.py b/src/database/Ligare/database/dependency_injection.py index 8900a616..84eb1e0e 100644 --- a/src/database/Ligare/database/dependency_injection.py +++ b/src/database/Ligare/database/dependency_injection.py @@ -2,7 +2,9 @@ from Ligare.database.config import Config, DatabaseConfig from Ligare.database.engine import DatabaseEngine from Ligare.database.types import MetaBase +from Ligare.programming.config import AbstractConfig from Ligare.programming.dependency_injection import ConfigModule +from Ligare.programming.patterns.dependency_injection import ConfigurableModule from sqlalchemy.orm.scoping import ScopedSession from sqlalchemy.orm.session import Session from typing_extensions import override @@ -10,11 +12,16 @@ from .config import DatabaseConfig -class ScopedSessionModule(Module): +class ScopedSessionModule(ConfigurableModule): # Module): """ Configure SQLAlchemy Session depedencies for Injector. """ + @override + @staticmethod + def get_config_type() -> type[AbstractConfig]: + return DatabaseConfig + _bases: list[MetaBase | type[MetaBase]] | None = None @override diff --git a/src/identity/Ligare/identity/dependency_injection.py b/src/identity/Ligare/identity/dependency_injection.py index 06e72cfe..fff58eec 100644 --- a/src/identity/Ligare/identity/dependency_injection.py +++ b/src/identity/Ligare/identity/dependency_injection.py @@ -10,11 +10,7 @@ class SSOModule(Module): - def __init__(self): # , metadata: str, settings: AnyDict) -> None: - """ - metadata can be XML or a URL - """ - super().__init__() + pass class SAML2Module(SSOModule): diff --git a/src/programming/Ligare/programming/config/__init__.py b/src/programming/Ligare/programming/config/__init__.py index 6beb773d..90b4d08d 100644 --- a/src/programming/Ligare/programming/config/__init__.py +++ b/src/programming/Ligare/programming/config/__init__.py @@ -8,6 +8,7 @@ ConfigBuilderStateError, NotEndsWithConfigError, ) +from typing_extensions import Self class AbstractConfig(abc.ABC): @@ -23,12 +24,20 @@ class ConfigBuilder(Generic[TConfig]): _root_config: type[TConfig] | None = None _configs: list[type[AbstractConfig]] | None = None - def with_root_config(self, config: type[TConfig]): - self._root_config = config + def with_root_config(self, config_type: type[TConfig]) -> Self: + self._root_config = config_type return self - def with_configs(self, configs: list[type[AbstractConfig]]): - self._configs = configs + def with_configs(self, configs: list[type[AbstractConfig]] | None) -> Self: + if configs is not None: + self._configs = configs + return self + + def with_config(self, config_type: type[AbstractConfig]) -> Self: + if self._configs is None: + self._configs = [] + + self._configs.append(config_type) return self def build(self) -> type[TConfig]: diff --git a/src/programming/Ligare/programming/patterns/dependency_injection.py b/src/programming/Ligare/programming/patterns/dependency_injection.py index 40be53c3..1682e954 100644 --- a/src/programming/Ligare/programming/patterns/dependency_injection.py +++ b/src/programming/Ligare/programming/patterns/dependency_injection.py @@ -2,6 +2,7 @@ import sys from typing import Callable, TypeVar +from BL_Python.programming.config import AbstractConfig from injector import Binder, Module, Provider from typing_extensions import override @@ -40,3 +41,12 @@ def __init__( def configure(self, binder: Binder) -> None: for interface, to in self._registrations.items(): binder.bind(interface, to) + + +from abc import ABC, abstractmethod + + +class ConfigurableModule(Module, ABC): + @staticmethod + @abstractmethod + def get_config_type() -> type[AbstractConfig]: ... diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index 7e5d639d..ebde14e3 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -35,10 +35,11 @@ ) from Ligare.programming.config.exceptions import ConfigBuilderStateError from Ligare.programming.dependency_injection import ConfigModule +from Ligare.programming.patterns.dependency_injection import ConfigurableModule from Ligare.web.exception import BuilderBuildError, InvalidBuilderStateError from typing_extensions import Self -from .config import Config, FlaskConfig +from .config import Config from .middleware import ( register_api_request_handlers, register_api_response_handlers, @@ -104,12 +105,12 @@ def create( ) -class UseConfigurationCallback(Protocol): +class UseConfigurationCallback(Protocol[TConfig]): def __call__( self, config_builder: ConfigBuilder[TConfig], config_overrides: dict[str, Any], - ) -> None: ... + ) -> "None | ConfigBuilder[TConfig]": ... @final @@ -121,8 +122,20 @@ def __init__(self) -> None: self._use_filename: bool = False self._use_ssm: bool = False - def use_configuration(self, builder_callback: UseConfigurationCallback) -> Self: - builder_callback(self._config_builder, self._config_overrides) + def with_config(self, config_type: type[AbstractConfig]) -> Self: + _ = self._config_builder.with_config(config_type) + return self + + def use_configuration( + self, builder_callback: UseConfigurationCallback[TConfig] + ) -> Self: + """ + Execute changes to the builder's `ConfigBuilder[TConfig]` instance. + + `builder_callback` can return `None`, or the instance of `ConfigBuilder[TConfig]` passed to its `config_builder` argument. + This allowance is so lambdas can be used; `ApplicationConfigBuilder[TConfig]` does not use the return value. + """ + _ = builder_callback(self._config_builder, self._config_overrides) return self def use_filename(self, filename: str) -> Self: @@ -184,7 +197,11 @@ def build(self) -> TConfig | None | AbstractConfig: return full_config -TAA = TypeVar("TAA") +class ApplicationConfigBuilderCallback(Protocol[TAppConfig]): + def __call__( + self, + config_builder: ApplicationConfigBuilder[TAppConfig], + ) -> "None | ApplicationConfigBuilder[TAppConfig]": ... @final @@ -193,22 +210,57 @@ def __init__(self) -> None: self._modules: list[Module | type[Module]] = [] self._configs: list[type[AbstractConfig]] = [] self._config_overrides: dict[str, Any] = {} - self._application_config_builder: ApplicationConfigBuilder[TAppConfig] + self._application_config_builder: ApplicationConfigBuilder[TAppConfig] = ( + ApplicationConfigBuilder[TAppConfig]() + ) @overload def with_module(self, module: Module) -> Self: ... @overload def with_module(self, module: type[Module]) -> Self: ... def with_module(self, module: Module | type[Module]) -> Self: + # FIXME something's up with this + if isinstance(module, ConfigurableModule): + _ = self._application_config_builder.with_config(module.get_config_type()) + self._modules.append(module) return self + def with_modules(self, modules: list[Module | type[Module]] | None) -> Self: + if modules is not None: + self._modules.extend(modules) + return self + + @overload def use_configuration( self, - config_builder_callback: Callable[[ApplicationConfigBuilder[TAppConfig]], None], + __builder_callback: ApplicationConfigBuilderCallback[TAppConfig], ) -> Self: - self._application_config_builder = ApplicationConfigBuilder[TAppConfig]() - config_builder_callback(self._application_config_builder) + """ + Execute changes to the builder's `ApplicationConfigBuilder[TAppConfig]` instance. + + `__builder_callback` can return `None`, or the instance of `ApplicationConfigBuilder[TAppConfig]` passed to its `config_builder` argument. + This allowance is so lambdas can be used; `ApplicationBuilder[T_app, TAppConfig]` does not use the return value. + """ + ... + + @overload + def use_configuration( + self, __config_builder: ApplicationConfigBuilder[TAppConfig] + ) -> Self: + """Replace the builder's default `ApplicationConfigBuilder[TAppConfig]` instance, or any instance previously assigned.""" + ... + + def use_configuration( + self, + application_config_builder: ApplicationConfigBuilderCallback[TAppConfig] + | ApplicationConfigBuilder[TAppConfig], + ) -> Self: + if callable(application_config_builder): + _ = application_config_builder(self._application_config_builder) + else: + self._application_config_builder = application_config_builder + return self def with_flask_app_name(self, value: str) -> Self: @@ -221,8 +273,9 @@ def with_flask_env(self, value: str) -> Self: def build(self) -> CreateAppResult[T_app]: def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] + config_builder: ConfigBuilder[TConfig], config_overrides: dict[str, Any] ) -> None: + """Handle "legacy" Flask envvar values and override them in the generated config object.""" if "flask" not in config_overrides: config_overrides["flask"] = {} @@ -298,83 +351,22 @@ def create_app( # also required to call before json_logging.config_root_logger() logging.basicConfig(force=True) - config_overrides = {} - if environ.get("FLASK_APP"): - config_overrides["app_name"] = environ["FLASK_APP"] - - if environ.get("FLASK_ENV"): - config_overrides["env"] = environ["FLASK_ENV"] - - config_type = Config - if application_configs is not None: - # fmt: off - config_type = ConfigBuilder[Config]()\ - .with_root_config(Config)\ - .with_configs(application_configs)\ - .build() - # fmt: on - - full_config: Config | None = None - try: - # requires that aws-ssm.ini exists and is correctly configured - ssm_parameters = SSMParameters() - full_config = ssm_parameters.load_config(config_type) - except Exception as e: - logging.getLogger().warning(f"SSM parameter load failed: {e}") - - if full_config is None: - if config_overrides: - full_config = load_config( - config_type, config_filename, {"flask": config_overrides} + application_builder = ( + ApplicationBuilder[TApp, Config]() + .with_modules(application_modules) + .use_configuration( + lambda config_builder: config_builder.use_ssm(True) + .use_filename(config_filename) + .use_configuration( + lambda config_builder, + config_overrides: config_builder.with_root_config(Config).with_configs( + application_configs + ) ) - else: - full_config = load_config(config_type, config_filename) - - full_config.prepare_env_for_flask() - - if full_config.flask is None: - raise Exception("You must set [flask] in the application configuration.") - - if not full_config.flask.app_name: - raise Exception( - "You must set the Flask application name in the [flask.app_name] config or FLASK_APP envvar." - ) - - app: Flask | FlaskApp - - if full_config.flask.openapi is not None: - openapi = configure_openapi(full_config) - app = openapi - else: - app = configure_blueprint_routes(full_config) - - register_error_handlers(app) - _ = register_api_request_handlers(app) - _ = register_api_response_handlers(app) - _ = register_context_middleware(app) - # register_app_teardown_handlers(app) - - # Register every subconfig as a ConfigModule. - # This will allow subpackages to resolve their own config types, - # allow for type safety against objects of those types. - # Otherwise, they can resolve `AbstractConfig`, but then type - # safety is lost. - # Note that, if any `ConfigModule` is provided in `application_modules`, - # those will override the automatically generated `ConfigModule`s. - application_modules = [ - ConfigModule(config, type(config)) - for (_, config) in cast( - Generator[tuple[str, AbstractConfig], None, None], full_config ) - ] + (application_modules if application_modules else []) - # The `full_config` module cannot be overridden unless the application - # IoC container is fiddled with. `full_config` is the instance registered - # to `AbstractConfig`. - modules = application_modules + [ConfigModule(full_config, Config)] - flask_injector = configure_dependencies(app, application_modules=modules) - - flask_app = app.app if isinstance(app, FlaskApp) else app - return CreateAppResult(flask_app, AppInjector(app, flask_injector)) + ) + app = application_builder.build() + return app def configure_openapi(config: Config, name: Optional[str] = None): @@ -391,26 +383,10 @@ def configure_openapi(config: Config, name: Optional[str] = None): "OpenAPI configuration is empty. Review the `openapi` section of your application's `config.toml`." ) - ## host configuration set up - ## TODO host/port setup should move into application initialization - ## and not be tied to connexion configuration - # host = "127.0.0.1" - # port = 5000 - ## TODO replace SERVER_NAME with host/port in config - # if environ.get("SERVER_NAME") is not None: - # (host, port_str) = environ["SERVER_NAME"].split(":") - # port = int(port_str) - - # connexion and openapi set up - # openapi_spec_dir: str = "app/swagger/" - # if environ.get("OPENAPI_SPEC_DIR"): - # openapi_spec_dir = environ["OPENAPI_SPEC_DIR"] - exec_dir = _get_exec_dir() connexion_app = FlaskApp( config.flask.app_name, - # TODO support relative OPENAPI_SPEC_DIR and prepend program_dir? specification_dir=exec_dir, # host=host, # port=port, @@ -510,7 +486,6 @@ def _import_blueprint_modules(app: Flask, blueprint_import_subdir: str): # find all Flask blueprints in # the module and register them for module_name, module_var in vars(module).items(): - # TODO why did we allow _blueprint when it's not a Blueprint? if module_name.endswith("_blueprint") or isinstance(module_var, Blueprint): blueprint_modules.append(module_var) diff --git a/src/web/Ligare/web/middleware/sso.py b/src/web/Ligare/web/middleware/sso.py index b4e29003..1723ec26 100644 --- a/src/web/Ligare/web/middleware/sso.py +++ b/src/web/Ligare/web/middleware/sso.py @@ -42,6 +42,8 @@ from Ligare.identity.dependency_injection import SAML2Module, SSOModule from Ligare.identity.SAML2 import SAML2Client from Ligare.platform.identity.user_loader import Role, UserId, UserLoader, UserMixin +from Ligare.programming.config import AbstractConfig +from Ligare.programming.patterns.dependency_injection import ConfigurableModule from Ligare.web.config import Config from Ligare.web.encryption import decrypt_flask_cookie from saml2.validate import ( @@ -439,7 +441,12 @@ def unauthorized(self): raise Unauthorized(response.data, response) -class SAML2MiddlewareModule(Module): +class SAML2MiddlewareModule(ConfigurableModule): # Module): + @override + @staticmethod + def get_config_type() -> type[AbstractConfig]: + return SSOConfig + @override def configure(self, binder: Binder) -> None: binder.install(SAML2Module) diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index 8107fc86..506ebc45 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -50,7 +50,7 @@ def test__Config__prepare_env_for_flask__requires_flask_secret_key_when_sessions with pytest.raises( Exception, match=r"^`flask.session.cookie.secret_key` must be set in config.$" ): - config = load_config(Config, "foo.toml") + _ = load_config(Config, "foo.toml") @pytest.mark.parametrize("mode", ["ssm", "filename"]) From e8a2d4374cf904d1c6cf2b4e5c3822de54e14c29 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 10 Sep 2024 15:41:53 -0700 Subject: [PATCH 11/41] Add a nested dictionary type with generic keys. --- src/programming/Ligare/programming/collections/dict.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/programming/Ligare/programming/collections/dict.py b/src/programming/Ligare/programming/collections/dict.py index 97ab78d2..cf11cc9e 100644 --- a/src/programming/Ligare/programming/collections/dict.py +++ b/src/programming/Ligare/programming/collections/dict.py @@ -1,8 +1,11 @@ from __future__ import annotations -from typing import Any, Union +from typing import Any, TypeVar, Union AnyDict = dict[Any, Union[Any, "AnyDict"]] +TKey = TypeVar("TKey") +TValue = TypeVar("TValue") +NestedDict = dict[TKey, Union[TValue, "NestedDict"]] def merge(a: AnyDict, b: AnyDict, skip_existing: bool = False): From 3cde543df295950e20481f6acf8bcc5c8041864c Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 10 Sep 2024 15:51:52 -0700 Subject: [PATCH 12/41] Further updates to builders. Update associated tests. --- src/web/Ligare/web/application.py | 112 ++++++++++++++++-------------- src/web/test/unit/test_config.py | 101 ++++++--------------------- 2 files changed, 81 insertions(+), 132 deletions(-) diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index ebde14e3..6a39cf7b 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -5,11 +5,11 @@ """ import logging +from collections import defaultdict from dataclasses import dataclass from os import environ, path from typing import ( Any, - Callable, Generator, Generic, Optional, @@ -27,6 +27,7 @@ from injector import Module from lib_programname import get_path_executed_script from Ligare.AWS.ssm import SSMParameters +from Ligare.programming.collections.dict import NestedDict from Ligare.programming.config import ( AbstractConfig, ConfigBuilder, @@ -116,34 +117,38 @@ def __call__( @final class ApplicationConfigBuilder(Generic[TConfig]): def __init__(self) -> None: - self._config_overrides: dict[str, Any] = {} + self._config_value_overrides: dict[str, Any] = {} self._config_builder: ConfigBuilder[TConfig] = ConfigBuilder[TConfig]() self._config_filename: str self._use_filename: bool = False self._use_ssm: bool = False - def with_config(self, config_type: type[AbstractConfig]) -> Self: - _ = self._config_builder.with_config(config_type) + def with_config_builder(self, config_builder: ConfigBuilder[TConfig]) -> Self: + self._config_builder = config_builder return self - def use_configuration( - self, builder_callback: UseConfigurationCallback[TConfig] - ) -> Self: - """ - Execute changes to the builder's `ConfigBuilder[TConfig]` instance. + def with_root_config_type(self, config_type: type[TConfig]) -> Self: + _ = self._config_builder.with_root_config(config_type) + return self - `builder_callback` can return `None`, or the instance of `ConfigBuilder[TConfig]` passed to its `config_builder` argument. - This allowance is so lambdas can be used; `ApplicationConfigBuilder[TConfig]` does not use the return value. - """ - _ = builder_callback(self._config_builder, self._config_overrides) + def with_config_types(self, configs: list[type[AbstractConfig]] | None) -> Self: + _ = self._config_builder.with_configs(configs) return self - def use_filename(self, filename: str) -> Self: + def with_config_type(self, config_type: type[AbstractConfig]) -> Self: + _ = self._config_builder.with_config(config_type) + return self + + def with_config_value_overrides(self, values: dict[str, Any]) -> Self: + self._config_value_overrides = values + return self + + def with_config_filename(self, filename: str) -> Self: self._config_filename = filename self._use_filename = True return self - def use_ssm(self, value: bool) -> Self: + def enable_ssm(self, value: bool) -> Self: """ Try to load config from AWS SSM. If `use_filename` was configured, a failed attempt to load from SSM will instead attempt to load from @@ -157,7 +162,7 @@ def use_ssm(self, value: bool) -> Self: self._use_ssm = value return self - def build(self) -> TConfig | None | AbstractConfig: + def build(self) -> TConfig | None: if not (self._use_ssm or self._use_filename): raise InvalidBuilderStateError( "Cannot build the application config without either `use_ssm` or `use_filename` having been configured." @@ -167,7 +172,7 @@ def build(self) -> TConfig | None | AbstractConfig: config_type = self._config_builder.build() except ConfigBuilderStateError as e: raise BuilderBuildError( - "`use_configuration` must be called on `ApplicationConfigBuilder`, and a root config must be specified, before calling `build()`." + "A root config must be specified using `with_root_config` before calling `build()`." ) from e full_config: TConfig | None = None @@ -187,9 +192,9 @@ def build(self) -> TConfig | None | AbstractConfig: raise BuilderBuildError(SSM_FAIL_ERROR_MSG) from e if self._use_filename and full_config is None: - if self._config_overrides: + if self._config_value_overrides: full_config = load_config( - config_type, self._config_filename, self._config_overrides + config_type, self._config_filename, self._config_value_overrides ) else: full_config = load_config(config_type, self._config_filename) @@ -208,7 +213,6 @@ def __call__( class ApplicationBuilder(Generic[T_app, TAppConfig]): def __init__(self) -> None: self._modules: list[Module | type[Module]] = [] - self._configs: list[type[AbstractConfig]] = [] self._config_overrides: dict[str, Any] = {} self._application_config_builder: ApplicationConfigBuilder[TAppConfig] = ( ApplicationConfigBuilder[TAppConfig]() @@ -221,7 +225,9 @@ def with_module(self, module: type[Module]) -> Self: ... def with_module(self, module: Module | type[Module]) -> Self: # FIXME something's up with this if isinstance(module, ConfigurableModule): - _ = self._application_config_builder.with_config(module.get_config_type()) + _ = self._application_config_builder.with_config_type( + module.get_config_type() + ) self._modules.append(module) return self @@ -234,7 +240,9 @@ def with_modules(self, modules: list[Module | type[Module]] | None) -> Self: @overload def use_configuration( self, - __builder_callback: ApplicationConfigBuilderCallback[TAppConfig], + __application_config_builder_callback: ApplicationConfigBuilderCallback[ + TAppConfig + ], ) -> Self: """ Execute changes to the builder's `ApplicationConfigBuilder[TAppConfig]` instance. @@ -246,7 +254,7 @@ def use_configuration( @overload def use_configuration( - self, __config_builder: ApplicationConfigBuilder[TAppConfig] + self, __application_config_builder: ApplicationConfigBuilder[TAppConfig] ) -> Self: """Replace the builder's default `ApplicationConfigBuilder[TAppConfig]` instance, or any instance previously assigned.""" ... @@ -263,36 +271,36 @@ def use_configuration( return self - def with_flask_app_name(self, value: str) -> Self: + def with_flask_app_name(self, value: str | None) -> Self: self._config_overrides["app_name"] = value return self - def with_flask_env(self, value: str) -> Self: + def with_flask_env(self, value: str | None) -> Self: self._config_overrides["env"] = value return self def build(self) -> CreateAppResult[T_app]: - def use_configuration( - config_builder: ConfigBuilder[TConfig], config_overrides: dict[str, Any] - ) -> None: - """Handle "legacy" Flask envvar values and override them in the generated config object.""" - if "flask" not in config_overrides: - config_overrides["flask"] = {} - - if "app_name" in self._config_overrides: - config_overrides["flask"]["app_name"] = self._config_overrides[ - "app_name" - ] - - if "env" in self._config_overrides: - config_overrides["flask"]["env"] = self._config_overrides["env"] - - config = cast( - TAppConfig, - self._application_config_builder.use_configuration( - use_configuration - ).build(), + config_overrides: NestedDict[str, Any] = defaultdict(dict) + + if ( + override_app_name := self._config_overrides.get("app_name", None) + ) is not None and override_app_name != "": + config_overrides["flask"]["app_name"] = override_app_name + + if ( + override_env := self._config_overrides.get("env", None) + ) is not None and override_env != "": + config_overrides["flask"]["env"] = override_env + + _ = self._application_config_builder.with_config_value_overrides( + config_overrides ) + config = self._application_config_builder.build() + + if config is None: + raise BuilderBuildError( + "Failed to load the application configuration correctly." + ) if config.flask is None: raise Exception("You must set [flask] in the application configuration.") @@ -353,16 +361,14 @@ def create_app( application_builder = ( ApplicationBuilder[TApp, Config]() + .with_flask_app_name(environ.get("FLASK_APP", None)) + .with_flask_env(environ.get("FLASK_ENV", None)) .with_modules(application_modules) .use_configuration( - lambda config_builder: config_builder.use_ssm(True) - .use_filename(config_filename) - .use_configuration( - lambda config_builder, - config_overrides: config_builder.with_root_config(Config).with_configs( - application_configs - ) - ) + lambda config_builder: config_builder.enable_ssm(True) + .with_config_filename(config_filename) + .with_root_config_type(Config) + .with_config_types(application_configs) ) ) app = application_builder.build() diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index 506ebc45..41ff6c33 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -62,19 +62,14 @@ def test__ApplicationConfigBuilder__build__succeeds_with_either_ssm_or_filename( _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) _ = mocker.patch("Ligare.AWS.ssm.SSMParameters.load_config") - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - _ = config_builder.with_root_config(Config) - - application_config_builder = ApplicationConfigBuilder[Config]().use_configuration( - use_configuration - ) + application_config_builder = ApplicationConfigBuilder[ + Config + ]().with_root_config_type(Config) if mode == "ssm": - _ = application_config_builder.use_ssm(True) + _ = application_config_builder.enable_ssm(True) else: - _ = application_config_builder.use_filename("foo.toml") + _ = application_config_builder.with_config_filename("foo.toml") _ = application_config_builder.build() @@ -104,7 +99,7 @@ def test__ApplicationConfigBuilder__build__raises_BuilderBuildError_when_ssm_fai side_effect=Exception("Test mode failure."), ) - application_config_builder = ApplicationConfigBuilder[Config]().use_ssm(True) + application_config_builder = ApplicationConfigBuilder[Config]().enable_ssm(True) with pytest.raises(BuilderBuildError): _ = application_config_builder.build() @@ -121,15 +116,10 @@ def test__ApplicationConfigBuilder__build__uses_filename_when_ssm_fails( ) toml_mock = mocker.patch("toml.load") - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - _ = config_builder.with_root_config(Config) - application_config_builder = ( ApplicationConfigBuilder[Config]() - .use_configuration(use_configuration) - .use_filename("foo.toml") + .with_root_config_type(Config) + .with_config_filename("foo.toml") ) _ = application_config_builder.build() @@ -137,49 +127,19 @@ def use_configuration( assert toml_mock.called -def test__ApplicationConfigBuilder__build__calls_configuration_callback( - mocker: MockerFixture, -): - _ = mocker.patch("io.open") - _ = mocker.patch("toml.decoder.loads") - _ = mocker.patch("toml.load") - - use_configuration_mock = MagicMock() - - application_config_builder = ( - ApplicationConfigBuilder[Config]() - .use_configuration(use_configuration_mock) - .use_filename("foo.toml") - ) - - with pytest.raises(Exception): - _ = application_config_builder.build() - - use_configuration_mock.assert_called_once() - call_args = use_configuration_mock.call_args.args - assert isinstance(call_args[0], ConfigBuilder) - assert isinstance(call_args[1], dict) - - def test__ApplicationConfigBuilder__build__requires_root_config( mocker: MockerFixture, ): _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads") - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - pass - - application_config_builder = ( - ApplicationConfigBuilder[Config]() - .use_configuration(use_configuration) - .use_filename("foo.toml") - ) + application_config_builder = ApplicationConfigBuilder[ + Config + ]().with_config_filename("foo.toml") with pytest.raises( - BuilderBuildError, match="`use_configuration` must be called" + BuilderBuildError, + match="A root config must be specified", ) as e: _ = application_config_builder.build() @@ -200,15 +160,11 @@ def post_load(self) -> None: foo: str - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - _ = config_builder.with_root_config(Config).with_configs([TestConfig]) - application_config_builder = ( ApplicationConfigBuilder[Config]() - .use_configuration(use_configuration) - .use_filename("foo.toml") + .with_root_config_type(Config) + .with_config_type(TestConfig) + .with_config_filename("foo.toml") ) config = application_config_builder.build() @@ -225,16 +181,11 @@ def test__ApplicationConfigBuilder__build__applies_config_overrides( _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - _ = config_builder.with_root_config(Config) - config_overrides["logging"] = {"log_level": "INFO"} - application_config_builder = ( ApplicationConfigBuilder[Config]() - .use_configuration(use_configuration) - .use_filename("foo.toml") + .with_root_config_type(Config) + .with_config_value_overrides({"logging": {"log_level": "INFO"}}) + .with_config_filename("foo.toml") ) config = application_config_builder.build() @@ -250,18 +201,10 @@ def test__ApplicationBuilder__build__something(mocker: MockerFixture): _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) - def use_configuration(application_config_builder: ApplicationConfigBuilder[Config]): - def use_configuration( - config_builder: ConfigBuilder[Config], config_overrides: dict[str, Any] - ) -> None: - _ = config_builder.with_root_config(Config) - - _ = application_config_builder.use_configuration( - use_configuration - ).use_filename("foo.toml") - application_builder = ApplicationBuilder[Flask, Config]().use_configuration( - use_configuration + lambda config_builder: config_builder.with_root_config_type( + Config + ).with_config_filename("foo.toml") ) _ = ( From 3ce52334478a6a6c81c131fe4a7dba356a3dbd71 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 10 Sep 2024 16:50:29 -0700 Subject: [PATCH 13/41] Fix automatic configuration of module config types. --- .../Ligare/programming/config/__init__.py | 8 +++++++- src/web/Ligare/web/application.py | 20 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/programming/Ligare/programming/config/__init__.py b/src/programming/Ligare/programming/config/__init__.py index 90b4d08d..29668023 100644 --- a/src/programming/Ligare/programming/config/__init__.py +++ b/src/programming/Ligare/programming/config/__init__.py @@ -29,8 +29,14 @@ def with_root_config(self, config_type: type[TConfig]) -> Self: return self def with_configs(self, configs: list[type[AbstractConfig]] | None) -> Self: - if configs is not None: + if configs is None: + return self + + if self._configs is None: self._configs = configs + else: + self._configs.extend(configs) + return self def with_config(self, config_type: type[AbstractConfig]) -> Self: diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index 6a39cf7b..63c573aa 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -116,10 +116,12 @@ def __call__( @final class ApplicationConfigBuilder(Generic[TConfig]): + _DEFAULT_CONFIG_FILENAME: str = "config.toml" + def __init__(self) -> None: self._config_value_overrides: dict[str, Any] = {} self._config_builder: ConfigBuilder[TConfig] = ConfigBuilder[TConfig]() - self._config_filename: str + self._config_filename: str = ApplicationConfigBuilder._DEFAULT_CONFIG_FILENAME self._use_filename: bool = False self._use_ssm: bool = False @@ -223,10 +225,10 @@ def with_module(self, module: Module) -> Self: ... @overload def with_module(self, module: type[Module]) -> Self: ... def with_module(self, module: Module | type[Module]) -> Self: - # FIXME something's up with this - if isinstance(module, ConfigurableModule): + module_type = type(module) if isinstance(module, Module) else module + if issubclass(module_type, ConfigurableModule): _ = self._application_config_builder.with_config_type( - module.get_config_type() + module_type.get_config_type() ) self._modules.append(module) @@ -234,7 +236,8 @@ def with_module(self, module: Module | type[Module]) -> Self: def with_modules(self, modules: list[Module | type[Module]] | None) -> Self: if modules is not None: - self._modules.extend(modules) + for module in modules: + _ = self.with_module(module) return self @overload @@ -346,14 +349,9 @@ def create_app( # FIXME should be a list of PydanticDataclass application_configs: list[type[AbstractConfig]] | None = None, application_modules: list[Module | type[Module]] | None = None, - # FIXME eventually should replace with builders - # and configurators so this list of params doesn't - # just grow and grow. - # startup_builder: IStartupBuilder, - # config: Config, ) -> CreateAppResult[TApp]: """ - Do not use this method directly. Instead, use `App[T_app].create()` + Do not use this method directly. Instead, use `App[T_app].create()` or `ApplicationBuilder[TApp, TConfig]()` """ # set up the default configuration as soon as possible # also required to call before json_logging.config_root_logger() From abefe0a26cdbf8e55e64a462eb5636a8cb60f6a8 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Wed, 11 Sep 2024 11:11:40 -0700 Subject: [PATCH 14/41] Use first application config for root by default. Also removed `TAppConfig` generics from application builder. --- .../Ligare/programming/config/__init__.py | 25 +++++++---- src/programming/test/unit/test_config.py | 44 +++++++++---------- src/web/Ligare/web/application.py | 35 ++++++++++----- src/web/test/unit/test_config.py | 2 +- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/programming/Ligare/programming/config/__init__.py b/src/programming/Ligare/programming/config/__init__.py index 29668023..8020d68c 100644 --- a/src/programming/Ligare/programming/config/__init__.py +++ b/src/programming/Ligare/programming/config/__init__.py @@ -19,10 +19,12 @@ def post_load(self) -> None: TConfig = TypeVar("TConfig", bound=AbstractConfig) +from collections import deque + class ConfigBuilder(Generic[TConfig]): _root_config: type[TConfig] | None = None - _configs: list[type[AbstractConfig]] | None = None + _configs: deque[type[AbstractConfig]] | None = None def with_root_config(self, config_type: type[TConfig]) -> Self: self._root_config = config_type @@ -33,7 +35,7 @@ def with_configs(self, configs: list[type[AbstractConfig]] | None) -> Self: return self if self._configs is None: - self._configs = configs + self._configs = deque(configs) else: self._configs.extend(configs) @@ -41,7 +43,7 @@ def with_configs(self, configs: list[type[AbstractConfig]] | None) -> Self: def with_config(self, config_type: type[AbstractConfig]) -> Self: if self._configs is None: - self._configs = [] + self._configs = deque() self._configs.append(config_type) return self @@ -55,16 +57,23 @@ def build(self) -> type[TConfig]: "Cannot build a config without any base config types specified." ) - _new_type_base = self._root_config if self._root_config else object + def test_type_name(config_type: type[AbstractConfig]): + if not config_type.__name__.endswith("Config"): + raise NotEndsWithConfigError( + f"Class name '{config_type.__name__}' is not a valid config class. The name must end with 'Config'" + ) + + _new_type_base = ( + self._root_config if self._root_config else self._configs.popleft() + ) + + test_type_name(_new_type_base) attrs: dict[Any, Any] = {} annotations: dict[str, Any] = {} for config in self._configs: - if not config.__name__.endswith("Config"): - raise NotEndsWithConfigError( - f"Class name '{config.__name__}' is not a valid config class. The name must end with 'Config'" - ) + test_type_name(config) config_name = config.__name__[: config.__name__.rindex("Config")].lower() annotations[config_name] = config diff --git a/src/programming/test/unit/test_config.py b/src/programming/test/unit/test_config.py index c1f7bc9d..e6a7e73f 100644 --- a/src/programming/test/unit/test_config.py +++ b/src/programming/test/unit/test_config.py @@ -10,12 +10,12 @@ class FooConfig(BaseModel): - value: str - other_value: bool = False + foo_value: str + foo_other_value: bool = False class BarConfig(BaseModel): - value: str + bar_value: str class BazConfig(BaseModel, AbstractConfig): @@ -23,7 +23,7 @@ class BazConfig(BaseModel, AbstractConfig): def post_load(self) -> None: return super().post_load() - value: str + baz_value: str class TestConfig(BaseModel, AbstractConfig): @@ -31,7 +31,7 @@ class TestConfig(BaseModel, AbstractConfig): def post_load(self) -> None: return super().post_load() - foo: FooConfig = FooConfig(value="xyz") + foo: FooConfig = FooConfig(foo_value="xyz") bar: BarConfig | None = None @@ -51,29 +51,29 @@ def test__Config__load_config__reads_toml_file(mocker: MockerFixture): def test__Config__load_config__initializes_section_config_value(mocker: MockerFixture): - fake_config_dict = {"foo": {"value": "abc123"}} + fake_config_dict = {"foo": {"foo_value": "abc123"}} _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) config = load_config(TestConfig, "foo.toml") - assert config.foo.value == "abc123" + assert config.foo.foo_value == "abc123" def test__Config__load_config__initializes_section_config(mocker: MockerFixture): - fake_config_dict = {"bar": {"value": "abc123"}} + fake_config_dict = {"bar": {"bar_value": "abc123"}} _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) config = load_config(TestConfig, "foo.toml") assert config.bar is not None - assert config.bar.value == "abc123" + assert config.bar.bar_value == "abc123" def test__Config__load_config__applies_overrides(mocker: MockerFixture): - fake_config_dict = {"foo": {"value": "abc123"}} - override_config_dict = {"foo": {"value": "XYZ"}} + fake_config_dict = {"foo": {"foo_value": "abc123"}} + override_config_dict = {"foo": {"foo_value": "XYZ"}} _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) config = load_config(TestConfig, "foo.toml", override_config_dict) - assert config.foo.value == override_config_dict["foo"]["value"] + assert config.foo.foo_value == override_config_dict["foo"]["foo_value"] def test__ConfigBuilder__build__raises_error_when_no_root_config_and_no_section_configs_specified(): @@ -83,20 +83,20 @@ def test__ConfigBuilder__build__raises_error_when_no_root_config_and_no_section_ def test__ConfigBuilder__build__raises_error_when_section_class_name_is_invalid(): - config_builder = ConfigBuilder[TestConfig]() + config_builder = ConfigBuilder[InvalidConfigClass()]() _ = config_builder.with_configs([InvalidConfigClass]) with pytest.raises(NotEndsWithConfigError): _ = config_builder.build() -def test__ConfigBuilder__build__uses_object_as_root_config_when_no_root_config_specified(): - config_builder = ConfigBuilder[TestConfig]() +def test__ConfigBuilder__build__uses_first_config_as_root_config_when_no_root_config_specified(): + config_builder = ConfigBuilder[BazConfig]() _ = config_builder.with_configs([BazConfig]) config_type = config_builder.build() assert TestConfig not in config_type.__mro__ - assert BazConfig not in config_type.__mro__ - assert hasattr(config_type, "baz") - assert hasattr(config_type(), "baz") + assert BazConfig in config_type.__mro__ + assert "baz_value" in config_type.model_fields + assert hasattr(config_type(baz_value="abc"), "baz_value") def test__ConfigBuilder__build__uses_root_config_when_no_section_configs_specified(): @@ -110,7 +110,7 @@ def test__ConfigBuilder__build__uses_root_config_when_no_section_configs_specifi def test__ConfigBuilder__build__creates_config_type_when_multiple_configs_specified( mocker: MockerFixture, ): - fake_config_dict = {"baz": {"value": "ABC"}} + fake_config_dict = {"baz": {"baz_value": "ABC"}} _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) @@ -127,7 +127,7 @@ def test__ConfigBuilder__build__creates_config_type_when_multiple_configs_specif def test__ConfigBuilder__build__sets_dynamic_config_values_when_multiple_configs_specified( mocker: MockerFixture, ): - fake_config_dict = {"baz": {"value": "ABC"}} + fake_config_dict = {"baz": {"baz_value": "ABC"}} _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) @@ -139,5 +139,5 @@ def test__ConfigBuilder__build__sets_dynamic_config_values_when_multiple_configs assert hasattr(config, "baz") assert getattr(config, "baz") - assert getattr(getattr(config, "baz"), "value") - assert getattr(getattr(config, "baz"), "value") == "ABC" + assert getattr(getattr(config, "baz"), "baz_value") + assert getattr(getattr(config, "baz"), "baz_value") == "ABC" diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index 63c573aa..18992e62 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -212,12 +212,29 @@ def __call__( @final -class ApplicationBuilder(Generic[T_app, TAppConfig]): +class ApplicationBuilder(Generic[T_app]): def __init__(self) -> None: self._modules: list[Module | type[Module]] = [] self._config_overrides: dict[str, Any] = {} - self._application_config_builder: ApplicationConfigBuilder[TAppConfig] = ( - ApplicationConfigBuilder[TAppConfig]() + + _APPLICATION_CONFIG_BUILDER_PROPERTY_NAME: str = "__application_config_builder" + + @property + def _application_config_builder(self) -> ApplicationConfigBuilder[Config]: + builder = getattr( + self, ApplicationBuilder._APPLICATION_CONFIG_BUILDER_PROPERTY_NAME, None + ) + + if builder is None: + builder = ApplicationConfigBuilder[Config]() + self._application_config_builder = builder.with_root_config_type(Config) + + return builder + + @_application_config_builder.setter + def _application_config_builder(self, value: ApplicationConfigBuilder[Config]): + setattr( + self, ApplicationBuilder._APPLICATION_CONFIG_BUILDER_PROPERTY_NAME, value ) @overload @@ -243,9 +260,7 @@ def with_modules(self, modules: list[Module | type[Module]] | None) -> Self: @overload def use_configuration( self, - __application_config_builder_callback: ApplicationConfigBuilderCallback[ - TAppConfig - ], + __application_config_builder_callback: ApplicationConfigBuilderCallback[Config], ) -> Self: """ Execute changes to the builder's `ApplicationConfigBuilder[TAppConfig]` instance. @@ -257,15 +272,15 @@ def use_configuration( @overload def use_configuration( - self, __application_config_builder: ApplicationConfigBuilder[TAppConfig] + self, __application_config_builder: ApplicationConfigBuilder[Config] ) -> Self: """Replace the builder's default `ApplicationConfigBuilder[TAppConfig]` instance, or any instance previously assigned.""" ... def use_configuration( self, - application_config_builder: ApplicationConfigBuilderCallback[TAppConfig] - | ApplicationConfigBuilder[TAppConfig], + application_config_builder: ApplicationConfigBuilderCallback[Config] + | ApplicationConfigBuilder[Config], ) -> Self: if callable(application_config_builder): _ = application_config_builder(self._application_config_builder) @@ -358,7 +373,7 @@ def create_app( logging.basicConfig(force=True) application_builder = ( - ApplicationBuilder[TApp, Config]() + ApplicationBuilder[TApp]() .with_flask_app_name(environ.get("FLASK_APP", None)) .with_flask_env(environ.get("FLASK_ENV", None)) .with_modules(application_modules) diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index 41ff6c33..4ca8f251 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -201,7 +201,7 @@ def test__ApplicationBuilder__build__something(mocker: MockerFixture): _ = mocker.patch("io.open") _ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict) - application_builder = ApplicationBuilder[Flask, Config]().use_configuration( + application_builder = ApplicationBuilder[Flask]().use_configuration( lambda config_builder: config_builder.with_root_config_type( Config ).with_config_filename("foo.toml") From 5e28ceedeccf244bb2cc4b058fae6c56a9d9a8ca Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Wed, 11 Sep 2024 11:53:30 -0700 Subject: [PATCH 15/41] Fix crash caused by dataclass decorator on dataclass subclass. --- .../Ligare/platform/feature_flag/caching_feature_flag_router.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index 36890cdd..8a7cbdb2 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -1,14 +1,12 @@ from logging import Logger from typing import Generic, Sequence, cast -from attr import dataclass from typing_extensions import override from .feature_flag_router import FeatureFlag as FeatureFlagBaseData from .feature_flag_router import FeatureFlagChange, FeatureFlagRouter, TFeatureFlag -@dataclass(frozen=True) class FeatureFlag(FeatureFlagBaseData): pass From 30aa6efcab949e99b055db6251e303d60bb8ca16 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Wed, 11 Sep 2024 15:18:59 -0700 Subject: [PATCH 16/41] Replace OpenAPI spec with dict in test fixtures. --- src/web/Ligare/web/testing/create_app.py | 76 +++++++++++++++--------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/src/web/Ligare/web/testing/create_app.py b/src/web/Ligare/web/testing/create_app.py index 12e60b2b..ab168c3a 100644 --- a/src/web/Ligare/web/testing/create_app.py +++ b/src/web/Ligare/web/testing/create_app.py @@ -5,7 +5,6 @@ from contextlib import _GeneratorContextManager # pyright: ignore[reportPrivateUsage] from contextlib import ExitStack from dataclasses import dataclass -from functools import lru_cache from types import ModuleType from typing import ( Any, @@ -22,7 +21,6 @@ import json_logging import pytest -import yaml from _pytest.fixtures import SubRequest from connexion import FlaskApp from flask import Flask, Request, Response, session @@ -36,6 +34,7 @@ from Ligare.platform.dependency_injection import UserLoaderModule from Ligare.platform.identity import Role, User from Ligare.platform.identity.user_loader import TRole, UserId, UserMixin +from Ligare.programming.collections.dict import NestedDict from Ligare.programming.config import AbstractConfig, ConfigBuilder from Ligare.programming.str import get_random_str from Ligare.web.application import ( @@ -693,32 +692,51 @@ def _flask_request_getter( return _flask_request_getter - @lru_cache - def _get_openapi_spec(self): - return yaml.safe_load( - """openapi: 3.0.3 -servers: - - url: http://testserver/ - description: Test Application -info: - title: "Test Application" - version: 3.0.3 -paths: - /: - get: - description: "Check whether the application is running." - operationId: "root.get" - parameters: [] - responses: - "200": - content: - application/json: - schema: - type: string - description: "Application is running correctly." - summary: "A simple method that returns 200 as long as the application is running." -""" - ) + # this is the YAML-parsed dictionary from this OpenAPI spec + # openapi: 3.0.3 + # servers: + # - url: http://testserver/ + # description: Test Application + # info: + # title: "Test Application" + # version: 3.0.3 + # paths: + # /: + # get: + # description: "Check whether the application is running." + # operationId: "root.get" + # parameters: [] + # responses: + # "200": + # content: + # application/json: + # schema: + # type: string + # description: "Application is running correctly." + # summary: "A simple method that returns 200 as long as the application is running." + _openapi_spec: NestedDict[str, Any] = { + "info": {"title": "Test Application", "version": "3.0.3"}, + "openapi": "3.0.3", + "paths": { + "/": { + "get": { + "description": "Check whether the application is running.", + "operationId": "root.get", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": {"schema": {"type": "string"}} + }, + "description": "Application is running correctly", + } + }, + "summary": "A simple method that returns 200 as long as the application is running.", + } + } + }, + "servers": [{"description": "Test Application", "url": "http://testserver/"}], + } @pytest.fixture() def openapi_mock_controller(self, request: FixtureRequest, mocker: MockerFixture): @@ -760,7 +778,7 @@ def begin(): if spec_loader_mock is None: spec_loader_mock = mocker.patch( "connexion.spec.Specification._load_spec_from_file", - return_value=self._get_openapi_spec(), + return_value=CreateOpenAPIApp._openapi_spec, ) def end(): From a811c0ef7f00ef3d91d4c9dfb2d68e4ed26c29bc Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 12 Sep 2024 14:13:53 -0700 Subject: [PATCH 17/41] Make feature flags work with user sessions. --- .../caching_feature_flag_router.py | 2 + .../web/middleware/feature_flags/__init__.py | 116 ++++------ .../middleware/feature_flags/old-server.py | 128 ---------- src/web/Ligare/web/middleware/sso.py | 6 +- .../test_feature_flags_middleware.py | 219 +++++++++++++----- 5 files changed, 220 insertions(+), 251 deletions(-) delete mode 100644 src/web/BL_Python/web/middleware/feature_flags/old-server.py diff --git a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py index 8a7cbdb2..c6be383c 100644 --- a/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/caching_feature_flag_router.py @@ -1,6 +1,7 @@ from logging import Logger from typing import Generic, Sequence, cast +from injector import inject from typing_extensions import override from .feature_flag_router import FeatureFlag as FeatureFlagBaseData @@ -12,6 +13,7 @@ class FeatureFlag(FeatureFlagBaseData): class CachingFeatureFlagRouter(Generic[TFeatureFlag], FeatureFlagRouter[TFeatureFlag]): + @inject def __init__(self, logger: Logger) -> None: self._logger: Logger = logger self._feature_flags: dict[str, bool] = {} diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 9832183e..89e9dd6a 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -1,37 +1,9 @@ -# """ -# Server blueprint -# Non-API specific endpoints for application management -# """ -# -# from typing import Any, Sequence, cast -# -# import flask -# from BL_Python.platform.feature_flag import FeatureFlagChange, FeatureFlagRouter -# from BL_Python.web.middleware.sso import login_required -# from flask import request -# from injector import inject -# -# from CAP import __version__ -# from CAP.app.models.user.role import Role as UserRole -# from CAP.app.schemas.platform.get_request_feature_flag_schema import ( -# GetResponseFeatureFlagSchema, -# ) -# from CAP.app.schemas.platform.patch_request_feature_flag_schema import ( -# PatchRequestFeatureFlag, -# PatchRequestFeatureFlagSchema, -# PatchResponseFeatureFlagSchema, -# ) -# from CAP.app.schemas.platform.response_problem_schema import ( -# ResponseProblem, -# ResponseProblemSchema, -# ) -# -# _FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE = "Feature Flag Not Found" -# _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS = 404 -############ from logging import Logger from typing import Any, Callable, Generic, Sequence, cast +from BL_Python.platform.feature_flag.caching_feature_flag_router import ( + CachingFeatureFlagRouter, +) from BL_Python.platform.feature_flag.caching_feature_flag_router import ( FeatureFlag as CachingFeatureFlag, ) @@ -48,22 +20,21 @@ FeatureFlagRouter, TFeatureFlag, ) -from BL_Python.platform.identity.user_loader import Role, UserId, UserLoader, UserMixin +from BL_Python.platform.identity.user_loader import Role from BL_Python.programming.config import AbstractConfig +from BL_Python.programming.patterns.dependency_injection import ConfigurableModule from BL_Python.web.middleware.sso import login_required from connexion import FlaskApp from flask import Blueprint, Flask, request from injector import Binder, Injector, Module, inject, provider from pydantic import BaseModel - -# from sqlalchemy.orm.scoping import ScopedSession from starlette.types import ASGIApp, Receive, Scope, Send from typing_extensions import override class FeatureFlagConfig(BaseModel): api_base_url: str = "/server" - access_role_name: str | None = None # "Operator" + access_role_name: str | bool | None = None class Config(BaseModel, AbstractConfig): @@ -74,13 +45,16 @@ def post_load(self) -> None: feature_flag: FeatureFlagConfig -# TODO consider having the DI registration log a warning if -# an interface is being overwritten -class FeatureFlagRouterModule(Module, Generic[TFeatureFlag]): +class FeatureFlagRouterModule(ConfigurableModule, Generic[TFeatureFlag]): def __init__(self, t_feature_flag: type[FeatureFlagRouter[TFeatureFlag]]) -> None: self._t_feature_flag = t_feature_flag super().__init__() + @override + @staticmethod + def get_config_type() -> type[AbstractConfig]: + return Config + @provider def _provide_feature_flag_router( self, injector: Injector @@ -106,6 +80,9 @@ def _provide_db_feature_flag_router_table_base(self) -> type[FeatureFlagTableBas class CachingFeatureFlagRouterModule(FeatureFlagRouterModule[CachingFeatureFlag]): + def __init__(self) -> None: + super().__init__(CachingFeatureFlagRouter) + @provider def _provide_caching_feature_flag_router( self, injector: Injector @@ -113,29 +90,27 @@ def _provide_caching_feature_flag_router( return injector.get(self._t_feature_flag) -def get_feature_flag_blueprint( - config: FeatureFlagConfig, access_roles: list[Role] | bool = True -): +def get_feature_flag_blueprint(config: FeatureFlagConfig): feature_flag_blueprint = Blueprint( "feature_flag", __name__, url_prefix=f"{config.api_base_url}" ) - # access_role = config.feature_flag.access_role_name - # convert this enum somehow + access_role = config.access_role_name def _login_required(fn: Callable[..., Any]): - if access_roles is False: + if access_role is False: return fn - if access_roles is True: + # None means no roles were specified, but a session is still required + if access_role is None or access_role is True: return login_required(fn) - return login_required(access_roles)(fn) + return login_required([access_role])(fn) - @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) + @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] @_login_required @inject - def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): + def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] request_query_names: list[str] | None = request.args.to_dict(flat=False).get( "name" ) @@ -153,33 +128,38 @@ def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): raise ValueError("Unexpected type from Flask query parameters.") response: dict[str, Any] = {} + problems: list[Any] = [] - if missing_flags: - # problems: list[ResponseProblem] = [] - problems: list[Any] = [] + if missing_flags is not None: for missing_flag in missing_flags: - problems.append( - # ResponseProblem( - { - "title": "feature flag not found", # _FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, - "detail": "Queried feature flag does not exist.", - "instance": missing_flag, - "status": 404, # _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, - "type": None, - } - # ) - ) - response["problems"] = ( - problems # ResponseProblemSchema().dump(problems, many=True) + problems.append({ + "title": "feature flag not found", + "detail": "Queried feature flag does not exist.", + "instance": missing_flag, + "status": 404, + "type": None, + }) + response["problems"] = problems + + elif not feature_flags: + problems.append( + # ResponseProblem( + { + "title": "No feature flags found", + "detail": "Queried feature flags do not exist.", + "instance": "", + "status": 404, + "type": None, + } + # ) ) + response["problems"] = problems if feature_flags: - response["data"] = feature_flags # GetResponseFeatureFlagSchema().dump( - # feature_flags, many=True - # ) + response["data"] = feature_flags return response else: - return response, 404 # _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS + return response, 404 # # diff --git a/src/web/BL_Python/web/middleware/feature_flags/old-server.py b/src/web/BL_Python/web/middleware/feature_flags/old-server.py deleted file mode 100644 index b1f016f2..00000000 --- a/src/web/BL_Python/web/middleware/feature_flags/old-server.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Server blueprint -Non-API specific endpoints for application management -""" - -from typing import Any, Sequence, cast - -import flask -from BL_Python.platform.feature_flag import FeatureFlagChange, FeatureFlagRouter -from BL_Python.platform.feature_flag.db_feature_flag_router import ( - FeatureFlag as DBFeatureFlag, -) -from BL_Python.web.middleware.sso import login_required -from flask import request -from injector import inject - -from CAP import __version__ -from CAP.app.models.user.role import Role as UserRole -from CAP.app.schemas.platform.get_request_feature_flag_schema import ( - GetResponseFeatureFlagSchema, -) -from CAP.app.schemas.platform.patch_request_feature_flag_schema import ( - PatchRequestFeatureFlag, - PatchRequestFeatureFlagSchema, - PatchResponseFeatureFlagSchema, -) -from CAP.app.schemas.platform.response_problem_schema import ( - ResponseProblem, - ResponseProblemSchema, -) - -_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE = "Feature Flag Not Found" -_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS = 404 - - -def healthcheck(): - return "healthcheck: flask app is running" - - -@login_required([UserRole.Administrator]) -def server_meta(): - return {"CAP": {"version": __version__}} - - -# @server_blueprint.route("/server/feature_flag", methods=("GET",)) -@login_required([UserRole.Operator]) -@inject -def feature_flag(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): - request_query_names: list[str] | None = request.args.to_dict(flat=False).get("name") - - feature_flags: Sequence[DBFeatureFlag] - missing_flags: set[str] | None = None - if request_query_names is None: - feature_flags = feature_flag_router.get_feature_flags() - elif isinstance( - request_query_names, list - ): # pyright: ignore[reportUnnecessaryIsInstance] - feature_flags = feature_flag_router.get_feature_flags(request_query_names) - missing_flags = set(request_query_names).difference( - set([feature_flag.name for feature_flag in feature_flags]) - ) - else: - raise ValueError("Unexpected type from Flask query parameters.") - - response: dict[str, Any] = {} - - if missing_flags: - problems: list[ResponseProblem] = [] - for missing_flag in missing_flags: - problems.append( - ResponseProblem( - title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, - detail="Queried feature flag does not exist.", - instance=missing_flag, - status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, - type=None, - ) - ) - response["problems"] = ResponseProblemSchema().dump(problems, many=True) - - if feature_flags: - response["data"] = GetResponseFeatureFlagSchema().dump(feature_flags, many=True) - return response - else: - return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS - - -# @server_blueprint.route("/server/feature_flag", methods=("PATCH",)) -@login_required([UserRole.Operator]) -@inject -def feature_flag_patch(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): - post_request_feature_flag_schema = PatchRequestFeatureFlagSchema() - - feature_flags: list[PatchRequestFeatureFlag] = cast( - list[PatchRequestFeatureFlag], - post_request_feature_flag_schema.load( - flask.request.json, # pyright: ignore[reportArgumentType] why is `flask.request.json` wrong here? - many=True, - ), - ) - - changes: list[FeatureFlagChange] = [] - problems: list[ResponseProblem] = [] - for flag in feature_flags: - try: - change = feature_flag_router.set_feature_is_enabled(flag.name, flag.enabled) - changes.append(change) - except LookupError: - problems.append( - ResponseProblem( - title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, - detail="Feature flag to PATCH does not exist. It must be created first.", - instance=flag.name, - status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, - type=None, - ) - ) - - response: dict[str, Any] = {} - - if problems: - response["problems"] = ResponseProblemSchema().dump(problems, many=True) - - if changes: - response["data"] = PatchResponseFeatureFlagSchema().dump(changes, many=True) - return response - else: - return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS diff --git a/src/web/Ligare/web/middleware/sso.py b/src/web/Ligare/web/middleware/sso.py index 1723ec26..904169cf 100644 --- a/src/web/Ligare/web/middleware/sso.py +++ b/src/web/Ligare/web/middleware/sso.py @@ -80,7 +80,7 @@ def __call__(self, user: AuthCheckUser, *args: Any, **kwargs: Any) -> bool: ... def login_required( - roles: Sequence[Role] | Callable[P, R] | Callable[..., Any] | None = None, + roles: Sequence[Role | str] | Callable[P, R] | Callable[..., Any] | None = None, auth_check_override: AuthCheckOverrideCallable | None = None, ): """ @@ -156,7 +156,9 @@ def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R | Response: # if roles is empty, no roles will intersect. # this means an empty list means "no roles have access" role_intersection = [ - role for role in user.roles if role in (roles or []) + str(role) + for role in user.roles + if (str(role) in ({str(r) for r in roles}) or []) ] if len(role_intersection) == 0: # this should end up raising a 401 exception diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 175b0adb..4ea94b8f 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -1,42 +1,78 @@ -import uuid -from typing import Literal +from dataclasses import dataclass +from enum import auto +from typing import Generic, Sequence, TypeVar import pytest from BL_Python.platform.dependency_injection import UserLoaderModule -from BL_Python.platform.feature_flag.db_feature_flag_router import DBFeatureFlagRouter -from BL_Python.platform.feature_flag.db_feature_flag_router import ( - FeatureFlag as DBFeatureFlag, -) -from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlagRouter -from BL_Python.platform.identity import Role, User +from BL_Python.platform.identity.user_loader import Role as LoaderRole from BL_Python.programming.config import AbstractConfig from BL_Python.web.application import OpenAPIAppResult from BL_Python.web.config import Config -from BL_Python.web.middleware import bind_errorhandler -from BL_Python.web.middleware.consts import CORRELATION_ID_HEADER -from BL_Python.web.middleware.feature_flags import Config as RootFeatureFlagConfig from BL_Python.web.middleware.feature_flags import ( - DBFeatureFlagRouterModule, + CachingFeatureFlagRouterModule, FeatureFlagConfig, FeatureFlagMiddlewareModule, - FeatureFlagRouterModule, -) -from BL_Python.web.middleware.flask import ( - _get_correlation_id, # pyright: ignore[reportPrivateUsage] ) -from BL_Python.web.middleware.flask import bind_requesthandler from BL_Python.web.testing.create_app import ( CreateOpenAPIApp, OpenAPIClientInjectorConfigurable, OpenAPIMockController, - RequestConfigurable, ) -from connexion import FlaskApp -from flask import Flask, abort +from flask_login import UserMixin from injector import Module from mock import MagicMock from pytest_mock import MockerFixture -from werkzeug.exceptions import BadRequest, HTTPException, Unauthorized +from typing_extensions import override + + +@dataclass +class UserId: + user_id: int + username: str + + +class Role(LoaderRole): + User = auto() + Administrator = auto() + Operator = auto() + + @staticmethod + def items(): + return Role.__members__.items() + + +TRole = TypeVar("TRole", bound=Role, covariant=True) + + +class User(UserMixin, Generic[TRole]): + """ + Represents the user object stored in a session. + """ + + id: UserId + roles: Sequence[TRole] + + def get_id(self): + """ + Override the UserMixin.get_id so the username is returned instead of `id` (the dataclass) + when `flask_login.login_user` calls this method to assign the + session `_user_id` key. + """ + return str(self.id.username) + + @override + def __init__(self, id: UserId, roles: Sequence[TRole] | None = None): + """ + Create a new user with the given user name or id, and a list of roles. + If roles are not given, an empty list is assigned by default. + """ + super().__init__() + + if roles is None: + roles = [] + + self.id = id + self.roles = roles class TestFeatureFlagsMiddleware(CreateOpenAPIApp): @@ -45,34 +81,17 @@ def test__FeatureFlagMiddleware__feature_flag_api_get_requires_user_session( openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, openapi_mock_controller: OpenAPIMockController, - mocker: MockerFixture, ): def app_init_hook( application_configs: list[type[AbstractConfig]], application_modules: list[Module | type[Module]], ): - application_modules.append(DBFeatureFlagRouterModule) - application_configs.append(RootFeatureFlagConfig) + application_modules.append(CachingFeatureFlagRouterModule) application_modules.append(FeatureFlagMiddlewareModule()) - def client_init_hook(app: OpenAPIAppResult): - feature_flag_config = FeatureFlagConfig( - access_role_name="Operator", - api_base_url="/server", # the default - ) - root_feature_flag_config = RootFeatureFlagConfig( - feature_flag=feature_flag_config - ) - app.app_injector.flask_injector.injector.binder.bind( - FeatureFlagConfig, to=feature_flag_config - ) - app.app_injector.flask_injector.injector.binder.bind( - RootFeatureFlagConfig, to=root_feature_flag_config - ) - openapi_mock_controller.begin() app = next( - openapi_client_configurable(openapi_config, client_init_hook, app_init_hook) + openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) ) response = app.client.get("/server/feature_flag") @@ -81,13 +100,18 @@ def client_init_hook(app: OpenAPIAppResult): # if SSO was broken, 500 would return assert response.status_code == 401 - def test__FeatureFlagMiddleware__something( + def test__FeatureFlagMiddleware__feature_flag_api_gets_feature_flags_when_user_has_session( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, openapi_mock_controller: OpenAPIMockController, mocker: MockerFixture, ): + get_feature_flag_mock = mocker.patch( + "BL_Python.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", + return_value=[], + ) + def app_init_hook( application_configs: list[type[AbstractConfig]], application_modules: list[Module | type[Module]], @@ -95,38 +119,127 @@ def app_init_hook( application_modules.append( UserLoaderModule( loader=User, # pyright: ignore[reportArgumentType] - roles=Role, # pyright: ignore[reportArgumentType] + roles=Role, user_table=MagicMock(), # pyright: ignore[reportArgumentType] role_table=MagicMock(), # pyright: ignore[reportArgumentType] bases=[], ) ) - application_modules.append(DBFeatureFlagRouterModule) - application_configs.append(RootFeatureFlagConfig) + application_modules.append(CachingFeatureFlagRouterModule) application_modules.append(FeatureFlagMiddlewareModule()) + openapi_mock_controller.begin() + app = next( + openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) + ) + + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.get("/server/feature_flag") + + assert response.status_code == 404 + get_feature_flag_mock.assert_called_once() + + @pytest.mark.parametrize("has_role", [True, False]) + def test__FeatureFlagMiddleware__feature_flag_api_requires_specified_role( + self, + has_role: bool, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + get_feature_flag_mock = mocker.patch( + "BL_Python.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", + return_value=[], + ) + def client_init_hook(app: OpenAPIAppResult): feature_flag_config = FeatureFlagConfig( access_role_name="Operator", api_base_url="/server", # the default ) - root_feature_flag_config = RootFeatureFlagConfig( - feature_flag=feature_flag_config - ) app.app_injector.flask_injector.injector.binder.bind( FeatureFlagConfig, to=feature_flag_config ) - app.app_injector.flask_injector.injector.binder.bind( - RootFeatureFlagConfig, to=root_feature_flag_config + + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + application_modules.append( + UserLoaderModule( + loader=User, # pyright: ignore[reportArgumentType] + roles=Role, + user_table=MagicMock(), # pyright: ignore[reportArgumentType] + role_table=MagicMock(), # pyright: ignore[reportArgumentType] + bases=[], + ) ) + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) openapi_mock_controller.begin() app = next( openapi_client_configurable(openapi_config, client_init_hook, app_init_hook) ) - response = app.client.get("/server/feature_flag") + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + [Role.Operator] if has_role else [], + ): + response = app.client.get("/server/feature_flag") - # 401 for now because no real auth is configured. - # if SSO was broken, 500 would return - assert response.status_code == 401 + if has_role: + assert response.status_code == 404 + get_feature_flag_mock.assert_called_once() + else: + assert response.status_code == 401 + get_feature_flag_mock.assert_not_called() + + def test__FeatureFlagMiddleware__api_returns_no_feature_flags_when_none_exist( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + application_modules.append( + UserLoaderModule( + loader=User, # pyright: ignore[reportArgumentType] + roles=Role, + user_table=MagicMock(), # pyright: ignore[reportArgumentType] + role_table=MagicMock(), # pyright: ignore[reportArgumentType] + bases=[], + ) + ) + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) + ) + + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.get("/server/feature_flag") + + assert response.status_code == 404 + response_json = response.json() + assert (problems := response_json.get("problems", None)) is not None + assert len(problems) == 1 + assert (title := problems[0].get("title", None)) is not None + assert title == "No feature flags found" From 960eaa897fa67233e56e6c8660961e48ec98b12a Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 12 Sep 2024 16:54:00 -0700 Subject: [PATCH 18/41] Fix some type errors. --- src/web/test/unit/middleware/test_feature_flags_middleware.py | 1 + src/web/test/unit/test_config.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 4ea94b8f..64e1266e 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -52,6 +52,7 @@ class User(UserMixin, Generic[TRole]): id: UserId roles: Sequence[TRole] + @override def get_id(self): """ Override the UserMixin.get_id so the username is returned instead of `id` (the dataclass) diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index 4ca8f251..4237eab6 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -1,5 +1,3 @@ -from typing import Any - import pytest from flask import Flask from Ligare.programming.collections.dict import AnyDict From 91b94a5f89fa96b461e00e5a5f8f0c4e58e22d36 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 13 Sep 2024 14:48:53 -0700 Subject: [PATCH 19/41] Make "get" work for feature flags and add test. --- .../web/middleware/feature_flags/__init__.py | 6 +- .../test_feature_flags_middleware.py | 129 ++++++++++-------- 2 files changed, 78 insertions(+), 57 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 89e9dd6a..0bc0e2bb 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -26,7 +26,7 @@ from BL_Python.web.middleware.sso import login_required from connexion import FlaskApp from flask import Blueprint, Flask, request -from injector import Binder, Injector, Module, inject, provider +from injector import Binder, Injector, Module, inject, provider, singleton from pydantic import BaseModel from starlette.types import ASGIApp, Receive, Scope, Send from typing_extensions import override @@ -55,6 +55,7 @@ def __init__(self, t_feature_flag: type[FeatureFlagRouter[TFeatureFlag]]) -> Non def get_config_type() -> type[AbstractConfig]: return Config + @singleton @provider def _provide_feature_flag_router( self, injector: Injector @@ -66,12 +67,14 @@ class DBFeatureFlagRouterModule(FeatureFlagRouterModule[DBFeatureFlag]): def __init__(self) -> None: super().__init__(DBFeatureFlagRouter) + @singleton @provider def _provide_db_feature_flag_router( self, injector: Injector ) -> FeatureFlagRouter[DBFeatureFlag]: return injector.get(self._t_feature_flag) + @singleton @provider def _provide_db_feature_flag_router_table_base(self) -> type[FeatureFlagTableBase]: # FeatureFlagTable is a FeatureFlagTableBase provided through @@ -83,6 +86,7 @@ class CachingFeatureFlagRouterModule(FeatureFlagRouterModule[CachingFeatureFlag] def __init__(self) -> None: super().__init__(CachingFeatureFlagRouter) + @singleton @provider def _provide_caching_feature_flag_router( self, injector: Injector diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 64e1266e..528b9986 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -4,9 +4,14 @@ import pytest from BL_Python.platform.dependency_injection import UserLoaderModule +from BL_Python.platform.feature_flag import FeatureFlag, caching_feature_flag_router +from BL_Python.platform.feature_flag.caching_feature_flag_router import ( + CachingFeatureFlagRouter, +) +from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlagRouter from BL_Python.platform.identity.user_loader import Role as LoaderRole from BL_Python.programming.config import AbstractConfig -from BL_Python.web.application import OpenAPIAppResult +from BL_Python.web.application import CreateAppResult, OpenAPIAppResult from BL_Python.web.config import Config from BL_Python.web.middleware.feature_flags import ( CachingFeatureFlagRouterModule, @@ -18,6 +23,7 @@ OpenAPIClientInjectorConfigurable, OpenAPIMockController, ) +from connexion import FlaskApp from flask_login import UserMixin from injector import Module from mock import MagicMock @@ -77,7 +83,24 @@ def __init__(self, id: UserId, roles: Sequence[TRole] | None = None): class TestFeatureFlagsMiddleware(CreateOpenAPIApp): - def test__FeatureFlagMiddleware__feature_flag_api_get_requires_user_session( + def _user_session_app_init_hook( + self, + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + application_modules.append( + UserLoaderModule( + loader=User, # pyright: ignore[reportArgumentType] + roles=Role, + user_table=MagicMock(), # pyright: ignore[reportArgumentType] + role_table=MagicMock(), # pyright: ignore[reportArgumentType] + bases=[], + ) + ) + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) + + def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_user_session( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, @@ -101,7 +124,7 @@ def app_init_hook( # if SSO was broken, 500 would return assert response.status_code == 401 - def test__FeatureFlagMiddleware__feature_flag_api_gets_feature_flags_when_user_has_session( + def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_user_has_session( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, @@ -113,25 +136,11 @@ def test__FeatureFlagMiddleware__feature_flag_api_gets_feature_flags_when_user_h return_value=[], ) - def app_init_hook( - application_configs: list[type[AbstractConfig]], - application_modules: list[Module | type[Module]], - ): - application_modules.append( - UserLoaderModule( - loader=User, # pyright: ignore[reportArgumentType] - roles=Role, - user_table=MagicMock(), # pyright: ignore[reportArgumentType] - role_table=MagicMock(), # pyright: ignore[reportArgumentType] - bases=[], - ) - ) - application_modules.append(CachingFeatureFlagRouterModule) - application_modules.append(FeatureFlagMiddlewareModule()) - openapi_mock_controller.begin() app = next( - openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) + openapi_client_configurable( + openapi_config, app_init_hook=self._user_session_app_init_hook + ) ) with self.get_authenticated_request_context( @@ -145,7 +154,7 @@ def app_init_hook( get_feature_flag_mock.assert_called_once() @pytest.mark.parametrize("has_role", [True, False]) - def test__FeatureFlagMiddleware__feature_flag_api_requires_specified_role( + def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_specified_role( self, has_role: bool, openapi_config: Config, @@ -167,25 +176,11 @@ def client_init_hook(app: OpenAPIAppResult): FeatureFlagConfig, to=feature_flag_config ) - def app_init_hook( - application_configs: list[type[AbstractConfig]], - application_modules: list[Module | type[Module]], - ): - application_modules.append( - UserLoaderModule( - loader=User, # pyright: ignore[reportArgumentType] - roles=Role, - user_table=MagicMock(), # pyright: ignore[reportArgumentType] - role_table=MagicMock(), # pyright: ignore[reportArgumentType] - bases=[], - ) - ) - application_modules.append(CachingFeatureFlagRouterModule) - application_modules.append(FeatureFlagMiddlewareModule()) - openapi_mock_controller.begin() app = next( - openapi_client_configurable(openapi_config, client_init_hook, app_init_hook) + openapi_client_configurable( + openapi_config, client_init_hook, self._user_session_app_init_hook + ) ) with self.get_authenticated_request_context( @@ -203,32 +198,18 @@ def app_init_hook( assert response.status_code == 401 get_feature_flag_mock.assert_not_called() - def test__FeatureFlagMiddleware__api_returns_no_feature_flags_when_none_exist( + def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_no_feature_flags_when_none_exist( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, openapi_mock_controller: OpenAPIMockController, mocker: MockerFixture, ): - def app_init_hook( - application_configs: list[type[AbstractConfig]], - application_modules: list[Module | type[Module]], - ): - application_modules.append( - UserLoaderModule( - loader=User, # pyright: ignore[reportArgumentType] - roles=Role, - user_table=MagicMock(), # pyright: ignore[reportArgumentType] - role_table=MagicMock(), # pyright: ignore[reportArgumentType] - bases=[], - ) - ) - application_modules.append(CachingFeatureFlagRouterModule) - application_modules.append(FeatureFlagMiddlewareModule()) - openapi_mock_controller.begin() app = next( - openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) + openapi_client_configurable( + openapi_config, app_init_hook=self._user_session_app_init_hook + ) ) with self.get_authenticated_request_context( @@ -244,3 +225,39 @@ def app_init_hook( assert len(problems) == 1 assert (title := problems[0].get("title", None)) is not None assert title == "No feature flags found" + + def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_feature_flags_when_they_exist( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def client_init_hook(app: CreateAppResult[FlaskApp]): + caching_feature_flag_router = app.app_injector.flask_injector.injector.get( + FeatureFlagRouter[FeatureFlag] + ) + _ = caching_feature_flag_router.set_feature_is_enabled("foo_feature", True) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + client_init_hook, + self._user_session_app_init_hook, + ) + ) + + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.get("/server/feature_flag") + + assert response.status_code == 200 + response_json = response.json() + assert (data := response_json.get("data", None)) is not None + assert len(data) == 1 + assert data[0].get("enabled", None) is True + assert data[0].get("name", None) == "foo_feature" From f5a7068c4741d36891120cca53ac17a01d206259 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 17 Sep 2024 14:17:37 -0700 Subject: [PATCH 20/41] Make feature flag PATCH request work and add test. --- .../web/middleware/feature_flags/__init__.py | 127 +++++++++--------- src/web/Ligare/web/middleware/openapi/cors.py | 2 +- .../test_feature_flags_middleware.py | 46 ++++++- 3 files changed, 105 insertions(+), 70 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 0bc0e2bb..7a53af93 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -1,5 +1,6 @@ +from dataclasses import dataclass from logging import Logger -from typing import Any, Callable, Generic, Sequence, cast +from typing import Any, Callable, Generic, Sequence, TypedDict, cast from BL_Python.platform.feature_flag.caching_feature_flag_router import ( CachingFeatureFlagRouter, @@ -24,8 +25,8 @@ from BL_Python.programming.config import AbstractConfig from BL_Python.programming.patterns.dependency_injection import ConfigurableModule from BL_Python.web.middleware.sso import login_required -from connexion import FlaskApp -from flask import Blueprint, Flask, request +from connexion import FlaskApp, request +from flask import Blueprint, Flask from injector import Binder, Injector, Module, inject, provider, singleton from pydantic import BaseModel from starlette.types import ASGIApp, Receive, Scope, Send @@ -45,6 +46,17 @@ def post_load(self) -> None: feature_flag: FeatureFlagConfig +class FeatureFlagPatchRequest(TypedDict): + name: str + enabled: bool + + +@dataclass +class FeatureFlagPatch: + name: str + enabled: bool + + class FeatureFlagRouterModule(ConfigurableModule, Generic[TFeatureFlag]): def __init__(self, t_feature_flag: type[FeatureFlagRouter[TFeatureFlag]]) -> None: self._t_feature_flag = t_feature_flag @@ -115,9 +127,7 @@ def _login_required(fn: Callable[..., Any]): @_login_required @inject def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] - request_query_names: list[str] | None = request.args.to_dict(flat=False).get( - "name" - ) + request_query_names: list[str] | None = request.query_params.get("name") feature_flags: Sequence[FeatureFlag] missing_flags: set[str] | None = None @@ -146,17 +156,13 @@ def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyrigh response["problems"] = problems elif not feature_flags: - problems.append( - # ResponseProblem( - { - "title": "No feature flags found", - "detail": "Queried feature flags do not exist.", - "instance": "", - "status": 404, - "type": None, - } - # ) - ) + problems.append({ + "title": "No feature flags found", + "detail": "Queried feature flags do not exist.", + "instance": "", + "status": 404, + "type": None, + }) response["problems"] = problems if feature_flags: @@ -165,57 +171,48 @@ def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyrigh else: return response, 404 - # - # - ## @server_blueprint.route("/server/feature_flag", methods=("PATCH",)) + @feature_flag_blueprint.route("/feature_flag", methods=("PATCH",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] # @login_required([UserRole.Operator]) - # @inject - # def feature_flag_patch(feature_flag_router: FeatureFlagRouter[DBFeatureFlag]): - # post_request_feature_flag_schema = PatchRequestFeatureFlagSchema() - # - # feature_flags: list[PatchRequestFeatureFlag] = cast( - # list[PatchRequestFeatureFlag], - # post_request_feature_flag_schema.load( - # flask.request.json, # pyright: ignore[reportArgumentType] why is `flask.request.json` wrong here? - # many=True, - # ), - # ) - # - # changes: list[FeatureFlagChange] = [] - # problems: list[ResponseProblem] = [] - # for flag in feature_flags: - # try: - # change = feature_flag_router.set_feature_is_enabled(flag.name, flag.enabled) - # changes.append(change) - # except LookupError: - # problems.append( - # ResponseProblem( - # title=_FEATURE_FLAG_NOT_FOUND_PROBLEM_TITLE, - # detail="Feature flag to PATCH does not exist. It must be created first.", - # instance=flag.name, - # status=_FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS, - # type=None, - # ) - # ) - # - # response: dict[str, Any] = {} - # - # if problems: - # response["problems"] = ResponseProblemSchema().dump(problems, many=True) - # - # if changes: - # response["data"] = PatchResponseFeatureFlagSchema().dump(changes, many=True) - # return response - # else: - # return response, _FEATURE_FLAG_NOT_FOUND_PROBLEM_STATUS - # - return feature_flag_blueprint + # TODO assign a specific role ? + @_login_required + @inject + async def feature_flag_patch(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] + feature_flags_request: list[FeatureFlagPatchRequest] = await request.json() + feature_flags = [ + FeatureFlagPatch(name=flag["name"], enabled=flag["enabled"]) + for flag in feature_flags_request + ] -# class FeatureFlagModule(Module): -# def __init__(self): -# """ """ -# super().__init__() + changes: list[Any] = [] + problems: list[Any] = [] + for flag in feature_flags: + try: + change = feature_flag_router.set_feature_is_enabled( + flag.name, flag.enabled + ) + changes.append(change) + except LookupError: + problems.append({ + "title": "feature flag not found", + "detail": "Feature flag to PATCH does not exist. It must be created first.", + "instance": flag.name, + "status": 404, + "type": None, + }) + + response: dict[str, Any] = {} + + if problems: + response["problems"] = problems + + if changes: + response["data"] = changes + return response + else: + return response, 404 + + return feature_flag_blueprint class FeatureFlagMiddlewareModule(Module): diff --git a/src/web/Ligare/web/middleware/openapi/cors.py b/src/web/Ligare/web/middleware/openapi/cors.py index 7bdaa8a1..2e4313e0 100644 --- a/src/web/Ligare/web/middleware/openapi/cors.py +++ b/src/web/Ligare/web/middleware/openapi/cors.py @@ -13,7 +13,7 @@ def register_middleware(self, app: FlaskApp, config: Config): app.add_middleware( CORSMiddleware, position=MiddlewarePosition.BEFORE_EXCEPTION, - allow_origins=cors_config.origins, + allow_origins=cors_config.origins or [], allow_credentials=cors_config.allow_credentials, allow_methods=cors_config.allow_methods, allow_headers=cors_config.allow_headers, diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 528b9986..3fe3f785 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -4,10 +4,7 @@ import pytest from BL_Python.platform.dependency_injection import UserLoaderModule -from BL_Python.platform.feature_flag import FeatureFlag, caching_feature_flag_router -from BL_Python.platform.feature_flag.caching_feature_flag_router import ( - CachingFeatureFlagRouter, -) +from BL_Python.platform.feature_flag import FeatureFlag from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlagRouter from BL_Python.platform.identity.user_loader import Role as LoaderRole from BL_Python.programming.config import AbstractConfig @@ -226,6 +223,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_no_feature_flags_w assert (title := problems[0].get("title", None)) is not None assert title == "No feature flags found" + # TODO need test for querying for specific flag def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_feature_flags_when_they_exist( self, openapi_config: Config, @@ -261,3 +259,43 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): assert len(data) == 1 assert data[0].get("enabled", None) is True assert data[0].get("name", None) == "foo_feature" + + def test__FeatureFlagMiddleware__feature_flag_api_PATCH_modifies_something( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def client_init_hook(app: CreateAppResult[FlaskApp]): + caching_feature_flag_router = app.app_injector.flask_injector.injector.get( + FeatureFlagRouter[FeatureFlag] + ) + _ = caching_feature_flag_router.set_feature_is_enabled("foo_feature", True) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + client_init_hook, + self._user_session_app_init_hook, + ) + ) + + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.patch( + "/server/feature_flag", + json=[{"name": "foo_feature", "enabled": False}], + ) + + assert response.status_code == 200 + response_json = response.json() + assert (data := response_json.get("data", None)) is not None + assert len(data) == 1 + assert data[0].get("name", None) == "foo_feature" + assert data[0].get("new_value", None) == False + assert data[0].get("old_value", None) == True From f57ca2fe44acb120ff879d71a91be8eed0c28142 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 17 Sep 2024 15:26:17 -0700 Subject: [PATCH 21/41] Support querying for one or more specific feature flags. --- .../web/middleware/feature_flags/__init__.py | 4 +- .../test_feature_flags_middleware.py | 51 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 7a53af93..02e4653c 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -127,11 +127,11 @@ def _login_required(fn: Callable[..., Any]): @_login_required @inject def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] - request_query_names: list[str] | None = request.query_params.get("name") + request_query_names: list[str] | None = request.query_params.getlist("name") feature_flags: Sequence[FeatureFlag] missing_flags: set[str] | None = None - if request_query_names is None: + if request_query_names is None or not request_query_names: feature_flags = feature_flag_router.get_feature_flags() elif isinstance(request_query_names, list): # pyright: ignore[reportUnnecessaryIsInstance] feature_flags = feature_flag_router.get_feature_flags(request_query_names) diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 3fe3f785..c58a208e 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -223,7 +223,6 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_no_feature_flags_w assert (title := problems[0].get("title", None)) is not None assert title == "No feature flags found" - # TODO need test for querying for specific flag def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_feature_flags_when_they_exist( self, openapi_config: Config, @@ -260,6 +259,56 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): assert data[0].get("enabled", None) is True assert data[0].get("name", None) == "foo_feature" + @pytest.mark.parametrize( + "query_flags", ["bar_feature", ["foo_feature", "baz_feature"]] + ) + def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_specific_feature_flags_when_they_exist( + self, + query_flags: str | list[str], + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + def client_init_hook(app: CreateAppResult[FlaskApp]): + caching_feature_flag_router = app.app_injector.flask_injector.injector.get( + FeatureFlagRouter[FeatureFlag] + ) + _ = caching_feature_flag_router.set_feature_is_enabled("foo_feature", True) + _ = caching_feature_flag_router.set_feature_is_enabled("bar_feature", False) + _ = caching_feature_flag_router.set_feature_is_enabled("baz_feature", True) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + client_init_hook, + self._user_session_app_init_hook, + ) + ) + + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.get( + "/server/feature_flag", params={"name": query_flags} + ) + + assert response.status_code == 200 + response_json = response.json() + assert (data := response_json.get("data", None)) is not None + if isinstance(query_flags, str): + assert len(data) == 1 + assert data[0].get("enabled", None) is False + assert data[0].get("name", None) == query_flags + else: + assert len(data) == len(query_flags) + for i, flag in enumerate(query_flags): + assert data[i].get("enabled", None) is True + assert data[i].get("name", None) == flag + def test__FeatureFlagMiddleware__feature_flag_api_PATCH_modifies_something( self, openapi_config: Config, From 40cdf08360aa8dfdd5873b8957583fc585a26af6 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 19 Sep 2024 15:25:26 -0700 Subject: [PATCH 22/41] Remove commented code. --- src/database/Ligare/database/dependency_injection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/Ligare/database/dependency_injection.py b/src/database/Ligare/database/dependency_injection.py index 84eb1e0e..bbd2da47 100644 --- a/src/database/Ligare/database/dependency_injection.py +++ b/src/database/Ligare/database/dependency_injection.py @@ -12,7 +12,7 @@ from .config import DatabaseConfig -class ScopedSessionModule(ConfigurableModule): # Module): +class ScopedSessionModule(ConfigurableModule): """ Configure SQLAlchemy Session depedencies for Injector. """ From a903db91ee0002df5782e59bf75be9b6aabfadb5 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 19 Sep 2024 15:28:53 -0700 Subject: [PATCH 23/41] Remove useless `pass` --- src/programming/test/unit/test_config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/programming/test/unit/test_config.py b/src/programming/test/unit/test_config.py index e6a7e73f..152ae3ae 100644 --- a/src/programming/test/unit/test_config.py +++ b/src/programming/test/unit/test_config.py @@ -40,8 +40,6 @@ class InvalidConfigClass(BaseModel, AbstractConfig): def post_load(self) -> None: return super().post_load() - pass - def test__Config__load_config__reads_toml_file(mocker: MockerFixture): fake_config_dict = {} From fd353304f46592150f043c2f2d4f9adb94aa7cdf Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 20 Sep 2024 13:41:10 -0700 Subject: [PATCH 24/41] Add Alembic migration files for feature flags. --- .../feature_flag/database/__init__.py | 0 .../platform/feature_flag/database/migrate.py | 79 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/platform/BL_Python/platform/feature_flag/database/__init__.py create mode 100644 src/platform/BL_Python/platform/feature_flag/database/migrate.py diff --git a/src/platform/BL_Python/platform/feature_flag/database/__init__.py b/src/platform/BL_Python/platform/feature_flag/database/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/platform/BL_Python/platform/feature_flag/database/migrate.py b/src/platform/BL_Python/platform/feature_flag/database/migrate.py new file mode 100644 index 00000000..91a61b01 --- /dev/null +++ b/src/platform/BL_Python/platform/feature_flag/database/migrate.py @@ -0,0 +1,79 @@ +"""Add feature flags""" + +import sqlalchemy as sa +from alembic.operations.base import Operations +from BL_Python.database.schema import get_type_from_op +from sqlalchemy import false + +from ..db_feature_flag_router import FeatureFlagTable + + +# fmt: off +def upgrade(op: Operations): + dialect = get_type_from_op(op) + dialect_supports_schemas = dialect.supports_schemas + get_full_table_name = dialect.get_full_table_name + get_dialect_schema = dialect.get_dialect_schema + timestamp_sql = dialect.timestamp_sql + + base_schema_name = get_dialect_schema(FeatureFlagTable) + if dialect_supports_schemas: + if base_schema_name: + op.execute(f'CREATE SCHEMA IF NOT EXISTS {base_schema_name}') + + full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) + _ = op.create_table(full_table_name, + sa.Column('ctime', sa.DateTime(), server_default=sa.text(timestamp_sql), nullable=False), + sa.Column('mtime', sa.DateTime(), server_default=sa.text(timestamp_sql), nullable=False), + sa.Column('name', sa.Unicode(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=True, server_default=false()), + sa.Column('description', sa.Unicode(), nullable=False), + sa.PrimaryKeyConstraint('name'), + schema=base_schema_name + ) + + if dialect.DIALECT_NAME == 'postgresql': + op.execute(""" +CREATE OR REPLACE FUNCTION func_update_mtime() +RETURNS TRIGGER LANGUAGE 'plpgsql' AS +' +BEGIN + NEW.mtime = now(); + RETURN NEW; +END; +';""") + + op.execute(f""" +CREATE TRIGGER trigger_update_mtime +BEFORE UPDATE ON {base_schema_name}.{full_table_name} +FOR EACH ROW EXECUTE PROCEDURE func_update_mtime();""") + + else: + op.execute(f""" +CREATE TRIGGER IF NOT EXISTS '{full_table_name}.trigger_update_mtime' +BEFORE UPDATE +ON '{full_table_name}' +FOR EACH ROW +BEGIN + UPDATE '{full_table_name}' + SET mtime=CURRENT_TIMESTAMP + WHERE name = NEW.name; +END;""") + + +def downgrade(op: Operations): + dialect = get_type_from_op(op) + get_full_table_name = dialect.get_full_table_name + get_dialect_schema = dialect.get_dialect_schema + + base_schema_name = get_dialect_schema(FeatureFlagTable) + full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) + + if dialect.DIALECT_NAME == 'postgresql': + op.execute(f'DROP SCHEMA {base_schema_name} CASCADE;') + op.execute("DROP FUNCTION func_update_mtime;") + op.execute('COMMIT;') + + else: + op.execute(f"""DROP TRIGGER '{full_table_name}.trigger_update_mtime';""") + op.drop_table(full_table_name, schema=base_schema_name) From abdf49176d8702a7fb315676951fa542f2fdca51 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 20 Sep 2024 13:41:51 -0700 Subject: [PATCH 25/41] Fix issues with Alembic migrations not running successfully. --- .../Ligare/database/migrations/alembic.py | 209 ------------------ .../database/migrations/alembic/env_setup.py | 2 +- .../Ligare/database/schema/__init__.py | 4 +- .../Ligare/database/schema/dialect.py | 8 +- .../feature_flag/db_feature_flag_router.py | 2 + 5 files changed, 9 insertions(+), 216 deletions(-) delete mode 100644 src/database/Ligare/database/migrations/alembic.py diff --git a/src/database/Ligare/database/migrations/alembic.py b/src/database/Ligare/database/migrations/alembic.py deleted file mode 100644 index ddec9385..00000000 --- a/src/database/Ligare/database/migrations/alembic.py +++ /dev/null @@ -1,209 +0,0 @@ -# TODO integrate Alembic into Ligare -# import logging -# from configparser import ConfigParser -# from dataclasses import dataclass -# from functools import lru_cache -# from logging.config import fileConfig -# from typing import Any, List, Optional, Protocol, cast -# -# from alembic import context -# from psycopg2.errors import UndefinedTable -# from sqlalchemy import engine_from_config, pool -# from sqlalchemy.engine import Connectable, Connection, Engine -# from sqlalchemy.exc import ProgrammingError -# from sqlalchemy.sql.schema import MetaData, Table -# -# from Ligare.database.migrations import DialectHelper, MetaBaseType -# -# -# class type_include_object(Protocol): -# def __call__( -# self, -# object: Table, -# name: str, -# type_: str, -# reflected: Any, -# compare_to: Any, -# ) -> bool: -# ... -# -# -# class type_include_schemas(Protocol): -# def __call__(self, names: list[str]) -> type_include_object: -# ... -# -# -# @dataclass -# class type_metadata: -# include_schemas: type_include_schemas -# target_metadata: List[MetaData] -# schemas: List[str] -# -# -# class AlembicEnvSetup: -# _connection_string: str -# _bases: list[MetaBaseType] -# -# def __init__(self, connection_string: str, bases: list[MetaBaseType]) -> None: -# self._connection_string = connection_string -# self._bases = bases -# -# @lru_cache(maxsize=1) -# def get_config(self): -# # this is the Alembic Config object, which provides -# # access to the values within the .ini file in use. -# config = context.config -# -# # Interpret the config file for Python logging. -# # This line sets up loggers basically. -# if config.config_file_name is not None: -# # raise Exception("Config file is missing.") -# fileConfig(config.config_file_name) -# -# config.set_main_option("sqlalchemy.url", self._connection_string) -# -# return config -# -# @lru_cache(maxsize=1) -# def get_metadata(self): -# # add your model's MetaData object here -# # for 'autogenerate' support -# # from myapp import mymodel -# # target_metadata = mymodel.Base.metadata -# # from CAP.database.models.CAP import Base -# # from CAP.database.models.identity import IdentityBase -# # from CAP.database.models.platform import PlatformBase -# -# def include_schemas(names: List[str]): -# def include_object( -# object: Table, -# name: str, -# type_: str, -# reflected: Any, -# compare_to: Any, -# ): -# if type_ == "table": -# return object.schema in names -# return True -# -# return include_object -# -# target_metadata = [base.metadata for base in self._bases] -# schemas: list[str] = [] -# for base in self._bases: -# schema = DialectHelper.get_schema(base) -# if schema is not None: -# schemas.append(schema) -# -# return type_metadata(include_schemas, target_metadata, schemas) -# -# def _configure_context(self, connection: Connection | Connectable | Engine): -# metadata = self.get_metadata() -# target_metadata = metadata.target_metadata -# include_schemas = metadata.include_schemas -# schemas = metadata.schemas -# -# if connection.engine is not None and connection.engine.name == "sqlite": -# context.configure( -# connection=cast(Connection, connection), -# target_metadata=target_metadata, -# compare_type=True, -# include_schemas=True, -# include_object=include_schemas(schemas), -# render_as_batch=True, -# ) -# else: -# context.configure( -# connection=cast(Connection, connection), -# target_metadata=target_metadata, -# compare_type=True, -# include_schemas=True, -# include_object=include_schemas(schemas), -# ) -# -# def _run_migrations(self, connection: Connection | Connectable | Engine): -# if connection.engine is None: -# raise Exception( -# "SQLAlchemy Session is not bound to an engine. This is not supported." -# ) -# -# metadata = self.get_metadata() -# schemas = metadata.schemas -# with context.begin_transaction(): -# try: -# if connection.engine.name == "postgresql": -# _ = connection.execute( -# f"SET search_path TO {','.join(schemas)},public;" -# ) -# context.run_migrations() -# except ProgrammingError as error: -# # This occurs when downgrading from the very last version -# # because the `alembic_version` table is dropped. The exception -# # can be safely ignored because the migration commits the transaction -# # before the failure, and there is nothing left for Alembic to do. -# if not ( -# type(error.orig) is UndefinedTable -# and "DELETE FROM alembic_version" in error.statement -# ): -# raise -# -# def run_migrations_offline(self, connection_string: str): -# """Run migrations in 'offline' mode. -# -# This configures the context with just a URL -# and not an Engine, though an Engine is acceptable -# here as well. By skipping the Engine creation -# we don't even need a DBAPI to be available. -# -# Calls to context.execute() here emit the given string to the -# script output. -# -# """ -# -# config = self.get_config() -# metadata = self.get_metadata() -# target_metadata = metadata.target_metadata -# include_schemas = metadata.include_schemas -# schemas = metadata.schemas -# -# url = config.get_main_option("sqlalchemy.url") -# context.configure( -# url=url, -# target_metadata=target_metadata, -# literal_binds=True, -# dialect_opts={"paramstyle": "named"}, -# compare_type=True, -# include_schemas=True, -# include_object=include_schemas(schemas), -# ) -# -# with context.begin_transaction(): -# context.run_migrations() -# -# def run_migrations_online(self, connection_string: str): -# """Run migrations in 'online' mode. -# -# In this scenario we need to create an Engine -# and associate a connection with the context. -# -# """ -# config = self.get_config() -# -# connectable: Connectable = cast(dict[Any, Any], config.attributes).get( -# "connection", None -# ) -# -# if connectable: -# self._configure_context(connectable) -# self._run_migrations(connectable) -# else: -# connectable = engine_from_config( -# config.get_section(config.config_ini_section), -# prefix="sqlalchemy.", -# poolclass=pool.NullPool, -# ) -# -# with connectable.connect() as connection: -# self._configure_context(connection) -# self._run_migrations(connection) -# diff --git a/src/database/Ligare/database/migrations/alembic/env_setup.py b/src/database/Ligare/database/migrations/alembic/env_setup.py index 1f642bf5..c55272de 100644 --- a/src/database/Ligare/database/migrations/alembic/env_setup.py +++ b/src/database/Ligare/database/migrations/alembic/env_setup.py @@ -172,7 +172,7 @@ def _run_migrations( try: if connection.engine.name == "postgresql": _ = connection.execute( - f"SET search_path TO {','.join(schemas)},public;" + f"SET search_path TO {','.join(schemas + ['public'])};" ) context.run_migrations() except ProgrammingError as error: diff --git a/src/database/Ligare/database/schema/__init__.py b/src/database/Ligare/database/schema/__init__.py index be2eed47..9c2c6b1c 100644 --- a/src/database/Ligare/database/schema/__init__.py +++ b/src/database/Ligare/database/schema/__init__.py @@ -7,7 +7,7 @@ _dialect_type_map = {"sqlite": SQLiteDialect, "postgresql": PostgreSQLDialect} -def get_type_from_dialect(dialect: Dialect): +def get_type_from_dialect(dialect: Dialect) -> PostgreSQLDialect | SQLiteDialect: if not _dialect_type_map.get(dialect.name): raise ValueError( f"Unexpected dialect with name `{dialect.name}`. Expected one of {list(_dialect_type_map.keys())}." @@ -16,6 +16,6 @@ def get_type_from_dialect(dialect: Dialect): return _dialect_type_map[dialect.name](dialect) -def get_type_from_op(op: Operations): +def get_type_from_op(op: Operations) -> PostgreSQLDialect | SQLiteDialect: dialect: Dialect = op.get_bind().dialect return get_type_from_dialect(dialect) diff --git a/src/database/Ligare/database/schema/dialect.py b/src/database/Ligare/database/schema/dialect.py index 4b64fe56..f6db4cef 100644 --- a/src/database/Ligare/database/schema/dialect.py +++ b/src/database/Ligare/database/schema/dialect.py @@ -8,7 +8,7 @@ class DialectBase(ABC): supports_schemas: bool = False @staticmethod - def get_schema(meta: MetaBase): + def get_schema(meta: MetaBase) -> str | None: table_args = hasattr(meta, "__table_args__") and meta.__table_args__ or None if isinstance(table_args, dict): @@ -21,7 +21,7 @@ def iterate_table_names( dialect: "DialectBase", schema_tables: dict[MetaBase, list[str]], table_name_callback: TableNameCallback, - ): + ) -> None: """ Call `table_name_callback` once for every table in every Base. @@ -40,13 +40,13 @@ def iterate_table_names( dialect_schema, full_table_name, base_table, meta_base ) - def get_dialect_schema(self, meta: MetaBase): + def get_dialect_schema(self, meta: MetaBase) -> str | None: if self.supports_schemas: return DialectBase.get_schema(meta) return None - def get_full_table_name(self, table_name: str, meta: MetaBase): + def get_full_table_name(self, table_name: str, meta: MetaBase) -> str: """ If the dialect supports schemas, then the table name does not have the schema prepended. In dialects that don't support schemas, e.g., SQLite, the table name has the schema prepended. diff --git a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py index 525d250d..0137bff8 100644 --- a/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py +++ b/src/platform/Ligare/platform/feature_flag/db_feature_flag_router.py @@ -42,6 +42,8 @@ def __init__( # pyright: ignore[reportMissingSuperCall] class FeatureFlagTable: + __table_args__ = {"schema": "platform"} + def __new__(cls, base: Type[DeclarativeMeta]) -> type[FeatureFlagTableBase]: class _FeatureFlag(base): """ From a28c8665d3e4346f385c23594573a1b3c0c644c2 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 20 Sep 2024 14:04:19 -0700 Subject: [PATCH 26/41] Ignore some pyright type errors. --- .../BL_Python/platform/feature_flag/database/migrate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platform/BL_Python/platform/feature_flag/database/migrate.py b/src/platform/BL_Python/platform/feature_flag/database/migrate.py index 91a61b01..198a0e9f 100644 --- a/src/platform/BL_Python/platform/feature_flag/database/migrate.py +++ b/src/platform/BL_Python/platform/feature_flag/database/migrate.py @@ -16,12 +16,12 @@ def upgrade(op: Operations): get_dialect_schema = dialect.get_dialect_schema timestamp_sql = dialect.timestamp_sql - base_schema_name = get_dialect_schema(FeatureFlagTable) + base_schema_name = get_dialect_schema(FeatureFlagTable) # pyright: ignore[reportArgumentType] if dialect_supports_schemas: if base_schema_name: op.execute(f'CREATE SCHEMA IF NOT EXISTS {base_schema_name}') - full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) + full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) # pyright: ignore[reportArgumentType] _ = op.create_table(full_table_name, sa.Column('ctime', sa.DateTime(), server_default=sa.text(timestamp_sql), nullable=False), sa.Column('mtime', sa.DateTime(), server_default=sa.text(timestamp_sql), nullable=False), @@ -66,8 +66,8 @@ def downgrade(op: Operations): get_full_table_name = dialect.get_full_table_name get_dialect_schema = dialect.get_dialect_schema - base_schema_name = get_dialect_schema(FeatureFlagTable) - full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) + base_schema_name = get_dialect_schema(FeatureFlagTable) # pyright: ignore[reportArgumentType] + full_table_name = get_full_table_name('feature_flag', FeatureFlagTable) # pyright: ignore[reportArgumentType] if dialect.DIALECT_NAME == 'postgresql': op.execute(f'DROP SCHEMA {base_schema_name} CASCADE;') From e96d700645caf97be60049648e2917981b7ffdcd Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 20 Sep 2024 15:26:16 -0700 Subject: [PATCH 27/41] Fix crash when web openapi app tries to log username but login_manager has not been defined. --- .../Ligare/web/middleware/openapi/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/web/Ligare/web/middleware/openapi/__init__.py b/src/web/Ligare/web/middleware/openapi/__init__.py index c173ece2..d6a0ccec 100644 --- a/src/web/Ligare/web/middleware/openapi/__init__.py +++ b/src/web/Ligare/web/middleware/openapi/__init__.py @@ -187,6 +187,7 @@ def _headers_as_dict( def _log_all_api_requests( request: MiddlewareRequestDict, response: MiddlewareResponseDict, + app: Flask, config: Config, log: Logger, ): @@ -216,7 +217,10 @@ def _log_all_api_requests( f"{server.host}:{server.port}", f"{client.host}:{client.port}", "Anonymous" - if isinstance(current_user, AnonymousUserMixin) + if ( + isinstance(current_user, AnonymousUserMixin) + or not hasattr(app, "login_manager") + ) else current_user.get_id(), extra={ "props": { @@ -318,7 +322,13 @@ def __init__(self, app: ASGIApp): @inject async def __call__( - self, scope: Scope, receive: Receive, send: Send, config: Config, log: Logger + self, + scope: Scope, + receive: Receive, + send: Send, + config: Config, + log: Logger, + app: Flask, ) -> None: async def wrapped_send(message: Any) -> None: nonlocal scope @@ -331,7 +341,7 @@ async def wrapped_send(message: Any) -> None: response = cast(MiddlewareResponseDict, scope) request = cast(MiddlewareRequestDict, scope) - _log_all_api_requests(request, response, config, log) + _log_all_api_requests(request, response, app, config, log) return await send(message) From 75045fb82d212d4e16e93af383d3f6a19498adf9 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 20 Sep 2024 15:32:51 -0700 Subject: [PATCH 28/41] Change feature flag root API URL to /platform. --- .../web/middleware/feature_flags/__init__.py | 2 +- .../middleware/test_feature_flags_middleware.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index 02e4653c..b9f978ff 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -34,7 +34,7 @@ class FeatureFlagConfig(BaseModel): - api_base_url: str = "/server" + api_base_url: str = "/platform" access_role_name: str | bool | None = None diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index c58a208e..ebf2c76c 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -115,7 +115,7 @@ def app_init_hook( openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) ) - response = app.client.get("/server/feature_flag") + response = app.client.get("/platform/feature_flag") # 401 for now because no real auth is configured. # if SSO was broken, 500 would return @@ -145,7 +145,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_us User, # pyright: ignore[reportArgumentType] mocker, ): - response = app.client.get("/server/feature_flag") + response = app.client.get("/platform/feature_flag") assert response.status_code == 404 get_feature_flag_mock.assert_called_once() @@ -167,7 +167,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_specified_role( def client_init_hook(app: OpenAPIAppResult): feature_flag_config = FeatureFlagConfig( access_role_name="Operator", - api_base_url="/server", # the default + api_base_url="/platform", # the default ) app.app_injector.flask_injector.injector.binder.bind( FeatureFlagConfig, to=feature_flag_config @@ -186,7 +186,7 @@ def client_init_hook(app: OpenAPIAppResult): mocker, [Role.Operator] if has_role else [], ): - response = app.client.get("/server/feature_flag") + response = app.client.get("/platform/feature_flag") if has_role: assert response.status_code == 404 @@ -214,7 +214,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_returns_no_feature_flags_w User, # pyright: ignore[reportArgumentType] mocker, ): - response = app.client.get("/server/feature_flag") + response = app.client.get("/platform/feature_flag") assert response.status_code == 404 response_json = response.json() @@ -250,7 +250,7 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): User, # pyright: ignore[reportArgumentType] mocker, ): - response = app.client.get("/server/feature_flag") + response = app.client.get("/platform/feature_flag") assert response.status_code == 200 response_json = response.json() @@ -293,7 +293,7 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): mocker, ): response = app.client.get( - "/server/feature_flag", params={"name": query_flags} + "/platform/feature_flag", params={"name": query_flags} ) assert response.status_code == 200 @@ -337,7 +337,7 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): mocker, ): response = app.client.patch( - "/server/feature_flag", + "/platform/feature_flag", json=[{"name": "foo_feature", "enabled": False}], ) From 1607b574291b65beb355e887f0d8d536813813b2 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Mon, 23 Sep 2024 16:14:22 -0700 Subject: [PATCH 29/41] Add the ability for Feature Flags to work without user sessions. This requires some updates to how the Flask client creator for tests works. This commit contains an intentionally broken test. --- .../web/middleware/feature_flags/__init__.py | 49 ++++++++++++++----- src/web/Ligare/web/testing/create_app.py | 2 - .../test_feature_flags_middleware.py | 38 +++++++++++++- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/BL_Python/web/middleware/feature_flags/__init__.py index b9f978ff..df47e673 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/BL_Python/web/middleware/feature_flags/__init__.py @@ -106,25 +106,52 @@ def _provide_caching_feature_flag_router( return injector.get(self._t_feature_flag) -def get_feature_flag_blueprint(config: FeatureFlagConfig): +@inject +def get_feature_flag_blueprint(app: Flask, config: FeatureFlagConfig, log: Logger): feature_flag_blueprint = Blueprint( "feature_flag", __name__, url_prefix=f"{config.api_base_url}" ) access_role = config.access_role_name - def _login_required(fn: Callable[..., Any]): - if access_role is False: - return fn + def _login_required(require_flask_login: bool): + """ + Decorate an API endpoint with the correct flask_login authentication + method given the requirements of the API endpoint. - # None means no roles were specified, but a session is still required - if access_role is None or access_role is True: - return login_required(fn) + require_flask_login is ignored if flask_login has been configured. - return login_required([access_role])(fn) + If flask_login has _not_ been configured: + * If require_flask_login is True, a warning is logged and a method returning False is returned, rather than returning the endpoint function + * If require_flask_login is False, the endpoint function is returned + + :param bool require_flask_login: Determine whether flask_login must be configured for this endpoint to function + :return _type_: _description_ + """ + if not hasattr(app, "login_manager"): + if require_flask_login: + log.warning( + "The Feature Flag module expects flask_login to be configured in order to control access to feature flag modifications. flask_login has not been configured, so the Feature Flag modification API is disabled." + ) + return lambda *args, **kwargs: False + else: + return lambda fn: fn + + def _login_required(fn: Callable[..., Any]): + if access_role is False: + return fn + + # None means no roles were specified, but a session is still required + if access_role is None or access_role is True: + # FIXME feature flags needs a login_manager assigned to Flask + return login_required(fn) + + return login_required([access_role])(fn) + + return _login_required @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] - @_login_required + @_login_required(False) @inject def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] request_query_names: list[str] | None = request.query_params.getlist("name") @@ -174,7 +201,7 @@ def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyrigh @feature_flag_blueprint.route("/feature_flag", methods=("PATCH",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] # @login_required([UserRole.Operator]) # TODO assign a specific role ? - @_login_required + @_login_required(True) @inject async def feature_flag_patch(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] feature_flags_request: list[FeatureFlagPatchRequest] = await request.json() @@ -256,7 +283,7 @@ async def wrapped_send(message: Any) -> None: log.debug("Registering FeatureFlag blueprint.") app.register_blueprint( - get_feature_flag_blueprint(injector.get(FeatureFlagConfig)) + injector.call_with_injection(get_feature_flag_blueprint) ) log.debug("FeatureFlag blueprint registered.") diff --git a/src/web/Ligare/web/testing/create_app.py b/src/web/Ligare/web/testing/create_app.py index ab168c3a..7b855e63 100644 --- a/src/web/Ligare/web/testing/create_app.py +++ b/src/web/Ligare/web/testing/create_app.py @@ -417,7 +417,6 @@ def __get_basic_flask_app( application_configs = [] if application_modules is None: application_modules = [] - application_configs.append(SSOConfig) application_modules.append(SAML2MiddlewareModule) app = App[Flask].create("config.toml", application_configs, application_modules) yield app @@ -538,7 +537,6 @@ def _get_real_openapi_app( application_configs = [] if application_modules is None: application_modules = [] - application_configs.append(SSOConfig) application_modules.append(SAML2MiddlewareModule) application_modules.append( UserLoaderModule( diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index ebf2c76c..65ac86f5 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -15,6 +15,7 @@ FeatureFlagConfig, FeatureFlagMiddlewareModule, ) +from BL_Python.web.middleware.sso import SAML2MiddlewareModule from BL_Python.web.testing.create_app import ( CreateOpenAPIApp, OpenAPIClientInjectorConfigurable, @@ -97,7 +98,7 @@ def _user_session_app_init_hook( application_modules.append(CachingFeatureFlagRouterModule) application_modules.append(FeatureFlagMiddlewareModule()) - def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_user_session( + def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_user_session_when_flask_login_is_configured( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, @@ -121,6 +122,41 @@ def app_init_hook( # if SSO was broken, 500 would return assert response.status_code == 401 + def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + ): + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + # application_modules.remove(SAML2MiddlewareModule) + # application_modules.remove(UserLoaderModule) + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) + + def client_init_hook(app: CreateAppResult[FlaskApp]): + pass + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + client_init_hook=client_init_hook, + app_init_hook=app_init_hook, + ) + ) + + # del app.client.app.app.login_manager + + response = app.client.get("/platform/feature_flag") + + # 401 for now because no real auth is configured. + # if SSO was broken, 500 would return + assert response.status_code == 200 + def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_user_has_session( self, openapi_config: Config, From b4a46744f93748178dcede0964224ee60aeef0e2 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 26 Sep 2024 17:08:13 -0700 Subject: [PATCH 30/41] Final rename changes necessary after rebase with main. --- .../Ligare/database/dependency_injection.py | 2 +- src/database/pyproject.toml | 79 +++++---------- .../feature_flag/database/__init__.py | 0 .../platform/feature_flag/database/migrate.py | 2 +- src/platform/pyproject.toml | 60 +++++------- .../test_db_feature_flag_router.py | 4 - .../patterns/dependency_injection.py | 2 +- src/web/Ligare/web/config.py | 1 - .../{BL_Python => Ligare}/web/exception.py | 0 .../web/middleware/feature_flags/__init__.py | 26 ++--- src/web/Ligare/web/middleware/sso.py | 2 +- src/web/pyproject.toml | 94 ++++++------------ .../test_feature_flags_middleware.py | 97 +++++++++---------- src/web/test/unit/test_config.py | 3 +- 14 files changed, 141 insertions(+), 231 deletions(-) rename src/platform/{BL_Python => Ligare}/platform/feature_flag/database/__init__.py (100%) rename src/platform/{BL_Python => Ligare}/platform/feature_flag/database/migrate.py (97%) rename src/web/{BL_Python => Ligare}/web/exception.py (100%) rename src/web/{BL_Python => Ligare}/web/middleware/feature_flags/__init__.py (93%) diff --git a/src/database/Ligare/database/dependency_injection.py b/src/database/Ligare/database/dependency_injection.py index bbd2da47..56d1ef3f 100644 --- a/src/database/Ligare/database/dependency_injection.py +++ b/src/database/Ligare/database/dependency_injection.py @@ -1,4 +1,4 @@ -from injector import Binder, CallableProvider, Injector, Module, inject, singleton +from injector import Binder, CallableProvider, Injector, inject, singleton from Ligare.database.config import Config, DatabaseConfig from Ligare.database.engine import DatabaseEngine from Ligare.database.types import MetaBase diff --git a/src/database/pyproject.toml b/src/database/pyproject.toml index 66c75601..82299dec 100644 --- a/src/database/pyproject.toml +++ b/src/database/pyproject.toml @@ -1,70 +1,43 @@ [build-system] - -requires = [ - "setuptools>=42", - "wheel" -] - +requires = [ "setuptools>=42", "wheel",] build-backend = "setuptools.build_meta" [project] name = "Ligare.database" requires-python = ">=3.10" -authors = [ - {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} -] -description = 'Libraries for working with databases.' -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License (GPL)", - "Operating System :: OS Independent", - "Topic :: Scientific/Engineering", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Intended Audience :: Science/Research", - "Intended Audience :: Developers", - "Development Status :: 4 - Beta", - "Natural Language :: English" -] - -dependencies = [ - "Ligare.programming", - - "sqlalchemy >= 1.4,< 2.0", - "alembic ~= 1.8", - "sqlalchemy2-stubs ~= 0.0.2a34", - "injector", - "pydantic" -] - -dynamic = ["version", "readme"] -[tool.setuptools.dynamic] -version = {attr = "Ligare.database.__version__"} -readme = {file = ["README.md"], content-type = "text/markdown"} +description = "Libraries for working with databases." +classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] +dependencies = [ "Ligare.programming @ file:///home/aholmes/repos/Ligare/src/programming", "sqlalchemy >= 1.4,< 2.0", "alembic ~= 1.8", "sqlalchemy2-stubs ~= 0.0.2a34", "injector", "pydantic",] +dynamic = [ "version", "readme",] +[[project.authors]] +name = "Aaron Holmes" +email = "aholmes@mednet.ucla.edu" [project.urls] -"Homepage" = "https://github.com/uclahs-cds/Ligare" +Homepage = "https://github.com/uclahs-cds/Ligare" "Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -"Repository" = "https://github.com/uclahs-cds/Ligare.git" -"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" +Repository = "https://github.com/uclahs-cds/Ligare.git" +Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" + +[project.scripts] +ligare-alembic = "Ligare.database.migrations.alembic.__main__:ligare_alembic" + +[project.optional-dependencies] +postgres = [ "psycopg2 ~= 2.9",] +postgres-binary = [ "psycopg2-binary ~= 2.9",] [tool.setuptools.package-dir] "Ligare.database" = "Ligare/database" -[tool.setuptools.packages.find] -exclude = ["build*"] - [tool.setuptools.package-data] -"Ligare.database" = ["py.typed"] +"Ligare.database" = [ "py.typed",] -[project.scripts] -ligare-alembic = "Ligare.database.migrations.alembic.__main__:ligare_alembic" +[tool.setuptools.dynamic.version] +attr = "Ligare.database.__version__" -[project.optional-dependencies] -postgres = [ - "psycopg2 ~= 2.9" -] +[tool.setuptools.dynamic.readme] +file = [ "README.md",] +content-type = "text/markdown" -postgres-binary = [ - "psycopg2-binary ~= 2.9" -] +[tool.setuptools.packages.find] +exclude = [ "build*",] diff --git a/src/platform/BL_Python/platform/feature_flag/database/__init__.py b/src/platform/Ligare/platform/feature_flag/database/__init__.py similarity index 100% rename from src/platform/BL_Python/platform/feature_flag/database/__init__.py rename to src/platform/Ligare/platform/feature_flag/database/__init__.py diff --git a/src/platform/BL_Python/platform/feature_flag/database/migrate.py b/src/platform/Ligare/platform/feature_flag/database/migrate.py similarity index 97% rename from src/platform/BL_Python/platform/feature_flag/database/migrate.py rename to src/platform/Ligare/platform/feature_flag/database/migrate.py index 198a0e9f..ad24a2d6 100644 --- a/src/platform/BL_Python/platform/feature_flag/database/migrate.py +++ b/src/platform/Ligare/platform/feature_flag/database/migrate.py @@ -2,7 +2,7 @@ import sqlalchemy as sa from alembic.operations.base import Operations -from BL_Python.database.schema import get_type_from_op +from Ligare.database.schema import get_type_from_op from sqlalchemy import false from ..db_feature_flag_router import FeatureFlagTable diff --git a/src/platform/pyproject.toml b/src/platform/pyproject.toml index 6c959938..aa6fe654 100644 --- a/src/platform/pyproject.toml +++ b/src/platform/pyproject.toml @@ -1,52 +1,36 @@ [build-system] - -requires = [ - "setuptools>=42", - "wheel" -] - +requires = [ "setuptools>=42", "wheel",] build-backend = "setuptools.build_meta" [project] name = "Ligare.platform" requires-python = ">=3.10" -authors = [ - {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} -] -description = 'Libraries for developing PaaS software.' -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License (GPL)", - "Operating System :: OS Independent", - "Topic :: Scientific/Engineering", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Intended Audience :: Science/Research", - "Intended Audience :: Developers", - "Development Status :: 4 - Beta", - "Natural Language :: English" -] - -dependencies = [ - "Ligare.database" -] - -dynamic = ["version", "readme"] -[tool.setuptools.dynamic] -version = {attr = "Ligare.platform.__version__"} -readme = {file = ["README.md"], content-type = "text/markdown"} +description = "Libraries for developing PaaS software." +classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] +dependencies = [ "Ligare.database @ file:///home/aholmes/repos/Ligare/src/database",] +dynamic = [ "version", "readme",] +[[project.authors]] +name = "Aaron Holmes" +email = "aholmes@mednet.ucla.edu" [project.urls] -"Homepage" = "https://github.com/uclahs-cds/Ligare" +Homepage = "https://github.com/uclahs-cds/Ligare" "Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -"Repository" = "https://github.com/uclahs-cds/Ligare.git" -"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" +Repository = "https://github.com/uclahs-cds/Ligare.git" +Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" [tool.setuptools.package-dir] "Ligare.platform" = "Ligare/platform" -[tool.setuptools.packages.find] -exclude = ["build*"] - [tool.setuptools.package-data] -"Ligare.platform" = ["py.typed"] +"Ligare.platform" = [ "py.typed",] + +[tool.setuptools.dynamic.version] +attr = "Ligare.platform.__version__" + +[tool.setuptools.dynamic.readme] +file = [ "README.md",] +content-type = "text/markdown" + +[tool.setuptools.packages.find] +exclude = [ "build*",] diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index 28eac030..cafb75f6 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -4,10 +4,6 @@ import pytest from Ligare.database.config import DatabaseConfig from Ligare.database.dependency_injection import ScopedSessionModule -from Ligare.platform.feature_flag import caching_feature_flag_router -from Ligare.platform.feature_flag.caching_feature_flag_router import ( - CachingFeatureFlagRouter, -) from Ligare.platform.feature_flag.db_feature_flag_router import ( DBFeatureFlagRouter, FeatureFlag, diff --git a/src/programming/Ligare/programming/patterns/dependency_injection.py b/src/programming/Ligare/programming/patterns/dependency_injection.py index 1682e954..1a5393ce 100644 --- a/src/programming/Ligare/programming/patterns/dependency_injection.py +++ b/src/programming/Ligare/programming/patterns/dependency_injection.py @@ -2,8 +2,8 @@ import sys from typing import Callable, TypeVar -from BL_Python.programming.config import AbstractConfig from injector import Binder, Module, Provider +from Ligare.programming.config import AbstractConfig from typing_extensions import override diff --git a/src/web/Ligare/web/config.py b/src/web/Ligare/web/config.py index b5de5ece..e41c2ebf 100644 --- a/src/web/Ligare/web/config.py +++ b/src/web/Ligare/web/config.py @@ -2,7 +2,6 @@ from os import environ from typing import Literal -from BL_Python.programming.config import AbstractConfig from flask.config import Config as FlaskAppConfig from Ligare.programming.config import AbstractConfig from pydantic import BaseModel diff --git a/src/web/BL_Python/web/exception.py b/src/web/Ligare/web/exception.py similarity index 100% rename from src/web/BL_Python/web/exception.py rename to src/web/Ligare/web/exception.py diff --git a/src/web/BL_Python/web/middleware/feature_flags/__init__.py b/src/web/Ligare/web/middleware/feature_flags/__init__.py similarity index 93% rename from src/web/BL_Python/web/middleware/feature_flags/__init__.py rename to src/web/Ligare/web/middleware/feature_flags/__init__.py index df47e673..444301c9 100644 --- a/src/web/BL_Python/web/middleware/feature_flags/__init__.py +++ b/src/web/Ligare/web/middleware/feature_flags/__init__.py @@ -2,32 +2,32 @@ from logging import Logger from typing import Any, Callable, Generic, Sequence, TypedDict, cast -from BL_Python.platform.feature_flag.caching_feature_flag_router import ( +from connexion import FlaskApp, request +from flask import Blueprint, Flask +from injector import Binder, Injector, Module, inject, provider, singleton +from Ligare.platform.feature_flag.caching_feature_flag_router import ( CachingFeatureFlagRouter, ) -from BL_Python.platform.feature_flag.caching_feature_flag_router import ( +from Ligare.platform.feature_flag.caching_feature_flag_router import ( FeatureFlag as CachingFeatureFlag, ) -from BL_Python.platform.feature_flag.db_feature_flag_router import DBFeatureFlagRouter -from BL_Python.platform.feature_flag.db_feature_flag_router import ( +from Ligare.platform.feature_flag.db_feature_flag_router import DBFeatureFlagRouter +from Ligare.platform.feature_flag.db_feature_flag_router import ( FeatureFlag as DBFeatureFlag, ) -from BL_Python.platform.feature_flag.db_feature_flag_router import ( +from Ligare.platform.feature_flag.db_feature_flag_router import ( FeatureFlagTable, FeatureFlagTableBase, ) -from BL_Python.platform.feature_flag.feature_flag_router import ( +from Ligare.platform.feature_flag.feature_flag_router import ( FeatureFlag, FeatureFlagRouter, TFeatureFlag, ) -from BL_Python.platform.identity.user_loader import Role -from BL_Python.programming.config import AbstractConfig -from BL_Python.programming.patterns.dependency_injection import ConfigurableModule -from BL_Python.web.middleware.sso import login_required -from connexion import FlaskApp, request -from flask import Blueprint, Flask -from injector import Binder, Injector, Module, inject, provider, singleton +from Ligare.platform.identity.user_loader import Role +from Ligare.programming.config import AbstractConfig +from Ligare.programming.patterns.dependency_injection import ConfigurableModule +from Ligare.web.middleware.sso import login_required from pydantic import BaseModel from starlette.types import ASGIApp, Receive, Scope, Send from typing_extensions import override diff --git a/src/web/Ligare/web/middleware/sso.py b/src/web/Ligare/web/middleware/sso.py index 904169cf..0c034f2e 100644 --- a/src/web/Ligare/web/middleware/sso.py +++ b/src/web/Ligare/web/middleware/sso.py @@ -37,7 +37,7 @@ from flask_login import logout_user # pyright: ignore[reportUnknownVariableType] from flask_login import current_user from flask_login import login_required as flask_login_required -from injector import Binder, Injector, Module, inject +from injector import Binder, Injector, inject from Ligare.identity.config import Config, SAML2Config, SSOConfig from Ligare.identity.dependency_injection import SAML2Module, SSOModule from Ligare.identity.SAML2 import SAML2Client diff --git a/src/web/pyproject.toml b/src/web/pyproject.toml index 08bd03e6..6ac96bcd 100644 --- a/src/web/pyproject.toml +++ b/src/web/pyproject.toml @@ -1,86 +1,46 @@ [build-system] - -requires = [ - "setuptools>=42", - "wheel" -] - +requires = [ "setuptools>=42", "wheel",] build-backend = "setuptools.build_meta" [project] name = "Ligare.web" requires-python = ">=3.10" -authors = [ - {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} -] -description = 'Libraries for building web applications.' -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License (GPL)", - "Operating System :: OS Independent", - "Topic :: Scientific/Engineering", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Intended Audience :: Science/Research", - "Intended Audience :: Developers", - "Development Status :: 4 - Beta", - "Natural Language :: English" -] +description = "Libraries for building web applications." +classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] +dependencies = [ "Ligare.programming @ file:///home/aholmes/repos/Ligare/src/programming", "Ligare.platform @ file:///home/aholmes/repos/Ligare/src/platform", "Ligare.identity @ file:///home/aholmes/repos/Ligare/src/identity", "Ligare.database @ file:///home/aholmes/repos/Ligare/src/database", "Flask == 3.0.3", "flask-injector", "flask-login", "connexion == 3.1.0", "connexion[uvicorn]", "uvicorn-worker", "swagger_ui_bundle", "python-dotenv", "json-logging", "lib_programname", "toml", "pydantic", "a2wsgi",] +dynamic = [ "version", "readme",] +[[project.authors]] +name = "Aaron Holmes" +email = "aholmes@mednet.ucla.edu" + +[project.urls] +Homepage = "https://github.com/uclahs-cds/Ligare" +"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" +Repository = "https://github.com/uclahs-cds/Ligare.git" +Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" -dependencies = [ - "Ligare.programming", - "Ligare.platform", - "Ligare.identity", - "Ligare.database", +[project.scripts] +ligare-scaffold = "Ligare.web.scaffolding.__main__:scaffold" - "Flask == 3.0.3", - "flask-injector", - "flask-login", - "connexion == 3.1.0", - "connexion[uvicorn]", - "uvicorn-worker", - "swagger_ui_bundle", - "python-dotenv", - "json-logging", - "lib_programname", - "toml", - "pydantic", - "a2wsgi" -] +[project.optional-dependencies] +dev-dependencies = [ "pytest", "pytest-mock", "mock", "pytest-cov",] -dynamic = ["version", "readme"] [tool.setuptools] include-package-data = true -[project.urls] -"Homepage" = "https://github.com/uclahs-cds/Ligare" -"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -"Repository" = "https://github.com/uclahs-cds/Ligare.git" -"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" - -[tool.setuptools.dynamic] -version = {attr = "Ligare.web.__version__"} -readme = {file = ["README.md"], content-type = "text/markdown"} - [tool.setuptools.package-dir] "Ligare.web" = "Ligare/web" -[tool.setuptools.packages.find] -exclude = ["build*"] - - [tool.setuptools.package-data] -"*" = ["*.j2"] -"Ligare.web" = ["py.typed"] +"*" = [ "*.j2",] +"Ligare.web" = [ "py.typed",] +[tool.setuptools.dynamic.version] +attr = "Ligare.web.__version__" -[project.scripts] -ligare-scaffold = "Ligare.web.scaffolding.__main__:scaffold" +[tool.setuptools.dynamic.readme] +file = [ "README.md",] +content-type = "text/markdown" -[project.optional-dependencies] -dev-dependencies = [ - "pytest", - "pytest-mock", - "mock", - "pytest-cov" -] +[tool.setuptools.packages.find] +exclude = [ "build*",] diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 65ac86f5..17465feb 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -3,27 +3,26 @@ from typing import Generic, Sequence, TypeVar import pytest -from BL_Python.platform.dependency_injection import UserLoaderModule -from BL_Python.platform.feature_flag import FeatureFlag -from BL_Python.platform.feature_flag.feature_flag_router import FeatureFlagRouter -from BL_Python.platform.identity.user_loader import Role as LoaderRole -from BL_Python.programming.config import AbstractConfig -from BL_Python.web.application import CreateAppResult, OpenAPIAppResult -from BL_Python.web.config import Config -from BL_Python.web.middleware.feature_flags import ( +from connexion import FlaskApp +from flask_login import UserMixin +from injector import Module +from Ligare.platform.dependency_injection import UserLoaderModule +from Ligare.platform.feature_flag import FeatureFlag +from Ligare.platform.feature_flag.feature_flag_router import FeatureFlagRouter +from Ligare.platform.identity.user_loader import Role as LoaderRole +from Ligare.programming.config import AbstractConfig +from Ligare.web.application import CreateAppResult, OpenAPIAppResult +from Ligare.web.config import Config +from Ligare.web.middleware.feature_flags import ( CachingFeatureFlagRouterModule, FeatureFlagConfig, FeatureFlagMiddlewareModule, ) -from BL_Python.web.middleware.sso import SAML2MiddlewareModule -from BL_Python.web.testing.create_app import ( +from Ligare.web.testing.create_app import ( CreateOpenAPIApp, OpenAPIClientInjectorConfigurable, OpenAPIMockController, ) -from connexion import FlaskApp -from flask_login import UserMixin -from injector import Module from mock import MagicMock from pytest_mock import MockerFixture from typing_extensions import override @@ -122,40 +121,40 @@ def app_init_hook( # if SSO was broken, 500 would return assert response.status_code == 401 - def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( - self, - openapi_config: Config, - openapi_client_configurable: OpenAPIClientInjectorConfigurable, - openapi_mock_controller: OpenAPIMockController, - ): - def app_init_hook( - application_configs: list[type[AbstractConfig]], - application_modules: list[Module | type[Module]], - ): - # application_modules.remove(SAML2MiddlewareModule) - # application_modules.remove(UserLoaderModule) - application_modules.append(CachingFeatureFlagRouterModule) - application_modules.append(FeatureFlagMiddlewareModule()) - - def client_init_hook(app: CreateAppResult[FlaskApp]): - pass - - openapi_mock_controller.begin() - app = next( - openapi_client_configurable( - openapi_config, - client_init_hook=client_init_hook, - app_init_hook=app_init_hook, - ) - ) - - # del app.client.app.app.login_manager - - response = app.client.get("/platform/feature_flag") - - # 401 for now because no real auth is configured. - # if SSO was broken, 500 would return - assert response.status_code == 200 + # def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( + # self, + # openapi_config: Config, + # openapi_client_configurable: OpenAPIClientInjectorConfigurable, + # openapi_mock_controller: OpenAPIMockController, + # ): + # def app_init_hook( + # application_configs: list[type[AbstractConfig]], + # application_modules: list[Module | type[Module]], + # ): + # # application_modules.remove(SAML2MiddlewareModule) + # # application_modules.remove(UserLoaderModule) + # application_modules.append(CachingFeatureFlagRouterModule) + # application_modules.append(FeatureFlagMiddlewareModule()) + + # def client_init_hook(app: CreateAppResult[FlaskApp]): + # pass + + # openapi_mock_controller.begin() + # app = next( + # openapi_client_configurable( + # openapi_config, + # client_init_hook=client_init_hook, + # app_init_hook=app_init_hook, + # ) + # ) + + # # del app.client.app.app.login_manager + + # response = app.client.get("/platform/feature_flag") + + # # 401 for now because no real auth is configured. + # # if SSO was broken, 500 would return + # assert response.status_code == 200 def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_user_has_session( self, @@ -165,7 +164,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_us mocker: MockerFixture, ): get_feature_flag_mock = mocker.patch( - "BL_Python.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", + "Ligare.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", return_value=[], ) @@ -196,7 +195,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_specified_role( mocker: MockerFixture, ): get_feature_flag_mock = mocker.patch( - "BL_Python.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", + "Ligare.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", return_value=[], ) diff --git a/src/web/test/unit/test_config.py b/src/web/test/unit/test_config.py index 4237eab6..37fe3c24 100644 --- a/src/web/test/unit/test_config.py +++ b/src/web/test/unit/test_config.py @@ -1,12 +1,11 @@ import pytest from flask import Flask from Ligare.programming.collections.dict import AnyDict -from Ligare.programming.config import AbstractConfig, ConfigBuilder, load_config +from Ligare.programming.config import AbstractConfig, load_config from Ligare.programming.config.exceptions import ConfigBuilderStateError from Ligare.web.application import ApplicationBuilder, ApplicationConfigBuilder from Ligare.web.config import Config from Ligare.web.exception import BuilderBuildError, InvalidBuilderStateError -from mock import MagicMock from pydantic import BaseModel from pytest_mock import MockerFixture from typing_extensions import override From 26078dae8cc3d4a0dc8082796eb9d9de4d8ccc21 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 26 Sep 2024 17:08:43 -0700 Subject: [PATCH 31/41] Fix Makefile issues: - previously unable to run `make test*` or `make format*` targets due to empty variable value for `BUILD_TARGET` - `make test-pytest` did not exit with an error code when `pytest` had failed tests --- Makefile | 23 +++---- .../test_feature_flags_middleware.py | 68 +++++++++---------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/Makefile b/Makefile index c09a20e0..0f48c611 100644 --- a/Makefile +++ b/Makefile @@ -41,16 +41,6 @@ TOX_DIR := .tox DEFAULT_TARGET ?= dev .DEFAULT_GOAL := $(DEFAULT_TARGET) - -ifeq ($(DEFAULT_TARGET),dev) - BUILD_TARGET := $(SETUP_DEV_SENTINEL) -else ifeq ($(DEFAULT_TARGET),cicd) - BUILD_TARGET := $(SETUP_CICD_SENTINEL) -else - $(error DEFAULT_TARGET must be one of "dev" or "cicd") -endif - - ACTIVATE_VENV := . $(VENV)/bin/activate REPORT_VENV_USAGE := echo '\nActivate your venv with `. $(VENV)/bin/activate`' @@ -73,6 +63,15 @@ SETUP_DEV_SENTINEL = $(MAKE_ARTIFACT_DIRECTORY)/setup_dev_sentinel SETUP_CICD_SENTINEL = $(MAKE_ARTIFACT_DIRECTORY)/setup_cicd_sentinel PYPROJECT_FILES_SENTINEL = $(MAKE_ARTIFACT_DIRECTORY)/pyproject_sentinel +ifeq ($(DEFAULT_TARGET),dev) + BUILD_TARGET := $(SETUP_DEV_SENTINEL) +else ifeq ($(DEFAULT_TARGET),cicd) + BUILD_TARGET := $(SETUP_CICD_SENTINEL) +else + $(error DEFAULT_TARGET must be one of "dev" or "cicd") +endif + + $(PYPROJECT_FILES_SENTINEL): $(VENV) $(MAKE) $(PYPROJECT_FILES) touch $@ @@ -196,10 +195,10 @@ test-bandit : $(VENV) $(BUILD_TARGET) -r . test-pytest : $(VENV) $(BUILD_TARGET) - -$(ACTIVATE_VENV) && \ + $(ACTIVATE_VENV) && \ PYTEST_TARGET=$(PYTEST_TARGET) tox && PYTEST_EXIT_CODE=0 || PYTEST_EXIT_CODE=$$?; \ coverage html --data-file=$(REPORTS_DIR)/$(PYTEST_REPORT)/.coverage; \ - junit2html $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.xml $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.html; \ + junit2html $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.xml $(REPORTS_DIR)/$(PYTEST_REPORT)/pytest.html && \ exit $$PYTEST_EXIT_CODE .PHONY: test test-pytest test-bandit test-pyright test-ruff test-isort diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index 17465feb..fb04f61d 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -121,40 +121,40 @@ def app_init_hook( # if SSO was broken, 500 would return assert response.status_code == 401 - # def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( - # self, - # openapi_config: Config, - # openapi_client_configurable: OpenAPIClientInjectorConfigurable, - # openapi_mock_controller: OpenAPIMockController, - # ): - # def app_init_hook( - # application_configs: list[type[AbstractConfig]], - # application_modules: list[Module | type[Module]], - # ): - # # application_modules.remove(SAML2MiddlewareModule) - # # application_modules.remove(UserLoaderModule) - # application_modules.append(CachingFeatureFlagRouterModule) - # application_modules.append(FeatureFlagMiddlewareModule()) - - # def client_init_hook(app: CreateAppResult[FlaskApp]): - # pass - - # openapi_mock_controller.begin() - # app = next( - # openapi_client_configurable( - # openapi_config, - # client_init_hook=client_init_hook, - # app_init_hook=app_init_hook, - # ) - # ) - - # # del app.client.app.app.login_manager - - # response = app.client.get("/platform/feature_flag") - - # # 401 for now because no real auth is configured. - # # if SSO was broken, 500 would return - # assert response.status_code == 200 + def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( + self, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + ): + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + # application_modules.remove(SAML2MiddlewareModule) + # application_modules.remove(UserLoaderModule) + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) + + def client_init_hook(app: CreateAppResult[FlaskApp]): + pass + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + client_init_hook=client_init_hook, + app_init_hook=app_init_hook, + ) + ) + + # del app.client.app.app.login_manager + + response = app.client.get("/platform/feature_flag") + + # 401 for now because no real auth is configured. + # if SSO was broken, 500 would return + assert response.status_code == 200 def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_user_has_session( self, From d56215ef928d80e5607ce4fd1bfd65fd701bc403 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 26 Sep 2024 17:15:23 -0700 Subject: [PATCH 32/41] Prevent tests from writing alembic.ini at project root. --- src/database/test/unit/migrations/alembic/test_ligare_alembic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/database/test/unit/migrations/alembic/test_ligare_alembic.py b/src/database/test/unit/migrations/alembic/test_ligare_alembic.py index 00f6b2e4..4ecce3ad 100644 --- a/src/database/test/unit/migrations/alembic/test_ligare_alembic.py +++ b/src/database/test/unit/migrations/alembic/test_ligare_alembic.py @@ -76,6 +76,7 @@ def test__LigareAlembic__passes_through_to_alembic_with_default_config_when_not_ ) ligare_alembic = LigareAlembic(None, MagicMock()) + ligare_alembic._write_ligare_alembic_config = MagicMock() ligare_alembic.run() assert alembic_main.called From 63299079fd6e9d6ac041c5055f5f0367a9e44f46 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 27 Sep 2024 11:00:33 -0700 Subject: [PATCH 33/41] Revert unintentional changes to pyproject.toml files. --- src/database/pyproject.toml | 79 +++++++++++++++++++++---------- src/platform/pyproject.toml | 60 ++++++++++++++--------- src/web/pyproject.toml | 94 ++++++++++++++++++++++++++----------- 3 files changed, 158 insertions(+), 75 deletions(-) diff --git a/src/database/pyproject.toml b/src/database/pyproject.toml index 82299dec..66c75601 100644 --- a/src/database/pyproject.toml +++ b/src/database/pyproject.toml @@ -1,43 +1,70 @@ [build-system] -requires = [ "setuptools>=42", "wheel",] + +requires = [ + "setuptools>=42", + "wheel" +] + build-backend = "setuptools.build_meta" [project] name = "Ligare.database" requires-python = ">=3.10" -description = "Libraries for working with databases." -classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] -dependencies = [ "Ligare.programming @ file:///home/aholmes/repos/Ligare/src/programming", "sqlalchemy >= 1.4,< 2.0", "alembic ~= 1.8", "sqlalchemy2-stubs ~= 0.0.2a34", "injector", "pydantic",] -dynamic = [ "version", "readme",] -[[project.authors]] -name = "Aaron Holmes" -email = "aholmes@mednet.ucla.edu" +authors = [ + {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} +] +description = 'Libraries for working with databases.' +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Development Status :: 4 - Beta", + "Natural Language :: English" +] -[project.urls] -Homepage = "https://github.com/uclahs-cds/Ligare" -"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -Repository = "https://github.com/uclahs-cds/Ligare.git" -Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" +dependencies = [ + "Ligare.programming", -[project.scripts] -ligare-alembic = "Ligare.database.migrations.alembic.__main__:ligare_alembic" + "sqlalchemy >= 1.4,< 2.0", + "alembic ~= 1.8", + "sqlalchemy2-stubs ~= 0.0.2a34", + "injector", + "pydantic" +] -[project.optional-dependencies] -postgres = [ "psycopg2 ~= 2.9",] -postgres-binary = [ "psycopg2-binary ~= 2.9",] +dynamic = ["version", "readme"] +[tool.setuptools.dynamic] +version = {attr = "Ligare.database.__version__"} +readme = {file = ["README.md"], content-type = "text/markdown"} + +[project.urls] +"Homepage" = "https://github.com/uclahs-cds/Ligare" +"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" +"Repository" = "https://github.com/uclahs-cds/Ligare.git" +"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" [tool.setuptools.package-dir] "Ligare.database" = "Ligare/database" +[tool.setuptools.packages.find] +exclude = ["build*"] + [tool.setuptools.package-data] -"Ligare.database" = [ "py.typed",] +"Ligare.database" = ["py.typed"] -[tool.setuptools.dynamic.version] -attr = "Ligare.database.__version__" +[project.scripts] +ligare-alembic = "Ligare.database.migrations.alembic.__main__:ligare_alembic" -[tool.setuptools.dynamic.readme] -file = [ "README.md",] -content-type = "text/markdown" +[project.optional-dependencies] +postgres = [ + "psycopg2 ~= 2.9" +] -[tool.setuptools.packages.find] -exclude = [ "build*",] +postgres-binary = [ + "psycopg2-binary ~= 2.9" +] diff --git a/src/platform/pyproject.toml b/src/platform/pyproject.toml index aa6fe654..6c959938 100644 --- a/src/platform/pyproject.toml +++ b/src/platform/pyproject.toml @@ -1,36 +1,52 @@ [build-system] -requires = [ "setuptools>=42", "wheel",] + +requires = [ + "setuptools>=42", + "wheel" +] + build-backend = "setuptools.build_meta" [project] name = "Ligare.platform" requires-python = ">=3.10" -description = "Libraries for developing PaaS software." -classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] -dependencies = [ "Ligare.database @ file:///home/aholmes/repos/Ligare/src/database",] -dynamic = [ "version", "readme",] -[[project.authors]] -name = "Aaron Holmes" -email = "aholmes@mednet.ucla.edu" +authors = [ + {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} +] +description = 'Libraries for developing PaaS software.' +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Development Status :: 4 - Beta", + "Natural Language :: English" +] + +dependencies = [ + "Ligare.database" +] + +dynamic = ["version", "readme"] +[tool.setuptools.dynamic] +version = {attr = "Ligare.platform.__version__"} +readme = {file = ["README.md"], content-type = "text/markdown"} [project.urls] -Homepage = "https://github.com/uclahs-cds/Ligare" +"Homepage" = "https://github.com/uclahs-cds/Ligare" "Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -Repository = "https://github.com/uclahs-cds/Ligare.git" -Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" +"Repository" = "https://github.com/uclahs-cds/Ligare.git" +"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" [tool.setuptools.package-dir] "Ligare.platform" = "Ligare/platform" -[tool.setuptools.package-data] -"Ligare.platform" = [ "py.typed",] - -[tool.setuptools.dynamic.version] -attr = "Ligare.platform.__version__" - -[tool.setuptools.dynamic.readme] -file = [ "README.md",] -content-type = "text/markdown" - [tool.setuptools.packages.find] -exclude = [ "build*",] +exclude = ["build*"] + +[tool.setuptools.package-data] +"Ligare.platform" = ["py.typed"] diff --git a/src/web/pyproject.toml b/src/web/pyproject.toml index 6ac96bcd..08bd03e6 100644 --- a/src/web/pyproject.toml +++ b/src/web/pyproject.toml @@ -1,46 +1,86 @@ [build-system] -requires = [ "setuptools>=42", "wheel",] + +requires = [ + "setuptools>=42", + "wheel" +] + build-backend = "setuptools.build_meta" [project] name = "Ligare.web" requires-python = ">=3.10" -description = "Libraries for building web applications." -classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Development Status :: 4 - Beta", "Natural Language :: English",] -dependencies = [ "Ligare.programming @ file:///home/aholmes/repos/Ligare/src/programming", "Ligare.platform @ file:///home/aholmes/repos/Ligare/src/platform", "Ligare.identity @ file:///home/aholmes/repos/Ligare/src/identity", "Ligare.database @ file:///home/aholmes/repos/Ligare/src/database", "Flask == 3.0.3", "flask-injector", "flask-login", "connexion == 3.1.0", "connexion[uvicorn]", "uvicorn-worker", "swagger_ui_bundle", "python-dotenv", "json-logging", "lib_programname", "toml", "pydantic", "a2wsgi",] -dynamic = [ "version", "readme",] -[[project.authors]] -name = "Aaron Holmes" -email = "aholmes@mednet.ucla.edu" - -[project.urls] -Homepage = "https://github.com/uclahs-cds/Ligare" -"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" -Repository = "https://github.com/uclahs-cds/Ligare.git" -Changelog = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" +authors = [ + {name = 'Aaron Holmes', email = 'aholmes@mednet.ucla.edu'} +] +description = 'Libraries for building web applications.' +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Development Status :: 4 - Beta", + "Natural Language :: English" +] -[project.scripts] -ligare-scaffold = "Ligare.web.scaffolding.__main__:scaffold" +dependencies = [ + "Ligare.programming", + "Ligare.platform", + "Ligare.identity", + "Ligare.database", -[project.optional-dependencies] -dev-dependencies = [ "pytest", "pytest-mock", "mock", "pytest-cov",] + "Flask == 3.0.3", + "flask-injector", + "flask-login", + "connexion == 3.1.0", + "connexion[uvicorn]", + "uvicorn-worker", + "swagger_ui_bundle", + "python-dotenv", + "json-logging", + "lib_programname", + "toml", + "pydantic", + "a2wsgi" +] +dynamic = ["version", "readme"] [tool.setuptools] include-package-data = true +[project.urls] +"Homepage" = "https://github.com/uclahs-cds/Ligare" +"Bug Tracker" = "https://github.com/uclahs-cds/Ligare/issues" +"Repository" = "https://github.com/uclahs-cds/Ligare.git" +"Changelog" = "https://github.com/uclahs-cds/Ligare/blob/main/CHANGELOG.md" + +[tool.setuptools.dynamic] +version = {attr = "Ligare.web.__version__"} +readme = {file = ["README.md"], content-type = "text/markdown"} + [tool.setuptools.package-dir] "Ligare.web" = "Ligare/web" +[tool.setuptools.packages.find] +exclude = ["build*"] + + [tool.setuptools.package-data] -"*" = [ "*.j2",] -"Ligare.web" = [ "py.typed",] +"*" = ["*.j2"] +"Ligare.web" = ["py.typed"] -[tool.setuptools.dynamic.version] -attr = "Ligare.web.__version__" -[tool.setuptools.dynamic.readme] -file = [ "README.md",] -content-type = "text/markdown" +[project.scripts] +ligare-scaffold = "Ligare.web.scaffolding.__main__:scaffold" -[tool.setuptools.packages.find] -exclude = [ "build*",] +[project.optional-dependencies] +dev-dependencies = [ + "pytest", + "pytest-mock", + "mock", + "pytest-cov" +] From de10375e0186f76b43a1f296a666da1cfd1b3d56 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Mon, 30 Sep 2024 13:47:22 -0700 Subject: [PATCH 34/41] Alter test OpenAPI/Flask client to pass partially initialized application configs and modules to init methods used in tests. This changes allows tests to module configs and modules that would otherwise be included in the test app. Some tests may need to operate without certain modules. --- src/web/Ligare/web/testing/create_app.py | 66 ++++++++++-------------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/src/web/Ligare/web/testing/create_app.py b/src/web/Ligare/web/testing/create_app.py index 7b855e63..77f3b7c3 100644 --- a/src/web/Ligare/web/testing/create_app.py +++ b/src/web/Ligare/web/testing/create_app.py @@ -302,12 +302,7 @@ def _client_configurable( self, mocker: MockerFixture, app_getter: Callable[ - [ - Config, - MockerFixture, - list[type[AbstractConfig]] | None, - list[Module | type[Module]] | None, - ], + [Config, MockerFixture, TAppInitHook | None], Generator[CreateAppResult[T_app], Any, None], ], client_getter: Callable[ @@ -320,16 +315,7 @@ def _client_getter( client_init_hook: TClientInitHook[T_app] | None = None, app_init_hook: TAppInitHook | None = None, ) -> Generator[ClientInjector[T_flask_client], Any, None]: - application_configs: list[type[AbstractConfig]] | None = None - application_modules: list[Module | type[Module]] | None = None - if app_init_hook is not None: - application_configs = [] - application_modules = [] - app_init_hook(application_configs, application_modules) - - application_result = next( - app_getter(config, mocker, application_configs, application_modules) - ) + application_result = next(app_getter(config, mocker, app_init_hook)) if client_init_hook is not None: client_init_hook(application_result) @@ -396,8 +382,7 @@ def __get_basic_flask_app( self, config: Config, mocker: MockerFixture, - application_configs: list[type[AbstractConfig]] | None = None, - application_modules: list[Module | type[Module]] | None = None, + app_init_hook: TAppInitHook | None = None, ) -> Generator[FlaskAppResult, Any, None]: # prevents the creation of a Connexion application if config.flask is not None: @@ -413,10 +398,12 @@ def __get_basic_flask_app( return_value=MagicMock(load_config=MagicMock(return_value=config)), ) - if application_configs is None: - application_configs = [] - if application_modules is None: - application_modules = [] + application_configs: list[type[AbstractConfig]] | None = [] + application_modules: list[Module | type[Module]] | None = [] + + if app_init_hook is not None: + app_init_hook(application_configs, application_modules) + application_modules.append(SAML2MiddlewareModule) app = App[Flask].create("config.toml", application_configs, application_modules) yield app @@ -518,8 +505,7 @@ def _get_real_openapi_app( self, config: Config, mocker: MockerFixture, - application_configs: list[type[AbstractConfig]] | None = None, - application_modules: list[Module | type[Module]] | None = None, + app_init_hook: TAppInitHook | None = None, ) -> Generator[OpenAPIAppResult, Any, None]: # prevents the creation of a Connexion application if config.flask is None or config.flask.openapi is None: @@ -533,22 +519,26 @@ def _get_real_openapi_app( return_value=MagicMock(load_config=MagicMock(return_value=config)), ) - if application_configs is None: - application_configs = [] - if application_modules is None: - application_modules = [] - application_modules.append(SAML2MiddlewareModule) - application_modules.append( - UserLoaderModule( - loader=User, # pyright: ignore[reportArgumentType] - roles=Role, # pyright: ignore[reportArgumentType] - user_table=MagicMock(), # pyright: ignore[reportArgumentType] - role_table=MagicMock(), # pyright: ignore[reportArgumentType] - bases=[], - ) + _application_configs: list[type[AbstractConfig]] = [] + _application_modules = cast( + list[Module | type[Module]], + [ + SAML2MiddlewareModule, + UserLoaderModule( + loader=User, # pyright: ignore[reportArgumentType] + roles=Role, # pyright: ignore[reportArgumentType] + user_table=MagicMock(), # pyright: ignore[reportArgumentType] + role_table=MagicMock(), # pyright: ignore[reportArgumentType] + bases=[], + ), + ], ) + + if app_init_hook is not None: + app_init_hook(_application_configs, _application_modules) + app = App[FlaskApp].create( - "config.toml", application_configs, application_modules + "config.toml", _application_configs, _application_modules ) yield app From ff18f909830132d167947d9b505c904c0075bfee Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 1 Oct 2024 14:30:26 -0700 Subject: [PATCH 35/41] Get feature flag middleware to permit access to GET when flask-login is not configured. --- .../web/middleware/feature_flags/__init__.py | 42 +++--- src/web/Ligare/web/middleware/sso.py | 38 ++++- .../test_feature_flags_middleware.py | 135 ++++++++++++------ 3 files changed, 153 insertions(+), 62 deletions(-) diff --git a/src/web/Ligare/web/middleware/feature_flags/__init__.py b/src/web/Ligare/web/middleware/feature_flags/__init__.py index 444301c9..054e5dbd 100644 --- a/src/web/Ligare/web/middleware/feature_flags/__init__.py +++ b/src/web/Ligare/web/middleware/feature_flags/__init__.py @@ -1,9 +1,10 @@ from dataclasses import dataclass +from functools import wraps from logging import Logger from typing import Any, Callable, Generic, Sequence, TypedDict, cast from connexion import FlaskApp, request -from flask import Blueprint, Flask +from flask import Blueprint, Flask, abort from injector import Binder, Injector, Module, inject, provider, singleton from Ligare.platform.feature_flag.caching_feature_flag_router import ( CachingFeatureFlagRouter, @@ -128,27 +129,34 @@ def _login_required(require_flask_login: bool): :param bool require_flask_login: Determine whether flask_login must be configured for this endpoint to function :return _type_: _description_ """ - if not hasattr(app, "login_manager"): - if require_flask_login: - log.warning( - "The Feature Flag module expects flask_login to be configured in order to control access to feature flag modifications. flask_login has not been configured, so the Feature Flag modification API is disabled." - ) - return lambda *args, **kwargs: False - else: - return lambda fn: fn - def _login_required(fn: Callable[..., Any]): - if access_role is False: - return fn + def __login_required(fn: Callable[..., Any]): + authorization_implementation: Callable[..., Any] + if access_role is False: + authorization_implementation = fn # None means no roles were specified, but a session is still required - if access_role is None or access_role is True: - # FIXME feature flags needs a login_manager assigned to Flask - return login_required(fn) + elif access_role is None or access_role is True: + authorization_implementation = login_required(fn) + else: + authorization_implementation = login_required([access_role])(fn) + + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if not hasattr(app, "login_manager"): + if require_flask_login: + log.warning( + "The Feature Flag module expects flask_login to be configured in order to control access to feature flag modifications. flask_login has not been configured, so the Feature Flag modification API is disabled." + ) + + return abort(405) + else: + return fn(*args, **kwargs) + return authorization_implementation(*args, **kwargs) - return login_required([access_role])(fn) + return wrapper - return _login_required + return __login_required @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] @_login_required(False) diff --git a/src/web/Ligare/web/middleware/sso.py b/src/web/Ligare/web/middleware/sso.py index 0c034f2e..58a158f4 100644 --- a/src/web/Ligare/web/middleware/sso.py +++ b/src/web/Ligare/web/middleware/sso.py @@ -76,13 +76,47 @@ def __call__(self, user: AuthCheckUser, *args: Any, **kwargs: Any) -> bool: ... P = ParamSpec("P") -R = TypeVar("R") +R = TypeVar("R", bound=Response) + + +from typing import TypeVar, overload + +TWrappedFunc = TypeVar("TWrappedFunc", bound=Callable[..., Callable[..., Any]]) + + +@overload +def login_required() -> Callable[[TWrappedFunc], TWrappedFunc]: ... + + +@overload +def login_required( + roles: Callable[P, R], +) -> Callable[P, R]: ... + + +@overload +def login_required( + roles: Callable[..., Any], +) -> Callable[..., Any]: ... + + +@overload +def login_required( + roles: Sequence[Role | str], +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +@overload +def login_required( + roles: Sequence[Role | str], + auth_check_override: AuthCheckOverrideCallable, +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... def login_required( roles: Sequence[Role | str] | Callable[P, R] | Callable[..., Any] | None = None, auth_check_override: AuthCheckOverrideCallable | None = None, -): +) -> Callable[[TWrappedFunc], TWrappedFunc] | Callable[P, R] | Callable[..., Any]: """ Require a valid Flask session before calling the decorated function. diff --git a/src/web/test/unit/middleware/test_feature_flags_middleware.py b/src/web/test/unit/middleware/test_feature_flags_middleware.py index fb04f61d..dfa20432 100644 --- a/src/web/test/unit/middleware/test_feature_flags_middleware.py +++ b/src/web/test/unit/middleware/test_feature_flags_middleware.py @@ -102,6 +102,7 @@ def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_user_session_when openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, ): def app_init_hook( application_configs: list[type[AbstractConfig]], @@ -112,32 +113,43 @@ def app_init_hook( openapi_mock_controller.begin() app = next( - openapi_client_configurable(openapi_config, app_init_hook=app_init_hook) + openapi_client_configurable( + openapi_config, + app_init_hook=app_init_hook, + ) ) response = app.client.get("/platform/feature_flag") - # 401 for now because no real auth is configured. - # if SSO was broken, 500 would return assert response.status_code == 401 - def test__FeatureFlagMiddleware__feature_flag_api_GET_does_not_require_user_session_when_flask_login_is_not_configured( + @pytest.mark.parametrize( + "flask_login_is_configured,user_has_session", + [[True, True], [False, True], [False, False]], + ) + def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags( self, + flask_login_is_configured: bool, + user_has_session: bool, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, ): def app_init_hook( application_configs: list[type[AbstractConfig]], application_modules: list[Module | type[Module]], ): - # application_modules.remove(SAML2MiddlewareModule) - # application_modules.remove(UserLoaderModule) + if not flask_login_is_configured: + application_modules.clear() application_modules.append(CachingFeatureFlagRouterModule) application_modules.append(FeatureFlagMiddlewareModule()) def client_init_hook(app: CreateAppResult[FlaskApp]): - pass + caching_feature_flag_router = app.app_injector.flask_injector.injector.get( + FeatureFlagRouter[FeatureFlag] + ) + _ = caching_feature_flag_router.set_feature_is_enabled("foo_feature", True) openapi_mock_controller.begin() app = next( @@ -148,45 +160,27 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): ) ) - # del app.client.app.app.login_manager - - response = app.client.get("/platform/feature_flag") - - # 401 for now because no real auth is configured. - # if SSO was broken, 500 would return - assert response.status_code == 200 - - def test__FeatureFlagMiddleware__feature_flag_api_GET_gets_feature_flags_when_user_has_session( - self, - openapi_config: Config, - openapi_client_configurable: OpenAPIClientInjectorConfigurable, - openapi_mock_controller: OpenAPIMockController, - mocker: MockerFixture, - ): - get_feature_flag_mock = mocker.patch( - "Ligare.web.middleware.feature_flags.CachingFeatureFlagRouter.get_feature_flags", - return_value=[], - ) - - openapi_mock_controller.begin() - app = next( - openapi_client_configurable( - openapi_config, app_init_hook=self._user_session_app_init_hook - ) - ) - - with self.get_authenticated_request_context( - app, - User, # pyright: ignore[reportArgumentType] - mocker, - ): + if user_has_session: + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.get("/platform/feature_flag") + else: response = app.client.get("/platform/feature_flag") - assert response.status_code == 404 - get_feature_flag_mock.assert_called_once() + assert response.status_code == 200 + response_json = response.json() + assert (data := response_json.get("data", None)) is not None + assert len(data) == 1 + assert (name := data[0].get("name", None)) is not None + assert (enabled := data[0].get("enabled", None)) is not None + assert name == "foo_feature" + assert enabled == True @pytest.mark.parametrize("has_role", [True, False]) - def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_specified_role( + def test__FeatureFlagMiddleware__feature_flag_api_GET_requires_specified_role_when_flask_login_is_configured( self, has_role: bool, openapi_config: Config, @@ -344,7 +338,62 @@ def client_init_hook(app: CreateAppResult[FlaskApp]): assert data[i].get("enabled", None) is True assert data[i].get("name", None) == flag - def test__FeatureFlagMiddleware__feature_flag_api_PATCH_modifies_something( + @pytest.mark.parametrize( + "flask_login_is_configured,user_has_session,error_code", + [[True, False, 401], [False, True, 405], [False, False, 405]], + ) + def test__FeatureFlagMiddleware__feature_flag_api_PATCH_requires_user_session_and_flask_login( + self, + flask_login_is_configured: bool, + user_has_session: bool, + error_code: int, + openapi_config: Config, + openapi_client_configurable: OpenAPIClientInjectorConfigurable, + openapi_mock_controller: OpenAPIMockController, + mocker: MockerFixture, + ): + set_feature_flag_mock = mocker.patch( + "Ligare.web.middleware.feature_flags.CachingFeatureFlagRouter.set_feature_is_enabled", + return_value=[], + ) + + def app_init_hook( + application_configs: list[type[AbstractConfig]], + application_modules: list[Module | type[Module]], + ): + if not flask_login_is_configured: + application_modules.clear() + application_modules.append(CachingFeatureFlagRouterModule) + application_modules.append(FeatureFlagMiddlewareModule()) + + openapi_mock_controller.begin() + app = next( + openapi_client_configurable( + openapi_config, + app_init_hook=app_init_hook, + ) + ) + + if user_has_session: + with self.get_authenticated_request_context( + app, + User, # pyright: ignore[reportArgumentType] + mocker, + ): + response = app.client.patch( + "/platform/feature_flag", + json=[{"name": "foo_feature", "enabled": False}], + ) + else: + response = app.client.patch( + "/platform/feature_flag", + json=[{"name": "foo_feature", "enabled": False}], + ) + + assert response.status_code == error_code + set_feature_flag_mock.assert_not_called() + + def test__FeatureFlagMiddleware__feature_flag_api_PATCH_modifies_feature_flag_when_user_has_session_and_flask_login_is_configured( self, openapi_config: Config, openapi_client_configurable: OpenAPIClientInjectorConfigurable, From d44b79fd90693147b9b8e2fbdf3763c7bd537658 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Tue, 1 Oct 2024 15:04:04 -0700 Subject: [PATCH 36/41] Fix type errors. --- .../test/unit/migrations/alembic/test_ligare_alembic.py | 2 +- .../test/unit/feature_flags/test_db_feature_flag_router.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/database/test/unit/migrations/alembic/test_ligare_alembic.py b/src/database/test/unit/migrations/alembic/test_ligare_alembic.py index 4ecce3ad..b9e0bc6e 100644 --- a/src/database/test/unit/migrations/alembic/test_ligare_alembic.py +++ b/src/database/test/unit/migrations/alembic/test_ligare_alembic.py @@ -76,7 +76,7 @@ def test__LigareAlembic__passes_through_to_alembic_with_default_config_when_not_ ) ligare_alembic = LigareAlembic(None, MagicMock()) - ligare_alembic._write_ligare_alembic_config = MagicMock() + ligare_alembic._write_ligare_alembic_config = MagicMock() # pyright: ignore[reportPrivateUsage] ligare_alembic.run() assert alembic_main.called diff --git a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py index cafb75f6..392a5233 100644 --- a/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py +++ b/src/platform/test/unit/feature_flags/test_db_feature_flag_router.py @@ -503,7 +503,7 @@ def test__get_feature_flags__caches_all_existing_flags_when_queried( for flag_name, enabled in added_flags.items(): _create_feature_flag(feature_flag_session, flag_name) - db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) + _ = db_feature_flag_router.set_feature_is_enabled(flag_name, enabled) cache_mock = mocker.patch( "Ligare.platform.feature_flag.caching_feature_flag_router.CachingFeatureFlagRouter.set_feature_is_enabled", From aa15c3da4fc3e98a8eabd2fa87a641a62f773d12 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Wed, 2 Oct 2024 14:53:55 -0700 Subject: [PATCH 37/41] Improve docs for `login_required` --- src/web/Ligare/web/middleware/sso.py | 72 ++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/src/web/Ligare/web/middleware/sso.py b/src/web/Ligare/web/middleware/sso.py index 58a158f4..e46207a9 100644 --- a/src/web/Ligare/web/middleware/sso.py +++ b/src/web/Ligare/web/middleware/sso.py @@ -14,6 +14,7 @@ TypedDict, TypeVar, cast, + overload, ) from urllib.parse import urlparse @@ -76,52 +77,83 @@ def __call__(self, user: AuthCheckUser, *args: Any, **kwargs: Any) -> bool: ... P = ParamSpec("P") -R = TypeVar("R", bound=Response) +R = TypeVar("R") -from typing import TypeVar, overload +@overload +def login_required() -> Callable[[Callable[P, R]], Callable[P, R]]: + """ + Require a user session for the decorated API. No further requirements are applied. + In effect, this uses `flask_login.login_required` and is used in its usual way. -TWrappedFunc = TypeVar("TWrappedFunc", bound=Callable[..., Callable[..., Any]]) + This is meant to be used as a decorator with `@login_required`. It is the the equivalent + of using the decorator `@flask_login.login_required`. + + :return Callable[[Callable[P, R]], Callable[P, R]]: Returns the `flask_login.login_required` decorated function. + """ @overload -def login_required() -> Callable[[TWrappedFunc], TWrappedFunc]: ... +def login_required(function: Callable[P, R], /) -> Callable[P, R]: + """ + Require a user session for the decorated API. No further requirements are applied. + In effect, this passes along `flask_login.login_required` without modification. + This can be used as a decorator with `@login_required()`, not `@login_required`, though + its use case is to wrap a function without using the decorator form, e.g., `wrapped_func = login_required(my_func)`. + This is the equivalent of `wrapped_func = flask_login.login_required(my_func)`. -@overload -def login_required( - roles: Callable[P, R], -) -> Callable[P, R]: ... + :return Callable[P, R]: Returns the `flask_login.login_required` wrapped function. + """ @overload def login_required( - roles: Callable[..., Any], -) -> Callable[..., Any]: ... + roles: Sequence[Role | str], / +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """ + Require a user session, and require that the user has at least one of the specified roles. + + :param Sequence[Role | str] roles: The list of roles the user can have that will allow them to access the decorated API. + :return Callable[[Callable[P, R]], Callable[P, R]]: Returns the decorated function. + """ @overload def login_required( - roles: Sequence[Role | str], -) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + roles: Sequence[Role | str], auth_check_override: AuthCheckOverrideCallable, / +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """ + Require a user session, and require that the user has at least one of the specified roles. + `auth_check_override` is called to override authorization. If it returns True, the user is considered to have access to the API. + If it returns False, the roles are checked instead, and the user will have access to the API if they have one of the specified roles. -@overload -def login_required( - roles: Sequence[Role | str], - auth_check_override: AuthCheckOverrideCallable, -) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + :param Sequence[Role | str] roles: The list of roles the user can have that will allow them to access the decorated API. + :param AuthCheckOverrideCallable auth_check_override: The method that is called to override authorization. It receives the following parameters: + + * `user` is the current session user + + * `*args` will be any arguments passed without argument keywords. When using `login_required` as a + decorator, this will be an empty tuple. + + * `**kwargs` will be any parameters specified with keywords. When using `login_required` as a decorator, + this will be the parameters passed into the decorated method. + In the case of a Flask API endpoint, for example, this will be all of the endpoint method parameters. + :return Callable[[Callable[P, R]], Callable[P, R]]: _description_ + """ def login_required( roles: Sequence[Role | str] | Callable[P, R] | Callable[..., Any] | None = None, auth_check_override: AuthCheckOverrideCallable | None = None, -) -> Callable[[TWrappedFunc], TWrappedFunc] | Callable[P, R] | Callable[..., Any]: + /, +) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R] | Callable[..., Any]: """ Require a valid Flask session before calling the decorated function. This method uses the list of `roles` to determine whether the current session user - has any of the roles listed. Alternatively, the use of `auth_check_override` can is used to + has any of the roles listed. Alternatively, the use of `auth_check_override` is used to bypass the role check. If the `auth_check_override` method returns True, the user is considered to have access to the decorated API endpoint. If the `auth_check_override` method returns False, `login_required` falls back to checking `roles`. @@ -143,8 +175,10 @@ def login_required( If `auth_check_override` is a callable, it will be called with the following parameters: * `user` is the current session user + * `*args` will be any arguments passed without argument keywords. When using `login_required` as a decorator, this will be an empty tuple. + * `**kwargs` will be any parameters specified with keywords. When using `login_required` as a decorator, this will be the parameters passed into the decorated method. In the case of a Flask API endpoint, for example, this will be all of the endpoint method parameters. From 186b929e10fb0a70490927be04240f324f7fe297 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 3 Oct 2024 13:30:26 -0700 Subject: [PATCH 38/41] Treat use of deprecated types as an error. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index caf5c109..d5104d57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,7 @@ reportUnnecessaryTypeIgnoreComment = "information" reportUnusedCallResult = "information" reportMissingTypeStubs = "information" reportWildcardImportFromLibrary = "warning" +reportDeprecated = "error" [tool.pytest.ini_options] pythonpath = [ From 4be9a498cef7bc4791c582c08ef10d24de291210 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 3 Oct 2024 13:31:26 -0700 Subject: [PATCH 39/41] Deprecated old methods for creating an application. Also add some more docs. --- src/web/Ligare/web/application.py | 57 ++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/src/web/Ligare/web/application.py b/src/web/Ligare/web/application.py index 18992e62..db18405c 100644 --- a/src/web/Ligare/web/application.py +++ b/src/web/Ligare/web/application.py @@ -1,9 +1,3 @@ -""" -Compound Assay Platform Flask application. - -Flask entry point. -""" - import logging from collections import defaultdict from dataclasses import dataclass @@ -38,7 +32,7 @@ from Ligare.programming.dependency_injection import ConfigModule from Ligare.programming.patterns.dependency_injection import ConfigurableModule from Ligare.web.exception import BuilderBuildError, InvalidBuilderStateError -from typing_extensions import Self +from typing_extensions import Self, deprecated from .config import Config from .middleware import ( @@ -60,12 +54,29 @@ @dataclass class AppInjector(Generic[T_app]): + """ + Contains an instantiated `T_app` application in `app`, + and its associated `FlaskInjector` IoC container. + + :param T_app Generic: An instance of Flask or FlaskApp. + :param flask_inject FlaskInject: The applications IoC container. + """ + app: T_app flask_injector: FlaskInjector @dataclass class CreateAppResult(Generic[T_app]): + """ + Contains an instantiated Flask application and its + associated application "container." This is either + the same Flask instance, or an OpenAPI application. + + :param flask_app Generic: The Flask application. + :param app_injector AppInjector[T_app]: The application's wrapper and IoC container. + """ + flask_app: Flask app_injector: AppInjector[T_app] @@ -77,6 +88,7 @@ class CreateAppResult(Generic[T_app]): # In Python 3.12 we can use generics in functions, # but we target >= Python 3.10. This is one way # around that limitation. +@deprecated("`App` is deprecated. Use `ApplicationBuilder`.") class App(Generic[T_app]): """ Create a new generic type for the application instance. @@ -85,6 +97,7 @@ class App(Generic[T_app]): T_app: Either `Flask` or `FlaskApp` """ + @deprecated("`App.create` is deprecated. Use `ApplicationBuilder`.") @staticmethod def create( config_filename: str = "config.toml", @@ -102,16 +115,32 @@ def create( """ return cast( CreateAppResult[T_app], - create_app(config_filename, application_configs, application_modules), + _create_app(config_filename, application_configs, application_modules), ) class UseConfigurationCallback(Protocol[TConfig]): + """ + The callback for configuring an application's configuration. + + :param TConfig Protocol: The AbstractConfig type to be configured. + """ + def __call__( self, config_builder: ConfigBuilder[TConfig], config_overrides: dict[str, Any], - ) -> "None | ConfigBuilder[TConfig]": ... + ) -> "None | ConfigBuilder[TConfig]": + """ + Set up parameters for the application's configuration. + + :param ConfigBuilder[TConfig] config_builder: The ConfigBuilder instance. + :param dict[str, Any] config_overrides: A dictionary of key/values that are applied over all keys that might exist in an instantiated config. + :raises InvalidBuilderStateError: Upon a call to `build()`, the builder is misconfigured. + :raises BuilderBuildError: Upon a call to `build()`, a failure occurred during the instantiation of the configuration. + :raises Exception: Upon a call to `build()`, an unknown error occurred. + :return None | ConfigBuilder[TConfig]: The callback may return `None` or the received `ConfigBuilder` instance so as to support the use of lambdas. This return value is not used. + """ @final @@ -359,6 +388,7 @@ def build(self) -> CreateAppResult[T_app]: ) +@deprecated("`create_app` is deprecated. Use `ApplicationBuilder`.") def create_app( config_filename: str = "config.toml", # FIXME should be a list of PydanticDataclass @@ -368,6 +398,15 @@ def create_app( """ Do not use this method directly. Instead, use `App[T_app].create()` or `ApplicationBuilder[TApp, TConfig]()` """ + return _create_app(config_filename, application_configs, application_modules) + + +def _create_app( + config_filename: str = "config.toml", + # FIXME should be a list of PydanticDataclass + application_configs: list[type[AbstractConfig]] | None = None, + application_modules: list[Module | type[Module]] | None = None, +) -> CreateAppResult[TApp]: # set up the default configuration as soon as possible # also required to call before json_logging.config_root_logger() logging.basicConfig(force=True) From 371c9534f41ff7042426009588b1981dd6f34ff2 Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Thu, 3 Oct 2024 16:58:51 -0700 Subject: [PATCH 40/41] Update tests to comply with deprecation. --- src/web/Ligare/web/testing/create_app.py | 30 ++++++++++++++++--- .../unit/application/test_create_flask_app.py | 15 +++++----- .../application/test_create_openapi_app.py | 18 ++++++----- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/web/Ligare/web/testing/create_app.py b/src/web/Ligare/web/testing/create_app.py index 77f3b7c3..234ff90e 100644 --- a/src/web/Ligare/web/testing/create_app.py +++ b/src/web/Ligare/web/testing/create_app.py @@ -38,7 +38,7 @@ from Ligare.programming.config import AbstractConfig, ConfigBuilder from Ligare.programming.str import get_random_str from Ligare.web.application import ( - App, + ApplicationBuilder, CreateAppResult, FlaskAppResult, OpenAPIAppResult, @@ -405,7 +405,20 @@ def __get_basic_flask_app( app_init_hook(application_configs, application_modules) application_modules.append(SAML2MiddlewareModule) - app = App[Flask].create("config.toml", application_configs, application_modules) + + logging.basicConfig(force=True) + + application_builder = ( + ApplicationBuilder[Flask]() + .with_modules(application_modules) + .use_configuration( + lambda config_builder: config_builder.enable_ssm(True) + .with_config_filename("config.toml") + .with_root_config_type(Config) + .with_config_types(application_configs) + ) + ) + app = application_builder.build() yield app @pytest.fixture() @@ -537,9 +550,17 @@ def _get_real_openapi_app( if app_init_hook is not None: app_init_hook(_application_configs, _application_modules) - app = App[FlaskApp].create( - "config.toml", _application_configs, _application_modules + application_builder = ( + ApplicationBuilder[FlaskApp]() + .with_modules(_application_modules) + .use_configuration( + lambda config_builder: config_builder.enable_ssm(True) + .with_config_filename("config.toml") + .with_root_config_type(Config) + .with_config_types(_application_configs) + ) ) + app = application_builder.build() yield app @pytest.fixture() @@ -642,6 +663,7 @@ def openapi_client( def openapi_client_configurable( self, mocker: MockerFixture ) -> OpenAPIClientInjectorConfigurable: + # FIXME some day json_logging needs to be fixed _ = mocker.patch("Ligare.web.application.json_logging") return self._client_configurable( mocker, self._get_real_openapi_app, self._openapi_client diff --git a/src/web/test/unit/application/test_create_flask_app.py b/src/web/test/unit/application/test_create_flask_app.py index 580d58c8..1a1c6983 100644 --- a/src/web/test/unit/application/test_create_flask_app.py +++ b/src/web/test/unit/application/test_create_flask_app.py @@ -7,7 +7,8 @@ from flask import Blueprint, Flask from Ligare.programming.config import AbstractConfig from Ligare.programming.str import get_random_str -from Ligare.web.application import App, configure_blueprint_routes +from Ligare.web.application import App # pyright: ignore[reportDeprecated] +from Ligare.web.application import configure_blueprint_routes from Ligare.web.config import Config, FlaskConfig from Ligare.web.testing.create_app import ( CreateFlaskApp, @@ -259,7 +260,7 @@ def test__CreateFlaskApp__create_app__loads_config_from_toml( ) toml_filename = f"{TestCreateFlaskApp.test__CreateFlaskApp__create_app__loads_config_from_toml.__name__}-config.toml" - _ = App[Flask].create(config_filename=toml_filename) + _ = App[Flask].create(config_filename=toml_filename) # pyright: ignore[reportDeprecated] assert load_config_mock.called assert load_config_mock.call_args and load_config_mock.call_args[0] assert load_config_mock.call_args[0][1] == toml_filename @@ -288,7 +289,7 @@ def post_load(self) -> None: foo: str = get_random_str(k=26) - app = App[Flask].create( + app = App[Flask].create( # pyright: ignore[reportDeprecated] config_filename=toml_filename, application_configs=[CustomConfig] ) @@ -322,7 +323,7 @@ def test__CreateFlaskApp__create_app__updates_flask_config_from_envvars( _ = mocker.patch( "Ligare.web.application.load_config", return_value=basic_config ) - _ = App[Flask].create() + _ = App[Flask].create() # pyright: ignore[reportDeprecated] assert object.__getattribute__(basic_config.flask, config_var_name) == var_value @@ -361,9 +362,9 @@ def test__CreateFlaskApp__create_app__requires_application_name( if should_fail: with pytest.raises(Exception): - _ = App[Flask].create() + _ = App[Flask].create() # pyright: ignore[reportDeprecated] else: - _ = App[Flask].create() + _ = App[Flask].create() # pyright: ignore[reportDeprecated] def test__CreateFlaskApp__create_app__configures_appropriate_app_type_based_on_config( self, mocker: MockerFixture @@ -384,6 +385,6 @@ def test__CreateFlaskApp__create_app__configures_appropriate_app_type_based_on_c ) config = Config(flask=FlaskConfig(app_name=app_name)) _ = mocker.patch("Ligare.web.application.load_config", return_value=config) - _ = App[Flask].create(config_filename=toml_filename) + _ = App[Flask].create(config_filename=toml_filename) # pyright: ignore[reportDeprecated] configure_method_mock.assert_called_once_with(config) diff --git a/src/web/test/unit/application/test_create_openapi_app.py b/src/web/test/unit/application/test_create_openapi_app.py index 1f4d85a8..2c2c109c 100644 --- a/src/web/test/unit/application/test_create_openapi_app.py +++ b/src/web/test/unit/application/test_create_openapi_app.py @@ -5,7 +5,8 @@ from flask import Flask from Ligare.programming.config import AbstractConfig from Ligare.programming.str import get_random_str -from Ligare.web.application import App, configure_openapi +from Ligare.web.application import App # pyright:ignore[reportDeprecated] +from Ligare.web.application import ApplicationBuilder, configure_openapi from Ligare.web.config import Config, FlaskConfig, FlaskOpenApiConfig from Ligare.web.testing.create_app import CreateOpenAPIApp from mock import MagicMock @@ -54,7 +55,10 @@ def test__CreateOpenAPIApp__create_app__loads_config_from_toml( ) toml_filename = f"{TestCreateOpenAPIApp.test__CreateOpenAPIApp__create_app__loads_config_from_toml.__name__}-config.toml" - _ = App[Flask].create(config_filename=toml_filename) + application_builder = ApplicationBuilder[Flask]().use_configuration( + lambda config_builder: config_builder.with_config_filename(toml_filename) + ) + _ = application_builder.build() assert load_config_mock.called assert load_config_mock.call_args and load_config_mock.call_args[0] assert load_config_mock.call_args[0][1] == toml_filename @@ -83,7 +87,7 @@ def post_load(self) -> None: foo: str = get_random_str(k=26) - app = App[Flask].create( + app = App[Flask].create( # pyright:ignore[reportDeprecated] config_filename=toml_filename, application_configs=[CustomConfig] ) @@ -117,7 +121,7 @@ def test__CreateOpenAPIApp__create_app__updates_flask_config_from_envvars( _ = mocker.patch( "Ligare.web.application.load_config", return_value=basic_config ) - _ = App[Flask].create() + _ = App[Flask].create() # pyright:ignore[reportDeprecated] assert object.__getattribute__(basic_config.flask, config_var_name) == var_value @@ -156,9 +160,9 @@ def test__CreateOpenAPIApp__create_app__requires_application_name( if should_fail: with pytest.raises(Exception): - _ = App[Flask].create() + _ = App[Flask].create() # pyright:ignore[reportDeprecated] else: - _ = App[Flask].create() + _ = App[Flask].create() # pyright:ignore[reportDeprecated] def test__CreateOpenAPIApp__create_app__configures_appropriate_app_type_based_on_config( self, mocker: MockerFixture @@ -179,6 +183,6 @@ def test__CreateOpenAPIApp__create_app__configures_appropriate_app_type_based_on flask=FlaskConfig(app_name=app_name, openapi=FlaskOpenApiConfig()) ) _ = mocker.patch("Ligare.web.application.load_config", return_value=config) - _ = App[FlaskApp].create(config_filename=toml_filename) + _ = App[FlaskApp].create(config_filename=toml_filename) # pyright:ignore[reportDeprecated] configure_method_mock.assert_called_once_with(config) From 62e47bde34bc23884e86554bbb50b4b004df948a Mon Sep 17 00:00:00 2001 From: Aaron Holmes Date: Fri, 4 Oct 2024 14:10:06 -0700 Subject: [PATCH 41/41] Update some docs for Feature Flag middleware. --- .../web/middleware/feature_flags/__init__.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/web/Ligare/web/middleware/feature_flags/__init__.py b/src/web/Ligare/web/middleware/feature_flags/__init__.py index 054e5dbd..d9487611 100644 --- a/src/web/Ligare/web/middleware/feature_flags/__init__.py +++ b/src/web/Ligare/web/middleware/feature_flags/__init__.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from functools import wraps from logging import Logger -from typing import Any, Callable, Generic, Sequence, TypedDict, cast +from typing import Any, Callable, Generic, ParamSpec, Sequence, TypedDict, TypeVar, cast from connexion import FlaskApp, request from flask import Blueprint, Flask, abort @@ -25,7 +25,6 @@ FeatureFlagRouter, TFeatureFlag, ) -from Ligare.platform.identity.user_loader import Role from Ligare.programming.config import AbstractConfig from Ligare.programming.patterns.dependency_injection import ConfigurableModule from Ligare.web.middleware.sso import login_required @@ -107,8 +106,12 @@ def _provide_caching_feature_flag_router( return injector.get(self._t_feature_flag) +P = ParamSpec("P") +R = TypeVar("R") + + @inject -def get_feature_flag_blueprint(app: Flask, config: FeatureFlagConfig, log: Logger): +def _get_feature_flag_blueprint(app: Flask, config: FeatureFlagConfig, log: Logger): feature_flag_blueprint = Blueprint( "feature_flag", __name__, url_prefix=f"{config.api_base_url}" ) @@ -123,14 +126,16 @@ def _login_required(require_flask_login: bool): require_flask_login is ignored if flask_login has been configured. If flask_login has _not_ been configured: - * If require_flask_login is True, a warning is logged and a method returning False is returned, rather than returning the endpoint function - * If require_flask_login is False, the endpoint function is returned + * If require_flask_login is True, a warning is logged and the request is aborted with a 405 error + * If require_flask_login is False, the endpoint function is executed :param bool require_flask_login: Determine whether flask_login must be configured for this endpoint to function :return _type_: _description_ """ - def __login_required(fn: Callable[..., Any]): + def __login_required( + fn: Callable[P, R], + ) -> Callable[P, R]: authorization_implementation: Callable[..., Any] if access_role is False: @@ -158,7 +163,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return __login_required - @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] + @feature_flag_blueprint.route("/feature_flag", methods=("GET",)) @_login_required(False) @inject def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] @@ -206,9 +211,7 @@ def feature_flag(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyrigh else: return response, 404 - @feature_flag_blueprint.route("/feature_flag", methods=("PATCH",)) # pyright: ignore[reportArgumentType,reportUntypedFunctionDecorator] - # @login_required([UserRole.Operator]) - # TODO assign a specific role ? + @feature_flag_blueprint.route("/feature_flag", methods=("PATCH",)) @_login_required(True) @inject async def feature_flag_patch(feature_flag_router: FeatureFlagRouter[FeatureFlag]): # pyright: ignore[reportUnusedFunction] @@ -251,10 +254,9 @@ async def feature_flag_patch(feature_flag_router: FeatureFlagRouter[FeatureFlag] class FeatureFlagMiddlewareModule(Module): - @override - def __init__(self, access_roles: list[Role] | bool = True) -> None: - self._access_roles = access_roles - super().__init__() + """ + Enable the use of Feature Flags and a Feature Flag management API. + """ @override def configure(self, binder: Binder) -> None: @@ -264,6 +266,12 @@ def register_middleware(self, app: FlaskApp): app.add_middleware(FeatureFlagMiddlewareModule.FeatureFlagMiddleware) class FeatureFlagMiddleware: + """ + ASGI middleware for Feature Flags. + + This middleware create a Flask blueprint the enables a Feature Flag management API. + """ + _app: ASGIApp def __init__(self, app: ASGIApp): @@ -291,7 +299,7 @@ async def wrapped_send(message: Any) -> None: log.debug("Registering FeatureFlag blueprint.") app.register_blueprint( - injector.call_with_injection(get_feature_flag_blueprint) + injector.call_with_injection(_get_feature_flag_blueprint) ) log.debug("FeatureFlag blueprint registered.")