diff --git a/.flake8 b/.flake8 index 1d36346c..eac3b3a7 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,3 @@ [flake8] -max-line-length = 88 \ No newline at end of file +max-line-length = 88 +exclude = .venv/*,venv/*,.git,__pycache__ \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..a848f951 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Test + +on: + push: + branches: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + container: [ "python:3.8", "python:3.9", "python:3.10" ] + container: + image: ${{ matrix.container }} + + steps: + - uses: actions/checkout@v2 + + - name: Cache virtualenvironment + uses: actions/cache@v2 + with: + path: ~/.venv + key: ${{ hashFiles('requirements.txt', 'requirements-dev.txt') }} + + - name: Upgrade pip + run: pip install --upgrade pip + + - name: Create and activate Virtualenv + run: | + pip install virtualenv + [ ! -d ".venv" ] && virtualenv .venv + . .venv/bin/activate + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run black formatter check + run: black --check . + + - name: Run flake8 formatter check + run: flake8 . + + - name: Run isort formatter check + run: isort . + + - name: Test with pytest + run: pytest \ No newline at end of file diff --git a/src/__init__.py b/open_feature/__init__.py similarity index 100% rename from src/__init__.py rename to open_feature/__init__.py diff --git a/src/evaluation_context/__init__.py b/open_feature/evaluation_context/__init__.py similarity index 100% rename from src/evaluation_context/__init__.py rename to open_feature/evaluation_context/__init__.py diff --git a/src/evaluation_context/evaluation_context.py b/open_feature/evaluation_context/evaluation_context.py similarity index 100% rename from src/evaluation_context/evaluation_context.py rename to open_feature/evaluation_context/evaluation_context.py diff --git a/src/exception/__init__.py b/open_feature/exception/__init__.py similarity index 100% rename from src/exception/__init__.py rename to open_feature/exception/__init__.py diff --git a/open_feature/exception/exceptions.py b/open_feature/exception/exceptions.py new file mode 100644 index 00000000..b23eee03 --- /dev/null +++ b/open_feature/exception/exceptions.py @@ -0,0 +1,87 @@ +from open_feature.flag_evaluation.error_code import ErrorCode + + +class OpenFeatureError(Exception): + """ + A generic open feature exception, this exception should not be raised. Instead + the more specific exceptions extending this one should be used. + """ + + def __init__(self, error_message: str = None, error_code: ErrorCode = None): + """ + Constructor for the generic OpenFeatureError. + @param error_message: a 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 + + +class FlagNotFoundError(OpenFeatureError): + """ + This exception should be raised when the provider cannot find a flag with the + key provided by the user. + """ + + def __init__(self, error_message: 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 + """ + super().__init__(error_message, ErrorCode.FLAG_NOT_FOUND) + + +class GeneralError(OpenFeatureError): + """ + This exception should be raised when the for an exception within the open + feature python sdk. + """ + + def __init__(self, error_message: 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 + """ + super().__init__(error_message, ErrorCode.GENERAL) + + +class ParseError(OpenFeatureError): + """ + This exception should be raised when the flag returned by the provider cannot + be parsed into a FlagEvaluationDetails object. + """ + + def __init__(self, error_message: 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 + """ + super().__init__(error_message, ErrorCode.PARSE_ERROR) + + +class TypeMismatchError(OpenFeatureError): + """ + This exception should be raised when the flag returned by the provider does + not match the type requested by the user. + """ + + def __init__(self, error_message: str = None): + """ + Constructor for the TypeMismatchError. The error code for this type of + exception is ErrorCode.TYPE_MISMATCH. + @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) diff --git a/src/flag_evaluation/__init__.py b/open_feature/flag_evaluation/__init__.py similarity index 100% rename from src/flag_evaluation/__init__.py rename to open_feature/flag_evaluation/__init__.py diff --git a/src/flag_evaluation/error_code.py b/open_feature/flag_evaluation/error_code.py similarity index 100% rename from src/flag_evaluation/error_code.py rename to open_feature/flag_evaluation/error_code.py diff --git a/src/flag_evaluation/flag_evaluation_details.py b/open_feature/flag_evaluation/flag_evaluation_details.py similarity index 74% rename from src/flag_evaluation/flag_evaluation_details.py rename to open_feature/flag_evaluation/flag_evaluation_details.py index af597470..4deb9b1b 100644 --- a/src/flag_evaluation/flag_evaluation_details.py +++ b/open_feature/flag_evaluation/flag_evaluation_details.py @@ -1,5 +1,5 @@ -from src.flag_evaluation.error_code import ErrorCode -from src.flag_evaluation.reason import Reason +from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.flag_evaluation.reason import Reason class FlagEvaluationDetails: diff --git a/src/flag_evaluation/flag_type.py b/open_feature/flag_evaluation/flag_type.py similarity index 100% rename from src/flag_evaluation/flag_type.py rename to open_feature/flag_evaluation/flag_type.py diff --git a/src/flag_evaluation/hooks.py b/open_feature/flag_evaluation/hooks.py similarity index 100% rename from src/flag_evaluation/hooks.py rename to open_feature/flag_evaluation/hooks.py diff --git a/src/flag_evaluation/reason.py b/open_feature/flag_evaluation/reason.py similarity index 100% rename from src/flag_evaluation/reason.py rename to open_feature/flag_evaluation/reason.py diff --git a/src/open_feature_api.py b/open_feature/open_feature_api.py similarity index 77% rename from src/open_feature_api.py rename to open_feature/open_feature_api.py index 6484cddf..60c3f3d2 100644 --- a/src/open_feature_api.py +++ b/open_feature/open_feature_api.py @@ -1,8 +1,8 @@ import typing -from src.exception.exceptions import GeneralError -from src.open_feature_client import OpenFeatureClient -from src.provider.provider import AbstractProvider +from open_feature.exception.exceptions import GeneralError +from open_feature.open_feature_client import OpenFeatureClient +from open_feature.provider.provider import AbstractProvider _provider = None diff --git a/src/open_feature_client.py b/open_feature/open_feature_client.py similarity index 92% rename from src/open_feature_client.py rename to open_feature/open_feature_client.py index 4c85cc20..868b51c3 100644 --- a/src/open_feature_client.py +++ b/open_feature/open_feature_client.py @@ -1,12 +1,12 @@ import typing from numbers import Number -from src.exception.exceptions import GeneralError, OpenFeatureError -from src.flag_evaluation.error_code import ErrorCode -from src.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails -from src.flag_evaluation.flag_type import FlagType -from src.flag_evaluation.reason import Reason -from src.provider.provider import AbstractProvider +from open_feature.exception.exceptions import GeneralError, OpenFeatureError +from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.flag_evaluation.flag_type import FlagType +from open_feature.flag_evaluation.reason import Reason +from open_feature.provider.provider import AbstractProvider class OpenFeatureClient: diff --git a/src/provider/__init__.py b/open_feature/provider/__init__.py similarity index 100% rename from src/provider/__init__.py rename to open_feature/provider/__init__.py diff --git a/src/provider/no_op_provider.py b/open_feature/provider/no_op_provider.py similarity index 89% rename from src/provider/no_op_provider.py rename to open_feature/provider/no_op_provider.py index 03e11c60..e9fe1a42 100644 --- a/src/provider/no_op_provider.py +++ b/open_feature/provider/no_op_provider.py @@ -1,9 +1,9 @@ import typing from numbers import Number -from src.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails -from src.flag_evaluation.reason import Reason -from src.provider.provider import AbstractProvider +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.flag_evaluation.reason import Reason +from open_feature.provider.provider import AbstractProvider PASSED_IN_DEFAULT = "Passed in default" diff --git a/src/provider/provider.py b/open_feature/provider/provider.py similarity index 100% rename from src/provider/provider.py rename to open_feature/provider/provider.py diff --git a/pyproject.toml b/pyproject.toml index aa7e7666..de27186a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,30 @@ +# pyproject.toml +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "python_open_feature_sdk" +version = "0.0.1" +description = "Standardizing Feature Flagging for Everyone" +readme = "README.md" +authors = [{ name = "Open Feature", email = "opensource@dynatrace.com" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [] +requires-python = ">=3.8" + +[project.optional-dependencies] +dev = ["black", "flake8", "isort", "pip-tools", "pytest", "pre-commit"] + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk" + [tool.isort] profile = "black" multi_line_output = 3 diff --git a/readme.md b/readme.md index 9ce35041..4113de00 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,65 @@ -# Open Feature Python SDK -This SDK is still in an experimental phase. +# Open Feature SDK for Python +[![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) + +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. + +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) +number_result = open_feature_client.get_number_value(key=flag_key) +string_result = open_feature_client.get_string_value(key=flag_key) +object_result = open_feature_client.get_object_value(key=flag_key) +``` +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 +``` + +requirements.txt +```bash +python-open-feature-sdk==0.0.1 +``` +```python +pip install requirements.txt +``` + +### Configure it +In order to use the sdk there is some minor configuration. Follow the script below: + +```python +from open_feature import open_feature_api + +open_feature_api.set_provider(NoOpProvider()) +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/). + +## Contributors + +Thanks so much to our contributors. + + + + -## How to use +Made with [contrib.rocks](https://contrib.rocks). \ No newline at end of file diff --git a/requirements-dev.in b/requirements-dev.in index 741af966..84463ebd 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -1,11 +1,8 @@ pylint -pep8 -autopep8 pytest pytest-mock black pip-tools -responses pre-commit flake8 pytest-mock \ No newline at end of file diff --git a/src/exception/exceptions.py b/src/exception/exceptions.py deleted file mode 100644 index 6a27de81..00000000 --- a/src/exception/exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -from src.flag_evaluation.error_code import ErrorCode - - -class OpenFeatureError(Exception): - def __init__(self, error_message: str = None, error_code: ErrorCode = None): - self.error_message = error_message - self.error_code = error_code - - -class FlagNotFoundError(OpenFeatureError): - def __init__(self, error_message: str = None): - super().__init__(error_message, ErrorCode.FLAG_NOT_FOUND) - - -class GeneralError(OpenFeatureError): - def __init__(self, error_message: str = None): - super().__init__(error_message, ErrorCode.GENERAL) - - -class ParseError(OpenFeatureError): - def __init__(self, error_message: str = None): - super().__init__(error_message, ErrorCode.PARSE_ERROR) - - -class TypeMismatchError(OpenFeatureError): - def __init__(self, error_message: str = None): - super().__init__(error_message, ErrorCode.TYPE_MISMATCH) diff --git a/tests/provider/conftest.py b/tests/provider/conftest.py new file mode 100644 index 00000000..b2161c68 --- /dev/null +++ b/tests/provider/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from open_feature import open_feature_api as api +from open_feature.provider.no_op_provider import NoOpProvider + + +@pytest.fixture() +def no_op_provider_client(): + api.set_provider(NoOpProvider()) + return api.get_client() diff --git a/tests/provider/test_no_op_provider.py b/tests/provider/test_no_op_provider.py index bef71814..a493f8ea 100644 --- a/tests/provider/test_no_op_provider.py +++ b/tests/provider/test_no_op_provider.py @@ -1,7 +1,7 @@ from numbers import Number -from src import open_feature_api as api -from src.provider.no_op_provider import NoOpProvider +from open_feature import open_feature_api as api +from open_feature.provider.no_op_provider import NoOpProvider def setup(): @@ -10,53 +10,47 @@ def setup(): assert isinstance(provider, NoOpProvider) -def test_should_get_boolean_flag_from_no_op(): +def test_should_get_boolean_flag_from_no_op(no_op_provider_client): # Given - api.set_provider(NoOpProvider()) - client = api.get_client() # When - flag = client.get_boolean_details(key="Key", default_value=True) + flag = no_op_provider_client.get_boolean_details(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_get_number_flag_from_no_op(no_op_provider_client): # Given - api.set_provider(NoOpProvider()) - client = api.get_client() # When - flag = client.get_number_details(key="Key", default_value=100) + flag = no_op_provider_client.get_number_details(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_get_string_flag_from_no_op(no_op_provider_client): # Given - api.set_provider(NoOpProvider()) - client = api.get_client() # When - flag = client.get_string_details(key="Key", default_value="String") + flag = no_op_provider_client.get_string_details(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_get_object_flag_from_no_op(no_op_provider_client): # Given return_value = { "String": "string", "Number": 2, "Boolean": True, } - api.set_provider(NoOpProvider()) - client = api.get_client() # When - flag = client.get_string_details(key="Key", default_value=return_value) + flag = no_op_provider_client.get_string_details( + key="Key", default_value=return_value + ) # Then assert flag is not None assert flag.value == return_value