pjdev-armis-sdk 5.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ .DS_Store
2
+ .idea
3
+
4
+ !**/.gitkeep
5
+
6
+ # Byte-compiled / optimized / DLL files
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+
11
+ # C extensions
12
+ *.so
13
+
14
+ # Distribution / packaging
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+
44
+ # Unit test / coverage reports
45
+ htmlcov/
46
+ .tox/
47
+ .nox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ *.py,cover
55
+ .hypothesis/
56
+ .pytest_cache/
57
+ cover/
58
+
59
+ # Translations
60
+ *.mo
61
+ *.pot
62
+
63
+ # Django stuff:
64
+ *.log
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ .pybuilder/
81
+ target/
82
+
83
+ # Jupyter Notebook
84
+ .ipynb_checkpoints
85
+
86
+ # IPython
87
+ profile_default/
88
+ ipython_config.py
89
+
90
+ # pyenv
91
+ # For a library or package, you might want to ignore these files since the code is
92
+ # intended to run in multiple environments; otherwise, check them in:
93
+ # .python-version
94
+
95
+ # pipenv
96
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
98
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
99
+ # install all needed dependencies.
100
+ #Pipfile.lock
101
+
102
+ # poetry
103
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
104
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
105
+ # commonly ignored for libraries.
106
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
107
+ #poetry.lock
108
+
109
+ # pdm
110
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
111
+ #pdm.lock
112
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
113
+ # in version control.
114
+ # https://pdm.fming.dev/#use-with-ide
115
+ .pdm.toml
116
+
117
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
118
+ __pypackages__/
119
+
120
+ # Celery stuff
121
+ celerybeat-schedule
122
+ celerybeat.pid
123
+
124
+ # SageMath parsed files
125
+ *.sage.py
126
+
127
+ # Environments
128
+ .env
129
+ .venv
130
+ env/
131
+ venv/
132
+ ENV/
133
+ env.bak/
134
+ venv.bak/
135
+
136
+ # Spyder project settings
137
+ .spyderproject
138
+ .spyproject
139
+
140
+ # Rope project settings
141
+ .ropeproject
142
+
143
+ # mkdocs documentation
144
+ /site
145
+
146
+ # mypy
147
+ .mypy_cache/
148
+ .dmypy.json
149
+ dmypy.json
150
+
151
+ # Pyre type checker
152
+ .pyre/
153
+
154
+ # pytype static type analyzer
155
+ .pytype/
156
+
157
+ # Cython debug symbols
158
+ cython_debug/
159
+
160
+ # PyCharm
161
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
162
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
163
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
164
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
165
+ #.idea/
166
+
167
+ **/uv.lock
168
+ !./uv.lock
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Chris O'Neill <chris@purplejay.io>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: pjdev-armis-sdk
3
+ Version: 5.1.4
4
+ Summary: Async Python SDK for the Armis API
5
+ Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-armis-sdk/README.md
6
+ Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
7
+ Project-URL: Source, https://gitlab.purplejay.io/keystone/python
8
+ Author-email: Purple Jay LLC <developers@purplejay.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.txt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: Implementation :: CPython
15
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: httpx
18
+ Requires-Dist: loguru
19
+ Requires-Dist: pydantic-settings>=2.13.1
20
+ Requires-Dist: pydantic>=2.12.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: ruff; extra == 'dev'
23
+ Provides-Extra: test
24
+ Requires-Dist: coverage; extra == 'test'
25
+ Requires-Dist: pytest; extra == 'test'
26
+ Requires-Dist: pytest-asyncio; extra == 'test'
27
+ Requires-Dist: respx; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # pjdev-armis-sdk
31
+
32
+ [![PyPI - Version](https://img.shields.io/pypi/v/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)
33
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)
34
+
35
+ -----
36
+
37
+ Async Python SDK for the [Armis](https://www.armis.com/) API, built on `httpx` and `pydantic`.
38
+
39
+ ## Table of Contents
40
+
41
+ - [Installation](#installation)
42
+ - [Usage](#usage)
43
+ - [License](#license)
44
+
45
+ ## Installation
46
+
47
+ ```console
48
+ pip install pjdev-armis-sdk
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ Configure once at startup (reads from environment variables prefixed with `ARMIS_`):
54
+
55
+ ```python
56
+ from pjdev_armis_sdk import config_service
57
+
58
+ config_service.init(
59
+ instance_url="https://your-tenant.armis.com",
60
+ secret_key="your-secret-key",
61
+ )
62
+ ```
63
+
64
+ Then call any of the resource modules:
65
+
66
+ ```python
67
+ from pjdev_armis_sdk import devices, search
68
+
69
+ # AQL search
70
+ result = await search.aql_search("in:devices type:MOBILE_PHONE")
71
+
72
+ # Lookup a single device by id
73
+ device = await devices.get_device(id=12345)
74
+ ```
75
+
76
+ Auth (`Authorization` header with a temporary access token) is handled automatically
77
+ by the underlying `httpx.AsyncClient`. Tokens are refreshed on the fly when they expire
78
+ or when the API returns 401.
79
+
80
+ ## License
81
+
82
+ `pjdev-armis-sdk` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,53 @@
1
+ # pjdev-armis-sdk
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)
5
+
6
+ -----
7
+
8
+ Async Python SDK for the [Armis](https://www.armis.com/) API, built on `httpx` and `pydantic`.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Installation](#installation)
13
+ - [Usage](#usage)
14
+ - [License](#license)
15
+
16
+ ## Installation
17
+
18
+ ```console
19
+ pip install pjdev-armis-sdk
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Configure once at startup (reads from environment variables prefixed with `ARMIS_`):
25
+
26
+ ```python
27
+ from pjdev_armis_sdk import config_service
28
+
29
+ config_service.init(
30
+ instance_url="https://your-tenant.armis.com",
31
+ secret_key="your-secret-key",
32
+ )
33
+ ```
34
+
35
+ Then call any of the resource modules:
36
+
37
+ ```python
38
+ from pjdev_armis_sdk import devices, search
39
+
40
+ # AQL search
41
+ result = await search.aql_search("in:devices type:MOBILE_PHONE")
42
+
43
+ # Lookup a single device by id
44
+ device = await devices.get_device(id=12345)
45
+ ```
46
+
47
+ Auth (`Authorization` header with a temporary access token) is handled automatically
48
+ by the underlying `httpx.AsyncClient`. Tokens are refreshed on the fly when they expire
49
+ or when the API returns 401.
50
+
51
+ ## License
52
+
53
+ `pjdev-armis-sdk` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,90 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pjdev-armis-sdk"
7
+ dynamic = ["version"]
8
+ description = 'Async Python SDK for the Armis API'
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ keywords = []
13
+ authors = [
14
+ { name = "Purple Jay LLC", email = "developers@purplejay.io" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: Implementation :: CPython",
21
+ "Programming Language :: Python :: Implementation :: PyPy",
22
+ ]
23
+ dependencies = [
24
+ "httpx",
25
+ "loguru",
26
+ "pydantic-settings>=2.13.1",
27
+ "pydantic>=2.12.5",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "ruff",
33
+ ]
34
+
35
+ test = [
36
+ "pytest",
37
+ "pytest-asyncio",
38
+ "coverage",
39
+ "respx",
40
+ ]
41
+
42
+ [project.urls]
43
+ Documentation = "https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-armis-sdk/README.md"
44
+ Issues = "https://gitlab.purplejay.io/keystone/python/-/issues"
45
+ Source = "https://gitlab.purplejay.io/keystone/python"
46
+
47
+ [tool.hatch.version]
48
+ path = "src/pjdev_armis_sdk/__about__.py"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/pjdev_armis_sdk"]
52
+
53
+ [tool.hatch.build.targets.wheel.force-include]
54
+ "src/pjdev_armis_sdk/py.typed" = "pjdev_armis_sdk/py.typed"
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ include = [
58
+ "src/pjdev_armis_sdk",
59
+ "README.md",
60
+ "LICENSE.txt",
61
+ ]
62
+
63
+ [tool.hatch.envs.types]
64
+ extra-dependencies = [
65
+ "mypy>=1.0.0",
66
+ ]
67
+ [tool.hatch.envs.types.scripts]
68
+ check = "mypy --install-types --non-interactive {args:src/pjdev_armis_sdk tests}"
69
+
70
+ [tool.coverage.run]
71
+ source_pkgs = ["pjdev_armis_sdk", "tests"]
72
+ branch = true
73
+ parallel = true
74
+ omit = [
75
+ "src/pjdev_armis_sdk/__about__.py",
76
+ ]
77
+
78
+ [tool.coverage.paths]
79
+ pjdev_armis_sdk = ["src/pjdev_armis_sdk", "*/pjdev-armis-sdk/src/pjdev_armis_sdk"]
80
+ tests = ["tests", "*/pjdev-armis-sdk/tests"]
81
+
82
+ [tool.coverage.report]
83
+ exclude_lines = [
84
+ "no cov",
85
+ "if __name__ == .__main__.:",
86
+ "if TYPE_CHECKING:",
87
+ ]
88
+
89
+ [tool.pytest.ini_options]
90
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "5.1.4"
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from pjdev_armis_sdk import (
6
+ access_token,
7
+ api_utilities,
8
+ boundaries,
9
+ config_service,
10
+ devices,
11
+ http_client,
12
+ integrations,
13
+ models,
14
+ search,
15
+ sites,
16
+ users,
17
+ )
18
+
19
+ __all__ = [
20
+ "access_token",
21
+ "api_utilities",
22
+ "boundaries",
23
+ "config_service",
24
+ "devices",
25
+ "http_client",
26
+ "integrations",
27
+ "models",
28
+ "search",
29
+ "sites",
30
+ "users",
31
+ ]
@@ -0,0 +1,32 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import Any, AsyncIterator, Awaitable, Callable, Optional, TypeVar
3
+
4
+ import httpx
5
+
6
+ from pjdev_armis_sdk.http_client import http_client
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ @asynccontextmanager
12
+ async def _resolve_client(
13
+ client: Optional[httpx.AsyncClient],
14
+ ) -> AsyncIterator[httpx.AsyncClient]:
15
+ if client is not None:
16
+ yield client
17
+ return
18
+ async with http_client() as _client:
19
+ yield _client
20
+
21
+
22
+ async def with_client(
23
+ client: Optional[httpx.AsyncClient],
24
+ fn: Callable[[httpx.AsyncClient], Awaitable[T]],
25
+ ) -> T:
26
+ """Run `fn` against either the caller-provided client or a fresh one."""
27
+ async with _resolve_client(client) as c:
28
+ return await fn(c)
29
+
30
+
31
+ def drop_none(params: dict[str, Any]) -> dict[str, Any]:
32
+ return {k: v for k, v in params.items() if v is not None}
@@ -0,0 +1,37 @@
1
+ from typing import Optional
2
+
3
+ import httpx
4
+
5
+ from pjdev_armis_sdk.api_utilities import async_retry_http
6
+ from pjdev_armis_sdk.config_service import get_config
7
+ from pjdev_armis_sdk.models import ArmisAccessTokenResponse, ArmisTokenData
8
+
9
+
10
+ @async_retry_http(status_codes_to_ignore=[400, 401, 403])
11
+ async def get_access_token(
12
+ secret_key: Optional[str] = None,
13
+ ) -> ArmisTokenData:
14
+ """
15
+ Exchange a secret_key for a temporary Armis access token.
16
+
17
+ This is normally handled automatically by `ArmisAccessTokenAuth` on the shared
18
+ `httpx.AsyncClient`. Call this directly only when you need the raw token (e.g.
19
+ for inspection, or to pass to a non-SDK consumer).
20
+ """
21
+ config = get_config()
22
+ key = secret_key or config.secret_key
23
+ if not key:
24
+ raise ValueError("secret_key not provided and not configured")
25
+ if not config.instance_url:
26
+ raise ValueError("instance_url not configured")
27
+
28
+ async with httpx.AsyncClient(
29
+ base_url=config.instance_url,
30
+ timeout=config.request_timeout_seconds,
31
+ ) as client:
32
+ r = await client.post(
33
+ "/api/v1/access_token/",
34
+ data={"secret_key": key},
35
+ )
36
+ r.raise_for_status()
37
+ return ArmisAccessTokenResponse.model_validate(r.json()).data
@@ -0,0 +1,110 @@
1
+ import asyncio
2
+ import time
3
+ from functools import wraps
4
+ from typing import Any, Awaitable, Callable, List, Optional, ParamSpec, TypeVar
5
+
6
+ import httpx
7
+ from httpx import ConnectError, HTTPStatusError
8
+ from loguru import logger
9
+
10
+ from pjdev_armis_sdk.config_service import get_config
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ async def log_request_headers(request: httpx.Request) -> None:
17
+ logger.debug(f"Request: {request.method} {request.url}")
18
+
19
+
20
+ async def log_response_headers(response: httpx.Response) -> None:
21
+ logger.debug(
22
+ f"Response: {response.request.method} {response.request.url} -> {response.status_code}"
23
+ )
24
+
25
+
26
+ def async_retry_http(
27
+ default_value: Optional[Any] = None,
28
+ status_codes_to_ignore: Optional[List[int]] = None,
29
+ ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
30
+ def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
31
+ @wraps(func)
32
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
33
+ config = get_config()
34
+ max_attempts = config.http_retry_max_count
35
+ delay_seconds = config.http_retry_delay_seconds
36
+ attempts = 0
37
+ exceptions: List[Exception] = []
38
+
39
+ while attempts < max_attempts:
40
+ try:
41
+ return await func(*args, **kwargs)
42
+ except HTTPStatusError as e:
43
+ logger.warning(
44
+ f"{e.response.status_code}: {e.response.reason_phrase} - {e.response.text}"
45
+ )
46
+ exceptions.append(e)
47
+ if (
48
+ status_codes_to_ignore
49
+ and e.response.status_code in status_codes_to_ignore
50
+ ):
51
+ break
52
+ except ConnectError as e:
53
+ logger.warning(f"{e.request.url} not reachable")
54
+ exceptions.append(e)
55
+
56
+ attempts += 1
57
+ if attempts == max_attempts:
58
+ break
59
+ total_delay = delay_seconds**attempts
60
+ logger.warning(
61
+ f"Attempt {attempts}/{max_attempts} failed. Retrying in {total_delay} seconds..."
62
+ )
63
+ await asyncio.sleep(total_delay)
64
+
65
+ if default_value is None:
66
+ raise ExceptionGroup(
67
+ f"Failed after {max_attempts} attempts", exceptions
68
+ )
69
+ logger.error(f"Failed after {max_attempts} attempts")
70
+ return default_value
71
+
72
+ return wrapper
73
+
74
+ return decorator
75
+
76
+
77
+ def record_time(
78
+ log: bool = False,
79
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
80
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
81
+ if asyncio.iscoroutinefunction(func):
82
+
83
+ @wraps(func)
84
+ async def wrap_func_async(*args: P.args, **kwargs: P.kwargs) -> Any:
85
+ t1 = time.time()
86
+ result = await func(*args, **kwargs)
87
+ t2 = time.time()
88
+ if log:
89
+ logger.info(
90
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
91
+ )
92
+ return result
93
+
94
+ return wrap_func_async # type: ignore[return-value]
95
+
96
+ @wraps(func)
97
+ def wrap_func(*args: P.args, **kwargs: P.kwargs) -> R:
98
+ t1 = time.time()
99
+ result = func(*args, **kwargs)
100
+ t2 = time.time()
101
+ if log:
102
+ logger.info(
103
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
104
+ )
105
+
106
+ return result
107
+
108
+ return wrap_func
109
+
110
+ return decorator