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.
- pjdev_armis_sdk/__about__.py +4 -0
- pjdev_armis_sdk/__init__.py +31 -0
- pjdev_armis_sdk/_internal.py +32 -0
- pjdev_armis_sdk/access_token.py +37 -0
- pjdev_armis_sdk/api_utilities.py +110 -0
- pjdev_armis_sdk/boundaries.py +104 -0
- pjdev_armis_sdk/config_service.py +38 -0
- pjdev_armis_sdk/devices.py +156 -0
- pjdev_armis_sdk/http_client.py +112 -0
- pjdev_armis_sdk/integrations.py +197 -0
- pjdev_armis_sdk/models.py +174 -0
- pjdev_armis_sdk/py.typed +0 -0
- pjdev_armis_sdk/search.py +104 -0
- pjdev_armis_sdk/sites.py +217 -0
- pjdev_armis_sdk/users.py +101 -0
- pjdev_armis_sdk-5.1.4.dist-info/METADATA +82 -0
- pjdev_armis_sdk-5.1.4.dist-info/RECORD +19 -0
- pjdev_armis_sdk-5.1.4.dist-info/WHEEL +4 -0
- pjdev_armis_sdk-5.1.4.dist-info/licenses/LICENSE.txt +9 -0
|
@@ -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
|