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.
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.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.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 = { text = "MIT" }
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.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, urlparse
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, Door, AccessLog, UserRole, QRCard, CustomField, TimeZone, TimeSpan
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: Optional[httpx.AsyncClient] = None
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
- self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
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
- self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
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
- await self._ensure_client()
101
+ async with self._login_lock:
102
+ await self._ensure_client()
103
+ if self.session:
104
+ return self.session
90
105
 
91
- protocols = ["https", "http"]
92
- # Start with current if it has one, otherwise prefer https
93
- current_proto = "https"
94
- if "://" in self.host:
95
- current_proto = self.host.split("://")[0]
96
-
97
- # Reorder to try current first
98
- if current_proto == "http":
99
- protocols = ["http", "https"]
100
-
101
- last_err = None
102
- for proto in protocols:
103
- # Build URL for this protocol attempt
104
- clean_host = self.host.split("://")[-1]
105
- try_host = f"{proto}://{clean_host}"
106
- url = f"{try_host}{const.LOGIN}"
107
- payload = {"login": self.user, "password": self.password}
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
- raise ex.AuthenticationError(f"Connection failed after trying all protocols: {last_err}")
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
- await self._ensure_client()
176
- if not self.session:
177
- await self.login()
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
- url = self._get_url(endpoint)
194
- try:
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
- response.raise_for_status()
197
- return response.json()
198
- except httpx.HTTPStatusError as retry_e:
199
- status_code = retry_e.response.status_code
200
- msg = f"API Error: {retry_e.response.text}"
201
- if status_code == 400:
202
- raise ex.BadRequestError(msg, status_code=status_code, response=retry_e.response.text)
203
- elif status_code == 401:
204
- raise ex.UnauthorizedError(msg, status_code=status_code, response=retry_e.response.text)
205
- elif status_code == 403:
206
- raise ex.ForbiddenError(msg, status_code=status_code, response=retry_e.response.text)
207
- elif status_code == 404:
208
- raise ex.NotFoundError(msg, status_code=status_code, response=retry_e.response.text)
209
- elif status_code == 429:
210
- raise ex.RateLimitError(msg, status_code=status_code, response=retry_e.response.text)
211
- else:
212
- raise ex.APIError(msg, status_code=status_code, response=retry_e.response.text)
213
-
214
- status_code = e.response.status_code
215
- msg = f"API Error: {e.response.text}"
216
- if status_code == 400:
217
- raise ex.BadRequestError(msg, status_code=status_code, response=e.response.text)
218
- elif status_code == 401:
219
- raise ex.UnauthorizedError(msg, status_code=status_code, response=e.response.text)
220
- elif status_code == 403:
221
- raise ex.ForbiddenError(msg, status_code=status_code, response=e.response.text)
222
- elif status_code == 404:
223
- raise ex.NotFoundError(msg, status_code=status_code, response=e.response.text)
224
- elif status_code == 429:
225
- raise ex.RateLimitError(msg, status_code=status_code, response=e.response.text)
226
- else:
227
- raise ex.APIError(msg, status_code=status_code, response=e.response.text)
228
- except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
229
- raise ex.DeviceUnreachableError(f"Device unreachable: {type(e).__name__} {e}")
230
- except Exception as e:
231
- if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)): raise
232
- raise ex.ControlIDError(f"Request failed: {type(e).__name__} {e}")
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: where["user_id"] = user_id
374
- if value: where["value"] = value
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,5 +1,5 @@
1
- from pydantic import BaseModel, Field
2
- from typing import Optional, List, Dict, Any
1
+ from pydantic import BaseModel
2
+ from typing import Optional, Dict, Any
3
3
 
4
4
 
5
5
  # ─── Core Models ───────────────────────────────────────────────────────────────
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.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