shipthisapi-python 3.0.2__py3-none-any.whl → 3.0.4__py3-none-any.whl

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.
ShipthisAPI/__init__.py CHANGED
@@ -1,2 +1,16 @@
1
1
  # __variables__ with double-quoted values will be available in setup.py
2
- __version__ = "0.1.1"
2
+ __version__ = "3.0.4"
3
+
4
+ from .shipthisapi import (
5
+ ShipthisAPI,
6
+ ShipthisAPIError,
7
+ ShipthisAuthError,
8
+ ShipthisRequestError,
9
+ )
10
+
11
+ __all__ = [
12
+ "ShipthisAPI",
13
+ "ShipthisAPIError",
14
+ "ShipthisAuthError",
15
+ "ShipthisRequestError",
16
+ ]
@@ -1,24 +1,1015 @@
1
- from typing import Dict
2
- import requests
1
+ """Shipthis API Client.
2
+
3
+ An async Python client for the Shipthis public API.
4
+
5
+ Usage:
6
+ import asyncio
7
+ from ShipthisAPI import ShipthisAPI
8
+
9
+ async def main():
10
+ # Initialize the client
11
+ client = ShipthisAPI(
12
+ organisation="your_org_id",
13
+ x_api_key="your_api_key",
14
+ region_id="your_region",
15
+ location_id="your_location"
16
+ )
17
+
18
+ # Connect and validate
19
+ await client.connect()
20
+
21
+ # Get items from a collection
22
+ items = await client.get_list("shipment")
23
+
24
+ # Patch document fields
25
+ await client.patch_item("fcl_load", doc_id, {"status": "completed"})
26
+
27
+ asyncio.run(main())
28
+ """
29
+
30
+ from typing import Any, Dict, List, Optional
31
+ import json
32
+ import httpx
33
+
34
+
35
+ class ShipthisAPIError(Exception):
36
+ """Base exception for Shipthis API errors."""
37
+
38
+ def __init__(self, message: str, status_code: int = None, details: dict = None):
39
+ self.message = message
40
+ self.status_code = status_code
41
+ self.details = details or {}
42
+ super().__init__(self.message)
43
+
44
+
45
+ class ShipthisAuthError(ShipthisAPIError):
46
+ """Raised when authentication fails."""
47
+
48
+ pass
49
+
50
+
51
+ class ShipthisRequestError(ShipthisAPIError):
52
+ """Raised when a request fails."""
53
+
54
+ pass
55
+
56
+
3
57
  class ShipthisAPI:
4
- base_api_endpoint = 'https://api.shipthis.co/api/v3/'
58
+ """Async Shipthis API client for public API access.
59
+
60
+ Attributes:
61
+ base_api_endpoint: The base URL for the API.
62
+ organisation_id: Your organisation ID.
63
+ x_api_key: Your API key.
64
+ user_type: User type for requests (default: "employee").
65
+ region_id: Region ID for requests.
66
+ location_id: Location ID for requests.
67
+ timeout: Request timeout in seconds.
68
+ custom_headers: Custom headers to override defaults.
69
+ """
70
+
71
+ DEFAULT_TIMEOUT = 30
72
+ BASE_API_ENDPOINT = "https://api.shipthis.co/api/v3/"
73
+
74
+ def __init__(
75
+ self,
76
+ organisation: str,
77
+ x_api_key: str = None,
78
+ user_type: str = "employee",
79
+ region_id: str = None,
80
+ location_id: str = None,
81
+ timeout: int = None,
82
+ base_url: str = None,
83
+ custom_headers: Dict[str, str] = None,
84
+ ) -> None:
85
+ """Initialize the Shipthis API client.
86
+
87
+ Args:
88
+ organisation: Your organisation ID.
89
+ x_api_key: Your API key (optional if using custom_headers with auth).
90
+ user_type: User type for requests (default: "employee").
91
+ region_id: Region ID for requests.
92
+ location_id: Location ID for requests.
93
+ timeout: Request timeout in seconds (default: 30).
94
+ base_url: Custom base URL (optional, for testing).
95
+ custom_headers: Custom headers that override defaults.
96
+ """
97
+ if not organisation:
98
+ raise ValueError("organisation is required")
5
99
 
6
- def __init__(self, base_url: str, organisation: str, x_api_key:str, user_type='employee') -> None:
7
100
  self.x_api_key = x_api_key
8
101
  self.organisation_id = organisation
9
- self.base_url = base_url
10
102
  self.user_type = user_type
11
-
12
- def _make_request(self, method: str, path: str, query_params: str=None, request_data=None) -> None:
103
+ self.region_id = region_id
104
+ self.location_id = location_id
105
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
106
+ self.base_api_endpoint = base_url or self.BASE_API_ENDPOINT
107
+ self.custom_headers = custom_headers or {}
108
+ self.organisation_info = None
109
+ self.is_connected = False
110
+
111
+ def set_region_location(self, region_id: str, location_id: str) -> None:
112
+ """Set the region and location for subsequent requests.
113
+
114
+ Args:
115
+ region_id: Region ID.
116
+ location_id: Location ID.
117
+ """
118
+ self.region_id = region_id
119
+ self.location_id = location_id
120
+
121
+ def _get_headers(self, override_headers: Dict[str, str] = None) -> Dict[str, str]:
122
+ """Build request headers.
123
+
124
+ Args:
125
+ override_headers: Headers to override for this specific request.
126
+
127
+ Returns:
128
+ Dictionary of headers.
129
+ """
13
130
  headers = {
14
- "x-api-key": self.x_api_key,
15
- "organisation_id": self.organisation_id,
16
- "user_type": self.user_type
131
+ "organisation": self.organisation_id,
132
+ "usertype": self.user_type,
133
+ "Content-Type": "application/json",
134
+ "Accept": "application/json",
135
+ }
136
+ if self.x_api_key:
137
+ headers["x-api-key"] = self.x_api_key
138
+ if self.region_id:
139
+ headers["region"] = self.region_id
140
+ if self.location_id:
141
+ headers["location"] = self.location_id
142
+ # Apply custom headers from init
143
+ headers.update(self.custom_headers)
144
+ # Apply per-request override headers
145
+ if override_headers:
146
+ headers.update(override_headers)
147
+ return headers
148
+
149
+ async def _make_request(
150
+ self,
151
+ method: str,
152
+ path: str,
153
+ query_params: Dict[str, Any] = None,
154
+ request_data: Dict[str, Any] = None,
155
+ headers: Dict[str, str] = None,
156
+ ) -> Dict[str, Any]:
157
+ """Make an async HTTP request to the API.
158
+
159
+ Args:
160
+ method: HTTP method (GET, POST, PUT, PATCH, DELETE).
161
+ path: API endpoint path.
162
+ query_params: Query parameters.
163
+ request_data: Request body data.
164
+ headers: Headers to override for this request.
165
+
166
+ Returns:
167
+ API response data.
168
+
169
+ Raises:
170
+ ShipthisAuthError: If authentication fails.
171
+ ShipthisRequestError: If the request fails.
172
+ """
173
+ url = self.base_api_endpoint + path
174
+ request_headers = self._get_headers(headers)
175
+
176
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
177
+ try:
178
+ response = await client.request(
179
+ method,
180
+ url,
181
+ headers=request_headers,
182
+ params=query_params,
183
+ json=request_data,
184
+ )
185
+ except httpx.TimeoutException:
186
+ raise ShipthisRequestError(
187
+ message="Request timed out",
188
+ status_code=408,
189
+ )
190
+ except httpx.ConnectError as e:
191
+ raise ShipthisRequestError(
192
+ message=f"Connection error: {str(e)}",
193
+ status_code=0,
194
+ )
195
+ except httpx.RequestError as e:
196
+ raise ShipthisRequestError(
197
+ message=f"Request failed: {str(e)}",
198
+ status_code=0,
199
+ )
200
+
201
+ # Handle authentication errors
202
+ if response.status_code == 401:
203
+ raise ShipthisAuthError(
204
+ message="Authentication failed. Check your API key.",
205
+ status_code=401,
206
+ )
207
+ if response.status_code == 403:
208
+ raise ShipthisAuthError(
209
+ message="Access denied. Check your permissions.",
210
+ status_code=403,
211
+ )
212
+
213
+ # Parse response
214
+ try:
215
+ result = response.json()
216
+ except json.JSONDecodeError:
217
+ raise ShipthisRequestError(
218
+ message=f"Invalid JSON response: {response.text[:200]}",
219
+ status_code=response.status_code,
220
+ )
221
+
222
+ # Handle success
223
+ if response.status_code in [200, 201]:
224
+ if result.get("success"):
225
+ return result.get("data")
226
+ else:
227
+ errors = result.get("errors", [])
228
+ if errors and isinstance(errors, list) and len(errors) > 0:
229
+ error_msg = errors[0].get("message", "Unknown error")
230
+ else:
231
+ error_msg = result.get("message", "API call failed")
232
+ raise ShipthisRequestError(
233
+ message=error_msg,
234
+ status_code=response.status_code,
235
+ details=result,
236
+ )
237
+
238
+ # Handle other error status codes
239
+ raise ShipthisRequestError(
240
+ message=f"Request failed with status {response.status_code}",
241
+ status_code=response.status_code,
242
+ details=result if isinstance(result, dict) else {"response": str(result)},
243
+ )
244
+
245
+ # ==================== Connection ====================
246
+
247
+ async def connect(self) -> Dict[str, Any]:
248
+ """Connect and validate the API connection.
249
+
250
+ Fetches organisation info and validates region/location.
251
+ If no region/location is set, uses the first available one.
252
+
253
+ Returns:
254
+ Dictionary with region_id and location_id.
255
+
256
+ Raises:
257
+ ShipthisAPIError: If connection fails.
258
+ """
259
+ info = await self.info()
260
+ self.organisation_info = info.get("organisation")
261
+
262
+ if not self.region_id or not self.location_id:
263
+ # Use first available region/location
264
+ regions = self.organisation_info.get("regions", [])
265
+ if regions:
266
+ self.region_id = regions[0].get("region_id")
267
+ locations = regions[0].get("locations", [])
268
+ if locations:
269
+ self.location_id = locations[0].get("location_id")
270
+
271
+ self.is_connected = True
272
+ return {
273
+ "region_id": self.region_id,
274
+ "location_id": self.location_id,
275
+ "organisation": self.organisation_info,
17
276
  }
18
- resp = requests.request(method, self.base_api_endpoint + path, data=request_data or {}, headers=headers)
19
- if resp.status_code == 200:
20
- return resp.json
21
277
 
22
- def info(self) -> Dict:
23
- info_resp = self._make_request('GET', 'auth/info')
24
- return info_resp
278
+ def disconnect(self) -> None:
279
+ """Disconnect and clear credentials."""
280
+ self.x_api_key = None
281
+ self.is_connected = False
282
+
283
+ # ==================== Info ====================
284
+
285
+ async def info(self) -> Dict[str, Any]:
286
+ """Get organisation and user info.
287
+
288
+ Returns:
289
+ Dictionary with organisation and user information.
290
+
291
+ Raises:
292
+ ShipthisAPIError: If the request fails.
293
+ """
294
+ return await self._make_request("GET", "user-auth/info")
295
+
296
+ # ==================== Collection CRUD ====================
297
+
298
+ async def get_one_item(
299
+ self,
300
+ collection_name: str,
301
+ doc_id: str = None,
302
+ filters: Dict[str, Any] = None,
303
+ only_fields: str = None,
304
+ ) -> Optional[Dict[str, Any]]:
305
+ """Get a single item from a collection.
306
+
307
+ Args:
308
+ collection_name: Name of the collection.
309
+ doc_id: Document ID (optional, if not provided returns first item).
310
+ filters: Query filters (optional).
311
+ only_fields: Comma-separated list of fields to return.
312
+
313
+ Returns:
314
+ Document data or None if not found.
315
+
316
+ Raises:
317
+ ShipthisAPIError: If the request fails.
318
+ """
319
+ if doc_id:
320
+ path = f"incollection/{collection_name}/{doc_id}"
321
+ params = {}
322
+ if only_fields:
323
+ params["only"] = only_fields
324
+ return await self._make_request("GET", path, query_params=params if params else None)
325
+ else:
326
+ params = {}
327
+ if filters:
328
+ params["query_filter_v2"] = json.dumps(filters)
329
+ if only_fields:
330
+ params["only"] = only_fields
331
+ resp = await self._make_request("GET", f"incollection/{collection_name}", params)
332
+ if isinstance(resp, dict) and resp.get("items"):
333
+ return resp.get("items")[0]
334
+ return None
335
+
336
+ async def get_list(
337
+ self,
338
+ collection_name: str,
339
+ filters: Dict[str, Any] = None,
340
+ search_query: str = None,
341
+ page: int = 1,
342
+ count: int = 20,
343
+ only_fields: str = None,
344
+ sort: List[Dict[str, Any]] = None,
345
+ output_type: str = None,
346
+ meta: bool = True,
347
+ ) -> List[Dict[str, Any]]:
348
+ """Get a list of items from a collection.
349
+
350
+ Args:
351
+ collection_name: Name of the collection.
352
+ filters: Query filters (optional).
353
+ search_query: Search query string (optional).
354
+ page: Page number (default: 1).
355
+ count: Items per page (default: 20).
356
+ only_fields: Comma-separated list of fields to return (optional).
357
+ sort: List of sort objects [{"field": "name", "order": "asc"}] (optional).
358
+ output_type: Output type (optional).
359
+ meta: Include metadata (default: True).
360
+
361
+ Returns:
362
+ List of documents.
363
+
364
+ Raises:
365
+ ShipthisAPIError: If the request fails.
366
+ """
367
+ params = {"page": page, "count": count}
368
+ if filters:
369
+ params["query_filter_v2"] = json.dumps(filters)
370
+ if search_query:
371
+ params["search_query"] = search_query
372
+ if only_fields:
373
+ params["only"] = only_fields
374
+ if sort:
375
+ params["multi_sort"] = json.dumps(sort)
376
+ if output_type:
377
+ params["output_type"] = output_type
378
+ if not meta:
379
+ params["meta"] = "false"
380
+
381
+ response = await self._make_request("GET", f"incollection/{collection_name}", params)
382
+
383
+ if isinstance(response, dict):
384
+ return response.get("items", [])
385
+ return []
386
+
387
+ async def search(
388
+ self,
389
+ collection_name: str,
390
+ query: str,
391
+ page: int = 1,
392
+ count: int = 20,
393
+ only_fields: str = None,
394
+ ) -> List[Dict[str, Any]]:
395
+ """Search for items in a collection.
396
+
397
+ Args:
398
+ collection_name: Name of the collection.
399
+ query: Search query string.
400
+ page: Page number (default: 1).
401
+ count: Items per page (default: 20).
402
+ only_fields: Comma-separated list of fields to return (optional).
403
+
404
+ Returns:
405
+ List of matching documents.
406
+
407
+ Raises:
408
+ ShipthisAPIError: If the request fails.
409
+ """
410
+ return await self.get_list(
411
+ collection_name,
412
+ search_query=query,
413
+ page=page,
414
+ count=count,
415
+ only_fields=only_fields,
416
+ )
417
+
418
+ async def create_item(
419
+ self, collection_name: str, data: Dict[str, Any]
420
+ ) -> Dict[str, Any]:
421
+ """Create a new item in a collection.
422
+
423
+ Args:
424
+ collection_name: Name of the collection.
425
+ data: Document data.
426
+
427
+ Returns:
428
+ Created document data.
429
+
430
+ Raises:
431
+ ShipthisAPIError: If the request fails.
432
+ """
433
+ resp = await self._make_request(
434
+ "POST",
435
+ f"incollection/{collection_name}",
436
+ request_data={"reqbody": data},
437
+ )
438
+ if isinstance(resp, dict) and resp.get("data"):
439
+ return resp.get("data")
440
+ return resp
441
+
442
+ async def update_item(
443
+ self,
444
+ collection_name: str,
445
+ object_id: str,
446
+ updated_data: Dict[str, Any],
447
+ ) -> Dict[str, Any]:
448
+ """Update an existing item (full replacement).
449
+
450
+ Args:
451
+ collection_name: Name of the collection.
452
+ object_id: Document ID.
453
+ updated_data: Updated document data.
454
+
455
+ Returns:
456
+ Updated document data.
457
+
458
+ Raises:
459
+ ShipthisAPIError: If the request fails.
460
+ """
461
+ resp = await self._make_request(
462
+ "PUT",
463
+ f"incollection/{collection_name}/{object_id}",
464
+ request_data={"reqbody": updated_data},
465
+ )
466
+ if isinstance(resp, dict) and resp.get("data"):
467
+ return resp.get("data")
468
+ return resp
469
+
470
+ async def patch_item(
471
+ self,
472
+ collection_name: str,
473
+ object_id: str,
474
+ update_fields: Dict[str, Any],
475
+ ) -> Dict[str, Any]:
476
+ """Patch specific fields of an item.
477
+
478
+ This is the recommended way to update document fields. It goes through
479
+ full field validation, workflow triggers, audit logging, and business logic.
480
+
481
+ Args:
482
+ collection_name: Name of the collection (e.g., "sea_shipment", "fcl_load").
483
+ object_id: Document ID.
484
+ update_fields: Dictionary of field_id to value mappings.
485
+
486
+ Returns:
487
+ Updated document data.
488
+
489
+ Raises:
490
+ ShipthisAPIError: If the request fails.
491
+
492
+ Example:
493
+ await client.patch_item(
494
+ "fcl_load",
495
+ "68a4f906743189ad061429a7",
496
+ update_fields={"container_no": "CONT123", "seal_no": "SEAL456"}
497
+ )
498
+ """
499
+ return await self._make_request(
500
+ "PATCH",
501
+ f"incollection/{collection_name}/{object_id}",
502
+ request_data={"update_fields": update_fields},
503
+ )
504
+
505
+ async def delete_item(self, collection_name: str, object_id: str) -> Dict[str, Any]:
506
+ """Delete an item.
507
+
508
+ Args:
509
+ collection_name: Name of the collection.
510
+ object_id: Document ID.
511
+
512
+ Returns:
513
+ Deletion response.
514
+
515
+ Raises:
516
+ ShipthisAPIError: If the request fails.
517
+ """
518
+ return await self._make_request(
519
+ "DELETE",
520
+ f"incollection/{collection_name}/{object_id}",
521
+ )
522
+
523
+ # ==================== Workflow Operations ====================
524
+
525
+ async def get_job_status(
526
+ self, collection_name: str, object_id: str
527
+ ) -> Dict[str, Any]:
528
+ """Get the job status for a document.
529
+
530
+ Args:
531
+ collection_name: Name of the collection.
532
+ object_id: Document ID.
533
+
534
+ Returns:
535
+ Job status data.
536
+
537
+ Raises:
538
+ ShipthisAPIError: If the request fails.
539
+ """
540
+ return await self._make_request(
541
+ "GET",
542
+ f"workflow/{collection_name}/job_status/{object_id}",
543
+ )
544
+
545
+ async def set_job_status(
546
+ self,
547
+ collection_name: str,
548
+ object_id: str,
549
+ action_index: int,
550
+ ) -> Dict[str, Any]:
551
+ """Set the job status for a document.
552
+
553
+ Args:
554
+ collection_name: Name of the collection.
555
+ object_id: Document ID.
556
+ action_index: The index of the action to execute.
557
+
558
+ Returns:
559
+ Updated status data.
560
+
561
+ Raises:
562
+ ShipthisAPIError: If the request fails.
563
+ """
564
+ return await self._make_request(
565
+ "POST",
566
+ f"workflow/{collection_name}/job_status/{object_id}",
567
+ request_data={"action_index": action_index},
568
+ )
569
+
570
+ async def get_workflow(self, object_id: str) -> Dict[str, Any]:
571
+ """Get a workflow configuration.
572
+
573
+ Args:
574
+ object_id: Workflow ID.
575
+
576
+ Returns:
577
+ Workflow data.
578
+
579
+ Raises:
580
+ ShipthisAPIError: If the request fails.
581
+ """
582
+ return await self._make_request("GET", f"incollection/workflow/{object_id}")
583
+
584
+ # ==================== Reports ====================
585
+
586
+ async def get_report_view(
587
+ self,
588
+ report_name: str,
589
+ start_date: str,
590
+ end_date: str,
591
+ post_data: Dict[str, Any] = None,
592
+ output_type: str = "json",
593
+ skip_meta: bool = True,
594
+ ) -> Dict[str, Any]:
595
+ """Get a report view.
596
+
597
+ Args:
598
+ report_name: Name of the report.
599
+ start_date: Start date (YYYY-MM-DD or timestamp).
600
+ end_date: End date (YYYY-MM-DD or timestamp).
601
+ post_data: Additional filter data (optional).
602
+ output_type: Output format (default: "json").
603
+ skip_meta: Skip metadata (default: True).
604
+
605
+ Returns:
606
+ Report data.
607
+
608
+ Raises:
609
+ ShipthisAPIError: If the request fails.
610
+ """
611
+ params = {
612
+ "start_date": start_date,
613
+ "end_date": end_date,
614
+ "output_type": output_type,
615
+ "skip_meta": "true" if skip_meta else "false",
616
+ }
617
+ if self.location_id:
618
+ params["location"] = self.location_id
619
+
620
+ return await self._make_request(
621
+ "POST",
622
+ f"report-view/{report_name}",
623
+ query_params=params,
624
+ request_data=post_data,
625
+ )
626
+
627
+ # ==================== Third-party Services ====================
628
+
629
+ async def get_exchange_rate(
630
+ self,
631
+ source_currency: str,
632
+ target_currency: str = "USD",
633
+ date: int = None,
634
+ ) -> Dict[str, Any]:
635
+ """Get exchange rate between currencies.
636
+
637
+ Args:
638
+ source_currency: Source currency code (e.g., "EUR").
639
+ target_currency: Target currency code (default: "USD").
640
+ date: Date timestamp in milliseconds (optional, defaults to now).
641
+
642
+ Returns:
643
+ Exchange rate data.
644
+
645
+ Raises:
646
+ ShipthisAPIError: If the request fails.
647
+ """
648
+ import time
649
+ if date is None:
650
+ date = int(time.time() * 1000)
651
+
652
+ return await self._make_request(
653
+ "GET",
654
+ f"thirdparty/currency?source={source_currency}&target={target_currency}&date={date}",
655
+ )
656
+
657
+ async def autocomplete(
658
+ self,
659
+ reference_name: str,
660
+ data: Dict[str, Any],
661
+ ) -> List[Dict[str, Any]]:
662
+ """Get autocomplete suggestions for a reference field.
663
+
664
+ Args:
665
+ reference_name: Name of the reference (e.g., "port", "airport").
666
+ data: Search data with query.
667
+
668
+ Returns:
669
+ List of suggestions.
670
+
671
+ Raises:
672
+ ShipthisAPIError: If the request fails.
673
+ """
674
+ params = {}
675
+ if self.location_id:
676
+ params["location"] = self.location_id
677
+
678
+ return await self._make_request(
679
+ "POST",
680
+ f"autocomplete-reference/{reference_name}",
681
+ query_params=params if params else None,
682
+ request_data=data,
683
+ )
684
+
685
+ async def search_location(self, query: str) -> List[Dict[str, Any]]:
686
+ """Search for locations using Google Places.
687
+
688
+ Args:
689
+ query: Search query string.
690
+
691
+ Returns:
692
+ List of location suggestions.
693
+
694
+ Raises:
695
+ ShipthisAPIError: If the request fails.
696
+ """
697
+ return await self._make_request(
698
+ "GET",
699
+ f"thirdparty/search-place-autocomplete?query={query}",
700
+ )
701
+
702
+ async def get_place_details(
703
+ self,
704
+ place_id: str,
705
+ description: str = "",
706
+ ) -> Dict[str, Any]:
707
+ """Get details for a Google Place.
708
+
709
+ Args:
710
+ place_id: Google Place ID.
711
+ description: Place description (optional).
712
+
713
+ Returns:
714
+ Place details.
715
+
716
+ Raises:
717
+ ShipthisAPIError: If the request fails.
718
+ """
719
+ return await self._make_request(
720
+ "GET",
721
+ f"thirdparty/select-google-place?query={place_id}&description={description}",
722
+ )
723
+
724
+ # ==================== Conversations ====================
725
+
726
+ async def create_conversation(
727
+ self,
728
+ view_name: str,
729
+ document_id: str,
730
+ conversation_data: Dict[str, Any],
731
+ ) -> Dict[str, Any]:
732
+ """Create a conversation/message on a document.
733
+
734
+ Args:
735
+ view_name: Collection/view name.
736
+ document_id: Document ID.
737
+ conversation_data: Conversation data (message, type, etc.).
738
+
739
+ Returns:
740
+ Created conversation data.
741
+
742
+ Raises:
743
+ ShipthisAPIError: If the request fails.
744
+ """
745
+ payload = {
746
+ "conversation": conversation_data,
747
+ "document_id": document_id,
748
+ "view_name": view_name,
749
+ "message_type": conversation_data.get("type", ""),
750
+ }
751
+ return await self._make_request("POST", "conversation", request_data=payload)
752
+
753
+ async def get_conversations(
754
+ self,
755
+ view_name: str,
756
+ document_id: str,
757
+ message_type: str = "all",
758
+ page: int = 1,
759
+ count: int = 100,
760
+ ) -> Dict[str, Any]:
761
+ """Get conversations for a document.
762
+
763
+ Args:
764
+ view_name: Collection/view name.
765
+ document_id: Document ID.
766
+ message_type: Filter by message type (default: "all").
767
+ page: Page number (default: 1).
768
+ count: Items per page (default: 100).
769
+
770
+ Returns:
771
+ Conversations data.
772
+
773
+ Raises:
774
+ ShipthisAPIError: If the request fails.
775
+ """
776
+ params = {
777
+ "view_name": view_name,
778
+ "document_id": document_id,
779
+ "page": str(page),
780
+ "count": str(count),
781
+ "message_type": message_type,
782
+ "version": "2",
783
+ }
784
+ return await self._make_request("GET", "conversation", query_params=params)
785
+
786
+ # ==================== Bulk Operations ====================
787
+
788
+ async def bulk_edit(
789
+ self,
790
+ collection_name: str,
791
+ ids: List[str],
792
+ update_data: Dict[str, Any],
793
+ external_update_data: Dict[str, Any] = None,
794
+ ) -> Dict[str, Any]:
795
+ """Bulk edit multiple items in a collection.
796
+
797
+ Args:
798
+ collection_name: Name of the collection.
799
+ ids: List of document IDs to update.
800
+ update_data: Key-value pairs of fields to update.
801
+ external_update_data: Extra data for external updates (optional).
802
+
803
+ Returns:
804
+ Update response.
805
+
806
+ Raises:
807
+ ShipthisAPIError: If the request fails.
808
+
809
+ Example:
810
+ await client.bulk_edit(
811
+ "customer",
812
+ ids=["5fdc00487f7636c97b9fa064", "608fe19fc33215427867f34e"],
813
+ update_data={"company.fax_no": "12323231", "address.state": "California"}
814
+ )
815
+ """
816
+ payload = {
817
+ "data": {
818
+ "ids": ids,
819
+ "update_data": update_data,
820
+ }
821
+ }
822
+ if external_update_data:
823
+ payload["data"]["external_update_data"] = external_update_data
824
+
825
+ return await self._make_request(
826
+ "POST",
827
+ f"incollection_group_edit/{collection_name}",
828
+ request_data=payload,
829
+ )
830
+
831
+ # ==================== Workflow Actions ====================
832
+
833
+ async def primary_workflow_action(
834
+ self,
835
+ collection: str,
836
+ workflow_id: str,
837
+ object_id: str,
838
+ action_index: int,
839
+ intended_state_id: str,
840
+ start_state_id: str = None,
841
+ ) -> Dict[str, Any]:
842
+ """Trigger a primary workflow transition (status change on a record).
843
+
844
+ Args:
845
+ collection: Target collection (e.g., "pickup_delivery", "sea_shipment").
846
+ workflow_id: Workflow status key (e.g., "job_status").
847
+ object_id: The document's ID.
848
+ action_index: Index of action within the status.
849
+ intended_state_id: Intended resulting state ID (e.g., "ops_complete").
850
+ start_state_id: Current/starting state ID (optional).
851
+
852
+ Returns:
853
+ Workflow action response with success status and resulting state.
854
+
855
+ Raises:
856
+ ShipthisAPIError: If the request fails.
857
+
858
+ Example:
859
+ await client.primary_workflow_action(
860
+ collection="pickup_delivery",
861
+ workflow_id="job_status",
862
+ object_id="68a4f906743189ad061429a7",
863
+ action_index=0,
864
+ intended_state_id="ops_complete",
865
+ start_state_id="closed"
866
+ )
867
+ """
868
+ payload = {
869
+ "action_index": action_index,
870
+ "intended_state_id": intended_state_id,
871
+ }
872
+ if start_state_id:
873
+ payload["start_state_id"] = start_state_id
874
+
875
+ return await self._make_request(
876
+ "POST",
877
+ f"workflow/{collection}/{workflow_id}/{object_id}",
878
+ request_data=payload,
879
+ )
880
+
881
+ async def secondary_workflow_action(
882
+ self,
883
+ collection: str,
884
+ workflow_id: str,
885
+ object_id: str,
886
+ target_state: str,
887
+ additional_data: Dict[str, Any] = None,
888
+ ) -> Dict[str, Any]:
889
+ """Trigger a secondary workflow transition (sub-status change).
890
+
891
+ Args:
892
+ collection: Target collection (e.g., "pickup_delivery").
893
+ workflow_id: Secondary status key (e.g., "driver_status").
894
+ object_id: The document's ID.
895
+ target_state: Resulting sub-state (e.g., "to_pick_up").
896
+ additional_data: Optional additional data to send with the request.
897
+
898
+ Returns:
899
+ Workflow action response.
900
+
901
+ Raises:
902
+ ShipthisAPIError: If the request fails.
903
+
904
+ Example:
905
+ await client.secondary_workflow_action(
906
+ collection="pickup_delivery",
907
+ workflow_id="driver_status",
908
+ object_id="67ed10859b7cf551a19f813e",
909
+ target_state="to_pick_up"
910
+ )
911
+ """
912
+ payload = additional_data or {}
913
+
914
+ return await self._make_request(
915
+ "POST",
916
+ f"workflow/{collection}/{workflow_id}/{object_id}/{target_state}",
917
+ request_data=payload,
918
+ )
919
+
920
+ # ==================== File Upload ====================
921
+
922
+ async def upload_file(
923
+ self,
924
+ file_path: str,
925
+ file_name: str = None,
926
+ ) -> Dict[str, Any]:
927
+ """Upload a file.
928
+
929
+ Args:
930
+ file_path: Path to the file to upload.
931
+ file_name: Custom file name (optional).
932
+
933
+ Returns:
934
+ Upload response with file URL.
935
+
936
+ Raises:
937
+ ShipthisAPIError: If the request fails.
938
+ """
939
+ import os
940
+
941
+ if file_name is None:
942
+ file_name = os.path.basename(file_path)
943
+
944
+ upload_url = self.base_api_endpoint.replace("/api/v3/", "").rstrip("/")
945
+ upload_url = upload_url.replace("api.", "upload.")
946
+ upload_url = f"{upload_url}/api/v3/file-upload"
947
+
948
+ headers = self._get_headers()
949
+ # Remove Content-Type for multipart
950
+ headers.pop("Content-Type", None)
951
+
952
+ try:
953
+ with open(file_path, "rb") as f:
954
+ files = {"file": (file_name, f)}
955
+ async with httpx.AsyncClient(timeout=self.timeout * 2) as client:
956
+ response = await client.post(
957
+ upload_url,
958
+ headers=headers,
959
+ files=files,
960
+ )
961
+ except FileNotFoundError:
962
+ raise ShipthisRequestError(
963
+ message=f"File not found: {file_path}",
964
+ status_code=0,
965
+ )
966
+ except httpx.RequestError as e:
967
+ raise ShipthisRequestError(
968
+ message=f"Upload failed: {str(e)}",
969
+ status_code=0,
970
+ )
971
+
972
+ if response.status_code == 200:
973
+ try:
974
+ return response.json()
975
+ except json.JSONDecodeError:
976
+ return {"url": response.text}
977
+
978
+ raise ShipthisRequestError(
979
+ message=f"Upload failed with status {response.status_code}",
980
+ status_code=response.status_code,
981
+ )
982
+
983
+ # ==================== Reference Linked Fields ====================
984
+
985
+ async def create_reference_linked_field(
986
+ self,
987
+ collection_name: str,
988
+ doc_id: str,
989
+ payload: Dict[str, Any],
990
+ ) -> Dict[str, Any]:
991
+ """Create a reference-linked field on a document.
992
+
993
+ Args:
994
+ collection_name: Collection name.
995
+ doc_id: Document ID.
996
+ payload: Field data to create.
997
+
998
+ Returns:
999
+ API response.
1000
+
1001
+ Raises:
1002
+ ShipthisAPIError: If the request fails.
1003
+
1004
+ Example:
1005
+ await client.create_reference_linked_field(
1006
+ "sea_shipment",
1007
+ "68a4f906743189ad061429a7",
1008
+ payload={"field_name": "containers", "data": {...}}
1009
+ )
1010
+ """
1011
+ return await self._make_request(
1012
+ "POST",
1013
+ f"incollection/create-reference-linked-field/{collection_name}/{doc_id}",
1014
+ request_data=payload,
1015
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shipthisapi-python
3
- Version: 3.0.2
3
+ Version: 3.0.4
4
4
  Summary: ShipthisAPI async utility package
5
5
  Home-page: https://github.com/shipthisco/shipthisapi-python
6
6
  Author: Mayur Rawte
@@ -0,0 +1,6 @@
1
+ ShipthisAPI/__init__.py,sha256=6bJiEUPwRd3vgRVABju-NlGaTCyh074gNkd7Im8zswU,322
2
+ ShipthisAPI/shipthisapi.py,sha256=__SzjYFohS3mOjK0tXShWSBpLEuVsZm4diLguR7IPfw,31512
3
+ shipthisapi_python-3.0.4.dist-info/METADATA,sha256=siKVxarQlEk9I_cXF3Pa2IiWh21UL0v04qM3Z84d8eg,2213
4
+ shipthisapi_python-3.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
5
+ shipthisapi_python-3.0.4.dist-info/top_level.txt,sha256=35r4y5buufpRwYKMsKPB59GoDeEprokRcBpgf-PVEFg,12
6
+ shipthisapi_python-3.0.4.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- ShipthisAPI/__init__.py,sha256=eEx4pJ9QtrqOUJqCNtp_u5GEehhUFSG_QpRCJNHOl2Q,93
2
- ShipthisAPI/shipthisapi.py,sha256=pUQRZSEJph3eSQCf_UGqy3vv34pjNznRrBIckDi0NiI,933
3
- shipthisapi_python-3.0.2.dist-info/METADATA,sha256=faMFG39_GdM0C9I8OYJR3CxtRhza8rGZrQ1GUMO_YI0,2213
4
- shipthisapi_python-3.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
5
- shipthisapi_python-3.0.2.dist-info/top_level.txt,sha256=35r4y5buufpRwYKMsKPB59GoDeEprokRcBpgf-PVEFg,12
6
- shipthisapi_python-3.0.2.dist-info/RECORD,,