shipthisapi-python 1.3.0__tar.gz → 1.4.0__tar.gz

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