diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8b923b..9793be3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,13 +14,29 @@ permissions: # added using https://github.com/step-security/secure-workflows contents: read jobs: - build: + release-please: + permissions: + contents: write # for google-github-actions/release-please-action to create release commit + pull-requests: write # for google-github-actions/release-please-action to create release PR runs-on: ubuntu-latest - strategy: - matrix: - container: [ "python:3.10" ] + + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + command: manifest + token: ${{secrets.GITHUB_TOKEN}} + default-branch: main + outputs: + release_created: ${{ steps.release.outputs.release_created }} + release_tag_name: ${{ steps.release.outputs.tag_name }} + + release: + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} container: - image: ${{ matrix.container }} + image: "python:3.11" steps: - uses: actions/checkout@v3 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..729b0ad --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{".":"0.0.4"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b4a4a61 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## [0.0.4](https://github.com/open-feature/python-sdk/compare/v0.0.3...v0.0.4) (2022-11-15) + + +### Features + +* Add needs to release job ([#52](https://github.com/open-feature/python-sdk/issues/52)) ([fb7655a](https://github.com/open-feature/python-sdk/commit/fb7655aa3aae0fb021e0aae57c0a7d182a8218cf)) + +## [0.0.3](https://github.com/open-feature/python-sdk/compare/v0.0.2...v0.0.3) (2022-11-15) + + +### Features + +* Run a single container for sdk release ([#50](https://github.com/open-feature/python-sdk/issues/50)) ([87c62cf](https://github.com/open-feature/python-sdk/commit/87c62cfae7ce2bd47ed79adb7bb9b58d3b0072fd)) + +## [0.0.2](https://github.com/open-feature/python-sdk/compare/v0.0.1...v0.0.2) (2022-11-15) + + +### Features + +* Add metadata to providers ([#26](https://github.com/open-feature/python-sdk/issues/26)) ([b39cced](https://github.com/open-feature/python-sdk/commit/b39cced329d16741aa8fa8768fd44ff51f916bfa)) +* Add release please to handle releases ([#45](https://github.com/open-feature/python-sdk/issues/45)) ([5bc0571](https://github.com/open-feature/python-sdk/commit/5bc057192d69659d17b9552cae854843a86d879c)) +* Fix release workflow ([#48](https://github.com/open-feature/python-sdk/issues/48)) ([2c44d55](https://github.com/open-feature/python-sdk/commit/2c44d55af349e9485a3f697f28c1391cc11c5ed0)) +* spec-0.2.0 ([#38](https://github.com/open-feature/python-sdk/issues/38)) ([311b8ee](https://github.com/open-feature/python-sdk/commit/311b8eef53cfd535f8f45e5cd680381cc79abbc1)) +* specification-0.5.0 ([#44](https://github.com/open-feature/python-sdk/issues/44)) ([04a4323](https://github.com/open-feature/python-sdk/commit/04a432331036cf771a613dd66dcc46c4e10d9284)) + + +### Bug Fixes + +* eval context fixes and new error types ([#43](https://github.com/open-feature/python-sdk/issues/43)) ([06d0494](https://github.com/open-feature/python-sdk/commit/06d0494331b62deb0c0ec96846ffed5ab8471e60)) +* Move flag evaluation details to a dataclass ([#27](https://github.com/open-feature/python-sdk/issues/27)) ([b44224b](https://github.com/open-feature/python-sdk/commit/b44224be0ddaf3745d031d6e7caea19f41322cf1)) +* requirements-dev.txt to reduce vulnerabilities ([#37](https://github.com/open-feature/python-sdk/issues/37)) ([1e82122](https://github.com/open-feature/python-sdk/commit/1e82122978e92173fab3c65c75ca3b5477ce3655)) +* Unit tests ([#28](https://github.com/open-feature/python-sdk/issues/28)) ([df0c033](https://github.com/open-feature/python-sdk/commit/df0c03308346c5b3be7223edc162582c578b4678)) + + +### Documentation + +* add badge showing supported Python version range ([c2d214a](https://github.com/open-feature/python-sdk/commit/c2d214a809bf2b9f12f50dc8a5a6aec32f9d1dca)) +* add badge showing supported Python version range ([f845d9e](https://github.com/open-feature/python-sdk/commit/f845d9e4184ca9328311906c825d42b2cc73a319)) diff --git a/open_feature/evaluation_context/evaluation_context.py b/open_feature/evaluation_context/evaluation_context.py index bba9354..64edf6d 100644 --- a/open_feature/evaluation_context/evaluation_context.py +++ b/open_feature/evaluation_context/evaluation_context.py @@ -1,5 +1,12 @@ +import typing + + class EvaluationContext: - def __init__(self, targeting_key: str = None, attributes: dict = None): + def __init__( + self, + targeting_key: typing.Optional[str] = None, + attributes: typing.Optional[dict] = None, + ): self.targeting_key = targeting_key self.attributes = attributes or {} @@ -7,7 +14,7 @@ class EvaluationContext: if not (self and ctx2): return self or ctx2 - self.attributes = {**self.attributes, **ctx2.attributes} - self.targeting_key = ctx2.targeting_key or self.targeting_key + attributes = {**self.attributes, **ctx2.attributes} + targeting_key = ctx2.targeting_key or self.targeting_key - return self + return EvaluationContext(targeting_key=targeting_key, attributes=attributes) diff --git a/open_feature/flag_evaluation/error_code.py b/open_feature/exception/error_code.py similarity index 70% rename from open_feature/flag_evaluation/error_code.py rename to open_feature/exception/error_code.py index 79506ff..96c5954 100644 --- a/open_feature/flag_evaluation/error_code.py +++ b/open_feature/exception/error_code.py @@ -6,4 +6,6 @@ class ErrorCode(Enum): FLAG_NOT_FOUND = "FLAG_NOT_FOUND" PARSE_ERROR = "PARSE_ERROR" TYPE_MISMATCH = "TYPE_MISMATCH" + TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING" + INVALID_CONTEXT = "INVALID_CONTEXT" GENERAL = "GENERAL" diff --git a/open_feature/exception/exceptions.py b/open_feature/exception/exceptions.py index b23eee0..bfab67b 100644 --- a/open_feature/exception/exceptions.py +++ b/open_feature/exception/exceptions.py @@ -1,4 +1,6 @@ -from open_feature.flag_evaluation.error_code import ErrorCode +import typing + +from open_feature.exception.error_code import ErrorCode class OpenFeatureError(Exception): @@ -7,13 +9,14 @@ class OpenFeatureError(Exception): the more specific exceptions extending this one should be used. """ - def __init__(self, error_message: str = None, error_code: ErrorCode = None): + def __init__( + self, error_code: ErrorCode, error_message: typing.Optional[str] = None + ): """ Constructor for the generic OpenFeatureError. - @param error_message: a string message representing why the error has been - raised + @param error_message: an optional string message representing why the + error has been raised @param error_code: the ErrorCode string enum value for the type of error - @return: the generic OpenFeatureError exception """ self.error_message = error_message self.error_code = error_code @@ -25,15 +28,14 @@ class FlagNotFoundError(OpenFeatureError): key provided by the user. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the FlagNotFoundError. The error code for this type of exception is ErrorCode.FLAG_NOT_FOUND. - @param error_message: a string message representing why the error has been - raised - @return: the generic FlagNotFoundError exception + @param error_message: an optional string message representing + why the error has been raised """ - super().__init__(error_message, ErrorCode.FLAG_NOT_FOUND) + super().__init__(ErrorCode.FLAG_NOT_FOUND, error_message) class GeneralError(OpenFeatureError): @@ -42,15 +44,14 @@ class GeneralError(OpenFeatureError): feature python sdk. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the GeneralError. The error code for this type of exception is ErrorCode.GENERAL. - @param error_message: a string message representing why the error has been - raised - @return: the generic GeneralError exception + @param error_message: an optional string message representing why the error + has been raised """ - super().__init__(error_message, ErrorCode.GENERAL) + super().__init__(ErrorCode.GENERAL, error_message) class ParseError(OpenFeatureError): @@ -59,15 +60,14 @@ class ParseError(OpenFeatureError): be parsed into a FlagEvaluationDetails object. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the ParseError. The error code for this type of exception is ErrorCode.PARSE_ERROR. - @param error_message: a string message representing why the error has been - raised - @return: the generic ParseError exception + @param error_message: an optional string message representing why the + error has been raised """ - super().__init__(error_message, ErrorCode.PARSE_ERROR) + super().__init__(ErrorCode.PARSE_ERROR, error_message) class TypeMismatchError(OpenFeatureError): @@ -76,12 +76,43 @@ class TypeMismatchError(OpenFeatureError): not match the type requested by the user. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the TypeMismatchError. The error code for this type of exception is ErrorCode.TYPE_MISMATCH. + @param error_message: an optional string message representing why the + error has been raised + """ + super().__init__(ErrorCode.TYPE_MISMATCH, error_message) + + +class TargetingKeyMissingError(OpenFeatureError): + """ + This exception should be raised when the provider requires a targeting key + but one was not provided in the evaluation context. + """ + + def __init__(self, error_message: typing.Optional[str] = None): + """ + Constructor for the TargetingKeyMissingError. The error code for this type of + exception is ErrorCode.TARGETING_KEY_MISSING. @param error_message: a string message representing why the error has been raised - @return: the generic TypeMismatchError exception """ - super().__init__(error_message, ErrorCode.TYPE_MISMATCH) + super().__init__(ErrorCode.TARGETING_KEY_MISSING, error_message) + + +class InvalidContextError(OpenFeatureError): + """ + This exception should be raised when the evaluation context does not meet provider + requirements. + """ + + def __init__(self, error_message: typing.Optional[str]): + """ + Constructor for the InvalidContextError. The error code for this type of + exception is ErrorCode.INVALID_CONTEXT. + @param error_message: a string message representing why the error has been + raised + """ + super().__init__(ErrorCode.INVALID_CONTEXT, error_message) diff --git a/open_feature/flag_evaluation/flag_evaluation_details.py b/open_feature/flag_evaluation/flag_evaluation_details.py index 0caf4c0..2bf9fce 100644 --- a/open_feature/flag_evaluation/flag_evaluation_details.py +++ b/open_feature/flag_evaluation/flag_evaluation_details.py @@ -1,15 +1,17 @@ import typing from dataclasses import dataclass -from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.exception.error_code import ErrorCode from open_feature.flag_evaluation.reason import Reason +T = typing.TypeVar("T", covariant=True) + @dataclass -class FlagEvaluationDetails: +class FlagEvaluationDetails(typing.Generic[T]): flag_key: str - value: typing.Any - variant: str = None - reason: Reason = None - error_code: ErrorCode = None - error_message: str = None + value: T + variant: typing.Optional[str] = None + reason: typing.Optional[Reason] = None + error_code: typing.Optional[ErrorCode] = None + error_message: typing.Optional[str] = None diff --git a/open_feature/flag_evaluation/flag_evaluation_options.py b/open_feature/flag_evaluation/flag_evaluation_options.py new file mode 100644 index 0000000..1a35542 --- /dev/null +++ b/open_feature/flag_evaluation/flag_evaluation_options.py @@ -0,0 +1,10 @@ +import typing +from dataclasses import dataclass, field + +from open_feature.hooks.hook import Hook + + +@dataclass +class FlagEvaluationOptions: + hooks: typing.List[Hook] = field(default_factory=list) + hook_hints: dict = field(default_factory=dict) diff --git a/open_feature/flag_evaluation/flag_type.py b/open_feature/flag_evaluation/flag_type.py index 1fd52d7..1deada9 100644 --- a/open_feature/flag_evaluation/flag_type.py +++ b/open_feature/flag_evaluation/flag_type.py @@ -2,7 +2,8 @@ from enum import Enum class FlagType(Enum): - BOOLEAN = 1 - STRING = 2 - NUMBER = 3 - OBJECT = 4 + BOOLEAN = bool + STRING = str + OBJECT = dict + FLOAT = float + INTEGER = int diff --git a/open_feature/hooks/hook_context.py b/open_feature/hooks/hook_context.py index 13b90c4..7969a05 100644 --- a/open_feature/hooks/hook_context.py +++ b/open_feature/hooks/hook_context.py @@ -11,5 +11,5 @@ class HookContext: flag_type: FlagType default_value: typing.Any evaluation_context: EvaluationContext - client_metadata: dict = None - provider_metadata: dict = None + client_metadata: typing.Optional[dict] = None + provider_metadata: typing.Optional[dict] = None diff --git a/open_feature/hooks/hook_support.py b/open_feature/hooks/hook_support.py index 1111504..039afe7 100644 --- a/open_feature/hooks/hook_support.py +++ b/open_feature/hooks/hook_support.py @@ -15,7 +15,7 @@ def error_hooks( hook_context: HookContext, exception: Exception, hooks: typing.List[Hook], - hints: dict, + hints: typing.Optional[typing.Mapping] = None, ): kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints} _execute_hooks( @@ -27,7 +27,7 @@ def after_all_hooks( flag_type: FlagType, hook_context: HookContext, hooks: typing.List[Hook], - hints: dict, + hints: typing.Optional[typing.Mapping] = None, ): kwargs = {"hook_context": hook_context, "hints": hints} _execute_hooks( @@ -40,7 +40,7 @@ def after_hooks( hook_context: HookContext, details: FlagEvaluationDetails, hooks: typing.List[Hook], - hints: dict, + hints: typing.Optional[typing.Mapping] = None, ): kwargs = {"hook_context": hook_context, "details": details, "hints": hints} _execute_hooks_unchecked( @@ -52,7 +52,7 @@ def before_hooks( flag_type: FlagType, hook_context: HookContext, hooks: typing.List[Hook], - hints: dict, + hints: typing.Optional[typing.Mapping] = None, ) -> EvaluationContext: kwargs = {"hook_context": hook_context, "hints": hints} executed_hooks = _execute_hooks_unchecked( diff --git a/open_feature/open_feature_api.py b/open_feature/open_feature_api.py index 60c3f3d..7a4c9f5 100644 --- a/open_feature/open_feature_api.py +++ b/open_feature/open_feature_api.py @@ -4,10 +4,12 @@ from open_feature.exception.exceptions import GeneralError from open_feature.open_feature_client import OpenFeatureClient from open_feature.provider.provider import AbstractProvider -_provider = None +_provider: typing.Optional[AbstractProvider] = None -def get_client(name: str = None, version: str = None) -> OpenFeatureClient: +def get_client( + name: typing.Optional[str] = None, version: typing.Optional[str] = None +) -> OpenFeatureClient: if _provider is None: raise GeneralError( error_message="Provider not set. Call set_provider before using get_client" diff --git a/open_feature/open_feature_client.py b/open_feature/open_feature_client.py index 02ac08a..a6dbdb3 100644 --- a/open_feature/open_feature_client.py +++ b/open_feature/open_feature_client.py @@ -1,11 +1,15 @@ import logging import typing -from numbers import Number from open_feature.evaluation_context.evaluation_context import EvaluationContext -from open_feature.exception.exceptions import GeneralError, OpenFeatureError -from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.exception.error_code import ErrorCode +from open_feature.exception.exceptions import ( + GeneralError, + OpenFeatureError, + TypeMismatchError, +) from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.flag_evaluation.flag_evaluation_options import FlagEvaluationOptions from open_feature.flag_evaluation.flag_type import FlagType from open_feature.flag_evaluation.reason import Reason from open_feature.hooks.hook import Hook @@ -22,14 +26,33 @@ from open_feature.provider.no_op_provider import NoOpProvider from open_feature.provider.provider import AbstractProvider +GetDetailCallable = typing.Union[ + typing.Callable[ + [str, bool, typing.Optional[EvaluationContext]], FlagEvaluationDetails[bool] + ], + typing.Callable[ + [str, int, typing.Optional[EvaluationContext]], FlagEvaluationDetails[int] + ], + typing.Callable[ + [str, float, typing.Optional[EvaluationContext]], FlagEvaluationDetails[float] + ], + typing.Callable[ + [str, str, typing.Optional[EvaluationContext]], FlagEvaluationDetails[str] + ], + typing.Callable[ + [str, dict, typing.Optional[EvaluationContext]], FlagEvaluationDetails[dict] + ], +] + + class OpenFeatureClient: def __init__( self, - name: str, - version: str, - context: EvaluationContext = None, - hooks: typing.List[Hook] = None, - provider: AbstractProvider = None, + name: typing.Optional[str], + version: typing.Optional[str], + provider: AbstractProvider, + context: typing.Optional[EvaluationContext] = None, + hooks: typing.Optional[typing.List[Hook]] = None, ): self.name = name self.version = version @@ -44,8 +67,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: bool, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> bool: return self.evaluate_flag_details( FlagType.BOOLEAN, @@ -59,8 +82,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: bool, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.BOOLEAN, @@ -74,8 +97,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: str, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> str: return self.evaluate_flag_details( FlagType.STRING, @@ -89,8 +112,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: str, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.STRING, @@ -100,30 +123,58 @@ class OpenFeatureClient: flag_evaluation_options, ) - def get_number_value( + def get_integer_value( self, flag_key: str, - default_value: Number, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, - ) -> Number: - return self.evaluate_flag_details( - FlagType.NUMBER, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> int: + return self.get_integer_details( flag_key, default_value, evaluation_context, flag_evaluation_options, ).value - def get_number_details( + def get_integer_details( self, flag_key: str, - default_value: Number, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( - FlagType.NUMBER, + FlagType.INTEGER, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + + def get_float_value( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> float: + return self.get_float_details( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value + + def get_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + return self.evaluate_flag_details( + FlagType.FLOAT, flag_key, default_value, evaluation_context, @@ -134,8 +185,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: dict, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> dict: return self.evaluate_flag_details( FlagType.OBJECT, @@ -149,8 +200,8 @@ class OpenFeatureClient: self, flag_key: str, default_value: dict, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.OBJECT, @@ -165,14 +216,14 @@ class OpenFeatureClient: flag_type: FlagType, flag_key: str, default_value: typing.Any, - evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, ) -> FlagEvaluationDetails: """ Evaluate the flag requested by the user from the clients provider. :param flag_type: the type of the flag being returned - :param key: the string key of the selected flag + :param flag_key: the string key of the selected flag :param default_value: backup value returned if no result found by the provider :param evaluation_context: Information for the purposes of flag evaluation :param flag_evaluation_options: Additional flag evaluation information @@ -195,18 +246,34 @@ class OpenFeatureClient: client_metadata=None, provider_metadata=None, ) - merged_hooks = self.hooks + # Todo add api level hooks + # https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#requirement-442 + # Hooks need to be handled in different orders at different stages + # in the flag evaluation + # before: API, Client, Invocation, Provider + merged_hooks = ( + self.hooks + + flag_evaluation_options.hooks + + self.provider.get_provider_hooks() + ) + # after, error, finally: Provider, Invocation, Client, API + reversed_merged_hooks = ( + self.provider.get_provider_hooks() + + flag_evaluation_options.hooks + + self.hooks + ) try: # https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md # Any resulting evaluation context from a before hook will overwrite # duplicate fields defined globally, on the client, or in the invocation. + # Requirement 3.2.2, 4.3.4: API.context->client.context->invocation.context invocation_context = before_hooks( flag_type, hook_context, merged_hooks, hook_hints ) - invocation_context.merge(ctx2=evaluation_context) + invocation_context = invocation_context.merge(ctx2=evaluation_context) - # merge of: API.context, client.context, invocation.context + # Requirement 3.2.2 merge: API.context->client.context->invocation.context merged_context = ( api_evaluation_context().merge(self.context).merge(invocation_context) ) @@ -218,12 +285,14 @@ class OpenFeatureClient: merged_context, ) - after_hooks(type, hook_context, flag_evaluation, merged_hooks, hook_hints) + after_hooks( + flag_type, hook_context, flag_evaluation, reversed_merged_hooks, hook_hints return flag_evaluation except OpenFeatureError as e: - error_hooks(flag_type, hook_context, e, merged_hooks, hook_hints) + error_hooks(flag_type, hook_context, e, reversed_merged_hooks, hook_hints) + return FlagEvaluationDetails( flag_key=flag_key, value=default_value, @@ -234,7 +303,8 @@ class OpenFeatureClient: # Catch any type of exception here since the user can provide any exception # in the error hooks except Exception as e: # noqa - error_hooks(flag_type, hook_context, e, merged_hooks, hook_hints) + error_hooks(flag_type, hook_context, e, reversed_merged_hooks, hook_hints) + error_message = getattr(e, "error_message", str(e)) return FlagEvaluationDetails( flag_key=flag_key, @@ -245,14 +315,14 @@ class OpenFeatureClient: ) finally: - after_all_hooks(flag_type, hook_context, merged_hooks, hook_hints) + after_all_hooks(flag_type, hook_context, reversed_merged_hooks, hook_hints) def _create_provider_evaluation( self, flag_type: FlagType, flag_key: str, default_value: typing.Any, - evaluation_context: EvaluationContext = None, + evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagEvaluationDetails: """ Encapsulated method to create a FlagEvaluationDetail from a specific provider. @@ -274,18 +344,25 @@ class OpenFeatureClient: logging.info("No provider configured, using no-op provider.") self.provider = NoOpProvider() - get_details_callable = { - FlagType.BOOLEAN: self.provider.get_boolean_details, - FlagType.NUMBER: self.provider.get_number_details, - FlagType.OBJECT: self.provider.get_object_details, - FlagType.STRING: self.provider.get_string_details, - }.get(flag_type) + get_details_callables: typing.Mapping[FlagType, GetDetailCallable] = { + FlagType.BOOLEAN: self.provider.resolve_boolean_details, + FlagType.INTEGER: self.provider.resolve_integer_details, + FlagType.FLOAT: self.provider.resolve_float_details, + FlagType.OBJECT: self.provider.resolve_object_details, + FlagType.STRING: self.provider.resolve_string_details, + } + get_details_callable = get_details_callables.get(flag_type) if not get_details_callable: raise GeneralError(error_message="Unknown flag type") - return get_details_callable(*args) + value = get_details_callable(*args) + if not isinstance(value.value, flag_type.value): + raise TypeMismatchError() + + return value + def __extract_evaluation_options( self, flag_evaluation_options: typing.Any ) -> typing.Tuple(typing.List[Hook], MappingProxyType): diff --git a/open_feature/provider/no_op_provider.py b/open_feature/provider/no_op_provider.py index 8e80786..14b7dac 100644 --- a/open_feature/provider/no_op_provider.py +++ b/open_feature/provider/no_op_provider.py @@ -1,8 +1,9 @@ -from numbers import Number +import typing from open_feature.evaluation_context.evaluation_context import EvaluationContext from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails from open_feature.flag_evaluation.reason import Reason +from open_feature.hooks.hook import Hook from open_feature.provider.metadata import Metadata from open_feature.provider.no_op_metadata import NoOpMetadata from open_feature.provider.provider import AbstractProvider @@ -14,12 +15,15 @@ class NoOpProvider(AbstractProvider): def get_metadata(self) -> Metadata: return NoOpMetadata() - def get_boolean_details( + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + + def resolve_boolean_details( self, flag_key: str, default_value: bool, - evaluation_context: EvaluationContext = None, - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[bool]: return FlagEvaluationDetails( flag_key=flag_key, value=default_value, @@ -27,12 +31,12 @@ class NoOpProvider(AbstractProvider): variant=PASSED_IN_DEFAULT, ) - def get_string_details( + def resolve_string_details( self, flag_key: str, default_value: str, - evaluation_context: EvaluationContext = None, - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[str]: return FlagEvaluationDetails( flag_key=flag_key, value=default_value, @@ -40,12 +44,12 @@ class NoOpProvider(AbstractProvider): variant=PASSED_IN_DEFAULT, ) - def get_number_details( + def resolve_integer_details( self, flag_key: str, - default_value: Number, - evaluation_context: EvaluationContext = None, - ): + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[int]: return FlagEvaluationDetails( flag_key=flag_key, value=default_value, @@ -53,12 +57,25 @@ class NoOpProvider(AbstractProvider): variant=PASSED_IN_DEFAULT, ) - def get_object_details( + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[float]: + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.DEFAULT, + variant=PASSED_IN_DEFAULT, + ) + + def resolve_object_details( self, flag_key: str, default_value: dict, - evaluation_context: EvaluationContext = None, - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[dict]: return FlagEvaluationDetails( flag_key=flag_key, value=default_value, diff --git a/open_feature/provider/provider.py b/open_feature/provider/provider.py index db99373..5b01a49 100644 --- a/open_feature/provider/provider.py +++ b/open_feature/provider/provider.py @@ -1,7 +1,9 @@ +import typing from abc import abstractmethod -from numbers import Number from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.hooks.hook import Hook from open_feature.provider.metadata import Metadata @@ -11,37 +13,50 @@ class AbstractProvider: pass @abstractmethod - def get_boolean_details( + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + + @abstractmethod + def resolve_boolean_details( self, flag_key: str, default_value: bool, - evaluation_context: EvaluationContext = EvaluationContext(), - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[bool]: pass @abstractmethod - def get_string_details( + def resolve_string_details( self, flag_key: str, default_value: str, - evaluation_context: EvaluationContext = EvaluationContext(), - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[str]: pass @abstractmethod - def get_number_details( + def resolve_integer_details( self, flag_key: str, - default_value: Number, - evaluation_context: EvaluationContext = EvaluationContext(), - ): + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[int]: pass @abstractmethod - def get_object_details( + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[float]: + pass + + @abstractmethod + def resolve_object_details( self, flag_key: str, default_value: dict, - evaluation_context: EvaluationContext = EvaluationContext(), - ): + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[dict]: pass diff --git a/pyproject.toml b/pyproject.toml index f4a4734..4da83c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "openfeature_sdk" -version = "0.0.1" +version = "0.0.4" description = "Standardizing Feature Flagging for Everyone" readme = "readme.md" authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] diff --git a/readme.md b/readme.md index 64405d0..3e457aa 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,5 @@ -# Open Feature SDK for Python +# OpenFeature SDK for Python + [![PyPI version](https://badge.fury.io/py/openfeature-sdk.svg)](https://badge.fury.io/py/openfeature-sdk) ![Python 3.8+](https://img.shields.io/badge/python->=3.8-blue.svg) [![Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) @@ -8,42 +9,56 @@ This is the Python implementation of [OpenFeature](https://openfeature.dev), a vendor-agnostic abstraction library for evaluating feature flags. -We support multiple data types for flags (numbers, strings, booleans, objects) as well as hooks, which can alter the lifecycle of a flag evaluation. +We support multiple data types for flags (numbers, strings, booleans, objects) as well as hooks, which can alter the lifecycle of a flag evaluation. This library is intended to be used in server-side contexts and has not been evaluated for use in mobile devices. - ## Usage + While Boolean provides the simplest introduction, we offer a variety of flag types. + ```python # Depending on the flag type, use one of the methods below flag_key = "PROVIDER_FLAG" boolean_result = open_feature_client.get_boolean_value(key=flag_key,default_value=False) -number_result = open_feature_client.get_number_value(key=flag_key,default_value=-1) +integer_result = open_feature_client.get_integer_value(key=flag_key,default_value=-1) +float_result = open_feature_client.get_float_value(key=flag_key,default_value=-1) string_result = open_feature_client.get_string_value(key=flag_key,default_value="") object_result = open_feature_client.get_object_value(key=flag_key,default_value={}) ``` + Each provider class may have further setup required i.e. secret keys, environment variables etc ## Requirements + - Python 3.8+ ## Installation + ### Add it to your build + + + Pip install + ```bash -pip install python-open-feature-sdk==0.0.1 +pip install openfeature-sdk==0.0.4 ``` requirements.txt + ```bash -python-open-feature-sdk==0.0.1 +openfeature-sdk==0.0.4 ``` + ```python pip install requirements.txt ``` + + ### Configure it + In order to use the sdk there is some minor configuration. Follow the script below: ```python @@ -54,6 +69,7 @@ open_feature_client = open_feature_api.get_client() ``` ## Contacting us + We hold regular meetings which you can see [here](https://github.com/open-feature/community/#meetings-and-events). We are also present on the `#openfeature` channel in the [CNCF slack](https://slack.cncf.io/). @@ -66,5 +82,4 @@ Thanks so much to our contributors. - -Made with [contrib.rocks](https://contrib.rocks). \ No newline at end of file +Made with [contrib.rocks](https://contrib.rocks). diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..585de8b --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,16 @@ +{ + "bootstrap-sha": "198336b098f167f858675235214cc907ede10182", + "packages": { + ".": { + "release-type": "python", + "monorepo-tags": false, + "include-component-in-tag": false, + "prerelease": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "extra-files": [ + "README.md" + ] + } + } +} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index f6d4c4a..5ec08ed 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -102,3 +102,4 @@ wrapt==1.14.1 # The following packages are considered to be unsafe in a requirements file: # pip # setuptools +setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/tests/provider/test_no_op_provider.py b/tests/provider/test_no_op_provider.py index 1e1e4b9..26e9a9d 100644 --- a/tests/provider/test_no_op_provider.py +++ b/tests/provider/test_no_op_provider.py @@ -13,37 +13,47 @@ def test_should_return_no_op_provider_metadata(): assert metadata.is_default_provider -def test_should_get_boolean_flag_from_no_op(): +def test_should_resolve_boolean_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_boolean_details(flag_key="Key", default_value=True) + flag = NoOpProvider().resolve_boolean_details(flag_key="Key", default_value=True) # Then assert flag is not None assert flag.value assert isinstance(flag.value, bool) -def test_should_get_number_flag_from_no_op(): +def test_should_resolve_integer_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_number_details(flag_key="Key", default_value=100) + flag = NoOpProvider().resolve_integer_details(flag_key="Key", default_value=100) # Then assert flag is not None assert flag.value == 100 assert isinstance(flag.value, Number) -def test_should_get_string_flag_from_no_op(): +def test_should_resolve_float_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_string_details(flag_key="Key", default_value="String") + flag = NoOpProvider().resolve_float_details(flag_key="Key", default_value=10.23) + # Then + assert flag is not None + assert flag.value == 10.23 + assert isinstance(flag.value, Number) + + +def test_should_resolve_string_flag_from_no_op(): + # Given + # When + flag = NoOpProvider().resolve_string_details(flag_key="Key", default_value="String") # Then assert flag is not None assert flag.value == "String" assert isinstance(flag.value, str) -def test_should_get_object_flag_from_no_op(): +def test_should_resolve_object_flag_from_no_op(): # Given return_value = { "String": "string", @@ -51,7 +61,9 @@ def test_should_get_object_flag_from_no_op(): "Boolean": True, } # When - flag = NoOpProvider().get_object_details(flag_key="Key", default_value=return_value) + flag = NoOpProvider().resolve_object_details( + flag_key="Key", default_value=return_value + ) # Then assert flag is not None assert flag.value == return_value diff --git a/tests/test_open_feature_api.py b/tests/test_open_feature_api.py index c3bee46..ae68714 100644 --- a/tests/test_open_feature_api.py +++ b/tests/test_open_feature_api.py @@ -1,7 +1,7 @@ import pytest +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import GeneralError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.open_feature_api import get_client, get_provider, set_provider from open_feature.provider.no_op_provider import NoOpProvider diff --git a/tests/test_open_feature_client.py b/tests/test_open_feature_client.py index 9e7fb5e..2c539e9 100644 --- a/tests/test_open_feature_client.py +++ b/tests/test_open_feature_client.py @@ -1,10 +1,9 @@ -from numbers import Number from unittest.mock import MagicMock import pytest +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import OpenFeatureError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.flag_evaluation.reason import Reason from open_feature.hooks.hook import Hook @@ -14,7 +13,8 @@ from open_feature.hooks.hook import Hook ( (bool, True, "get_boolean_value"), (str, "String", "get_string_value"), - (Number, 100, "get_number_value"), + (int, 100, "get_integer_value"), + (float, 10.23, "get_float_value"), ( dict, { @@ -45,7 +45,8 @@ def test_should_get_flag_value_based_on_method_type( ( (bool, True, "get_boolean_details"), (str, "String", "get_string_details"), - (Number, 100, "get_number_details"), + (int, 100, "get_integer_details"), + (float, 10.23, "get_float_details"), ( dict, { @@ -107,7 +108,7 @@ def test_should_handle_an_open_feature_exception_thrown_by_a_provider( # Given exception_hook = MagicMock(spec=Hook) exception_hook.after.side_effect = OpenFeatureError( - "error_message", ErrorCode.GENERAL + ErrorCode.GENERAL, "error_message" ) no_op_provider_client.add_hooks([exception_hook]) diff --git a/tests/test_open_feature_evaluation_context.py b/tests/test_open_feature_evaluation_context.py index fc353d9..41539a6 100644 --- a/tests/test_open_feature_evaluation_context.py +++ b/tests/test_open_feature_evaluation_context.py @@ -1,8 +1,8 @@ import pytest from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import GeneralError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.open_feature_evaluation_context import ( api_evaluation_context, set_api_evaluation_context,