fluidcloud 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.
@@ -0,0 +1,41 @@
1
+ # --- Python -----------------------------------------------------------------
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ *.db
10
+ *.sqlite3
11
+
12
+ # --- Env / secrets ----------------------------------------------------------
13
+ .env
14
+ .env.*
15
+ !.env.example
16
+
17
+ # --- Node / Next.js ---------------------------------------------------------
18
+ node_modules/
19
+ .next/
20
+ out/
21
+ dist/
22
+ build/
23
+ .turbo/
24
+ next-env.d.ts
25
+ *.tsbuildinfo
26
+
27
+ # --- Cloudflare Workers / wrangler ------------------------------------------
28
+ .wrangler/
29
+ .dev.vars
30
+ # wrangler bundles from the TS source; a stray tsc emit next to it is build noise.
31
+ workers/share/src/*.js
32
+
33
+ # --- Editor / OS ------------------------------------------------------------
34
+ .DS_Store
35
+ Thumbs.db
36
+ .idea/
37
+ .vscode/
38
+ *.log
39
+
40
+ # --- Host deploy artifacts (leftover source tarballs from the old transfer flow)
41
+ *.tgz
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluidcloud
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for FluidCloud — private file storage, shareable raw links, and stable public (hotlinkable) URLs.
5
+ Project-URL: Homepage, https://cloud.fluidvip.com
6
+ Project-URL: Source, https://github.com/Trebuu/FluidCloud
7
+ Author: Fluidvip
8
+ License-Expression: MIT
9
+ Keywords: cdn,fluidcloud,share-links,storage,uploads
10
+ Requires-Python: >=3.8
11
+ Requires-Dist: httpx>=0.24
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # fluidcloud — Python SDK for FluidCloud
17
+
18
+ Official Python client for [FluidCloud](https://cloud.fluidvip.com) — file storage
19
+ with shareable raw links and **stable public (hotlinkable) URLs**. The SDK hides the
20
+ upload plumbing (presign → direct-to-storage PUT → complete, including multipart for
21
+ large files) behind a single `upload()` call.
22
+
23
+ 📚 **Full documentation:** <https://cloud.fluidvip.com/docs>
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install fluidcloud
29
+ ```
30
+
31
+ Requires Python 3.8+. The only runtime dependency is `httpx`.
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from fluidcloud import FluidCloud
37
+
38
+ fc = FluidCloud(api_key="fck_live_...") # base_url defaults to production
39
+
40
+ # A Space is a top-level bucket; folders and files live inside it.
41
+ space = fc.spaces.create("Brand Assets")
42
+
43
+ # Upload — one call hides presign -> PUT -> complete (+ multipart for big files).
44
+ asset = fc.files.upload("logo.png", space_id=space.id, public=True)
45
+ print(asset.public_url) # stable, inline, cacheable hotlink (use as <img src>)
46
+
47
+ # Or mint links explicitly:
48
+ public = fc.files.public_url(asset.id) # permanent (never expires)
49
+ signed = fc.files.signed_url(asset.id, expires_in_days=7, permission="view")
50
+ ```
51
+
52
+ ## Authentication
53
+
54
+ Create an API key in the [dashboard](https://cloud.fluidvip.com) (Settings →
55
+ Developer; an active subscription is required) and pass it to the client. The key
56
+ (`fck_live_…` / `fck_test_…`) is sent as `X-API-Key`. Keys are **scoped**; a call
57
+ outside a key's scopes raises `PermissionError_` (HTTP 403 `insufficient_scope`).
58
+
59
+ ## API surface
60
+
61
+ ```python
62
+ fc.spaces.list() / create(name)
63
+ fc.folders.list(space_id, parent_id=None) / create(name, space_id, parent_id=None)
64
+ / rename(id, name) / move(id, parent_id) / delete(id) / restore(id)
65
+ fc.files.upload(path_or_bytes_or_fileobj, space_id, folder_id=None,
66
+ name=None, content_type=None, public=False)
67
+ .list(space_id, folder_id=None) / get(id)
68
+ .rename(id, name) / move(id, folder_id) / delete(id) / restore(id)
69
+ .download_url(id) # short-lived download URL
70
+ .public_url(id) # permanent public hotlink (Link)
71
+ .signed_url(id, expires_in_days=7, permission="view") # expiring (Link)
72
+ fc.shares.list(file_id=None, include_inactive=False) / revoke(share_id)
73
+ fc.quota.usage() # bytes_used / bytes_limit / links_*
74
+ ```
75
+
76
+ `public_url` vs `signed_url`: a **public** link never expires and is served inline +
77
+ edge-cached (ideal for embedding an image in a page, a bot message, or a vision
78
+ model's `image_url`). A **signed** link expires (1–365 days). Use
79
+ `fc.shares.revoke(link.id)` to revoke either.
80
+
81
+ ## Errors
82
+
83
+ All errors derive from `FluidCloudError`: `AuthError` (401),
84
+ `QuotaExceededError` (402), `PermissionError_` (403), `NotFoundError` (404),
85
+ `ConflictError` (409, e.g. sharing a file still being scanned), or `ApiError`.
86
+
87
+ ## Notes
88
+
89
+ - The client is synchronous (`httpx`) and works as a context manager:
90
+ `with FluidCloud(api_key=...) as fc: ...`.
91
+ - The SDK targets the versioned API (`/api/v1`).
92
+ - Override the API origin with `base_url=...` if you have been given a different endpoint.
93
+
94
+ MIT licensed.
@@ -0,0 +1,79 @@
1
+ # fluidcloud — Python SDK for FluidCloud
2
+
3
+ Official Python client for [FluidCloud](https://cloud.fluidvip.com) — file storage
4
+ with shareable raw links and **stable public (hotlinkable) URLs**. The SDK hides the
5
+ upload plumbing (presign → direct-to-storage PUT → complete, including multipart for
6
+ large files) behind a single `upload()` call.
7
+
8
+ 📚 **Full documentation:** <https://cloud.fluidvip.com/docs>
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install fluidcloud
14
+ ```
15
+
16
+ Requires Python 3.8+. The only runtime dependency is `httpx`.
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from fluidcloud import FluidCloud
22
+
23
+ fc = FluidCloud(api_key="fck_live_...") # base_url defaults to production
24
+
25
+ # A Space is a top-level bucket; folders and files live inside it.
26
+ space = fc.spaces.create("Brand Assets")
27
+
28
+ # Upload — one call hides presign -> PUT -> complete (+ multipart for big files).
29
+ asset = fc.files.upload("logo.png", space_id=space.id, public=True)
30
+ print(asset.public_url) # stable, inline, cacheable hotlink (use as <img src>)
31
+
32
+ # Or mint links explicitly:
33
+ public = fc.files.public_url(asset.id) # permanent (never expires)
34
+ signed = fc.files.signed_url(asset.id, expires_in_days=7, permission="view")
35
+ ```
36
+
37
+ ## Authentication
38
+
39
+ Create an API key in the [dashboard](https://cloud.fluidvip.com) (Settings →
40
+ Developer; an active subscription is required) and pass it to the client. The key
41
+ (`fck_live_…` / `fck_test_…`) is sent as `X-API-Key`. Keys are **scoped**; a call
42
+ outside a key's scopes raises `PermissionError_` (HTTP 403 `insufficient_scope`).
43
+
44
+ ## API surface
45
+
46
+ ```python
47
+ fc.spaces.list() / create(name)
48
+ fc.folders.list(space_id, parent_id=None) / create(name, space_id, parent_id=None)
49
+ / rename(id, name) / move(id, parent_id) / delete(id) / restore(id)
50
+ fc.files.upload(path_or_bytes_or_fileobj, space_id, folder_id=None,
51
+ name=None, content_type=None, public=False)
52
+ .list(space_id, folder_id=None) / get(id)
53
+ .rename(id, name) / move(id, folder_id) / delete(id) / restore(id)
54
+ .download_url(id) # short-lived download URL
55
+ .public_url(id) # permanent public hotlink (Link)
56
+ .signed_url(id, expires_in_days=7, permission="view") # expiring (Link)
57
+ fc.shares.list(file_id=None, include_inactive=False) / revoke(share_id)
58
+ fc.quota.usage() # bytes_used / bytes_limit / links_*
59
+ ```
60
+
61
+ `public_url` vs `signed_url`: a **public** link never expires and is served inline +
62
+ edge-cached (ideal for embedding an image in a page, a bot message, or a vision
63
+ model's `image_url`). A **signed** link expires (1–365 days). Use
64
+ `fc.shares.revoke(link.id)` to revoke either.
65
+
66
+ ## Errors
67
+
68
+ All errors derive from `FluidCloudError`: `AuthError` (401),
69
+ `QuotaExceededError` (402), `PermissionError_` (403), `NotFoundError` (404),
70
+ `ConflictError` (409, e.g. sharing a file still being scanned), or `ApiError`.
71
+
72
+ ## Notes
73
+
74
+ - The client is synchronous (`httpx`) and works as a context manager:
75
+ `with FluidCloud(api_key=...) as fc: ...`.
76
+ - The SDK targets the versioned API (`/api/v1`).
77
+ - Override the API origin with `base_url=...` if you have been given a different endpoint.
78
+
79
+ MIT licensed.
@@ -0,0 +1,40 @@
1
+ """FluidCloud — official Python SDK.
2
+
3
+ from fluidcloud import FluidCloud
4
+ fc = FluidCloud(api_key="fck_live_...")
5
+ asset = fc.files.upload("logo.png", space_id=space_id, public=True)
6
+ print(asset.public_url)
7
+ """
8
+
9
+ from .client import FluidCloud
10
+ from .errors import (
11
+ ApiError,
12
+ AuthError,
13
+ ConflictError,
14
+ FluidCloudError,
15
+ NotFoundError,
16
+ PermissionError_,
17
+ QuotaExceededError,
18
+ )
19
+ from .models import DelegatedToken, File, Folder, Link, Quota, Share, Space
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "FluidCloud",
25
+ "FluidCloudError",
26
+ "ApiError",
27
+ "AuthError",
28
+ "PermissionError_",
29
+ "NotFoundError",
30
+ "QuotaExceededError",
31
+ "ConflictError",
32
+ "Space",
33
+ "Folder",
34
+ "File",
35
+ "Quota",
36
+ "Link",
37
+ "Share",
38
+ "DelegatedToken",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,420 @@
1
+ """FluidCloud Python client.
2
+
3
+ from fluidcloud import FluidCloud
4
+ fc = FluidCloud(api_key="fck_live_...") # base_url defaults to prod
5
+
6
+ sp = fc.spaces.create("Brand Assets")
7
+ asset = fc.files.upload("logo.png", space_id=sp.id, public=True)
8
+ print(asset.public_url) # stable hotlinkable URL
9
+
10
+ The client targets the versioned API (``/api/v1``) and authenticates with a
11
+ first-party API key: ``fck_…`` keys are sent as ``X-API-Key``; any other value is
12
+ treated as a bearer token (a Supabase JWT) and sent as ``Authorization: Bearer``.
13
+ ``files.upload`` hides the whole presign -> direct-PUT -> complete flow, including
14
+ multipart for files >= 100 MB.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import io
21
+ import mimetypes
22
+ import os
23
+ from typing import Any, BinaryIO, Dict, List, Optional, Tuple, Union
24
+
25
+ import httpx
26
+
27
+ from .errors import ApiError, error_for
28
+ from .models import DelegatedToken, File, Folder, Link, Quota, Share, Space
29
+
30
+ __all__ = ["FluidCloud"]
31
+
32
+ PathOrData = Union[str, bytes, bytearray, BinaryIO]
33
+
34
+ DEFAULT_BASE_URL = "https://api-cloud.fluidvip.com"
35
+
36
+
37
+ def _drop_none(d: Dict[str, Any]) -> Dict[str, Any]:
38
+ return {k: v for k, v in d.items() if v is not None}
39
+
40
+
41
+ class FluidCloud:
42
+ """The FluidCloud API client.
43
+
44
+ Args:
45
+ api_key: A first-party API key (``fck_live_…`` / ``fck_test_…``) sent as
46
+ ``X-API-Key``, or a Supabase JWT (sent as a bearer token).
47
+ base_url: API origin. Defaults to production
48
+ (``https://api-cloud.fluidvip.com``); point at
49
+ ``https://api-green-cloud.fluidvip.com`` for the green/test stack.
50
+ api_version: API version segment. Defaults to ``"v1"``.
51
+ timeout: Per-request timeout in seconds (uploads can be slow — default 300).
52
+ http_client: An optional pre-built ``httpx.Client`` (handy for tests/mocks).
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: str,
58
+ *,
59
+ base_url: str = DEFAULT_BASE_URL,
60
+ api_version: str = "v1",
61
+ timeout: float = 300.0,
62
+ http_client: Optional[httpx.Client] = None,
63
+ ) -> None:
64
+ if not api_key:
65
+ raise ValueError("api_key is required")
66
+ self._api_key = api_key
67
+ self._base_url = base_url.rstrip("/")
68
+ self._api = f"{self._base_url}/api/{api_version}"
69
+ self._http = http_client or httpx.Client(timeout=timeout)
70
+
71
+ self.spaces = _Spaces(self)
72
+ self.folders = _Folders(self)
73
+ self.files = _Files(self)
74
+ self.shares = _Shares(self)
75
+ self.quota = _Quota(self)
76
+
77
+ # -- cross-service delegation (federation) ------------------------------
78
+ def exchange_service_token(
79
+ self,
80
+ user_jwt: str,
81
+ app: str,
82
+ *,
83
+ job_id: Optional[str] = None,
84
+ ttl_seconds: Optional[int] = None,
85
+ ) -> DelegatedToken:
86
+ """Trade this (service) key + an end user's Supabase JWT for a delegation token.
87
+
88
+ First-party only: the key must hold ``service:delegate`` (and
89
+ ``service:jobtoken`` when ``job_id`` is given, for a longer-lived worker
90
+ token). The returned ``fcd_`` token acts AS the user, scoped to their
91
+ tenant + ``app`` namespace. See :meth:`as_user` for a ready-bound client.
92
+ """
93
+ body = _drop_none(
94
+ {"user_jwt": user_jwt, "app": app, "job_id": job_id, "ttl_seconds": ttl_seconds}
95
+ )
96
+ return DelegatedToken.from_dict(self._request("POST", "/auth/service-token", json=body))
97
+
98
+ def as_user(
99
+ self,
100
+ user_jwt: str,
101
+ app: str,
102
+ *,
103
+ job_id: Optional[str] = None,
104
+ ttl_seconds: Optional[int] = None,
105
+ timeout: float = 300.0,
106
+ ) -> "FluidCloud":
107
+ """Return a NEW client that acts AS the end user (federation).
108
+
109
+ Exchanges this service key + the user's JWT for a delegation token and
110
+ returns a :class:`FluidCloud` bound to it — so a federated product
111
+ (FluidTalk/FluidGhost) stores + serves a user's media in the USER's tenant
112
+ with unchanged call sites. The new client reuses this one's base URL +
113
+ HTTP client.
114
+ """
115
+ token = self.exchange_service_token(user_jwt, app, job_id=job_id, ttl_seconds=ttl_seconds)
116
+ return FluidCloud(api_key=token.token, base_url=self._base_url, timeout=timeout, http_client=self._http)
117
+
118
+ # -- lifecycle ----------------------------------------------------------
119
+ def close(self) -> None:
120
+ self._http.close()
121
+
122
+ def __enter__(self) -> "FluidCloud":
123
+ return self
124
+
125
+ def __exit__(self, *_exc: Any) -> None:
126
+ self.close()
127
+
128
+ # -- low-level HTTP -----------------------------------------------------
129
+ def _auth_headers(self) -> Dict[str, str]:
130
+ if self._api_key.startswith("fck_"):
131
+ return {"X-API-Key": self._api_key}
132
+ return {"Authorization": f"Bearer {self._api_key}"}
133
+
134
+ def _request(
135
+ self,
136
+ method: str,
137
+ path: str,
138
+ *,
139
+ json: Any = None,
140
+ params: Optional[Dict[str, Any]] = None,
141
+ ) -> Any:
142
+ resp = self._http.request(
143
+ method,
144
+ self._api + path,
145
+ json=json,
146
+ params=params,
147
+ headers={"Accept": "application/json", **self._auth_headers()},
148
+ )
149
+ return self._handle(resp)
150
+
151
+ @staticmethod
152
+ def _handle(resp: httpx.Response) -> Any:
153
+ if resp.status_code == 204 or not resp.content:
154
+ if resp.is_success:
155
+ return None
156
+ if resp.is_success:
157
+ return resp.json()
158
+ detail: Any
159
+ try:
160
+ detail = resp.json().get("detail")
161
+ except Exception:
162
+ detail = resp.text
163
+ raise error_for(resp.status_code, detail)
164
+
165
+ def _put_bytes(self, url: str, data: bytes, content_type: Optional[str]) -> str:
166
+ """PUT raw bytes to a presigned URL (NOT an API call — no auth header).
167
+
168
+ Returns the object's ETag (needed to complete a multipart upload).
169
+ """
170
+ headers = {"Content-Type": content_type} if content_type else {}
171
+ resp = self._http.put(url, content=data, headers=headers)
172
+ if not resp.is_success:
173
+ raise ApiError(resp.status_code, resp.text[:300], message="presigned upload PUT failed")
174
+ return resp.headers.get("etag", "")
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Upload source normalization
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def _normalize_source(
182
+ file: PathOrData,
183
+ name: Optional[str],
184
+ content_type: Optional[str],
185
+ ) -> Tuple[BinaryIO, int, str, str]:
186
+ """Resolve (stream, size, name, content_type) from a path / bytes / file object."""
187
+ if isinstance(file, (bytes, bytearray)):
188
+ stream: BinaryIO = io.BytesIO(bytes(file))
189
+ size = len(file)
190
+ name = name or "upload.bin"
191
+ elif isinstance(file, str):
192
+ size = os.path.getsize(file)
193
+ name = name or os.path.basename(file)
194
+ stream = open(file, "rb") # noqa: SIM115 (closed by the caller via upload())
195
+ elif hasattr(file, "read"):
196
+ stream = file # type: ignore[assignment]
197
+ # Determine size by seeking, restoring the original position afterward.
198
+ pos = stream.tell()
199
+ stream.seek(0, os.SEEK_END)
200
+ size = stream.tell() - pos
201
+ stream.seek(pos)
202
+ name = name or getattr(file, "name", None) or "upload.bin"
203
+ if name:
204
+ name = os.path.basename(str(name))
205
+ else:
206
+ raise TypeError("file must be a path (str), bytes, or a binary file object")
207
+
208
+ if not content_type:
209
+ content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
210
+ return stream, size, name, content_type
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Resources
215
+ # ---------------------------------------------------------------------------
216
+
217
+ class _Resource:
218
+ def __init__(self, client: FluidCloud) -> None:
219
+ self._c = client
220
+
221
+
222
+ class _Spaces(_Resource):
223
+ def list(self) -> List[Space]:
224
+ return [Space.from_dict(x) for x in self._c._request("GET", "/spaces")]
225
+
226
+ def create(self, name: str) -> Space:
227
+ return Space.from_dict(self._c._request("POST", "/spaces", json={"name": name}))
228
+
229
+
230
+ class _Folders(_Resource):
231
+ def list(self, space_id: str, parent_id: Optional[str] = None, *, trash: bool = False) -> List[Folder]:
232
+ params = _drop_none({"space_id": space_id, "parent_id": parent_id, "trash": trash})
233
+ return [Folder.from_dict(x) for x in self._c._request("GET", "/folders", params=params)]
234
+
235
+ def create(self, name: str, space_id: str, parent_id: Optional[str] = None) -> Folder:
236
+ body = _drop_none({"name": name, "space_id": space_id, "parent_id": parent_id})
237
+ return Folder.from_dict(self._c._request("POST", "/folders", json=body))
238
+
239
+ def rename(self, folder_id: str, name: str) -> Folder:
240
+ return Folder.from_dict(self._c._request("PATCH", f"/folders/{folder_id}", json={"name": name}))
241
+
242
+ def move(self, folder_id: str, parent_id: Optional[str]) -> Folder:
243
+ # parent_id is sent EXPLICITLY (incl. null = move to space root).
244
+ return Folder.from_dict(
245
+ self._c._request("PATCH", f"/folders/{folder_id}", json={"parent_id": parent_id})
246
+ )
247
+
248
+ def delete(self, folder_id: str) -> Folder:
249
+ return Folder.from_dict(self._c._request("DELETE", f"/folders/{folder_id}"))
250
+
251
+ def restore(self, folder_id: str) -> Folder:
252
+ return Folder.from_dict(self._c._request("POST", f"/folders/{folder_id}/restore"))
253
+
254
+
255
+ class _Files(_Resource):
256
+ def upload(
257
+ self,
258
+ file: PathOrData,
259
+ space_id: str,
260
+ *,
261
+ folder_id: Optional[str] = None,
262
+ name: Optional[str] = None,
263
+ content_type: Optional[str] = None,
264
+ public: bool = False,
265
+ app: Optional[str] = None,
266
+ client_key: Optional[str] = None,
267
+ ) -> File:
268
+ """Upload a file and return the stored :class:`~fluidcloud.models.File`.
269
+
270
+ Hides the full flow: presign (single PUT for <100 MB, multipart otherwise)
271
+ -> direct upload to storage -> complete. When ``public=True`` a permanent
272
+ public link is minted and set on the result's ``public_url``.
273
+
274
+ Args:
275
+ file: A filesystem path, raw ``bytes``, or an open binary file object.
276
+ space_id: The Space to store the file in.
277
+ folder_id: Optional folder within the Space (default: Space root).
278
+ name: Override the stored filename (default: derived from the source).
279
+ content_type: Override the MIME type (default: guessed from the name).
280
+ public: Also mint a stable public hotlink and set ``result.public_url``.
281
+ app: Federation — the originating product (e.g. ``"fluidtalk"``); tags
282
+ + key-namespaces the object. Omit for the native app.
283
+ client_key: Federation — the caller's own logical key, so it can later
284
+ ``files.resolve(client_key, app=...)`` without a local key->id map.
285
+ """
286
+ stream, size, name, content_type = _normalize_source(file, name, content_type)
287
+ opened_path = isinstance(file, str)
288
+ try:
289
+ ticket = self._c._request(
290
+ "POST",
291
+ "/uploads/initiate",
292
+ json={
293
+ "original_name": name,
294
+ "content_type": content_type,
295
+ "size": size,
296
+ "space_id": space_id,
297
+ "folder_id": folder_id,
298
+ "app": app,
299
+ },
300
+ )
301
+ digest = hashlib.sha256()
302
+ upload_id: Optional[str] = None
303
+ parts: Optional[List[Dict[str, Any]]] = None
304
+
305
+ if ticket["mode"] == "single":
306
+ data = stream.read()
307
+ digest.update(data)
308
+ self._c._put_bytes(ticket["upload_url"], data, content_type)
309
+ else:
310
+ upload_id = ticket["upload_id"]
311
+ part_size = int(ticket["part_size"])
312
+ parts = []
313
+ for part in ticket["part_urls"]:
314
+ chunk = stream.read(part_size)
315
+ digest.update(chunk)
316
+ etag = self._c._put_bytes(part["url"], chunk, None)
317
+ parts.append({"PartNumber": part["part_number"], "ETag": etag})
318
+
319
+ row = self._c._request(
320
+ "POST",
321
+ "/uploads/complete",
322
+ json={
323
+ "file_id": ticket["file_id"],
324
+ "key": ticket["key"],
325
+ "upload_id": upload_id,
326
+ "parts": parts,
327
+ "original_name": name,
328
+ "mime": content_type,
329
+ "size": size,
330
+ "sha256": digest.hexdigest(),
331
+ "space_id": space_id,
332
+ "folder_id": folder_id,
333
+ "app": app,
334
+ "client_key": client_key,
335
+ },
336
+ )
337
+ finally:
338
+ if opened_path:
339
+ stream.close()
340
+
341
+ result = File.from_dict(row)
342
+ if public:
343
+ result.public_url = self.public_url(result.id).url
344
+ return result
345
+
346
+ def list(
347
+ self,
348
+ space_id: str,
349
+ folder_id: Optional[str] = None,
350
+ *,
351
+ trash: bool = False,
352
+ app: Optional[str] = None,
353
+ ) -> List[File]:
354
+ params = _drop_none({"space_id": space_id, "folder_id": folder_id, "trash": trash, "app": app})
355
+ return [File.from_dict(x) for x in self._c._request("GET", "/files", params=params)]
356
+
357
+ def get(self, file_id: str) -> File:
358
+ return File.from_dict(self._c._request("GET", f"/files/{file_id}"))
359
+
360
+ def resolve(self, client_key: str, *, app: Optional[str] = None) -> File:
361
+ """Resolve a file by the caller's own logical ``client_key`` (federation).
362
+
363
+ Lets a federated client fetch the file it uploaded under its own key
364
+ without keeping a key->id map. Scoped to the caller's tenant + ``app``
365
+ namespace; raises ``NotFoundError`` if there's no live match.
366
+ """
367
+ params = _drop_none({"client_key": client_key, "app": app})
368
+ return File.from_dict(self._c._request("GET", "/files/resolve", params=params))
369
+
370
+ def rename(self, file_id: str, name: str) -> File:
371
+ return File.from_dict(self._c._request("PATCH", f"/files/{file_id}", json={"original_name": name}))
372
+
373
+ def move(self, file_id: str, folder_id: Optional[str]) -> File:
374
+ return File.from_dict(self._c._request("PATCH", f"/files/{file_id}", json={"folder_id": folder_id}))
375
+
376
+ def delete(self, file_id: str) -> File:
377
+ return File.from_dict(self._c._request("DELETE", f"/files/{file_id}"))
378
+
379
+ def restore(self, file_id: str) -> File:
380
+ return File.from_dict(self._c._request("POST", f"/files/{file_id}/restore"))
381
+
382
+ def download_url(self, file_id: str) -> str:
383
+ """A short-lived presigned GET URL for the OWNER to download the file."""
384
+ return self._c._request("GET", f"/files/{file_id}/download")["url"]
385
+
386
+ def public_url(self, file_id: str) -> Link:
387
+ """Mint (and return) a STABLE PUBLIC hotlink — a permanent, inline,
388
+ cacheable URL suitable for ``<img>``/embeds. Never expires until revoked.
389
+ """
390
+ return Link.from_dict(
391
+ self._c._request("POST", "/shares", json={"file_id": file_id, "expires_in_days": None})
392
+ )
393
+
394
+ def signed_url(self, file_id: str, *, expires_in_days: int = 7, permission: str = "view") -> Link:
395
+ """Mint an EXPIRING share link (1..365 days)."""
396
+ return Link.from_dict(
397
+ self._c._request(
398
+ "POST",
399
+ "/shares",
400
+ json={
401
+ "file_id": file_id,
402
+ "expires_in_days": expires_in_days,
403
+ "permission": permission,
404
+ },
405
+ )
406
+ )
407
+
408
+
409
+ class _Shares(_Resource):
410
+ def list(self, *, file_id: Optional[str] = None, include_inactive: bool = False) -> List[Share]:
411
+ params = _drop_none({"file_id": file_id, "include_inactive": include_inactive})
412
+ return [Share.from_dict(x) for x in self._c._request("GET", "/shares", params=params)]
413
+
414
+ def revoke(self, share_id: str) -> None:
415
+ self._c._request("DELETE", f"/shares/{share_id}")
416
+
417
+
418
+ class _Quota(_Resource):
419
+ def usage(self) -> Quota:
420
+ return Quota.from_dict(self._c._request("GET", "/quota"))
@@ -0,0 +1,62 @@
1
+ """Typed exceptions raised by the FluidCloud SDK.
2
+
3
+ Every non-2xx API response is mapped to one of these. They all derive from
4
+ :class:`FluidCloudError`, so callers can ``except FluidCloudError`` to catch any
5
+ SDK/API failure, or catch a specific subclass (e.g. :class:`QuotaExceededError`)
6
+ to handle it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+
14
+ class FluidCloudError(Exception):
15
+ """Base class for every error this SDK raises."""
16
+
17
+
18
+ class ApiError(FluidCloudError):
19
+ """A non-2xx HTTP response from the FluidCloud API (or a presigned PUT).
20
+
21
+ Attributes:
22
+ status: The HTTP status code.
23
+ detail: The parsed ``detail`` field of the error body (str or dict), or
24
+ the raw response text when it wasn't JSON.
25
+ """
26
+
27
+ def __init__(self, status: int, detail: Any = None, message: Optional[str] = None):
28
+ self.status = status
29
+ self.detail = detail
30
+ super().__init__(message or f"FluidCloud API error {status}: {detail!r}")
31
+
32
+
33
+ class AuthError(ApiError):
34
+ """401 — the API key / token is missing, malformed, or revoked."""
35
+
36
+
37
+ class PermissionError_(ApiError):
38
+ """403 — the key lacks a required scope (``insufficient_scope``) or access."""
39
+
40
+
41
+ class NotFoundError(ApiError):
42
+ """404 — the resource does not exist within the caller's tenant."""
43
+
44
+
45
+ class QuotaExceededError(ApiError):
46
+ """402 — the tenant is over its storage / share-link cap."""
47
+
48
+
49
+ class ConflictError(ApiError):
50
+ """409 — e.g. sharing a file that has not finished scanning (not 'clean')."""
51
+
52
+
53
+ def error_for(status: int, detail: Any) -> ApiError:
54
+ """Map an HTTP status + parsed detail to the most specific ApiError subclass."""
55
+ cls = {
56
+ 401: AuthError,
57
+ 402: QuotaExceededError,
58
+ 403: PermissionError_,
59
+ 404: NotFoundError,
60
+ 409: ConflictError,
61
+ }.get(status, ApiError)
62
+ return cls(status, detail)
@@ -0,0 +1,125 @@
1
+ """Typed result objects returned by the SDK.
2
+
3
+ These are light dataclasses mirroring the API's response shapes (FastAPI
4
+ serializes UUID/datetime columns to strings, so ids and timestamps are ``str``).
5
+ Each ``from_dict`` ignores unknown keys so a server that adds a field never breaks
6
+ an older SDK.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, fields
12
+ from typing import Optional, Type, TypeVar
13
+
14
+ T = TypeVar("T", bound="_Model")
15
+
16
+
17
+ class _Model:
18
+ @classmethod
19
+ def from_dict(cls: Type[T], data: dict) -> T:
20
+ """Build the dataclass from a response dict, dropping unknown keys."""
21
+ known = {f.name for f in fields(cls)} # type: ignore[arg-type]
22
+ return cls(**{k: v for k, v in data.items() if k in known}) # type: ignore[arg-type]
23
+
24
+
25
+ @dataclass
26
+ class Space(_Model):
27
+ id: str
28
+ name: str
29
+ tenant_id: Optional[str] = None
30
+ created_at: Optional[str] = None
31
+ updated_at: Optional[str] = None
32
+
33
+
34
+ @dataclass
35
+ class Folder(_Model):
36
+ id: str
37
+ name: str
38
+ space_id: Optional[str] = None
39
+ parent_id: Optional[str] = None
40
+ tenant_id: Optional[str] = None
41
+ deleted_at: Optional[str] = None
42
+ created_at: Optional[str] = None
43
+ updated_at: Optional[str] = None
44
+
45
+
46
+ @dataclass
47
+ class File(_Model):
48
+ id: str
49
+ original_name: str
50
+ name: Optional[str] = None
51
+ space_id: Optional[str] = None
52
+ folder_id: Optional[str] = None
53
+ tenant_id: Optional[str] = None
54
+ mime: Optional[str] = None
55
+ size: Optional[int] = None
56
+ sha256: Optional[str] = None
57
+ scan_status: Optional[str] = None
58
+ status: Optional[str] = None
59
+ created_by: Optional[str] = None
60
+ # Federation (Ecosystem Storage Federation): the originating product, and the
61
+ # caller's own logical key (when uploaded by a federated client).
62
+ source_app: Optional[str] = None
63
+ client_key: Optional[str] = None
64
+ deleted_at: Optional[str] = None
65
+ created_at: Optional[str] = None
66
+ updated_at: Optional[str] = None
67
+ # Set by files.upload(..., public=True): the stable public hotlink URL.
68
+ public_url: Optional[str] = None
69
+
70
+
71
+ @dataclass
72
+ class DelegatedToken(_Model):
73
+ """The result of a cross-service token exchange (federation).
74
+
75
+ A first-party service trades its key + an end user's JWT for ``token`` (an
76
+ ``fcd_`` delegation token) that acts AS that user. Use ``FluidCloud.as_user``
77
+ to get a client already bound to it.
78
+ """
79
+
80
+ token: str
81
+ expires_in: int = 0
82
+ tenant_id: Optional[str] = None
83
+ user_id: Optional[str] = None
84
+ app: Optional[str] = None
85
+ token_type: str = "Bearer"
86
+
87
+
88
+ @dataclass
89
+ class Quota(_Model):
90
+ bytes_used: int
91
+ bytes_limit: int
92
+ links_used: int
93
+ links_limit: int
94
+ # Per-product storage footprint within the shared pool (federation).
95
+ by_app: Optional[list] = None
96
+
97
+
98
+ @dataclass
99
+ class Link(_Model):
100
+ """A minted share link (the create response). ``url`` is the raw file URL.
101
+
102
+ ``expires_at`` is ``None`` for a PERMANENT public link (``files.public_url``);
103
+ an ISO-8601 string for an expiring one (``files.signed_url``).
104
+ """
105
+
106
+ url: str
107
+ id: str
108
+ permission: str = "view"
109
+ expires_at: Optional[str] = None
110
+
111
+
112
+ @dataclass
113
+ class Share(_Model):
114
+ """A listed share link (``shares.list``) — the token itself is never returned."""
115
+
116
+ id: str
117
+ file_id: str
118
+ permission: str
119
+ expires_at: Optional[str] = None
120
+ max_downloads: Optional[int] = None
121
+ download_count: int = 0
122
+ revoked: bool = False
123
+ has_password: bool = False
124
+ created_at: Optional[str] = None
125
+ updated_at: Optional[str] = None
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fluidcloud"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for FluidCloud — private file storage, shareable raw links, and stable public (hotlinkable) URLs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ authors = [{ name = "Fluidvip" }]
13
+ keywords = ["fluidcloud", "storage", "uploads", "cdn", "share-links"]
14
+ dependencies = ["httpx>=0.24"]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=7"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://cloud.fluidvip.com"
21
+ Source = "https://github.com/Trebuu/FluidCloud"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["fluidcloud"]
@@ -0,0 +1,263 @@
1
+ """Unit tests for the FluidCloud SDK using httpx MockTransport (no network).
2
+
3
+ These assert the SDK's request *shapes* — that upload() walks initiate -> PUT ->
4
+ complete, that the presigned PUT carries no API auth, that public_url posts
5
+ expires_in_days=null, that auth headers are chosen correctly, and that errors map
6
+ to typed exceptions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as jsonlib
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from fluidcloud import FluidCloud
17
+ from fluidcloud.errors import PermissionError_, QuotaExceededError
18
+
19
+
20
+ def _client(handler, api_key="fck_live_test"):
21
+ transport = httpx.MockTransport(handler)
22
+ return FluidCloud(api_key=api_key, base_url="https://api.test", http_client=httpx.Client(transport=transport))
23
+
24
+
25
+ def _body(request):
26
+ return jsonlib.loads(request.content.decode())
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Auth header selection
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def test_fck_key_uses_x_api_key():
34
+ seen = {}
35
+
36
+ def handler(request):
37
+ seen["x-api-key"] = request.headers.get("x-api-key")
38
+ seen["authorization"] = request.headers.get("authorization")
39
+ return httpx.Response(200, json=[])
40
+
41
+ _client(handler, api_key="fck_live_abc").spaces.list()
42
+ assert seen["x-api-key"] == "fck_live_abc"
43
+ assert seen["authorization"] is None
44
+
45
+
46
+ def test_jwt_uses_bearer():
47
+ seen = {}
48
+
49
+ def handler(request):
50
+ seen["x-api-key"] = request.headers.get("x-api-key")
51
+ seen["authorization"] = request.headers.get("authorization")
52
+ return httpx.Response(200, json=[])
53
+
54
+ _client(handler, api_key="eyJhbG.payload.sig").spaces.list()
55
+ assert seen["authorization"] == "Bearer eyJhbG.payload.sig"
56
+ assert seen["x-api-key"] is None
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Upload (single) + public link
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def test_upload_single_public():
64
+ seen = {"put_auth": "unset"}
65
+
66
+ def handler(request):
67
+ p = request.url.path
68
+ if request.method == "POST" and p == "/api/v1/uploads/initiate":
69
+ b = _body(request)
70
+ assert b["original_name"] == "logo.png"
71
+ assert b["space_id"] == "sp1"
72
+ assert b["content_type"] == "image/png"
73
+ return httpx.Response(200, json={
74
+ "file_id": "f1", "key": "t/sp1/incoming/f1.png",
75
+ "mode": "single", "upload_url": "https://r2.test/put/f1",
76
+ })
77
+ if request.method == "PUT" and request.url.host == "r2.test":
78
+ # The presigned PUT must NOT carry the API key.
79
+ seen["put_auth"] = request.headers.get("x-api-key")
80
+ assert request.headers.get("content-type") == "image/png"
81
+ return httpx.Response(200, headers={"ETag": '"etag1"'})
82
+ if request.method == "POST" and p == "/api/v1/uploads/complete":
83
+ b = _body(request)
84
+ assert b["file_id"] == "f1"
85
+ assert b["key"] == "t/sp1/incoming/f1.png"
86
+ assert b["upload_id"] is None and b["parts"] is None
87
+ assert b["sha256"] and len(b["sha256"]) == 64 # computed client-side
88
+ return httpx.Response(200, json={
89
+ "id": "f1", "original_name": "logo.png",
90
+ "scan_status": "clean", "status": "pending", "size": 8,
91
+ })
92
+ if request.method == "POST" and p == "/api/v1/shares":
93
+ b = _body(request)
94
+ assert b["file_id"] == "f1"
95
+ assert b["expires_in_days"] is None # permanent / public
96
+ return httpx.Response(201, json={
97
+ "url": "https://files.test/s/TOKEN", "id": "sh1",
98
+ "permission": "view", "expires_at": None,
99
+ })
100
+ return httpx.Response(404, json={"detail": "unexpected"})
101
+
102
+ asset = _client(handler).files.upload(b"\x89PNGdata", space_id="sp1", name="logo.png", public=True)
103
+ assert asset.id == "f1"
104
+ assert asset.scan_status == "clean"
105
+ assert asset.public_url == "https://files.test/s/TOKEN"
106
+ assert seen["put_auth"] is None # no API auth on the presigned PUT
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Upload (multipart)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def test_upload_multipart():
114
+ puts = []
115
+
116
+ def handler(request):
117
+ p = request.url.path
118
+ if p == "/api/v1/uploads/initiate":
119
+ return httpx.Response(200, json={
120
+ "file_id": "f2", "key": "t/sp1/incoming/f2.bin", "mode": "multipart",
121
+ "upload_id": "U1", "part_size": 5,
122
+ "part_urls": [
123
+ {"part_number": 1, "url": "https://r2.test/part/1"},
124
+ {"part_number": 2, "url": "https://r2.test/part/2"},
125
+ ],
126
+ })
127
+ if request.method == "PUT" and request.url.host == "r2.test":
128
+ puts.append(len(request.content))
129
+ n = request.url.path.split("/")[-1]
130
+ return httpx.Response(200, headers={"ETag": f'"e{n}"'})
131
+ if p == "/api/v1/uploads/complete":
132
+ b = _body(request)
133
+ assert b["upload_id"] == "U1"
134
+ assert b["parts"] == [
135
+ {"PartNumber": 1, "ETag": '"e1"'},
136
+ {"PartNumber": 2, "ETag": '"e2"'},
137
+ ]
138
+ return httpx.Response(200, json={"id": "f2", "original_name": "f2.bin"})
139
+ return httpx.Response(404, json={"detail": "unexpected"})
140
+
141
+ asset = _client(handler).files.upload(b"0123456789", space_id="sp1", name="f2.bin")
142
+ assert asset.id == "f2"
143
+ assert puts == [5, 5] # two 5-byte parts
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Links
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def test_signed_url_sends_expiry():
151
+ def handler(request):
152
+ b = _body(request)
153
+ assert b["expires_in_days"] == 14
154
+ assert b["permission"] == "download"
155
+ return httpx.Response(201, json={
156
+ "url": "https://files.test/s/T2", "id": "sh2",
157
+ "permission": "download", "expires_at": "2026-07-01T00:00:00Z",
158
+ })
159
+
160
+ link = _client(handler).files.signed_url("f1", expires_in_days=14, permission="download")
161
+ assert link.url == "https://files.test/s/T2"
162
+ assert link.expires_at is not None
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Error mapping
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def test_403_maps_to_permission_error():
170
+ def handler(request):
171
+ return httpx.Response(403, json={"detail": {"error": "insufficient_scope", "required": ["files:write"]}})
172
+
173
+ with pytest.raises(PermissionError_) as ei:
174
+ _client(handler).files.delete("f1")
175
+ assert ei.value.status == 403
176
+
177
+
178
+ def test_402_maps_to_quota_error():
179
+ def handler(request):
180
+ return httpx.Response(402, json={"detail": "quota_exceeded"})
181
+
182
+ with pytest.raises(QuotaExceededError):
183
+ _client(handler).files.upload(b"x", space_id="sp1", name="x.bin")
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Federation: app namespacing + client_key + resolve + delegation
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def test_upload_passes_app_and_client_key():
191
+ seen = {}
192
+
193
+ def handler(request):
194
+ p = request.url.path
195
+ if p == "/api/v1/uploads/initiate":
196
+ seen["initiate"] = _body(request)
197
+ return httpx.Response(200, json={
198
+ "file_id": "f1", "key": "t/fluidtalk/sp1/incoming/f1.png",
199
+ "mode": "single", "upload_url": "https://r2.test/put/f1",
200
+ })
201
+ if request.method == "PUT" and request.url.host == "r2.test":
202
+ return httpx.Response(200, headers={"ETag": '"e"'})
203
+ if p == "/api/v1/uploads/complete":
204
+ seen["complete"] = _body(request)
205
+ return httpx.Response(200, json={
206
+ "id": "f1", "original_name": "p.png", "source_app": "fluidtalk",
207
+ "client_key": "personas/9/p.png",
208
+ })
209
+ return httpx.Response(404, json={"detail": "unexpected"})
210
+
211
+ asset = _client(handler).files.upload(
212
+ b"x", space_id="sp1", name="p.png", app="fluidtalk", client_key="personas/9/p.png",
213
+ )
214
+ assert seen["initiate"]["app"] == "fluidtalk"
215
+ assert seen["complete"]["app"] == "fluidtalk"
216
+ assert seen["complete"]["client_key"] == "personas/9/p.png"
217
+ assert asset.source_app == "fluidtalk"
218
+ assert asset.client_key == "personas/9/p.png"
219
+
220
+
221
+ def test_files_resolve_by_client_key():
222
+ def handler(request):
223
+ assert request.url.path == "/api/v1/files/resolve"
224
+ assert dict(request.url.params)["client_key"] == "personas/9/p.png"
225
+ assert dict(request.url.params)["app"] == "fluidtalk"
226
+ return httpx.Response(200, json={"id": "f1", "original_name": "p.png", "client_key": "personas/9/p.png"})
227
+
228
+ f = _client(handler).files.resolve("personas/9/p.png", app="fluidtalk")
229
+ assert f.id == "f1"
230
+
231
+
232
+ def test_exchange_service_token_and_as_user():
233
+ seen = {}
234
+
235
+ def handler(request):
236
+ p = request.url.path
237
+ if p == "/api/v1/auth/service-token":
238
+ seen["exchange_body"] = _body(request)
239
+ seen["exchange_auth"] = request.headers.get("x-api-key")
240
+ return httpx.Response(200, json={
241
+ "token": "fcd_USERTOKEN", "expires_in": 3600,
242
+ "tenant_id": "tenant-1", "user_id": "user-9", "app": "fluidtalk",
243
+ })
244
+ if p == "/api/v1/spaces":
245
+ # The as_user client must authenticate with the fcd_ token as Bearer.
246
+ seen["asuser_bearer"] = request.headers.get("authorization")
247
+ seen["asuser_xapikey"] = request.headers.get("x-api-key")
248
+ return httpx.Response(200, json=[])
249
+ return httpx.Response(404, json={"detail": "unexpected"})
250
+
251
+ fc = _client(handler, api_key="fck_live_svc")
252
+ tok = fc.exchange_service_token("user.jwt", "fluidtalk", job_id="job-1")
253
+ assert tok.token == "fcd_USERTOKEN"
254
+ assert tok.tenant_id == "tenant-1"
255
+ assert seen["exchange_auth"] == "fck_live_svc" # service key auths the exchange
256
+ assert seen["exchange_body"]["user_jwt"] == "user.jwt"
257
+ assert seen["exchange_body"]["job_id"] == "job-1"
258
+
259
+ # as_user() returns a client bound to the delegation token.
260
+ user_client = fc.as_user("user.jwt", "fluidtalk")
261
+ user_client.spaces.list()
262
+ assert seen["asuser_bearer"] == "Bearer fcd_USERTOKEN"
263
+ assert seen["asuser_xapikey"] is None
@@ -0,0 +1,56 @@
1
+ """Live integration test — skipped unless FLUIDCLOUD_API_KEY is set.
2
+
3
+ Run against a real FluidCloud stack:
4
+
5
+ FLUIDCLOUD_API_KEY=fck_live_... \
6
+ FLUIDCLOUD_BASE_URL=https://api-green-cloud.fluidvip.com \
7
+ pytest tests/test_e2e_green.py -v
8
+
9
+ It uploads a tiny PNG, publishes a permanent link, fetches that hotlink (hitting
10
+ the share Worker) and checks the bytes, then cleans up.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import urllib.request
17
+
18
+ import pytest
19
+
20
+ from fluidcloud import FluidCloud
21
+
22
+ API_KEY = os.environ.get("FLUIDCLOUD_API_KEY")
23
+ BASE_URL = os.environ.get("FLUIDCLOUD_BASE_URL", "https://api-green-cloud.fluidvip.com")
24
+
25
+ pytestmark = pytest.mark.skipif(
26
+ not API_KEY, reason="set FLUIDCLOUD_API_KEY to run the live integration test"
27
+ )
28
+
29
+ # A 1x1 PNG (valid magic bytes so the server-side scan allowlists it).
30
+ PNG = bytes(
31
+ [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48,
32
+ 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4, 0x89,
33
+ 0, 0, 0, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x62, 0, 1, 0, 0, 5, 0,
34
+ 1, 0x0D, 0x0A, 0x2D, 0xB4, 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42,
35
+ 0x60, 0x82]
36
+ )
37
+
38
+
39
+ def test_live_upload_public_fetch_delete():
40
+ fc = FluidCloud(api_key=API_KEY, base_url=BASE_URL)
41
+ try:
42
+ space_id = fc.spaces.list()[0].id
43
+ asset = fc.files.upload(PNG, space_id=space_id, name="ci_e2e.png", public=True)
44
+ assert asset.scan_status == "clean"
45
+ assert asset.public_url
46
+
47
+ req = urllib.request.Request(asset.public_url, headers={"User-Agent": "Mozilla/5.0"})
48
+ resp = urllib.request.urlopen(req, timeout=40)
49
+ assert resp.status == 200
50
+ assert resp.read() == PNG
51
+ assert resp.headers.get("content-type") == "image/png"
52
+
53
+ assert any(f.id == asset.id for f in fc.files.list(space_id))
54
+ fc.files.delete(asset.id)
55
+ finally:
56
+ fc.close()