controlid-sdk 0.3.3__tar.gz → 0.3.5__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.
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/PKG-INFO +4 -2
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/pyproject.toml +5 -3
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid/__init__.py +3 -2
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid/client.py +131 -110
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid/models.py +2 -2
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/PKG-INFO +4 -2
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/tests/test_client.py +62 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/README.md +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/setup.cfg +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid/constants.py +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid/exceptions.py +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/SOURCES.txt +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/dependency_links.txt +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/requires.txt +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/top_level.txt +0 -0
- {controlid_sdk-0.3.3 → controlid_sdk-0.3.5}/tests/test_models.py +0 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlid-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/tulioamancio/controlid-python-sdk
|
|
8
|
+
Project-URL: Source, https://github.com/tulioamancio/controlid-python-sdk
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/tulioamancio/controlid-python-sdk/issues
|
|
10
|
+
Keywords: controlid,access-control,biometrics,iot,async,sdk
|
|
8
11
|
Classifier: Development Status :: 4 - Beta
|
|
9
12
|
Classifier: Intended Audience :: Developers
|
|
10
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -12,7 +15,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
12
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
18
|
Classifier: Operating System :: OS Independent
|
|
17
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
20
|
Classifier: Topic :: System :: Hardware
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlid-sdk"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.5"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tulio Amancio", email="root@tsuriu.com.br" },
|
|
10
10
|
]
|
|
11
11
|
description = "A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules."
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
license = {
|
|
13
|
+
license = {text = "MIT"}
|
|
14
|
+
keywords = ["controlid", "access-control", "biometrics", "iot", "async", "sdk"]
|
|
14
15
|
requires-python = ">=3.9"
|
|
15
16
|
classifiers = [
|
|
16
17
|
"Development Status :: 4 - Beta",
|
|
@@ -20,7 +21,6 @@ classifiers = [
|
|
|
20
21
|
"Programming Language :: Python :: 3.10",
|
|
21
22
|
"Programming Language :: Python :: 3.11",
|
|
22
23
|
"Programming Language :: Python :: 3.12",
|
|
23
|
-
"License :: OSI Approved :: MIT License",
|
|
24
24
|
"Operating System :: OS Independent",
|
|
25
25
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
26
|
"Topic :: System :: Hardware",
|
|
@@ -38,6 +38,8 @@ dev = [
|
|
|
38
38
|
|
|
39
39
|
[project.urls]
|
|
40
40
|
"Homepage" = "https://github.com/tulioamancio/controlid-python-sdk"
|
|
41
|
+
"Source" = "https://github.com/tulioamancio/controlid-python-sdk"
|
|
42
|
+
"Bug Tracker" = "https://github.com/tulioamancio/controlid-python-sdk/issues"
|
|
41
43
|
|
|
42
44
|
[tool.setuptools.packages.find]
|
|
43
45
|
where = ["src"]
|
|
@@ -11,7 +11,6 @@ from .models import (
|
|
|
11
11
|
Door,
|
|
12
12
|
AccessLog,
|
|
13
13
|
Device,
|
|
14
|
-
TimeZone,
|
|
15
14
|
TimeSpan,
|
|
16
15
|
FaceTemplate,
|
|
17
16
|
CatraInfo,
|
|
@@ -32,7 +31,7 @@ from .exceptions import (
|
|
|
32
31
|
)
|
|
33
32
|
from . import constants
|
|
34
33
|
|
|
35
|
-
__version__ = "0.3.
|
|
34
|
+
__version__ = "0.3.4"
|
|
36
35
|
|
|
37
36
|
__all__ = [
|
|
38
37
|
"ControlIDClient",
|
|
@@ -44,6 +43,7 @@ __all__ = [
|
|
|
44
43
|
"UserGroup",
|
|
45
44
|
"AccessRule",
|
|
46
45
|
"TimeZone",
|
|
46
|
+
"TimeSpan",
|
|
47
47
|
"Area",
|
|
48
48
|
"Door",
|
|
49
49
|
"AccessLog",
|
|
@@ -67,3 +67,4 @@ __all__ = [
|
|
|
67
67
|
"constants",
|
|
68
68
|
]
|
|
69
69
|
|
|
70
|
+
|
|
@@ -2,10 +2,11 @@ import httpx
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import time as _time
|
|
4
4
|
from typing import List, Dict, Any, Optional, Union
|
|
5
|
-
from urllib.parse import quote
|
|
5
|
+
from urllib.parse import quote
|
|
6
6
|
from . import constants as const
|
|
7
7
|
from . import exceptions as ex
|
|
8
|
-
from .models import User, Card,
|
|
8
|
+
from .models import User, Card, AccessLog, UserRole, CustomField, TimeZone, TimeSpan
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _normalize_host(host: str) -> str:
|
|
@@ -45,6 +46,7 @@ class ControlIDClient:
|
|
|
45
46
|
timeout: float = 15.0,
|
|
46
47
|
verify: bool = True,
|
|
47
48
|
on_host_change: Optional[Any] = None,
|
|
49
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
48
50
|
):
|
|
49
51
|
self.host = _normalize_host(host)
|
|
50
52
|
self.user = user
|
|
@@ -53,16 +55,21 @@ class ControlIDClient:
|
|
|
53
55
|
self.timeout = timeout
|
|
54
56
|
self.verify = verify
|
|
55
57
|
self.on_host_change = on_host_change
|
|
56
|
-
self._client
|
|
58
|
+
self._client = client
|
|
59
|
+
self._external_client = client is not None
|
|
60
|
+
self._client_lock = asyncio.Lock()
|
|
61
|
+
self._login_lock = asyncio.Lock()
|
|
62
|
+
self._semaphore = asyncio.Semaphore(3)
|
|
57
63
|
|
|
58
64
|
# ─── Context Manager ───────────────────────────────────────────────────────
|
|
59
65
|
|
|
60
66
|
async def __aenter__(self) -> "ControlIDClient":
|
|
61
|
-
|
|
67
|
+
if not self._client:
|
|
68
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
|
|
62
69
|
return self
|
|
63
70
|
|
|
64
71
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
65
|
-
if self._client:
|
|
72
|
+
if self._client and not self._external_client:
|
|
66
73
|
await self._client.aclose()
|
|
67
74
|
self._client = None
|
|
68
75
|
|
|
@@ -78,7 +85,12 @@ class ControlIDClient:
|
|
|
78
85
|
async def _ensure_client(self):
|
|
79
86
|
"""Create the httpx client if it doesn't exist or was closed."""
|
|
80
87
|
if self._client is None or self._client.is_closed:
|
|
81
|
-
|
|
88
|
+
async with self._client_lock:
|
|
89
|
+
if self._client is None or self._client.is_closed:
|
|
90
|
+
if self._external_client:
|
|
91
|
+
raise ex.ControlIDError("External httpx client is closed or not initialized")
|
|
92
|
+
else:
|
|
93
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
|
|
82
94
|
|
|
83
95
|
# ─── Authentication ────────────────────────────────────────────────────────
|
|
84
96
|
|
|
@@ -86,54 +98,57 @@ class ControlIDClient:
|
|
|
86
98
|
"""Authenticate with the device and store the session token.
|
|
87
99
|
Attempts HTTP/HTTPS fallback if connection fails.
|
|
88
100
|
"""
|
|
89
|
-
|
|
101
|
+
async with self._login_lock:
|
|
102
|
+
await self._ensure_client()
|
|
103
|
+
if self.session:
|
|
104
|
+
return self.session
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
try:
|
|
110
|
-
logger_ctx = {"host": try_host, "user": self.user}
|
|
111
|
-
response = await self._client.post(url, json=payload, timeout=5.0) # Short timeout for login attempt
|
|
112
|
-
response.raise_for_status()
|
|
113
|
-
data = response.json()
|
|
114
|
-
if "session" in data:
|
|
115
|
-
self.session = data["session"]
|
|
116
|
-
if self.host != try_host:
|
|
117
|
-
old_host = self.host
|
|
118
|
-
self.host = try_host # Update to working host
|
|
119
|
-
if self.on_host_change:
|
|
120
|
-
try:
|
|
121
|
-
if asyncio.iscoroutinefunction(self.on_host_change):
|
|
122
|
-
asyncio.create_task(self.on_host_change(self.host))
|
|
123
|
-
else:
|
|
124
|
-
self.on_host_change(self.host)
|
|
125
|
-
except Exception: pass
|
|
126
|
-
return self.session
|
|
127
|
-
raise ex.AuthenticationError(f"Login failed: {data}")
|
|
128
|
-
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.ProtocolError, httpx.RemoteProtocolError, httpx.ProxyError) as e:
|
|
129
|
-
last_err = f"{type(e).__name__} on {proto}"
|
|
130
|
-
continue
|
|
131
|
-
except Exception as e:
|
|
132
|
-
if isinstance(e, ex.AuthenticationError): raise
|
|
133
|
-
last_err = f"{type(e).__name__}: {str(e)}"
|
|
134
|
-
continue
|
|
106
|
+
protocols = ["https", "http"]
|
|
107
|
+
# Start with current if it has one, otherwise prefer https
|
|
108
|
+
current_proto = "https"
|
|
109
|
+
if "://" in self.host:
|
|
110
|
+
current_proto = self.host.split("://")[0]
|
|
111
|
+
|
|
112
|
+
# Reorder to try current first
|
|
113
|
+
if current_proto == "http":
|
|
114
|
+
protocols = ["http", "https"]
|
|
115
|
+
|
|
116
|
+
last_err = None
|
|
117
|
+
for proto in protocols:
|
|
118
|
+
# Build URL for this protocol attempt
|
|
119
|
+
clean_host = self.host.split("://")[-1]
|
|
120
|
+
try_host = f"{proto}://{clean_host}"
|
|
121
|
+
url = f"{try_host}{const.LOGIN}"
|
|
122
|
+
payload = {"login": self.user, "password": self.password}
|
|
135
123
|
|
|
136
|
-
|
|
124
|
+
try:
|
|
125
|
+
response = await self._client.post(url, json=payload, timeout=5.0) # Short timeout for login attempt
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
data = response.json()
|
|
128
|
+
if "session" in data:
|
|
129
|
+
self.session = data["session"]
|
|
130
|
+
if self.host != try_host:
|
|
131
|
+
self.host = try_host # Update to working host
|
|
132
|
+
if self.on_host_change:
|
|
133
|
+
try:
|
|
134
|
+
if asyncio.iscoroutinefunction(self.on_host_change):
|
|
135
|
+
asyncio.create_task(self.on_host_change(self.host))
|
|
136
|
+
else:
|
|
137
|
+
self.on_host_change(self.host)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return self.session
|
|
141
|
+
raise ex.AuthenticationError(f"Login failed: {data}")
|
|
142
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.ProtocolError, httpx.RemoteProtocolError, httpx.ProxyError) as e:
|
|
143
|
+
last_err = f"{type(e).__name__} on {proto}"
|
|
144
|
+
continue
|
|
145
|
+
except Exception as e:
|
|
146
|
+
if isinstance(e, ex.AuthenticationError):
|
|
147
|
+
raise
|
|
148
|
+
last_err = f"{type(e).__name__}: {str(e)}"
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
raise ex.AuthenticationError(f"Connection failed after trying all protocols: {last_err}")
|
|
137
152
|
|
|
138
153
|
async def logout(self):
|
|
139
154
|
"""Invalidate the current session on the device."""
|
|
@@ -172,64 +187,66 @@ class ControlIDClient:
|
|
|
172
187
|
Automatically logs in if no session is available, and re-authenticates
|
|
173
188
|
once on 401 errors (session expired).
|
|
174
189
|
"""
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
url = self._get_url(endpoint)
|
|
180
|
-
timeout = timeout_override or self.timeout
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
if method.upper() == "POST":
|
|
184
|
-
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
185
|
-
else:
|
|
186
|
-
response = await self._client.get(url, timeout=timeout)
|
|
187
|
-
response.raise_for_status()
|
|
188
|
-
return response.json()
|
|
189
|
-
except httpx.HTTPStatusError as e:
|
|
190
|
-
if e.response.status_code == 401:
|
|
191
|
-
# Session expired — re-login and retry once
|
|
190
|
+
async with self._semaphore:
|
|
191
|
+
await self._ensure_client()
|
|
192
|
+
if not self.session:
|
|
192
193
|
await self.login()
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
|
|
195
|
+
url = self._get_url(endpoint)
|
|
196
|
+
timeout = timeout_override or self.timeout
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
if method.upper() == "POST":
|
|
195
200
|
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
201
|
+
else:
|
|
202
|
+
response = await self._client.get(url, timeout=timeout)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
return response.json()
|
|
205
|
+
except httpx.HTTPStatusError as e:
|
|
206
|
+
if e.response.status_code == 401:
|
|
207
|
+
# Session expired — re-login and retry once
|
|
208
|
+
await self.login()
|
|
209
|
+
url = self._get_url(endpoint)
|
|
210
|
+
try:
|
|
211
|
+
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
212
|
+
response.raise_for_status()
|
|
213
|
+
return response.json()
|
|
214
|
+
except httpx.HTTPStatusError as retry_e:
|
|
215
|
+
status_code = retry_e.response.status_code
|
|
216
|
+
msg = f"API Error: {retry_e.response.text}"
|
|
217
|
+
if status_code == 400:
|
|
218
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=retry_e.response.text)
|
|
219
|
+
elif status_code == 401:
|
|
220
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=retry_e.response.text)
|
|
221
|
+
elif status_code == 403:
|
|
222
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=retry_e.response.text)
|
|
223
|
+
elif status_code == 404:
|
|
224
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=retry_e.response.text)
|
|
225
|
+
elif status_code == 429:
|
|
226
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=retry_e.response.text)
|
|
227
|
+
else:
|
|
228
|
+
raise ex.APIError(msg, status_code=status_code, response=retry_e.response.text)
|
|
229
|
+
|
|
230
|
+
status_code = e.response.status_code
|
|
231
|
+
msg = f"API Error: {e.response.text}"
|
|
232
|
+
if status_code == 400:
|
|
233
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=e.response.text)
|
|
234
|
+
elif status_code == 401:
|
|
235
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=e.response.text)
|
|
236
|
+
elif status_code == 403:
|
|
237
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=e.response.text)
|
|
238
|
+
elif status_code == 404:
|
|
239
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=e.response.text)
|
|
240
|
+
elif status_code == 429:
|
|
241
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=e.response.text)
|
|
242
|
+
else:
|
|
243
|
+
raise ex.APIError(msg, status_code=status_code, response=e.response.text)
|
|
244
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
|
|
245
|
+
raise ex.DeviceUnreachableError(f"Device unreachable: {type(e).__name__} {e}")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)):
|
|
248
|
+
raise
|
|
249
|
+
raise ex.ControlIDError(f"Request failed: {type(e).__name__} {e}")
|
|
233
250
|
|
|
234
251
|
async def get_system_information(self) -> Dict[str, Any]:
|
|
235
252
|
"""Fetch extensive system information (uptime, memory, network, serial, version, etc.)"""
|
|
@@ -370,8 +387,10 @@ class ControlIDClient:
|
|
|
370
387
|
async def get_cards(self, user_id: Optional[int] = None, value: Optional[int] = None) -> List[Card]:
|
|
371
388
|
"""Return credentials for all users, or filtered by user_id or card value."""
|
|
372
389
|
where = {}
|
|
373
|
-
if user_id:
|
|
374
|
-
|
|
390
|
+
if user_id:
|
|
391
|
+
where["user_id"] = user_id
|
|
392
|
+
if value:
|
|
393
|
+
where["value"] = value
|
|
375
394
|
return [Card(**c) for c in await self.load_objects(const.TABLE_CARDS, where=where or None)]
|
|
376
395
|
|
|
377
396
|
async def add_card(self, card: Card) -> int:
|
|
@@ -767,6 +786,8 @@ class ControlIDClient:
|
|
|
767
786
|
|
|
768
787
|
await client.set_configuration({"face_id": {"qrcode_legacy_mode_enabled": "1"}})
|
|
769
788
|
"""
|
|
789
|
+
return await self.request(const.SET_CONFIGURATION, config)
|
|
790
|
+
|
|
770
791
|
async def export_audit_logs(self, config: int = 1, api: int = 1, usb: int = 0, network: int = 0, time: int = 1, online: int = 0, menu: int = 1) -> List[Dict[str, Any]]:
|
|
771
792
|
"""
|
|
772
793
|
Export device audit logs (system events, config changes, etc.).
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlid-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/tulioamancio/controlid-python-sdk
|
|
8
|
+
Project-URL: Source, https://github.com/tulioamancio/controlid-python-sdk
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/tulioamancio/controlid-python-sdk/issues
|
|
10
|
+
Keywords: controlid,access-control,biometrics,iot,async,sdk
|
|
8
11
|
Classifier: Development Status :: 4 - Beta
|
|
9
12
|
Classifier: Intended Audience :: Developers
|
|
10
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -12,7 +15,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
12
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
18
|
Classifier: Operating System :: OS Independent
|
|
17
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
20
|
Classifier: Topic :: System :: Hardware
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
from unittest.mock import MagicMock, AsyncMock, patch
|
|
3
3
|
import httpx
|
|
4
|
+
import asyncio
|
|
4
5
|
from controlid import ControlIDClient
|
|
5
6
|
from controlid.exceptions import (
|
|
6
7
|
AuthenticationError,
|
|
@@ -96,3 +97,64 @@ async def test_client_request_device_unreachable():
|
|
|
96
97
|
with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.ConnectError("Connection refused")):
|
|
97
98
|
with pytest.raises(DeviceUnreachableError):
|
|
98
99
|
await client.request("/some-endpoint")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_set_configuration_calls_hardware_endpoint():
|
|
104
|
+
client = ControlIDClient(host="https://192.168.0.100")
|
|
105
|
+
payload = {"snmp_agent": {"snmp_enabled": "1"}}
|
|
106
|
+
with patch.object(client, "request", new_callable=AsyncMock, return_value={"status": "ok"}) as request:
|
|
107
|
+
result = await client.set_configuration(payload)
|
|
108
|
+
|
|
109
|
+
request.assert_awaited_once_with("/set_configuration.fcgi", payload)
|
|
110
|
+
assert result == {"status": "ok"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_client_reuses_shared_client():
|
|
115
|
+
shared_client = httpx.AsyncClient()
|
|
116
|
+
client = ControlIDClient(host="https://192.168.0.100", client=shared_client)
|
|
117
|
+
|
|
118
|
+
# 1. Ensure it starts with the shared client
|
|
119
|
+
assert client._client is shared_client
|
|
120
|
+
|
|
121
|
+
# 2. _ensure_client should not overwrite or recreate it
|
|
122
|
+
await client._ensure_client()
|
|
123
|
+
assert client._client is shared_client
|
|
124
|
+
|
|
125
|
+
# 3. Context manager exit should not close the shared client
|
|
126
|
+
async with client:
|
|
127
|
+
pass
|
|
128
|
+
assert client._client is shared_client
|
|
129
|
+
assert not shared_client.is_closed
|
|
130
|
+
await shared_client.aclose()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_client_concurrency_locks():
|
|
135
|
+
client = ControlIDClient(host="https://192.168.0.100")
|
|
136
|
+
|
|
137
|
+
mock_response = MagicMock(spec=httpx.Response)
|
|
138
|
+
mock_response.status_code = 200
|
|
139
|
+
mock_response.json = MagicMock(return_value={"session": "test-session"})
|
|
140
|
+
mock_response.raise_for_status = MagicMock()
|
|
141
|
+
|
|
142
|
+
login_calls = 0
|
|
143
|
+
|
|
144
|
+
async def mock_post(*args, **kwargs):
|
|
145
|
+
nonlocal login_calls
|
|
146
|
+
login_calls += 1
|
|
147
|
+
# sleep a bit to simulate network latency and test concurrency locking
|
|
148
|
+
await asyncio.sleep(0.1)
|
|
149
|
+
return mock_response
|
|
150
|
+
|
|
151
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, side_effect=mock_post):
|
|
152
|
+
# Trigger multiple logins concurrently using asyncio.gather
|
|
153
|
+
sessions = await asyncio.gather(
|
|
154
|
+
client.login(),
|
|
155
|
+
client.login(),
|
|
156
|
+
client.login()
|
|
157
|
+
)
|
|
158
|
+
# Ensure only 1 HTTP POST login request was actually sent
|
|
159
|
+
assert login_calls == 1
|
|
160
|
+
assert all(s == "test-session" for s in sessions)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|