Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

python-sdk: Work in progress initial commit of a python sdk #1

Merged
merged 14 commits into from
Jun 22, 2022

Conversation

ajhelsby
Copy link
Contributor

Work in progress: Initial commit of all the features discussed in the open feature specification

Copy link
Member

@matthewelwell matthewelwell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling the modules with hyphens in their name (e.g. flag-evaluation and evaluation-context) will need to be renamed to either remove the hyphen or replace it with an underscore. Python doesn't like hyphens in module names.

readme.md Outdated Show resolved Hide resolved
.pre-commit-config.yaml Outdated Show resolved Hide resolved
src/client.py Outdated Show resolved Hide resolved
src/client.py Outdated Show resolved Hide resolved
src/client.py Outdated Show resolved Hide resolved
src/client.py Outdated Show resolved Hide resolved
src/client.py Outdated Show resolved Hide resolved
Copy link
Member

@matthewelwell matthewelwell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more comments.

(Sorry, got trigger happy on my previous review)

src/open_feature.py Outdated Show resolved Hide resolved
def get_boolean_value(self, key: str, defaultValue: bool) -> bool:
value = self.flagsmith_provider.get_value(key)
if not isinstance(value, bool):
raise Exception()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the spec have a defined behaviour for exception management anywhere? For example, should we have our own exceptions defined in provider.flagsmith.exceptions - maybe these should inherit from some base exception in the client?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, interestingly, we have the concept of enabled and a boolean value. I think @dabeeeenster raised this but I'm not sure what the outcome was?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says no Exceptions should be raised so we should come up with a solution for what needs to be returned.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ajhelsby Correct, exceptions should never bubble up, the only possible situation where that's not the case is exceptions thrown in user-defined-code in error() and finally() hook stages, which is something we're hoping to clarify a bit more soon.

@matthewelwell The "enabled" state in Flagsmith is a bit of an outlier relative to most providers. We struggled with it for a while and decided not to reflect an equivalent concept in the spec.

It will be up to the FlagSmith provider implementation how to handle enabled vs getting a boolean value. It might be something that can be controlled/specified with an option to the FlagSmith provider.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, thanks @toddbaert.

Yes, I think something along the lines of a configuration option that allows the user to control not returning the value if the feature is not 'enabled' would work. We will have a think about it on our side and implement it in the provider as required.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the java side of things, we allow providers to throw exceptions, but they are caught by the client itself.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the java side of things, we allow providers to throw exceptions, but they are caught by the client itself.

Great point, this is how the TS/JS SDK works as well.

Copy link
Member

@matthewelwell matthewelwell Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding exceptions, are we ok with creating a base Exception class in the openfeature SDK and then expecting that all providers to subclass that or do we need to catch all Exceptions in the client? My preference would definitely be the former but it puts a responsibility on the providers to implement their exceptions correctly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to catch all exceptions in the client. No one is going to implement anything correctly ;) . The primary design principal I've been using is: "We should never throw for the user if we can avoid it, and certainly not surprisingly at runtime". Like, I want people to be able to write code like:

if client.get_boolean('show_redesign', False):
  return redesign()
return design()

and not this:

try:
  if client.get_boolean('show_redesign', False):
    return redesign()
  return design()
except:
  return design()

They'd have to do the second if they want to catch stuff like connection timeouts, etc. We should assume an imperfect, but well intentioned provider.

src/provider/provider.py Show resolved Hide resolved
@@ -0,0 +1,83 @@
import typing
Copy link
Member

@toddbaert toddbaert May 31, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be misunderstanding, but we want the SDK to be low (preferably no) dependency, and vendor neutral.

The SDK repo itself isn't the place for any particular vendor provider. Providers should likely exist in a "python-contrib" repo, which would have useful hooks and other open-source providers, or perhaps in repositories belonging to the vendor in question.

The idea is, even 3rd party libraries could include open-feature, which would be a very-low profile dependency, and then the consuming application registers whatever provider they want to use. This way, the SDK the 3rd party need to implement is very light and 100% vendor agnostic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @toddbaert. I just discussed it with @ajhelsby. Is the expectation therefore that the build process (perhaps via a github workflow) would pull in this python-contrib repo and build as a single package to pypi still?

We were thinking it would be quite cool to use the pypi subpackages functionality similar to e.g. boto so you'd end up with something like:

pip install openfeature[flagsmith]

Copy link
Member

@toddbaert toddbaert Jun 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for missing this for so long...

I'm the farthest thing from a python expert, but this seems like a reasonable solution. The goal is not to pull in deps for things you aren't using, and to even allow 3rd party libraries to include OpenFeature generically in their libraries, without any concern over any particular provider/backend. The first party application could then leverage these flags after registering their provider globally.

We basically want to package the evaluation separate from any provider implementation.

pyproject.toml Outdated
[tool.black]
line-length = 88
target-version = ['py37']
include = 'src.*py$|tests.*py$'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclude src files? What will those be?

@@ -1,5 +1,9 @@
# OpenFeature SDK for Python
# Open Feature Python SDK
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a note about experimental status? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed I'll add a comment in on the next commit

requirements.in Outdated
@@ -0,0 +1 @@
flagsmith
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the java sdk, I've been expecting that the provider code would live in a separate repository. We should discuss this as a broader group.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, I feel the same, I made this comment: #1 (comment)

src/client.py Outdated
return self.provider.get_number_value(
key, default_value, evaluation_context, flag_evaluation_options
)
if flag_type is FlagType.STRING:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll want a fallthrough case in the event nothing matches.

src/client.py Outdated
evaluation_context: typing.Any = None,
flag_evaluation_options: typing.Any = None,
):
return self.provider.get_number_details(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the thinking around running the value ones through evaluate_flag but the detail ones directly calling provider?

def get_boolean_value(self, key: str, defaultValue: bool) -> bool:
value = self.flagsmith_provider.get_value(key)
if not isinstance(value, bool):
raise Exception()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the java side of things, we allow providers to throw exceptions, but they are caught by the client itself.

default_value: bool,
evaluation_context: typing.Any = None,
flag_evaluation_options: typing.Any = None,
):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably want return value types here.

src/open_feature_api.py Outdated Show resolved Hide resolved
src/open_feature_client.py Outdated Show resolved Hide resolved

class AbstractProvider:
@abstractmethod
def get_name(self) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could probably just be an attribute on the class itself, right? name = None. Maybe this is better though.

self.error_code = error_code


class FlagNotFoundError(OpenFeatureError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you want to file an issue for it, but we should put docstrings on these.

from enum import Enum


class ErrorCode(Enum):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement 1.4.7
In cases of abnormal execution, the evaluation details structure's error code field MUST contain a string identifying an error occurred during flag evaluation and the nature of the error.

String is specified for this type, fyi.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed all the enums to strings now

Comment on lines 157 to 171
if flag_type is FlagType.BOOLEAN:
return self.provider.get_boolean_details(
key,
default_value,
evaluation_context,
flag_evaluation_options,
)

elif flag_type is FlagType.NUMBER:
return self.provider.get_number_details(
key,
default_value,
evaluation_context,
flag_evaluation_options,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if flag_type is FlagType.BOOLEAN:
return self.provider.get_boolean_details(
key,
default_value,
evaluation_context,
flag_evaluation_options,
)
elif flag_type is FlagType.NUMBER:
return self.provider.get_number_details(
key,
default_value,
evaluation_context,
flag_evaluation_options,
)
args = (key, default_value, evaluation_context, flag_evaluation_options)
if flag_type is FlagType.BOOLEAN:
return self.provider.get_boolean_details(*args)
elif flag_type is FlagType.NUMBER:
return self.provider.get_number_details(*args)
# or..
method = TYPE_TO_METHOD_NAME[flag_type]
return getattr(self.provider, method)(key, default_value, evaluation_context, flag_evaluation_options)

Possible things to reduce code duplication that I'm sure you probably already know. Not sure if you feel like it's a maintenance headache, but sharing in case you think it's worth it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes agreed that's much neater, I've made the change


def test_should_get_boolean_flag_from_no_op():
# Given
open_feature = OpenFeatureAPI()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems odd that you're using an instance here to call the static method. I had to go look twice to see if we were hitting the OFAPI = global singleton requirement. Not sure how that's typically done in python

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, so we have the provider as a global singleton (https://github.com/open-feature/python-sdk/blob/python-sdk/src/open_feature_api.py#L4) but not the OFAPI object itself - this is currently just a class with static methods which I guess somewhat behaves like a singleton but isn't strictly a singleton. What's the thinking behind ensure that is a singleton? Is there something in the spec you can point us towards?

Perhaps @ajhelsby, we should create a global variable for the OFAPI itself instead and the provider lives within that?

Copy link
Member

@toddbaert toddbaert Jun 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec is intentionally vague in some areas to support many languages.

The important part is that there's state stored in the OpenFeature top level object, such as the provider, global hooks, etc. There's an assumption this data is global/singleton.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, thanks @toddbaert & @justinabrahms.

In the latest commit, we've ensured that the API itself and the provider are singletons, just in slightly different ways.

The usage from a client perspective should look something like:

import open_feature_api

open_feature_api.set_provider(NoOpProvider())
client = open_feature_api.get_client()

There is an alternative that uses an OpenFeatureAPI class to keep it more in key with the Java implementation, however, this module / function based implementation is definitely the most pythonic way of doing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't make it java-y. ;)

@@ -154,37 +153,23 @@ def evaluate_flag_details(
flag_evaluation_options: typing.Any = None,
) -> FlagEvaluationDetails:
try:
args = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably simplify this by doing something like:

def evaluate_flag_details(self, flag_type, *args, **kwargs) -> FlagEvaluationDetails:
    try:
        if flag_type is FlagType.BOOLEAN:
            return self.provider.get_boolean_details(*args, **kwargs)

In that case, though, you'd need to create an accurate docstring for sure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, let's leave this as it is for now.


def test_should_get_boolean_flag_from_no_op():
# Given
open_feature = OpenFeatureAPI()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, so we have the provider as a global singleton (https://github.com/open-feature/python-sdk/blob/python-sdk/src/open_feature_api.py#L4) but not the OFAPI object itself - this is currently just a class with static methods which I guess somewhat behaves like a singleton but isn't strictly a singleton. What's the thinking behind ensure that is a singleton? Is there something in the spec you can point us towards?

Perhaps @ajhelsby, we should create a global variable for the OFAPI itself instead and the provider lives within that?

@matthewelwell
Copy link
Member

@toddbaert @justinabrahms I think this initial implementation (without hooks and evaluation context) is now ready for final review.

If you're ok with the approach, we'd like to get this merged and add the hooks and context in separate PRs.

Let me know if you'd rather keep it all to a single PR though and we can do that instead.

@justinabrahms
Copy link
Member

I support merging this as is. :)

@toddbaert
Copy link
Member

I support merging this as is. :)

As do I.

@toddbaert toddbaert self-requested a review June 21, 2022 14:51
@ajhelsby ajhelsby merged commit 0b30681 into main Jun 22, 2022
@ajhelsby ajhelsby deleted the python-sdk branch October 13, 2022 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants