pjdev-armis-sdk 5.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "5.1.4"
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from pjdev_armis_sdk import (
6
+ access_token,
7
+ api_utilities,
8
+ boundaries,
9
+ config_service,
10
+ devices,
11
+ http_client,
12
+ integrations,
13
+ models,
14
+ search,
15
+ sites,
16
+ users,
17
+ )
18
+
19
+ __all__ = [
20
+ "access_token",
21
+ "api_utilities",
22
+ "boundaries",
23
+ "config_service",
24
+ "devices",
25
+ "http_client",
26
+ "integrations",
27
+ "models",
28
+ "search",
29
+ "sites",
30
+ "users",
31
+ ]
@@ -0,0 +1,32 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import Any, AsyncIterator, Awaitable, Callable, Optional, TypeVar
3
+
4
+ import httpx
5
+
6
+ from pjdev_armis_sdk.http_client import http_client
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ @asynccontextmanager
12
+ async def _resolve_client(
13
+ client: Optional[httpx.AsyncClient],
14
+ ) -> AsyncIterator[httpx.AsyncClient]:
15
+ if client is not None:
16
+ yield client
17
+ return
18
+ async with http_client() as _client:
19
+ yield _client
20
+
21
+
22
+ async def with_client(
23
+ client: Optional[httpx.AsyncClient],
24
+ fn: Callable[[httpx.AsyncClient], Awaitable[T]],
25
+ ) -> T:
26
+ """Run `fn` against either the caller-provided client or a fresh one."""
27
+ async with _resolve_client(client) as c:
28
+ return await fn(c)
29
+
30
+
31
+ def drop_none(params: dict[str, Any]) -> dict[str, Any]:
32
+ return {k: v for k, v in params.items() if v is not None}
@@ -0,0 +1,37 @@
1
+ from typing import Optional
2
+
3
+ import httpx
4
+
5
+ from pjdev_armis_sdk.api_utilities import async_retry_http
6
+ from pjdev_armis_sdk.config_service import get_config
7
+ from pjdev_armis_sdk.models import ArmisAccessTokenResponse, ArmisTokenData
8
+
9
+
10
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403])
11
+ async def get_access_token(
12
+ secret_key: Optional[str] = None,
13
+ ) -> ArmisTokenData:
14
+ """
15
+ Exchange a secret_key for a temporary Armis access token.
16
+
17
+ This is normally handled automatically by `ArmisAccessTokenAuth` on the shared
18
+ `httpx.AsyncClient`. Call this directly only when you need the raw token (e.g.
19
+ for inspection, or to pass to a non-SDK consumer).
20
+ """
21
+ config = get_config()
22
+ key = secret_key or config.secret_key
23
+ if not key:
24
+ raise ValueError("secret_key not provided and not configured")
25
+ if not config.instance_url:
26
+ raise ValueError("instance_url not configured")
27
+
28
+ async with httpx.AsyncClient(
29
+ base_url=config.instance_url,
30
+ timeout=config.request_timeout_seconds,
31
+ ) as client:
32
+ r = await client.post(
33
+ "/api/v1/access_token/",
34
+ data={"secret_key": key},
35
+ )
36
+ r.raise_for_status()
37
+ return ArmisAccessTokenResponse.model_validate(r.json()).data
@@ -0,0 +1,110 @@
1
+ import asyncio
2
+ import time
3
+ from functools import wraps
4
+ from typing import Any, Awaitable, Callable, List, Optional, ParamSpec, TypeVar
5
+
6
+ import httpx
7
+ from httpx import ConnectError, HTTPStatusError
8
+ from loguru import logger
9
+
10
+ from pjdev_armis_sdk.config_service import get_config
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ async def log_request_headers(request: httpx.Request) -> None:
17
+ logger.debug(f"Request: {request.method} {request.url}")
18
+
19
+
20
+ async def log_response_headers(response: httpx.Response) -> None:
21
+ logger.debug(
22
+ f"Response: {response.request.method} {response.request.url} -> {response.status_code}"
23
+ )
24
+
25
+
26
+ def async_retry_http(
27
+ default_value: Optional[Any] = None,
28
+ status_codes_to_ignore: Optional[List[int]] = None,
29
+ ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
30
+ def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
31
+ @wraps(func)
32
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
33
+ config = get_config()
34
+ max_attempts = config.http_retry_max_count
35
+ delay_seconds = config.http_retry_delay_seconds
36
+ attempts = 0
37
+ exceptions: List[Exception] = []
38
+
39
+ while attempts < max_attempts:
40
+ try:
41
+ return await func(*args, **kwargs)
42
+ except HTTPStatusError as e:
43
+ logger.warning(
44
+ f"{e.response.status_code}: {e.response.reason_phrase} - {e.response.text}"
45
+ )
46
+ exceptions.append(e)
47
+ if (
48
+ status_codes_to_ignore
49
+ and e.response.status_code in status_codes_to_ignore
50
+ ):
51
+ break
52
+ except ConnectError as e:
53
+ logger.warning(f"{e.request.url} not reachable")
54
+ exceptions.append(e)
55
+
56
+ attempts += 1
57
+ if attempts == max_attempts:
58
+ break
59
+ total_delay = delay_seconds**attempts
60
+ logger.warning(
61
+ f"Attempt {attempts}/{max_attempts} failed. Retrying in {total_delay} seconds..."
62
+ )
63
+ await asyncio.sleep(total_delay)
64
+
65
+ if default_value is None:
66
+ raise ExceptionGroup(
67
+ f"Failed after {max_attempts} attempts", exceptions
68
+ )
69
+ logger.error(f"Failed after {max_attempts} attempts")
70
+ return default_value
71
+
72
+ return wrapper
73
+
74
+ return decorator
75
+
76
+
77
+ def record_time(
78
+ log: bool = False,
79
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
80
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
81
+ if asyncio.iscoroutinefunction(func):
82
+
83
+ @wraps(func)
84
+ async def wrap_func_async(*args: P.args, **kwargs: P.kwargs) -> Any:
85
+ t1 = time.time()
86
+ result = await func(*args, **kwargs)
87
+ t2 = time.time()
88
+ if log:
89
+ logger.info(
90
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
91
+ )
92
+ return result
93
+
94
+ return wrap_func_async # type: ignore[return-value]
95
+
96
+ @wraps(func)
97
+ def wrap_func(*args: P.args, **kwargs: P.kwargs) -> R:
98
+ t1 = time.time()
99
+ result = func(*args, **kwargs)
100
+ t2 = time.time()
101
+ if log:
102
+ logger.info(
103
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
104
+ )
105
+
106
+ return result
107
+
108
+ return wrap_func
109
+
110
+ return decorator
@@ -0,0 +1,104 @@
1
+ from typing import Any, Dict, Optional, Sequence
2
+
3
+ import httpx
4
+
5
+ from pjdev_armis_sdk._internal import drop_none, with_client
6
+ from pjdev_armis_sdk.api_utilities import async_retry_http
7
+ from pjdev_armis_sdk.models import Boundary
8
+
9
+
10
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403])
11
+ async def list_boundaries(
12
+ paging_from: int = 0,
13
+ paging_length: Optional[int] = None,
14
+ affected_sites: Optional[Sequence[str]] = None,
15
+ boundary_ids: Optional[Sequence[int]] = None,
16
+ include_total: bool = False,
17
+ fields: Optional[Sequence[str]] = None,
18
+ client: Optional[httpx.AsyncClient] = None,
19
+ ) -> Dict[str, Any]:
20
+ """GET /api/v1/boundaries/"""
21
+ params: Dict[str, Any] = drop_none(
22
+ {
23
+ "from": paging_from,
24
+ "length": paging_length,
25
+ "affectedSites": ",".join(affected_sites) if affected_sites else None,
26
+ "boundaryIds": ",".join(str(b) for b in boundary_ids)
27
+ if boundary_ids
28
+ else None,
29
+ "includeTotal": include_total,
30
+ "fields": ",".join(fields) if fields else None,
31
+ }
32
+ )
33
+
34
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
35
+ r = await c.get("/api/v1/boundaries/", params=params)
36
+ r.raise_for_status()
37
+ return r.json()
38
+
39
+ return await with_client(client, _exec)
40
+
41
+
42
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
43
+ async def get_boundary(
44
+ boundary_id: int,
45
+ fields: Optional[Sequence[str]] = None,
46
+ client: Optional[httpx.AsyncClient] = None,
47
+ ) -> Dict[str, Any]:
48
+ """GET /api/v1/boundaries/{boundary_id}/"""
49
+ params: Dict[str, Any] = drop_none(
50
+ {"fields": ",".join(fields) if fields else None}
51
+ )
52
+
53
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
54
+ r = await c.get(f"/api/v1/boundaries/{boundary_id}/", params=params)
55
+ r.raise_for_status()
56
+ return r.json()
57
+
58
+ return await with_client(client, _exec)
59
+
60
+
61
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403])
62
+ async def create_boundary(
63
+ boundary: Boundary,
64
+ client: Optional[httpx.AsyncClient] = None,
65
+ ) -> Dict[str, Any]:
66
+ """POST /api/v1/boundaries/"""
67
+ payload = boundary.model_dump(by_alias=True, exclude_none=True, exclude={"id"})
68
+
69
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
70
+ r = await c.post("/api/v1/boundaries/", json=payload)
71
+ r.raise_for_status()
72
+ return r.json()
73
+
74
+ return await with_client(client, _exec)
75
+
76
+
77
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
78
+ async def update_boundary(
79
+ boundary_id: int,
80
+ payload: Dict[str, Any],
81
+ client: Optional[httpx.AsyncClient] = None,
82
+ ) -> Dict[str, Any]:
83
+ """PATCH /api/v1/boundaries/{boundary_id}/"""
84
+
85
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
86
+ r = await c.patch(f"/api/v1/boundaries/{boundary_id}/", json=payload)
87
+ r.raise_for_status()
88
+ return r.json()
89
+
90
+ return await with_client(client, _exec)
91
+
92
+
93
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
94
+ async def delete_boundary(
95
+ boundary_id: int,
96
+ client: Optional[httpx.AsyncClient] = None,
97
+ ) -> None:
98
+ """DELETE /api/v1/boundaries/{boundary_id}/"""
99
+
100
+ async def _exec(c: httpx.AsyncClient) -> None:
101
+ r = await c.delete(f"/api/v1/boundaries/{boundary_id}/")
102
+ r.raise_for_status()
103
+
104
+ await with_client(client, _exec)
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional
3
+
4
+ from pjdev_armis_sdk.models import Config
5
+
6
+ __ctx: Dict[str, Config] = {}
7
+
8
+
9
+ def get_config() -> Config:
10
+ if "config" not in __ctx:
11
+ __ctx["config"] = Config()
12
+ return __ctx["config"]
13
+
14
+
15
+ def init(
16
+ env_path: Optional[Path] = None,
17
+ instance_url: Optional[str] = None,
18
+ secret_key: Optional[str] = None,
19
+ http_retry_max_count: Optional[int] = None,
20
+ http_retry_delay_seconds: Optional[int] = None,
21
+ request_timeout_seconds: Optional[float] = None,
22
+ ) -> None:
23
+ if env_path is not None:
24
+ Config.model_config["env_file"] = env_path
25
+
26
+ kwargs: Dict[str, Any] = {}
27
+ if instance_url is not None:
28
+ kwargs["instance_url"] = instance_url
29
+ if secret_key is not None:
30
+ kwargs["secret_key"] = secret_key
31
+ if http_retry_max_count is not None:
32
+ kwargs["http_retry_max_count"] = http_retry_max_count
33
+ if http_retry_delay_seconds is not None:
34
+ kwargs["http_retry_delay_seconds"] = http_retry_delay_seconds
35
+ if request_timeout_seconds is not None:
36
+ kwargs["request_timeout_seconds"] = request_timeout_seconds
37
+
38
+ __ctx["config"] = Config(**kwargs)
@@ -0,0 +1,156 @@
1
+ from typing import Any, Dict, List, Optional, Sequence
2
+
3
+ import httpx
4
+
5
+ from pjdev_armis_sdk._internal import drop_none, with_client
6
+ from pjdev_armis_sdk.api_utilities import async_retry_http
7
+ from pjdev_armis_sdk.models import Device, DeviceUpsert
8
+
9
+
10
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
11
+ async def get_device(
12
+ id: Optional[int] = None,
13
+ ip: Optional[str] = None,
14
+ mac: Optional[str] = None,
15
+ search: Optional[str] = None,
16
+ tag: Optional[str] = None,
17
+ fields: Optional[Sequence[str]] = None,
18
+ include_total: bool = True,
19
+ include_network_interfaces: bool = False,
20
+ paging_from: int = 0,
21
+ paging_length: Optional[int] = None,
22
+ client: Optional[httpx.AsyncClient] = None,
23
+ ) -> Dict[str, Any]:
24
+ """
25
+ GET /api/v1/devices/ — at least one of `id`, `ip`, `mac`, `search`, or `tag` is required.
26
+ Returns the raw envelope as a dict (`{success, data: {count, total, results, ...}}`).
27
+ """
28
+ if not any([id, ip, mac, search, tag]):
29
+ raise ValueError("Must provide one of: id, ip, mac, search, tag")
30
+
31
+ params: Dict[str, Any] = drop_none(
32
+ {
33
+ "id": id,
34
+ "ip": ip,
35
+ "mac": mac,
36
+ "search": search,
37
+ "tag": tag,
38
+ "fields": ",".join(fields) if fields else None,
39
+ "includeTotal": include_total,
40
+ "includeNetworkInterfaces": include_network_interfaces,
41
+ "from": paging_from,
42
+ "length": paging_length,
43
+ }
44
+ )
45
+
46
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
47
+ r = await c.get("/api/v1/devices/", params=params)
48
+ r.raise_for_status()
49
+ return r.json()
50
+
51
+ return await with_client(client, _exec)
52
+
53
+
54
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
55
+ async def patch_device(
56
+ device_id: int,
57
+ payload: Dict[str, Any],
58
+ client: Optional[httpx.AsyncClient] = None,
59
+ ) -> Dict[str, Any]:
60
+ """PATCH /api/v1/devices/{device_id}/"""
61
+
62
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
63
+ r = await c.patch(f"/api/v1/devices/{device_id}/", json=payload)
64
+ r.raise_for_status()
65
+ return r.json()
66
+
67
+ return await with_client(client, _exec)
68
+
69
+
70
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
71
+ async def delete_device(
72
+ device_id: int,
73
+ client: Optional[httpx.AsyncClient] = None,
74
+ ) -> None:
75
+ """DELETE /api/v1/devices/{device_id}/"""
76
+
77
+ async def _exec(c: httpx.AsyncClient) -> None:
78
+ r = await c.delete(f"/api/v1/devices/{device_id}/")
79
+ r.raise_for_status()
80
+
81
+ await with_client(client, _exec)
82
+
83
+
84
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403])
85
+ async def bulk_upsert_devices(
86
+ operations: List[Dict[str, Any]],
87
+ client: Optional[httpx.AsyncClient] = None,
88
+ ) -> Dict[str, Any]:
89
+ """
90
+ POST /api/v1/devices/_bulk/
91
+
92
+ Each operation is `{"upsert": Device}` or `{"delete": {"deviceId": int}}`.
93
+ Returns the multi-status envelope as a dict.
94
+ """
95
+
96
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
97
+ r = await c.post("/api/v1/devices/_bulk/", json=operations)
98
+ r.raise_for_status()
99
+ return r.json()
100
+
101
+ return await with_client(client, _exec)
102
+
103
+
104
+ def upsert_op(device: DeviceUpsert | Dict[str, Any]) -> Dict[str, Any]:
105
+ """Build a `{"upsert": ...}` entry for `bulk_upsert_devices`."""
106
+ if isinstance(device, DeviceUpsert):
107
+ return {"upsert": device.model_dump(by_alias=True, exclude_none=True)}
108
+ return {"upsert": device}
109
+
110
+
111
+ def delete_op(device_id: int) -> Dict[str, Any]:
112
+ """Build a `{"delete": ...}` entry for `bulk_upsert_devices`."""
113
+ return {"delete": {"deviceId": device_id}}
114
+
115
+
116
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
117
+ async def add_device_tags(
118
+ device_id: int,
119
+ tags: Sequence[str],
120
+ client: Optional[httpx.AsyncClient] = None,
121
+ ) -> Dict[str, Any]:
122
+ """POST /api/v1/devices/{device_id}/tags/"""
123
+
124
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
125
+ r = await c.post(
126
+ f"/api/v1/devices/{device_id}/tags/", json={"tags": list(tags)}
127
+ )
128
+ r.raise_for_status()
129
+ return r.json()
130
+
131
+ return await with_client(client, _exec)
132
+
133
+
134
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403, 404])
135
+ async def remove_device_tags(
136
+ device_id: int,
137
+ tags: Sequence[str],
138
+ client: Optional[httpx.AsyncClient] = None,
139
+ ) -> Dict[str, Any]:
140
+ """DELETE /api/v1/devices/{device_id}/tags/"""
141
+
142
+ async def _exec(c: httpx.AsyncClient) -> Dict[str, Any]:
143
+ r = await c.request(
144
+ "DELETE",
145
+ f"/api/v1/devices/{device_id}/tags/",
146
+ json={"tags": list(tags)},
147
+ )
148
+ r.raise_for_status()
149
+ return r.json()
150
+
151
+ return await with_client(client, _exec)
152
+
153
+
154
+ def parse_device(raw: Dict[str, Any]) -> Device:
155
+ """Helper: turn one raw `results[]` item into a typed `Device`."""
156
+ return Device.model_validate(raw)
@@ -0,0 +1,112 @@
1
+ from contextlib import asynccontextmanager
2
+ from datetime import datetime, timedelta, timezone
3
+ from typing import AsyncIterator, Generator, Optional
4
+
5
+ import httpx
6
+ from loguru import logger
7
+
8
+ from pjdev_armis_sdk.api_utilities import log_request_headers, log_response_headers
9
+ from pjdev_armis_sdk.config_service import get_config
10
+ from pjdev_armis_sdk.models import ArmisAccessTokenResponse, ArmisTokenData
11
+
12
+
13
+ class ArmisAuthError(Exception):
14
+ """Raised when token acquisition fails."""
15
+
16
+
17
+ class ArmisAccessTokenAuth(httpx.Auth):
18
+ """
19
+ httpx auth flow that maintains a cached Armis access token.
20
+
21
+ - Fetches a new token from `/api/v1/access_token/` when no token is cached,
22
+ or when the cached token is within `refresh_leeway` of expiry.
23
+ - On a 401 response, clears the cache, fetches a new token, and retries once.
24
+ """
25
+
26
+ requires_response_body = True
27
+
28
+ def __init__(
29
+ self,
30
+ secret_key: str,
31
+ refresh_leeway: timedelta = timedelta(minutes=1),
32
+ ) -> None:
33
+ self._secret_key = secret_key
34
+ self._refresh_leeway = refresh_leeway
35
+ self._token: Optional[ArmisTokenData] = None
36
+
37
+ def _token_is_fresh(self) -> bool:
38
+ if self._token is None:
39
+ return False
40
+ expiry = self._token.expiration_utc
41
+ if expiry.tzinfo is None:
42
+ expiry = expiry.replace(tzinfo=timezone.utc)
43
+ return expiry - self._refresh_leeway > datetime.now(timezone.utc)
44
+
45
+ def _build_token_request(self, base_url: httpx.URL) -> httpx.Request:
46
+ return httpx.Request(
47
+ "POST",
48
+ base_url.join("/api/v1/access_token/"),
49
+ data={"secret_key": self._secret_key},
50
+ )
51
+
52
+ def _apply_token(self, request: httpx.Request) -> None:
53
+ if self._token is None:
54
+ raise ArmisAuthError("No access token available")
55
+ request.headers["Authorization"] = self._token.access_token
56
+
57
+ def _parse_token_response(self, response: httpx.Response) -> None:
58
+ if response.status_code >= 400:
59
+ raise ArmisAuthError(
60
+ f"Failed to acquire Armis access token: "
61
+ f"{response.status_code} {response.reason_phrase} - {response.text}"
62
+ )
63
+ parsed = ArmisAccessTokenResponse.model_validate(response.json())
64
+ self._token = parsed.data
65
+ logger.debug(
66
+ f"Acquired Armis access token (expires {parsed.data.expiration_utc.isoformat()})"
67
+ )
68
+
69
+ def auth_flow(
70
+ self, request: httpx.Request
71
+ ) -> Generator[httpx.Request, httpx.Response, None]:
72
+ if not self._token_is_fresh():
73
+ token_response = yield self._build_token_request(request.url)
74
+ self._parse_token_response(token_response)
75
+
76
+ self._apply_token(request)
77
+ response = yield request
78
+
79
+ if response.status_code == 401:
80
+ self._token = None
81
+ token_response = yield self._build_token_request(request.url)
82
+ self._parse_token_response(token_response)
83
+ self._apply_token(request)
84
+ yield request
85
+
86
+
87
+ def get_http_client() -> httpx.AsyncClient:
88
+ config = get_config()
89
+ if not config.instance_url:
90
+ raise ValueError(
91
+ "Armis instance_url not configured; call config_service.init(instance_url=..., secret_key=...) first"
92
+ )
93
+ if not config.secret_key:
94
+ raise ValueError(
95
+ "Armis secret_key not configured; call config_service.init(instance_url=..., secret_key=...) first"
96
+ )
97
+
98
+ return httpx.AsyncClient(
99
+ base_url=config.instance_url,
100
+ auth=ArmisAccessTokenAuth(secret_key=config.secret_key),
101
+ timeout=config.request_timeout_seconds,
102
+ event_hooks={
103
+ "response": [log_response_headers],
104
+ "request": [log_request_headers],
105
+ },
106
+ )
107
+
108
+
109
+ @asynccontextmanager
110
+ async def http_client() -> AsyncIterator[httpx.AsyncClient]:
111
+ async with get_http_client() as client:
112
+ yield client