Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.25.0
ignore: {}
patch: {}
exclude:
global:
- tests/**
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A Python SDK for Switcher API

[![Master CI](https://github.com/switcherapi/switcher-client-py/actions/workflows/master.yml/badge.svg)](https://github.com/switcherapi/switcher-client-py/actions/workflows/master.yml)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-client-py&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-py)
[![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-client-py/badge.svg)](https://snyk.io/test/github/switcherapi/switcher-client-py)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Slack: Switcher-HQ](https://img.shields.io/badge/slack-@switcher/hq-blue.svg?logo=slack)](https://switcher-hq.slack.com/)

Expand Down Expand Up @@ -97,37 +98,37 @@ There are a few different ways to call the API using the JavaScript module.
Here are some examples:

1. **No parameters**
Invoking the API can be done by instantiating the switcher and calling *isItOn* passing its key as a parameter.
Invoking the API can be done by instantiating the switcher and calling *is_on* passing its key as a parameter.


```python
switcher = Client.get_switcher()
switcher.check('FEATURE01')
switcher.is_on('FEATURE01')
# or
result, reason, metadata = switcher.detail().check('FEATURE01')
result, reason, metadata = switcher.detail().is_on('FEATURE01')
```

2. **Strategy validation - preparing input**
Loading information into the switcher can be made by using *prepare*, in case you want to include input from a different place of your code. Otherwise, it is also possible to include everything in the same call.

```python
switcher.check_value('USER_1').prepare('FEATURE01')
switcher.check()
switcher.is_on()
```

3. **Strategy validation - all-in-one execution**
All-in-one method is fast and include everything you need to execute a complex call to the API.

```python
result, reason, metadata = switcher.detail().check_value('User 1').check_network('192.168.0.1').check('FEATURE01')
result, reason, metadata = switcher.detail().check_value('User 1').check_network('192.168.0.1').is_on('FEATURE01')
```

4. **Throttle**
Throttling is useful when placing Feature Flags at critical code blocks require zero-latency without having to switch to local.
API calls will happen asynchronously and the result returned is based on the last API response.

```python
switcher.throttle(1000).check('FEATURE01')
switcher.throttle(1000).is_on('FEATURE01')
```

In order to capture issues that may occur during the process, it is possible to log the error by subscribing to the error events.
Expand All @@ -141,21 +142,21 @@ Forcing Switchers to resolve remotely can help you define exclusive features tha
This feature is ideal if you want to run the SDK in local mode but still want to resolve a specific switcher remotely.

```python
switcher.remote().check('FEATURE01')
switcher.remote().is_on('FEATURE01')
```

## Built-in mock feature
You can also bypass your switcher configuration by invoking 'Client.assume'. This is perfect for your test code where you want to test both scenarios when the switcher is true and false.

```python
Client.assume('FEATURE01').true()
switcher.check('FEATURE01') # True
switcher.is_on('FEATURE01') # True

Client.forget('FEATURE01')
switcher.check('FEATURE01') # Now, it's going to return the result retrieved from the API or the Snaopshot file
switcher.is_on('FEATURE01') # Now, it's going to return the result retrieved from the API or the Snaopshot file

Client.assume('FEATURE01').false().with_metadata({ 'message': 'Feature is disabled' }) # Include metadata to emulate Relay response
response = switcher.detail().check('FEATURE01') # False
response = switcher.detail().is_on('FEATURE01') # False
print(response.metadata['message']) # Feature is disabled
```

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests==2.32.3
11 changes: 10 additions & 1 deletion switcher_client/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import Optional

from switcher_client.lib.remote_auth import RemoteAuth
from switcher_client.context import Context, ContextOptions
from switcher_client.context import DEFAULT_ENVIRONMENT
from switcher_client.switcher import Switcher

class Client:
context: Optional[Context] = None
Expand All @@ -26,6 +28,7 @@ def build_context(

"""
Client.context = Context(domain, url, api_key, component, environment, options)
RemoteAuth.init(Client.context)

@staticmethod
def clear_context():
Expand All @@ -34,4 +37,10 @@ def clear_context():
@staticmethod
def verify_context():
if not Client.context:
raise ValueError('Context is not set')
raise ValueError('Context is not set')

@staticmethod
def get_switcher(key: str = None) -> Switcher:
""" Get a switcher by key """
Client.verify_context()
return Switcher(Client.context, key)
4 changes: 4 additions & 0 deletions switcher_client/errors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class RemoteAuthError(Exception):
def __init__(self, message):
self.message = message
super().__init__(self.message)
5 changes: 5 additions & 0 deletions switcher_client/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .remote_auth import RemoteAuth

__all__ = [
'RemoteAuth'
]
29 changes: 29 additions & 0 deletions switcher_client/lib/remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import requests

from switcher_client.errors import RemoteAuthError
from switcher_client.context import Context

class Remote:

@staticmethod
def auth(context: Context):
url = context.url + '/criteria/auth'

response = Remote.do_post(url, {
'domain': context.domain,
'component': context.component,
'environment': context.environment,
}, {
'switcher-api-key': context.api_key,
'Content-Type': 'application/json',
})

if response.status_code == 200:
return response.json()['token'], response.json()['exp']

raise RemoteAuthError('Invalid API key')

@staticmethod
def do_post(url, data, headers) -> requests.Response:
""" Perform a POST request """
return requests.post(url, json=data, headers=headers)
30 changes: 30 additions & 0 deletions switcher_client/lib/remote_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional

from switcher_client.errors import RemoteAuthError
from switcher_client.lib.remote import Remote
from switcher_client.context import Context

class RemoteAuth:
__context: Optional[Context] = None
__token = None
__exp = None

@staticmethod
def init(context: Context):
RemoteAuth.__context = context
RemoteAuth.__token = None
RemoteAuth.__exp = None

@staticmethod
def auth():
token, exp = Remote.auth(RemoteAuth.__context)
RemoteAuth.__token = token
RemoteAuth.__exp = exp

@staticmethod
def get_token():
return RemoteAuth.__token

@staticmethod
def get_exp():
return RemoteAuth.__exp
36 changes: 36 additions & 0 deletions switcher_client/switcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional

from switcher_client.context import Context
from switcher_client.lib.remote_auth import RemoteAuth

class Switcher:
def __init__(self, context: Context, key: str = None):
self.context = context
self.key = key

def is_on(self, key: str = None) -> bool:
""" Execute criteria """
result = False

self.__validate_args(key)
self.__execute_api_checks()
result = self.__execute_remote_criteria()

return result

def __validate_args(self, key: str = None):
if self.key is None:
self.key = key

if self.key is None:
raise ValueError('Key is required')

def __execute_api_checks(self):
""" Assure API is available and token is valid """
RemoteAuth.auth()

def __execute_remote_criteria(self):
""" Execute remote criteria """
RemoteAuth.get_token()
RemoteAuth.get_exp()
return True
3 changes: 2 additions & 1 deletion tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pytest==8.3.3
pytest-cov==5.0.0
pytest-cov==5.0.0
responses==0.25.6
6 changes: 3 additions & 3 deletions tests/test_client_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from switcher_client import Client, ContextOptions

def test_context():
""" Test building and verifying context """
""" Should build and verify context """

Client.build_context(
domain='My Domain',
Expand All @@ -23,7 +23,7 @@ def test_context():
pytest.fail(f'Context verification failed: {e}')

def test_clear_context():
""" Test clearing context """
""" Should clear context """

Client.build_context(
domain='My Domain',
Expand All @@ -38,7 +38,7 @@ def test_clear_context():
Client.verify_context()

def test_context_with_optionals():
""" Test building context with optional parameters - local and snapshot_location """
""" Should build context with optional parameters - local and snapshot_location """

Client.build_context(
domain='My Domain',
Expand Down
65 changes: 65 additions & 0 deletions tests/test_switcher_remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import pytest
import responses
import time

from switcher_client.errors import RemoteAuthError
from switcher_client import Client, ContextOptions

@responses.activate
def test_remote():
""" Should call the remote API with success """

# given
given_auth()
given_context()

switcher = Client.get_switcher()

# test
assert switcher.is_on('MY_SWITCHER')

def test_remote_err_no_key():
""" Should raise an exception when no key is provided """

# given
given_context()

switcher = Client.get_switcher()

# test
with pytest.raises(ValueError):
switcher.is_on()

@responses.activate
def test_remote_err_invalid_api_key():
""" Should raise an exception when the API key is invalid """

# given
given_auth(status=401)
given_context()

switcher = Client.get_switcher()

# test
with pytest.raises(RemoteAuthError) as excinfo:
switcher.is_on('MY_SWITCHER')

assert 'Invalid API key' in str(excinfo.value)

# Helpers

def given_context(url='https://api.switcherapi.com', api_key='[API_KEY]'):
Client.build_context(
url=url,
api_key=api_key,
domain='Playground',
component='switcher-playground'
)

def given_auth(status=200, token='[token]', exp=int(round(time.time() * 1000))):
responses.add(
responses.POST,
'https://api.switcherapi.com/criteria/auth',
json={'token': token, 'exp': exp},
status=status
)