Skip to content

Commit 2b00a3a

Browse files
committed
adding AsyncFoxopsClient
1 parent 98dcc06 commit 2b00a3a

File tree

7 files changed

+149
-50
lines changed

7 files changed

+149
-50
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ pip install foxops-client
1111
## Usage
1212

1313
```python
14-
from foxops_client import FoxOpsClient
14+
from foxops_client import FoxopsClient, AsyncFoxopsClient
1515

16-
client = FoxOpsClient("http://localhost:8080", "my-token")
17-
client.list_incarnations()
16+
client = FoxopsClient("http://localhost:8080", "my-token")
17+
incarnations = client.list_incarnations()
18+
19+
# or alternatively, the async version
20+
client = AsyncFoxopsClient("http://localhost:8080", "my-token")
21+
incarnations = await client.list_incarnations()
1822
```

src/foxops_client/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
from importlib.metadata import version
22

3-
from .client import FoxOpsClient
3+
from foxops_client.client_async import AsyncFoxopsClient
4+
from foxops_client.client_sync import FoxopsClient
5+
from foxops_client.exceptions import (
6+
AuthenticationError,
7+
FoxopsApiError,
8+
IncarnationDoesNotExistError,
9+
)
10+
from foxops_client.types import Incarnation, IncarnationWithDetails, MergeRequestStatus
411

512
__version__ = version("foxops_client")
613

7-
__all__ = ["FoxOpsClient"]
14+
__all__ = [
15+
"FoxopsClient",
16+
"AsyncFoxopsClient",
17+
# Types
18+
"Incarnation",
19+
"IncarnationWithDetails",
20+
"MergeRequestStatus",
21+
# Exceptions
22+
"FoxopsApiError",
23+
"AuthenticationError",
24+
"IncarnationDoesNotExistError",
25+
]
Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,16 @@
44
import httpx
55
from httpx import Response
66

7+
from foxops_client.exceptions import (
8+
AuthenticationError,
9+
FoxopsApiError,
10+
IncarnationDoesNotExistError,
11+
)
712
from foxops_client.retries import default_retry
813
from foxops_client.types import Incarnation, IncarnationWithDetails
914

1015

11-
class AuthenticationError(Exception):
12-
pass
13-
14-
15-
class FoxOpsApiError(Exception):
16-
def __init__(self, message: str):
17-
super().__init__()
18-
self.message = message
19-
20-
21-
class IncarnationDoesNotExistError(FoxOpsApiError):
22-
pass
23-
24-
25-
class FoxOpsClient:
16+
class AsyncFoxopsClient:
2617
"""
2718
This class can be used to call the FoxOps API.
2819
@@ -34,10 +25,9 @@ class FoxOpsClient:
3425

3526
def __init__(self, base_url: str, token: str):
3627
self.retry_function = default_retry()
37-
3828
self.log: logging.Logger = logging.getLogger(self.__class__.__name__)
3929

40-
self.client = httpx.Client(
30+
self.client = httpx.AsyncClient(
4131
base_url=base_url,
4232
headers={"Authorization": f"Bearer {token}"},
4333
verify=True,
@@ -55,16 +45,16 @@ def _handle_unexpected_response(self, resp: Response):
5545

5646
resp.raise_for_status()
5747

58-
def verify_token(self):
59-
response = self.retry_function(self.client.get)("/auth/test")
48+
async def verify_token(self):
49+
resp = await self.retry_function(self.client.get)("/auth/test")
6050

61-
if response.status_code == httpx.codes.OK:
51+
if resp.status_code == httpx.codes.OK:
6252
return
6353

64-
self._handle_unexpected_response(response)
54+
self._handle_unexpected_response(resp)
6555
raise ValueError("unexpected response")
6656

67-
def list_incarnations(
57+
async def list_incarnations(
6858
self, incarnation_repository: str | None = None, target_directory: str | None = None
6959
) -> list[Incarnation]:
7060
params = {}
@@ -73,7 +63,7 @@ def list_incarnations(
7363
if target_directory is not None:
7464
params["target_directory"] = target_directory
7565

76-
resp = self.retry_function(self.client.get)("/api/incarnations", params=params)
66+
resp = await self.retry_function(self.client.get)("/api/incarnations", params=params)
7767

7868
match resp.status_code:
7969
case httpx.codes.OK:
@@ -84,8 +74,8 @@ def list_incarnations(
8474
self._handle_unexpected_response(resp)
8575
raise ValueError("unexpected response")
8676

87-
def get_incarnation(self, incarnation_id: int) -> IncarnationWithDetails:
88-
resp = self.retry_function(self.client.get)(f"/api/incarnations/{incarnation_id}")
77+
async def get_incarnation(self, incarnation_id: int) -> IncarnationWithDetails:
78+
resp = await self.retry_function(self.client.get)(f"/api/incarnations/{incarnation_id}")
8979

9080
match resp.status_code:
9181
case httpx.codes.OK:
@@ -96,8 +86,8 @@ def get_incarnation(self, incarnation_id: int) -> IncarnationWithDetails:
9686
self._handle_unexpected_response(resp)
9787
raise ValueError("unexpected response")
9888

99-
def delete_incarnation(self, incarnation_id: int):
100-
resp = self.retry_function(self.client.delete)(f"/api/incarnations/{incarnation_id}")
89+
async def delete_incarnation(self, incarnation_id: int):
90+
resp = await self.retry_function(self.client.delete)(f"/api/incarnations/{incarnation_id}")
10191

10292
match resp.status_code:
10393
case httpx.codes.NO_CONTENT:
@@ -108,7 +98,7 @@ def delete_incarnation(self, incarnation_id: int):
10898
self._handle_unexpected_response(resp)
10999
raise ValueError("unexpected response")
110100

111-
def update_incarnation(
101+
async def update_incarnation(
112102
self,
113103
incarnation_id: int,
114104
automerge: bool,
@@ -123,7 +113,7 @@ def update_incarnation(
123113
if template_data is not None:
124114
data["template_data"] = template_data
125115

126-
resp = self.retry_function(self.client.put)(f"/api/incarnations/{incarnation_id}", json=data)
116+
resp = await self.retry_function(self.client.put)(f"/api/incarnations/{incarnation_id}", json=data)
127117

128118
match resp.status_code:
129119
case httpx.codes.OK:
@@ -132,12 +122,12 @@ def update_incarnation(
132122
raise IncarnationDoesNotExistError(resp.json()["message"])
133123
case httpx.codes.BAD_REQUEST | httpx.codes.CONFLICT:
134124
self.log.error(f"received error from FoxOps API: {resp.status_code} {resp.headers} {resp.text}")
135-
raise FoxOpsApiError(resp.json()["message"])
125+
raise FoxopsApiError(resp.json()["message"])
136126

137127
self._handle_unexpected_response(resp)
138128
raise ValueError("unexpected response")
139129

140-
def create_incarnation(
130+
async def create_incarnation(
141131
self,
142132
incarnation_repository: str,
143133
template_repository: str,
@@ -169,7 +159,7 @@ def create_incarnation(
169159
if allow_import is not None:
170160
params["allow_import"] = allow_import
171161

172-
resp = self.retry_function(self.client.post)("/api/incarnations", params=params, json=data)
162+
resp = await self.retry_function(self.client.post)("/api/incarnations", params=params, json=data)
173163

174164
match resp.status_code:
175165
case httpx.codes.OK:
@@ -178,7 +168,7 @@ def create_incarnation(
178168
return False, IncarnationWithDetails(**resp.json())
179169
case httpx.codes.BAD_REQUEST:
180170
self.log.error(f"received error from FoxOps API: {resp.status_code} {resp.headers} {resp.text}")
181-
raise FoxOpsApiError(resp.json()["message"])
171+
raise FoxopsApiError(resp.json()["message"])
182172

183173
self._handle_unexpected_response(resp)
184174
raise ValueError("unexpected response")

src/foxops_client/client_sync.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import asyncio
2+
from typing import Tuple
3+
4+
from foxops_client.client_async import AsyncFoxopsClient
5+
from foxops_client.types import Incarnation, IncarnationWithDetails
6+
7+
8+
class FoxopsClient:
9+
"""
10+
This class can be used to call the FoxOps API.
11+
12+
It only exposes the API as-is, meaning it only takes care of doing the HTTP requests with retries,
13+
error handling and type conversions. Methods map to API endpoints 1:1.
14+
15+
It does not contain any business logic.
16+
17+
This synchronous version of the foxops client is merely a thin wrapper around the async version.
18+
"""
19+
20+
def __init__(self, base_url: str, token: str):
21+
self.client = AsyncFoxopsClient(base_url, token)
22+
23+
self.loop = asyncio.new_event_loop()
24+
25+
def verify_token(self):
26+
return self.loop.run_until_complete(self.client.verify_token())
27+
28+
def list_incarnations(
29+
self, incarnation_repository: str | None = None, target_directory: str | None = None
30+
) -> list[Incarnation]:
31+
return self.loop.run_until_complete(self.client.list_incarnations(incarnation_repository, target_directory))
32+
33+
def get_incarnation(self, incarnation_id: int) -> IncarnationWithDetails:
34+
return self.loop.run_until_complete(self.client.get_incarnation(incarnation_id))
35+
36+
def delete_incarnation(self, incarnation_id: int):
37+
return self.loop.run_until_complete(self.client.delete_incarnation(incarnation_id))
38+
39+
def update_incarnation(
40+
self,
41+
incarnation_id: int,
42+
automerge: bool,
43+
template_repository_version: str | None = None,
44+
template_data: dict[str, str] | None = None,
45+
) -> IncarnationWithDetails:
46+
return self.loop.run_until_complete(
47+
self.client.update_incarnation(
48+
incarnation_id,
49+
automerge,
50+
template_repository_version=template_repository_version,
51+
template_data=template_data,
52+
)
53+
)
54+
55+
def create_incarnation(
56+
self,
57+
incarnation_repository: str,
58+
template_repository: str,
59+
template_repository_version: str,
60+
template_data: dict[str, str],
61+
target_directory: str | None = None,
62+
automerge: bool | None = None,
63+
allow_import: bool | None = None,
64+
) -> Tuple[bool, IncarnationWithDetails]:
65+
return self.loop.run_until_complete(
66+
self.client.create_incarnation(
67+
incarnation_repository,
68+
template_repository,
69+
template_repository_version,
70+
template_data,
71+
target_directory=target_directory,
72+
automerge=automerge,
73+
allow_import=allow_import,
74+
)
75+
)

src/foxops_client/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class FoxopsApiError(Exception):
2+
def __init__(self, message: str):
3+
super().__init__()
4+
self.message = message
5+
6+
7+
class AuthenticationError(Exception):
8+
pass
9+
10+
11+
class IncarnationDoesNotExistError(FoxopsApiError):
12+
pass

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from infrastructure.network import network
2727
from pytest import fixture
2828

29-
from foxops_client.client import FoxOpsClient
29+
from foxops_client import FoxopsClient
3030

3131
# This variable is never used. We just declare it to mark the imported fixtures as used for linting.
3232
IMPORTED_FIXTURES = [
@@ -54,12 +54,12 @@
5454

5555
@fixture
5656
def foxops_client(foxops_host_url):
57-
return FoxOpsClient(foxops_host_url, FOXOPS_STATIC_TOKEN)
57+
return FoxopsClient(foxops_host_url, FOXOPS_STATIC_TOKEN)
5858

5959

6060
@fixture
6161
def foxops_client_invalid_token(foxops_host_url):
62-
return FoxOpsClient(foxops_host_url, FOXOPS_STATIC_TOKEN + "invalid")
62+
return FoxopsClient(foxops_host_url, FOXOPS_STATIC_TOKEN + "invalid")
6363

6464

6565
@fixture

tests/test_client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import pytest
22
from pytest import fixture
33

4-
from foxops_client.client import (
4+
from foxops_client import (
55
AuthenticationError,
6-
FoxOpsApiError,
7-
FoxOpsClient,
6+
FoxopsApiError,
7+
FoxopsClient,
88
IncarnationDoesNotExistError,
9+
MergeRequestStatus,
910
)
10-
from foxops_client.types import MergeRequestStatus
1111

1212

13-
def test_verify_token(foxops_client: FoxOpsClient):
13+
def test_verify_token(foxops_client: FoxopsClient):
1414
foxops_client.verify_token()
1515

1616

@@ -19,7 +19,7 @@ def test_verify_token_raises_unauthenticated_exception_when_using_an_invalid_tok
1919
foxops_client_invalid_token.verify_token()
2020

2121

22-
def test_create_incarnation(gitlab_api_client, foxops_client: FoxOpsClient, template, gitlab_project_factory):
22+
def test_create_incarnation(gitlab_api_client, foxops_client: FoxopsClient, template, gitlab_project_factory):
2323
# GIVEN
2424
template_path = template
2525
incarnation_path = gitlab_project_factory(return_path=True)
@@ -42,7 +42,7 @@ def test_create_incarnation(gitlab_api_client, foxops_client: FoxOpsClient, temp
4242
assert incarnation.template_data == {"input_variable": "foo"}
4343

4444

45-
def test_create_incarnation_import_with_existing_incarnation(incarnation, foxops_client: FoxOpsClient):
45+
def test_create_incarnation_import_with_existing_incarnation(incarnation, foxops_client: FoxopsClient):
4646
# GIVEN
4747
foxops_client.delete_incarnation(incarnation.id)
4848

@@ -66,7 +66,7 @@ def test_create_incarnation_import_with_existing_incarnation(incarnation, foxops
6666

6767
def test_create_incarnation_with_conflicting_existing_incarnation(incarnation, foxops_client):
6868
# WHEN
69-
with pytest.raises(FoxOpsApiError) as e:
69+
with pytest.raises(FoxopsApiError) as e:
7070
foxops_client.create_incarnation(
7171
incarnation_repository=incarnation.incarnation_repository,
7272
template_repository=incarnation.template_repository,
@@ -78,7 +78,7 @@ def test_create_incarnation_with_conflicting_existing_incarnation(incarnation, f
7878
assert e.value.message.find("already initialized") != -1
7979

8080

81-
def test_delete_incarnation(incarnation, foxops_client: FoxOpsClient):
81+
def test_delete_incarnation(incarnation, foxops_client: FoxopsClient):
8282
# WHEN
8383
foxops_client.delete_incarnation(incarnation.id)
8484

0 commit comments

Comments
 (0)