sentinel-python-client 2.0.0__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.
Files changed (44) hide show
  1. sentinel_python_client-2.0.0/.githooks/pre-commit +16 -0
  2. sentinel_python_client-2.0.0/.github/workflows/ci.yml +33 -0
  3. sentinel_python_client-2.0.0/.github/workflows/release.yml +33 -0
  4. sentinel_python_client-2.0.0/.gitignore +35 -0
  5. sentinel_python_client-2.0.0/LICENSE +21 -0
  6. sentinel_python_client-2.0.0/PKG-INFO +41 -0
  7. sentinel_python_client-2.0.0/README.md +14 -0
  8. sentinel_python_client-2.0.0/pyproject.toml +55 -0
  9. sentinel_python_client-2.0.0/src/sentinel/__init__.py +69 -0
  10. sentinel_python_client-2.0.0/src/sentinel/_async_client.py +56 -0
  11. sentinel_python_client-2.0.0/src/sentinel/_client.py +56 -0
  12. sentinel_python_client-2.0.0/src/sentinel/_exceptions.py +54 -0
  13. sentinel_python_client-2.0.0/src/sentinel/_http.py +192 -0
  14. sentinel_python_client-2.0.0/src/sentinel/_replay.py +32 -0
  15. sentinel_python_client-2.0.0/src/sentinel/_signature.py +119 -0
  16. sentinel_python_client-2.0.0/src/sentinel/models/__init__.py +24 -0
  17. sentinel_python_client-2.0.0/src/sentinel/models/license.py +63 -0
  18. sentinel_python_client-2.0.0/src/sentinel/models/page.py +15 -0
  19. sentinel_python_client-2.0.0/src/sentinel/models/requests.py +184 -0
  20. sentinel_python_client-2.0.0/src/sentinel/models/validation.py +102 -0
  21. sentinel_python_client-2.0.0/src/sentinel/services/__init__.py +1 -0
  22. sentinel_python_client-2.0.0/src/sentinel/services/_async_license.py +172 -0
  23. sentinel_python_client-2.0.0/src/sentinel/services/_async_operations.py +80 -0
  24. sentinel_python_client-2.0.0/src/sentinel/services/_license.py +193 -0
  25. sentinel_python_client-2.0.0/src/sentinel/services/_operations.py +74 -0
  26. sentinel_python_client-2.0.0/src/sentinel/util/__init__.py +5 -0
  27. sentinel_python_client-2.0.0/src/sentinel/util/fingerprint.py +383 -0
  28. sentinel_python_client-2.0.0/src/sentinel/util/public_ip.py +29 -0
  29. sentinel_python_client-2.0.0/tests/conftest.py +92 -0
  30. sentinel_python_client-2.0.0/tests/test_async_license_service.py +72 -0
  31. sentinel_python_client-2.0.0/tests/test_async_operations.py +36 -0
  32. sentinel_python_client-2.0.0/tests/test_client.py +129 -0
  33. sentinel_python_client-2.0.0/tests/test_exceptions.py +58 -0
  34. sentinel_python_client-2.0.0/tests/test_fingerprint.py +59 -0
  35. sentinel_python_client-2.0.0/tests/test_http.py +138 -0
  36. sentinel_python_client-2.0.0/tests/test_license_service.py +106 -0
  37. sentinel_python_client-2.0.0/tests/test_models.py +145 -0
  38. sentinel_python_client-2.0.0/tests/test_operations.py +93 -0
  39. sentinel_python_client-2.0.0/tests/test_page.py +23 -0
  40. sentinel_python_client-2.0.0/tests/test_public_ip.py +40 -0
  41. sentinel_python_client-2.0.0/tests/test_replay.py +44 -0
  42. sentinel_python_client-2.0.0/tests/test_request_models.py +150 -0
  43. sentinel_python_client-2.0.0/tests/test_signature.py +152 -0
  44. sentinel_python_client-2.0.0/tests/test_validation_models.py +122 -0
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+
3
+ STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM -- '*.py')
4
+
5
+ if [ -z "$STAGED_PY" ]; then
6
+ exit 0
7
+ fi
8
+
9
+ printf "Formatting Python files... "
10
+ if ruff format $STAGED_PY > /dev/null 2>&1; then
11
+ echo "$STAGED_PY" | xargs git add
12
+ echo "OK"
13
+ else
14
+ echo "FAILED"
15
+ exit 1
16
+ fi
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check src/ tests/
22
+ - run: ruff format --check src/ tests/
23
+ - run: pytest -v
24
+
25
+ typecheck:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v6
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.12"
32
+ - run: pip install -e ".[dev]"
33
+ - run: pyright src/
@@ -0,0 +1,33 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install build
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ *.whl
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Environment variables
15
+ .env
16
+
17
+ # Testing / coverage
18
+ .pytest_cache/
19
+ .coverage
20
+ htmlcov/
21
+
22
+ # Linting / type checking
23
+ .ruff_cache/
24
+ .pyright/
25
+ .mypy_cache/
26
+
27
+ # IDEs
28
+ .idea/
29
+ .vscode/
30
+ *.swp
31
+ *.swo
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Demeng Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: sentinel-python-client
3
+ Version: 2.0.0
4
+ Summary: Official Python client for the Sentinel v2 license validation and management API
5
+ Author: Demeng
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: pydantic>=2.0
19
+ Requires-Dist: pynacl>=1.5
20
+ Provides-Extra: dev
21
+ Requires-Dist: pyright>=1.1; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: respx>=0.21; extra == 'dev'
25
+ Requires-Dist: ruff>=0.8; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ [![PyPI](https://img.shields.io/pypi/v/sentinel-python-client)](https://pypi.org/project/sentinel-python-client/)
29
+
30
+ # Sentinel Python Client
31
+
32
+ Official Python client library for the [Sentinel](https://demeng.dev/sentinel) v2 API.
33
+
34
+ ## Documentation
35
+
36
+ Full documentation is available on GitBook:
37
+ [https://demeng.gitbook.io/sentinel/clients/python](https://demeng.gitbook.io/sentinel/clients/python)
38
+
39
+ ## License
40
+
41
+ [MIT](LICENSE)
@@ -0,0 +1,14 @@
1
+ [![PyPI](https://img.shields.io/pypi/v/sentinel-python-client)](https://pypi.org/project/sentinel-python-client/)
2
+
3
+ # Sentinel Python Client
4
+
5
+ Official Python client library for the [Sentinel](https://demeng.dev/sentinel) v2 API.
6
+
7
+ ## Documentation
8
+
9
+ Full documentation is available on GitBook:
10
+ [https://demeng.gitbook.io/sentinel/clients/python](https://demeng.gitbook.io/sentinel/clients/python)
11
+
12
+ ## License
13
+
14
+ [MIT](LICENSE)
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sentinel-python-client"
7
+ version = "2.0.0"
8
+ description = "Official Python client for the Sentinel v2 license validation and management API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Demeng" }]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27",
25
+ "pydantic>=2.0",
26
+ "PyNaCl>=1.5",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "respx>=0.21",
33
+ "pytest-asyncio>=0.24",
34
+ "ruff>=0.8",
35
+ "pyright>=1.1",
36
+ ]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/sentinel"]
40
+
41
+ [tool.pytest.ini_options]
42
+ asyncio_mode = "auto"
43
+ testpaths = ["tests"]
44
+ pythonpath = ["."]
45
+
46
+ [tool.ruff]
47
+ target-version = "py311"
48
+ line-length = 99
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP"]
52
+
53
+ [tool.pyright]
54
+ pythonVersion = "3.11"
55
+ typeCheckingMode = "standard"
@@ -0,0 +1,69 @@
1
+ """Sentinel Python Client - Official client for the Sentinel v2 API."""
2
+
3
+ from sentinel._async_client import AsyncSentinelClient
4
+ from sentinel._client import SentinelClient
5
+ from sentinel._exceptions import (
6
+ LicenseValidationError,
7
+ ReplayDetectedError,
8
+ SentinelApiError,
9
+ SentinelConnectionError,
10
+ SentinelError,
11
+ SignatureVerificationError,
12
+ )
13
+ from sentinel.models.license import (
14
+ BlacklistInfo,
15
+ License,
16
+ LicenseIssuer,
17
+ LicenseProduct,
18
+ LicenseTier,
19
+ SubUser,
20
+ )
21
+ from sentinel.models.page import Page
22
+ from sentinel.models.requests import (
23
+ CLEAR,
24
+ CLEAR_EXPIRATION,
25
+ CreateLicenseRequest,
26
+ ListLicensesRequest,
27
+ UpdateLicenseRequest,
28
+ ValidationRequest,
29
+ )
30
+ from sentinel.models.validation import (
31
+ BlacklistFailureDetails,
32
+ ExcessiveIpsFailureDetails,
33
+ ExcessiveServersFailureDetails,
34
+ FailureDetails,
35
+ ValidationDetails,
36
+ ValidationResult,
37
+ ValidationResultType,
38
+ )
39
+
40
+ __all__ = [
41
+ "SentinelClient",
42
+ "AsyncSentinelClient",
43
+ "SentinelError",
44
+ "SentinelApiError",
45
+ "SentinelConnectionError",
46
+ "LicenseValidationError",
47
+ "SignatureVerificationError",
48
+ "ReplayDetectedError",
49
+ "License",
50
+ "LicenseProduct",
51
+ "LicenseTier",
52
+ "LicenseIssuer",
53
+ "SubUser",
54
+ "BlacklistInfo",
55
+ "ValidationResult",
56
+ "ValidationResultType",
57
+ "ValidationDetails",
58
+ "FailureDetails",
59
+ "BlacklistFailureDetails",
60
+ "ExcessiveServersFailureDetails",
61
+ "ExcessiveIpsFailureDetails",
62
+ "CreateLicenseRequest",
63
+ "UpdateLicenseRequest",
64
+ "ListLicensesRequest",
65
+ "ValidationRequest",
66
+ "CLEAR",
67
+ "CLEAR_EXPIRATION",
68
+ "Page",
69
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from sentinel._http import AsyncSentinelHttpClient
6
+ from sentinel._replay import ReplayProtector
7
+ from sentinel._signature import SignatureVerifier
8
+ from sentinel.services._async_license import AsyncLicenseService
9
+
10
+
11
+ class AsyncSentinelClient:
12
+ def __init__(
13
+ self,
14
+ base_url: str,
15
+ api_key: str,
16
+ public_key: str | None = None,
17
+ connect_timeout: float = 5.0,
18
+ read_timeout: float = 10.0,
19
+ replay_protection_window: timedelta = timedelta(seconds=30),
20
+ nonce_cache_size: int = 1000,
21
+ ) -> None:
22
+ if not base_url:
23
+ raise ValueError("base_url is required")
24
+ if not api_key:
25
+ raise ValueError("api_key is required")
26
+
27
+ self._http = AsyncSentinelHttpClient(
28
+ base_url=base_url,
29
+ api_key=api_key,
30
+ connect_timeout=connect_timeout,
31
+ read_timeout=read_timeout,
32
+ )
33
+
34
+ sig: SignatureVerifier | None = None
35
+ replay: ReplayProtector | None = None
36
+ if public_key is not None:
37
+ sig = SignatureVerifier(public_key)
38
+ replay = ReplayProtector(
39
+ window_seconds=replay_protection_window.total_seconds(),
40
+ max_size=nonce_cache_size,
41
+ )
42
+
43
+ self.licenses = AsyncLicenseService(
44
+ http_client=self._http,
45
+ signature_verifier=sig,
46
+ replay_protector=replay,
47
+ )
48
+
49
+ async def aclose(self) -> None:
50
+ await self._http.aclose()
51
+
52
+ async def __aenter__(self) -> AsyncSentinelClient:
53
+ return self
54
+
55
+ async def __aexit__(self, *args: object) -> None:
56
+ await self.aclose()
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from sentinel._http import SentinelHttpClient
6
+ from sentinel._replay import ReplayProtector
7
+ from sentinel._signature import SignatureVerifier
8
+ from sentinel.services._license import LicenseService
9
+
10
+
11
+ class SentinelClient:
12
+ def __init__(
13
+ self,
14
+ base_url: str,
15
+ api_key: str,
16
+ public_key: str | None = None,
17
+ connect_timeout: float = 5.0,
18
+ read_timeout: float = 10.0,
19
+ replay_protection_window: timedelta = timedelta(seconds=30),
20
+ nonce_cache_size: int = 1000,
21
+ ) -> None:
22
+ if not base_url:
23
+ raise ValueError("base_url is required")
24
+ if not api_key:
25
+ raise ValueError("api_key is required")
26
+
27
+ self._http = SentinelHttpClient(
28
+ base_url=base_url,
29
+ api_key=api_key,
30
+ connect_timeout=connect_timeout,
31
+ read_timeout=read_timeout,
32
+ )
33
+
34
+ sig: SignatureVerifier | None = None
35
+ replay: ReplayProtector | None = None
36
+ if public_key is not None:
37
+ sig = SignatureVerifier(public_key)
38
+ replay = ReplayProtector(
39
+ window_seconds=replay_protection_window.total_seconds(),
40
+ max_size=nonce_cache_size,
41
+ )
42
+
43
+ self.licenses = LicenseService(
44
+ http_client=self._http,
45
+ signature_verifier=sig,
46
+ replay_protector=replay,
47
+ )
48
+
49
+ def close(self) -> None:
50
+ self._http.close()
51
+
52
+ def __enter__(self) -> SentinelClient:
53
+ return self
54
+
55
+ def __exit__(self, *args: object) -> None:
56
+ self.close()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from sentinel.models.validation import ValidationResultType
7
+
8
+
9
+ class SentinelError(Exception):
10
+ pass
11
+
12
+
13
+ class SentinelApiError(SentinelError):
14
+ def __init__(
15
+ self,
16
+ http_status: int,
17
+ type: str | None,
18
+ message: str,
19
+ retry_after_seconds: float | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.http_status = http_status
24
+ self.type = type
25
+ self.retry_after_seconds = retry_after_seconds
26
+
27
+
28
+ class SentinelConnectionError(SentinelError):
29
+ def __init__(self, message: str, cause: Exception | None = None) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ if cause is not None:
33
+ self.__cause__ = cause
34
+
35
+
36
+ class LicenseValidationError(SentinelError):
37
+ def __init__(
38
+ self,
39
+ type: ValidationResultType,
40
+ message: str,
41
+ failure_details: object | None = None,
42
+ ) -> None:
43
+ super().__init__(message)
44
+ self.message = message
45
+ self.type = type
46
+ self.failure_details = failure_details
47
+
48
+
49
+ class SignatureVerificationError(SentinelError):
50
+ pass
51
+
52
+
53
+ class ReplayDetectedError(SentinelError):
54
+ pass
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+
10
+ from sentinel._exceptions import SentinelApiError, SentinelConnectionError
11
+
12
+
13
+ @dataclass
14
+ class ApiResponse:
15
+ http_status: int
16
+ type: str | None
17
+ message: str | None
18
+ result: dict[str, Any] | None
19
+
20
+ def require_result(self) -> dict[str, Any]:
21
+ if self.result is None:
22
+ raise SentinelApiError(
23
+ http_status=self.http_status,
24
+ type=self.type,
25
+ message="Response contained no result",
26
+ )
27
+ return self.result
28
+
29
+
30
+ def _parse_response(
31
+ status_code: int,
32
+ body: str,
33
+ headers: httpx.Headers,
34
+ allowed_statuses: set[int] | None,
35
+ ) -> ApiResponse:
36
+ if status_code == 204:
37
+ return ApiResponse(http_status=204, type=None, message=None, result=None)
38
+
39
+ try:
40
+ data = json.loads(body)
41
+ except (json.JSONDecodeError, ValueError) as e:
42
+ raise SentinelApiError(
43
+ http_status=status_code, type=None, message="Failed to parse response body"
44
+ ) from e
45
+
46
+ resp_type = data.get("type")
47
+ message = data.get("message")
48
+ result = data.get("result") if isinstance(data.get("result"), dict) else None
49
+
50
+ if 200 <= status_code < 300:
51
+ return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
52
+
53
+ if allowed_statuses and status_code in allowed_statuses:
54
+ return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
55
+
56
+ retry_after: float | None = None
57
+ if status_code == 429:
58
+ raw = headers.get("X-Rate-Limit-Retry-After-Seconds")
59
+ if raw is not None:
60
+ try:
61
+ retry_after = int(raw)
62
+ except ValueError:
63
+ pass
64
+
65
+ raise SentinelApiError(
66
+ http_status=status_code,
67
+ type=resp_type,
68
+ message=message or "Unknown error",
69
+ retry_after_seconds=retry_after,
70
+ )
71
+
72
+
73
+ def _build_url(
74
+ base_url: str,
75
+ path: str,
76
+ query_params: dict[str, str] | None = None,
77
+ multi_query_params: dict[str, list[str]] | None = None,
78
+ ) -> str:
79
+ url = base_url + path
80
+ if multi_query_params:
81
+ parts: list[str] = []
82
+ for key in sorted(multi_query_params):
83
+ for val in sorted(multi_query_params[key]):
84
+ parts.append(f"{urlencode({key: val})}")
85
+ url += "?" + "&".join(parts)
86
+ return url
87
+ if query_params:
88
+ sorted_params = sorted(query_params.items())
89
+ url += "?" + urlencode(sorted_params)
90
+ return url
91
+
92
+
93
+ class SentinelHttpClient:
94
+ def __init__(
95
+ self,
96
+ base_url: str,
97
+ api_key: str,
98
+ connect_timeout: float = 5.0,
99
+ read_timeout: float = 10.0,
100
+ http_client: httpx.Client | None = None,
101
+ ) -> None:
102
+ self._base_url = base_url.rstrip("/")
103
+ self._api_key = api_key
104
+ if http_client is not None:
105
+ self._client = http_client
106
+ else:
107
+ self._client = httpx.Client(
108
+ timeout=httpx.Timeout(
109
+ connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
110
+ ),
111
+ )
112
+
113
+ def request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ json_body: dict[str, Any] | list[Any] | None = None,
118
+ query_params: dict[str, str] | None = None,
119
+ multi_query_params: dict[str, list[str]] | None = None,
120
+ allowed_statuses: set[int] | None = None,
121
+ ) -> ApiResponse:
122
+ url = _build_url(self._base_url, path, query_params, multi_query_params)
123
+ headers = {"Authorization": f"Bearer {self._api_key}"}
124
+ kwargs: dict[str, Any] = {"headers": headers}
125
+
126
+ if json_body is not None:
127
+ headers["Content-Type"] = "application/json"
128
+ kwargs["content"] = json.dumps(json_body)
129
+ try:
130
+ response = self._client.request(method, url, **kwargs)
131
+ except httpx.TimeoutException as e:
132
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
133
+ except httpx.NetworkError as e:
134
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
135
+
136
+ return _parse_response(
137
+ response.status_code, response.text, response.headers, allowed_statuses
138
+ )
139
+
140
+ def close(self) -> None:
141
+ self._client.close()
142
+
143
+
144
+ class AsyncSentinelHttpClient:
145
+ def __init__(
146
+ self,
147
+ base_url: str,
148
+ api_key: str,
149
+ connect_timeout: float = 5.0,
150
+ read_timeout: float = 10.0,
151
+ http_client: httpx.AsyncClient | None = None,
152
+ ) -> None:
153
+ self._base_url = base_url.rstrip("/")
154
+ self._api_key = api_key
155
+ if http_client is not None:
156
+ self._client = http_client
157
+ else:
158
+ self._client = httpx.AsyncClient(
159
+ timeout=httpx.Timeout(
160
+ connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
161
+ ),
162
+ )
163
+
164
+ async def request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ json_body: dict[str, Any] | list[Any] | None = None,
169
+ query_params: dict[str, str] | None = None,
170
+ multi_query_params: dict[str, list[str]] | None = None,
171
+ allowed_statuses: set[int] | None = None,
172
+ ) -> ApiResponse:
173
+ url = _build_url(self._base_url, path, query_params, multi_query_params)
174
+ headers = {"Authorization": f"Bearer {self._api_key}"}
175
+ kwargs: dict[str, Any] = {"headers": headers}
176
+
177
+ if json_body is not None:
178
+ headers["Content-Type"] = "application/json"
179
+ kwargs["content"] = json.dumps(json_body)
180
+ try:
181
+ response = await self._client.request(method, url, **kwargs)
182
+ except httpx.TimeoutException as e:
183
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
184
+ except httpx.NetworkError as e:
185
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
186
+
187
+ return _parse_response(
188
+ response.status_code, response.text, response.headers, allowed_statuses
189
+ )
190
+
191
+ async def aclose(self) -> None:
192
+ await self._client.aclose()