sentinel-python-client 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields
4
+ from datetime import UTC, datetime
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from sentinel.models.license import SubUser
9
+
10
+
11
+ class _ClearValue:
12
+ """Sentinel indicating a field should be cleared in the API."""
13
+
14
+ _instance: _ClearValue | None = None
15
+
16
+ def __new__(cls) -> _ClearValue:
17
+ if cls._instance is None:
18
+ cls._instance = super().__new__(cls)
19
+ return cls._instance
20
+
21
+ def __repr__(self) -> str:
22
+ return "CLEAR"
23
+
24
+
25
+ CLEAR: Any = _ClearValue()
26
+ """Pass as a field value in :class:`UpdateLicenseRequest` to clear the stored value.
27
+ Sends the appropriate empty/reset value per field type (empty string for text fields,
28
+ empty collection for collection fields, epoch for expiration)."""
29
+
30
+ CLEAR_EXPIRATION = datetime(1970, 1, 1, tzinfo=UTC)
31
+ """Pass as ``expiration`` in :class:`UpdateLicenseRequest` to clear a license's expiration
32
+ (i.e. set it to never expire). The API interprets the Unix epoch as "no expiration"."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class CreateLicenseRequest:
37
+ product: str
38
+ key: str | None = None
39
+ tier: str | None = None
40
+ expiration: datetime | None = None
41
+ max_servers: int | None = None
42
+ max_ips: int | None = None
43
+ note: str | None = None
44
+ connections: dict[str, str] | None = None
45
+ additional_entitlements: set[str] | None = None
46
+
47
+ def to_body(self) -> dict[str, Any]:
48
+ body: dict[str, Any] = {"product": self.product}
49
+ if self.key is not None:
50
+ body["key"] = self.key
51
+ if self.tier is not None:
52
+ body["tier"] = self.tier
53
+ if self.expiration is not None:
54
+ body["expiration"] = self.expiration.isoformat()
55
+ if self.max_servers is not None:
56
+ body["maxServers"] = self.max_servers
57
+ if self.max_ips is not None:
58
+ body["maxIps"] = self.max_ips
59
+ if self.note is not None:
60
+ body["note"] = self.note
61
+ if self.connections is not None:
62
+ body["connections"] = self.connections
63
+ if self.additional_entitlements is not None:
64
+ body["additionalEntitlements"] = list(self.additional_entitlements)
65
+ return body
66
+
67
+
68
+ _CLEAR_JSON_VALUES: dict[str, Any] = {
69
+ "connections": {},
70
+ "subUsers": [],
71
+ "servers": [],
72
+ "ips": [],
73
+ "additionalEntitlements": [],
74
+ "expiration": datetime(1970, 1, 1, tzinfo=UTC).isoformat(),
75
+ }
76
+
77
+ _FIELD_TO_JSON: dict[str, str] = {
78
+ "product": "product",
79
+ "tier": "tier",
80
+ "expiration": "expiration",
81
+ "max_servers": "maxServers",
82
+ "max_ips": "maxIps",
83
+ "note": "note",
84
+ "blacklist_reason": "blacklistReason",
85
+ "connections": "connections",
86
+ "sub_users": "subUsers",
87
+ "servers": "servers",
88
+ "ips": "ips",
89
+ "additional_entitlements": "additionalEntitlements",
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class UpdateLicenseRequest:
95
+ product: str | None = None
96
+ tier: str | None = None
97
+ expiration: datetime | None = None
98
+ max_servers: int | None = None
99
+ max_ips: int | None = None
100
+ note: str | None = None
101
+ blacklist_reason: str | None = None
102
+ connections: dict[str, str] | None = None
103
+ sub_users: list[SubUser] | None = None
104
+ servers: set[str] | None = None
105
+ ips: set[str] | None = None
106
+ additional_entitlements: set[str] | None = None
107
+
108
+ def to_body(self) -> dict[str, Any]:
109
+ body: dict[str, Any] = {}
110
+ for f in fields(self):
111
+ value = getattr(self, f.name)
112
+ json_key = _FIELD_TO_JSON.get(f.name, f.name)
113
+ if isinstance(value, _ClearValue):
114
+ body[json_key] = _CLEAR_JSON_VALUES.get(json_key, "")
115
+ elif value is not None:
116
+ if isinstance(value, datetime):
117
+ body[json_key] = value.isoformat()
118
+ elif isinstance(value, set):
119
+ body[json_key] = list(value)
120
+ elif isinstance(value, list):
121
+ body[json_key] = [
122
+ item.model_dump() if hasattr(item, "model_dump") else item
123
+ for item in value
124
+ ]
125
+ else:
126
+ body[json_key] = value
127
+ return body
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ListLicensesRequest:
132
+ product: str | None = None
133
+ status: str | None = None
134
+ query: str | None = None
135
+ platform: str | None = None
136
+ value: str | None = None
137
+ server: str | None = None
138
+ ip: str | None = None
139
+ page: int = 0
140
+ size: int = 50
141
+
142
+ def to_query_params(self) -> dict[str, str]:
143
+ params: dict[str, str] = {}
144
+ if self.product is not None:
145
+ params["product"] = self.product
146
+ if self.status is not None:
147
+ params["status"] = self.status
148
+ if self.query is not None:
149
+ params["query"] = self.query
150
+ if self.platform is not None:
151
+ params["platform"] = self.platform
152
+ if self.value is not None:
153
+ params["value"] = self.value
154
+ if self.server is not None:
155
+ params["server"] = self.server
156
+ if self.ip is not None:
157
+ params["ip"] = self.ip
158
+ params["page"] = str(self.page)
159
+ params["size"] = str(self.size)
160
+ return params
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class ValidationRequest:
165
+ product: str
166
+ key: str | None = None
167
+ server: str | None = None
168
+ ip: str | None = None
169
+ connection_platform: str | None = None
170
+ connection_value: str | None = None
171
+
172
+ def to_body(self) -> dict[str, str]:
173
+ body: dict[str, str] = {"product": self.product}
174
+ if self.key is not None:
175
+ body["key"] = self.key
176
+ if self.server is not None:
177
+ body["server"] = self.server
178
+ if self.ip is not None:
179
+ body["ip"] = self.ip
180
+ if self.connection_platform is not None:
181
+ body["connectionPlatform"] = self.connection_platform
182
+ if self.connection_value is not None:
183
+ body["connectionValue"] = self.connection_value
184
+ return body
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ from sentinel._exceptions import LicenseValidationError
8
+
9
+
10
+ class ValidationResultType(StrEnum):
11
+ SUCCESS = "SUCCESS"
12
+ INVALID_PRODUCT = "INVALID_PRODUCT"
13
+ INVALID_LICENSE = "INVALID_LICENSE"
14
+ INVALID_PLATFORM = "INVALID_PLATFORM"
15
+ EXPIRED_LICENSE = "EXPIRED_LICENSE"
16
+ BLACKLISTED_LICENSE = "BLACKLISTED_LICENSE"
17
+ CONNECTION_MISMATCH = "CONNECTION_MISMATCH"
18
+ EXCESSIVE_SERVERS = "EXCESSIVE_SERVERS"
19
+ EXCESSIVE_IPS = "EXCESSIVE_IPS"
20
+ UNKNOWN = "UNKNOWN"
21
+
22
+ @staticmethod
23
+ def from_string(value: str | None) -> ValidationResultType:
24
+ if value is None:
25
+ return ValidationResultType.UNKNOWN
26
+ try:
27
+ return ValidationResultType(value)
28
+ except ValueError:
29
+ return ValidationResultType.UNKNOWN
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ValidationDetails:
34
+ expiration: datetime | None
35
+ server_count: int
36
+ max_servers: int
37
+ ip_count: int
38
+ max_ips: int
39
+ tier: str | None
40
+ entitlements: set[str]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FailureDetails:
45
+ pass
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BlacklistFailureDetails(FailureDetails):
50
+ timestamp: datetime
51
+ reason: str | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ExcessiveServersFailureDetails(FailureDetails):
56
+ max_servers: int
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ExcessiveIpsFailureDetails(FailureDetails):
61
+ max_ips: int
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ValidationResult:
66
+ type: ValidationResultType
67
+ message: str
68
+ is_valid: bool
69
+ details: ValidationDetails | None = None
70
+ failure_details: FailureDetails | None = None
71
+
72
+ @staticmethod
73
+ def success(details: ValidationDetails, message: str) -> ValidationResult:
74
+ return ValidationResult(
75
+ type=ValidationResultType.SUCCESS,
76
+ message=message,
77
+ is_valid=True,
78
+ details=details,
79
+ )
80
+
81
+ @staticmethod
82
+ def failure(
83
+ type: ValidationResultType,
84
+ message: str,
85
+ failure_details: FailureDetails | None = None,
86
+ ) -> ValidationResult:
87
+ return ValidationResult(
88
+ type=type,
89
+ message=message,
90
+ is_valid=False,
91
+ failure_details=failure_details,
92
+ )
93
+
94
+ def require_valid(self) -> ValidationDetails:
95
+ if not self.is_valid:
96
+ raise LicenseValidationError(
97
+ type=self.type,
98
+ message=self.message,
99
+ failure_details=self.failure_details,
100
+ )
101
+ assert self.details is not None
102
+ return self.details
@@ -0,0 +1 @@
1
+ """Sentinel API services."""
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sentinel._exceptions import SentinelApiError
6
+ from sentinel._http import ApiResponse, AsyncSentinelHttpClient
7
+ from sentinel._replay import ReplayProtector
8
+ from sentinel._signature import SignatureVerifier
9
+ from sentinel.models.license import License
10
+ from sentinel.models.page import Page
11
+ from sentinel.models.requests import (
12
+ CreateLicenseRequest,
13
+ ListLicensesRequest,
14
+ UpdateLicenseRequest,
15
+ ValidationRequest,
16
+ )
17
+ from sentinel.models.validation import (
18
+ ValidationDetails,
19
+ ValidationResult,
20
+ ValidationResultType,
21
+ )
22
+ from sentinel.services._async_operations import (
23
+ AsyncLicenseConnectionOperations,
24
+ AsyncLicenseIpOperations,
25
+ AsyncLicenseServerOperations,
26
+ AsyncLicenseSubUserOperations,
27
+ )
28
+ from sentinel.services._license import _parse_failure_details
29
+
30
+ _BASE_PATH = "/api/v2/licenses"
31
+
32
+
33
+ class AsyncLicenseService:
34
+ def __init__(
35
+ self,
36
+ http_client: AsyncSentinelHttpClient,
37
+ signature_verifier: SignatureVerifier | None = None,
38
+ replay_protector: ReplayProtector | None = None,
39
+ ) -> None:
40
+ self._http = http_client
41
+ self._sig = signature_verifier
42
+ self._replay = replay_protector
43
+ self.connections = AsyncLicenseConnectionOperations(http_client)
44
+ self.servers = AsyncLicenseServerOperations(http_client)
45
+ self.ips = AsyncLicenseIpOperations(http_client)
46
+ self.sub_users = AsyncLicenseSubUserOperations(http_client)
47
+
48
+ async def validate(self, request: ValidationRequest) -> ValidationResult:
49
+ body = request.to_body()
50
+ if "server" not in body:
51
+ import asyncio
52
+
53
+ from sentinel.util.fingerprint import generate_fingerprint
54
+
55
+ body["server"] = await asyncio.to_thread(generate_fingerprint)
56
+
57
+ resp = await self._http.request(
58
+ "POST", f"{_BASE_PATH}/validate", json_body=body, allowed_statuses={403}
59
+ )
60
+
61
+ if resp.http_status == 200:
62
+ return self._parse_validation_success(resp)
63
+ if resp.http_status == 403:
64
+ return self._parse_validation_failure(resp)
65
+ raise SentinelApiError(
66
+ http_status=resp.http_status,
67
+ type=resp.type,
68
+ message=resp.message or "Unknown error",
69
+ )
70
+
71
+ async def create(self, request: CreateLicenseRequest) -> License:
72
+ resp = await self._http.request("POST", _BASE_PATH, json_body=request.to_body())
73
+ return License.model_validate(resp.require_result()["license"])
74
+
75
+ async def get(self, key: str) -> License:
76
+ resp = await self._http.request("GET", f"{_BASE_PATH}/{key}")
77
+ return License.model_validate(resp.require_result()["license"])
78
+
79
+ async def list(self, request: ListLicensesRequest) -> Page[License]:
80
+ resp = await self._http.request("GET", _BASE_PATH, query_params=request.to_query_params())
81
+ page_data = resp.require_result()["page"]
82
+ licenses = [License.model_validate(item) for item in page_data["content"]]
83
+ meta = page_data["page"]
84
+ return Page(
85
+ content=licenses,
86
+ size=meta["size"],
87
+ number=meta["number"],
88
+ total_elements=meta["totalElements"],
89
+ total_pages=meta["totalPages"],
90
+ )
91
+
92
+ async def update(self, key: str, request: UpdateLicenseRequest) -> License:
93
+ resp = await self._http.request(
94
+ "PATCH", f"{_BASE_PATH}/{key}", json_body=request.to_body()
95
+ )
96
+ return License.model_validate(resp.require_result()["license"])
97
+
98
+ async def delete(self, key: str) -> None:
99
+ await self._http.request("DELETE", f"{_BASE_PATH}/{key}")
100
+
101
+ async def regenerate_key(self, key: str, new_key: str | None = None) -> License:
102
+ params = {"newKey": new_key} if new_key else None
103
+ resp = await self._http.request(
104
+ "POST", f"{_BASE_PATH}/{key}/regenerate-key", query_params=params
105
+ )
106
+ return License.model_validate(resp.require_result()["license"])
107
+
108
+ def _parse_validation_success(self, resp: ApiResponse) -> ValidationResult:
109
+ validation = resp.require_result()["validation"]
110
+ nonce = validation["nonce"]
111
+ timestamp = validation["timestamp"]
112
+ signature = validation.get("signature")
113
+ details_data = validation["details"]
114
+
115
+ expiration_str = details_data.get("expiration")
116
+ expiration = datetime.fromisoformat(expiration_str) if expiration_str else None
117
+ server_count = details_data["serverCount"]
118
+ max_servers = details_data["maxServers"]
119
+ ip_count = details_data["ipCount"]
120
+ max_ips = details_data["maxIps"]
121
+ tier = details_data.get("tier")
122
+
123
+ raw_entitlements = details_data.get("entitlements")
124
+ entitlements_list: list[str] | None = None
125
+ entitlements_set: set[str] = set()
126
+ if raw_entitlements is not None:
127
+ entitlements_list = list(raw_entitlements)
128
+ entitlements_set = set(raw_entitlements)
129
+
130
+ details = ValidationDetails(
131
+ expiration=expiration,
132
+ server_count=server_count,
133
+ max_servers=max_servers,
134
+ ip_count=ip_count,
135
+ max_ips=max_ips,
136
+ tier=tier,
137
+ entitlements=entitlements_set,
138
+ )
139
+ result = ValidationResult.success(details=details, message=resp.message or "")
140
+
141
+ if self._sig is not None:
142
+ self._sig.verify(
143
+ signature_base64=signature,
144
+ nonce=nonce,
145
+ timestamp=timestamp,
146
+ expiration=expiration_str,
147
+ server_count=server_count,
148
+ max_servers=max_servers,
149
+ ip_count=ip_count,
150
+ max_ips=max_ips,
151
+ tier=tier,
152
+ entitlements=entitlements_list,
153
+ )
154
+ if self._replay is not None:
155
+ self._replay.check(nonce, timestamp)
156
+
157
+ return result
158
+
159
+ def _parse_validation_failure(self, resp: ApiResponse) -> ValidationResult:
160
+ result_type = ValidationResultType.from_string(resp.type)
161
+ if result_type == ValidationResultType.UNKNOWN:
162
+ raise SentinelApiError(
163
+ http_status=resp.http_status,
164
+ type=resp.type,
165
+ message=resp.message or "Unknown error",
166
+ )
167
+ failure_details = _parse_failure_details(result_type, resp.result)
168
+ return ValidationResult.failure(
169
+ type=result_type,
170
+ message=resp.message or "",
171
+ failure_details=failure_details,
172
+ )
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from sentinel._http import AsyncSentinelHttpClient
4
+ from sentinel.models.license import License, SubUser
5
+
6
+ _BASE_PATH = "/api/v2/licenses"
7
+
8
+
9
+ class AsyncLicenseConnectionOperations:
10
+ def __init__(self, http_client: AsyncSentinelHttpClient) -> None:
11
+ self._http = http_client
12
+
13
+ async def add(self, key: str, connections: dict[str, str]) -> License:
14
+ resp = await self._http.request(
15
+ "POST", f"{_BASE_PATH}/{key}/connections", json_body=connections
16
+ )
17
+ return License.model_validate(resp.require_result()["license"])
18
+
19
+ async def remove(self, key: str, platforms: set[str]) -> License:
20
+ resp = await self._http.request(
21
+ "DELETE",
22
+ f"{_BASE_PATH}/{key}/connections",
23
+ multi_query_params={"platforms": sorted(platforms)},
24
+ )
25
+ return License.model_validate(resp.require_result()["license"])
26
+
27
+
28
+ class AsyncLicenseServerOperations:
29
+ def __init__(self, http_client: AsyncSentinelHttpClient) -> None:
30
+ self._http = http_client
31
+
32
+ async def add(self, key: str, identifiers: set[str]) -> License:
33
+ resp = await self._http.request(
34
+ "POST", f"{_BASE_PATH}/{key}/servers", json_body=sorted(identifiers)
35
+ )
36
+ return License.model_validate(resp.require_result()["license"])
37
+
38
+ async def remove(self, key: str, identifiers: set[str]) -> License:
39
+ resp = await self._http.request(
40
+ "DELETE",
41
+ f"{_BASE_PATH}/{key}/servers",
42
+ multi_query_params={"servers": sorted(identifiers)},
43
+ )
44
+ return License.model_validate(resp.require_result()["license"])
45
+
46
+
47
+ class AsyncLicenseIpOperations:
48
+ def __init__(self, http_client: AsyncSentinelHttpClient) -> None:
49
+ self._http = http_client
50
+
51
+ async def add(self, key: str, addresses: set[str]) -> License:
52
+ resp = await self._http.request(
53
+ "POST", f"{_BASE_PATH}/{key}/ips", json_body=sorted(addresses)
54
+ )
55
+ return License.model_validate(resp.require_result()["license"])
56
+
57
+ async def remove(self, key: str, addresses: set[str]) -> License:
58
+ resp = await self._http.request(
59
+ "DELETE",
60
+ f"{_BASE_PATH}/{key}/ips",
61
+ multi_query_params={"ips": sorted(addresses)},
62
+ )
63
+ return License.model_validate(resp.require_result()["license"])
64
+
65
+
66
+ class AsyncLicenseSubUserOperations:
67
+ def __init__(self, http_client: AsyncSentinelHttpClient) -> None:
68
+ self._http = http_client
69
+
70
+ async def add(self, key: str, sub_users: list[SubUser]) -> License:
71
+ body = [su.model_dump() for su in sub_users]
72
+ resp = await self._http.request("POST", f"{_BASE_PATH}/{key}/sub-users", json_body=body)
73
+ return License.model_validate(resp.require_result()["license"])
74
+
75
+ async def remove(self, key: str, sub_users: list[SubUser]) -> License:
76
+ body = [su.model_dump() for su in sub_users]
77
+ resp = await self._http.request(
78
+ "POST", f"{_BASE_PATH}/{key}/sub-users/remove", json_body=body
79
+ )
80
+ return License.model_validate(resp.require_result()["license"])