controlid-sdk 0.3.4__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.4 → controlid_sdk-0.3.5}/PKG-INFO +4 -1
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/pyproject.toml +4 -1
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid/client.py +122 -107
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/PKG-INFO +4 -1
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/tests/test_client.py +51 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/README.md +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/setup.cfg +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid/__init__.py +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid/constants.py +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid/exceptions.py +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid/models.py +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/SOURCES.txt +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/dependency_links.txt +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/requires.txt +0 -0
- {controlid_sdk-0.3.4 → controlid_sdk-0.3.5}/src/controlid_sdk.egg-info/top_level.txt +0 -0
- {controlid_sdk-0.3.4 → 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
|
|
@@ -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
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",
|
|
@@ -37,6 +38,8 @@ dev = [
|
|
|
37
38
|
|
|
38
39
|
[project.urls]
|
|
39
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"
|
|
40
43
|
|
|
41
44
|
[tool.setuptools.packages.find]
|
|
42
45
|
where = ["src"]
|
|
@@ -46,6 +46,7 @@ class ControlIDClient:
|
|
|
46
46
|
timeout: float = 15.0,
|
|
47
47
|
verify: bool = True,
|
|
48
48
|
on_host_change: Optional[Any] = None,
|
|
49
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
49
50
|
):
|
|
50
51
|
self.host = _normalize_host(host)
|
|
51
52
|
self.user = user
|
|
@@ -54,16 +55,21 @@ class ControlIDClient:
|
|
|
54
55
|
self.timeout = timeout
|
|
55
56
|
self.verify = verify
|
|
56
57
|
self.on_host_change = on_host_change
|
|
57
|
-
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)
|
|
58
63
|
|
|
59
64
|
# ─── Context Manager ───────────────────────────────────────────────────────
|
|
60
65
|
|
|
61
66
|
async def __aenter__(self) -> "ControlIDClient":
|
|
62
|
-
|
|
67
|
+
if not self._client:
|
|
68
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
|
|
63
69
|
return self
|
|
64
70
|
|
|
65
71
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
66
|
-
if self._client:
|
|
72
|
+
if self._client and not self._external_client:
|
|
67
73
|
await self._client.aclose()
|
|
68
74
|
self._client = None
|
|
69
75
|
|
|
@@ -79,7 +85,12 @@ class ControlIDClient:
|
|
|
79
85
|
async def _ensure_client(self):
|
|
80
86
|
"""Create the httpx client if it doesn't exist or was closed."""
|
|
81
87
|
if self._client is None or self._client.is_closed:
|
|
82
|
-
|
|
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)
|
|
83
94
|
|
|
84
95
|
# ─── Authentication ────────────────────────────────────────────────────────
|
|
85
96
|
|
|
@@ -87,54 +98,57 @@ class ControlIDClient:
|
|
|
87
98
|
"""Authenticate with the device and store the session token.
|
|
88
99
|
Attempts HTTP/HTTPS fallback if connection fails.
|
|
89
100
|
"""
|
|
90
|
-
|
|
101
|
+
async with self._login_lock:
|
|
102
|
+
await self._ensure_client()
|
|
103
|
+
if self.session:
|
|
104
|
+
return self.session
|
|
91
105
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
try:
|
|
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
|
-
self.host = try_host # Update to working host
|
|
118
|
-
if self.on_host_change:
|
|
119
|
-
try:
|
|
120
|
-
if asyncio.iscoroutinefunction(self.on_host_change):
|
|
121
|
-
asyncio.create_task(self.on_host_change(self.host))
|
|
122
|
-
else:
|
|
123
|
-
self.on_host_change(self.host)
|
|
124
|
-
except Exception:
|
|
125
|
-
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):
|
|
133
|
-
raise
|
|
134
|
-
last_err = f"{type(e).__name__}: {str(e)}"
|
|
135
|
-
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}
|
|
136
123
|
|
|
137
|
-
|
|
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}")
|
|
138
152
|
|
|
139
153
|
async def logout(self):
|
|
140
154
|
"""Invalidate the current session on the device."""
|
|
@@ -173,65 +187,66 @@ class ControlIDClient:
|
|
|
173
187
|
Automatically logs in if no session is available, and re-authenticates
|
|
174
188
|
once on 401 errors (session expired).
|
|
175
189
|
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
url = self._get_url(endpoint)
|
|
181
|
-
timeout = timeout_override or self.timeout
|
|
182
|
-
|
|
183
|
-
try:
|
|
184
|
-
if method.upper() == "POST":
|
|
185
|
-
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
186
|
-
else:
|
|
187
|
-
response = await self._client.get(url, timeout=timeout)
|
|
188
|
-
response.raise_for_status()
|
|
189
|
-
return response.json()
|
|
190
|
-
except httpx.HTTPStatusError as e:
|
|
191
|
-
if e.response.status_code == 401:
|
|
192
|
-
# Session expired — re-login and retry once
|
|
190
|
+
async with self._semaphore:
|
|
191
|
+
await self._ensure_client()
|
|
192
|
+
if not self.session:
|
|
193
193
|
await self.login()
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
|
|
195
|
+
url = self._get_url(endpoint)
|
|
196
|
+
timeout = timeout_override or self.timeout
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
if method.upper() == "POST":
|
|
196
200
|
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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}")
|
|
235
250
|
|
|
236
251
|
async def get_system_information(self) -> Dict[str, Any]:
|
|
237
252
|
"""Fetch extensive system information (uptime, memory, network, serial, version, 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
|
|
@@ -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,
|
|
@@ -107,3 +108,53 @@ async def test_set_configuration_calls_hardware_endpoint():
|
|
|
107
108
|
|
|
108
109
|
request.assert_awaited_once_with("/set_configuration.fcgi", payload)
|
|
109
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
|
|
File without changes
|
|
File without changes
|