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,193 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sentinel._exceptions import SentinelApiError
6
+ from sentinel._http import ApiResponse, SentinelHttpClient
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
+ BlacklistFailureDetails,
19
+ ExcessiveIpsFailureDetails,
20
+ ExcessiveServersFailureDetails,
21
+ FailureDetails,
22
+ ValidationDetails,
23
+ ValidationResult,
24
+ ValidationResultType,
25
+ )
26
+ from sentinel.services._operations import (
27
+ LicenseConnectionOperations,
28
+ LicenseIpOperations,
29
+ LicenseServerOperations,
30
+ LicenseSubUserOperations,
31
+ )
32
+
33
+ _BASE_PATH = "/api/v2/licenses"
34
+
35
+
36
+ class LicenseService:
37
+ def __init__(
38
+ self,
39
+ http_client: SentinelHttpClient,
40
+ signature_verifier: SignatureVerifier | None = None,
41
+ replay_protector: ReplayProtector | None = None,
42
+ ) -> None:
43
+ self._http = http_client
44
+ self._sig = signature_verifier
45
+ self._replay = replay_protector
46
+ self.connections = LicenseConnectionOperations(http_client)
47
+ self.servers = LicenseServerOperations(http_client)
48
+ self.ips = LicenseIpOperations(http_client)
49
+ self.sub_users = LicenseSubUserOperations(http_client)
50
+
51
+ def validate(self, request: ValidationRequest) -> ValidationResult:
52
+ body = request.to_body()
53
+ if "server" not in body:
54
+ from sentinel.util.fingerprint import generate_fingerprint
55
+
56
+ body["server"] = generate_fingerprint()
57
+
58
+ resp = self._http.request(
59
+ "POST", f"{_BASE_PATH}/validate", json_body=body, allowed_statuses={403}
60
+ )
61
+
62
+ if resp.http_status == 200:
63
+ return self._parse_validation_success(resp)
64
+ if resp.http_status == 403:
65
+ return self._parse_validation_failure(resp)
66
+ raise SentinelApiError(
67
+ http_status=resp.http_status,
68
+ type=resp.type,
69
+ message=resp.message or "Unknown error",
70
+ )
71
+
72
+ def create(self, request: CreateLicenseRequest) -> License:
73
+ resp = self._http.request("POST", _BASE_PATH, json_body=request.to_body())
74
+ return License.model_validate(resp.require_result()["license"])
75
+
76
+ def get(self, key: str) -> License:
77
+ resp = self._http.request("GET", f"{_BASE_PATH}/{key}")
78
+ return License.model_validate(resp.require_result()["license"])
79
+
80
+ def list(self, request: ListLicensesRequest) -> Page[License]:
81
+ resp = self._http.request("GET", _BASE_PATH, query_params=request.to_query_params())
82
+ page_data = resp.require_result()["page"]
83
+ licenses = [License.model_validate(item) for item in page_data["content"]]
84
+ meta = page_data["page"]
85
+ return Page(
86
+ content=licenses,
87
+ size=meta["size"],
88
+ number=meta["number"],
89
+ total_elements=meta["totalElements"],
90
+ total_pages=meta["totalPages"],
91
+ )
92
+
93
+ def update(self, key: str, request: UpdateLicenseRequest) -> License:
94
+ resp = self._http.request("PATCH", f"{_BASE_PATH}/{key}", json_body=request.to_body())
95
+ return License.model_validate(resp.require_result()["license"])
96
+
97
+ def delete(self, key: str) -> None:
98
+ self._http.request("DELETE", f"{_BASE_PATH}/{key}")
99
+
100
+ def regenerate_key(self, key: str, new_key: str | None = None) -> License:
101
+ params = {"newKey": new_key} if new_key else None
102
+ resp = self._http.request(
103
+ "POST", f"{_BASE_PATH}/{key}/regenerate-key", query_params=params
104
+ )
105
+ return License.model_validate(resp.require_result()["license"])
106
+
107
+ def _parse_validation_success(self, resp: ApiResponse) -> ValidationResult:
108
+ validation = resp.require_result()["validation"]
109
+ nonce = validation["nonce"]
110
+ timestamp = validation["timestamp"]
111
+ signature = validation.get("signature")
112
+ details_data = validation["details"]
113
+
114
+ expiration_str = details_data.get("expiration")
115
+ expiration = datetime.fromisoformat(expiration_str) if expiration_str else None
116
+ server_count = details_data["serverCount"]
117
+ max_servers = details_data["maxServers"]
118
+ ip_count = details_data["ipCount"]
119
+ max_ips = details_data["maxIps"]
120
+ tier = details_data.get("tier")
121
+
122
+ raw_entitlements = details_data.get("entitlements")
123
+ entitlements_list: list[str] | None = None
124
+ entitlements_set: set[str] = set()
125
+ if raw_entitlements is not None:
126
+ entitlements_list = list(raw_entitlements)
127
+ entitlements_set = set(raw_entitlements)
128
+
129
+ details = ValidationDetails(
130
+ expiration=expiration,
131
+ server_count=server_count,
132
+ max_servers=max_servers,
133
+ ip_count=ip_count,
134
+ max_ips=max_ips,
135
+ tier=tier,
136
+ entitlements=entitlements_set,
137
+ )
138
+ result = ValidationResult.success(details=details, message=resp.message or "")
139
+
140
+ if self._sig is not None:
141
+ self._sig.verify(
142
+ signature_base64=signature,
143
+ nonce=nonce,
144
+ timestamp=timestamp,
145
+ expiration=expiration_str,
146
+ server_count=server_count,
147
+ max_servers=max_servers,
148
+ ip_count=ip_count,
149
+ max_ips=max_ips,
150
+ tier=tier,
151
+ entitlements=entitlements_list,
152
+ )
153
+ if self._replay is not None:
154
+ self._replay.check(nonce, timestamp)
155
+
156
+ return result
157
+
158
+ def _parse_validation_failure(self, resp: ApiResponse) -> ValidationResult:
159
+ result_type = ValidationResultType.from_string(resp.type)
160
+ if result_type == ValidationResultType.UNKNOWN:
161
+ raise SentinelApiError(
162
+ http_status=resp.http_status,
163
+ type=resp.type,
164
+ message=resp.message or "Unknown error",
165
+ )
166
+ failure_details = _parse_failure_details(result_type, resp.result)
167
+ return ValidationResult.failure(
168
+ type=result_type,
169
+ message=resp.message or "",
170
+ failure_details=failure_details,
171
+ )
172
+
173
+
174
+ def _parse_failure_details(
175
+ result_type: ValidationResultType,
176
+ result: dict | None,
177
+ ) -> FailureDetails | None:
178
+ if result is None:
179
+ return None
180
+ try:
181
+ if result_type == ValidationResultType.BLACKLISTED_LICENSE:
182
+ bl = result["blacklist"]
183
+ return BlacklistFailureDetails(
184
+ timestamp=datetime.fromisoformat(bl["timestamp"]),
185
+ reason=bl.get("reason"),
186
+ )
187
+ if result_type == ValidationResultType.EXCESSIVE_SERVERS:
188
+ return ExcessiveServersFailureDetails(max_servers=result["maxServers"])
189
+ if result_type == ValidationResultType.EXCESSIVE_IPS:
190
+ return ExcessiveIpsFailureDetails(max_ips=result["maxIps"])
191
+ except (KeyError, ValueError):
192
+ pass
193
+ return None
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from sentinel._http import SentinelHttpClient
4
+ from sentinel.models.license import License, SubUser
5
+
6
+ _BASE_PATH = "/api/v2/licenses"
7
+
8
+
9
+ class LicenseConnectionOperations:
10
+ def __init__(self, http_client: SentinelHttpClient) -> None:
11
+ self._http = http_client
12
+
13
+ def add(self, key: str, connections: dict[str, str]) -> License:
14
+ resp = self._http.request("POST", f"{_BASE_PATH}/{key}/connections", json_body=connections)
15
+ return License.model_validate(resp.require_result()["license"])
16
+
17
+ def remove(self, key: str, platforms: set[str]) -> License:
18
+ resp = self._http.request(
19
+ "DELETE",
20
+ f"{_BASE_PATH}/{key}/connections",
21
+ multi_query_params={"platforms": sorted(platforms)},
22
+ )
23
+ return License.model_validate(resp.require_result()["license"])
24
+
25
+
26
+ class LicenseServerOperations:
27
+ def __init__(self, http_client: SentinelHttpClient) -> None:
28
+ self._http = http_client
29
+
30
+ def add(self, key: str, identifiers: set[str]) -> License:
31
+ resp = self._http.request(
32
+ "POST", f"{_BASE_PATH}/{key}/servers", json_body=sorted(identifiers)
33
+ )
34
+ return License.model_validate(resp.require_result()["license"])
35
+
36
+ def remove(self, key: str, identifiers: set[str]) -> License:
37
+ resp = self._http.request(
38
+ "DELETE",
39
+ f"{_BASE_PATH}/{key}/servers",
40
+ multi_query_params={"servers": sorted(identifiers)},
41
+ )
42
+ return License.model_validate(resp.require_result()["license"])
43
+
44
+
45
+ class LicenseIpOperations:
46
+ def __init__(self, http_client: SentinelHttpClient) -> None:
47
+ self._http = http_client
48
+
49
+ def add(self, key: str, addresses: set[str]) -> License:
50
+ resp = self._http.request("POST", f"{_BASE_PATH}/{key}/ips", json_body=sorted(addresses))
51
+ return License.model_validate(resp.require_result()["license"])
52
+
53
+ def remove(self, key: str, addresses: set[str]) -> License:
54
+ resp = self._http.request(
55
+ "DELETE",
56
+ f"{_BASE_PATH}/{key}/ips",
57
+ multi_query_params={"ips": sorted(addresses)},
58
+ )
59
+ return License.model_validate(resp.require_result()["license"])
60
+
61
+
62
+ class LicenseSubUserOperations:
63
+ def __init__(self, http_client: SentinelHttpClient) -> None:
64
+ self._http = http_client
65
+
66
+ def add(self, key: str, sub_users: list[SubUser]) -> License:
67
+ body = [su.model_dump() for su in sub_users]
68
+ resp = self._http.request("POST", f"{_BASE_PATH}/{key}/sub-users", json_body=body)
69
+ return License.model_validate(resp.require_result()["license"])
70
+
71
+ def remove(self, key: str, sub_users: list[SubUser]) -> License:
72
+ body = [su.model_dump() for su in sub_users]
73
+ resp = self._http.request("POST", f"{_BASE_PATH}/{key}/sub-users/remove", json_body=body)
74
+ return License.model_validate(resp.require_result()["license"])
@@ -0,0 +1,5 @@
1
+ """Sentinel utility modules."""
2
+
3
+ from sentinel.util.fingerprint import generate_fingerprint as generate_fingerprint
4
+ from sentinel.util.public_ip import async_get_public_ip as async_get_public_ip
5
+ from sentinel.util.public_ip import get_public_ip as get_public_ip
@@ -0,0 +1,383 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import platform
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import uuid
10
+
11
+ _PROCESS_TIMEOUT = 5
12
+ _CONTAINER_MARKERS = (
13
+ "docker",
14
+ "containerd",
15
+ "kubepods",
16
+ "podman",
17
+ "libpod",
18
+ "lxc",
19
+ "machine.slice",
20
+ )
21
+ _IGNORED_PREFIXES = (
22
+ "lo",
23
+ "docker",
24
+ "br-",
25
+ "veth",
26
+ "cni",
27
+ "flannel",
28
+ "cali",
29
+ "virbr",
30
+ "podman",
31
+ "vmnet",
32
+ "vboxnet",
33
+ "awdl",
34
+ "llw",
35
+ "utun",
36
+ "dummy",
37
+ "zt",
38
+ "tailscale",
39
+ "wg",
40
+ "tun",
41
+ "tap",
42
+ "isatap",
43
+ "teredo",
44
+ "bond",
45
+ "team",
46
+ )
47
+ _INVALID_VALUES = frozenset(
48
+ {
49
+ "unknown",
50
+ "none",
51
+ "null",
52
+ "not specified",
53
+ "not available",
54
+ "not settable",
55
+ "not applicable",
56
+ "invalid",
57
+ "n/a",
58
+ "na",
59
+ "default string",
60
+ "system serial number",
61
+ "system product name",
62
+ "system manufacturer",
63
+ "chassis serial number",
64
+ "base board serial number",
65
+ "no asset information",
66
+ "type1productconfigid",
67
+ "o.e.m.",
68
+ "empty",
69
+ "unspecified",
70
+ "default",
71
+ "serial",
72
+ "none specified",
73
+ "123456789",
74
+ "1234567890",
75
+ }
76
+ )
77
+
78
+ _cached: str | None = None
79
+ _lock = threading.Lock()
80
+
81
+
82
+ def generate_fingerprint() -> str:
83
+ global _cached
84
+ if _cached is not None:
85
+ return _cached
86
+ with _lock:
87
+ if _cached is not None:
88
+ return _cached
89
+ result = _sha256_hex(_get_platform_id())[:32]
90
+ _cached = result
91
+ return result
92
+
93
+
94
+ def _get_platform_id() -> str:
95
+ system = platform.system().lower()
96
+
97
+ if "linux" in system:
98
+ mid = _read_linux_machine_id()
99
+ if mid is not None:
100
+ return mid
101
+ elif "darwin" in system:
102
+ mid = _read_mac_uuid()
103
+ if mid is not None:
104
+ return mid
105
+ elif "windows" in system:
106
+ mid = _read_windows_machine_guid()
107
+ if mid is not None:
108
+ return mid
109
+ elif "bsd" in system:
110
+ mid = _read_bsd_host_uuid()
111
+ if mid is not None:
112
+ return mid
113
+
114
+ return _fallback_fingerprint()
115
+
116
+
117
+ def _read_linux_machine_id() -> str | None:
118
+ mid = _read_text_file("/etc/machine-id")
119
+ if mid is not None:
120
+ return mid
121
+ return _read_text_file("/var/lib/dbus/machine-id")
122
+
123
+
124
+ def _read_mac_uuid() -> str | None:
125
+ output = _run_command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
126
+ if output is None:
127
+ return None
128
+ for line in output.split("\n"):
129
+ if "IOPlatformUUID" in line:
130
+ eq_idx = line.find("=")
131
+ if eq_idx < 0:
132
+ continue
133
+ start = line.find('"', eq_idx)
134
+ if start < 0:
135
+ continue
136
+ end = line.find('"', start + 1)
137
+ if end > start:
138
+ return _normalize_identifier(line[start + 1 : end])
139
+ return None
140
+
141
+
142
+ def _read_windows_machine_guid() -> str | None:
143
+ if sys.platform == "win32":
144
+ try:
145
+ import winreg
146
+
147
+ with winreg.OpenKey(
148
+ winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography"
149
+ ) as key:
150
+ value, _ = winreg.QueryValueEx(key, "MachineGuid")
151
+ return _normalize_identifier(str(value))
152
+ except Exception:
153
+ pass
154
+ output = _run_command(
155
+ "reg", "query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"
156
+ )
157
+ if output is None:
158
+ return None
159
+ for line in output.split("\n"):
160
+ if "MachineGuid" in line:
161
+ parts = line.strip().split()
162
+ if len(parts) >= 3:
163
+ return _normalize_identifier(parts[-1])
164
+ return None
165
+
166
+
167
+ def _read_bsd_host_uuid() -> str | None:
168
+ output = _run_command("sysctl", "-n", "kern.hostuuid")
169
+ if output is None:
170
+ return None
171
+ return _normalize_identifier(output)
172
+
173
+
174
+ def _fallback_fingerprint() -> str:
175
+ parts: list[str] = []
176
+
177
+ stable_id = _read_stable_host_id()
178
+ if stable_id is not None:
179
+ parts.append(stable_id)
180
+
181
+ dmi = _read_dmi_composite()
182
+ if dmi is not None:
183
+ parts.append(dmi)
184
+
185
+ parts.append(platform.system())
186
+ parts.append(platform.machine())
187
+
188
+ containerized = _is_containerized()
189
+
190
+ hostname = None
191
+ if not containerized:
192
+ hostname = _resolve_hostname()
193
+ if hostname is not None:
194
+ parts.append(hostname)
195
+
196
+ macs = _read_mac_addresses(containerized)
197
+ if not macs and containerized:
198
+ macs = _read_mac_addresses(False)
199
+ parts.extend(macs)
200
+
201
+ if not macs and hostname is None:
202
+ parts.append(os.environ.get("USER", os.environ.get("USERNAME", "")))
203
+ parts.append(os.path.expanduser("~"))
204
+
205
+ joined = "\0".join(parts)
206
+ if not joined.strip():
207
+ return (
208
+ "static-fallback" + "\0" + platform.python_implementation() + "\0" + platform.version()
209
+ )
210
+ return joined
211
+
212
+
213
+ def _resolve_hostname() -> str | None:
214
+ for var in ("HOSTNAME", "COMPUTERNAME"):
215
+ val = os.environ.get(var)
216
+ if val and val.strip():
217
+ return val
218
+ val = _read_text_file("/etc/hostname")
219
+ if val is not None:
220
+ return val
221
+ return None
222
+
223
+
224
+ def _read_stable_host_id() -> str | None:
225
+ paths = [
226
+ "/sys/class/dmi/id/product_serial",
227
+ "/sys/devices/virtual/dmi/id/product_serial",
228
+ "/sys/class/dmi/id/board_serial",
229
+ "/sys/devices/virtual/dmi/id/board_serial",
230
+ "/sys/class/dmi/id/product_uuid",
231
+ "/sys/devices/virtual/dmi/id/product_uuid",
232
+ ]
233
+ for path in paths:
234
+ val = _read_text_file(path)
235
+ if val is not None:
236
+ return val
237
+
238
+ val = _read_binary_text_file("/proc/device-tree/serial-number")
239
+ if val is not None:
240
+ return val
241
+ return None
242
+
243
+
244
+ def _read_dmi_composite() -> str | None:
245
+ parts: list[str] = []
246
+ for path in [
247
+ "/sys/class/dmi/id/board_vendor",
248
+ "/sys/class/dmi/id/board_name",
249
+ "/sys/class/dmi/id/product_name",
250
+ "/sys/class/dmi/id/sys_vendor",
251
+ ]:
252
+ val = _read_text_file(path)
253
+ if val is not None:
254
+ parts.append(val)
255
+ return "\0".join(parts) if parts else None
256
+
257
+
258
+ def _read_mac_addresses(skip_locally_administered: bool) -> list[str]:
259
+ try:
260
+ node = uuid.getnode()
261
+ if node and not (node >> 40) & 0x01:
262
+ mac_hex = f"{node:012x}"
263
+ mac_bytes = bytes.fromhex(mac_hex)
264
+ if not _is_all_zeros(mac_bytes) and not _is_all_ones(mac_bytes):
265
+ if not (skip_locally_administered and (mac_bytes[0] & 0x02)):
266
+ return [mac_hex]
267
+ except Exception:
268
+ pass
269
+ return []
270
+
271
+
272
+ def _is_containerized() -> bool:
273
+ if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
274
+ return True
275
+ for var in ("KUBERNETES_SERVICE_HOST", "container", "DOTNET_RUNNING_IN_CONTAINER"):
276
+ if os.environ.get(var) is not None:
277
+ return True
278
+ cgroup = _read_raw_text_file("/proc/1/cgroup")
279
+ if cgroup is not None:
280
+ lower = cgroup.lower()
281
+ for marker in _CONTAINER_MARKERS:
282
+ if marker in lower:
283
+ return True
284
+ self_cgroup = _read_raw_text_file("/proc/self/cgroup")
285
+ if self_cgroup is not None:
286
+ for line in self_cgroup.split("\n"):
287
+ if line.startswith("0::"):
288
+ path = line[3:].strip().lower()
289
+ for marker in _CONTAINER_MARKERS:
290
+ if marker in path:
291
+ return True
292
+ mountinfo = _read_raw_text_file("/proc/1/mountinfo")
293
+ if mountinfo is not None:
294
+ for line in mountinfo.split("\n"):
295
+ if " / / " in line and "overlay" in line:
296
+ return True
297
+ return False
298
+
299
+
300
+ def _normalize_identifier(raw_value: str | None) -> str | None:
301
+ if raw_value is None:
302
+ return None
303
+ value = raw_value.strip()
304
+ if not value:
305
+ return None
306
+ lower = value.lower()
307
+
308
+ if lower in _INVALID_VALUES:
309
+ return None
310
+ if lower.startswith("to be filled"):
311
+ return None
312
+ if lower.startswith("0123456789"):
313
+ return None
314
+ compact = lower.replace("-", "").replace(":", "").replace(" ", "")
315
+ if compact == "03000200040005000006000700080009":
316
+ return None
317
+ if len(compact) <= 3:
318
+ return None
319
+ if all(c == "0" for c in compact):
320
+ return None
321
+ if all(c == "f" for c in compact):
322
+ return None
323
+ if all(c == "1" for c in compact):
324
+ return None
325
+ if all(c == "x" for c in compact):
326
+ return None
327
+
328
+ return lower
329
+
330
+
331
+ def _run_command(*args: str) -> str | None:
332
+ try:
333
+ result = subprocess.run(
334
+ args,
335
+ capture_output=True,
336
+ text=True,
337
+ timeout=_PROCESS_TIMEOUT,
338
+ )
339
+ if result.returncode != 0:
340
+ return None
341
+ return result.stdout
342
+ except Exception:
343
+ return None
344
+
345
+
346
+ def _read_text_file(path: str) -> str | None:
347
+ try:
348
+ with open(path) as f:
349
+ return _normalize_identifier(f.read())
350
+ except Exception:
351
+ return None
352
+
353
+
354
+ def _read_binary_text_file(path: str) -> str | None:
355
+ try:
356
+ with open(path, "rb") as f:
357
+ data = f.read()
358
+ length = data.find(b"\x00")
359
+ if length < 0:
360
+ length = len(data)
361
+ return _normalize_identifier(data[:length].decode("utf-8", errors="replace"))
362
+ except Exception:
363
+ return None
364
+
365
+
366
+ def _read_raw_text_file(path: str) -> str | None:
367
+ try:
368
+ with open(path) as f:
369
+ return f.read()
370
+ except Exception:
371
+ return None
372
+
373
+
374
+ def _is_all_zeros(data: bytes) -> bool:
375
+ return all(b == 0 for b in data)
376
+
377
+
378
+ def _is_all_ones(data: bytes) -> bool:
379
+ return all(b == 0xFF for b in data)
380
+
381
+
382
+ def _sha256_hex(value: str) -> str:
383
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ _CHECKIP_URL = "https://checkip.amazonaws.com"
6
+ _TIMEOUT = 5.0
7
+
8
+
9
+ def get_public_ip() -> str | None:
10
+ try:
11
+ response = httpx.get(_CHECKIP_URL, timeout=_TIMEOUT)
12
+ if response.status_code != 200:
13
+ return None
14
+ ip = response.text.strip()
15
+ return ip if ip else None
16
+ except Exception:
17
+ return None
18
+
19
+
20
+ async def async_get_public_ip() -> str | None:
21
+ try:
22
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
23
+ response = await client.get(_CHECKIP_URL)
24
+ if response.status_code != 200:
25
+ return None
26
+ ip = response.text.strip()
27
+ return ip if ip else None
28
+ except Exception:
29
+ return None