controlid-sdk 0.3.2__tar.gz → 0.3.4__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3.9
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Classifier: Operating System :: OS Independent
17
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
17
  Classifier: Topic :: System :: Hardware
@@ -20,6 +19,9 @@ Requires-Python: >=3.9
20
19
  Description-Content-Type: text/markdown
21
20
  Requires-Dist: httpx>=0.24.0
22
21
  Requires-Dist: pydantic>=2.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
23
25
 
24
26
  # ControlID Python SDK (Async)
25
27
 
@@ -192,6 +194,16 @@ await client.add_time_spans([
192
194
 
193
195
  ### 🧩 Generic Object API
194
196
 
197
+ ## Firmware Compatibility Matrix
198
+
199
+ Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
200
+
201
+ | Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
202
+ | --- | --- | --- | --- | --- |
203
+ | **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
204
+ | **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
205
+ | **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
206
+
195
207
  ## Directory Structure
196
208
  - `src/controlid/client.py`: Core asynchronous logic and endpoints.
197
209
  - `src/controlid/models.py`: Pydantic V2 definitions.
@@ -169,6 +169,16 @@ await client.add_time_spans([
169
169
 
170
170
  ### 🧩 Generic Object API
171
171
 
172
+ ## Firmware Compatibility Matrix
173
+
174
+ Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
175
+
176
+ | Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
177
+ | --- | --- | --- | --- | --- |
178
+ | **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
179
+ | **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
180
+ | **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
181
+
172
182
  ## Directory Structure
173
183
  - `src/controlid/client.py`: Core asynchronous logic and endpoints.
174
184
  - `src/controlid/models.py`: Pydantic V2 definitions.
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "controlid-sdk"
7
- version = "0.3.2"
7
+ version = "0.3.4"
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
14
  requires-python = ">=3.9"
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
@@ -20,7 +20,6 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.10",
21
21
  "Programming Language :: Python :: 3.11",
22
22
  "Programming Language :: Python :: 3.12",
23
- "License :: OSI Approved :: MIT License",
24
23
  "Operating System :: OS Independent",
25
24
  "Topic :: Software Development :: Libraries :: Python Modules",
26
25
  "Topic :: System :: Hardware",
@@ -30,5 +29,15 @@ dependencies = [
30
29
  "pydantic>=2.0.0",
31
30
  ]
32
31
 
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0.0",
35
+ "pytest-asyncio>=0.21.0",
36
+ ]
37
+
33
38
  [project.urls]
34
39
  "Homepage" = "https://github.com/tulioamancio/controlid-python-sdk"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
@@ -11,17 +11,27 @@ from .models import (
11
11
  Door,
12
12
  AccessLog,
13
13
  Device,
14
- TimeZone,
15
14
  TimeSpan,
16
15
  FaceTemplate,
17
16
  CatraInfo,
18
17
  GPIOStatus,
19
18
  CustomField,
20
19
  )
21
- from .exceptions import ControlIDError, AuthenticationError, SessionError, APIError
20
+ from .exceptions import (
21
+ ControlIDError,
22
+ AuthenticationError,
23
+ SessionError,
24
+ APIError,
25
+ BadRequestError,
26
+ UnauthorizedError,
27
+ ForbiddenError,
28
+ NotFoundError,
29
+ RateLimitError,
30
+ DeviceUnreachableError,
31
+ )
22
32
  from . import constants
23
33
 
24
- __version__ = "0.3.2"
34
+ __version__ = "0.3.4"
25
35
 
26
36
  __all__ = [
27
37
  "ControlIDClient",
@@ -33,6 +43,7 @@ __all__ = [
33
43
  "UserGroup",
34
44
  "AccessRule",
35
45
  "TimeZone",
46
+ "TimeSpan",
36
47
  "Area",
37
48
  "Door",
38
49
  "AccessLog",
@@ -46,6 +57,14 @@ __all__ = [
46
57
  "AuthenticationError",
47
58
  "SessionError",
48
59
  "APIError",
60
+ "BadRequestError",
61
+ "UnauthorizedError",
62
+ "ForbiddenError",
63
+ "NotFoundError",
64
+ "RateLimitError",
65
+ "DeviceUnreachableError",
49
66
  # Constants module (handy for card type enums, table names, etc.)
50
67
  "constants",
51
68
  ]
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:
@@ -107,14 +108,12 @@ class ControlIDClient:
107
108
  payload = {"login": self.user, "password": self.password}
108
109
 
109
110
  try:
110
- logger_ctx = {"host": try_host, "user": self.user}
111
111
  response = await self._client.post(url, json=payload, timeout=5.0) # Short timeout for login attempt
112
112
  response.raise_for_status()
113
113
  data = response.json()
114
114
  if "session" in data:
115
115
  self.session = data["session"]
116
116
  if self.host != try_host:
117
- old_host = self.host
118
117
  self.host = try_host # Update to working host
119
118
  if self.on_host_change:
120
119
  try:
@@ -122,14 +121,16 @@ class ControlIDClient:
122
121
  asyncio.create_task(self.on_host_change(self.host))
123
122
  else:
124
123
  self.on_host_change(self.host)
125
- except Exception: pass
124
+ except Exception:
125
+ pass
126
126
  return self.session
127
127
  raise ex.AuthenticationError(f"Login failed: {data}")
128
128
  except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.ProtocolError, httpx.RemoteProtocolError, httpx.ProxyError) as e:
129
129
  last_err = f"{type(e).__name__} on {proto}"
130
130
  continue
131
131
  except Exception as e:
132
- if isinstance(e, ex.AuthenticationError): raise
132
+ if isinstance(e, ex.AuthenticationError):
133
+ raise
133
134
  last_err = f"{type(e).__name__}: {str(e)}"
134
135
  continue
135
136
 
@@ -191,16 +192,45 @@ class ControlIDClient:
191
192
  # Session expired — re-login and retry once
192
193
  await self.login()
193
194
  url = self._get_url(endpoint)
194
- response = await self._client.post(url, json=payload or {}, timeout=timeout)
195
- response.raise_for_status()
196
- return response.json()
197
- raise ex.APIError(
198
- f"API Error: {e.response.text}",
199
- status_code=e.response.status_code,
200
- response=e.response.text,
201
- )
195
+ try:
196
+ 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}")
202
231
  except Exception as e:
203
- if isinstance(e, (ex.APIError, ex.AuthenticationError)): raise
232
+ if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)):
233
+ raise
204
234
  raise ex.ControlIDError(f"Request failed: {type(e).__name__} {e}")
205
235
 
206
236
  async def get_system_information(self) -> Dict[str, Any]:
@@ -342,8 +372,10 @@ class ControlIDClient:
342
372
  async def get_cards(self, user_id: Optional[int] = None, value: Optional[int] = None) -> List[Card]:
343
373
  """Return credentials for all users, or filtered by user_id or card value."""
344
374
  where = {}
345
- if user_id: where["user_id"] = user_id
346
- if value: where["value"] = value
375
+ if user_id:
376
+ where["user_id"] = user_id
377
+ if value:
378
+ where["value"] = value
347
379
  return [Card(**c) for c in await self.load_objects(const.TABLE_CARDS, where=where or None)]
348
380
 
349
381
  async def add_card(self, card: Card) -> int:
@@ -739,6 +771,8 @@ class ControlIDClient:
739
771
 
740
772
  await client.set_configuration({"face_id": {"qrcode_legacy_mode_enabled": "1"}})
741
773
  """
774
+ return await self.request(const.SET_CONFIGURATION, config)
775
+
742
776
  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]]:
743
777
  """
744
778
  Export device audit logs (system events, config changes, etc.).
@@ -0,0 +1,47 @@
1
+ class ControlIDError(Exception):
2
+ """Base exception for ControlID SDK."""
3
+ pass
4
+
5
+ class AuthenticationError(ControlIDError):
6
+ """Raised when authentication fails."""
7
+ pass
8
+
9
+ class SessionError(ControlIDError):
10
+ """Raised when there is an issue with the session."""
11
+ pass
12
+
13
+ class APIError(ControlIDError):
14
+ """Raised when the API returns an error response."""
15
+ def __init__(self, message, status_code=None, response=None):
16
+ super().__init__(message)
17
+ self.status_code = status_code
18
+ self.response = response
19
+
20
+ class BadRequestError(APIError):
21
+ """Raised when the API returns a 400 Bad Request error."""
22
+ pass
23
+
24
+ class UnauthorizedError(APIError):
25
+ """Raised when the API returns a 401 Unauthorized error."""
26
+ pass
27
+
28
+ class ForbiddenError(APIError):
29
+ """Raised when the API returns a 403 Forbidden error."""
30
+ pass
31
+
32
+ class NotFoundError(APIError):
33
+ """Raised when the API returns a 404 Not Found error."""
34
+ pass
35
+
36
+ class RateLimitError(APIError):
37
+ """Raised when the API returns a 429 Too Many Requests error."""
38
+ pass
39
+
40
+ class DeviceUnreachableError(ControlIDError):
41
+ """Raised when the device is unreachable (e.g. connection timeout or dns failure)."""
42
+ pass
43
+
44
+ class ObjectError(ControlIDError):
45
+ """Raised when an object operation fails."""
46
+ pass
47
+
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3.9
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Classifier: Operating System :: OS Independent
17
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
17
  Classifier: Topic :: System :: Hardware
@@ -20,6 +19,9 @@ Requires-Python: >=3.9
20
19
  Description-Content-Type: text/markdown
21
20
  Requires-Dist: httpx>=0.24.0
22
21
  Requires-Dist: pydantic>=2.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
23
25
 
24
26
  # ControlID Python SDK (Async)
25
27
 
@@ -192,6 +194,16 @@ await client.add_time_spans([
192
194
 
193
195
  ### 🧩 Generic Object API
194
196
 
197
+ ## Firmware Compatibility Matrix
198
+
199
+ Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
200
+
201
+ | Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
202
+ | --- | --- | --- | --- | --- |
203
+ | **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
204
+ | **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
205
+ | **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
206
+
195
207
  ## Directory Structure
196
208
  - `src/controlid/client.py`: Core asynchronous logic and endpoints.
197
209
  - `src/controlid/models.py`: Pydantic V2 definitions.
@@ -9,4 +9,6 @@ src/controlid_sdk.egg-info/PKG-INFO
9
9
  src/controlid_sdk.egg-info/SOURCES.txt
10
10
  src/controlid_sdk.egg-info/dependency_links.txt
11
11
  src/controlid_sdk.egg-info/requires.txt
12
- src/controlid_sdk.egg-info/top_level.txt
12
+ src/controlid_sdk.egg-info/top_level.txt
13
+ tests/test_client.py
14
+ tests/test_models.py
@@ -0,0 +1,6 @@
1
+ httpx>=0.24.0
2
+ pydantic>=2.0.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-asyncio>=0.21.0
@@ -0,0 +1,109 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock, AsyncMock, patch
3
+ import httpx
4
+ from controlid import ControlIDClient
5
+ from controlid.exceptions import (
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ UnauthorizedError,
9
+ ForbiddenError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ DeviceUnreachableError,
13
+ APIError,
14
+ )
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_client_login_success():
18
+ client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
19
+
20
+ mock_response = MagicMock(spec=httpx.Response)
21
+ mock_response.status_code = 200
22
+ mock_response.json = MagicMock(return_value={"session": "test-session-token"})
23
+ mock_response.raise_for_status = MagicMock()
24
+
25
+ with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
26
+ session = await client.login()
27
+ assert session == "test-session-token"
28
+ assert client.session == "test-session-token"
29
+ mock_post.assert_called_once()
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_client_login_failure():
33
+ client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
34
+
35
+ mock_response = MagicMock(spec=httpx.Response)
36
+ mock_response.status_code = 401
37
+ mock_response.json = MagicMock(return_value={"error": "Invalid credentials"})
38
+ mock_response.raise_for_status = MagicMock()
39
+
40
+ with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
41
+ with pytest.raises(AuthenticationError):
42
+ await client.login()
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_client_request_injects_session():
46
+ client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
47
+ client.session = "active-session"
48
+
49
+ mock_response = MagicMock(spec=httpx.Response)
50
+ mock_response.status_code = 200
51
+ mock_response.json = MagicMock(return_value={"status": "success"})
52
+ mock_response.raise_for_status = MagicMock()
53
+
54
+ await client._ensure_client()
55
+
56
+ with patch.object(client._client, "post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
57
+ res = await client.request("/some-endpoint", {"param": "val"})
58
+ assert res == {"status": "success"}
59
+ called_url = mock_post.call_args[0][0]
60
+ assert "session=active-session" in called_url
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_client_request_handles_status_errors():
64
+ client = ControlIDClient(host="https://192.168.0.100")
65
+ client.session = "active-session"
66
+ await client._ensure_client()
67
+
68
+ error_cases = [
69
+ (400, BadRequestError),
70
+ (401, UnauthorizedError),
71
+ (403, ForbiddenError),
72
+ (404, NotFoundError),
73
+ (429, RateLimitError),
74
+ (500, APIError),
75
+ ]
76
+
77
+ for code, exc_class in error_cases:
78
+ mock_response = httpx.Response(status_code=code, text=f"Error {code}")
79
+
80
+ if code == 401:
81
+ with patch.object(client, "login", new_callable=AsyncMock, return_value="new-token"):
82
+ with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.HTTPStatusError("Err", request=None, response=mock_response)):
83
+ with pytest.raises(exc_class):
84
+ await client.request("/some-endpoint")
85
+ else:
86
+ with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.HTTPStatusError("Err", request=None, response=mock_response)):
87
+ with pytest.raises(exc_class):
88
+ await client.request("/some-endpoint")
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_client_request_device_unreachable():
92
+ client = ControlIDClient(host="https://192.168.0.100")
93
+ client.session = "active-session"
94
+ await client._ensure_client()
95
+
96
+ with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.ConnectError("Connection refused")):
97
+ with pytest.raises(DeviceUnreachableError):
98
+ await client.request("/some-endpoint")
99
+
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_set_configuration_calls_hardware_endpoint():
103
+ client = ControlIDClient(host="https://192.168.0.100")
104
+ payload = {"snmp_agent": {"snmp_enabled": "1"}}
105
+ with patch.object(client, "request", new_callable=AsyncMock, return_value={"status": "ok"}) as request:
106
+ result = await client.set_configuration(payload)
107
+
108
+ request.assert_awaited_once_with("/set_configuration.fcgi", payload)
109
+ assert result == {"status": "ok"}
@@ -0,0 +1,30 @@
1
+ from controlid.models import User, Card, TimeZone, TimeSpan
2
+
3
+ def test_user_model():
4
+ user = User(id=1, name="John Doe", registration="12345")
5
+ assert user.id == 1
6
+ assert user.name == "John Doe"
7
+ assert user.registration == "12345"
8
+ assert user.password == ""
9
+
10
+ def test_card_model():
11
+ card1 = Card(id=10, value=123456, user_id=1, type=0)
12
+ assert card1.id == 10
13
+ assert card1.value == 123456
14
+ assert card1.user_id == 1
15
+ assert card1.type == 0
16
+
17
+ # Exclude_none test
18
+ card2 = Card(value=654321, user_id=2)
19
+ assert card2.type is None
20
+ dumped = card2.model_dump(exclude_none=True)
21
+ assert "type" not in dumped
22
+
23
+ def test_timezone_and_timespan():
24
+ tz = TimeZone(id=1, name="Standard")
25
+ assert tz.name == "Standard"
26
+
27
+ span = TimeSpan(time_zone_id=1, start=28800, end=64800, mon=1, tue=1)
28
+ assert span.start == 28800
29
+ assert span.mon == 1
30
+ assert span.sun == 0
@@ -1,22 +0,0 @@
1
- class ControlIDError(Exception):
2
- """Base exception for ControlID SDK."""
3
- pass
4
-
5
- class AuthenticationError(ControlIDError):
6
- """Raised when authentication fails."""
7
- pass
8
-
9
- class SessionError(ControlIDError):
10
- """Raised when there is an issue with the session."""
11
- pass
12
-
13
- class APIError(ControlIDError):
14
- """Raised when the API returns an error response."""
15
- def __init__(self, message, status_code=None, response=None):
16
- super().__init__(message)
17
- self.status_code = status_code
18
- self.response = response
19
-
20
- class ObjectError(ControlIDError):
21
- """Raised when an object operation fails."""
22
- pass
@@ -1,2 +0,0 @@
1
- httpx>=0.24.0
2
- pydantic>=2.0.0
File without changes