dragoneye-python 0.1.0__tar.gz → 0.3.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 (23) hide show
  1. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/PKG-INFO +3 -3
  2. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/dragoneye_python.egg-info/PKG-INFO +3 -3
  3. dragoneye_python-0.3.0/dragoneye_python.egg-info/SOURCES.txt +17 -0
  4. dragoneye_python-0.3.0/dragoneye_python.egg-info/top_level.txt +1 -0
  5. dragoneye_python-0.3.0/pyproject.toml +12 -0
  6. dragoneye_python-0.3.0/src/dragoneye/__init__.py +25 -0
  7. dragoneye_python-0.3.0/src/dragoneye/classification.py +323 -0
  8. dragoneye_python-0.3.0/src/dragoneye/client.py +18 -0
  9. dragoneye_python-0.3.0/src/dragoneye/constants.py +2 -0
  10. dragoneye_python-0.3.0/src/dragoneye/models.py +50 -0
  11. dragoneye_python-0.3.0/src/dragoneye/types/__init__.py +0 -0
  12. dragoneye_python-0.3.0/src/dragoneye/types/common.py +31 -0
  13. dragoneye_python-0.3.0/src/dragoneye/types/exception.py +18 -0
  14. dragoneye_python-0.3.0/src/dragoneye/types/media.py +184 -0
  15. dragoneye_python-0.1.0/LICENSE +0 -21
  16. dragoneye_python-0.1.0/dragoneye_python.egg-info/SOURCES.txt +0 -9
  17. dragoneye_python-0.1.0/dragoneye_python.egg-info/top_level.txt +0 -1
  18. dragoneye_python-0.1.0/pyproject.toml +0 -8
  19. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/README.md +0 -0
  20. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/dragoneye_python.egg-info/dependency_links.txt +0 -0
  21. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/dragoneye_python.egg-info/requires.txt +0 -0
  22. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/requirements.txt +0 -0
  23. {dragoneye_python-0.1.0 → dragoneye_python-0.3.0}/setup.cfg +0 -0
@@ -1,7 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: dragoneye-python
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
+ License: MIT
4
5
  Requires-Python: >=3.8
5
- License-File: LICENSE
6
6
  Requires-Dist: requests
7
7
  Requires-Dist: pydantic>=2
@@ -1,7 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: dragoneye-python
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
+ License: MIT
4
5
  Requires-Python: >=3.8
5
- License-File: LICENSE
6
6
  Requires-Dist: requests
7
7
  Requires-Dist: pydantic>=2
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ requirements.txt
4
+ dragoneye_python.egg-info/PKG-INFO
5
+ dragoneye_python.egg-info/SOURCES.txt
6
+ dragoneye_python.egg-info/dependency_links.txt
7
+ dragoneye_python.egg-info/requires.txt
8
+ dragoneye_python.egg-info/top_level.txt
9
+ src/dragoneye/__init__.py
10
+ src/dragoneye/classification.py
11
+ src/dragoneye/client.py
12
+ src/dragoneye/constants.py
13
+ src/dragoneye/models.py
14
+ src/dragoneye/types/__init__.py
15
+ src/dragoneye/types/common.py
16
+ src/dragoneye/types/exception.py
17
+ src/dragoneye/types/media.py
@@ -0,0 +1,12 @@
1
+ [project]
2
+ name = "dragoneye-python"
3
+ version = "0.3.0"
4
+ requires-python = ">=3.8"
5
+ dynamic = ["dependencies"]
6
+ license = {text = "MIT"}
7
+
8
+ [tool.setuptools.dynamic]
9
+ dependencies = {file = ["requirements.txt"]}
10
+
11
+ [tool.setuptools.package-dir]
12
+ dragoneye = "src/dragoneye"
@@ -0,0 +1,25 @@
1
+ from .classification import (
2
+ Classification,
3
+ )
4
+ from .client import Dragoneye
5
+ from .models import (
6
+ ClassificationObjectPrediction,
7
+ ClassificationPredictImageResponse,
8
+ ClassificationTraitRootPrediction,
9
+ )
10
+ from .types.common import NormalizedBbox, TaxonID, TaxonPrediction, TaxonType
11
+ from .types.media import Image, Video
12
+
13
+ __all__ = [
14
+ "Classification",
15
+ "ClassificationObjectPrediction",
16
+ "ClassificationPredictImageResponse",
17
+ "ClassificationTraitRootPrediction",
18
+ "Dragoneye",
19
+ "Image",
20
+ "Video",
21
+ "NormalizedBbox",
22
+ "TaxonID",
23
+ "TaxonPrediction",
24
+ "TaxonType",
25
+ ]
@@ -0,0 +1,323 @@
1
+ import asyncio
2
+ import time
3
+ from typing import TYPE_CHECKING, Any, Literal, overload
4
+
5
+ import aiohttp
6
+ from aiohttp import ClientError
7
+ from pydantic import BaseModel
8
+
9
+ from .constants import FAILED_STATUS_PREFIX, PREDICTED_STATUS
10
+ from .models import (
11
+ ClassificationPredictImageResponse,
12
+ ClassificationPredictVideoResponse,
13
+ PredictionTaskStatusResponse,
14
+ )
15
+ from .types.common import (
16
+ BASE_API_URL,
17
+ PredictionTaskState,
18
+ PredictionTaskUUID,
19
+ PredictionType,
20
+ )
21
+ from .types.exception import (
22
+ PredictionTaskBeginError,
23
+ PredictionTaskError,
24
+ PredictionTaskResultsUnavailableError,
25
+ PredictionTimeoutException,
26
+ PredictionUploadError,
27
+ )
28
+ from .types.media import Image, Media, Video
29
+
30
+ if TYPE_CHECKING:
31
+ from .client import Dragoneye
32
+
33
+
34
+ class _PresignedPostRequest(BaseModel):
35
+ url: str
36
+ fields: dict[str, Any]
37
+
38
+
39
+ class _MediaUploadUrl(BaseModel):
40
+ blob_path: str
41
+ presigned_post_request: _PresignedPostRequest
42
+
43
+
44
+ class _PredictionTaskBeginResponse(BaseModel):
45
+ prediction_task_uuid: PredictionTaskUUID
46
+ prediction_type: PredictionType
47
+ signed_urls: list[_MediaUploadUrl]
48
+
49
+
50
+ def _is_task_successful(status: PredictionTaskState) -> bool:
51
+ return status == PREDICTED_STATUS
52
+
53
+
54
+ def _is_task_failed(status: PredictionTaskState) -> bool:
55
+ return status.startswith(FAILED_STATUS_PREFIX)
56
+
57
+
58
+ def _is_task_complete(status: PredictionTaskState) -> bool:
59
+ """
60
+ Returns True if the prediction task is complete, either successfully or unsuccessfully.
61
+
62
+ Avoid enum to allow additional states to be backwards compatible.
63
+ """
64
+ return _is_task_successful(status) or _is_task_failed(status)
65
+
66
+
67
+ class Classification:
68
+ def __init__(self, client: "Dragoneye"):
69
+ self._client = client
70
+
71
+ async def predict_image(
72
+ self,
73
+ media: Image,
74
+ model_name: str,
75
+ timeout_seconds: int | None = None,
76
+ ) -> ClassificationPredictImageResponse:
77
+ return await self._predict_unified(
78
+ media=media,
79
+ model_name=model_name,
80
+ frames_per_second=None,
81
+ timeout_seconds=timeout_seconds,
82
+ )
83
+
84
+ async def predict_video(
85
+ self,
86
+ media: Video,
87
+ model_name: str,
88
+ frames_per_second: int = 1,
89
+ timeout_seconds: int | None = None,
90
+ ) -> ClassificationPredictVideoResponse:
91
+ return await self._predict_unified(
92
+ media=media,
93
+ model_name=model_name,
94
+ frames_per_second=frames_per_second,
95
+ timeout_seconds=timeout_seconds,
96
+ )
97
+
98
+ async def status(
99
+ self, prediction_task_uuid: PredictionTaskUUID
100
+ ) -> PredictionTaskStatusResponse:
101
+ """
102
+ Given a prediction task UUID, return
103
+ """
104
+ url = f"{BASE_API_URL}/prediction-task/status?predictionTaskUuid={prediction_task_uuid}"
105
+ headers = {"Authorization": f"Bearer {self._client.api_key}"}
106
+
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()
111
+
112
+ return PredictionTaskStatusResponse.model_validate(payload)
113
+
114
+ async def get_image_results(
115
+ self,
116
+ prediction_task_uuid: PredictionTaskUUID,
117
+ ) -> ClassificationPredictImageResponse:
118
+ return await self._get_results_unified(
119
+ prediction_task_uuid=prediction_task_uuid,
120
+ prediction_type="image",
121
+ )
122
+
123
+ async def get_video_results(
124
+ self,
125
+ prediction_task_uuid: PredictionTaskUUID,
126
+ ) -> ClassificationPredictVideoResponse:
127
+ return await self._get_results_unified(
128
+ prediction_task_uuid=prediction_task_uuid,
129
+ prediction_type="video",
130
+ )
131
+
132
+ @overload
133
+ async def _get_results_unified(
134
+ self,
135
+ prediction_task_uuid: PredictionTaskUUID,
136
+ prediction_type: Literal["image"],
137
+ ) -> ClassificationPredictImageResponse: ...
138
+
139
+ @overload
140
+ async def _get_results_unified(
141
+ self,
142
+ prediction_task_uuid: PredictionTaskUUID,
143
+ prediction_type: Literal["video"],
144
+ ) -> ClassificationPredictVideoResponse: ...
145
+
146
+ @overload
147
+ async def _get_results_unified(
148
+ self,
149
+ prediction_task_uuid: PredictionTaskUUID,
150
+ prediction_type: PredictionType,
151
+ ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse: ...
152
+
153
+ async def _get_results_unified(
154
+ self, prediction_task_uuid: PredictionTaskUUID, prediction_type: PredictionType
155
+ ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse:
156
+ url = f"{BASE_API_URL}/prediction-task/results?predictionTaskUuid={prediction_task_uuid}"
157
+ headers = {"Authorization": f"Bearer {self._client.api_key}"}
158
+
159
+ try:
160
+ async with aiohttp.ClientSession() as session:
161
+ async with session.get(url, headers=headers) as resp:
162
+ resp.raise_for_status()
163
+ payload = await resp.json()
164
+ except ClientError as error:
165
+ raise PredictionTaskResultsUnavailableError(
166
+ f"Error getting prediction task results: {error}"
167
+ )
168
+
169
+ # Add the prediction task uuid to the response before returning
170
+ payload["prediction_task_uuid"] = prediction_task_uuid
171
+
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}")
179
+
180
+ ##### Internal API methods #####
181
+ @overload
182
+ async def _predict_unified(
183
+ self,
184
+ media: Image,
185
+ model_name: str,
186
+ frames_per_second: int | None,
187
+ timeout_seconds: int | None = None,
188
+ ) -> ClassificationPredictImageResponse: ...
189
+
190
+ @overload
191
+ async def _predict_unified(
192
+ self,
193
+ media: Video,
194
+ model_name: str,
195
+ frames_per_second: int | None,
196
+ timeout_seconds: int | None = None,
197
+ ) -> ClassificationPredictVideoResponse: ...
198
+
199
+ async def _predict_unified(
200
+ self,
201
+ media: Image | Video,
202
+ model_name: str,
203
+ frames_per_second: int | None,
204
+ timeout_seconds: int | None = None,
205
+ ) -> ClassificationPredictImageResponse | ClassificationPredictVideoResponse:
206
+ prediction_task_begin_response = await self._begin_prediction_task(
207
+ mime_type=media.mime_type,
208
+ frames_per_second=frames_per_second,
209
+ )
210
+
211
+ await self._upload_media_to_prediction_task(
212
+ media, prediction_task_begin_response.signed_urls[0]
213
+ )
214
+
215
+ predict_url = f"{BASE_API_URL}/predict"
216
+ predict_data = {
217
+ "model_name": model_name,
218
+ "prediction_task_uuid": prediction_task_begin_response.prediction_task_uuid,
219
+ }
220
+ predict_headers = {
221
+ "Authorization": f"Bearer {self._client.api_key}",
222
+ }
223
+ try:
224
+ async with aiohttp.ClientSession() as session:
225
+ async with session.post(
226
+ predict_url, data=predict_data, headers=predict_headers
227
+ ) as resp:
228
+ resp.raise_for_status()
229
+ except ClientError as error:
230
+ raise PredictionTaskError("Error initiating prediction:", error)
231
+
232
+ status = await self._wait_for_prediction_task_completion(
233
+ prediction_task_uuid=prediction_task_begin_response.prediction_task_uuid,
234
+ timeout_seconds=timeout_seconds,
235
+ )
236
+
237
+ if _is_task_failed(status.status):
238
+ raise PredictionTaskError(f"Prediction task failed: {status.status}")
239
+
240
+ return await self._get_results_unified(
241
+ prediction_task_uuid=status.prediction_task_uuid,
242
+ prediction_type=prediction_task_begin_response.prediction_type,
243
+ )
244
+
245
+ async def _wait_for_prediction_task_completion(
246
+ self,
247
+ prediction_task_uuid: PredictionTaskUUID,
248
+ polling_interval: float = 1.0,
249
+ timeout_seconds: int | None = None,
250
+ ) -> PredictionTaskStatusResponse:
251
+ start_time = time.monotonic()
252
+ while True:
253
+ # Check if we've exceeded the timeout
254
+ if timeout_seconds is not None:
255
+ elapsed = time.monotonic() - start_time
256
+ if elapsed >= timeout_seconds:
257
+ raise PredictionTimeoutException(
258
+ f"Prediction task {prediction_task_uuid} did not complete within {timeout_seconds} seconds."
259
+ )
260
+
261
+ status = await self.status(prediction_task_uuid)
262
+ if _is_task_complete(status.status):
263
+ return status
264
+
265
+ await asyncio.sleep(polling_interval)
266
+
267
+ async def _upload_media_to_prediction_task(
268
+ self, media: Media, signed_url: _MediaUploadUrl
269
+ ) -> None:
270
+ # Build multipart form: include all presigned fields + the file
271
+ form = aiohttp.FormData()
272
+ for k, v in signed_url.presigned_post_request.fields.items():
273
+ form.add_field(k, str(v))
274
+
275
+ file_obj = media.bytes_io()
276
+ try:
277
+ file_obj.seek(0)
278
+ except Exception:
279
+ pass # if it's already at start or non-seekable
280
+
281
+ form.add_field(
282
+ "file",
283
+ file_obj,
284
+ filename="file",
285
+ )
286
+
287
+ try:
288
+ async with aiohttp.ClientSession() as session:
289
+ async with session.post(
290
+ signed_url.presigned_post_request.url,
291
+ data=form,
292
+ ) as resp:
293
+ resp.raise_for_status()
294
+ except ClientError as error:
295
+ raise PredictionUploadError(
296
+ "Error uploading media to prediction task:", error
297
+ )
298
+
299
+ async def _begin_prediction_task(
300
+ self,
301
+ mime_type: str,
302
+ frames_per_second: int | None,
303
+ ) -> _PredictionTaskBeginResponse:
304
+ url = f"{BASE_API_URL}/prediction-task/begin"
305
+
306
+ form_data = aiohttp.FormData()
307
+ form_data.add_field("mimetype", mime_type)
308
+ if frames_per_second is not None:
309
+ form_data.add_field("frames_per_second", str(frames_per_second))
310
+
311
+ headers = {
312
+ "Authorization": f"Bearer {self._client.api_key}",
313
+ }
314
+
315
+ try:
316
+ async with aiohttp.ClientSession() as session:
317
+ async with session.post(url, data=form_data, headers=headers) as resp:
318
+ resp.raise_for_status()
319
+ payload = await resp.json()
320
+ except ClientError as error:
321
+ raise PredictionTaskBeginError("Error beginning prediction task:", error)
322
+
323
+ return _PredictionTaskBeginResponse.model_validate(payload)
@@ -0,0 +1,18 @@
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)
@@ -0,0 +1,2 @@
1
+ PREDICTED_STATUS = "predicted"
2
+ FAILED_STATUS_PREFIX = "failed"
@@ -0,0 +1,50 @@
1
+ from typing import Sequence
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from dragoneye.types.common import (
6
+ NormalizedBbox,
7
+ PredictionTaskState,
8
+ PredictionTaskUUID,
9
+ PredictionType,
10
+ TaxonID,
11
+ TaxonPrediction,
12
+ )
13
+
14
+
15
+ class PredictionTaskStatusResponse(BaseModel):
16
+ prediction_task_uuid: PredictionTaskUUID
17
+ prediction_type: PredictionType
18
+ status: PredictionTaskState
19
+
20
+
21
+ class ClassificationTraitRootPrediction(BaseModel):
22
+ id: TaxonID
23
+ name: str
24
+ displayName: str
25
+ taxons: Sequence[TaxonPrediction]
26
+
27
+
28
+ class ClassificationObjectPrediction(BaseModel):
29
+ normalizedBbox: NormalizedBbox
30
+ category: TaxonPrediction
31
+ traits: Sequence[ClassificationTraitRootPrediction]
32
+
33
+
34
+ class ClassificationPredictImageResponse(BaseModel):
35
+ predictions: Sequence[ClassificationObjectPrediction]
36
+ prediction_task_uuid: PredictionTaskUUID
37
+
38
+
39
+ class ClassificationVideoObjectPrediction(ClassificationObjectPrediction):
40
+ frame_id: str
41
+ frame_index: int
42
+ timestamp_microseconds: int
43
+
44
+
45
+ class ClassificationPredictVideoResponse(BaseModel):
46
+ timestamp_us_to_predictions: dict[
47
+ int, Sequence[ClassificationVideoObjectPrediction]
48
+ ]
49
+ frames_per_second: int
50
+ prediction_task_uuid: PredictionTaskUUID
File without changes
@@ -0,0 +1,31 @@
1
+ from enum import Enum
2
+ from typing import Literal, NewType, Optional, Sequence, Tuple
3
+
4
+ from pydantic import BaseModel
5
+
6
+ PredictionType = Literal["image", "video"]
7
+ PredictionTaskState = NewType("PredictionTaskState", str)
8
+
9
+ NormalizedBbox = NewType("NormalizedBbox", Tuple[float, float, float, float])
10
+
11
+
12
+ class TaxonType(str, Enum):
13
+ CATEGORY = ("category",)
14
+ TRAIT = ("trait",)
15
+
16
+
17
+ TaxonID = NewType("TaxonID", int)
18
+
19
+ PredictionTaskUUID = NewType("PredictionTaskUUID", str)
20
+
21
+
22
+ class TaxonPrediction(BaseModel):
23
+ id: TaxonID
24
+ type: TaxonType
25
+ name: str
26
+ displayName: str
27
+ score: Optional[float]
28
+ children: Sequence["TaxonPrediction"]
29
+
30
+
31
+ BASE_API_URL = "https://api.dragoneye.ai"
@@ -0,0 +1,18 @@
1
+ class PredictionTaskError(Exception):
2
+ pass
3
+
4
+
5
+ class PredictionUploadError(Exception):
6
+ pass
7
+
8
+
9
+ class PredictionTaskBeginError(Exception):
10
+ pass
11
+
12
+
13
+ class PredictionTaskResultsUnavailableError(Exception):
14
+ pass
15
+
16
+
17
+ class PredictionTimeoutException(Exception):
18
+ pass
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import os
5
+ from dataclasses import dataclass
6
+ from io import BufferedReader, BytesIO
7
+ from pathlib import Path
8
+ from typing import BinaryIO, ClassVar, Self, Union
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Media:
13
+ """Generic binary media + mime_type with conservative, non-destructive access."""
14
+
15
+ file_or_bytes: Union[bytes, BytesIO, BinaryIO, BufferedReader]
16
+ mime_type: str
17
+
18
+ # Subclasses set this to enforce a family of mimetypes, e.g. ("image/",)
19
+ ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ()
20
+
21
+ def __post_init__(self) -> None:
22
+ # Enforce subtype-specific mimetype families when specified.
23
+ if self.ACCEPT_PREFIXES and not any(
24
+ self.mime_type.startswith(p) for p in self.ACCEPT_PREFIXES
25
+ ):
26
+ raise ValueError(
27
+ f"{self.__class__.__name__} requires mime_type starting with "
28
+ f"{' or '.join(self.ACCEPT_PREFIXES)}; got {self.mime_type!r}"
29
+ )
30
+
31
+ # ---------- Convenience constructors ----------
32
+
33
+ @classmethod
34
+ def from_bytes(cls, data: bytes, mime_type: str) -> Self:
35
+ return cls(file_or_bytes=data, mime_type=mime_type)
36
+
37
+ @classmethod
38
+ def from_stream(cls, stream: BinaryIO, *, mime_type: str) -> Self:
39
+ """
40
+ Accepts any readable binary stream (e.g., open('file', 'rb')).
41
+ Keeps the stream as-is; reading is deferred to bytes_io().
42
+ """
43
+ return cls(file_or_bytes=stream, mime_type=mime_type)
44
+
45
+ @classmethod
46
+ def from_path(
47
+ cls,
48
+ path: Union[str, os.PathLike[str]],
49
+ *,
50
+ mime_type: str | None = None,
51
+ guess_from_extension: bool = True,
52
+ read_into_memory: bool = False,
53
+ ) -> Self:
54
+ """
55
+ Create a Media (or subclass) from a filesystem path.
56
+
57
+ - `path`: Path to the file on disk.
58
+ - `mime_type`: Explicit mime type. If omitted and `guess_from_extension=True`,
59
+ we'll try to guess from the file extension.
60
+ - `read_into_memory=True`: load file bytes into memory (closes file immediately).
61
+ Otherwise, keep an open file stream.
62
+ """
63
+ path = Path(path)
64
+ if not path.exists():
65
+ raise FileNotFoundError(path)
66
+
67
+ mt = mime_type or (
68
+ mimetypes.guess_type(path.name)[0] if guess_from_extension else None
69
+ )
70
+ if mt is None:
71
+ raise ValueError(
72
+ f"mime_type is required for {path} (no extension-based guess available)."
73
+ )
74
+
75
+ if read_into_memory:
76
+ data = path.read_bytes()
77
+ return cls(file_or_bytes=data, mime_type=mt)
78
+ else:
79
+ f = path.open("rb")
80
+ return cls(file_or_bytes=f, mime_type=mt)
81
+
82
+ # ---------- Utilities ----------
83
+
84
+ def bytes_io(self) -> BytesIO:
85
+ """
86
+ Returns a fresh BytesIO with the full content.
87
+ - If we wrap a BytesIO, we non-destructively rewind and copy.
88
+ - If we wrap a stream, we read it (without assuming seekability).
89
+ - If we hold bytes, we just wrap them.
90
+ """
91
+ src = self.file_or_bytes
92
+
93
+ if isinstance(src, bytes):
94
+ return BytesIO(src)
95
+
96
+ if isinstance(src, BytesIO):
97
+ # Non-destructively copy contents
98
+ pos = src.tell()
99
+ try:
100
+ src.seek(0)
101
+ except Exception:
102
+ pass
103
+ data = src.read()
104
+ try:
105
+ src.seek(pos)
106
+ except Exception:
107
+ pass
108
+ return BytesIO(data)
109
+
110
+ # For any readable object with .read()
111
+ if hasattr(src, "read"):
112
+ pos = _tell_safe(src)
113
+ data = src.read()
114
+ _seek_safe(src, pos)
115
+ return BytesIO(data)
116
+
117
+ raise TypeError(
118
+ "Invalid media source: expected bytes, BytesIO, or a readable binary stream."
119
+ )
120
+
121
+ def size_bytes(self) -> int | None:
122
+ """
123
+ Best-effort size inference without consuming the stream.
124
+ Returns None if size can't be determined cheaply.
125
+ """
126
+ src = self.file_or_bytes
127
+ if isinstance(src, bytes):
128
+ return len(src)
129
+ if isinstance(src, BytesIO):
130
+ pos = src.tell()
131
+ try:
132
+ src.seek(0, os.SEEK_END)
133
+ end = src.tell()
134
+ finally:
135
+ _seek_safe(src, pos)
136
+ return end
137
+ if hasattr(src, "fileno"):
138
+ try:
139
+ return os.fstat(src.fileno()).st_size # type: ignore[arg-type]
140
+ except Exception:
141
+ return None
142
+ # Path-based size if it looks like a buffered reader with .name
143
+ if hasattr(src, "name"):
144
+ try:
145
+ return Path(src.name).stat().st_size # type: ignore[arg-type]
146
+ except Exception:
147
+ return None
148
+ return None
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Image(Media):
153
+ """Media restricted to image/* mimetypes."""
154
+
155
+ ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ("image/",)
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Video(Media):
160
+ """Media restricted to video/* mimetypes."""
161
+
162
+ ACCEPT_PREFIXES: ClassVar[tuple[str, ...]] = ("video/",)
163
+
164
+
165
+ # ---------- Helpers ----------
166
+
167
+
168
+ def _tell_safe(stream: BinaryIO) -> int | None:
169
+ try:
170
+ if hasattr(stream, "tell"):
171
+ return stream.tell()
172
+ except Exception:
173
+ pass
174
+ return None
175
+
176
+
177
+ def _seek_safe(stream: BinaryIO, pos: int | None) -> None:
178
+ if pos is None:
179
+ return
180
+ try:
181
+ if hasattr(stream, "seek"):
182
+ stream.seek(pos)
183
+ except Exception:
184
+ pass
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 dragoneyeAI
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,9 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- requirements.txt
5
- dragoneye_python.egg-info/PKG-INFO
6
- dragoneye_python.egg-info/SOURCES.txt
7
- dragoneye_python.egg-info/dependency_links.txt
8
- dragoneye_python.egg-info/requires.txt
9
- dragoneye_python.egg-info/top_level.txt
@@ -1,8 +0,0 @@
1
- [project]
2
- name = "dragoneye-python"
3
- version = "0.1.0"
4
- requires-python = ">=3.8"
5
- dynamic = ["dependencies"]
6
-
7
- [tool.setuptools.dynamic]
8
- dependencies = {file = ["requirements.txt"]}