truthscan-image-detector-client 0.0.1__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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: truthscan-image-detector-client
3
+ Version: 0.0.1
4
+ Summary: Python client for AI Image Detection API
5
+ Author: undetectableai
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: requests>=2.31.0
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "truthscan-image-detector-client"
7
+ version = "0.0.1"
8
+ description = "Python client for AI Image Detection API"
9
+ requires-python = ">=3.9"
10
+ authors = [{name = "undetectableai"}]
11
+ dependencies = ["requests>=2.31.0"]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
15
+
16
+ [tool.setuptools.package-data]
17
+ ai_image_detection = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ from .client import ImageDetectionClient
2
+ from .service import ImageDetectionService
3
+ from .logger import Logger, DefaultConsoleLogger
4
+ from .errors import (
5
+ ApiClientError,
6
+ PresignError,
7
+ UploadError,
8
+ DetectError,
9
+ QueryError,
10
+ CreditCheckError,
11
+ )
12
+ from .api_types import (
13
+ PresignResponse,
14
+ DetectResponse,
15
+ ResultDetails,
16
+ QueryResponse,
17
+ CreditCheckResponse,
18
+ Image,
19
+ DetectionResult,
20
+ )
21
+
22
+ __all__ = [
23
+ "ImageDetectionClient",
24
+ "ImageDetectionService",
25
+ "Logger",
26
+ "DefaultConsoleLogger",
27
+ "ApiClientError",
28
+ "PresignError",
29
+ "UploadError",
30
+ "DetectError",
31
+ "QueryError",
32
+ "CreditCheckError",
33
+ "PresignResponse",
34
+ "DetectResponse",
35
+ "ResultDetails",
36
+ "QueryResponse",
37
+ "CreditCheckResponse",
38
+ "Image",
39
+ "DetectionResult",
40
+ ]
@@ -0,0 +1,52 @@
1
+ from typing import TypedDict, Optional, Dict, Any, List, Tuple
2
+
3
+
4
+ class PresignResponse(TypedDict):
5
+ """Response from presign endpoint"""
6
+ status: str
7
+ presigned_url: str
8
+ file_path: str
9
+ document_id: Optional[str]
10
+
11
+
12
+ class DetectResponse(TypedDict):
13
+ """Response from detect endpoint"""
14
+ id: str
15
+ status: str
16
+
17
+
18
+ class ResultDetails(TypedDict, total=False):
19
+ """Detailed detection results"""
20
+ is_valid: Optional[bool]
21
+ detection_step: Optional[int]
22
+ final_result: Optional[str]
23
+ metadata: Optional[List[Tuple[str, str]]]
24
+ metadata_basic_source: Optional[str]
25
+ ocr: Optional[List[Tuple[str, int]]]
26
+ ml_model: Optional[List[Tuple[str, int]]]
27
+ confidence: Optional[int]
28
+ analysis_results: Optional[Dict[str, Any]]
29
+ analysis_results_status: Optional[str]
30
+ heatmap_url: Optional[str]
31
+ heatmap_status: Optional[str]
32
+
33
+
34
+ class QueryResponse(TypedDict, total=False):
35
+ """Response from query endpoint"""
36
+ id: str
37
+ status: str
38
+ result: Optional[int]
39
+ result_details: Optional[ResultDetails]
40
+ preview_url: Optional[str]
41
+
42
+
43
+ class CreditCheckResponse(TypedDict):
44
+ """Response from credit check endpoint"""
45
+ baseCredits: int
46
+ boostCredits: int
47
+ credits: int
48
+
49
+
50
+ # Type aliases
51
+ Image = str
52
+ DetectionResult = QueryResponse
@@ -0,0 +1,128 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from .service import ImageDetectionService
5
+ from .logger import Logger, DefaultConsoleLogger
6
+ from .errors import QueryError, CreditCheckError
7
+ from .api_types import Image, DetectionResult, CreditCheckResponse
8
+
9
+
10
+ class ImageDetectionClient:
11
+ def __init__(
12
+ self,
13
+ api_key: str,
14
+ base_url: Optional[str] = None,
15
+ timeout: Optional[int] = None,
16
+ logger: Optional[Logger] = None
17
+ ) -> None:
18
+ """
19
+ Initialize the Image Detection Client.
20
+
21
+ Args:
22
+ api_key: API key for authentication
23
+ base_url: Base URL of the API server (optional, default: 'https://ai-image-detect.undetectable.ai')
24
+ timeout: Default timeout for requests in seconds (optional, default: 60)
25
+ logger: Logger instance (optional, default: DefaultConsoleLogger("info"))
26
+ """
27
+ self.base_url = base_url or 'https://ai-image-detect.undetectable.ai'
28
+ resolved_logger = logger if logger is not None else DefaultConsoleLogger("info")
29
+ self.service = ImageDetectionService(
30
+ self.base_url,
31
+ api_key,
32
+ timeout or 60,
33
+ resolved_logger
34
+ )
35
+
36
+ def detect(
37
+ self,
38
+ image: Image,
39
+ email: Optional[str] = None,
40
+ generate_preview: bool = True,
41
+ max_poll_attempts: int = 60,
42
+ poll_interval_seconds: float = 0.5
43
+ ) -> DetectionResult:
44
+ """
45
+ Detect AI-generated content in an image.
46
+
47
+ This method handles the entire workflow:
48
+ 1. Presign - Get presigned URL for upload
49
+ 2. Upload - Upload the image file
50
+ 3. Detect - Submit the image for detection
51
+ 4. Poll - Wait for detection results
52
+
53
+ Args:
54
+ image: File path to the image to detect
55
+ email: Optional email address for processing
56
+ generate_preview: Optional flag to generate preview (default: True)
57
+ max_poll_attempts: Maximum number of polling attempts (default: 60)
58
+ poll_interval_seconds: Seconds to sleep between polling attempts (default: 0.5)
59
+
60
+ Returns:
61
+ DetectionResult containing detection status and results
62
+
63
+ Raises:
64
+ QueryError: If polling times out or other errors occur
65
+ """
66
+ # 1. Extract filename from path
67
+ file_name = os.path.basename(image).replace(' ', '')
68
+
69
+ # 2. Presign - Get presigned URL
70
+ presign_result = self.service.get_presigned_url(file_name)
71
+
72
+ # 3. Upload - Upload the file
73
+ self.service.upload(presign_result["presigned_url"], image)
74
+
75
+ # 4. Build file URL for detect operation
76
+ # Determine CDN base URL based on the baseUrl (dev vs prod)
77
+ cdn_base_url = self._get_cdn_base_url()
78
+ file_path_remote_full = f"{cdn_base_url}{presign_result['file_path']}"
79
+ file_url = ImageDetectionService.build_detect_file_url(
80
+ file_path_remote_full,
81
+ presign_result["presigned_url"]
82
+ )
83
+
84
+ # 5. Detect - Submit for detection
85
+ detect_result = self.service.detect(
86
+ file_url,
87
+ email,
88
+ generate_preview
89
+ )
90
+
91
+ # 6. Poll for result - Wait for completion
92
+ final_result = self.service.poll_for_result(
93
+ detect_result["id"],
94
+ max_poll_attempts,
95
+ poll_interval_seconds
96
+ )
97
+
98
+ if final_result is None:
99
+ raise QueryError('Polling timeout - detection result not available')
100
+
101
+ return final_result
102
+
103
+ def check_user_credits(self) -> CreditCheckResponse:
104
+ """
105
+ Check user credits for the API key.
106
+
107
+ Returns:
108
+ CreditCheckResponse containing credit information
109
+
110
+ Raises:
111
+ CreditCheckError: If the credit check operation fails
112
+ """
113
+ try:
114
+ return self.service.check_user_credits()
115
+ except Exception as e:
116
+ raise CreditCheckError(str(e)) from e
117
+
118
+ def _get_cdn_base_url(self) -> str:
119
+ """
120
+ Get the CDN base URL based on the configured baseUrl.
121
+
122
+ Returns:
123
+ CDN base URL string
124
+ """
125
+ if 'undetectable.ai' in self.base_url:
126
+ return 'https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/'
127
+ else:
128
+ return 'https://ai-image-detector-dev.nyc3.digitaloceanspaces.com/'
@@ -0,0 +1,28 @@
1
+ class ApiClientError(Exception):
2
+ """Base exception for API client errors."""
3
+ pass
4
+
5
+
6
+ class PresignError(ApiClientError):
7
+ """Exception raised when presign operation fails."""
8
+ pass
9
+
10
+
11
+ class UploadError(ApiClientError):
12
+ """Exception raised when upload operation fails."""
13
+ pass
14
+
15
+
16
+ class DetectError(ApiClientError):
17
+ """Exception raised when detect operation fails."""
18
+ pass
19
+
20
+
21
+ class QueryError(ApiClientError):
22
+ """Exception raised when query operation fails."""
23
+ pass
24
+
25
+
26
+ class CreditCheckError(ApiClientError):
27
+ """Exception raised when credit check operation fails."""
28
+ pass
@@ -0,0 +1,79 @@
1
+ from typing import Protocol
2
+ from datetime import datetime
3
+
4
+
5
+ class Logger(Protocol):
6
+ """Logger interface for the Image Detection Client"""
7
+
8
+ def info(self, message: str) -> None:
9
+ """Log an info message"""
10
+ ...
11
+
12
+ def debug(self, message: str) -> None:
13
+ """Log a debug message"""
14
+ ...
15
+
16
+ def warn(self, message: str) -> None:
17
+ """Log a warning message"""
18
+ ...
19
+
20
+ def error(self, message: str) -> None:
21
+ """Log an error message"""
22
+ ...
23
+
24
+
25
+ class DefaultConsoleLogger:
26
+ """
27
+ Default console logger implementation
28
+ """
29
+
30
+ def __init__(self, log_level: str = "info") -> None:
31
+ """
32
+ Initialize the default console logger.
33
+
34
+ Args:
35
+ log_level: Logging level ('debug', 'info', 'warn', 'error', default: 'info')
36
+ """
37
+ self.log_level = log_level
38
+
39
+ def _timestamp(self) -> str:
40
+ """Generate ISO timestamp"""
41
+ return datetime.now().isoformat()
42
+
43
+ def _should_log(self, level: str) -> bool:
44
+ """
45
+ Check if a message at the given level should be logged.
46
+
47
+ Args:
48
+ level: Log level to check
49
+
50
+ Returns:
51
+ True if the message should be logged
52
+ """
53
+ levels = {
54
+ 'debug': 0,
55
+ 'info': 1,
56
+ 'warn': 2,
57
+ 'error': 3,
58
+ }
59
+ return levels.get(level, 1) >= levels.get(self.log_level, 1)
60
+
61
+ def info(self, message: str) -> None:
62
+ """Log an info message"""
63
+ if self._should_log('info'):
64
+ print(f"[{self._timestamp()}] INFO: {message}")
65
+
66
+ def debug(self, message: str) -> None:
67
+ """Log a debug message"""
68
+ if self._should_log('debug'):
69
+ print(f"[{self._timestamp()}] DEBUG: {message}")
70
+
71
+ def warn(self, message: str) -> None:
72
+ """Log a warning message"""
73
+ if self._should_log('warn'):
74
+ print(f"[{self._timestamp()}] WARN: {message}")
75
+
76
+ def error(self, message: str) -> None:
77
+ """Log an error message"""
78
+ if self._should_log('error'):
79
+ print(f"[{self._timestamp()}] ERROR: {message}")
@@ -0,0 +1,496 @@
1
+ import os
2
+ import time
3
+ import requests
4
+ import mimetypes
5
+ from urllib.parse import urlparse
6
+ from typing import Dict, Optional, Any, Callable
7
+
8
+ from .logger import Logger, DefaultConsoleLogger
9
+ from .errors import PresignError, UploadError, DetectError, QueryError, CreditCheckError
10
+ from .api_types import PresignResponse, DetectResponse, QueryResponse, CreditCheckResponse
11
+
12
+ class ImageDetectionService:
13
+ def __init__(
14
+ self,
15
+ base_url: str,
16
+ api_key: str,
17
+ timeout: int = 60,
18
+ logger: Optional[Logger] = None
19
+ ) -> None:
20
+ """
21
+ Initialize the API client interface.
22
+
23
+ Args:
24
+ base_url: Base URL of the API server
25
+ api_key: API key for authentication
26
+ timeout: Default timeout for requests in seconds (default: 60)
27
+ logger: Logger instance (optional, default: DefaultConsoleLogger("info"))
28
+ """
29
+ self.base_url = base_url.rstrip("/")
30
+ self.api_key = api_key
31
+ self.timeout = timeout # Timeout in seconds for requests library
32
+ self.logger = logger if logger is not None else DefaultConsoleLogger("info")
33
+
34
+ @staticmethod
35
+ def guess_mime_type(file_name: str) -> str:
36
+ """
37
+ Guess MIME type from file name.
38
+
39
+ Args:
40
+ file_name: Name of the file
41
+
42
+ Returns:
43
+ MIME type string
44
+ """
45
+ mime, _ = mimetypes.guess_type(file_name)
46
+ if mime:
47
+ return mime
48
+
49
+ ext = os.path.splitext(file_name)[1].lower().lstrip('.')
50
+ mime_map = {
51
+ 'jpg': 'image/jpeg',
52
+ 'jpeg': 'image/jpeg',
53
+ 'jfif': 'image/jpeg',
54
+ 'png': 'image/png',
55
+ 'webp': 'image/webp',
56
+ 'heic': 'image/heic',
57
+ 'heif': 'image/heif',
58
+ 'avif': 'image/avif',
59
+ 'bmp': 'image/bmp',
60
+ 'tiff': 'image/tiff',
61
+ 'tif': 'image/tiff',
62
+ 'svg': 'image/svg+xml',
63
+ 'gif': 'image/gif',
64
+ 'pdf': 'application/pdf',
65
+ }
66
+ return mime_map.get(ext, "application/octet-stream")
67
+
68
+ def get_presigned_url(self, file_name: str) -> PresignResponse:
69
+ """
70
+ Get a presigned URL for file upload.
71
+
72
+ Args:
73
+ file_name: Name of the file to upload
74
+
75
+ Returns:
76
+ PresignResponse containing presigned_url and file_path
77
+
78
+ Raises:
79
+ PresignError: If the presign operation fails
80
+ """
81
+ self.logger.info(f"Requesting presigned URL for file: {file_name}")
82
+
83
+ try:
84
+ url = f"{self.base_url}/get-presigned-url"
85
+ response = requests.get(
86
+ url,
87
+ headers={'apikey': self.api_key},
88
+ params={'file_name': file_name},
89
+ timeout=self.timeout,
90
+ )
91
+
92
+ self.logger.debug(f"Presign response status: {response.status_code}")
93
+
94
+ if not response.ok:
95
+ error_msg = f"Presign failed with status {response.status_code}"
96
+ try:
97
+ error_data = response.json()
98
+ error_msg = error_data.get("error", error_msg)
99
+ except Exception:
100
+ error_msg = f"{error_msg}: {response.text}"
101
+
102
+ self.logger.error(error_msg)
103
+ raise PresignError(error_msg)
104
+
105
+ result = response.json()
106
+
107
+ if "presigned_url" not in result or "file_path" not in result:
108
+ error_msg = "Invalid presign response: missing required fields"
109
+ self.logger.error(error_msg)
110
+ raise PresignError(error_msg)
111
+
112
+ self.logger.info(f"Presigned URL obtained successfully for {file_name}")
113
+ return result
114
+
115
+ except PresignError:
116
+ raise
117
+ except requests.exceptions.RequestException as e:
118
+ error_msg = f"Network error during presign: {str(e)}"
119
+ self.logger.error(error_msg)
120
+ raise PresignError(error_msg) from e
121
+ except Exception as e:
122
+ error_msg = f"Unexpected error during presign: {str(e)}"
123
+ self.logger.error(error_msg)
124
+ raise PresignError(error_msg) from e
125
+
126
+ def upload(
127
+ self,
128
+ presigned_url: str,
129
+ file_path: str,
130
+ mime_type: Optional[str] = None
131
+ ) -> bool:
132
+ """
133
+ Upload a file to the presigned URL.
134
+
135
+ Args:
136
+ presigned_url: Presigned URL obtained from get_presigned_url() method
137
+ file_path: Local path to the file to upload
138
+ mime_type: MIME type of the file (auto-detected if not provided)
139
+
140
+ Returns:
141
+ True if upload was successful
142
+
143
+ Raises:
144
+ UploadError: If the upload operation fails
145
+ """
146
+ if not os.path.exists(file_path):
147
+ error_msg = f"File not found: {file_path}"
148
+ self.logger.error(error_msg)
149
+ raise UploadError(error_msg)
150
+
151
+ if mime_type is None:
152
+ mime_type = ImageDetectionService.guess_mime_type(file_path)
153
+
154
+ self.logger.info(f"Uploading file {file_path} (MIME type: {mime_type})")
155
+
156
+ try:
157
+ file_stats = os.stat(file_path)
158
+ file_size = file_stats.st_size
159
+
160
+ with open(file_path, "rb") as f:
161
+ response = requests.put(
162
+ presigned_url,
163
+ headers={
164
+ 'Content-Type': mime_type,
165
+ 'x-amz-acl': 'private',
166
+ 'Content-Length': str(file_size),
167
+ },
168
+ data=f,
169
+ timeout=300, # 5 minutes for file uploads
170
+ )
171
+
172
+ self.logger.debug(f"Upload response status: {response.status_code}")
173
+
174
+ if response.status_code not in (200, 204):
175
+ error_msg = f"Upload failed with status {response.status_code}"
176
+ self.logger.error(error_msg)
177
+ raise UploadError(error_msg)
178
+
179
+ self.logger.info(f"File uploaded successfully: {file_path}")
180
+ return True
181
+
182
+ except UploadError:
183
+ raise
184
+ except requests.exceptions.RequestException as e:
185
+ error_msg = f"Network error during upload: {str(e)}"
186
+ self.logger.error(error_msg)
187
+ raise UploadError(error_msg) from e
188
+ except Exception as e:
189
+ error_msg = f"Unexpected error during upload: {str(e)}"
190
+ self.logger.error(error_msg)
191
+ raise UploadError(error_msg) from e
192
+
193
+ def detect(
194
+ self,
195
+ file_url: str,
196
+ email: Optional[str] = None,
197
+ generate_preview: bool = False
198
+ ) -> DetectResponse:
199
+ """
200
+ Submit an image for AI detection.
201
+
202
+ Args:
203
+ file_url: URL of the uploaded file
204
+ email: Optional email address for processing
205
+ generate_preview: Optional flag to generate preview (default: False)
206
+
207
+ Returns:
208
+ DetectResponse containing detection ID and status
209
+
210
+ Raises:
211
+ DetectError: If the detect operation fails
212
+ """
213
+ self.logger.info(f"Submitting detect request for URL: {file_url}")
214
+
215
+ payload: Dict[str, Any] = {
216
+ "key": self.api_key,
217
+ "url": file_url,
218
+ "document_type": "Image",
219
+ "model": "generic",
220
+ "generate_preview": generate_preview,
221
+ }
222
+
223
+ if email:
224
+ payload["email"] = email
225
+
226
+ try:
227
+ response = requests.post(
228
+ f"{self.base_url}/detect",
229
+ headers={'Content-Type': 'application/json'},
230
+ json=payload,
231
+ timeout=120, # 2 minutes for detect requests
232
+ )
233
+
234
+ self.logger.debug(f"Detect response status: {response.status_code}")
235
+
236
+ if not response.ok:
237
+ error_msg = f"Detect failed with status {response.status_code}"
238
+ try:
239
+ error_data = response.json()
240
+ error_msg = error_data.get("error", error_msg)
241
+ except Exception:
242
+ error_msg = f"{error_msg}: {response.text}"
243
+
244
+ self.logger.error(error_msg)
245
+ raise DetectError(error_msg)
246
+
247
+ result = response.json()
248
+
249
+ if "id" not in result:
250
+ error_msg = "Invalid detect response: missing detection ID"
251
+ self.logger.error(error_msg)
252
+ raise DetectError(error_msg)
253
+
254
+ detect_id = result.get("id")
255
+ self.logger.info(f"Detect request submitted successfully. ID: {detect_id}")
256
+ return result
257
+
258
+ except DetectError:
259
+ raise
260
+ except Exception as e:
261
+ if isinstance(e, DetectError):
262
+ raise
263
+
264
+ error_msg = (
265
+ f"Network error during detect: {str(e)}"
266
+ if hasattr(e, 'message') and e.message
267
+ else f"Unexpected error during detect: {str(e)}"
268
+ )
269
+ self.logger.error(error_msg)
270
+ raise DetectError(error_msg) from e
271
+
272
+ def query(self, detect_id: str) -> QueryResponse:
273
+ """
274
+ Query the detection status and results.
275
+
276
+ Args:
277
+ detect_id: Detection ID returned from detect() method
278
+
279
+ Returns:
280
+ QueryResponse containing detection status and results
281
+
282
+ Raises:
283
+ QueryError: If the query operation fails
284
+ """
285
+ self.logger.debug(f"Querying detection status for ID: {detect_id}")
286
+
287
+ try:
288
+ response = requests.post(
289
+ f"{self.base_url}/query",
290
+ headers={'Content-Type': 'application/json'},
291
+ json={'id': detect_id},
292
+ timeout=self.timeout,
293
+ )
294
+
295
+ self.logger.debug(f"Query response status: {response.status_code}")
296
+
297
+ if not response.ok:
298
+ error_msg = f"Query failed with status {response.status_code}"
299
+ try:
300
+ error_data = response.json()
301
+ error_msg = error_data.get("error", error_msg)
302
+ except Exception:
303
+ error_msg = f"{error_msg}: {response.text}"
304
+
305
+ self.logger.error(error_msg)
306
+ raise QueryError(error_msg)
307
+
308
+ response_text = response.text
309
+ if response_text.strip() == "null" or not response_text.strip():
310
+ self.logger.warn(f"Document not found for ID: {detect_id}")
311
+ return {"id": detect_id, "status": "not_found"}
312
+
313
+ result = response.json()
314
+
315
+ if result is None:
316
+ self.logger.warn(f"Document not found for ID: {detect_id}")
317
+ return {"id": detect_id, "status": "not_found"}
318
+
319
+ status = result.get("status", "unknown")
320
+ self.logger.debug(f"Query result status: {status}")
321
+ return result
322
+
323
+ except QueryError:
324
+ raise
325
+ except requests.exceptions.RequestException as e:
326
+ error_msg = f"Network error during query: {str(e)}"
327
+ self.logger.error(error_msg)
328
+ raise QueryError(error_msg) from e
329
+ except Exception as e:
330
+ error_msg = f"Unexpected error during query: {str(e)}"
331
+ self.logger.error(error_msg)
332
+ raise QueryError(error_msg) from e
333
+
334
+ def poll_for_result(
335
+ self,
336
+ detect_id: str,
337
+ max_attempts: int = 60,
338
+ sleep_seconds: float = 0.5,
339
+ callback: Optional[Callable[[int, QueryResponse], None]] = None
340
+ ) -> Optional[QueryResponse]:
341
+ """
342
+ Poll for detection results until completion.
343
+
344
+ Continues polling until status is "done" or "failed"
345
+ or until max_attempts is reached.
346
+
347
+ Args:
348
+ detect_id: Detection ID returned from detect() method
349
+ max_attempts: Maximum number of polling attempts (default: 60)
350
+ sleep_seconds: Seconds to sleep between attempts (default: 0.5)
351
+ callback: Optional callback function called on each attempt
352
+
353
+ Returns:
354
+ Final result if status reached completion, None if timeout
355
+ """
356
+ self.logger.info(f"Starting to poll for detection ID: {detect_id}")
357
+
358
+ final_result: Optional[QueryResponse] = None
359
+
360
+ for attempt_idx in range(1, max_attempts + 1):
361
+ try:
362
+ result = self.query(detect_id)
363
+
364
+ if callback:
365
+ try:
366
+ callback(attempt_idx, result)
367
+ except Exception as e:
368
+ self.logger.warn(f"Callback error: {e}")
369
+
370
+ status = result.get("status", "unknown")
371
+
372
+ if status == "done" or status == "failed":
373
+ final_result = result
374
+ self.logger.info(
375
+ f"Polling completed. Status: {status} "
376
+ f"(attempt {attempt_idx}/{max_attempts})"
377
+ )
378
+ break
379
+
380
+ if attempt_idx < max_attempts:
381
+ time.sleep(sleep_seconds)
382
+
383
+ except QueryError as e:
384
+ self.logger.warn(f"Query error on attempt {attempt_idx}: {e}")
385
+ time.sleep(sleep_seconds)
386
+ continue
387
+ except Exception as e:
388
+ self.logger.warn(
389
+ f"Unexpected error on attempt {attempt_idx}: "
390
+ f"{str(e) if hasattr(e, '__str__') else 'Unknown error'}"
391
+ )
392
+ time.sleep(sleep_seconds)
393
+ continue
394
+
395
+ if final_result is None:
396
+ self.logger.warn(
397
+ f"Polling timeout reached for detection ID: {detect_id} "
398
+ f"(max attempts: {max_attempts})"
399
+ )
400
+
401
+ return final_result
402
+
403
+ def check_user_credits(self) -> CreditCheckResponse:
404
+ """
405
+ Check user credits for the API key.
406
+
407
+ Returns:
408
+ CreditCheckResponse containing credit information
409
+
410
+ Raises:
411
+ CreditCheckError: If the credit check operation fails
412
+ """
413
+ self.logger.info(f"Requesting credit check for API key: {self.api_key}")
414
+
415
+ try:
416
+ response = requests.get(
417
+ f"{self.base_url}/check-user-credits",
418
+ headers={'apikey': self.api_key},
419
+ timeout=self.timeout,
420
+ )
421
+
422
+ self.logger.debug(f"Credit check response status: {response.status_code}")
423
+
424
+ if not response.ok:
425
+ error_msg = f"Credit check failed with status {response.status_code}"
426
+ try:
427
+ error_data = response.json()
428
+ error_msg = error_data.get("error", error_msg)
429
+ except Exception:
430
+ error_msg = f"{error_msg}: {response.text}"
431
+
432
+ self.logger.error(error_msg)
433
+ raise CreditCheckError(error_msg)
434
+
435
+ result = response.json()
436
+
437
+ # Check if response has required fields
438
+ if not result or not isinstance(result, dict):
439
+ error_msg = (
440
+ f"Invalid credit check response: unexpected response format. "
441
+ f"Response: {result}"
442
+ )
443
+ self.logger.error(error_msg)
444
+ raise CreditCheckError(error_msg)
445
+
446
+ # Check for required fields (allow 0 values but not undefined/null)
447
+ if (
448
+ result.get("baseCredits") is None
449
+ or result.get("boostCredits") is None
450
+ or result.get("credits") is None
451
+ ):
452
+ error_msg = (
453
+ f"Invalid credit check response: missing required fields. "
454
+ f"Response: {result}"
455
+ )
456
+ self.logger.error(error_msg)
457
+ raise CreditCheckError(error_msg)
458
+
459
+ self.logger.info("Credit check obtained successfully")
460
+ return result
461
+
462
+ except CreditCheckError:
463
+ raise
464
+ except requests.exceptions.RequestException as e:
465
+ error_msg = f"Network error during credit check: {str(e)}"
466
+ self.logger.error(error_msg)
467
+ raise CreditCheckError(error_msg) from e
468
+ except Exception as e:
469
+ error_msg = f"Unexpected error during credit check: {str(e)}"
470
+ self.logger.error(error_msg)
471
+ raise CreditCheckError(error_msg) from e
472
+
473
+ @staticmethod
474
+ def build_detect_file_url(
475
+ file_path_remote: str,
476
+ presigned_url: str
477
+ ) -> str:
478
+ """
479
+ Build the full file URL for detect operation.
480
+
481
+ If file_path_remote is already a full URL, returns it as-is.
482
+ Otherwise, constructs URL from presigned URL origin.
483
+
484
+ Args:
485
+ file_path_remote: Remote file path or full URL
486
+ presigned_url: Presigned URL to extract origin from
487
+
488
+ Returns:
489
+ Full URL to the file
490
+ """
491
+ if file_path_remote.startswith("http://") or file_path_remote.startswith("https://"):
492
+ return file_path_remote
493
+
494
+ parsed = urlparse(presigned_url)
495
+ origin = f"{parsed.scheme}://{parsed.netloc}"
496
+ return f"{origin}/{file_path_remote.lstrip('/')}"
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: truthscan-image-detector-client
3
+ Version: 0.0.1
4
+ Summary: Python client for AI Image Detection API
5
+ Author: undetectableai
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: requests>=2.31.0
@@ -0,0 +1,14 @@
1
+ pyproject.toml
2
+ src/ai_image_detection/__init__.py
3
+ src/ai_image_detection/api_types.py
4
+ src/ai_image_detection/client.py
5
+ src/ai_image_detection/errors.py
6
+ src/ai_image_detection/logger.py
7
+ src/ai_image_detection/py.typed
8
+ src/ai_image_detection/service.py
9
+ src/truthscan_image_detector_client.egg-info/PKG-INFO
10
+ src/truthscan_image_detector_client.egg-info/SOURCES.txt
11
+ src/truthscan_image_detector_client.egg-info/dependency_links.txt
12
+ src/truthscan_image_detector_client.egg-info/requires.txt
13
+ src/truthscan_image_detector_client.egg-info/top_level.txt
14
+ tests/test_client.py
@@ -0,0 +1,304 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List, Dict, Optional, Any
7
+
8
+ from ai_image_detection import ImageDetectionClient
9
+
10
+ def log(message: str) -> None:
11
+ """Log a message with timestamp."""
12
+ timestamp = time.strftime('%H:%M:%S')
13
+ print(f"[{timestamp}] {message}")
14
+
15
+
16
+ def discover_image_files(
17
+ folder: str,
18
+ allowed_exts: set,
19
+ min_bytes: int,
20
+ max_bytes: int
21
+ ) -> List[str]:
22
+ """
23
+ Discover image files in the specified folder.
24
+
25
+ Args:
26
+ folder: Folder path to search
27
+ allowed_exts: Set of allowed file extensions
28
+ min_bytes: Minimum file size in bytes
29
+ max_bytes: Maximum file size in bytes
30
+
31
+ Returns:
32
+ List of image file names
33
+ """
34
+ files: List[str] = []
35
+
36
+ if not os.path.exists(folder):
37
+ return files
38
+
39
+ entries = os.listdir(folder)
40
+
41
+ for entry in entries:
42
+ entry_path = os.path.join(folder, entry)
43
+
44
+ try:
45
+ stats = os.stat(entry_path)
46
+ if os.path.isdir(entry_path):
47
+ continue
48
+ except Exception:
49
+ continue
50
+
51
+ ext = os.path.splitext(entry)[1].lower()
52
+ if ext not in allowed_exts:
53
+ continue
54
+
55
+ try:
56
+ file_size = stats.st_size
57
+ if file_size < min_bytes or file_size > max_bytes:
58
+ continue
59
+ except Exception:
60
+ continue
61
+
62
+ files.append(entry)
63
+
64
+ return files
65
+
66
+
67
+ def process_file(
68
+ client: ImageDetectionClient,
69
+ folder: str,
70
+ file_name: str
71
+ ) -> Dict[str, Any]:
72
+ """
73
+ Process a single image file through the complete workflow.
74
+
75
+ Args:
76
+ client: ImageDetectionClient instance
77
+ folder: Folder containing the image
78
+ file_name: Name of the image file
79
+
80
+ Returns:
81
+ Dictionary containing processing results and timing information
82
+ """
83
+ log(f"Processing file: {file_name}")
84
+
85
+ file_start_time = time.time()
86
+ full_path = os.path.join(folder, file_name)
87
+
88
+ result_data: Dict[str, Any] = {
89
+ "filename": file_name,
90
+ "detect_id": None,
91
+ "status": "error",
92
+ "error_message": None,
93
+ "query_result": None,
94
+ "total_time": 0,
95
+ }
96
+
97
+ try:
98
+ log(f"Starting detection for {file_name}...")
99
+ final_result = client.detect(full_path)
100
+
101
+ result_data["total_time"] = time.time() - file_start_time
102
+ result_data["detect_id"] = final_result.get("id")
103
+ result_data["query_result"] = final_result
104
+ result_data["status"] = final_result.get("status", "unknown")
105
+
106
+ status = final_result.get("status")
107
+ result_value = final_result.get("result")
108
+ log(f"Detection completed. Status: {status}, Result: {result_value}")
109
+ log(f"Total time: {result_data['total_time']:.3f}s")
110
+
111
+ return result_data
112
+ except Exception as e:
113
+ result_data["total_time"] = time.time() - file_start_time
114
+ if e is not None:
115
+ result_data["error_message"] = f"Query error: {str(e)}"
116
+ result_data["status"] = "query_error"
117
+ else:
118
+ result_data["error_message"] = f"Error: {str(e)}"
119
+ result_data["status"] = "error"
120
+ log(f"Error: {result_data['error_message']}")
121
+ return result_data
122
+
123
+
124
+ def print_summary(results: List[Dict[str, Any]]) -> None:
125
+ """Print a summary of all processing results."""
126
+ print("\n" + "=" * 70)
127
+ print(" PROCESSING SUMMARY")
128
+ print("=" * 70)
129
+
130
+ total_files = len(results)
131
+ successful_submissions = sum(1 for r in results if r.get("detect_id"))
132
+ errors = sum(1 for r in results if r.get("status") == "error")
133
+ completed = sum(1 for r in results if r.get("status") in ("done", "failed"))
134
+ pending = sum(1 for r in results if r.get("status") in ("submitted", "pending"))
135
+
136
+ print(f"\nTotal files processed: {total_files}")
137
+ print(f"Successful submissions: {successful_submissions}")
138
+ print(f"Errors: {errors}")
139
+ print(f"Completed (done/failed): {completed}")
140
+ print(f"Pending/Submitted: {pending}")
141
+
142
+ if successful_submissions > 0:
143
+ total_processing_time = sum(r.get("total_time", 0) for r in results)
144
+
145
+ print("\n" + "-" * 70)
146
+ print("Timing Statistics:")
147
+ print(f" Average total time: {(total_processing_time / successful_submissions):.3f}s")
148
+
149
+ failed_files = [r for r in results if r.get("status") == "error" or r.get("error_message")]
150
+ if failed_files:
151
+ print("\n" + "-" * 70)
152
+ print("Failed Files:")
153
+ for result in failed_files:
154
+ filename = result.get("filename", "unknown")
155
+ error = result.get("error_message", "Unknown error")
156
+ print(f" - {filename}: {error}")
157
+
158
+ print("\n" + "=" * 70)
159
+
160
+
161
+ def main() -> None:
162
+ """Main test function."""
163
+ print("\n" + "=" * 70)
164
+ print(" Image Detection Client Test Script - Batch Processing")
165
+ print("=" * 70)
166
+
167
+ # For local testing
168
+ # BASE_URL = 'http://localhost:8000'
169
+
170
+ # For development
171
+ # BASE_URL = 'https://ai-image-detector-dev-api-server-zo6e9.ondigitalocean.app'
172
+
173
+ # For production
174
+ BASE_URL = 'https://ai-image-detect.undetectable.ai'
175
+
176
+ API_KEY = "<YOUR API KEY>"
177
+
178
+ # Folder path
179
+ FOLDER = "./test_images/"
180
+
181
+ # Allow folder path as command line argument
182
+ if len(sys.argv) > 1:
183
+ folder_arg = sys.argv[1]
184
+ try:
185
+ stats = os.stat(folder_arg)
186
+ if os.path.isdir(folder_arg):
187
+ FOLDER = folder_arg
188
+ elif os.path.isfile(folder_arg):
189
+ FOLDER = os.path.dirname(folder_arg)
190
+ else:
191
+ print(f"Invalid path: {folder_arg}")
192
+ sys.exit(1)
193
+ except Exception:
194
+ print(f"Invalid path: {folder_arg}")
195
+ sys.exit(1)
196
+
197
+ ALLOWED_EXTS = {
198
+ '.jpg',
199
+ '.jpeg',
200
+ '.png',
201
+ '.bmp',
202
+ '.tiff',
203
+ '.webp',
204
+ '.heic',
205
+ '.heif',
206
+ '.avif',
207
+ '.jfif',
208
+ '.tif',
209
+ '.svg',
210
+ '.gif',
211
+ }
212
+ MIN_BYTES = 1 * 1024
213
+ MAX_BYTES = 10 * 1024 * 1024
214
+
215
+ log('Initializing ImageDetectionClient...')
216
+ client = ImageDetectionClient(API_KEY, BASE_URL)
217
+
218
+ initial_credits = None
219
+ try:
220
+ log('Checking initial credits...')
221
+ initial_credits = client.check_user_credits()
222
+ log(
223
+ f"Initial credits - Base: {initial_credits['baseCredits']}, "
224
+ f"Boost: {initial_credits['boostCredits']}, "
225
+ f"Total: {initial_credits['credits']}"
226
+ )
227
+ except Exception as e:
228
+ if e is not None:
229
+ log(f"Warning: Could not check initial credits: {str(e)}")
230
+ else:
231
+ log(f"Warning: Could not check initial credits: {str(e)}")
232
+
233
+ log(f"\nSearching for image files in: {FOLDER}")
234
+ if not os.path.exists(FOLDER):
235
+ print(f"Folder not found: {FOLDER}")
236
+ print('\nUsage:')
237
+ print(' python test_api_client.py [folder_path]')
238
+ sys.exit(1)
239
+
240
+ files = discover_image_files(FOLDER, ALLOWED_EXTS, MIN_BYTES, MAX_BYTES)
241
+
242
+ if len(files) == 0:
243
+ print(f"No valid image files found in: {FOLDER}")
244
+ print(f"\nAllowed extensions: {', '.join(sorted(ALLOWED_EXTS))}")
245
+ print(f"File size limits: {MIN_BYTES} bytes - {MAX_BYTES} bytes")
246
+ sys.exit(1)
247
+
248
+ log(f"Found {len(files)} files to process")
249
+
250
+ results: List[Dict[str, Any]] = []
251
+
252
+ for file_name in files:
253
+ result = process_file(client, FOLDER, file_name)
254
+ results.append(result)
255
+ print() # Empty line between files
256
+
257
+ print_summary(results)
258
+
259
+ final_credits = None
260
+ try:
261
+ log('\nChecking final credits...')
262
+ final_credits = client.check_user_credits()
263
+ log(
264
+ f"Final credits - Base: {final_credits['baseCredits']}, "
265
+ f"Boost: {final_credits['boostCredits']}, "
266
+ f"Total: {final_credits['credits']}"
267
+ )
268
+
269
+ if initial_credits and final_credits:
270
+ credits_charged = initial_credits['credits'] - final_credits['credits']
271
+ print('\n' + '-' * 70)
272
+ print('CREDIT USAGE SUMMARY')
273
+ print('-' * 70)
274
+ print(f"Initial Credits: {initial_credits['credits']}")
275
+ print(f"Final Credits: {final_credits['credits']}")
276
+ print(f"Credits Used: {credits_charged}")
277
+ print('-' * 70)
278
+ except Exception as e:
279
+ if e is not None:
280
+ log(f"Warning: Could not check final credits: {str(e)}")
281
+ else:
282
+ log(f"Warning: Could not check final credits: {str(e)}")
283
+
284
+ output_file = 'api_client_test_results.json'
285
+ with open(output_file, 'w', encoding='utf-8') as f:
286
+ json.dump(results, f, ensure_ascii=False, indent=2)
287
+ log(f"\nResults saved to: {output_file}")
288
+
289
+ print('\n' + '=' * 70)
290
+ print(' All processing completed!')
291
+ print('=' * 70)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ try:
296
+ main()
297
+ except KeyboardInterrupt:
298
+ print("\n\n Processing interrupted by user")
299
+ sys.exit(1)
300
+ except Exception as error:
301
+ print(f"\n Unexpected error: {error}")
302
+ import traceback
303
+ traceback.print_exc()
304
+ sys.exit(1)