pycomad 0.1.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.
pycomad-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycomad
3
+ Version: 0.1.0
4
+ Summary: Python client library for the Comad DAM (Digital Asset Management) API
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/ullav-dev/pycomad
7
+ Project-URL: Documentation, https://ullav-dev.github.io/pycomad/
8
+ Project-URL: Bug Tracker, https://github.com/ullav-dev/pycomad/issues
9
+ Keywords: dam,digital asset management,comad,ullav
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: requests>=2.28
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: pytest-cov>=4; extra == "dev"
25
+ Requires-Dist: responses>=0.23; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: mypy; extra == "dev"
28
+ Requires-Dist: types-requests; extra == "dev"
29
+ Provides-Extra: docs
30
+ Requires-Dist: mkdocs-material>=9; extra == "docs"
31
+ Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
32
+
33
+ # pycomad
34
+
35
+ Python client library for the [Comad DAM (Digital Asset Management)](https://github.com/colinmanning/ullav-dam-server) API.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install pycomad
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from pycomad import ComadClient
47
+
48
+ client = ComadClient(
49
+ api_url="http://localhost:8080",
50
+ auth_url="http://localhost:8081", # ullav-user-management service
51
+ )
52
+ client.login(email="user@example.com", password="secret")
53
+
54
+ # Upload a file (creates the record and uploads in one step)
55
+ asset = client.assets.upload("photo.jpg", creator="alice", is_private=False)
56
+
57
+ # Or create a record first, then upload the file
58
+ record = client.assets.create("Report", "application/pdf")
59
+ client.assets.upload_file(record.id, "report.pdf")
60
+
61
+ # Download and thumbnail
62
+ raw_bytes = client.assets.download(asset.id)
63
+ thumb_png = client.assets.thumbnail(asset.id)
64
+
65
+ # Categories
66
+ cat = client.categories.create("Architecture", access_level="Global")
67
+ client.assets.add_category(asset.id, cat.id)
68
+ detail = client.assets.get(asset.id) # AssetWithCategories
69
+
70
+ # Search
71
+ results = client.search.search(q="photo", creator="alice")
72
+ nearby = client.search.nearby(lat=53.3, lon=-6.2, radius_km=5)
73
+
74
+ # Metadata (EXIF / IPTC / XMP)
75
+ meta = client.metadata.get(asset.id)
76
+ if meta.exif:
77
+ print(meta.exif.get("camera_make"))
78
+
79
+ # Usage quotas
80
+ usage = client.assets.usage()
81
+ print(f"{usage.asset_count} assets, {usage.used_bytes // 1024**2} MB used")
82
+
83
+ # ZIP batch import
84
+ result = client.zip.upload("photos.zip", creator="alice")
85
+ print(f"Imported {len(result.assets)} assets into {len(result.categories)} categories")
86
+ ```
87
+
88
+ ## Example: upload a file
89
+
90
+ Print asset details after uploading a local file:
91
+
92
+ ```python
93
+ import os
94
+ from pycomad import ComadClient
95
+
96
+ client = ComadClient(
97
+ api_url=os.environ["COMAD_API_URL"],
98
+ auth_url=os.environ.get("COMAD_AUTH_URL", os.environ["COMAD_API_URL"]),
99
+ )
100
+ client.login(email=os.environ["COMAD_EMAIL"], password=os.environ["COMAD_PASSWORD"])
101
+
102
+ asset = client.assets.upload("photo.jpg", creator="alice")
103
+ print(f"{asset.name} ({asset.asset_type}, {asset.size:,} bytes) id={asset.id}")
104
+ ```
105
+
106
+ A runnable version of this script is at `examples/upload_asset.py`.
107
+
108
+ ## Authentication
109
+
110
+ `ComadClient` authenticates against the `ullav-user-management` service, which
111
+ issues the JWT accepted by the Comad DAM server.
112
+
113
+ - `api_url` — Comad DAM server base URL (e.g. `http://comad-server:8080`)
114
+ - `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
115
+ omit if both services are behind the same proxy
116
+
117
+ Call `client.login(email, password)` before any other method. Tokens expire
118
+ according to the server's configuration; call `login()` again to refresh.
119
+
120
+ ## Resource clients
121
+
122
+ | Attribute | Resource |
123
+ |---|---|
124
+ | `client.assets` | Asset CRUD, file upload/download/thumbnail, category links, usage |
125
+ | `client.metadata` | EXIF/IPTC/XMP metadata get and refresh |
126
+ | `client.search` | Full-text and metadata-driven search, geo search |
127
+ | `client.categories` | Category CRUD (hierarchical tree) |
128
+ | `client.custom_field_schemas` | Team custom field schema CRUD |
129
+ | `client.zip` | ZIP batch import |
130
+
131
+ ## File uploads
132
+
133
+ `client.assets.upload()` and `client.assets.upload_file()` accept:
134
+
135
+ - A file path as a `str` or `pathlib.Path`
136
+ - An already-open binary file object (`IO[bytes]`)
137
+
138
+ The MIME type is inferred from the file extension if `asset_type` is omitted.
139
+
140
+ ## Error handling
141
+
142
+ ```python
143
+ from pycomad import ComadAuthError, ComadNotFoundError, ComadValidationError
144
+
145
+ try:
146
+ asset = client.assets.get("non-existent-id")
147
+ except ComadNotFoundError:
148
+ print("not found")
149
+ except ComadAuthError:
150
+ client.login(email, password) # token expired — re-authenticate
151
+ except ComadValidationError as e:
152
+ print("bad request:", e)
153
+ ```
154
+
155
+ | Exception | HTTP status |
156
+ |---|---|
157
+ | `ComadAuthError` | 401 / 403, or `login()` not called |
158
+ | `ComadNotFoundError` | 404 |
159
+ | `ComadValidationError` | 400 |
160
+ | `ComadServerError` | 5xx |
161
+ | `ComadError` | base class |
162
+
163
+ ## Access levels
164
+
165
+ Use the `AccessLevel` enumeration or plain strings when creating categories:
166
+
167
+ ```python
168
+ from pycomad import AccessLevel
169
+
170
+ client.categories.create("Press", access_level=AccessLevel.GLOBAL)
171
+ client.categories.create("Internal", access_level="Private") # equivalent
172
+ ```
173
+
174
+ ## Licence
175
+
176
+ MIT
@@ -0,0 +1,144 @@
1
+ # pycomad
2
+
3
+ Python client library for the [Comad DAM (Digital Asset Management)](https://github.com/colinmanning/ullav-dam-server) API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pycomad
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from pycomad import ComadClient
15
+
16
+ client = ComadClient(
17
+ api_url="http://localhost:8080",
18
+ auth_url="http://localhost:8081", # ullav-user-management service
19
+ )
20
+ client.login(email="user@example.com", password="secret")
21
+
22
+ # Upload a file (creates the record and uploads in one step)
23
+ asset = client.assets.upload("photo.jpg", creator="alice", is_private=False)
24
+
25
+ # Or create a record first, then upload the file
26
+ record = client.assets.create("Report", "application/pdf")
27
+ client.assets.upload_file(record.id, "report.pdf")
28
+
29
+ # Download and thumbnail
30
+ raw_bytes = client.assets.download(asset.id)
31
+ thumb_png = client.assets.thumbnail(asset.id)
32
+
33
+ # Categories
34
+ cat = client.categories.create("Architecture", access_level="Global")
35
+ client.assets.add_category(asset.id, cat.id)
36
+ detail = client.assets.get(asset.id) # AssetWithCategories
37
+
38
+ # Search
39
+ results = client.search.search(q="photo", creator="alice")
40
+ nearby = client.search.nearby(lat=53.3, lon=-6.2, radius_km=5)
41
+
42
+ # Metadata (EXIF / IPTC / XMP)
43
+ meta = client.metadata.get(asset.id)
44
+ if meta.exif:
45
+ print(meta.exif.get("camera_make"))
46
+
47
+ # Usage quotas
48
+ usage = client.assets.usage()
49
+ print(f"{usage.asset_count} assets, {usage.used_bytes // 1024**2} MB used")
50
+
51
+ # ZIP batch import
52
+ result = client.zip.upload("photos.zip", creator="alice")
53
+ print(f"Imported {len(result.assets)} assets into {len(result.categories)} categories")
54
+ ```
55
+
56
+ ## Example: upload a file
57
+
58
+ Print asset details after uploading a local file:
59
+
60
+ ```python
61
+ import os
62
+ from pycomad import ComadClient
63
+
64
+ client = ComadClient(
65
+ api_url=os.environ["COMAD_API_URL"],
66
+ auth_url=os.environ.get("COMAD_AUTH_URL", os.environ["COMAD_API_URL"]),
67
+ )
68
+ client.login(email=os.environ["COMAD_EMAIL"], password=os.environ["COMAD_PASSWORD"])
69
+
70
+ asset = client.assets.upload("photo.jpg", creator="alice")
71
+ print(f"{asset.name} ({asset.asset_type}, {asset.size:,} bytes) id={asset.id}")
72
+ ```
73
+
74
+ A runnable version of this script is at `examples/upload_asset.py`.
75
+
76
+ ## Authentication
77
+
78
+ `ComadClient` authenticates against the `ullav-user-management` service, which
79
+ issues the JWT accepted by the Comad DAM server.
80
+
81
+ - `api_url` — Comad DAM server base URL (e.g. `http://comad-server:8080`)
82
+ - `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
83
+ omit if both services are behind the same proxy
84
+
85
+ Call `client.login(email, password)` before any other method. Tokens expire
86
+ according to the server's configuration; call `login()` again to refresh.
87
+
88
+ ## Resource clients
89
+
90
+ | Attribute | Resource |
91
+ |---|---|
92
+ | `client.assets` | Asset CRUD, file upload/download/thumbnail, category links, usage |
93
+ | `client.metadata` | EXIF/IPTC/XMP metadata get and refresh |
94
+ | `client.search` | Full-text and metadata-driven search, geo search |
95
+ | `client.categories` | Category CRUD (hierarchical tree) |
96
+ | `client.custom_field_schemas` | Team custom field schema CRUD |
97
+ | `client.zip` | ZIP batch import |
98
+
99
+ ## File uploads
100
+
101
+ `client.assets.upload()` and `client.assets.upload_file()` accept:
102
+
103
+ - A file path as a `str` or `pathlib.Path`
104
+ - An already-open binary file object (`IO[bytes]`)
105
+
106
+ The MIME type is inferred from the file extension if `asset_type` is omitted.
107
+
108
+ ## Error handling
109
+
110
+ ```python
111
+ from pycomad import ComadAuthError, ComadNotFoundError, ComadValidationError
112
+
113
+ try:
114
+ asset = client.assets.get("non-existent-id")
115
+ except ComadNotFoundError:
116
+ print("not found")
117
+ except ComadAuthError:
118
+ client.login(email, password) # token expired — re-authenticate
119
+ except ComadValidationError as e:
120
+ print("bad request:", e)
121
+ ```
122
+
123
+ | Exception | HTTP status |
124
+ |---|---|
125
+ | `ComadAuthError` | 401 / 403, or `login()` not called |
126
+ | `ComadNotFoundError` | 404 |
127
+ | `ComadValidationError` | 400 |
128
+ | `ComadServerError` | 5xx |
129
+ | `ComadError` | base class |
130
+
131
+ ## Access levels
132
+
133
+ Use the `AccessLevel` enumeration or plain strings when creating categories:
134
+
135
+ ```python
136
+ from pycomad import AccessLevel
137
+
138
+ client.categories.create("Press", access_level=AccessLevel.GLOBAL)
139
+ client.categories.create("Internal", access_level="Private") # equivalent
140
+ ```
141
+
142
+ ## Licence
143
+
144
+ MIT
@@ -0,0 +1,51 @@
1
+ """pycomad — Python client library for the Comad DAM (Digital Asset Management) API."""
2
+
3
+ from .client import ComadClient
4
+ from .exceptions import (
5
+ ComadAuthError,
6
+ ComadError,
7
+ ComadNotFoundError,
8
+ ComadServerError,
9
+ ComadValidationError,
10
+ )
11
+ from .models import (
12
+ AccessLevel,
13
+ Asset,
14
+ AssetMetadata,
15
+ AssetMetadataSummary,
16
+ AssetSearchResult,
17
+ AssetWithCategories,
18
+ Category,
19
+ CategoryWithChildren,
20
+ CustomFieldSchema,
21
+ FieldType,
22
+ LoginInfo,
23
+ UsageSummary,
24
+ ZipUploadResult,
25
+ )
26
+
27
+ __all__ = [
28
+ # Client
29
+ "ComadClient",
30
+ # Exceptions
31
+ "ComadError",
32
+ "ComadAuthError",
33
+ "ComadNotFoundError",
34
+ "ComadValidationError",
35
+ "ComadServerError",
36
+ # Enumerations
37
+ "AccessLevel",
38
+ "FieldType",
39
+ # Models
40
+ "LoginInfo",
41
+ "Asset",
42
+ "AssetWithCategories",
43
+ "AssetMetadata",
44
+ "AssetMetadataSummary",
45
+ "AssetSearchResult",
46
+ "Category",
47
+ "CategoryWithChildren",
48
+ "CustomFieldSchema",
49
+ "UsageSummary",
50
+ "ZipUploadResult",
51
+ ]
@@ -0,0 +1,133 @@
1
+ """Internal HTTP session: request dispatch and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any, Dict, Optional
7
+
8
+ import requests
9
+
10
+ from .exceptions import (
11
+ ComadAuthError,
12
+ ComadError,
13
+ ComadNotFoundError,
14
+ ComadServerError,
15
+ ComadValidationError,
16
+ )
17
+
18
+
19
+ def _compact(d: Dict[str, Any]) -> Dict[str, Any]:
20
+ """Return a copy of *d* with all ``None``-valued keys removed."""
21
+ return {k: v for k, v in d.items() if v is not None}
22
+
23
+
24
+ def _str_id(value: Any) -> Optional[str]:
25
+ """Coerce a ``uuid.UUID`` or string identifier to ``str``, pass ``None`` through."""
26
+ if value is None:
27
+ return None
28
+ return str(value) if isinstance(value, uuid.UUID) else value
29
+
30
+
31
+ class _HttpSession:
32
+ """Thin wrapper around :class:`requests.Session` that handles auth headers and
33
+ maps non-2xx responses to typed exceptions.
34
+
35
+ Content-Type is NOT set as a session default so that multipart and binary
36
+ requests are unaffected. JSON methods pass ``json=`` to requests, which
37
+ sets the header automatically per-call.
38
+ """
39
+
40
+ def __init__(self, api_url: str, auth_url: str) -> None:
41
+ self._api_url = api_url.rstrip("/")
42
+ self._auth_url = auth_url.rstrip("/")
43
+ self._session = requests.Session()
44
+ self._token: Optional[str] = None
45
+
46
+ def set_token(self, token: str) -> None:
47
+ """Store the JWT and attach it to all subsequent requests."""
48
+ self._token = token
49
+ self._session.headers["Authorization"] = f"Bearer {token}"
50
+
51
+ def _require_auth(self) -> None:
52
+ if self._token is None:
53
+ raise ComadAuthError("Not authenticated — call ComadClient.login() first.")
54
+
55
+ def _raise_for_status(self, response: requests.Response) -> None:
56
+ if response.ok:
57
+ return
58
+ try:
59
+ body = response.json()
60
+ message = body.get("error") or body.get("message") or response.text
61
+ except Exception:
62
+ message = response.text
63
+ if response.status_code == 401:
64
+ raise ComadAuthError(message)
65
+ if response.status_code == 403:
66
+ raise ComadAuthError(f"Forbidden: {message}")
67
+ if response.status_code == 404:
68
+ raise ComadNotFoundError(message)
69
+ if response.status_code == 400:
70
+ raise ComadValidationError(message)
71
+ if response.status_code >= 500:
72
+ raise ComadServerError(f"Server error {response.status_code}: {message}")
73
+ raise ComadError(f"HTTP {response.status_code}: {message}")
74
+
75
+ # ── auth service ──────────────────────────────────────────────────────────
76
+
77
+ def post_auth(self, path: str, json: Any) -> Any:
78
+ """POST to the authentication service (no JWT header required)."""
79
+ response = self._session.post(f"{self._auth_url}{path}", json=json)
80
+ self._raise_for_status(response)
81
+ return response.json()
82
+
83
+ # ── DAM API ───────────────────────────────────────────────────────────────
84
+
85
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
86
+ self._require_auth()
87
+ response = self._session.get(f"{self._api_url}{path}", params=params)
88
+ self._raise_for_status(response)
89
+ return response.json()
90
+
91
+ def get_bytes(self, path: str) -> bytes:
92
+ """GET a binary response (download, thumbnail)."""
93
+ self._require_auth()
94
+ response = self._session.get(f"{self._api_url}{path}")
95
+ self._raise_for_status(response)
96
+ return response.content
97
+
98
+ def post(self, path: str, json: Any = None) -> Any:
99
+ self._require_auth()
100
+ response = self._session.post(f"{self._api_url}{path}", json=json)
101
+ self._raise_for_status(response)
102
+ return response.json() if response.status_code != 204 else None
103
+
104
+ def post_multipart(
105
+ self,
106
+ path: str,
107
+ files: Dict[str, Any],
108
+ data: Optional[Dict[str, str]] = None,
109
+ ) -> Any:
110
+ """POST multipart/form-data (file uploads).
111
+
112
+ Does not set Content-Type — requests computes the boundary automatically
113
+ when ``files`` is passed.
114
+ """
115
+ self._require_auth()
116
+ headers = {"Authorization": f"Bearer {self._token}"}
117
+ response = requests.post(
118
+ f"{self._api_url}{path}", files=files, data=data, headers=headers
119
+ )
120
+ self._raise_for_status(response)
121
+ return response.json() if response.status_code != 204 else None
122
+
123
+ def put(self, path: str, json: Any = None) -> Any:
124
+ self._require_auth()
125
+ response = self._session.put(f"{self._api_url}{path}", json=json)
126
+ self._raise_for_status(response)
127
+ return response.json() if response.status_code != 204 else None
128
+
129
+ def delete(self, path: str) -> Any:
130
+ self._require_auth()
131
+ response = self._session.delete(f"{self._api_url}{path}")
132
+ self._raise_for_status(response)
133
+ return response.json() if response.status_code != 204 else None