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.
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.4
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.4"
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: 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)
58
63
 
59
64
  # ─── Context Manager ───────────────────────────────────────────────────────
60
65
 
61
66
  async def __aenter__(self) -> "ControlIDClient":
62
- 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)
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
- 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)
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
- await self._ensure_client()
101
+ async with self._login_lock:
102
+ await self._ensure_client()
103
+ if self.session:
104
+ return self.session
91
105
 
92
- protocols = ["https", "http"]
93
- # Start with current if it has one, otherwise prefer https
94
- current_proto = "https"
95
- if "://" in self.host:
96
- current_proto = self.host.split("://")[0]
97
-
98
- # Reorder to try current first
99
- if current_proto == "http":
100
- protocols = ["http", "https"]
101
-
102
- last_err = None
103
- for proto in protocols:
104
- # Build URL for this protocol attempt
105
- clean_host = self.host.split("://")[-1]
106
- try_host = f"{proto}://{clean_host}"
107
- url = f"{try_host}{const.LOGIN}"
108
- payload = {"login": self.user, "password": self.password}
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
- 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}")
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
- await self._ensure_client()
177
- if not self.session:
178
- await self.login()
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
- url = self._get_url(endpoint)
195
- try:
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
- response.raise_for_status()
198
- return response.json()
199
- except httpx.HTTPStatusError as retry_e:
200
- status_code = retry_e.response.status_code
201
- msg = f"API Error: {retry_e.response.text}"
202
- if status_code == 400:
203
- raise ex.BadRequestError(msg, status_code=status_code, response=retry_e.response.text)
204
- elif status_code == 401:
205
- raise ex.UnauthorizedError(msg, status_code=status_code, response=retry_e.response.text)
206
- elif status_code == 403:
207
- raise ex.ForbiddenError(msg, status_code=status_code, response=retry_e.response.text)
208
- elif status_code == 404:
209
- raise ex.NotFoundError(msg, status_code=status_code, response=retry_e.response.text)
210
- elif status_code == 429:
211
- raise ex.RateLimitError(msg, status_code=status_code, response=retry_e.response.text)
212
- else:
213
- raise ex.APIError(msg, status_code=status_code, response=retry_e.response.text)
214
-
215
- status_code = e.response.status_code
216
- msg = f"API Error: {e.response.text}"
217
- if status_code == 400:
218
- raise ex.BadRequestError(msg, status_code=status_code, response=e.response.text)
219
- elif status_code == 401:
220
- raise ex.UnauthorizedError(msg, status_code=status_code, response=e.response.text)
221
- elif status_code == 403:
222
- raise ex.ForbiddenError(msg, status_code=status_code, response=e.response.text)
223
- elif status_code == 404:
224
- raise ex.NotFoundError(msg, status_code=status_code, response=e.response.text)
225
- elif status_code == 429:
226
- raise ex.RateLimitError(msg, status_code=status_code, response=e.response.text)
227
- else:
228
- raise ex.APIError(msg, status_code=status_code, response=e.response.text)
229
- except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
230
- raise ex.DeviceUnreachableError(f"Device unreachable: {type(e).__name__} {e}")
231
- except Exception as e:
232
- if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)):
233
- raise
234
- 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}")
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.4
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