Skip to content

Commit ec22cc6

Browse files
Typos, temp fix for download endpoints. Tested model updating on latest API json
1 parent 6756d8b commit ec22cc6

File tree

10 files changed

+176
-24
lines changed

10 files changed

+176
-24
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@ pip install clio-manage-api-client
1010

1111
### Initialization
1212
```python
13-
import clio-manage-api-client as Clio
13+
import clio_manage_python_client as Clio
1414

1515
client = Clio.Manage(access_token="your_access_token") # 'store_responses=True' for sqlite response handler
1616
```
17+
Or
18+
```python
19+
from clio_manage_python_client import Manage as Client
20+
21+
client = Client(access_token="your_access_token")
22+
```
23+
1724
## Key Features
1825

1926
1. **Dynamic Endpoint Management**:

UPDATES.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
## 8/14/25 Update:
1+
## 9/15/25 Update:
2+
- **Added temporary fixes for download endpoints. I am unsure what version it stopped working in. Update to _download_content was generated almost exclusive with chatGPT which I rarely use for core functionality. An updated, cleaner version will be released soon**
3+
- **Fixed Typos and imports in README**
4+
- **Running in Async has not been tested recently and will likely require minor changes.**
5+
6+
## 8/14/25:
27
- **Added temporary fix for endpoints that have 'fields' requirements in both the Query Parameters and Payload Parameters**
38
- **To be able to take advantage of the field builder validation for "fields='all'", I added an optional response_fields argument**
49
- This will override any provided "fields" argument even if "fields" isn't a required payload paramater.

example.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from datetime import datetime, timedelta, date
55

6-
from clio_manage_python_client import ClioManage
6+
from clio_manage_python_client import Manage
77

88
# Example usage
99
if __name__ == "__main__":
@@ -12,7 +12,7 @@
1212
Read: Api, Calendars, Contacts, Custom Fields, Documents, General, Matters, Users
1313
'''
1414
token = "ACCESS TOKEN"
15-
client = ClioManage(access_token=token, store_responses=True)
15+
client = Manage(access_token=token, store_responses=True)
1616
try:
1717

1818
random_id = client.utils.export.get_random_id(client.get.matters(limit=100, fields="id"))

example_async.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import os
55
from datetime import datetime, timedelta, date
66

7-
from clio_manage_python_client import ClioManage
7+
from clio_manage_python_client import Manage
88

99

1010
async def main():
@@ -13,7 +13,7 @@ async def main():
1313
Read: Api, Calendars, Contacts, Custom Fields, Documents, General, Matters, Users
1414
'''
1515
token = "ACCESS TOKEN"
16-
client = ClioManage(access_token=token, store_responses=True, async_requests=True)
16+
client = Manage(access_token=token, store_responses=True, async_requests=True)
1717

1818
try:
1919
# Use utility to pick a random ID from recent matters

example_scripts/bulk_customfield_generation.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
try:
44
# Try the installed package (preferred)
5-
from clio_manage_python_client import Clio_Manage
5+
from clio_manage_python_client import Manage
66
except ImportError:
77
# Fallback: add local path (assumes script is in project_root/example_scripts/)
88
import sys
@@ -12,7 +12,7 @@
1212
src_path = project_root / "src"
1313
sys.path.insert(0, str(src_path))
1414

15-
from clio_manage_python_client import Clio_Manage
15+
from clio_manage_python_client import Manage
1616

1717

1818
def process_file(file_path, client):
@@ -86,7 +86,7 @@ def normalize_boolean(value):
8686
'''
8787
access_token = "CHANGEME"
8888
file_path = "CHANGEME"
89-
client = Clio_Manage(access_token=access_token, store_responses=False)
89+
client = Manage(access_token=access_token, store_responses=False)
9090
try:
9191
responses = process_file(file_path, client)
9292
print(responses)

example_scripts/customfield_migration.py

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

3636
try:
3737
# Try the installed package (preferred)
38-
from clio_manage_python_client import Clio_Manage
38+
from clio_manage_python_client import Manage
3939

4040
except ImportError:
4141
# Fallback: add local path (assumes script is in project_root/example_scripts/)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ tzdata
1616
urllib3
1717
aiohttp
1818
pyyaml
19-
clio-api-model-generator==0.2.2
19+
clio-api-model-generator>=0.2.2

setup.py

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

1111
setup(
1212
name="clio-manage-api-client",
13-
version="0.1.4",
13+
version="0.1.5",
1414
author="Unigrated Partners",
1515
author_email="dev@unigratedpartners.com",
1616
description="Python Client for the Clio Manage API.",

src/clio_manage_python_client/classes/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,9 @@ def describe_relation(self, relation_name: str) -> Dict[str, Any]:
632632
),
633633
}
634634

635+
def fields(self):
636+
print(self.post_method)
637+
635638
class DownloadEndpointBase:
636639
"""
637640
Class for grouping and managing download endpoints.

src/clio_manage_python_client/client.py

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import requests
22
import aiohttp
3-
from urllib.parse import urljoin
4-
3+
from urllib.parse import urljoin, unquote, urlsplit
4+
import re
5+
56
from .configs import *
67
from .classes.requests import Get, Put, Post, Patch, Delete, Download, All
78
from .db.response_handler import ResponseHandler
@@ -132,9 +133,10 @@ def _request_handler(self, api_url: str, method: str, params: dict = None, paylo
132133
def make_request():
133134
try:
134135
if method == "DOWNLOAD":
135-
response_obj = self._download_content(api_url, params)
136+
137+
output_path, response_obj = self._download_content(api_url, params)
136138
self.rate_limiter.update_rate_limits(endpoint, response_obj.headers)
137-
return response_obj
139+
return output_path, response_obj
138140

139141
if return_all is False:
140142
# Single request
@@ -174,17 +176,108 @@ def make_request():
174176

175177
return make_request()
176178

179+
# def _download_content(self, url: str, params: dict = None):
180+
# """
181+
# Makes the actual HTTP request based on the method.
182+
# """
183+
# print("Downloading COntent")
184+
# headers = {
185+
# "Authorization": f"Bearer {self.access_token}",
186+
# "Content-Type": "application/pdf",
187+
# }
188+
# def get_base_url(url: str) -> str:
189+
# base = url.rstrip('/').rsplit('/', 1)[0]
190+
# return base + '.json'
191+
192+
# base_params = {'fields':'name'}
193+
# api_base_url = get_base_url(url)
194+
# print(api_base_url)
195+
# response = requests.get(api_base_url, headers=headers, params=base_params)
196+
# print(response.json())
197+
198+
# try:
199+
# response = requests.get(url, headers=headers, params=params)
200+
201+
# if response.status_code == 429:
202+
# retry_after = int(response.headers.get("Retry-After", 1))
203+
# raise requests.exceptions.RequestException(
204+
# f"Rate limited. Retry after {retry_after} seconds."
205+
# )
206+
207+
# if response.status_code != 200:
208+
# raise requests.exceptions.RequestException(f"HTTP {response.status_code}: {response.text}")
209+
210+
# return response
211+
212+
# except requests.exceptions.RequestException as e:
213+
# raise RuntimeError(f"HTTP request failed: {e}") from e
214+
177215
def _download_content(self, url: str, params: dict = None):
178216
"""
179-
Makes the actual HTTP request based on the method.
217+
Downloads the file from Clio and saves it to the current working directory.
218+
If no filename is found, fallback to using the document ID with the correct extension.
180219
"""
181220

182-
headers = {
183-
"Authorization": f"Bearer {self.access_token}",
184-
"Content-Type": "application/pdf",
221+
# ------------ Internal Helper Functions ------------
222+
def _safe_filename(name: str) -> str:
223+
name = name.strip().replace("/", "-").replace("\\", "-")
224+
return re.sub(r'[\x00-\x1f<>:"|?*]+', "-", name)[:255] or "downloaded_file"
225+
226+
def _filename_from_content_disposition(cd: str | None) -> str | None:
227+
if not cd:
228+
return None
229+
match = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
230+
return _safe_filename(unquote(match.group(1))) if match else None
231+
232+
def get_base_url(url: str) -> str:
233+
base = url.rstrip('/').rsplit('/', 1)[0]
234+
return base + '.json'
235+
236+
def get_doc_id_from_url(url: str) -> str:
237+
parts = urlsplit(url).path.strip("/").split("/")
238+
for i, part in enumerate(parts):
239+
if part == "documents" and i + 1 < len(parts):
240+
return parts[i + 1]
241+
return "unknown_id"
242+
243+
def guess_extension(content_type: str) -> str:
244+
mime_map = {
245+
"application/pdf": ".pdf",
246+
"application/msword": ".doc",
247+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
248+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
249+
"application/zip": ".zip",
250+
"image/jpeg": ".jpg",
251+
"image/png": ".png",
185252
}
253+
return mime_map.get(content_type.split(";")[0].strip(), "")
254+
255+
# ------------ Step 1: Fetch Metadata (.json) ------------
256+
meta_url = get_base_url(url)
257+
print(f"Meta URL: {meta_url}")
258+
259+
meta_headers = {
260+
"Authorization": f"Bearer {self.access_token}",
261+
"Accept": "application/json"
262+
}
263+
186264
try:
187-
response = requests.get(url, headers=headers, params=params)
265+
response = requests.get(meta_url, headers=meta_headers, params={'fields': 'name'})
266+
response.raise_for_status()
267+
meta = response.json()
268+
269+
filename_from_json = _safe_filename(meta.get("data", {}).get("name", ""))
270+
except Exception:
271+
print("Failed to fetch metadata, continuing without it.")
272+
filename_from_json = ""
273+
274+
download_headers = {
275+
"Authorization": f"Bearer {self.access_token}",
276+
"Accept": "*/*"
277+
}
278+
279+
try:
280+
response = requests.get(url, headers=download_headers, params=params, stream=True, allow_redirects=True)
188281

189282
if response.status_code == 429:
190283
retry_after = int(response.headers.get("Retry-After", 1))
@@ -195,11 +288,23 @@ def _download_content(self, url: str, params: dict = None):
195288
if response.status_code != 200:
196289
raise requests.exceptions.RequestException(f"HTTP {response.status_code}: {response.text}")
197290

198-
return response
199-
291+
content_disposition = response.headers.get("Content-Disposition")
292+
content_type = response.headers.get("Content-Type", "")
293+
doc_id = get_doc_id_from_url(url)
294+
fallback_ext = guess_extension(content_type)
295+
filename = (
296+
_filename_from_content_disposition(content_disposition)
297+
or filename_from_json
298+
or f"{doc_id}{fallback_ext}"
299+
)
300+
301+
output_path = self._save_response_to_file(response, filename)
302+
303+
return output_path, response
304+
200305
except requests.exceptions.RequestException as e:
201306
raise RuntimeError(f"HTTP request failed: {e}") from e
202-
307+
203308
#Asyncronous Requests
204309
async def _make_async_request(self, url: str, method: str, params: dict = None, payload: dict = None):
205310
"""
@@ -323,6 +428,38 @@ async def _download_content_async(self, url: str, params: dict = None):
323428
except aiohttp.ClientError as e:
324429
raise RuntimeError(f"HTTP request failed: {e}") from e
325430

431+
432+
def _save_response_to_file(self, response: requests.Response, filename: str, subdir: str = "downloads") -> Path:
433+
"""
434+
Saves the streamed response to a file.
435+
If file already exists, appends (1), (2), etc.
436+
Returns the final Path used.
437+
"""
438+
output_dir = Path.cwd() / subdir
439+
output_dir.mkdir(parents=True, exist_ok=True)
440+
441+
base = output_dir / filename
442+
final_path = base
443+
444+
# Split filename into name and extension
445+
stem = final_path.stem
446+
suffix = final_path.suffix
447+
counter = 1
448+
449+
# Generate a non-colliding filename
450+
while final_path.exists():
451+
final_path = output_dir / f"{stem}({counter}){suffix}"
452+
counter += 1
453+
454+
# Save content
455+
with open(final_path, "wb") as f:
456+
for chunk in response.iter_content(chunk_size=8192):
457+
if chunk:
458+
f.write(chunk)
459+
460+
print(f"✅ File saved to: {final_path}")
461+
return final_path
462+
326463
def set_bearer_token(self, new_token: str):
327464
self.access_token = new_token
328465

0 commit comments

Comments
 (0)