dragoneye-python 0.3.0__tar.gz → 0.4.0__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.
Files changed (24) hide show
  1. dragoneye_python-0.4.0/PKG-INFO +10 -0
  2. dragoneye_python-0.4.0/dragoneye_python.egg-info/PKG-INFO +10 -0
  3. dragoneye_python-0.4.0/dragoneye_python.egg-info/requires.txt +5 -0
  4. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/pyproject.toml +6 -2
  5. dragoneye_python-0.4.0/requirements.txt +5 -0
  6. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/classification.py +75 -31
  7. dragoneye_python-0.4.0/src/dragoneye/client.py +29 -0
  8. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/models.py +2 -3
  9. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/types/media.py +10 -8
  10. dragoneye_python-0.3.0/PKG-INFO +0 -7
  11. dragoneye_python-0.3.0/dragoneye_python.egg-info/PKG-INFO +0 -7
  12. dragoneye_python-0.3.0/dragoneye_python.egg-info/requires.txt +0 -2
  13. dragoneye_python-0.3.0/requirements.txt +0 -2
  14. dragoneye_python-0.3.0/src/dragoneye/client.py +0 -18
  15. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/README.md +0 -0
  16. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/dragoneye_python.egg-info/SOURCES.txt +0 -0
  17. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/dragoneye_python.egg-info/dependency_links.txt +0 -0
  18. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/dragoneye_python.egg-info/top_level.txt +0 -0
  19. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/setup.cfg +0 -0
  20. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/__init__.py +0 -0
  21. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/constants.py +0 -0
  22. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/types/__init__.py +0 -0
  23. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/types/common.py +0 -0
  24. {dragoneye_python-0.3.0 → dragoneye_python-0.4.0}/src/dragoneye/types/exception.py +0 -0
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: dragoneye-python
3
+ Version: 0.4.0
4
+ License-Expression: MIT
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: requests
7
+ Requires-Dist: pydantic>=2
8
+ Requires-Dist: typing-extensions>=4.0.0
9
+ Requires-Dist: backoff>=2.0.0
10
+ Requires-Dist: aiohttp
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: dragoneye-python
3
+ Version: 0.4.0
4
+ License-Expression: MIT
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: requests
7
+ Requires-Dist: pydantic>=2
8
+ Requires-Dist: typing-extensions>=4.0.0
9
+ Requires-Dist: backoff>=2.0.0
10
+ Requires-Dist: aiohttp
@@ -0,0 +1,5 @@
1
+ requests
2
+ pydantic>=2
3
+ typing-extensions>=4.0.0
4
+ backoff>=2.0.0
5
+ aiohttp
@@ -1,9 +1,13 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
1
5
  [project]
2
6
  name = "dragoneye-python"
3
- version = "0.3.0"
7
+ version = "0.4.0"
4
8
  requires-python = ">=3.8"
5
9
  dynamic = ["dependencies"]
6
- license = {text = "MIT"}
10
+ license = "MIT"
7
11
 
8
12
  [tool.setuptools.dynamic]
9
13
  dependencies = {file = ["requirements.txt"]}
@@ -0,0 +1,5 @@
1
+ requests
2
+ pydantic>=2
3
+ typing-extensions>=4.0.0
4
+ backoff>=2.0.0
5
+ aiohttp
@@ -1,8 +1,10 @@
1
1
  import asyncio
2
+ import logging
2
3
  import time
3
- from typing import TYPE_CHECKING, Any, Literal, overload
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, overload
4
5
 
5
6
  import aiohttp
7
+ import backoff
6
8
  from aiohttp import ClientError
7
9
  from pydantic import BaseModel
8
10
 
@@ -33,7 +35,7 @@ if TYPE_CHECKING:
33
35
 
34
36
  class _PresignedPostRequest(BaseModel):
35
37
  url: str
36
- fields: dict[str, Any]
38
+ fields: Dict[str, Any]
37
39
 
38
40
 
39
41
  class _MediaUploadUrl(BaseModel):
@@ -44,7 +46,7 @@ class _MediaUploadUrl(BaseModel):
44
46
  class _PredictionTaskBeginResponse(BaseModel):
45
47
  prediction_task_uuid: PredictionTaskUUID
46
48
  prediction_type: PredictionType
47
- signed_urls: list[_MediaUploadUrl]
49
+ signed_urls: List[_MediaUploadUrl]
48
50
 
49
51
 
50
52
  def _is_task_successful(status: PredictionTaskState) -> bool:
@@ -68,11 +70,33 @@ class Classification:
68
70
  def __init__(self, client: "Dragoneye"):
69
71
  self._client = client
70
72
 
73
+ # Create a reusable backoff decorator for 429 rate limit errors
74
+ def _should_retry_429(exception: Exception) -> bool:
75
+ """Check if exception is a 429 rate limit error"""
76
+ return (
77
+ isinstance(exception, aiohttp.ClientResponseError)
78
+ and exception.status == 429
79
+ )
80
+
81
+ # Store the backoff decorator as an instance method
82
+ self._backoff_on_429 = backoff.on_exception(
83
+ wait_gen=backoff.expo,
84
+ exception=aiohttp.ClientResponseError,
85
+ max_tries=client.max_retries,
86
+ max_time=client.max_backoff_time,
87
+ on_backoff=lambda e: logging.info(
88
+ f"Rate limit exceeded - backing off: {e}"
89
+ ),
90
+ on_giveup=lambda e: logging.info(f"Rate limit exceeded - giving up: {e}"),
91
+ giveup=lambda e: not _should_retry_429(e),
92
+ jitter=client.backoff_jitter,
93
+ )
94
+
71
95
  async def predict_image(
72
96
  self,
73
97
  media: Image,
74
98
  model_name: str,
75
- timeout_seconds: int | None = None,
99
+ timeout_seconds: Optional[int] = None,
76
100
  ) -> ClassificationPredictImageResponse:
77
101
  return await self._predict_unified(
78
102
  media=media,
@@ -86,7 +110,7 @@ class Classification:
86
110
  media: Video,
87
111
  model_name: str,
88
112
  frames_per_second: int = 1,
89
- timeout_seconds: int | None = None,
113
+ timeout_seconds: Optional[int] = None,
90
114
  ) -> ClassificationPredictVideoResponse:
91
115
  return await self._predict_unified(
92
116
  media=media,
@@ -104,11 +128,15 @@ class Classification:
104
128
  url = f"{BASE_API_URL}/prediction-task/status?predictionTaskUuid={prediction_task_uuid}"
105
129
  headers = {"Authorization": f"Bearer {self._client.api_key}"}
106
130
 
107
- async with aiohttp.ClientSession() as session:
108
- async with session.get(url, headers=headers) as resp:
109
- resp.raise_for_status()
110
- payload = await resp.json()
131
+ @self._backoff_on_429
132
+ async def _make_request():
133
+ async with aiohttp.ClientSession() as session:
134
+ async with session.get(url, headers=headers) as resp:
135
+ resp.raise_for_status()
136
+ payload = await resp.json()
137
+ return payload
111
138
 
139
+ payload = await _make_request()
112
140
  return PredictionTaskStatusResponse.model_validate(payload)
113
141
 
114
142
  async def get_image_results(
@@ -148,19 +176,26 @@ class Classification:
148
176
  self,
149
177
  prediction_task_uuid: PredictionTaskUUID,
150
178
  prediction_type: PredictionType,
151
- ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse: ...
179
+ ) -> Union[
180
+ ClassificationPredictImageResponse, ClassificationPredictVideoResponse
181
+ ]: ...
152
182
 
153
183
  async def _get_results_unified(
154
184
  self, prediction_task_uuid: PredictionTaskUUID, prediction_type: PredictionType
155
- ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse:
185
+ ) -> Union[ClassificationPredictImageResponse, ClassificationPredictVideoResponse]:
156
186
  url = f"{BASE_API_URL}/prediction-task/results?predictionTaskUuid={prediction_task_uuid}"
157
187
  headers = {"Authorization": f"Bearer {self._client.api_key}"}
158
188
 
159
- try:
189
+ @self._backoff_on_429
190
+ async def _make_request():
160
191
  async with aiohttp.ClientSession() as session:
161
192
  async with session.get(url, headers=headers) as resp:
162
193
  resp.raise_for_status()
163
194
  payload = await resp.json()
195
+ return payload
196
+
197
+ try:
198
+ payload = await _make_request()
164
199
  except ClientError as error:
165
200
  raise PredictionTaskResultsUnavailableError(
166
201
  f"Error getting prediction task results: {error}"
@@ -169,13 +204,12 @@ class Classification:
169
204
  # Add the prediction task uuid to the response before returning
170
205
  payload["prediction_task_uuid"] = prediction_task_uuid
171
206
 
172
- match prediction_type:
173
- case "image":
174
- return ClassificationPredictImageResponse.model_validate(payload)
175
- case "video":
176
- return ClassificationPredictVideoResponse.model_validate(payload)
177
- case _: # pyright: ignore [reportUnnecessaryComparison]
178
- raise ValueError(f"Unsupported prediction type: {prediction_type}")
207
+ if prediction_type == "image":
208
+ return ClassificationPredictImageResponse.model_validate(payload)
209
+ elif prediction_type == "video":
210
+ return ClassificationPredictVideoResponse.model_validate(payload)
211
+ else:
212
+ raise ValueError(f"Unsupported prediction type: {prediction_type}")
179
213
 
180
214
  ##### Internal API methods #####
181
215
  @overload
@@ -183,8 +217,8 @@ class Classification:
183
217
  self,
184
218
  media: Image,
185
219
  model_name: str,
186
- frames_per_second: int | None,
187
- timeout_seconds: int | None = None,
220
+ frames_per_second: Optional[int],
221
+ timeout_seconds: Optional[int] = None,
188
222
  ) -> ClassificationPredictImageResponse: ...
189
223
 
190
224
  @overload
@@ -192,17 +226,17 @@ class Classification:
192
226
  self,
193
227
  media: Video,
194
228
  model_name: str,
195
- frames_per_second: int | None,
196
- timeout_seconds: int | None = None,
229
+ frames_per_second: Optional[int],
230
+ timeout_seconds: Optional[int] = None,
197
231
  ) -> ClassificationPredictVideoResponse: ...
198
232
 
199
233
  async def _predict_unified(
200
234
  self,
201
- media: Image | Video,
235
+ media: Union[Image, Video],
202
236
  model_name: str,
203
- frames_per_second: int | None,
204
- timeout_seconds: int | None = None,
205
- ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse:
237
+ frames_per_second: Optional[int],
238
+ timeout_seconds: Optional[int] = None,
239
+ ) -> Union[ClassificationPredictImageResponse, ClassificationPredictVideoResponse]:
206
240
  prediction_task_begin_response = await self._begin_prediction_task(
207
241
  mime_type=media.mime_type,
208
242
  frames_per_second=frames_per_second,
@@ -220,12 +254,17 @@ class Classification:
220
254
  predict_headers = {
221
255
  "Authorization": f"Bearer {self._client.api_key}",
222
256
  }
223
- try:
257
+
258
+ @self._backoff_on_429
259
+ async def _make_request():
224
260
  async with aiohttp.ClientSession() as session:
225
261
  async with session.post(
226
262
  predict_url, data=predict_data, headers=predict_headers
227
263
  ) as resp:
228
264
  resp.raise_for_status()
265
+
266
+ try:
267
+ await _make_request()
229
268
  except ClientError as error:
230
269
  raise PredictionTaskError("Error initiating prediction:", error)
231
270
 
@@ -246,7 +285,7 @@ class Classification:
246
285
  self,
247
286
  prediction_task_uuid: PredictionTaskUUID,
248
287
  polling_interval: float = 1.0,
249
- timeout_seconds: int | None = None,
288
+ timeout_seconds: Optional[int] = None,
250
289
  ) -> PredictionTaskStatusResponse:
251
290
  start_time = time.monotonic()
252
291
  while True:
@@ -299,7 +338,7 @@ class Classification:
299
338
  async def _begin_prediction_task(
300
339
  self,
301
340
  mime_type: str,
302
- frames_per_second: int | None,
341
+ frames_per_second: Optional[int],
303
342
  ) -> _PredictionTaskBeginResponse:
304
343
  url = f"{BASE_API_URL}/prediction-task/begin"
305
344
 
@@ -312,11 +351,16 @@ class Classification:
312
351
  "Authorization": f"Bearer {self._client.api_key}",
313
352
  }
314
353
 
315
- try:
354
+ @self._backoff_on_429
355
+ async def _make_request():
316
356
  async with aiohttp.ClientSession() as session:
317
357
  async with session.post(url, data=form_data, headers=headers) as resp:
318
358
  resp.raise_for_status()
319
359
  payload = await resp.json()
360
+ return payload
361
+
362
+ try:
363
+ payload = await _make_request()
320
364
  except ClientError as error:
321
365
  raise PredictionTaskBeginError("Error beginning prediction task:", error)
322
366
 
@@ -0,0 +1,29 @@
1
+ import os
2
+ from typing import Callable, Optional
3
+
4
+ import backoff
5
+
6
+ from dragoneye.classification import Classification
7
+
8
+
9
+ class Dragoneye:
10
+ def __init__(
11
+ self,
12
+ api_key: Optional[str] = None,
13
+ max_retries: int = 10,
14
+ max_backoff_time: int = 120,
15
+ backoff_jitter: Callable[[float], float] = backoff.full_jitter,
16
+ ):
17
+ if api_key is None:
18
+ api_key = os.getenv("DRAGONEYE_API_KEY")
19
+
20
+ assert api_key is not None, (
21
+ "API key is required - set the DRAGONEYE_API_KEY environment variable or pass it to the [Dragoneye] constructor"
22
+ )
23
+
24
+ self.api_key = api_key
25
+ self.max_retries = max_retries
26
+ self.max_backoff_time = max_backoff_time
27
+ self.backoff_jitter = backoff_jitter
28
+
29
+ self.classification = Classification(self)
@@ -1,4 +1,4 @@
1
- from typing import Sequence
1
+ from typing import Dict, Sequence
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
@@ -38,12 +38,11 @@ class ClassificationPredictImageResponse(BaseModel):
38
38
 
39
39
  class ClassificationVideoObjectPrediction(ClassificationObjectPrediction):
40
40
  frame_id: str
41
- frame_index: int
42
41
  timestamp_microseconds: int
43
42
 
44
43
 
45
44
  class ClassificationPredictVideoResponse(BaseModel):
46
- timestamp_us_to_predictions: dict[
45
+ timestamp_us_to_predictions: Dict[
47
46
  int, Sequence[ClassificationVideoObjectPrediction]
48
47
  ]
49
48
  frames_per_second: int
@@ -5,7 +5,9 @@ import os
5
5
  from dataclasses import dataclass
6
6
  from io import BufferedReader, BytesIO
7
7
  from pathlib import Path
8
- from typing import BinaryIO, ClassVar, Self, Union
8
+ from typing import BinaryIO, ClassVar, Optional, Tuple, Union
9
+
10
+ from typing_extensions import Self
9
11
 
10
12
 
11
13
  @dataclass(frozen=True)
@@ -16,7 +18,7 @@ class Media:
16
18
  mime_type: str
17
19
 
18
20
  # Subclasses set this to enforce a family of mimetypes, e.g. ("image/",)
19
- ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ()
21
+ ACCEPT_PREFIXES: ClassVar[Tuple[str, ...]] = ()
20
22
 
21
23
  def __post_init__(self) -> None:
22
24
  # Enforce subtype-specific mimetype families when specified.
@@ -47,7 +49,7 @@ class Media:
47
49
  cls,
48
50
  path: Union[str, os.PathLike[str]],
49
51
  *,
50
- mime_type: str | None = None,
52
+ mime_type: Optional[str] = None,
51
53
  guess_from_extension: bool = True,
52
54
  read_into_memory: bool = False,
53
55
  ) -> Self:
@@ -118,7 +120,7 @@ class Media:
118
120
  "Invalid media source: expected bytes, BytesIO, or a readable binary stream."
119
121
  )
120
122
 
121
- def size_bytes(self) -> int | None:
123
+ def size_bytes(self) -> Optional[int]:
122
124
  """
123
125
  Best-effort size inference without consuming the stream.
124
126
  Returns None if size can't be determined cheaply.
@@ -152,20 +154,20 @@ class Media:
152
154
  class Image(Media):
153
155
  """Media restricted to image/* mimetypes."""
154
156
 
155
- ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ("image/",)
157
+ ACCEPT_PREFIXES: ClassVar[Tuple[str, ...]] = ("image/",)
156
158
 
157
159
 
158
160
  @dataclass(frozen=True)
159
161
  class Video(Media):
160
162
  """Media restricted to video/* mimetypes."""
161
163
 
162
- ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ("video/",)
164
+ ACCEPT_PREFIXES: ClassVar[Tuple[str, ...]] = ("video/",)
163
165
 
164
166
 
165
167
  # ---------- Helpers ----------
166
168
 
167
169
 
168
- def _tell_safe(stream: BinaryIO) -> int | None:
170
+ def _tell_safe(stream: BinaryIO) -> Optional[int]:
169
171
  try:
170
172
  if hasattr(stream, "tell"):
171
173
  return stream.tell()
@@ -174,7 +176,7 @@ def _tell_safe(stream: BinaryIO) -> int | None:
174
176
  return None
175
177
 
176
178
 
177
- def _seek_safe(stream: BinaryIO, pos: int | None) -> None:
179
+ def _seek_safe(stream: BinaryIO, pos: Optional[int]) -> None:
178
180
  if pos is None:
179
181
  return
180
182
  try:
@@ -1,7 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: dragoneye-python
3
- Version: 0.3.0
4
- License: MIT
5
- Requires-Python: >=3.8
6
- Requires-Dist: requests
7
- Requires-Dist: pydantic>=2
@@ -1,7 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: dragoneye-python
3
- Version: 0.3.0
4
- License: MIT
5
- Requires-Python: >=3.8
6
- Requires-Dist: requests
7
- Requires-Dist: pydantic>=2
@@ -1,2 +0,0 @@
1
- requests
2
- pydantic>=2
@@ -1,2 +0,0 @@
1
- requests
2
- pydantic>=2
@@ -1,18 +0,0 @@
1
- import os
2
- from typing import Optional
3
-
4
- from dragoneye.classification import Classification
5
-
6
-
7
- class Dragoneye:
8
- def __init__(self, api_key: Optional[str] = None):
9
- if api_key is None:
10
- api_key = os.getenv("DRAGONEYE_API_KEY")
11
-
12
- assert (
13
- api_key is not None
14
- ), "API key is required - set the DRAGONEYE_API_KEY environment variable or pass it to the [Dragoneye] constructor"
15
-
16
- self.api_key = api_key
17
-
18
- self.classification = Classification(self)