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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ coverage.xml
cover
.pytest_cache
.env
tests/snapshots/temp/

# PyBuilder
target/
22 changes: 14 additions & 8 deletions switcher_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from switcher_client.lib.remote_auth import RemoteAuth
from switcher_client.lib.globals.global_context import Context, ContextOptions
from switcher_client.lib.globals.global_context import DEFAULT_ENVIRONMENT
from switcher_client.lib.snapshot_auto_updater import SnapshotAutoUpdater
from switcher_client.lib.snapshot_loader import load_domain, validate_snapshot
from switcher_client.lib.utils import get
from switcher_client.switcher import Switcher
Expand Down Expand Up @@ -121,8 +122,18 @@ def schedule_snapshot_auto_update(interval: Optional[int] = None, callback: Opti
if interval is not None:
Client.context.options.snapshot_auto_update_interval = interval

if Client.__is_auto_update_snapshot_available():
callback(None, True)
if Client.context.options.snapshot_auto_update_interval is not None and \
Client.context.options.snapshot_auto_update_interval > 0:
SnapshotAutoUpdater.schedule(
interval=Client.context.options.snapshot_auto_update_interval,
check_snapshot=Client.check_snapshot,
callback=callback
)

@staticmethod
def terminate_snapshot_auto_update():
""" Terminate Snapshot auto update """
SnapshotAutoUpdater.terminate()

@staticmethod
def snapshot_version() -> int:
Expand All @@ -136,9 +147,4 @@ def snapshot_version() -> int:

@staticmethod
def __is_check_snapshot_available(fetch_remote = False) -> bool:
return Client.snapshot_version() == 0 and (fetch_remote or not Client.context.options.local)

@staticmethod
def __is_auto_update_snapshot_available() -> bool:
return Client.context.options.snapshot_auto_update_interval is not None and \
Client.context.options.snapshot_auto_update_interval > 0
return Client.snapshot_version() == 0 and (fetch_remote or not Client.context.options.local)
2 changes: 1 addition & 1 deletion switcher_client/lib/globals/global_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class GlobalAuth:
__exp = None

@staticmethod
def init(url: Optional[str]):
def init():
GlobalAuth.__token = None
GlobalAuth.__exp = None

Expand Down
12 changes: 10 additions & 2 deletions switcher_client/lib/globals/global_context.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
from typing import Optional


DEFAULT_ENVIRONMENT = 'default'
DEFAULT_LOCAL = False

class ContextOptions:
def __init__(self, local = DEFAULT_LOCAL, snapshot_location = None, snapshot_auto_update_interval = None):
def __init__(self,
local = DEFAULT_LOCAL,
snapshot_location: Optional[str] = None,
snapshot_auto_update_interval: Optional[int] = None):
self.local = local
self.snapshot_location = snapshot_location
self.snapshot_auto_update_interval = snapshot_auto_update_interval

class Context:
def __init__(self, domain, url, api_key, component, environment, options = ContextOptions()):
def __init__(self,
domain: Optional[str], url: Optional[str], api_key: Optional[str], component: Optional[str],
environment: Optional[str], options: ContextOptions = ContextOptions()):
self.domain = domain
self.url = url
self.api_key = api_key
Expand Down
2 changes: 1 addition & 1 deletion switcher_client/lib/remote_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class RemoteAuth:
@staticmethod
def init(context: Context):
RemoteAuth.__context = context
GlobalAuth.init(context.url)
GlobalAuth.init()

@staticmethod
def auth():
Expand Down
56 changes: 56 additions & 0 deletions switcher_client/lib/snapshot_auto_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import threading
import time
from typing import Callable, Optional

class SnapshotAutoUpdater:
_timer_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None

@staticmethod
def schedule(interval: int, check_snapshot: Callable[[], bool], callback: Callable[[Optional[Exception], bool], None]) -> None:
"""
Schedule periodic snapshot updates in a background thread.

:param interval: Update interval in seconds
:param check_snapshot: Function that checks and updates snapshot, returns True if updated
:param callback: Callback function called with (error, updated) after each check
"""

SnapshotAutoUpdater.terminate()
SnapshotAutoUpdater._stop_event = threading.Event()

SnapshotAutoUpdater._timer_thread = threading.Thread(
target=SnapshotAutoUpdater.__update_worker,
args=(interval, check_snapshot, callback),
daemon=True,
name="SnapshotAutoUpdater"
)
SnapshotAutoUpdater._timer_thread.start()

@staticmethod
def terminate() -> None:
"""
Terminate the scheduled snapshot auto-update thread gracefully.
"""
if SnapshotAutoUpdater._stop_event is not None:
SnapshotAutoUpdater._stop_event.set()

if SnapshotAutoUpdater._timer_thread is not None and SnapshotAutoUpdater._timer_thread.is_alive():
SnapshotAutoUpdater._timer_thread.join(timeout=5.0)

SnapshotAutoUpdater._timer_thread = None
SnapshotAutoUpdater._stop_event = None

@staticmethod
def __update_worker(interval: int, check_snapshot: Callable[[], bool], callback: Callable[[Optional[Exception], bool], None]) -> None:
stop_event = SnapshotAutoUpdater._stop_event

time.sleep(interval) # delay start
while stop_event is not None and not stop_event.is_set():
try:
updated = check_snapshot()
callback(None, updated)
except Exception as error:
callback(error, False)

stop_event.wait(interval)
7 changes: 7 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os

SNAPSHOT_TEMP_DIR = 'tests/snapshots/temp'

if os.path.exists(SNAPSHOT_TEMP_DIR):
for f in os.listdir(SNAPSHOT_TEMP_DIR):
os.remove(os.path.join(SNAPSHOT_TEMP_DIR, f))
26 changes: 26 additions & 0 deletions tests/snapshots/default_load_2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"data": {
"domain": {
"name": "Business",
"description": "Business description",
"version": 1588557288041,
"activated": true,
"group": [
{
"name": "Rollout 2030",
"description": "Changes that will be applied during the rollout",
"activated": true,
"config": [
{
"key": "FF2FOR2030",
"description": "Feature Flag",
"activated": false,
"strategies": [],
"components": []
}
]
}
]
}
}
}
2 changes: 1 addition & 1 deletion tests/test_client_load_snapshot_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_load_from_snapshot_empty():
environment='generated-clean',
options=ContextOptions(
local=True,
snapshot_location='./tests/snapshots'
snapshot_location='./tests/snapshots/temp'
)
)

Expand Down
117 changes: 104 additions & 13 deletions tests/test_client_load_snapshot_remote.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
import pytest
import time

Expand Down Expand Up @@ -76,51 +77,136 @@ def test_resolve_snapshot_error(httpx_mock):

assert '[resolve_snapshot] failed with status: 500' in str(excinfo.value)

def test_auto_update_snapshot():
""" WIP: Should auto-update snapshot every second """
def test_auto_update_snapshot_from_context(httpx_mock):
""" Should auto-update snapshot every second from context """

# given
# given - load initial snapshot
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, version=0, status=False)
given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default_load_1.json'))

# given - snapshot out-of-date, needs update
given_check_snapshot_version(httpx_mock, version=1588557288040, status=False) # needs update
given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default_load_2.json'))

# given - context
given_context(
snapshot_location='tests/snapshots',
environment='default_load_1',
snapshot_location='tests/snapshots/temp',
environment='generated-auto-update',
snapshot_auto_update_interval=1
)

# test
Client.load_snapshot(LoadSnapshotOptions(fetch_remote=True))
assert Client.snapshot_version() == 1588557288040

callback_args = []
time.sleep(1.5) # wait for auto-update to trigger
assert Client.snapshot_version() == 1588557288041

# tear down
Client.terminate_snapshot_auto_update()
delete_snapshot_file('./tests/snapshots/temp', 'generated-auto-update')

def test_auto_update_snapshot(httpx_mock):
""" Should auto-update snapshot every second """

# given - load initial snapshot
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, version=0, status=False)
given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default_load_1.json'))

# given - snapshot out-of-date, needs update
given_check_snapshot_version(httpx_mock, version=1588557288040, status=False) # needs update
given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default_load_2.json'))

# given - context
given_context(
snapshot_location='tests/snapshots/temp',
environment='generated-auto-update'
)

Client.load_snapshot(LoadSnapshotOptions(fetch_remote=True))

# test
callback_args = []
Client.schedule_snapshot_auto_update(interval=1,
callback=lambda error, updated: callback_args.append((error, updated))
)

time.sleep(1.5) # wait for auto-update to trigger

error, updated = callback_args[0]
assert error is None
assert updated

# tear down
Client.terminate_snapshot_auto_update()
delete_snapshot_file('./tests/snapshots/temp', 'generated-auto-update')

def test_not_auto_update_snapshot_when_error(httpx_mock):
""" Should not auto-update snapshot if there is an error """

# given - load initial snapshot
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, version=0, status=False)
given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default_load_1.json'))

# given - snapshot out-of-date, needs update
given_check_snapshot_version(httpx_mock, version=1588557288040, status=False) # needs update
given_resolve_snapshot(httpx_mock, status_code=500) # will cause error

# given - context
given_context(
snapshot_location='tests/snapshots/temp',
environment='generated-auto-update-error'
)

Client.load_snapshot(LoadSnapshotOptions(fetch_remote=True))

# test
callback_args = []
Client.schedule_snapshot_auto_update(interval=1,
callback=lambda error, updated: callback_args.append((error, updated))
)

time.sleep(1.5) # wait for auto-update to trigger

error, updated = callback_args[0]
assert isinstance(error, RemoteError)
assert '[resolve_snapshot] failed with status: 500' in str(error)
assert not updated

# tear down
Client.terminate_snapshot_auto_update()
delete_snapshot_file('./tests/snapshots/temp', 'generated-auto-update-error')

# Helpers

def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]', exp=int(round(time.time() * 1000))):
def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]', exp=int(round(time.time() * 1000)), is_reusable=False):
httpx_mock.add_response(
url='https://api.switcherapi.com/criteria/auth',
method='POST',
status_code=status,
json={'token': token, 'exp': exp}
json={'token': token, 'exp': exp},
is_reusable=is_reusable
)

def given_check_snapshot_version(httpx_mock: HTTPXMock, status_code=200, version=0, status=False):
def given_check_snapshot_version(httpx_mock: HTTPXMock, status_code=200, version=0, status=False, is_reusable=False):
httpx_mock.add_response(
url=f'https://api.switcherapi.com/criteria/snapshot_check/{version}',
method='GET',
status_code=status_code,
json={'status': status}
json={'status': status},
is_reusable=is_reusable
)

def given_resolve_snapshot(httpx_mock: HTTPXMock, status_code=200, data=[]):
def given_resolve_snapshot(httpx_mock: HTTPXMock, status_code=200, data=[], is_reusable=False):
httpx_mock.add_response(
url='https://api.switcherapi.com/graphql',
method='POST',
status_code=status_code,
json={'data': data}
json={'data': data},
is_reusable=is_reusable
)

def given_context(url='https://api.switcherapi.com',
Expand All @@ -143,4 +229,9 @@ def given_context(url='https://api.switcherapi.com',

def load_snapshot_fixture(file_path: str):
with open(file_path, 'r') as f:
return json.loads(f.read()).get('data', {})
return json.loads(f.read()).get('data', {})

def delete_snapshot_file(snapshot_location: str, environment: str):
snapshot_file = f"{snapshot_location}/{environment}.json"
if os.path.exists(snapshot_file):
os.remove(snapshot_file)