Skip to content

Commit a973e5d

Browse files
authored
feat: add workflow support in patch_item, persistent HTTP client, bump to v3.1.0 (#13)
- Support workflow transitions in patch_item (direct-mode and action-based) - Reuse HTTP client across requests for connection pooling - Add async context manager support - Fix query param URL encoding in third-party methods - Properly clean up state in disconnect()
1 parent dddbfa0 commit a973e5d

File tree

3 files changed

+109
-36
lines changed

3 files changed

+109
-36
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [3.1.0] - 2026-03-27
6+
7+
### Added
8+
- Workflow transitions support in `patch_item` — update fields and trigger workflow actions in a single call
9+
- Async context manager support (`async with ShipthisAPI(...) as client:`)
10+
- Persistent HTTP client with connection pooling for better performance
11+
12+
### Changed
13+
- `disconnect()` is now async and properly closes the HTTP client
14+
- `upload_file` keeps its own client (separate upload host)
15+
16+
### Fixed
17+
- Query parameters in `get_exchange_rate`, `search_location`, and `get_place_details` now use proper URL encoding instead of string interpolation
18+
519
## [3.0.0] - 2025-02-06
620

721
### Breaking Changes

ShipthisAPI/shipthisapi.py

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def __init__(
107107
self.custom_headers = custom_headers or {}
108108
self.organisation_info = None
109109
self.is_connected = False
110+
self._client: httpx.AsyncClient = None
110111

111112
def set_region_location(self, region_id: str, location_id: str) -> None:
112113
"""Set the region and location for subsequent requests.
@@ -146,6 +147,19 @@ def _get_headers(self, override_headers: Dict[str, str] = None) -> Dict[str, str
146147
headers.update(override_headers)
147148
return headers
148149

150+
async def _ensure_client(self) -> httpx.AsyncClient:
151+
"""Get or create the shared HTTP client."""
152+
if self._client is None:
153+
self._client = httpx.AsyncClient(timeout=self.timeout)
154+
return self._client
155+
156+
async def __aenter__(self):
157+
await self.connect()
158+
return self
159+
160+
async def __aexit__(self, *args):
161+
await self.disconnect()
162+
149163
async def _make_request(
150164
self,
151165
method: str,
@@ -172,31 +186,31 @@ async def _make_request(
172186
"""
173187
url = self.base_api_endpoint + path
174188
request_headers = self._get_headers(headers)
189+
client = await self._ensure_client()
175190

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-
)
191+
try:
192+
response = await client.request(
193+
method,
194+
url,
195+
headers=request_headers,
196+
params=query_params,
197+
json=request_data,
198+
)
199+
except httpx.TimeoutException:
200+
raise ShipthisRequestError(
201+
message="Request timed out",
202+
status_code=408,
203+
)
204+
except httpx.ConnectError as e:
205+
raise ShipthisRequestError(
206+
message=f"Connection error: {str(e)}",
207+
status_code=0,
208+
)
209+
except httpx.RequestError as e:
210+
raise ShipthisRequestError(
211+
message=f"Request failed: {str(e)}",
212+
status_code=0,
213+
)
200214

201215
# Handle authentication errors
202216
if response.status_code == 401:
@@ -275,9 +289,13 @@ async def connect(self) -> Dict[str, Any]:
275289
"organisation": self.organisation_info,
276290
}
277291

278-
def disconnect(self) -> None:
279-
"""Disconnect and clear credentials."""
292+
async def disconnect(self) -> None:
293+
"""Disconnect, close the HTTP client, and clear credentials."""
294+
if self._client is not None:
295+
await self._client.aclose()
296+
self._client = None
280297
self.x_api_key = None
298+
self.organisation_info = None
281299
self.is_connected = False
282300

283301
# ==================== Info ====================
@@ -505,9 +523,10 @@ async def patch_item(
505523
self,
506524
collection_name: str,
507525
object_id: str,
508-
update_fields: Dict[str, Any],
526+
update_fields: Dict[str, Any] = None,
527+
workflow: List[Dict[str, Any]] = None,
509528
) -> Dict[str, Any]:
510-
"""Patch specific fields of an item.
529+
"""Patch specific fields of an item and/or trigger workflow transitions.
511530
512531
This is the recommended way to update document fields. It goes through
513532
full field validation, workflow triggers, audit logging, and business logic.
@@ -516,24 +535,57 @@ async def patch_item(
516535
collection_name: Name of the collection (e.g., "sea_shipment", "fcl_load").
517536
object_id: Document ID.
518537
update_fields: Dictionary of field_id to value mappings.
538+
workflow: List of workflow actions to execute. Each action is a dict with:
539+
- workflow_id (str): The workflow identifier (e.g., "status").
540+
- value (str, optional): Target state for direct-mode workflows.
541+
- action_id (str, optional): Action ID for action-based workflows.
542+
- payload (any, optional): Extra data for the action.
519543
520544
Returns:
521-
Updated document data.
545+
Updated document data and/or workflow results.
522546
523547
Raises:
524548
ShipthisAPIError: If the request fails.
525549
526550
Example:
551+
# Update fields only
527552
await client.patch_item(
528553
"fcl_load",
529554
"68a4f906743189ad061429a7",
530555
update_fields={"container_no": "CONT123", "seal_no": "SEAL456"}
531556
)
557+
558+
# Workflow transition only (direct mode)
559+
await client.patch_item(
560+
"sea_shipment",
561+
"68a4f906743189ad061429a7",
562+
workflow=[{"workflow_id": "status", "value": "approved"}]
563+
)
564+
565+
# Workflow transition only (action-based)
566+
await client.patch_item(
567+
"sea_shipment",
568+
"68a4f906743189ad061429a7",
569+
workflow=[{"workflow_id": "status", "action_id": "submit_review"}]
570+
)
571+
572+
# Fields + workflow in one call
573+
await client.patch_item(
574+
"sea_shipment",
575+
"68a4f906743189ad061429a7",
576+
update_fields={"notes": "ready"},
577+
workflow=[{"workflow_id": "status", "action_id": "submit_review"}]
578+
)
532579
"""
580+
request_data = {}
581+
if update_fields is not None:
582+
request_data["update_fields"] = update_fields
583+
if workflow is not None:
584+
request_data["workflow"] = workflow
533585
return await self._make_request(
534586
"PATCH",
535587
f"incollection/{collection_name}/{object_id}",
536-
request_data={"update_fields": update_fields},
588+
request_data=request_data,
537589
)
538590

539591
async def delete_item(self, collection_name: str, object_id: str) -> Dict[str, Any]:
@@ -686,7 +738,12 @@ async def get_exchange_rate(
686738

687739
return await self._make_request(
688740
"GET",
689-
f"thirdparty/currency?source={source_currency}&target={target_currency}&date={date}",
741+
"thirdparty/currency",
742+
query_params={
743+
"source": source_currency,
744+
"target": target_currency,
745+
"date": date,
746+
},
690747
)
691748

692749
async def autocomplete(
@@ -731,7 +788,8 @@ async def search_location(self, query: str) -> List[Dict[str, Any]]:
731788
"""
732789
return await self._make_request(
733790
"GET",
734-
f"thirdparty/search-place-autocomplete?query={query}",
791+
"thirdparty/search-place-autocomplete",
792+
query_params={"query": query},
735793
)
736794

737795
async def get_place_details(
@@ -753,7 +811,8 @@ async def get_place_details(
753811
"""
754812
return await self._make_request(
755813
"GET",
756-
f"thirdparty/select-google-place?query={place_id}&description={description}",
814+
"thirdparty/select-google-place",
815+
query_params={"query": place_id, "description": description},
757816
)
758817

759818
# ==================== Conversations ====================
@@ -987,8 +1046,8 @@ async def upload_file(
9871046
try:
9881047
with open(file_path, "rb") as f:
9891048
files = {"file": (file_name, f)}
990-
async with httpx.AsyncClient(timeout=self.timeout * 2) as client:
991-
response = await client.post(
1049+
async with httpx.AsyncClient(timeout=self.timeout * 2) as upload_client:
1050+
response = await upload_client.post(
9921051
upload_url,
9931052
headers=headers,
9941053
files=files,

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
setuptools.setup(
88
name='shipthisapi-python',
9-
version='3.0.5',
9+
version='3.1.0',
1010
author="Mayur Rawte",
1111
author_email="mayur@shipthis.co",
1212
description="ShipthisAPI async utility package",

0 commit comments

Comments
 (0)