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.
- fluidcloud-0.1.0/.gitignore +41 -0
- fluidcloud-0.1.0/PKG-INFO +94 -0
- fluidcloud-0.1.0/README.md +79 -0
- fluidcloud-0.1.0/fluidcloud/__init__.py +40 -0
- fluidcloud-0.1.0/fluidcloud/client.py +420 -0
- fluidcloud-0.1.0/fluidcloud/errors.py +62 -0
- fluidcloud-0.1.0/fluidcloud/models.py +125 -0
- fluidcloud-0.1.0/pyproject.toml +24 -0
- fluidcloud-0.1.0/tests/test_client.py +263 -0
- fluidcloud-0.1.0/tests/test_e2e_green.py +56 -0
|
@@ -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()
|