diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 248bd23..b587f91 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -16,8 +16,4 @@ jobs: - uses: ./.github/workflows/install_deps - name: Run formatter - run: poetry run black --check --diff . - - - name: Run isort - run: poetry run isort --check-only --diff . - + run: poetry run ruff format --check . diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fa8541e..fe6719e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,4 +16,4 @@ jobs: - uses: ./.github/workflows/install_deps - name: Run linter - run: poetry run pylint . + run: poetry run ruff check . diff --git a/armis_sdk/__init__.py b/armis_sdk/__init__.py index 6cc01ba..67329af 100644 --- a/armis_sdk/__init__.py +++ b/armis_sdk/__init__.py @@ -1,2 +1,2 @@ -from armis_sdk.core.armis_sdk import ArmisSdk # noqa: F401 -from armis_sdk.core.client_credentials import ClientCredentials # noqa: F401 +from armis_sdk.core.armis_sdk import ArmisSdk +from armis_sdk.core.client_credentials import ClientCredentials diff --git a/armis_sdk/clients/assets_client.py b/armis_sdk/clients/assets_client.py index 21eb22d..9aed6b1 100644 --- a/armis_sdk/clients/assets_client.py +++ b/armis_sdk/clients/assets_client.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import datetime -from typing import AsyncIterator from typing import Optional -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) +from typing import TYPE_CHECKING from typing import Union import universalasync @@ -18,9 +20,12 @@ from armis_sdk.types.asset_id_source import AssetIdSource +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + @universalasync.wrap -class AssetsClient(BaseEntityClient): # pylint: disable=too-few-public-methods - # pylint: disable=line-too-long +class AssetsClient(BaseEntityClient): """ A client for interacting with assets. @@ -31,10 +36,10 @@ class AssetsClient(BaseEntityClient): # pylint: disable=too-few-public-methods async def list_by_asset_id( self, - asset_class: Type[AssetT], - asset_ids: Union[list[int], list[str]], + asset_class: type[AssetT], + asset_ids: list[int] | list[str], asset_id_source: AssetIdSource = "ASSET_ID", - fields: Optional[list[str]] = None, + fields: list[str] | None = None, ) -> AsyncIterator[AssetT]: """List assets by asset ID or other identifiers. @@ -54,6 +59,7 @@ async def list_by_asset_id( from armis_sdk.clients.assets_client import AssetsClient from armis_sdk.entities.device import Device + async def main(): assets_client = AssetsClient() @@ -68,6 +74,7 @@ async def main(): async for device in assets_client.list_by_asset_id(Device, ipv4_addresses, asset_id_source="IPV4_ADDRESS"): print(device) + asyncio.run(main()) ``` """ @@ -81,9 +88,9 @@ async def main(): async def list_by_last_seen( self, - asset_class: Type[AssetT], - last_seen: Union[datetime.datetime, datetime.timedelta], - fields: Optional[list[str]] = None, + asset_class: type[AssetT], + last_seen: datetime.datetime | datetime.timedelta, + fields: list[str] | None = None, ) -> AsyncIterator[AssetT]: """List assets by last seen timestamp. @@ -106,6 +113,7 @@ async def list_by_last_seen( from armis_sdk.clients.assets_client import AssetsClient from armis_sdk.entities.device import Device + async def main(): assets_client = AssetsClient() @@ -117,10 +125,11 @@ async def main(): async for device in assets_client.list_by_last_seen(Device, datetime.datetime(2025, 12, 8)): print(device) + asyncio.run(main()) ``` """ - filter_: dict[str, Union[str, int]] = {"filter_criteria": "LAST_SEEN"} + filter_: dict[str, str | int] = {"filter_criteria": "LAST_SEEN"} if isinstance(last_seen, datetime.datetime): filter_["last_seen_ge"] = last_seen.isoformat() @@ -132,9 +141,7 @@ async def main(): async for item in self._list_assets(asset_class, fields, filter_): yield item - async def list_fields( - self, asset_class: Type[AssetT] - ) -> AsyncIterator[AssetFieldDescription]: + async def list_fields(self, asset_class: type[AssetT]) -> AsyncIterator[AssetFieldDescription]: """List all available fields for a given asset class. Args: @@ -150,12 +157,14 @@ async def list_fields( from armis_sdk.clients.assets_client import AssetsClient from armis_sdk.entities.device import Device + async def main(): assets_client = AssetsClient() async for field in assets_client.list_fields(Device): print(f"{field.name}: {field.type}") + asyncio.run(main()) ``` """ @@ -174,7 +183,6 @@ async def update( fields: list[str], asset_id_source: AssetIdSource = "ASSET_ID", ) -> None: - # pylint: disable=line-too-long """Bulk update assets. Args: @@ -204,6 +212,7 @@ async def main(): # Update based on the explicit source "IPV4_ADDRESS" await assets_client.update([device], ["custom.MyField"], asset_id_source="IPV4_ADDRESS") + asyncio.run(main()) ``` """ @@ -244,7 +253,7 @@ async def main(): def _create_bulk_update_request( cls, asset: Asset, - asset_id: Union[str, int], + asset_id: str | int, field: str, ): request = {"asset_id": asset_id, "key": field} @@ -266,7 +275,7 @@ def _get_asset_id( asset: Asset, index: int, asset_id_source: AssetIdSource, - ) -> Union[str, int]: + ) -> str | int: if isinstance(asset, Device): return cls._get_device_asset_id(asset, index, asset_id_source) @@ -286,30 +295,22 @@ def _get_device_asset_id( if asset_id_source == "MAC_ADDRESS": if device.mac_addresses is None or len(device.mac_addresses) != 1: - raise ArmisError( - f"Device at index {index} doesn't have exactly one mac address" - ) + raise ArmisError(f"Device at index {index} doesn't have exactly one mac address") return device.mac_addresses[0] if asset_id_source == "IPV4_ADDRESS": if device.ipv4_addresses is None or len(device.ipv4_addresses) != 1: - raise ArmisError( - f"Device at index {index} doesn't have exactly one IPv4 address" - ) + raise ArmisError(f"Device at index {index} doesn't have exactly one IPv4 address") return device.ipv4_addresses[0] if asset_id_source == "IPV6_ADDRESS": if device.ipv6_addresses is None or len(device.ipv6_addresses) != 1: - raise ArmisError( - f"Device at index {index} doesn't have exactly one IPv6 address" - ) + raise ArmisError(f"Device at index {index} doesn't have exactly one IPv6 address") return device.ipv6_addresses[0] if asset_id_source == "SERIAL_NUMBER": if device.serial_numbers is None or len(device.serial_numbers) != 1: - raise ArmisError( - f"Device at index {index} doesn't have exactly one serial number" - ) + raise ArmisError(f"Device at index {index} doesn't have exactly one serial number") return device.serial_numbers[0] raise ArmisError(f"Can't get {asset_id_source!r} of device at index {index}") @@ -324,8 +325,8 @@ def _is_integration_field(cls, field: str) -> bool: async def _list_assets( self, - asset_class: Type[AssetT], - fields: Optional[list[str]], + asset_class: type[AssetT], + fields: list[str] | None, filter_: dict, ) -> AsyncIterator[AssetT]: fields = fields or sorted(asset_class.all_fields()) @@ -345,15 +346,12 @@ def _validate_asset_class(cls, assets: list[AssetT]): asset_types = {type(asset) for asset in assets} if len(asset_types) > 1: asset_types_str = ", ".join(sorted(repr(at.__name__) for at in asset_types)) - raise ArmisError( - "All assets must be of the same type, " - f"got {len(asset_types)} types: {asset_types_str}" - ) + raise ArmisError(f"All assets must be of the same type, got {len(asset_types)} types: {asset_types_str}") @classmethod def _validate_fields( cls, - asset_class: Type[AssetT], + asset_class: type[AssetT], fields: list[str], allow_model_members=True, ): @@ -373,6 +371,4 @@ def _validate_fields( if invalid_fields: fields_str = ", ".join(map(repr, invalid_fields)) - raise ArmisError( - f"The following fields are not supported with this operation: {fields_str}" - ) + raise ArmisError(f"The following fields are not supported with this operation: {fields_str}") diff --git a/armis_sdk/clients/collectors_client.py b/armis_sdk/clients/collectors_client.py index 612a39a..8940319 100644 --- a/armis_sdk/clients/collectors_client.py +++ b/armis_sdk/clients/collectors_client.py @@ -1,7 +1,8 @@ +from __future__ import annotations + import contextlib from typing import IO -from typing import AsyncIterator -from typing import Generator +from typing import TYPE_CHECKING from typing import Union import httpx @@ -14,9 +15,13 @@ from armis_sdk.types.collector_image_type import CollectorImageType +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from collections.abc import Generator + + @universalasync.wrap class CollectorsClient(BaseEntityClient): - # pylint: disable=line-too-long """ A client for interacting with Armis collectors. @@ -25,7 +30,7 @@ class CollectorsClient(BaseEntityClient): async def download_image( self, - destination: Union[str, IO[bytes]], + destination: str | IO[bytes], image_type: CollectorImageType = "OVA", ) -> AsyncIterator[DownloadProgress]: """Download a collector image to a specified destination path / file. @@ -56,6 +61,7 @@ async def main(): async for progress in armis_sdk.collectors.download_image(file): print(progress.percent) + asyncio.run(main()) ``` Will output: @@ -71,7 +77,6 @@ async def main(): async with client.stream("GET", collector_image.url) as response: response.raise_for_status() total_size = int(response.headers.get("Content-Length", "0")) - # pylint: disable-next=contextmanager-generator-missing-cleanup with self.open_file(destination) as file: async for chunk in response.aiter_bytes(): file.write(chunk) @@ -97,6 +102,7 @@ async def main(): collectors_client = CollectorsClient() print(await collectors_client.get_image(image_type="OVA")) + asyncio.run(main()) ``` Will output: @@ -105,17 +111,13 @@ async def main(): ``` """ async with self._armis_client.client() as client: - response = await client.get( - "/v3/collectors/_image", params={"image_type": image_type} - ) + response = await client.get("/v3/collectors/_image", params={"image_type": image_type}) data = response_utils.get_data_dict(response) return CollectorImage.model_validate(data) @classmethod @contextlib.contextmanager - def open_file( - cls, destination: Union[str, IO[bytes]] - ) -> Generator[IO[bytes], None, None]: + def open_file(cls, destination: str | IO[bytes]) -> Generator[IO[bytes], None, None]: if isinstance(destination, str): with open(destination, "wb") as file: yield file diff --git a/armis_sdk/clients/data_export_client.py b/armis_sdk/clients/data_export_client.py index 66d3142..7c45267 100644 --- a/armis_sdk/clients/data_export_client.py +++ b/armis_sdk/clients/data_export_client.py @@ -1,7 +1,7 @@ import asyncio +from collections.abc import AsyncIterator from typing import Any -from typing import AsyncIterator -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) import pandas import universalasync @@ -16,8 +16,7 @@ @universalasync.wrap class DataExportClient(BaseEntityClient): - - async def disable(self, entity: Type[BaseExportedEntity]): + async def disable(self, entity: type[BaseExportedEntity]): """Disable data export of the entity. Args: @@ -35,12 +34,13 @@ async def main(): data_export_client = DataExportClient() await data_export_client.disable(Application) + asyncio.run(main()) ``` """ await self.toggle(entity, False) - async def enable(self, entity: Type[BaseExportedEntity]): + async def enable(self, entity: type[BaseExportedEntity]): """Enable data export of the entity. Args: @@ -58,13 +58,13 @@ async def main(): data_export_client = DataExportClient() await data_export_client.enable(Application) + asyncio.run(main()) ``` """ await self.toggle(entity, True) - async def iterate(self, entity: Type[T], **kwargs: Any) -> AsyncIterator[T]: - # pylint: disable=line-too-long + async def iterate(self, entity: type[T], **kwargs: Any) -> AsyncIterator[T]: """Iterate over the exported data. Args: @@ -90,6 +90,7 @@ async def main(): async for row in data_export_client.iterate(Application): print(type(row)) + asyncio.run(main()) ``` Will output: @@ -113,30 +114,27 @@ async def main(): async for row in data_export_client.iterate( Application, columns=["device_id", "vendor", "name", "version"], - filters=[("vendor", "in", ["Google", "Microsoft"])] + filters=[("vendor", "in", ["Google", "Microsoft"])], ): print(row.device_id, row.vendor, row.name, row.version) + asyncio.run(main()) ``` """ data_export = await self.get(entity) if not data_export.enabled: - raise ArmisError( - "Data export is disabled for this entity, please enable it first." - ) + raise ArmisError("Data export is disabled for this entity, please enable it first.") if data_export.file_format != "parquet": raise ArmisError("Only parquet files supported") for url in data_export.urls: - data_frame: pandas.DataFrame = await asyncio.to_thread( - pandas.read_parquet, url, **kwargs - ) + data_frame: pandas.DataFrame = await asyncio.to_thread(pandas.read_parquet, url, **kwargs) for _, row in data_frame.iterrows(): yield entity.series_to_model(row) - async def get(self, entity: Type[BaseExportedEntity]) -> DataExport: + async def get(self, entity: type[BaseExportedEntity]) -> DataExport: """Get the `DataExport` of the entity Args: @@ -157,6 +155,7 @@ async def main(): data_export_client = DataExportClient() print(await data_export_client.get(Application)) + asyncio.run(main()) ``` Will output: @@ -169,7 +168,7 @@ async def main(): data = response_utils.get_data_dict(response) return DataExport.model_validate(data) - async def toggle(self, entity: Type[BaseExportedEntity], enabled: bool): + async def toggle(self, entity: type[BaseExportedEntity], enabled: bool): """Enable / disable export of an entity. Args: @@ -191,12 +190,11 @@ async def main(): data_export_client = DataExportClient() await data_export_client.toggle(Application, True) + asyncio.run(main()) ``` """ data = {"enabled": enabled} async with self._armis_client.client() as client: - response = await client.patch( - f"/v3/data-export/{entity.entity_name}", json=data - ) + response = await client.patch(f"/v3/data-export/{entity.entity_name}", json=data) response_utils.raise_for_status(response) diff --git a/armis_sdk/clients/device_custom_properties_client.py b/armis_sdk/clients/device_custom_properties_client.py index 3d435ec..ff88b2d 100644 --- a/armis_sdk/clients/device_custom_properties_client.py +++ b/armis_sdk/clients/device_custom_properties_client.py @@ -1,4 +1,4 @@ -from typing import AsyncIterator +from collections.abc import AsyncIterator import universalasync @@ -18,7 +18,6 @@ class DeviceCustomPropertiesClient(BaseEntityClient): """ async def create(self, property_: DeviceCustomProperty) -> DeviceCustomProperty: - # pylint: disable=line-too-long """Create a `DeviceCustomProperty`. Args: @@ -41,6 +40,7 @@ async def main(): property_ = DeviceCustomProperty(name="MyConfig", type="string") print(await client.create(property_)) + asyncio.run(main()) ``` Will output: @@ -50,8 +50,7 @@ async def main(): """ if property_.id is not None: raise ArmisError( - "Can't create a property that already has an id. " - "Did you mean to call `.update(property_)`?", + "Can't create a property that already has an id. Did you mean to call `.update(property_)`?", ) if not property_.name: @@ -63,14 +62,11 @@ async def main(): payload = property_.model_dump(exclude_none=True) async with self._armis_client.client() as client: - response = await client.post( - "/v3/settings/device-custom-properties", json=payload - ) + response = await client.post("/v3/settings/device-custom-properties", json=payload) data = response_utils.get_data_dict(response) return DeviceCustomProperty.model_validate(data) async def delete(self, property_: DeviceCustomProperty): - # pylint: disable=line-too-long """Delete a `DeviceCustomProperty`. Args: @@ -90,6 +86,7 @@ async def main(): property_ = DeviceCustomProperty(id=1, name="MyConfig", type="string") await client.delete(property_) + asyncio.run(main()) ``` """ @@ -97,13 +94,10 @@ async def main(): raise ArmisError("Can't delete a property without an id.") async with self._armis_client.client() as client: - response = await client.delete( - f"/v3/settings/device-custom-properties/{property_.id}" - ) + response = await client.delete(f"/v3/settings/device-custom-properties/{property_.id}") response_utils.raise_for_status(response) async def get(self, property_id: int) -> DeviceCustomProperty: - # pylint: disable=line-too-long """Get a `DeviceCustomProperty` by its ID. Args: @@ -124,6 +118,7 @@ async def main(): client = DeviceCustomPropertiesClient() print(await client.get(1)) + asyncio.run(main()) ``` Will output: @@ -132,14 +127,11 @@ async def main(): ``` """ async with self._armis_client.client() as client: - response = await client.get( - f"/v3/settings/device-custom-properties/{property_id}" - ) + response = await client.get(f"/v3/settings/device-custom-properties/{property_id}") data = response_utils.get_data_dict(response) return DeviceCustomProperty.model_validate(data) async def list(self) -> AsyncIterator[DeviceCustomProperty]: - # pylint: disable=line-too-long """List all the tenant's `DeviceCustomProperty`s. This method takes care of pagination, so you don't have to deal with it. @@ -175,7 +167,6 @@ async def main(): yield DeviceCustomProperty.model_validate(item) async def update(self, property_: DeviceCustomProperty) -> DeviceCustomProperty: - # pylint: disable=line-too-long """Update a `DeviceCustomProperty`. Only `description` and `allowed_values` are updatable. @@ -203,13 +194,13 @@ async def main(): ) await client.update(property_) + asyncio.run(main()) ``` """ if property_.id is None: raise ArmisError( - "Can't update a property without an id. " - "Did you mean to call `.create(property_)`?", + "Can't update a property without an id. Did you mean to call `.create(property_)`?", ) data = property_.model_dump( diff --git a/armis_sdk/clients/sites_client.py b/armis_sdk/clients/sites_client.py index eac1e30..40ec06d 100644 --- a/armis_sdk/clients/sites_client.py +++ b/armis_sdk/clients/sites_client.py @@ -1,4 +1,4 @@ -from typing import AsyncIterator +from collections.abc import AsyncIterator import universalasync @@ -10,7 +10,6 @@ @universalasync.wrap class SitesClient(BaseEntityClient): - # pylint: disable=line-too-long """ A client for interacting with sites. @@ -40,6 +39,7 @@ async def main(): site_to_create = Site(name="my site") print(await sites_client.create(site_to_create)) + asyncio.run(main()) ``` Will output: @@ -48,10 +48,7 @@ async def main(): ``` """ if site.id is not None: - raise ArmisError( - "Can't create a site that already has an id. " - "Did you mean to call `.update(site)`?" - ) + raise ArmisError("Can't create a site that already has an id. Did you mean to call `.update(site)`?") if not site.name: raise ArmisError("Can't create a site without a name.") @@ -85,6 +82,7 @@ async def main(): site = Site(id=1) await sites_client.delete(site) + asyncio.run(main()) ``` """ @@ -116,6 +114,7 @@ async def main(): sites_client = SitesClient() print(await sites_client.get("1")) + asyncio.run(main()) ``` Will output: @@ -146,6 +145,7 @@ async def main(): sites_client = SitesClient() print(await sites_client.hierarchy()) + asyncio.run(main()) ``` Will output this structure (depending on the actual data): @@ -223,14 +223,12 @@ async def main(): site = Site(id=1, location="new location") await sites_client.update(site) + asyncio.run(main()) ``` """ if site.id is None: - raise ArmisError( - "Can't update a site without an id. " - "Did you mean to call `.create(site)`?" - ) + raise ArmisError("Can't update a site without an id. Did you mean to call `.create(site)`?") data = site.model_dump( exclude={"children", "id"}, diff --git a/armis_sdk/core/armis_auth.py b/armis_sdk/core/armis_auth.py index 3596f3f..b089a5a 100644 --- a/armis_sdk/core/armis_auth.py +++ b/armis_sdk/core/armis_auth.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import typing from typing import Optional @@ -8,6 +10,7 @@ from armis_sdk.core.armis_error import ArmisError from armis_sdk.core.client_credentials import ClientCredentials + AUTHORIZATION = "Authorization" @@ -28,24 +31,16 @@ class ArmisAuth(httpx.Auth): def __init__(self, base_url: str, credentials: ClientCredentials): self._base_url = base_url self._credentials = credentials - self._access_token: Optional[str] = None - self._expires_at: Optional[datetime.datetime] = None - - def auth_flow( - self, request: httpx.Request - ) -> typing.Generator[httpx.Request, httpx.Response, None]: - if ( - self._access_token is None - or self._expires_at is None - or self._expires_at < datetime.datetime.now() - ): + self._access_token: str | None = None + self._expires_at: datetime.datetime | None = None + + def auth_flow(self, request: httpx.Request) -> typing.Generator[httpx.Request, httpx.Response, None]: + if self._access_token is None or self._expires_at is None or self._expires_at < datetime.datetime.now(): access_token_response = yield self._build_access_token_request() self._update_access_token(access_token_response) if self._access_token is None: - raise ArmisError( - "Something went wrong, there is no access token available." - ) + raise ArmisError("Something went wrong, there is no access token available.") request.headers[AUTHORIZATION] = f"Bearer {self._access_token}" response = yield request @@ -74,6 +69,4 @@ def _build_access_token_request(self): def _update_access_token(self, response: httpx.Response): data = response_utils.get_data_dict(response) self._access_token = data["access_token"] - self._expires_at = datetime.datetime.now() + datetime.timedelta( - seconds=data["expires_in"] - ) + self._expires_at = datetime.datetime.now() + datetime.timedelta(seconds=data["expires_in"]) diff --git a/armis_sdk/core/armis_client.py b/armis_sdk/core/armis_client.py index 1e909c2..287b26c 100644 --- a/armis_sdk/core/armis_client.py +++ b/armis_sdk/core/armis_client.py @@ -1,19 +1,26 @@ +from __future__ import annotations + import importlib.metadata import os import platform -from typing import AsyncIterator from typing import Optional +from typing import TYPE_CHECKING from typing import TypeVar import httpx -import universalasync from httpx_retries import Retry from httpx_retries import RetryTransport +import universalasync from armis_sdk.core import response_utils from armis_sdk.core.armis_auth import ArmisAuth from armis_sdk.core.client_credentials import ClientCredentials + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + API_BASE_URL = "https://api.armis.com" ARMIS_CLIENT_ID = "ARMIS_CLIENT_ID" ARMIS_CLIENT_SECRET = "ARMIS_CLIENT_SECRET" @@ -38,7 +45,7 @@ @universalasync.wrap -class ArmisClient: # pylint: disable=too-few-public-methods +class ArmisClient: """ A class that provides easy access to the Armis API, taking care of: @@ -48,7 +55,7 @@ class ArmisClient: # pylint: disable=too-few-public-methods 4. Proxy configuration via HTTPS_PROXY and HTTP_PROXY environment variables. """ - def __init__(self, credentials: Optional[ClientCredentials] = None): + def __init__(self, credentials: ClientCredentials | None = None): credentials = self._get_credentials(credentials) self._auth = ArmisAuth(API_BASE_URL, credentials) self._user_agent = " ".join(USER_AGENT_PARTS) @@ -61,7 +68,7 @@ def __init__(self, credentials: Optional[ClientCredentials] = None): except ValueError: self._default_backoff = 0 - def client(self, retries: Optional[int] = None, backoff: Optional[float] = None): + def client(self, retries: int | None = None, backoff: float | None = None): retries = retries if retries is not None else self._default_retries backoff = backoff if backoff is not None else self._default_backoff retry = Retry(total=retries, backoff_factor=backoff) @@ -82,7 +89,7 @@ def client(self, retries: Optional[int] = None, backoff: Optional[float] = None) trust_env=True, ) - async def list(self, url: str, body: Optional[dict] = None) -> AsyncIterator[dict]: + async def list(self, url: str, body: dict | None = None) -> AsyncIterator[dict]: """List all items from a paginated endpoint. Args: @@ -104,6 +111,7 @@ async def main(): async for item in armis_client.list("/v3/settings/sites"): print(item) + asyncio.run(main()) ``` Will output: @@ -130,20 +138,14 @@ async def main(): break @classmethod - def _get_credentials( - cls, credentials: Optional[ClientCredentials] - ) -> ClientCredentials: + def _get_credentials(cls, credentials: ClientCredentials | None) -> ClientCredentials: credentials = credentials or ClientCredentials() credentials.vendor_id = credentials.vendor_id or os.getenv(ARMIS_VENDOR_ID) credentials.audience = credentials.audience or os.getenv(ARMIS_AUDIENCE) credentials.client_id = credentials.client_id or os.getenv(ARMIS_CLIENT_ID) - credentials.client_secret = credentials.client_secret or os.getenv( - ARMIS_CLIENT_SECRET - ) + credentials.client_secret = credentials.client_secret or os.getenv(ARMIS_CLIENT_SECRET) env_scopes = os.getenv(ARMIS_SCOPES) - credentials.scopes = credentials.scopes or ( - env_scopes.split(",") if env_scopes else [] - ) + credentials.scopes = credentials.scopes or (env_scopes.split(",") if env_scopes else []) if not credentials.audience: raise ValueError( diff --git a/armis_sdk/core/armis_error.py b/armis_sdk/core/armis_error.py index c6aea88..897f4da 100644 --- a/armis_sdk/core/armis_error.py +++ b/armis_sdk/core/armis_error.py @@ -3,29 +3,31 @@ while interacting with the SDK. """ +from __future__ import annotations + import json from typing import Optional +from typing import TYPE_CHECKING from typing import Union -from httpx import HTTPStatusError from pydantic import BaseModel +if TYPE_CHECKING: + from httpx import HTTPStatusError + + class DetailItem(BaseModel): - loc: list[Union[str, int]] + loc: list[str | int] msg: str type: str def __str__(self): - return ( - f"Type: {self.type}\n" - f"Message: {self.msg}\n" - f"Location: {json.dumps(self.loc)}" - ) + return f"Type: {self.type}\nMessage: {self.msg}\nLocation: {json.dumps(self.loc)}" class ErrorBody(BaseModel): - detail: Union[str, list[DetailItem]] + detail: str | list[DetailItem] class ArmisError(Exception): @@ -53,7 +55,6 @@ def __init__(self, items: list[BulkUpdateItemError]): class ResponseError(ArmisError): - # pylint: disable=line-too-long """ A class for all errors raised following a non-successful response from the Armis API. For example, if the server returns 400 for invalid input, an instance of this class will be raised. @@ -62,7 +63,7 @@ class ResponseError(ArmisError): def __init__( self, error_body: ErrorBody, - response_errors: Optional[list[HTTPStatusError]] = None, + response_errors: list[HTTPStatusError] | None = None, ): super().__init__(self._get_message(error_body)) self.response_errors = response_errors diff --git a/armis_sdk/core/armis_sdk.py b/armis_sdk/core/armis_sdk.py index e4653d4..8f4ffde 100644 --- a/armis_sdk/core/armis_sdk.py +++ b/armis_sdk/core/armis_sdk.py @@ -1,18 +1,17 @@ +from __future__ import annotations + from typing import Optional from armis_sdk.clients.assets_client import AssetsClient from armis_sdk.clients.collectors_client import CollectorsClient from armis_sdk.clients.data_export_client import DataExportClient -from armis_sdk.clients.device_custom_properties_client import ( - DeviceCustomPropertiesClient, -) +from armis_sdk.clients.device_custom_properties_client import DeviceCustomPropertiesClient from armis_sdk.clients.sites_client import SitesClient from armis_sdk.core.armis_client import ArmisClient from armis_sdk.core.client_credentials import ClientCredentials -class ArmisSdk: # pylint: disable=too-few-public-methods - # pylint: disable=line-too-long +class ArmisSdk: """ The `ArmisSdk` class provides access to the Armis API, while conveniently wraps common actions like authentication, pagination, parsing etc. @@ -33,20 +32,20 @@ class ArmisSdk: # pylint: disable=too-few-public-methods armis_sdk = ArmisSdk() + async def main(): async for site in armis_sdk.sites.list(): print(site) + asyncio.run(main()) ``` """ - def __init__(self, credentials: Optional[ClientCredentials] = None): + def __init__(self, credentials: ClientCredentials | None = None): self.client: ArmisClient = ArmisClient(credentials=credentials) self.assets: AssetsClient = AssetsClient(self.client) self.collectors: CollectorsClient = CollectorsClient(self.client) self.data_export: DataExportClient = DataExportClient(self.client) - self.device_custom_properties: DeviceCustomPropertiesClient = ( - DeviceCustomPropertiesClient(self.client) - ) + self.device_custom_properties: DeviceCustomPropertiesClient = DeviceCustomPropertiesClient(self.client) self.sites: SitesClient = SitesClient(self.client) diff --git a/armis_sdk/core/base_entity.py b/armis_sdk/core/base_entity.py index d10b4a3..b08137d 100644 --- a/armis_sdk/core/base_entity.py +++ b/armis_sdk/core/base_entity.py @@ -1,8 +1,8 @@ from typing import TypeVar +from pydantic import alias_generators from pydantic import BaseModel from pydantic import ConfigDict -from pydantic import alias_generators class BaseEntity(BaseModel): diff --git a/armis_sdk/core/base_entity_client.py b/armis_sdk/core/base_entity_client.py index 0ae2327..bc2933c 100644 --- a/armis_sdk/core/base_entity_client.py +++ b/armis_sdk/core/base_entity_client.py @@ -1,6 +1,8 @@ -from typing import AsyncIterator +from __future__ import annotations + from typing import Optional -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) +from typing import TYPE_CHECKING import universalasync @@ -8,14 +10,15 @@ from armis_sdk.core.base_entity import BaseEntityT -class BaseEntityClient: # pylint: disable=too-few-public-methods +if TYPE_CHECKING: + from collections.abc import AsyncIterator + - def __init__(self, armis_client: Optional[ArmisClient] = None) -> None: +class BaseEntityClient: + def __init__(self, armis_client: ArmisClient | None = None) -> None: self._armis_client = armis_client or ArmisClient() @universalasync.async_to_sync_wraps - async def _list( - self, url: str, model: Type[BaseEntityT] - ) -> AsyncIterator[BaseEntityT]: + async def _list(self, url: str, model: type[BaseEntityT]) -> AsyncIterator[BaseEntityT]: async for item in self._armis_client.list(url): yield model.model_validate(item) diff --git a/armis_sdk/core/client_credentials.py b/armis_sdk/core/client_credentials.py index e187d6e..385775e 100644 --- a/armis_sdk/core/client_credentials.py +++ b/armis_sdk/core/client_credentials.py @@ -1,11 +1,13 @@ +from __future__ import annotations + import dataclasses from typing import Optional @dataclasses.dataclass class ClientCredentials: - audience: Optional[str] = None - client_id: Optional[str] = None - client_secret: Optional[str] = None - vendor_id: Optional[str] = None - scopes: Optional[list[str]] = None + audience: str | None = None + client_id: str | None = None + client_secret: str | None = None + vendor_id: str | None = None + scopes: list[str] | None = None diff --git a/armis_sdk/core/response_utils.py b/armis_sdk/core/response_utils.py index 3732dbf..0ee5526 100644 --- a/armis_sdk/core/response_utils.py +++ b/armis_sdk/core/response_utils.py @@ -1,6 +1,6 @@ import json from json import JSONDecodeError -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) from typing import TypeVar import httpx @@ -13,12 +13,13 @@ from armis_sdk.core.armis_error import NotFoundError from armis_sdk.core.armis_error import ResponseError + DataTypeT = TypeVar("DataTypeT", dict, list) def get_data( response: httpx.Response, - data_type: Type[DataTypeT], + data_type: type[DataTypeT], ) -> DataTypeT: raise_for_status(response) data = parse_response(response, dict) @@ -36,7 +37,7 @@ def get_data_dict(response: httpx.Response): def parse_response( response: httpx.Response, - data_type: Type[DataTypeT], + data_type: type[DataTypeT], ) -> DataTypeT: try: response_data = response.json() diff --git a/armis_sdk/entities/asq_rule.py b/armis_sdk/entities/asq_rule.py index 8b2b5a0..51d83b3 100644 --- a/armis_sdk/entities/asq_rule.py +++ b/armis_sdk/entities/asq_rule.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Optional from typing import Union @@ -40,22 +42,25 @@ class AsqRule(BaseEntity): ``` """ - and_: Optional[list[Union[str, "AsqRule"]]] = Field(alias="and", default=None) + and_: list[str | AsqRule] | None = Field(alias="and", default=None) """Rules that all must match.""" - or_: Optional[list[Union[str, "AsqRule"]]] = Field(alias="or", default=None) + or_: list[str | AsqRule] | None = Field(alias="or", default=None) """Rules that at least one of them must match.""" @classmethod - def from_asq(cls, asq: str) -> "AsqRule": + def from_asq(cls, asq: str) -> AsqRule: """ Create a `AsqRule` object from a single ASQ string. """ return AsqRule(or_=[asq]) @model_validator(mode="after") - def validate_structure(self) -> "AsqRule": + def validate_structure(self) -> AsqRule: if (self.and_ is None) == (self.or_ is None): raise ValueError("Only one of 'and_' or 'or_' must be specified.") return self + + +AsqRule.model_rebuild() diff --git a/armis_sdk/entities/asset.py b/armis_sdk/entities/asset.py index 6442bc7..cc9dd70 100644 --- a/armis_sdk/entities/asset.py +++ b/armis_sdk/entities/asset.py @@ -1,15 +1,17 @@ import collections from typing import Any from typing import ClassVar -from typing import DefaultDict +from typing import DefaultDict # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) from typing import Literal -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) from typing import TypeVar from pydantic import Field +from typing_extensions import Self from armis_sdk.core.base_entity import BaseEntity + AssetT = TypeVar("AssetT", bound="Asset") @@ -26,8 +28,8 @@ class Asset(BaseEntity): """Integration properties of the asset. Values can by anything.""" @classmethod - def from_search_result(cls: Type[AssetT], data: dict) -> AssetT: - fields: DefaultDict[str, Any] = collections.defaultdict(dict) + def from_search_result(cls, data: dict) -> Self: + fields: collections.defaultdict[str, Any] = collections.defaultdict(dict) for key, value in data["fields"].items(): if len(parts := key.split(".", 1)) > 1: part1, part2 = parts @@ -44,4 +46,4 @@ def all_fields(cls) -> set[str]: return set(cls.model_fields.keys()) - { "custom", "integration", - } # pylint: disable=no-member + } diff --git a/armis_sdk/entities/data_export/application.py b/armis_sdk/entities/data_export/application.py index f9016b7..a014e9e 100644 --- a/armis_sdk/entities/data_export/application.py +++ b/armis_sdk/entities/data_export/application.py @@ -1,12 +1,17 @@ +from __future__ import annotations + import datetime from typing import ClassVar from typing import Optional - -import pandas +from typing import TYPE_CHECKING from armis_sdk.entities.data_export.base_exported_entity import BaseExportedEntity +if TYPE_CHECKING: + import pandas + + class Application(BaseExportedEntity): """ This class represents an application row that was exported using the data export API. @@ -38,7 +43,7 @@ class Application(BaseExportedEntity): **Example**: `30.0.1599.40` """ - cpe: Optional[str] + cpe: str | None """ The CPE (Common Platform Enumeration) of the application @@ -52,15 +57,13 @@ class Application(BaseExportedEntity): """When the application was last seen on the device""" @classmethod - def series_to_model(cls, series: pandas.Series) -> "Application": + def series_to_model(cls, series: pandas.Series) -> Application: return Application( device_id=series.loc["device_id"], vendor=series.loc["vendor"], name=series.loc["name"], version=series.loc["version"], - cpe=cls._value_or_none( - series.loc["cpe"] if "cpe" in series.index else None - ), + cpe=cls._value_or_none(series.loc["cpe"] if "cpe" in series.index else None), first_seen=series.loc["first_seen"].to_pydatetime(), last_seen=series["last_seen"].to_pydatetime(), ) diff --git a/armis_sdk/entities/data_export/base_exported_entity.py b/armis_sdk/entities/data_export/base_exported_entity.py index 13ed734..c9ab2ff 100644 --- a/armis_sdk/entities/data_export/base_exported_entity.py +++ b/armis_sdk/entities/data_export/base_exported_entity.py @@ -1,17 +1,18 @@ import abc -from typing import Type +from typing import Type # noqa: UP035 # TODO: fix UP035 (deprecated import, use updated module) from typing import TypeVar import pandas from pydantic import BaseModel + T = TypeVar("T", bound="BaseExportedEntity") class BaseExportedEntity(BaseModel, abc.ABC): @classmethod @abc.abstractmethod - def series_to_model(cls: Type[T], series: pandas.Series) -> T: ... + def series_to_model(cls: type[T], series: pandas.Series) -> T: ... @property @abc.abstractmethod @@ -23,7 +24,7 @@ def _to_list(cls, value) -> list: @classmethod def _value_or_none(cls, value): - if not value or pandas.isnull(value) or value == "N/A": + if not value or pandas.isnull(value) or value == "N/A": # noqa: PD003 # TODO: fix PD003 (use .isna() instead of .isnull()) return None if isinstance(value, pandas.Timestamp): diff --git a/armis_sdk/entities/data_export/data_export.py b/armis_sdk/entities/data_export/data_export.py index e46476d..19a60e1 100644 --- a/armis_sdk/entities/data_export/data_export.py +++ b/armis_sdk/entities/data_export/data_export.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime from typing import Literal from typing import Optional @@ -24,5 +26,5 @@ class DataExport(BaseModel): urls: list[str] """URLs to the files that contain the exported data.""" - urls_creation_time: Optional[datetime.datetime] = Field(strict=False) + urls_creation_time: datetime.datetime | None = Field(strict=False) """The creation time of the URLs.""" diff --git a/armis_sdk/entities/data_export/risk_factor.py b/armis_sdk/entities/data_export/risk_factor.py index 4a86a27..eb1735c 100644 --- a/armis_sdk/entities/data_export/risk_factor.py +++ b/armis_sdk/entities/data_export/risk_factor.py @@ -1,14 +1,20 @@ +from __future__ import annotations + import datetime import json from typing import ClassVar from typing import Optional +from typing import TYPE_CHECKING -import pandas from pydantic import BaseModel from armis_sdk.entities.data_export.base_exported_entity import BaseExportedEntity +if TYPE_CHECKING: + import pandas + + class RiskFactorRecommendedAction(BaseModel): id: int """The id of the recommended action""" @@ -68,7 +74,7 @@ class RiskFactor(BaseExportedEntity): **Example**: `Device Supports SMBv1` """ - score: Optional[int] + score: int | None """The score of the risk factor""" group: str @@ -78,14 +84,14 @@ class RiskFactor(BaseExportedEntity): **Example**: `INSECURE_TRAFFIC_AND_BEHAVIOR` """ - remediation_type: Optional[str] + remediation_type: str | None """ The type of the remediation **Example**: `Disable SMBv1 Protocol` """ - remediation_description: Optional[str] + remediation_description: str | None """ The description of the remediation @@ -110,13 +116,13 @@ class RiskFactor(BaseExportedEntity): **Example**: `OPEN` """ - status_update_time: Optional[datetime.datetime] + status_update_time: datetime.datetime | None """When was the status last changed""" - status_updated_by_user_id: Optional[int] + status_updated_by_user_id: int | None """Which used id last changed the status""" - status_update_reason: Optional[str] + status_update_reason: str | None """ The reason for the status change @@ -124,29 +130,21 @@ class RiskFactor(BaseExportedEntity): """ @classmethod - def series_to_model(cls, series: pandas.Series) -> "RiskFactor": + def series_to_model(cls, series: pandas.Series) -> RiskFactor: return RiskFactor( device_id=series.loc["device_id"], category=series.loc["category"], type=series.loc["type"], description=series.loc["description"], - score=( - int(score) - if (score := cls._value_or_none(series.loc["score"])) - else None - ), + score=(int(score) if (score := cls._value_or_none(series.loc["score"])) else None), status=series.loc["status"], group=series.loc["group"], remediation_type=cls._value_or_none(series.loc["remediation"]), - remediation_description=cls._value_or_none( - series.loc["remediation_description"] - ), + remediation_description=cls._value_or_none(series.loc["remediation_description"]), remediation_recommended_actions=( [ RiskFactorRecommendedAction(**item) - for item in json.loads( - series.loc["remediation_recommended_actions"] - ) + for item in json.loads(series.loc["remediation_recommended_actions"]) ] if series.loc["remediation_recommended_actions"] else [] @@ -154,8 +152,6 @@ def series_to_model(cls, series: pandas.Series) -> "RiskFactor": first_seen=series.loc["first_seen"].to_pydatetime(), last_seen=series.loc["last_seen"].to_pydatetime(), status_update_time=cls._value_or_none(series.loc["status_update_time"]), - status_updated_by_user_id=cls._value_or_none( - series.loc["status_updated_by_user_id"] - ), + status_updated_by_user_id=cls._value_or_none(series.loc["status_updated_by_user_id"]), status_update_reason=cls._value_or_none(series.loc["status_update_reason"]), ) diff --git a/armis_sdk/entities/data_export/vulnerability.py b/armis_sdk/entities/data_export/vulnerability.py index 2327c23..f7b6631 100644 --- a/armis_sdk/entities/data_export/vulnerability.py +++ b/armis_sdk/entities/data_export/vulnerability.py @@ -1,12 +1,17 @@ +from __future__ import annotations + import datetime from typing import ClassVar from typing import Optional - -import pandas +from typing import TYPE_CHECKING from armis_sdk.entities.data_export.base_exported_entity import BaseExportedEntity +if TYPE_CHECKING: + import pandas + + class Vulnerability(BaseExportedEntity): """ This class represents a vulnerability row that was exported using the data export API. @@ -24,7 +29,7 @@ class Vulnerability(BaseExportedEntity): **Example**: `CVE-2025-53799` """ - advisory_id: Optional[str] + advisory_id: str | None """ The id of the advisory @@ -38,7 +43,7 @@ class Vulnerability(BaseExportedEntity): **Example**: `["VERSION_UPDATE"]` """ - avm_rating: Optional[str] + avm_rating: str | None """The Armis AVM (Asset Vulnerability Management) rating of the vulnerability""" match_source: list[str] @@ -55,21 +60,19 @@ class Vulnerability(BaseExportedEntity): **Example**: `Open` """ - status_change_time: Optional[datetime.datetime] + status_change_time: datetime.datetime | None """When was the status last changed""" - status_change_reason: Optional[str] + status_change_reason: str | None """The reason for the status change""" @classmethod - def series_to_model(cls, series: pandas.Series) -> "Vulnerability": + def series_to_model(cls, series: pandas.Series) -> Vulnerability: return Vulnerability( device_id=series.loc["device_id"], cve_uid=series.loc["vulnerability_cve_uid"], advisory_id=cls._value_or_none(series.loc["vulnerability_advisory_id"]), - remediation_types=cls._to_list( - series.loc["vulnerability_remediation_types"] - ), + remediation_types=cls._to_list(series.loc["vulnerability_remediation_types"]), avm_rating=cls._value_or_none(series.loc["avm_rating"]), match_source=cls._to_list(series.loc["match_source"]), status=series.loc["status"], diff --git a/armis_sdk/entities/device.py b/armis_sdk/entities/device.py index 9fba2ad..2c169cd 100644 --- a/armis_sdk/entities/device.py +++ b/armis_sdk/entities/device.py @@ -1,7 +1,8 @@ -import datetime +from __future__ import annotations + +import datetime # noqa: TC003 from typing import ClassVar from typing import Literal -from typing import Optional from pydantic import Field @@ -12,107 +13,109 @@ class Device(Asset): - # pylint: disable=line-too-long asset_type: ClassVar[Literal["DEVICE"]] = "DEVICE" - boundaries: Optional[list[Boundary]] = None + boundaries: list[Boundary] | None = None """The list of boundaries the device belongs to.""" - brand: Optional[str] = None + brand: str | None = None """ The device brand. - + Example: `Apple` """ - category: Optional[str] = None + category: str | None = None """ The device category. - + Example: `Handheld` """ - device_id: Optional[int] = None + device_id: int | None = None """The unique identifier given to the device by thr Armis engine.""" - display: Optional[str] = None + display: str | None = None """ The display text of the device. - + Example: `My iPhone` """ - first_seen: Optional[datetime.datetime] = Field(strict=False, default=None) + first_seen: datetime.datetime | None = Field(strict=False, default=None) """When was the device first seen.""" - ipv4_addresses: Optional[list[str]] = None + ipv4_addresses: list[str] | None = None """The list of IPv4 addresses of the device""" - ipv6_addresses: Optional[list[str]] = None + ipv6_addresses: list[str] | None = None """The list of IPv6 addresses of the device""" - last_seen: Optional[datetime.datetime] = Field(strict=False, default=None) + last_seen: datetime.datetime | None = Field(strict=False, default=None) """When was the device last seen.""" - mac_addresses: Optional[list[str]] = None + mac_addresses: list[str] | None = None """The list of MAC addresses of the device""" - model: Optional[str] = None + model: str | None = None """ The model of the device. - + Example: `iPhone 17` """ - names: Optional[list[str]] = None + names: list[str] | None = None """ List of names of the device - + Example: `["My iPhone 17", "Jane's iPhone"]` """ - network_interfaces: Optional[list[NetworkInterface]] = None + network_interfaces: list[NetworkInterface] | None = None """List of network interfaces detected on the device.""" - os_name: Optional[str] = None + os_name: str | None = None """ The OS name running on the device. - + Example: `iOS` """ - os_version: Optional[str] = None + os_version: str | None = None """ The OS version running on the device. Example: `17` """ - purdue_level: Optional[float] = None + purdue_level: float | None = None """ The purdue level of the devices. See [Wikipedia](https://en.wikipedia.org/wiki/Purdue_Enterprise_Reference_Architecture) article for more details. - + Example: `4` """ - risk_level: Optional[int] = Field(ge=0, le=1000, default=None) + risk_level: int | None = Field(ge=0, le=1000, default=None) """The risk level given to the device by the Armis engine, between `0` and `100`.""" - serial_numbers: Optional[list[str]] = None + serial_numbers: list[str] | None = None """The list of serial numbers of the device""" - site: Optional[Site] = None + site: Site | None = None """The site in which this device was last seen.""" - tags: Optional[list[str]] = None + tags: list[str] | None = None """The tags given to the devices.""" - type: Optional[str] = None + type: str | None = None """ The type of the device. - + Example: `Mobile Phones` """ - visibility: Optional[Literal["Full", "Limited"]] = None + visibility: Literal["Full", "Limited"] | None = None """Whether the device is fully visibly or limited.""" + + +Device.model_rebuild() diff --git a/armis_sdk/entities/device_custom_property.py b/armis_sdk/entities/device_custom_property.py index dec7f24..247a0e2 100644 --- a/armis_sdk/entities/device_custom_property.py +++ b/armis_sdk/entities/device_custom_property.py @@ -1,14 +1,20 @@ -import datetime +from __future__ import annotations + from typing import Literal from typing import Optional +from typing import TYPE_CHECKING from pydantic import Field from armis_sdk.core.base_entity import BaseEntity +if TYPE_CHECKING: + import datetime + + class DeviceCustomProperty(BaseEntity): - id: Optional[int] = None + id: int | None = None """The id of the property.""" name: str = Field(max_length=40, pattern=r"^[\w_]*$") @@ -18,7 +24,7 @@ class DeviceCustomProperty(BaseEntity): Example: `Size` """ - description: Optional[str] = Field(max_length=250, default=None) + description: str | None = Field(max_length=250, default=None) """ The description of the property. @@ -39,15 +45,15 @@ class DeviceCustomProperty(BaseEntity): Example: `enum` """ - allowed_values: Optional[list[str]] = None + allowed_values: list[str] | None = None """ The allowed values of the property when the 'type' is 'enum'. Example: `["s", "m", "l"]` """ - created_by: Optional[str] = Field(max_length=50, default=None) + created_by: str | None = Field(max_length=50, default=None) """Who / what created the property.""" - creation_time: Optional[datetime.datetime] = Field(strict=False, default=None) + creation_time: datetime.datetime | None = Field(strict=False, default=None) """The creation time of the property.""" diff --git a/armis_sdk/entities/download_progress.py b/armis_sdk/entities/download_progress.py index 88ba17a..41ca30a 100644 --- a/armis_sdk/entities/download_progress.py +++ b/armis_sdk/entities/download_progress.py @@ -11,4 +11,4 @@ class DownloadProgress(BaseEntity): @property def percent(self) -> str: """Percentage of progress.""" - return f"{self.downloaded/self.total:.4%}" + return f"{self.downloaded / self.total:.4%}" diff --git a/armis_sdk/entities/network_interface.py b/armis_sdk/entities/network_interface.py index 0f3086b..71da22c 100644 --- a/armis_sdk/entities/network_interface.py +++ b/armis_sdk/entities/network_interface.py @@ -1,49 +1,50 @@ +from __future__ import annotations + from typing import Optional from armis_sdk.core.base_entity import BaseEntity class NetworkInterface(BaseEntity): - # pylint: disable=line-too-long """ A `NetworkInterface` represents a physical network card of a [Device][armis_sdk.entities.device.Device]. """ - alias: Optional[str] + alias: str | None """The alias of the interface.""" - brand: Optional[str] + brand: str | None """The brand of the interface.""" - broadcast_ssid: Optional[str] + broadcast_ssid: str | None """The last SSID broadcasted by the interface.""" channels: list[int] """The channels that the interface uses to transmit.""" - description: Optional[str] + description: str | None """The description of the interface""" - hidden_broadcast_ssid: Optional[bool] + hidden_broadcast_ssid: bool | None """Is the broadcasted SSID hidden.""" - ipv4_address: Optional[str] + ipv4_address: str | None """The last IPv4 address associated with the interface.""" - ipv6_address: Optional[str] + ipv6_address: str | None """The last IPv6 address associated with the interface.""" - last_connected_ssid: Optional[str] + last_connected_ssid: str | None """The SSID the interface last connected to.""" - mac_address: Optional[str] + mac_address: str | None """The MAC address of the interface.""" - name: Optional[str] + name: str | None """The name of the interface.""" - type: Optional[str] + type: str | None """The type of the interface.""" - vlan: Optional[int] + vlan: int | None """The VLAN of the interface.""" diff --git a/armis_sdk/entities/site.py b/armis_sdk/entities/site.py index 9815d4a..a2985b3 100644 --- a/armis_sdk/entities/site.py +++ b/armis_sdk/entities/site.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import Annotated from typing import Any @@ -23,64 +25,60 @@ class Site(BaseEntity): The `Site` entity represents a physical location at the customer's environment. """ - id: Optional[int] = Field(strict=False, default=None) + id: int | None = Field(strict=False, default=None) """The id of the site.""" - name: Optional[str] = None + name: str | None = None """The name of the site.""" - lat: Optional[float] = Field(frozen=True, default=None) + lat: float | None = Field(frozen=True, default=None) """ The latitude coordinate of the physical location of the site on earth. - This field is read-only and is automatically derived from the + This field is read-only and is automatically derived from the [`location`][armis_sdk.entities.site.Site.location] field. - + Example: `37.7900103` """ - lng: Optional[float] = Field(frozen=True, default=None) + lng: float | None = Field(frozen=True, default=None) """ The longitude coordinate of the physical location of the site on earth. - This field is read-only and is automatically derived from the + This field is read-only and is automatically derived from the [`location`][armis_sdk.entities.site.Site.location] field. - + Example: `-122.4007818` """ - location: Optional[str] = None + location: str | None = None """ The name of the location of the site, such as an address. - + Example: `548 Market Street Suite 97439 San Francisco, CA 94104-5401` - - When this field is set, the [`lat`][armis_sdk.entities.site.Site.lat] and + + When this field is set, the [`lat`][armis_sdk.entities.site.Site.lat] and [`lng`][armis_sdk.entities.site.Site.lng] are automatically derived from it. """ - parent_id: Optional[int] = Field(strict=False, default=None) + parent_id: int | None = Field(strict=False, default=None) """The id of the parent site.""" - tier: Optional[str] = None + tier: str | None = None """The tier of the site.""" - asq_rule: Optional[AsqRule] = Field(default=None) + asq_rule: AsqRule | None = Field(default=None) """The ASQ rule of the site.""" - network_equipment_device_ids: Annotated[ - Optional[list[int]], BeforeValidator(ensure_list_of_ints) - ] = None + network_equipment_device_ids: Annotated[list[int] | None, BeforeValidator(ensure_list_of_ints)] = None """The ids of network equipment devices associated with the site.""" - integration_ids: Annotated[ - Optional[list[int]], BeforeValidator(ensure_list_of_ints) - ] = None + integration_ids: Annotated[list[int] | None, BeforeValidator(ensure_list_of_ints)] = None """The ids of the integration associated with the site.""" - children: list["Site"] = Field(default_factory=list) - """The sub-sites that are directly under this site + children: list[Site] = Field(default_factory=list) + """The sub-sites that are directly under this site (their `parent_id` will match this site's `id`).""" @model_validator(mode="before") @@ -91,3 +89,6 @@ def transform_rule_aql_to_asq_rule(cls, data: Any) -> dict: if "ruleAql" in data: data["asq_rule"] = json.loads(data.pop("ruleAql")) return data + + +Site.model_rebuild() diff --git a/armis_sdk/types/asset_id_source.py b/armis_sdk/types/asset_id_source.py index 3ae0ebd..8902c3d 100644 --- a/armis_sdk/types/asset_id_source.py +++ b/armis_sdk/types/asset_id_source.py @@ -1,5 +1,6 @@ from typing import Literal + AssetIdSource = Literal[ "ASSET_ID", "IPV4_ADDRESS", diff --git a/armis_sdk/types/collector_image_type.py b/armis_sdk/types/collector_image_type.py index eaf844f..3162baa 100644 --- a/armis_sdk/types/collector_image_type.py +++ b/armis_sdk/types/collector_image_type.py @@ -1,5 +1,6 @@ from typing import Literal + CollectorImageType = Literal[ "DARWIN_AMD64_BROKER", "DARWIN_ARM64_BROKER", diff --git a/poetry.lock b/poetry.lock index 06fb033..cb0a070 100644 --- a/poetry.lock +++ b/poetry.lock @@ -37,22 +37,6 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] -[[package]] -name = "astroid" -version = "3.3.11" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, - {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} - [[package]] name = "babel" version = "2.17.0" @@ -89,54 +73,6 @@ files = [ [package.extras] extras = ["regex"] -[[package]] -name = "black" -version = "25.1.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, - {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, - {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, - {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, - {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, - {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, - {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, - {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, - {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, - {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, - {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, - {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, - {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, - {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, - {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, - {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, - {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, - {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, - {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, - {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, - {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, - {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" version = "2025.4.26" @@ -259,7 +195,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["dev", "docs"] +groups = ["docs"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, @@ -275,29 +211,28 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["build", "dev", "docs"] +groups = ["build", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {build = "(python_version <= \"3.11\" or python_version >= \"3.12\") and sys_platform == \"win32\"", dev = "(platform_system == \"Windows\" or sys_platform == \"win32\") and (python_version <= \"3.11\" or python_version >= \"3.12\")", docs = "python_version <= \"3.11\" or python_version >= \"3.12\""} +markers = {build = "python_version <= \"3.11\" and sys_platform == \"win32\" or python_version >= \"3.12\" and sys_platform == \"win32\"", docs = "python_version <= \"3.11\" or python_version >= \"3.12\""} [[package]] -name = "dill" -version = "0.4.0" -description = "serialize all of Python" +name = "eval-type-backport" +version = "0.3.1" +description = "Like `typing._eval_type`, but lets older Python versions use newer typing features." optional = false -python-versions = ">=3.8" -groups = ["dev"] +python-versions = ">=3.7" +groups = ["main"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, + {file = "eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8"}, + {file = "eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed"}, ] [package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] +tests = ["pytest"] [[package]] name = "exceptiongroup" @@ -453,7 +388,7 @@ version = "8.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, @@ -485,26 +420,6 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "isort" -version = "6.1.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, - {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.6.0", markers = "python_version < \"3.10\""} - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - [[package]] name = "jinja2" version = "3.1.6" @@ -616,19 +531,6 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "mergedeep" version = "1.3.4" @@ -933,7 +835,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["build", "dev", "docs"] +groups = ["build", "docs"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, @@ -1026,8 +928,8 @@ files = [ [package.dependencies] numpy = [ {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -1094,7 +996,7 @@ version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" files = [ {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, @@ -1332,38 +1234,6 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] -[[package]] -name = "pylint" -version = "3.3.7" -description = "python code static checker" -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "pylint-3.3.7-py3-none-any.whl", hash = "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d"}, - {file = "pylint-3.3.7.tar.gz", hash = "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559"}, -] - -[package.dependencies] -astroid = ">=3.3.8,<=3.4.0.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, -] -isort = ">=4.2.5,<5.13 || >5.13,<7" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2" -tomli = {version = ">=1.1", markers = "python_version < \"3.11\""} -tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - [[package]] name = "pymdown-extensions" version = "10.15" @@ -1598,6 +1468,35 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "ruff" +version = "0.15.6" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff"}, + {file = "ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3"}, + {file = "ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0"}, + {file = "ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c"}, + {file = "ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406"}, + {file = "ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837"}, + {file = "ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4"}, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -1689,19 +1588,6 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] -[[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - [[package]] name = "types-pytz" version = "2025.2.0.20250809" @@ -1726,7 +1612,7 @@ files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] -markers = {main = "python_version <= \"3.11\" or python_version >= \"3.12\"", build = "python_version >= \"3.12\" and python_version < \"3.13\" or python_version <= \"3.11\"", dev = "python_version <= \"3.11\" or python_version >= \"3.12\"", docs = "python_version < \"3.11\""} +markers = {main = "python_version <= \"3.11\" or python_version >= \"3.12\"", build = "python_version <= \"3.11\" or python_version >= \"3.12\" and python_version < \"3.13\"", dev = "python_version <= \"3.11\" or python_version >= \"3.12\"", docs = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -1839,7 +1725,7 @@ version = "3.22.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343"}, @@ -1857,4 +1743,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "606b6d2564790886f04f7795e7afb893dbd26079c46883561783e9db800b427f" +content-hash = "c87f9fabe58c94133995152e57db1001cdf444e9b57504c615cc0a5f8e20f411" diff --git a/pylintrc b/pylintrc deleted file mode 100644 index b6cfa48..0000000 --- a/pylintrc +++ /dev/null @@ -1,11 +0,0 @@ -[MAIN] - -ignore-paths= - local, - .venv - -disable= - duplicate-code, - missing-class-docstring, - missing-module-docstring, - missing-function-docstring, diff --git a/pyproject.toml b/pyproject.toml index a450881..1050640 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,11 @@ requires-python = ">=3.9" requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" -[tool.isort] -profile = "black" -force_single_line = "true" - [tool.mypy] plugins = ['pydantic.mypy'] [tool.poetry.dependencies] +eval_type_backport = "*" pandas = "*" pyarrow = "*" pydantic = "*" @@ -34,12 +31,10 @@ pytest-asyncio = "*" pytest-httpx = "*" [tool.poetry.group.dev.dependencies] -black = "*" -isort = "*" mypy = "*" pandas-stubs = "*" -pylint = "*" python-dotenv = "*" +ruff = "*" setuptools = "*" [tool.poetry.group.docs.dependencies] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..2ba50b1 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,347 @@ +line-length = 120 +target-version = "py39" + +[lint] + +select = [ + "F", # Pyflakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "C90", # mccabe complexity + "I", # isort + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "YTT", # flake8-2020 + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-except + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "LOG", # flake8-logging + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "TD", # flake8-todos + "FIX", # flake8-fixme + "ERA", # eradicate (commented-out code) + "PD", # pandas-vet + "PGH", # pygrep-hooks + "PL", # Pylint (PLC, PLE, PLR, PLW) + "TRY", # tryceratops + "FLY", # flynt + "NPY", # NumPy-specific rules + "PERF", # Perflint + "FURB", # refurb + "RUF", # Ruff-specific rules +] + +ignore = [ + "D", # Disable all docstring rules + "ANN001", # Missing type annotation for function argument + "ANN002", # Missing type annotation for *args + "ANN003", # Missing type annotation for **kwargs + "ANN201", # Missing return type annotation for public function + "ANN202", # Missing return type annotation for private function + "ANN204", # Missing return type annotation for special method + "ANN205", # Missing return type annotation for staticmethod + "ANN206", # Missing return type annotation for classmethod + "ANN401", # Dynamically typed expressions (Any) are disallowed + "TD002", # Missing author in TODO + "TD003", # Missing issue link on the line following TODO + "TD004", # Missing colon in TODO + "TD005", # Missing issue description after TODO + "FIX001", # Line contains FIXME + "FIX002", # Line contains TODO + "FIX003", # Line contains XXX + "FIX004", # Line contains HACK + + "E711", # none-comparison + "B904", # Raise without from + "SIM108", # Use ternary operator + "TRY003", # Avoid specifying long messages outside exception class + "EM101", # Exception must not use a string literal + "EM102", # Exception must not use an f-string literal + + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "FBT003", # Boolean positional value in function call + + "PLR0904", # Too many public methods + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments + "PLR0914", # Too many local variables + "PLR0915", # Too many statements + "PLR0916", # Too many boolean expressions + "PLR0917", # Too many positional arguments + "PLR2004", # Magic value used in comparison + + "BLE001", # Do not catch blind exception: Exception + "TRY002", # Create your own exception + "TRY300", # try-consider-else + "TRY400", # Use logging.exception instead of logging.error + "TRY201", # verbose-raise + + "S101", # Use of assert detected + "S104", # Possible binding to all interfaces + "S105", # Possible hardcoded password + "S106", # Possible hardcoded password assigned to argument + "S107", # Possible hardcoded password assigned to argument + "S108", # Probable insecure usage of temp file/directory + "S311", # Standard pseudo-random generators + "S324", # Probable use of insecure hash function + "S603", # subprocess call + "S607", # Starting a process with a partial executable path + "S608", # hardcoded-sql-expression + "S113", # request-without-timeout + "S501", # request-with-no-cert-validation + "S110", # try-except-pass + "S112", # try-except-continue + + "N", # Disable all pep8-naming rules + + "RET504", # Unnecessary variable assignment before return + "RET505", # Unnecessary else after return + "RET506", # Unnecessary else after raise + "RET507", # Unnecessary else after continue + "RET508", # Unnecessary else after break + "RET503", # implicit-return + + "SIM102", # Use a single if statement instead of nested if + "SIM105", # Use contextlib.suppress instead of try-except-pass + "SIM114", # Combine if branches using logical or + "SIM115", # open-file-with-context-handler + "SIM117", # Use a single with statement + "SIM118", # in-dict-keys + "SIM103", # needless-bool + + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG003", # Unused class method argument + "ARG005", # Unused lambda argument + + "PTH100", # os.path.abspath + "PTH103", # os.makedirs + "PTH107", # os.remove + "PTH109", # os.getcwd + "PTH110", # os.path.exists + "PTH111", # os.path.expanduser + "PTH112", # os.path.isdir + "PTH113", # os.path.isfile + "PTH118", # os.path.join + "PTH119", # os.path.basename + "PTH120", # os.path.dirname + "PTH122", # os.path.splitext + "PTH123", # open() + + "ISC001", # Implicitly concatenated string literals + "ISC002", # Implicitly concatenated string literals over multiple lines + "COM812", # Trailing comma missing + "COM819", # Trailing comma prohibited + + "INP001", # Implicit namespace package + "E501", # Line too long + "ERA001", # Commented-out code + "W291", # trailing-whitespace + "F405", # Undefined from star import + "F401", # unused-import + "W293", # blank-line-with-whitespace + "E402", # module-import-not-at-top + "DTZ005", # datetime.now() without tz + "DTZ901", # datetime.min/max without tz + "DTZ004", # call-datetime-utcfromtimestamp + "DTZ011", # call-date-today + "DTZ002", # call-datetime-today + "UP031", # printf-string-formatting + "DTZ003", # datetime.utcnow() + "PT006", # Wrong parametrize names type + "DTZ001", # datetime() without tz + "C408", # Unnecessary collection call + "RUF013", # Implicit optional + "PT009", # pytest unittest assertion + "B905", # zip-without-explicit-strict + "PLC0415", # import-outside-top-level + "PERF401", # manual-list-comprehension + "RUF012", # mutable-class-default + "RUF005", # collection-literal-concatenation + "PYI024", # collections-named-tuple + "F403", # undefined-local-with-import-star + "RUF015", # unnecessary-iterable-allocation-for-first-element + "PT019", # pytest-fixture-param-without-value + "PT007", # pytest-parametrize-values-wrong-type + "DTZ006", # call-datetime-fromtimestamp + "PT011", # pytest-raises-too-broad + "RUF059", # unused-unpacked-variable + "E701", # multiple-statements-on-one-line-colon + "B007", # unused-loop-control-variable + "LOG015", # root-logger-call + "PT012", # pytest-raises-with-multiple-statements + "G004", # logging-f-string + "A001", # builtin-variable-shadowing + "DTZ007", # call-datetime-strptime-without-zone + "RUF001", # ambiguous-unicode-character-string + "C401", # unnecessary-generator-set + "C405", # unnecessary-literal-set + "TC001", # typing-only-first-party-import + "C416", # unnecessary-comprehension + "A002", # builtin-argument-shadowing + "ICN001", # unconventional-import-alias + "EM103", # dot-format-in-exception + "FLY002", # static-join-to-f-string + "TRY301", # raise-within-try + "EXE001", # shebang-not-executable + "PLR1714", # repeated-equality-comparison + "E721", # type-comparison + "E741", # ambiguous-variable-name + "B008", # function-call-in-default-argument + "PT018", # pytest-composite-assertion + "PERF102", # incorrect-dict-iterator + "ARG004", # unused-static-method-argument + "E731", # lambda-assignment + "EXE002", # shebang-missing-executable-file + "UP008", # super-call-with-parameters + "SIM201", # negate-equal-op + "ASYNC230", # blocking-open-call-in-async-function + "ASYNC109", # async-function-with-timeout + "C417", # unnecessary-map + "UP030", # format-literals + "UP028", # yield-in-for-loop + "UP022", # replace-stdout-stderr + "UP036", # outdated-version-block + "W292", # missing-newline-at-end-of-file + "E101", # mixed-spaces-and-tabs + "COM818", # trailing-comma-on-bare-tuple + "B026", # star-arg-unpacking-after-keyword-arg + "B017", # assert-raises-exception + "B023", # function-uses-loop-variable + "B019", # cached-instance-method + "B024", # abstract-base-class-without-abstract-method + "B027", # empty-method-without-abstract-decorator + "W191", # tab-indentation + + "PERF203", # try-except within a loop + "C901", # Function is too complex + "E712", # Comparison to True/False + + "RUF003", # Ambiguous Unicode in comments + "SIM401", # if-else vs dict.get + "C419", # Unnecessary comprehension in call + "C414", # Unnecessary double cast or process + "PLC0206", # dict-index-missing-items + "C403", # unnecessary-list-comprehension-set + "TRY004", # type-check-without-type-error + "SIM112", # Uncapitalized environment variables + "PIE796", # non-unique-enums + "FURB116", # f-string-number-format + "SIM212", # if-expr-with-twisted-arms + "PIE810", # multiple-starts-ends-with + "FURB162", # fromisoformat-replace-z + "SIM211", # if-expr-with-false-true + "SIM101", # duplicate-isinstance-call + "FURB163", # redundant-log-base +] + +fixable = ["ALL"] +unfixable = [] + +dummy-variable-rgx = "^(_+|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|dummy|ignored_.*|unused_.*)$" + +[lint.per-file-ignores] + +"{**/tests/**/*.py,**/test_*.py,**/*_test.py}" = [ + "S101", + "B011", + "PT015", + "PT027", + "PLR2004", + "PLR0913", + "PLR0915", + "D", + "B008", + "PT008", + "PT011", + "SLF001", + "F811", + "F841", +] + +[lint.mccabe] +max-complexity = 10 + +[lint.pylint] +max-args = 8 +max-branches = 12 +max-returns = 6 +max-statements = 50 +max-public-methods = 20 +max-locals = 20 +max-nested-blocks = 5 +max-bool-expr = 5 + +[lint.isort] +force-single-line = true +force-sort-within-sections = true +order-by-type = false +known-local-folder = ["armis_sdk"] +lines-after-imports = 2 +combine-as-imports = false + +[lint.pydocstyle] +convention = "google" + +[lint.flake8-quotes] +inline-quotes = "double" +docstring-quotes = "double" +multiline-quotes = "double" +avoid-escape = true + +[lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false +parametrize-names-type = "tuple" +parametrize-values-type = "list" +parametrize-values-row-type = "tuple" + +[lint.pycodestyle] +max-line-length = 120 +ignore-overlong-task-comments = true + +[lint.flake8-type-checking] +strict = false +runtime-evaluated-base-classes = [ + "pydantic.BaseModel", + "armis_sdk.entities.data_export.base_exported_entity.BaseExportedEntity", +] + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +skip-magic-trailing-comma = false +docstring-code-format = true +docstring-code-line-length = 120 diff --git a/tests/armis_sdk/armis_sdk_test.py b/tests/armis_sdk/armis_sdk_test.py index a4e5ae1..363a194 100644 --- a/tests/armis_sdk/armis_sdk_test.py +++ b/tests/armis_sdk/armis_sdk_test.py @@ -5,6 +5,7 @@ from armis_sdk.clients.sites_client import SitesClient from armis_sdk.core.armis_client import ArmisClient + pytest_plugins = ["tests.plugins.setup_plugin"] diff --git a/tests/armis_sdk/clients/assets_client_test.py b/tests/armis_sdk/clients/assets_client_test.py index 3a4ab3e..0e0e97e 100644 --- a/tests/armis_sdk/clients/assets_client_test.py +++ b/tests/armis_sdk/clients/assets_client_test.py @@ -3,13 +3,15 @@ import pytest import pytest_httpx +from tests.armis_sdk.clients import assets_test_data + from armis_sdk.clients.assets_client import AssetsClient from armis_sdk.core.armis_error import ArmisError from armis_sdk.core.armis_error import BulkUpdateError from armis_sdk.entities.asset import Asset from armis_sdk.entities.asset_field_description import AssetFieldDescription from armis_sdk.entities.device import Device -from tests.armis_sdk.clients import assets_test_data + pytest_plugins = ["tests.plugins.auto_setup_plugin"] @@ -31,18 +33,12 @@ async def test_list_by_last_seen_datetime(httpx_mock: pytest_httpx.HTTPXMock): "last_seen_ge": "2025-12-03T00:00:00", }, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]}, ) assets_client = AssetsClient() last_seen = datetime.datetime(2025, 12, 3) - devices = [ - device async for device in assets_client.list_by_last_seen(Device, last_seen) - ] + devices = [device async for device in assets_client.list_by_last_seen(Device, last_seen)] assert devices == [assets_test_data.MOCK_DEVICE_FULL] @@ -62,22 +58,13 @@ async def test_list_by_last_seen_datetime_explicit_fields( "last_seen_ge": "2025-12-03T00:00:00", }, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA}]}, ) assets_client = AssetsClient() last_seen = datetime.datetime(2025, 12, 3) fields = ["brand", "custom.MyField1", "custom.MyField2", "purdue_level"] - devices = [ - device - async for device in assets_client.list_by_last_seen( - Device, last_seen, fields=fields - ) - ] + devices = [device async for device in assets_client.list_by_last_seen(Device, last_seen, fields=fields)] assert devices == [assets_test_data.MOCK_DEVICE_PARTIAL] @@ -92,18 +79,12 @@ async def test_list_by_last_seen_timedelta(httpx_mock: pytest_httpx.HTTPXMock): "fields": assets_test_data.ALL_DEVICE_FIELDS, "filter": {"filter_criteria": "LAST_SEEN", "last_seen_seconds": 3600}, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]}, ) assets_client = AssetsClient() last_seen = datetime.timedelta(hours=1) - devices = [ - device async for device in assets_client.list_by_last_seen(Device, last_seen) - ] + devices = [device async for device in assets_client.list_by_last_seen(Device, last_seen)] assert devices == [assets_test_data.MOCK_DEVICE_FULL] @@ -120,22 +101,13 @@ async def test_list_by_last_seen_timedelta_explicit_fields( "fields": ["brand", "custom.MyField1", "custom.MyField2", "purdue_level"], "filter": {"filter_criteria": "LAST_SEEN", "last_seen_seconds": 3600}, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA}]}, ) assets_client = AssetsClient() last_seen = datetime.timedelta(hours=1) fields = ["brand", "custom.MyField1", "custom.MyField2", "purdue_level"] - devices = [ - device - async for device in assets_client.list_by_last_seen( - Device, last_seen, fields=fields - ) - ] + devices = [device async for device in assets_client.list_by_last_seen(Device, last_seen, fields=fields)] assert devices == [assets_test_data.MOCK_DEVICE_PARTIAL] @@ -149,9 +121,7 @@ async def test_list_by_last_seen_invalid_fields(): ArmisError, match="The following fields are not supported with this operation: 'foo', 'bar'", ): - async for _ in assets_client.list_by_last_seen( - Device, last_seen, fields=fields - ): + async for _ in assets_client.list_by_last_seen(Device, last_seen, fields=fields): pass @@ -169,11 +139,7 @@ async def test_list_by_asset_id(httpx_mock: pytest_httpx.HTTPXMock): "asset_ids": ["1.1.1.1"], }, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_FULL_RAW_DATA}]}, ) assets_client = AssetsClient() @@ -204,11 +170,7 @@ async def test_list_by_asset_id_explicit_fields(httpx_mock: pytest_httpx.HTTPXMo "asset_ids": ["1.1.1.1"], }, }, - json={ - "items": [ - {"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA} - ] - }, + json={"items": [{"asset_id": 1, "fields": assets_test_data.MOCK_DEVICE_PARTIAL_RAW_DATA}]}, ) assets_client = AssetsClient() @@ -315,9 +277,7 @@ async def test_update_with_asset_id_source(httpx_mock: pytest_httpx.HTTPXMock): assets_client = AssetsClient() assets = [ - Device( - ipv4_addresses=["1.1.1.1"], custom={"MyField1": "value1", "MyField2": 2} - ), + Device(ipv4_addresses=["1.1.1.1"], custom={"MyField1": "value1", "MyField2": 2}), Device(ipv4_addresses=["2.2.2.2"], custom={"MyField1": "value3"}), ] fields = ["custom.MyField1", "custom.MyField2"] @@ -431,7 +391,5 @@ async def test_list_fields(httpx_mock: pytest_httpx.HTTPXMock): AssetFieldDescription(name="device_id", type="integer", is_list=False), AssetFieldDescription(name="names", type="string", is_list=True), AssetFieldDescription(name="custom.Size", type="enum", is_list=False), - AssetFieldDescription( - name="integration.qualys_agent_id", type="string", is_list=False - ), + AssetFieldDescription(name="integration.qualys_agent_id", type="string", is_list=False), ] diff --git a/tests/armis_sdk/clients/assets_test_data.py b/tests/armis_sdk/clients/assets_test_data.py index 77ad540..4220a3f 100644 --- a/tests/armis_sdk/clients/assets_test_data.py +++ b/tests/armis_sdk/clients/assets_test_data.py @@ -5,6 +5,7 @@ from armis_sdk.entities.network_interface import NetworkInterface from armis_sdk.entities.site import Site + ALL_DEVICE_FIELDS = [ "boundaries", "brand", diff --git a/tests/armis_sdk/clients/collectors_client_test.py b/tests/armis_sdk/clients/collectors_client_test.py index 5e0bb3e..a07d46c 100644 --- a/tests/armis_sdk/clients/collectors_client_test.py +++ b/tests/armis_sdk/clients/collectors_client_test.py @@ -7,6 +7,7 @@ from armis_sdk.entities.collector_image import CollectorImage from armis_sdk.entities.download_progress import DownloadProgress + pytest_plugins = ["tests.plugins.auto_setup_plugin"] @@ -72,9 +73,7 @@ async def test_download_image_to_path(httpx_mock: pytest_httpx.HTTPXMock): collectors_client = CollectorsClient() with tempfile.NamedTemporaryFile() as temp_file: - progress_items = [ - site async for site in collectors_client.download_image(temp_file.name) - ] + progress_items = [site async for site in collectors_client.download_image(temp_file.name)] assert progress_items == [ DownloadProgress(downloaded=16384, total=49151), @@ -103,9 +102,7 @@ async def test_download_image_to_file(httpx_mock: pytest_httpx.HTTPXMock): collectors_client = CollectorsClient() with tempfile.NamedTemporaryFile() as temp_file: - progress_items = [ - site async for site in collectors_client.download_image(temp_file) - ] + progress_items = [site async for site in collectors_client.download_image(temp_file)] assert progress_items == [ DownloadProgress(downloaded=16384, total=49151), diff --git a/tests/armis_sdk/clients/data_export_client_test.py b/tests/armis_sdk/clients/data_export_client_test.py index 8111499..78e1bf3 100644 --- a/tests/armis_sdk/clients/data_export_client_test.py +++ b/tests/armis_sdk/clients/data_export_client_test.py @@ -10,6 +10,7 @@ from armis_sdk.entities.data_export.base_exported_entity import BaseExportedEntity from armis_sdk.entities.data_export.data_export import DataExport + pytest_plugins = ["tests.plugins.auto_setup_plugin"] @@ -21,9 +22,7 @@ class MockEntity(BaseExportedEntity): @classmethod def series_to_model(cls, series: pandas.Series) -> "MockEntity": - return MockEntity( - name=series.loc["name"], description=series.loc["description"] - ) + return MockEntity(name=series.loc["name"], description=series.loc["description"]) async def test_disable(httpx_mock: pytest_httpx.HTTPXMock): @@ -83,9 +82,7 @@ async def test_get(httpx_mock: pytest_httpx.HTTPXMock): @mock.patch.object(pandas, "read_parquet") -async def test_export( - mock_read_parquet: mock.MagicMock, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_export(mock_read_parquet: mock.MagicMock, httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( url="https://api.armis.com/v3/data-export/mock-entity", json={ @@ -96,9 +93,7 @@ async def test_export( }, ) mock_read_parquet.side_effect = [ - pandas.DataFrame( - {"name": ["table", "chair"], "description": ["round", "high"]} - ), + pandas.DataFrame({"name": ["table", "chair"], "description": ["round", "high"]}), pandas.DataFrame({"name": ["book"], "description": ["hardcover"]}), ] diff --git a/tests/armis_sdk/clients/device_custom_properties_client_test.py b/tests/armis_sdk/clients/device_custom_properties_client_test.py index d3ad0af..1f349e1 100644 --- a/tests/armis_sdk/clients/device_custom_properties_client_test.py +++ b/tests/armis_sdk/clients/device_custom_properties_client_test.py @@ -3,12 +3,11 @@ import pytest import pytest_httpx -from armis_sdk.clients.device_custom_properties_client import ( - DeviceCustomPropertiesClient, -) +from armis_sdk.clients.device_custom_properties_client import DeviceCustomPropertiesClient from armis_sdk.core.armis_error import ArmisError from armis_sdk.entities.device_custom_property import DeviceCustomProperty + pytest_plugins = ["tests.plugins.auto_setup_plugin"] @@ -165,9 +164,7 @@ async def test_get(httpx_mock: pytest_httpx.HTTPXMock): ), ], ) -async def test_list_properties( - from_response, expected, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_list_properties(from_response, expected, httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( url="https://api.armis.com/v3/settings/device-custom-properties", method="GET", @@ -194,9 +191,7 @@ async def test_update(httpx_mock: pytest_httpx.HTTPXMock): ) client = DeviceCustomPropertiesClient() - property_ = DeviceCustomProperty( - id=1, name="mock_name", type="string", description="new_description" - ) + property_ = DeviceCustomProperty(id=1, name="mock_name", type="string", description="new_description") updated_property = await client.update(property_) assert updated_property == DeviceCustomProperty( diff --git a/tests/armis_sdk/clients/sites_client_test.py b/tests/armis_sdk/clients/sites_client_test.py index 5d1c7c5..c5a6e08 100644 --- a/tests/armis_sdk/clients/sites_client_test.py +++ b/tests/armis_sdk/clients/sites_client_test.py @@ -6,6 +6,7 @@ from armis_sdk.entities.asq_rule import AsqRule from armis_sdk.entities.site import Site + pytest_plugins = ["tests.plugins.auto_setup_plugin"] @@ -76,9 +77,7 @@ async def test_create_without_name(httpx_mock: pytest_httpx.HTTPXMock): async def test_delete(httpx_mock: pytest_httpx.HTTPXMock): - httpx_mock.add_response( - url="https://api.armis.com/v3/settings/sites/1", method="DELETE" - ) + httpx_mock.add_response(url="https://api.armis.com/v3/settings/sites/1", method="DELETE") site = Site(id=1) sites_client = SitesClient() @@ -235,9 +234,7 @@ async def test_list_sites(from_response, expected, httpx_mock: pytest_httpx.HTTP assert sites == [expected] -async def test_list_sites_with_multiple_pages( - monkeypatch, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_list_sites_with_multiple_pages(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): monkeypatch.setenv("ARMIS_PAGE_SIZE", "2") httpx_mock.add_response( url="https://api.armis.com/v3/settings/sites?limit=2", @@ -310,9 +307,7 @@ async def test_update_simple_properties(httpx_mock: pytest_httpx.HTTPXMock): site = Site(id=1, name="new_name", location="new location", parent_id=2) updated_site = await sites_client.update(site) - assert updated_site == Site( - id=1, name="new_name", location="new location", parent_id=2 - ) + assert updated_site == Site(id=1, name="new_name", location="new location", parent_id=2) async def test_update_without_id(httpx_mock: pytest_httpx.HTTPXMock): diff --git a/tests/armis_sdk/core/armis_client_test.py b/tests/armis_sdk/core/armis_client_test.py index d06cf7d..a7dda44 100644 --- a/tests/armis_sdk/core/armis_client_test.py +++ b/tests/armis_sdk/core/armis_client_test.py @@ -7,6 +7,7 @@ from armis_sdk.core.armis_client import ArmisClient + pytest_plugins = ["tests.plugins.auto_setup_plugin"] try: @@ -19,9 +20,7 @@ async def test_request_headers(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( match_headers={ "User-Agent": ( - f"Python/{platform.python_version()} " - f"python-httpx/{httpx.__version__} " - f"ArmisPythonSDK/v{VERSION}" + f"Python/{platform.python_version()} python-httpx/{httpx.__version__} ArmisPythonSDK/v{VERSION}" ), }, url="https://api.armis.com/mock/endpoint", @@ -55,9 +54,7 @@ async def test_retries(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): assert response.status_code == httpx.codes.OK -async def test_retries_with_eventual_failure( - monkeypatch, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_retries_with_eventual_failure(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): monkeypatch.setenv("ARMIS_REQUEST_RETRIES", "2") httpx_mock.add_response( url="https://api.armis.com/mock/endpoint", @@ -79,9 +76,7 @@ async def test_retries_with_eventual_failure( assert response.status_code == httpx.codes.GATEWAY_TIMEOUT -async def test_retrie_with_writable_method( - monkeypatch, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_retrie_with_writable_method(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): monkeypatch.setenv("ARMIS_REQUEST_RETRIES", "2") httpx_mock.add_response( method="POST", @@ -96,9 +91,7 @@ async def test_retrie_with_writable_method( assert response.status_code == httpx.codes.GATEWAY_TIMEOUT -async def test_list_with_multiple_pages( - monkeypatch, httpx_mock: pytest_httpx.HTTPXMock -): +async def test_list_with_multiple_pages(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): monkeypatch.setenv("ARMIS_PAGE_SIZE", "2") httpx_mock.add_response( url="https://api.armis.com/v3/settings/sites?limit=2", diff --git a/tests/armis_sdk/entities/asq_rule_test.py b/tests/armis_sdk/entities/asq_rule_test.py new file mode 100644 index 0000000..ad1cea7 --- /dev/null +++ b/tests/armis_sdk/entities/asq_rule_test.py @@ -0,0 +1,12 @@ +from armis_sdk.entities.asq_rule import AsqRule + + +def test_asq_rule_nested(): + inner = AsqRule(or_=["asq2", "asq3"]) + outer = AsqRule(and_=["asq1", inner]) + assert outer.and_ == ["asq1", inner] + + +def test_asq_rule_from_asq(): + rule = AsqRule.from_asq("deviceName:MyDevice") + assert rule.or_ == ["deviceName:MyDevice"] diff --git a/tests/armis_sdk/entities/data_export/application_test.py b/tests/armis_sdk/entities/data_export/application_test.py index 1ee4345..0994d9a 100644 --- a/tests/armis_sdk/entities/data_export/application_test.py +++ b/tests/armis_sdk/entities/data_export/application_test.py @@ -5,6 +5,7 @@ from armis_sdk.entities.data_export.application import Application + ApplicationNT = collections.namedtuple( "ApplicationNT", ["device_id", "name", "vendor", "version", "cpe", "first_seen", "last_seen"], diff --git a/tests/armis_sdk/entities/data_export/risk_factor_test.py b/tests/armis_sdk/entities/data_export/risk_factor_test.py index b35102b..446c243 100644 --- a/tests/armis_sdk/entities/data_export/risk_factor_test.py +++ b/tests/armis_sdk/entities/data_export/risk_factor_test.py @@ -7,6 +7,7 @@ from armis_sdk.entities.data_export.risk_factor import RiskFactor from armis_sdk.entities.data_export.risk_factor import RiskFactorRecommendedAction + RiskFactorNT = collections.namedtuple( "RiskFactorNT", [ diff --git a/tests/armis_sdk/entities/data_export/vulnerability_test.py b/tests/armis_sdk/entities/data_export/vulnerability_test.py index 56749e7..a77115e 100644 --- a/tests/armis_sdk/entities/data_export/vulnerability_test.py +++ b/tests/armis_sdk/entities/data_export/vulnerability_test.py @@ -5,6 +5,7 @@ from armis_sdk.entities.data_export.vulnerability import Vulnerability + VulnerabilityNT = collections.namedtuple( "VulnerabilityNT", [ diff --git a/tests/armis_sdk/entities/device_test.py b/tests/armis_sdk/entities/device_test.py new file mode 100644 index 0000000..9cf94c1 --- /dev/null +++ b/tests/armis_sdk/entities/device_test.py @@ -0,0 +1,24 @@ +import datetime + +from armis_sdk.entities.device import Device + + +def test_device_instantiation_with_datetime(): + device = Device(first_seen=datetime.datetime(2025, 1, 1), last_seen=datetime.datetime(2025, 6, 1)) + assert device.first_seen == datetime.datetime(2025, 1, 1) + assert device.last_seen == datetime.datetime(2025, 6, 1) + + +def test_device_instantiation_with_none_datetimes(): + device = Device() + assert device.first_seen is None + assert device.last_seen is None + + +def test_device_model_json_schema_includes_datetime_fields(): + """datetime.datetime fields must appear in the JSON schema (regression: TYPE_CHECKING import drops them).""" + schema = Device.model_json_schema() + props = schema.get("properties", {}) + # Fields use camelCase aliases per BaseEntity's alias_generator + assert "firstSeen" in props, f"firstSeen missing from schema properties: {sorted(props.keys())}" + assert "lastSeen" in props, f"lastSeen missing from schema properties: {sorted(props.keys())}" diff --git a/tests/armis_sdk/entities/site_test.py b/tests/armis_sdk/entities/site_test.py new file mode 100644 index 0000000..64eae0b --- /dev/null +++ b/tests/armis_sdk/entities/site_test.py @@ -0,0 +1,13 @@ +from armis_sdk.entities.site import Site + + +def test_site_with_children(): + child = Site(id=2, name="Child Site") + parent = Site(id=1, name="Parent Site", children=[child]) + assert len(parent.children) == 1 + assert parent.children[0].name == "Child Site" + + +def test_site_no_children(): + site = Site(id=1, name="HQ") + assert site.children == [] diff --git a/tests/plugins/auto_setup_plugin.py b/tests/plugins/auto_setup_plugin.py index c0bc741..6bad542 100644 --- a/tests/plugins/auto_setup_plugin.py +++ b/tests/plugins/auto_setup_plugin.py @@ -1,8 +1,9 @@ import pytest + pytest_plugins = ["tests.plugins.setup_plugin"] @pytest.fixture(autouse=True) -def auto_setup(setup_env_variables, authorized): # pylint: disable=unused-argument +def auto_setup(setup_env_variables, authorized): return