flowpydisk 0.0.1__py3-none-any.whl

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.
flowpydisk/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Python client for the FlowpyDisk API.
2
+
3
+ from flowpydisk import FlowpyDisk
4
+
5
+ client = FlowpyDisk(api_key="fd_…")
6
+ for f in client.paginate("storage/files"):
7
+ print(f["name"])
8
+
9
+ The shape and the reasoning behind every choice here are in the
10
+ FlowpyDisk SDK design plan.
11
+
12
+ **What is built:** the transport (C2) - auth, the error taxonomy, retries,
13
+ pagination; the generated operations (C3), one namespace per tag in the spec
14
+ (`client.storage.list_files()`, `client.tables.rows_tables(...)`); and the
15
+ hand-written layer over them (C4) - `client.files.upload(path)`, the three-step
16
+ upload as one call, and `client.index`. Everything a caller imports is
17
+ re-exported from this file, so `_generated` stays private and a regeneration
18
+ cannot change what a caller imports.
19
+ """
20
+
21
+ from ._client import DEFAULT_BASE_URL, FlowpyDisk
22
+ from ._errors import (
23
+ AuthError,
24
+ FlowpyDiskError,
25
+ InvalidRequest,
26
+ LimitExceeded,
27
+ NotFound,
28
+ Throttled,
29
+ )
30
+ from .files import Files
31
+ from .inbound_webhooks import sign_inbound_webhook
32
+ from .webhooks import SIGNATURE_HEADER, verify_webhook
33
+
34
+ __version__ = "0.0.1"
35
+
36
+ __all__ = [
37
+ "DEFAULT_BASE_URL",
38
+ "SIGNATURE_HEADER",
39
+ "AuthError",
40
+ "Files",
41
+ "FlowpyDisk",
42
+ "FlowpyDiskError",
43
+ "InvalidRequest",
44
+ "LimitExceeded",
45
+ "NotFound",
46
+ "Throttled",
47
+ "__version__",
48
+ "sign_inbound_webhook",
49
+ "verify_webhook",
50
+ ]
flowpydisk/_client.py ADDED
@@ -0,0 +1,266 @@
1
+ """The transport: auth, errors, retries, pagination.
2
+
3
+ SDK design plan, sections 3 and 4. Everything a generated
4
+ operation needs and cannot have. The generated layer (C3) will call
5
+ ``client.request(...)``; nothing below it knows about any particular endpoint,
6
+ which is why this file can be finished before the generator is chosen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+ from urllib.parse import urljoin
15
+
16
+ import httpx
17
+
18
+ from ._errors import BY_STATUS, FlowpyDiskError, Throttled
19
+ from ._generated.operations import ALIASES, NAMESPACES
20
+ from .files import Files
21
+ from .index import Index
22
+
23
+ DEFAULT_BASE_URL = "https://app.flowpydisk.com"
24
+
25
+ #: Verbs that may be retried. A retried POST is a duplicate row: this product
26
+ #: has no idempotency keys, so "the connection dropped" and "it was created and
27
+ #: the response was lost" are indistinguishable from here. DELETE is in because
28
+ #: deleting twice is the same state as deleting once; POST never is.
29
+ RETRYABLE_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
30
+
31
+ #: 5xx and 429 only. A 4xx will be refused identically however many times it is
32
+ #: sent, and retrying it just makes the caller wait to see their own mistake.
33
+ RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
34
+
35
+ DEFAULT_RETRIES = 2
36
+ DEFAULT_TIMEOUT = 30.0
37
+
38
+
39
+ class FlowpyDisk:
40
+ """A client for the one space its API key was made in.
41
+
42
+ client = FlowpyDisk(api_key="fd_…")
43
+
44
+ A key acts in its own space and nowhere else: a key made in your personal
45
+ space acts there, a key made in a workspace acts in that workspace. To act
46
+ in a workspace, mint a key in that workspace (Settings > API Keys).
47
+
48
+ There is no ``workspace`` argument (removed 2026-11-10, M3762). It sent a
49
+ ``Workspace-Id`` header, and the server ignores that header for every API
50
+ key, so a caller who passed it was told they acted in a workspace while
51
+ acting in the key's own space.
52
+
53
+ **There is no session-cookie mode**, and the absence is a decision rather
54
+ than an omission - see SDK_PLAN §3. A program authenticates with an API key
55
+ minted in Settings.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ api_key: str,
61
+ *,
62
+ base_url: str = DEFAULT_BASE_URL,
63
+ timeout: float = DEFAULT_TIMEOUT,
64
+ retries: int = DEFAULT_RETRIES,
65
+ transport: httpx.BaseTransport | None = None,
66
+ ) -> None:
67
+ if not api_key:
68
+ raise ValueError("api_key is required. Mint one in Settings > API Keys.")
69
+ self.base_url = base_url.rstrip("/") + "/"
70
+ self.retries = retries
71
+ headers = {
72
+ "Authorization": f"Bearer {api_key}",
73
+ "Accept": "application/json",
74
+ "User-Agent": "flowpydisk-python/0.0.1",
75
+ }
76
+ self._http = httpx.Client(headers=headers, timeout=timeout, transport=transport)
77
+ # One attribute per tag in the spec: client.storage, client.tables, …
78
+ # Built from the generated map rather than written out, so a new tag
79
+ # arrives with the regeneration and no edit here - which is the whole
80
+ # point of the layer being generated.
81
+ for attr, cls in NAMESPACES.items():
82
+ setattr(self, attr, cls(self))
83
+ # A renamed tag keeps its older name as the same object, so
84
+ # `client.ai_units is client.credits` (M4030). No warning: the older
85
+ # name is promised, and the README says which one is current.
86
+ for older, now in ALIASES.items():
87
+ setattr(self, older, getattr(self, now))
88
+ # The hand-written layer, over the top. `files` deliberately SHADOWS
89
+ # nothing: the generated storage namespace keeps every endpoint, and
90
+ # this adds the one call that is several endpoints (SDK_PLAN §4).
91
+ self.files = Files(self)
92
+ # "Index this file" for held files, one selector at a time (P7).
93
+ self.index = Index(self)
94
+
95
+ # -- lifecycle ---------------------------------------------------------
96
+
97
+ def close(self) -> None:
98
+ self._http.close()
99
+
100
+ def __enter__(self) -> FlowpyDisk:
101
+ return self
102
+
103
+ def __exit__(self, *exc: object) -> None:
104
+ self.close()
105
+
106
+ # -- the one call everything else is built on --------------------------
107
+
108
+ def request(self, method: str, path: str, **kw: Any) -> Any:
109
+ """Send one request, raise on refusal, return the decoded body.
110
+
111
+ ``path`` is relative to the API root (``storage/files``), so a
112
+ generated operation never has to know the deployment's hostname.
113
+ """
114
+ url = urljoin(self.base_url, f"api/v1/{path.lstrip('/')}")
115
+ method = method.upper()
116
+ attempts = self.retries + 1 if method in RETRYABLE_METHODS else 1
117
+
118
+ last: httpx.Response | None = None
119
+ for attempt in range(attempts):
120
+ response = self._http.request(method, url, **kw)
121
+ last = response
122
+ if response.status_code not in RETRYABLE_STATUS or attempt == attempts - 1:
123
+ break
124
+ time.sleep(_backoff(response, attempt))
125
+
126
+ assert last is not None # noqa: S101 - the loop runs at least once
127
+ if last.status_code >= 400:
128
+ self._raise(last)
129
+ if last.status_code == 204 or not last.content:
130
+ return None
131
+ # A picture the API streams (the marketplace preview's pages, reduced
132
+ # images and posters, M1008) is its bytes, not JSON.
133
+ if last.headers.get("content-type", "").startswith("image/"):
134
+ return last.content
135
+ return last.json()
136
+
137
+ def get(self, path: str, **kw: Any) -> Any:
138
+ return self.request("GET", path, **kw)
139
+
140
+ def post(self, path: str, **kw: Any) -> Any:
141
+ return self.request("POST", path, **kw)
142
+
143
+ def patch(self, path: str, **kw: Any) -> Any:
144
+ return self.request("PATCH", path, **kw)
145
+
146
+ def delete(self, path: str, **kw: Any) -> Any:
147
+ return self.request("DELETE", path, **kw)
148
+
149
+ def blob_put(self, path: str, **kw: Any) -> httpx.Response:
150
+ """PUT to a same-origin blob path, with this client's headers.
151
+
152
+ For the RELATIVE presigned URLs a dev backend hands back
153
+ (`/api/v1/storage/_dev/blob/…`). They are paths on our own origin and
154
+ need the Bearer header; an absolute R2 link must not get it, and
155
+ `files._blob_put` is where that fork lives. Raw response, not decoded:
156
+ the caller wants the ETag header, and a blob PUT has no JSON body.
157
+ """
158
+ return self._http.put(urljoin(self.base_url, path.lstrip("/")), **kw)
159
+
160
+ # -- pagination --------------------------------------------------------
161
+
162
+ def paginate(self, path: str, *, limit: int = 200, **kw: Any) -> Iterator[dict]:
163
+ """Every row of a paged listing, one at a time.
164
+
165
+ Safe to offer only since the ordering tiebreak landed on 2026-09-14,
166
+ and it is worth knowing why. These listings ordered by ``-created_at``
167
+ with no tiebreak, so two rows sharing a timestamp had no defined order
168
+ between pages: walking the list could return one of them twice and the
169
+ other never, and the "never" half was invisible to every client.
170
+ ``-id`` breaks the tie on all six paged listings now, so a walk is a
171
+ walk.
172
+
173
+ The entry is M17 of the mobile app requirements, not of the findings
174
+ register, which has its own unrelated M17.
175
+ """
176
+ offset = 0
177
+ params = dict(kw.pop("params", {}) or {})
178
+ while True:
179
+ page = self.request("GET", path, params={**params, "limit": limit, "offset": offset}, **kw)
180
+ rows = page if isinstance(page, list) else page.get("results", [])
181
+ yield from rows
182
+ if len(rows) < limit:
183
+ return
184
+ offset += limit
185
+
186
+ # -- errors ------------------------------------------------------------
187
+
188
+ def _raise(self, response: httpx.Response) -> None:
189
+ code: str | None = None
190
+ detail: Any = None
191
+ try:
192
+ body = response.json()
193
+ except ValueError:
194
+ # A proxy's HTML 502, a gateway timeout page. There is no envelope
195
+ # to read, and inventing a code for it would make `.code` a value
196
+ # the backend never sent.
197
+ body = None
198
+ if isinstance(body, dict):
199
+ code = body.get("code")
200
+ detail = body.get("detail", body.get("error", body))
201
+
202
+ message = _readable(detail) or response.reason_phrase or f"HTTP {response.status_code}"
203
+ cls = BY_STATUS.get(response.status_code, FlowpyDiskError)
204
+
205
+ kw: dict[str, Any] = {
206
+ "status": response.status_code,
207
+ "code": code,
208
+ "detail": detail,
209
+ "response": response,
210
+ }
211
+ if cls is Throttled:
212
+ raise Throttled(message, retry_after=_retry_after(response), **kw)
213
+ raise cls(message, **kw)
214
+
215
+
216
+ def _readable(detail: Any) -> str:
217
+ """A sentence, from whichever shape the backend used.
218
+
219
+ Three shapes reach here and only one of them reads well on its own:
220
+
221
+ {"detail": "File exceeds the 20 GB per-file limit."} -> the string
222
+ {"name": ["Ensure this field has no more than 255…"]} -> DRF's field errors
223
+ {} -> nothing at all
224
+
225
+ The middle one is a serializer refusal, and it is common - the quickstart
226
+ hit it on its first real run against a live server. Left alone it reaches
227
+ the caller as `{'name': ['Ensure this field has…']}`, a dict repr in an
228
+ exception message, which is the kind of thing that makes an SDK feel like
229
+ a thin wrapper over `requests`. Flattened it is "name: Ensure this field
230
+ has no more than 255 characters." and needs no explanation.
231
+ """
232
+ if not detail:
233
+ return ""
234
+ if isinstance(detail, str):
235
+ return detail
236
+ if isinstance(detail, list):
237
+ return "; ".join(_readable(d) for d in detail if d)
238
+ if isinstance(detail, dict):
239
+ return "; ".join(f"{field}: {_readable(problem)}" for field, problem in detail.items() if problem)
240
+ return str(detail)
241
+
242
+
243
+ def _retry_after(response: httpx.Response) -> float | None:
244
+ raw = response.headers.get("Retry-After")
245
+ if not raw:
246
+ return None
247
+ try:
248
+ return float(raw)
249
+ except ValueError:
250
+ # The header also allows an HTTP-date. Rare here, and a wrong number is
251
+ # worse than none: a caller who sleeps on a misparsed date sleeps for
252
+ # the wrong length and blames the SDK.
253
+ return None
254
+
255
+
256
+ def _backoff(response: httpx.Response, attempt: int) -> float:
257
+ """The server's number if it gave one, otherwise 0.5s, 1s, 2s.
258
+
259
+ `Retry-After` wins over the schedule, always. The server knows when its
260
+ window reopens and the client is guessing; ignoring it is how a client
261
+ retries into the same 429 three times and calls the API flaky.
262
+ """
263
+ told = _retry_after(response)
264
+ if told is not None:
265
+ return told
266
+ return 0.5 * (2**attempt)
flowpydisk/_errors.py ADDED
@@ -0,0 +1,118 @@
1
+ """Five classes, and every error code the backend has.
2
+
3
+ SDK design plan, section 5. The worklist's C5 row asks for
4
+ "quota, size cap, scoped-key not-found, throttles, each with its own exception
5
+ and message". Measured against the tree on 2026-09-15, the backend returns
6
+ **67 distinct ``code`` values**, and sixty-seven exception classes is a
7
+ taxonomy nobody can hold in their head and an ``except`` clause nobody can
8
+ write.
9
+
10
+ So the split is by **what you can do about it**, and the exact code survives
11
+ on ``.code``:
12
+
13
+ except Throttled as e: # back off, e.retry_after says how long
14
+ except LimitExceeded as e: # ask for more room, or send less
15
+ if e.code == "file_too_large": … # ...or handle exactly this one
16
+
17
+ Nothing is swallowed. ``.status``, ``.code``, ``.detail`` and the response
18
+ itself are all on the exception, so a caller who needs something this module
19
+ did not anticipate still has it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+
27
+ class FlowpyDiskError(Exception):
28
+ """Anything the API refused, plus what it said about it.
29
+
30
+ ``code`` is the backend's own string (``file_too_large``, ``no_workspace``,
31
+ ``kind_not_publishable``, …) and is the value to branch on. It is ``None``
32
+ only when a response carried no envelope at all - a proxy's 502, say - and
33
+ that is worth distinguishing from an empty string, which would read as "the
34
+ backend sent a code and it was blank".
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ *,
41
+ status: int | None = None,
42
+ code: str | None = None,
43
+ detail: Any = None,
44
+ response: Any = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+ self.status = status
48
+ self.code = code
49
+ self.detail = detail
50
+ self.response = response
51
+
52
+ def __str__(self) -> str:
53
+ base = super().__str__()
54
+ # The code in the repr, always. A traceback that says only "Not found"
55
+ # sends somebody to the wrong problem; one that says
56
+ # "Not found (code=folder_not_found, status=404)" does not.
57
+ bits = [f"status={self.status}" if self.status else "", f"code={self.code}" if self.code else ""]
58
+ suffix = ", ".join(b for b in bits if b)
59
+ return f"{base} ({suffix})" if suffix else base
60
+
61
+
62
+ class AuthError(FlowpyDiskError):
63
+ """401 and 403. The key is wrong, expired, or not allowed to do this."""
64
+
65
+
66
+ class NotFound(FlowpyDiskError):
67
+ """404. The object does not exist, or is outside the space this key acts
68
+ in: the server answers not-found rather than forbidden, on purpose,
69
+ because forbidden would confirm the thing exists.
70
+
71
+ The client once asked the server whether a folder-scoped key was behind
72
+ a 404 and named the folder. Folder keys are retired, so it no longer asks
73
+ (M2893).
74
+ """
75
+
76
+
77
+ class InvalidRequest(FlowpyDiskError):
78
+ """400 and 422. The call itself was wrong - a bad filter, an unknown kind."""
79
+
80
+
81
+ class LimitExceeded(FlowpyDiskError):
82
+ """402 and 413. Quota, the per-file cap, a plan limit.
83
+
84
+ Distinct from InvalidRequest because the remedy is different in kind: the
85
+ call was well formed and the account cannot currently accommodate it.
86
+ """
87
+
88
+
89
+ class Throttled(FlowpyDiskError):
90
+ """429. ``retry_after`` is seconds, when the response said so."""
91
+
92
+ def __init__(self, message: str, *, retry_after: float | None = None, **kw: Any) -> None:
93
+ super().__init__(message, **kw)
94
+ self.retry_after = retry_after
95
+
96
+
97
+ #: Status -> class. Anything unmapped raises the base class rather than being
98
+ #: forced into the nearest one: a 418 or a 502 is not an InvalidRequest, and
99
+ #: saying so wrongly is worse than saying nothing.
100
+ BY_STATUS: dict[int, type[FlowpyDiskError]] = {
101
+ 400: InvalidRequest,
102
+ 401: AuthError,
103
+ 402: LimitExceeded,
104
+ 403: AuthError,
105
+ 404: NotFound,
106
+ 413: LimitExceeded,
107
+ 422: InvalidRequest,
108
+ 429: Throttled,
109
+ }
110
+
111
+ __all__ = [
112
+ "AuthError",
113
+ "FlowpyDiskError",
114
+ "InvalidRequest",
115
+ "LimitExceeded",
116
+ "NotFound",
117
+ "Throttled",
118
+ ]
File without changes