Skip to content

Commit 2e74db0

Browse files
Implement GET /flag and POST /identities/ (#2)
* wip: Add apis * use environment values * squash@ flake8fix * fixup: use relative import * Corrected url for identities Updated requirements lock files * iSort * Trailing slashes included are the default * misc: move cache refresh to task * squash! fix linting * implement endpoints in place instead of using edge api * remove edge api submodule * Add marshmallow to requirements * remove edge-api interface code * move tasks to utils * misc fixes and add tests * fix! black linting * noqa repeat_every * Add pytest plugins * refac(cache): move session creation to init * tests(cache): Add test case for cache module * minor tasks tests * rm .gitmodules * Add pytest to pull request action * update requirements * remove test cases of max_repetitions * misc fixes add more tests * squash! Add default for api_keys * squash! * minor fix * use pyhon-decouple * squash! minor fixes * flak8 lint fix * fixup! use pyhon-decouple * cp fixtures/dynamodb fixtures/response_data * fix(tests): add env vars to runner * test: verify call to get_environment is cached * move tasks to fastapi_utils * copy fastapi utils LICENSE * Add dot env * isort Co-authored-by: Ben Rometsch <[email protected]>
1 parent 06bcaab commit 2e74db0

21 files changed

+714
-11
lines changed

.github/workflows/pull-request.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ jobs:
1212
if: github.event.pull_request.draft == false
1313
runs-on: ubuntu-latest
1414
name: Unit Tests and Linting
15+
env:
16+
FLAGSMITH_API_URL: https://api.flagsmith.com/api/v1
17+
FLAGSMITH_API_TOKEN: test_token
18+
ENVIRONMENT_API_KEYS: placeholder_key1
1519

1620
strategy:
1721
max-parallel: 4
@@ -45,3 +49,6 @@ jobs:
4549

4650
- name: Check isort imports
4751
run: isort --check .
52+
53+
- name: Run Tests
54+
run: pytest -p no:warnings

fastapi_utils/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 David Montague
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

fastapi_utils/tasks.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import asyncio
2+
import logging
3+
from asyncio import ensure_future
4+
from functools import wraps
5+
from traceback import format_exception
6+
from typing import Any, Callable, Coroutine, Optional, Union
7+
8+
from starlette.concurrency import run_in_threadpool
9+
10+
NoArgsNoReturnFuncT = Callable[[], None]
11+
NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]]
12+
NoArgsNoReturnDecorator = Callable[
13+
[Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]], NoArgsNoReturnAsyncFuncT
14+
]
15+
16+
17+
def repeat_every( # noqa: C901
18+
*,
19+
seconds: float,
20+
wait_first: bool = False,
21+
logger: Optional[logging.Logger] = None,
22+
raise_exceptions: bool = False,
23+
) -> NoArgsNoReturnDecorator:
24+
"""
25+
This function returns a decorator that modifies a function so it is periodically re-executed after its first call.
26+
The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished
27+
by using `functools.partial` or otherwise wrapping the target function prior to decoration.
28+
Parameters
29+
----------
30+
seconds: float
31+
The number of seconds to wait between repeated calls
32+
wait_first: bool (default False)
33+
If True, the function will wait for a single period before the first call
34+
logger: Optional[logging.Logger] (default None)
35+
The logger to use to log any exceptions raised by calls to the decorated function.
36+
If not provided, exceptions will not be logged by this function (though they may be handled by the event loop).
37+
raise_exceptions: bool (default False)
38+
If True, errors raised by the decorated function will be raised to the event loop's exception handler.
39+
Note that if an error is raised, the repeated execution will stop.
40+
Otherwise, exceptions are just logged and the execution continues to repeat.
41+
See https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_exception_handler for more info.
42+
"""
43+
44+
def decorator(
45+
func: Union[NoArgsNoReturnAsyncFuncT, NoArgsNoReturnFuncT]
46+
) -> NoArgsNoReturnAsyncFuncT:
47+
"""
48+
Converts the decorated function into a repeated, periodically-called version of itself.
49+
"""
50+
is_coroutine = asyncio.iscoroutinefunction(func)
51+
52+
@wraps(func)
53+
async def wrapped() -> None:
54+
async def loop() -> None:
55+
if wait_first:
56+
await asyncio.sleep(seconds)
57+
while True:
58+
try:
59+
if is_coroutine:
60+
await func() # type: ignore
61+
else:
62+
await run_in_threadpool(func)
63+
except Exception as exc:
64+
if logger is not None:
65+
formatted_exception = "".join(
66+
format_exception(type(exc), exc, exc.__traceback__)
67+
)
68+
logger.error(formatted_exception)
69+
if raise_exceptions:
70+
raise exc
71+
await asyncio.sleep(seconds)
72+
73+
ensure_future(loop())
74+
75+
return wrapped
76+
77+
return decorator

fastapi_utils/test_tasks.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import asyncio
2+
import logging
3+
import time
4+
from asyncio import AbstractEventLoop
5+
from typing import Any, Dict, List, NoReturn, Tuple
6+
7+
import pytest
8+
from _pytest.capture import CaptureFixture
9+
from _pytest.logging import LogCaptureFixture
10+
11+
from fastapi_utils.tasks import repeat_every
12+
13+
logging.basicConfig(level=logging.INFO)
14+
15+
16+
def ignore_exception(_loop: AbstractEventLoop, _context: Dict[str, Any]) -> None:
17+
pass
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def setup_event_loop(event_loop: AbstractEventLoop) -> None:
22+
event_loop.set_exception_handler(ignore_exception)
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_repeat_unlogged_error(caplog: LogCaptureFixture) -> None:
27+
# Given
28+
@repeat_every(seconds=0.07)
29+
def log_exc() -> NoReturn:
30+
raise ValueError("repeat")
31+
32+
# When
33+
await log_exc()
34+
await asyncio.sleep(0.1)
35+
36+
# Then
37+
record_tuples = [x for x in caplog.record_tuples if x[0] == __name__]
38+
assert len(record_tuples) == 0
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_repeat_log_error(caplog: LogCaptureFixture) -> None:
43+
# Given
44+
logger = logging.getLogger(__name__)
45+
46+
@repeat_every(seconds=0.1, logger=logger)
47+
def log_exc() -> NoReturn:
48+
raise ValueError("repeat")
49+
50+
# When
51+
await log_exc()
52+
n_record_tuples = 0
53+
record_tuples: List[Tuple[Any, ...]] = []
54+
start_time = time.time()
55+
while n_record_tuples < 2: # ensure multiple records are logged
56+
time_elapsed = time.time() - start_time
57+
if time_elapsed > 1:
58+
print(record_tuples)
59+
assert False, "Test timed out"
60+
await asyncio.sleep(0.05)
61+
62+
# Then
63+
record_tuples = [x for x in caplog.record_tuples if x[0] == __name__]
64+
n_record_tuples = len(record_tuples)
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_repeat_raise_error(
69+
caplog: LogCaptureFixture, capsys: CaptureFixture
70+
) -> None:
71+
72+
# Given
73+
logger = logging.getLogger(__name__)
74+
75+
@repeat_every(seconds=0.07, raise_exceptions=True, logger=logger)
76+
def raise_exc() -> NoReturn:
77+
raise ValueError("repeat")
78+
79+
# When
80+
await raise_exc()
81+
await asyncio.sleep(0.1)
82+
out, err = capsys.readouterr()
83+
84+
# Then
85+
assert out == ""
86+
assert err == ""
87+
record_tuples = [x for x in caplog.record_tuples if x[0] == __name__]
88+
assert len(record_tuples) == 1

requirements-dev.in

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ black
55
pip-tools
66
pre-commit
77
flake8
8+
pytest
9+
pytest-asyncio
10+
pytest-mock

requirements-dev.txt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#
2-
# This file is autogenerated by pip-compile
2+
# This file is autogenerated by pip-compile with python 3.10
33
# To update, run:
44
#
55
# pip-compile requirements-dev.in
66
#
77
astroid==2.9.0
88
# via pylint
9+
attrs==21.2.0
10+
# via pytest
911
autopep8==1.6.0
1012
# via -r requirements-dev.in
1113
backports.entry-points-selectable==1.1.1
@@ -26,6 +28,8 @@ flake8==4.0.1
2628
# via -r requirements-dev.in
2729
identify==2.4.0
2830
# via pre-commit
31+
iniconfig==1.1.1
32+
# via pytest
2933
isort==5.10.1
3034
# via pylint
3135
lazy-object-proxy==1.6.0
@@ -38,6 +42,8 @@ mypy-extensions==0.4.3
3842
# via black
3943
nodeenv==1.6.0
4044
# via pre-commit
45+
packaging==21.3
46+
# via pytest
4147
pathspec==0.9.0
4248
# via black
4349
pep517==0.12.0
@@ -51,8 +57,12 @@ platformdirs==2.4.0
5157
# black
5258
# pylint
5359
# virtualenv
60+
pluggy==1.0.0
61+
# via pytest
5462
pre-commit==2.16.0
5563
# via -r requirements-dev.in
64+
py==1.11.0
65+
# via pytest
5666
pycodestyle==2.8.0
5767
# via
5868
# autopep8
@@ -61,6 +71,17 @@ pyflakes==2.4.0
6171
# via flake8
6272
pylint==2.12.1
6373
# via -r requirements-dev.in
74+
pyparsing==3.0.6
75+
# via packaging
76+
pytest==6.2.5
77+
# via
78+
# -r requirements-dev.in
79+
# pytest-asyncio
80+
# pytest-mock
81+
pytest-asyncio==0.16.0
82+
# via -r requirements-dev.in
83+
pytest-mock==3.6.1
84+
# via -r requirements-dev.in
6485
pyyaml==6.0
6586
# via pre-commit
6687
regex==2021.11.10
@@ -72,15 +93,13 @@ toml==0.10.2
7293
# autopep8
7394
# pre-commit
7495
# pylint
96+
# pytest
7597
tomli==1.2.2
7698
# via
7799
# black
78100
# pep517
79101
typing-extensions==4.0.1
80-
# via
81-
# astroid
82-
# black
83-
# pylint
102+
# via black
84103
virtualenv==20.10.0
85104
# via pre-commit
86105
wheel==0.37.0

requirements.in

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
fastapi
22
uvicorn
3+
requests
4+
marshmallow
5+
flagsmith-flag-engine
6+
python-decouple
7+
python-dotenv
8+

requirements.txt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile
2+
# This file is autogenerated by pip-compile with python 3.10
33
# To update, run:
44
#
55
# pip-compile requirements.in
@@ -8,21 +8,41 @@ anyio==3.4.0
88
# via starlette
99
asgiref==3.4.1
1010
# via uvicorn
11+
certifi==2021.10.8
12+
# via requests
13+
charset-normalizer==2.0.9
14+
# via requests
1115
click==8.0.3
1216
# via uvicorn
1317
fastapi==0.70.0
1418
# via -r requirements.in
19+
flagsmith-flag-engine==1.5.0
20+
# via -r requirements.in
1521
h11==0.12.0
1622
# via uvicorn
1723
idna==3.3
18-
# via anyio
24+
# via
25+
# anyio
26+
# requests
27+
marshmallow==3.14.1
28+
# via
29+
# -r requirements.in
30+
# flagsmith-flag-engine
1931
pydantic==1.8.2
2032
# via fastapi
33+
python-decouple==3.5
34+
# via -r requirements.in
35+
python-dotenv==0.19.2
36+
# via -r requirements.in
37+
requests==2.26.0
38+
# via -r requirements.in
2139
sniffio==1.2.0
2240
# via anyio
2341
starlette==0.16.0
2442
# via fastapi
2543
typing-extensions==4.0.1
2644
# via pydantic
45+
urllib3==1.26.7
46+
# via requests
2747
uvicorn==0.15.0
2848
# via -r requirements.in

src/__init__.py

Whitespace-only changes.

src/cache.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
3+
import requests
4+
5+
6+
class CacheService:
7+
def __init__(self, api_url: str, api_token: str, api_keys: list):
8+
self.api_url = api_url
9+
self.api_token = api_token
10+
self.api_keys = api_keys
11+
self._session = requests.Session()
12+
self._session.headers.update({"Authorization": f"Token {self.api_token}"})
13+
14+
self._cache = {}
15+
16+
def _fetch_document(self, api_key):
17+
url = f"{self.api_url}/environments/{api_key}/document/"
18+
response = self._session.get(url)
19+
response.raise_for_status()
20+
return response.json()
21+
22+
def refresh(self):
23+
for api_key in self.api_keys:
24+
try:
25+
self._cache[api_key] = self._fetch_document(api_key)
26+
except requests.exceptions.HTTPError:
27+
logging.error(f"received non 200 response for {api_key}")
28+
29+
def get_environment(self, api_key):
30+
return self._cache[api_key]

0 commit comments

Comments
 (0)