Skip to content

Commit 61121ad

Browse files
author
GitHub Actions
committed
Auto commit from main repo: manual-sync
1 parent 535786e commit 61121ad

5 files changed

Lines changed: 304 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ Once you have an instance of `ArmisSdk`, you can start interacting with the vari
6666
6767
> [!NOTE]
6868
> Note that all functions in this SDK that eventually make HTTP requests are asynchronous.
69-
>
70-
> However, for convenience, all public asynchronous functions can also be executed in a synchronous way.
69+
>
70+
> However, for convenience, all public asynchronous functions can also be executed in a synchronous way.
7171
7272
For example, if you want to update a site's location:
7373
```python

armis_sdk/clients/assets_client.py

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ async def list_by_asset_id(
5353
Assets of the specified class matching the provided identifiers.
5454
5555
Example:
56-
```python linenums="1" hl_lines="13 17"
56+
```python linenums="1" hl_lines="14 18"
5757
import asyncio
5858
5959
from armis_sdk.clients.assets_client import AssetsClient
@@ -78,6 +78,8 @@ async def main():
7878
asyncio.run(main())
7979
```
8080
"""
81+
if not asset_ids:
82+
raise ArmisError("asset_ids must not be empty")
8183
filter_ = {
8284
"filter_criteria": "ASSET_ID",
8385
"asset_ids": asset_ids,
@@ -86,6 +88,44 @@ async def main():
8688
async for item in self._list_assets(asset_class, fields, filter_):
8789
yield item
8890

91+
async def list_by_boundary_id(
92+
self,
93+
asset_class: type[AssetT],
94+
boundary_ids: list[int],
95+
fields: list[str] | None = None,
96+
) -> AsyncIterator[AssetT]:
97+
"""List assets by boundary ID.
98+
99+
Args:
100+
asset_class: The asset class to list. Must inherit from [Asset][armis_sdk.entities.asset.Asset].
101+
boundary_ids: A list of boundary IDs to filter by.
102+
fields: Optional list of fields to retrieve. If None, all non-custom fields are retrieved.
103+
104+
Yields:
105+
Assets of the specified class belonging to any of the provided boundaries.
106+
107+
Example:
108+
```python linenums="1" hl_lines="10"
109+
import asyncio
110+
111+
from armis_sdk.clients.assets_client import AssetsClient
112+
from armis_sdk.entities.device import Device
113+
114+
115+
async def main():
116+
assets_client = AssetsClient()
117+
118+
async for device in assets_client.list_by_boundary_id(Device, [1, 2, 3]):
119+
print(device)
120+
121+
122+
asyncio.run(main())
123+
```
124+
"""
125+
filter_ = self._build_boundary_id_filter(boundary_ids)
126+
async for item in self._list_assets(asset_class, fields, filter_):
127+
yield item
128+
89129
async def list_by_last_seen(
90130
self,
91131
asset_class: type[AssetT],
@@ -106,7 +146,7 @@ async def list_by_last_seen(
106146
ArmisError: If last_seen is neither datetime nor timedelta.
107147
108148
Example:
109-
```python linenums="1" hl_lines="11 15"
149+
```python linenums="1" hl_lines="12 16"
110150
import asyncio
111151
import datetime
112152
@@ -129,15 +169,115 @@ async def main():
129169
asyncio.run(main())
130170
```
131171
"""
132-
filter_: dict[str, str | int] = {"filter_criteria": "LAST_SEEN"}
172+
filter_ = self._build_last_seen_filter(last_seen)
173+
async for item in self._list_assets(asset_class, fields, filter_):
174+
yield item
133175

134-
if isinstance(last_seen, datetime.datetime):
135-
filter_["last_seen_ge"] = last_seen.isoformat()
136-
elif isinstance(last_seen, datetime.timedelta):
137-
filter_["last_seen_seconds"] = int(last_seen.total_seconds())
138-
else:
139-
raise ArmisError(f"Invalid 'last_seen' type {type(last_seen)}")
176+
async def list_by_multiple(
177+
self,
178+
asset_class: type[AssetT],
179+
last_seen: datetime.datetime | datetime.timedelta | None = None,
180+
site_ids: list[int] | None = None,
181+
boundary_ids: list[int] | None = None,
182+
fields: list[str] | None = None,
183+
) -> AsyncIterator[AssetT]:
184+
"""List assets matching multiple filter criteria simultaneously (AND logic).
185+
186+
At least one of `last_seen`, `site_ids`, or `boundary_ids` must be provided.
187+
Each criterion that is provided is applied as an AND condition.
140188
189+
Args:
190+
asset_class: The asset class to list. Must inherit from [Asset][armis_sdk.entities.asset.Asset].
191+
last_seen: Either a datetime (assets seen on or after this time) or timedelta (assets seen within this duration).
192+
site_ids: A list of site IDs to filter by.
193+
boundary_ids: A list of boundary IDs to filter by.
194+
fields: Optional list of fields to retrieve. If None, all non-custom fields are retrieved.
195+
196+
Yields:
197+
Assets of the specified class matching all provided criteria.
198+
199+
Raises:
200+
ArmisError: If no filter criteria are provided, or if last_seen is an invalid type.
201+
202+
Example:
203+
```python linenums="1" hl_lines="11-15"
204+
import asyncio
205+
import datetime
206+
207+
from armis_sdk.clients.assets_client import AssetsClient
208+
from armis_sdk.entities.device import Device
209+
210+
211+
async def main():
212+
assets_client = AssetsClient()
213+
214+
async for device in assets_client.list_by_multiple(
215+
Device,
216+
site_ids=[1, 2],
217+
last_seen=datetime.timedelta(hours=1),
218+
):
219+
print(device)
220+
221+
222+
asyncio.run(main())
223+
```
224+
"""
225+
filters = []
226+
227+
if last_seen is not None:
228+
filters.append(self._build_last_seen_filter(last_seen))
229+
230+
if site_ids is not None:
231+
filters.append(self._build_site_id_filter(site_ids))
232+
233+
if boundary_ids is not None:
234+
filters.append(self._build_boundary_id_filter(boundary_ids))
235+
236+
if not filters:
237+
raise ArmisError("At least one of filter must be provided")
238+
239+
filter_ = {
240+
"filter_criteria": "MULTIPLE",
241+
"filters": filters,
242+
}
243+
async for item in self._list_assets(asset_class, fields, filter_):
244+
yield item
245+
246+
async def list_by_site_id(
247+
self,
248+
asset_class: type[AssetT],
249+
site_ids: list[int],
250+
fields: list[str] | None = None,
251+
) -> AsyncIterator[AssetT]:
252+
"""List assets by site ID.
253+
254+
Args:
255+
asset_class: The asset class to list. Must inherit from [Asset][armis_sdk.entities.asset.Asset].
256+
site_ids: A list of site IDs to filter by.
257+
fields: Optional list of fields to retrieve. If None, all non-custom fields are retrieved.
258+
259+
Yields:
260+
Assets of the specified class belonging to any of the provided sites.
261+
262+
Example:
263+
```python linenums="1" hl_lines="10"
264+
import asyncio
265+
266+
from armis_sdk.clients.assets_client import AssetsClient
267+
from armis_sdk.entities.device import Device
268+
269+
270+
async def main():
271+
assets_client = AssetsClient()
272+
273+
async for device in assets_client.list_by_site_id(Device, [1, 2, 3]):
274+
print(device)
275+
276+
277+
asyncio.run(main())
278+
```
279+
"""
280+
filter_ = self._build_site_id_filter(site_ids)
141281
async for item in self._list_assets(asset_class, fields, filter_):
142282
yield item
143283

@@ -151,7 +291,7 @@ async def list_fields(self, asset_class: type[AssetT]) -> AsyncIterator[AssetFie
151291
Field descriptions including field name, type, and other metadata.
152292
153293
Example:
154-
```python linenums="1" hl_lines="9"
294+
```python linenums="1" hl_lines="10"
155295
import asyncio
156296
157297
from armis_sdk.clients.assets_client import AssetsClient
@@ -249,6 +389,29 @@ async def main():
249389
if errors:
250390
raise BulkUpdateError(errors)
251391

392+
@staticmethod
393+
def _build_boundary_id_filter(boundary_ids: list[int]) -> dict:
394+
if not boundary_ids:
395+
raise ArmisError("boundary_ids must not be empty")
396+
return {"filter_criteria": "BOUNDARY_ID", "boundary_ids": boundary_ids}
397+
398+
@staticmethod
399+
def _build_last_seen_filter(last_seen: datetime.datetime | datetime.timedelta) -> dict:
400+
filter_: dict[str, str | int] = {"filter_criteria": "LAST_SEEN"}
401+
if isinstance(last_seen, datetime.datetime):
402+
filter_["last_seen_ge"] = last_seen.isoformat()
403+
elif isinstance(last_seen, datetime.timedelta):
404+
filter_["last_seen_seconds"] = int(last_seen.total_seconds())
405+
else:
406+
raise ArmisError(f"Invalid 'last_seen' type {type(last_seen)}")
407+
return filter_
408+
409+
@staticmethod
410+
def _build_site_id_filter(site_ids: list[int]) -> dict:
411+
if not site_ids:
412+
raise ArmisError("site_ids must not be empty")
413+
return {"filter_criteria": "SITE_ID", "site_ids": site_ids}
414+
252415
@classmethod
253416
def _create_bulk_update_request(
254417
cls,

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ pip install armis_sdk
99
```
1010
## Usage
1111

12-
All interaction with the SDK happens through the [ArmisSdk][armis_sdk.core.armis_sdk.ArmisSdk] class.
12+
All interaction with the SDK happens through the [ArmisSdk][armis_sdk.core.armis_sdk.ArmisSdk] class.
1313
You'll need five things:
1414

1515
1. **Audience**: The url of the tenant you want to interact with, including trailing slash (e.g. `https://acme.armis.com/`).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "armis_sdk"
3-
version = "1.1.3"
3+
version = "1.2.0"
44
description = "The Armis SDK is a package that encapsulates common use-cases for interacting with the Armis platform."
55
authors = [
66
{ name = "Shai Lachmanovich", email = "shai@armis.com" },

tests/armis_sdk/clients/assets_client_test.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,133 @@ async def test_list_by_asset_id_invalid_fields():
200200
pass
201201

202202

203+
async def test_list_by_boundary_id(httpx_mock: pytest_httpx.HTTPXMock):
204+
httpx_mock.add_response(
205+
url="https://api.armis.com/v3/assets/_search",
206+
method="POST",
207+
match_json={
208+
"limit": 100,
209+
"asset_type": "DEVICE",
210+
"fields": assets_test_data.ALL_DEVICE_FIELDS,
211+
"filter": {"filter_criteria": "BOUNDARY_ID", "boundary_ids": [1, 2, 3]},
212+
},
213+
json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]},
214+
)
215+
216+
assets_client = AssetsClient()
217+
devices = [device async for device in assets_client.list_by_boundary_id(Device, [1, 2, 3])]
218+
219+
assert devices == [assets_test_data.MOCK_DEVICE_FULL]
220+
221+
222+
@pytest.mark.parametrize(
223+
["kwargs", "expected_filters"],
224+
[
225+
(
226+
{"boundary_ids": [1, 2, 3]},
227+
[{"filter_criteria": "BOUNDARY_ID", "boundary_ids": [1, 2, 3]}],
228+
),
229+
(
230+
{"site_ids": [1, 2, 3]},
231+
[{"filter_criteria": "SITE_ID", "site_ids": [1, 2, 3]}],
232+
),
233+
(
234+
{"last_seen": datetime.datetime(2025, 12, 3)},
235+
[{"filter_criteria": "LAST_SEEN", "last_seen_ge": "2025-12-03T00:00:00"}],
236+
),
237+
(
238+
{"last_seen": datetime.timedelta(hours=1)},
239+
[{"filter_criteria": "LAST_SEEN", "last_seen_seconds": 3600}],
240+
),
241+
(
242+
{"last_seen": datetime.timedelta(hours=1), "site_ids": [1, 2], "boundary_ids": [3, 4]},
243+
[
244+
{"filter_criteria": "LAST_SEEN", "last_seen_seconds": 3600},
245+
{"filter_criteria": "SITE_ID", "site_ids": [1, 2]},
246+
{"filter_criteria": "BOUNDARY_ID", "boundary_ids": [3, 4]},
247+
],
248+
),
249+
],
250+
)
251+
async def test_list_by_multiple(httpx_mock: pytest_httpx.HTTPXMock, kwargs, expected_filters):
252+
httpx_mock.add_response(
253+
url="https://api.armis.com/v3/assets/_search",
254+
method="POST",
255+
match_json={
256+
"limit": 100,
257+
"asset_type": "DEVICE",
258+
"fields": assets_test_data.ALL_DEVICE_FIELDS,
259+
"filter": {"filter_criteria": "MULTIPLE", "filters": expected_filters},
260+
},
261+
json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]},
262+
)
263+
264+
assets_client = AssetsClient()
265+
devices = [device async for device in assets_client.list_by_multiple(Device, **kwargs)]
266+
267+
assert devices == [assets_test_data.MOCK_DEVICE_FULL]
268+
269+
270+
async def test_list_by_multiple_no_filters():
271+
assets_client = AssetsClient()
272+
273+
with pytest.raises(ArmisError, match="At least one of"):
274+
async for _ in assets_client.list_by_multiple(Device):
275+
pass
276+
277+
278+
@pytest.mark.parametrize(
279+
["kwargs", "expected_error"],
280+
[
281+
(
282+
{"last_seen": "2025-12-03"},
283+
r"Invalid 'last_seen' type",
284+
),
285+
(
286+
{"last_seen": 3600},
287+
r"Invalid 'last_seen' type",
288+
),
289+
(
290+
{"boundary_ids": []},
291+
"boundary_ids must not be empty",
292+
),
293+
(
294+
{"site_ids": []},
295+
"site_ids must not be empty",
296+
),
297+
(
298+
{"site_ids": [1, 2], "fields": ["device_id", "foo", "bar"]},
299+
"The following fields are not supported with this operation: 'foo', 'bar'",
300+
),
301+
],
302+
)
303+
async def test_list_by_multiple_invalid_input(kwargs, expected_error):
304+
assets_client = AssetsClient()
305+
306+
with pytest.raises(ArmisError, match=expected_error):
307+
async for _ in assets_client.list_by_multiple(Device, **kwargs):
308+
pass
309+
310+
311+
async def test_list_by_site_id(httpx_mock: pytest_httpx.HTTPXMock):
312+
httpx_mock.add_response(
313+
url="https://api.armis.com/v3/assets/_search",
314+
method="POST",
315+
match_json={
316+
"limit": 100,
317+
"asset_type": "DEVICE",
318+
"fields": assets_test_data.ALL_DEVICE_FIELDS,
319+
"filter": {"filter_criteria": "SITE_ID", "site_ids": [1, 2, 3]},
320+
},
321+
json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]},
322+
)
323+
324+
assets_client = AssetsClient()
325+
devices = [device async for device in assets_client.list_by_site_id(Device, [1, 2, 3])]
326+
327+
assert devices == [assets_test_data.MOCK_DEVICE_FULL]
328+
329+
203330
async def test_update(httpx_mock: pytest_httpx.HTTPXMock):
204331
httpx_mock.add_response(
205332
url="https://api.armis.com/v3/assets/_bulk",

0 commit comments

Comments
 (0)