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
8 changes: 4 additions & 4 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ jobs:

steps:
- name: Git checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"

Expand Down Expand Up @@ -47,10 +47,10 @@ jobs:

steps:
- name: Git checkout
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Install pypa/build
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ jobs:
core.setOutput('base_ref', pr.data.base.ref);
core.setOutput('head_sha', pr.data.head.sha);

- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0

- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ jobs:

steps:
- name: Git checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ github.event.inputs.python }}

Expand Down
12 changes: 5 additions & 7 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ verify_ssl = true
name = "pypi"

[packages]
requests = "==2.32.5"

# Managed dependencies and security patches
urllib3 = ">=2.5.0"
httpx = {extras = ["http2"], version = "==0.28.1"}

[dev-packages]
pytest = "==8.4.1"
pytest-cov = "==6.2.1"
responses = "==0.25.8"
pytest = "==8.4.2"
pytest-cov = "==7.0.0"
pytest-httpx = "==0.35.0"
typing-extensions = "4.15.0"
switcher-client = {file = ".", editable = true}
556 changes: 205 additions & 351 deletions Pipfile.lock

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,21 @@ Client.schedule_snapshot_auto_update(3000, {
'success': lambda updated: print('Snapshot updated', updated),
'reject': lambda err: print(err)
})
```
```

# Contributing
We welcome contributions to the Switcher Client SDK for Python! If you have suggestions, improvements, or bug fixes, please follow these steps:

1. Fork the repository.
2. Create a new branch for your feature or bug fix.
3. Make your changes and commit them with clear messages.
4. Submit a pull request detailing your changes and the problem they solve.

Thank you for helping us improve the Switcher Client SDK!

### Requirements
- Python 3.9 or higher
- Virtualenv - `pip install virtualenv`
- Create a virtual environment - `python3 -m venv .venv`
- Install Pipenv - `pip install pipenv`
- Check Makefile for all available commands
42 changes: 29 additions & 13 deletions switcher_client/lib/remote.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import requests
import httpx

from typing import Optional

from switcher_client.errors import RemoteAuthError, RemoteError
Expand All @@ -10,11 +11,12 @@
from switcher_client.switcher_data import SwitcherData

class Remote:
_client: Optional[httpx.Client] = None

@staticmethod
def auth(context: Context):
url = f'{context.url}/criteria/auth'
response = Remote.do_post(url, {
response = Remote.__do_post(url, {
'domain': context.domain,
'component': context.component,
'environment': context.environment,
Expand All @@ -29,12 +31,10 @@ def auth(context: Context):
raise RemoteAuthError('Invalid API key')

@staticmethod
def check_criteria(
token: Optional[str], context: Context, switcher: SwitcherData) -> ResultDetail:

def check_criteria(token: Optional[str], context: Context, switcher: SwitcherData) -> ResultDetail:
url = f'{context.url}/criteria?showReason={str(switcher._show_details).lower()}&key={switcher._key}'
entry = Remote.__get_entry(switcher._input)
response = Remote.do_post(url, entry, Remote.get_header(token))
response = Remote.__do_post(url, entry, Remote.__get_header(token))

if response.status_code == 200:
json_response = response.json()
Expand All @@ -49,7 +49,7 @@ def check_criteria(
@staticmethod
def check_snapshot_version(token: Optional[str], context: Context, snapshot_version: int) -> bool:
url = f'{context.url}/criteria/snapshot_check/{snapshot_version}'
response = Remote.do_get(url, Remote.get_header(token))
response = Remote.__do_get(url, Remote.__get_header(token))

if response.status_code == 200:
return response.json().get('status', False)
Expand Down Expand Up @@ -79,23 +79,39 @@ def resolve_snapshot(token: Optional[str], context: Context) -> str | None:
"""
}

response = Remote.do_post(f'{context.url}/graphql', data, Remote.get_header(token))
response = Remote.__do_post(f'{context.url}/graphql', data, Remote.__get_header(token))

if response.status_code == 200:
return json.dumps(response.json(), indent=4)

raise RemoteError(f'[resolve_snapshot] failed with status: {response.status_code}')

@classmethod
def __get_client(cls) -> httpx.Client:
if cls._client is None or cls._client.is_closed:
cls._client = httpx.Client(
timeout=30.0,
limits=httpx.Limits(
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=30.0
),
http2=True
)
return cls._client

@staticmethod
def do_post(url, data, headers) -> requests.Response:
return requests.post(url, json=data, headers=headers)
def __do_post(url, data, headers) -> httpx.Response:
client = Remote.__get_client()
return client.post(url, json=data, headers=headers)

@staticmethod
def do_get(url, headers=None) -> requests.Response:
return requests.get(url, headers=headers)
def __do_get(url, headers=None) -> httpx.Response:
client = Remote.__get_client()
return client.get(url, headers=headers)

@staticmethod
def get_header(token: Optional[str]):
def __get_header(token: Optional[str]):
return {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
Expand Down
73 changes: 35 additions & 38 deletions tests/test_client_load_snapshot_remote.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import json
import pytest
import responses
import time

from typing import Optional
from pytest_httpx import HTTPXMock

from switcher_client import Client
from switcher_client.errors import RemoteError
from switcher_client.lib.globals.global_context import DEFAULT_ENVIRONMENT, ContextOptions
from switcher_client.lib.globals.global_snapshot import LoadSnapshotOptions

@responses.activate
def test_load_from_snapshot_in_memory():
def test_load_from_snapshot_in_memory(httpx_mock):
""" Should load in-memory Domain from snapshot remote """

# given
given_auth()
given_check_snapshot_version(version=0, status=False)
given_resolve_snapshot(data=load_snapshot_fixture('tests/snapshots/default_load_1.json'))
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_context(environment='default_load_1')

# test
Expand All @@ -27,13 +27,12 @@ def test_load_from_snapshot_in_memory():
assert Client.snapshot_version() == 1588557288040
assert version == Client.snapshot_version()

@responses.activate
def test_load_from_snapshot_no_update():
def test_load_from_snapshot_no_update(httpx_mock):
""" Should not update snapshot if version is the same """

# given
given_auth()
given_check_snapshot_version(version=1588557288040, status=True)
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, version=1588557288040, status=True)
given_context(snapshot_location='tests/snapshots', environment='default_load_1')

# test
Expand All @@ -44,13 +43,12 @@ def test_load_from_snapshot_no_update():
assert version == Client.snapshot_version()
assert not updated

@responses.activate
def test_check_snapshot_version_error():
def test_check_snapshot_version_error(httpx_mock):
""" Should handle errors when checking snapshot version """

# given
given_auth()
given_check_snapshot_version(status_code=500, version=1588557288040)
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, status_code=500, version=1588557288040)
given_context(snapshot_location='tests/snapshots', environment='default_load_1')

Client.load_snapshot() # load from file
Expand All @@ -61,14 +59,13 @@ def test_check_snapshot_version_error():

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

@responses.activate
def test_resolve_snapshot_error():
def test_resolve_snapshot_error(httpx_mock):
""" Should handle errors when resolving snapshot """

# given
given_auth()
given_check_snapshot_version(version=1588557288040, status=False)
given_resolve_snapshot(status_code=500)
given_auth(httpx_mock)
given_check_snapshot_version(httpx_mock, version=1588557288040, status=False)
given_resolve_snapshot(httpx_mock, status_code=500)
given_context(snapshot_location='tests/snapshots', environment='default_load_1')

Client.load_snapshot() # load from file
Expand All @@ -81,28 +78,28 @@ def test_resolve_snapshot_error():

# Helpers

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

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

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

def given_context(url='https://api.switcherapi.com',
Expand All @@ -113,7 +110,7 @@ def given_context(url='https://api.switcherapi.com',
url=url,
api_key=api_key,
domain='Switcher API',
component='switcher4deno',
component='switcher-client-python',
environment=environment,
options=ContextOptions(
local=True,
Expand Down
Loading
Loading