icedcoffee-sync 0.1.0.dev5__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.
- icedcoffee_sync/__init__.py +3 -0
- icedcoffee_sync/apply/__init__.py +6 -0
- icedcoffee_sync/apply/diff.py +522 -0
- icedcoffee_sync/apply/execute.py +180 -0
- icedcoffee_sync/apply/model.py +45 -0
- icedcoffee_sync/apply/order.py +129 -0
- icedcoffee_sync/apply/park.py +107 -0
- icedcoffee_sync/apply/prune.py +215 -0
- icedcoffee_sync/erpnext/__init__.py +1 -0
- icedcoffee_sync/erpnext/accounts.py +183 -0
- icedcoffee_sync/erpnext/comment.py +62 -0
- icedcoffee_sync/erpnext/company.py +102 -0
- icedcoffee_sync/erpnext/documents.py +179 -0
- icedcoffee_sync/erpnext/ensure.py +34 -0
- icedcoffee_sync/erpnext/purchase.py +260 -0
- icedcoffee_sync/erpnext/webhook.py +137 -0
- icedcoffee_sync/frappe/__init__.py +0 -0
- icedcoffee_sync/frappe/access.py +379 -0
- icedcoffee_sync/frappe/client.py +539 -0
- icedcoffee_sync/frappe/errors.py +201 -0
- icedcoffee_sync/frappe/lifecycle.py +30 -0
- icedcoffee_sync/frappe/request.py +145 -0
- icedcoffee_sync/frappe/response.py +76 -0
- icedcoffee_sync/py.typed +0 -0
- icedcoffee_sync/site.py +65 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/METADATA +9 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/RECORD +29 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/WHEEL +4 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Synchronous and asynchronous Frappe clients.
|
|
2
|
+
|
|
3
|
+
Neither wraps the other. Request construction and response parsing are pure
|
|
4
|
+
functions shared by both, so only the send differs. A synchronous facade over
|
|
5
|
+
the async client would need `asyncio.run()`, which fails inside a marimo
|
|
6
|
+
notebook -- and notebooks are a first-class consumer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from collections.abc import AsyncIterator, Iterator
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
import httpx2
|
|
14
|
+
|
|
15
|
+
from icedcoffee_sync.frappe import request as req
|
|
16
|
+
from icedcoffee_sync.frappe.lifecycle import SubmitNotPermittedError
|
|
17
|
+
from icedcoffee_sync.frappe.response import envelope, has_next_page, unwrap
|
|
18
|
+
|
|
19
|
+
# Both clients define a public `list` method, which shadows the builtin
|
|
20
|
+
# `list` type within their class bodies under Python 3.14's lazy annotation
|
|
21
|
+
# evaluation (PEP 649): any method annotated with a bare `list[...]` after
|
|
22
|
+
# that point resolves it to the method, not the type, and blows up the first
|
|
23
|
+
# time something evaluates the annotation. A module-level alias sidesteps it.
|
|
24
|
+
_Rows = list[dict[str, Any]]
|
|
25
|
+
|
|
26
|
+
# Whitelisted server methods that commit a document to the ledger (move it
|
|
27
|
+
# off docstatus 0). `call()` is a generic passthrough to any dotted server
|
|
28
|
+
# method, so without this it would be a way around `submit`/`cancel` entirely
|
|
29
|
+
# -- the guard has to sit on the method name, not just the friendly wrapper.
|
|
30
|
+
#
|
|
31
|
+
# This is a guardrail against an accidental submission from client code, not
|
|
32
|
+
# a sandbox: the list can't be exhaustive, since a Frappe app can whitelist
|
|
33
|
+
# its own committing methods this client has no way to know about. The real
|
|
34
|
+
# boundary is the API key's role permissions on the server.
|
|
35
|
+
_COMMITTING_METHODS = frozenset({
|
|
36
|
+
"frappe.client.submit",
|
|
37
|
+
"frappe.client.cancel",
|
|
38
|
+
# Applies a workflow action, which calls doc.submit() when the action
|
|
39
|
+
# moves a draft to a submitted state.
|
|
40
|
+
"frappe.model.workflow.apply_workflow",
|
|
41
|
+
# Dotted-path twins of insert/update, with the same docstatus mechanics
|
|
42
|
+
# guarded by `_require_no_docstatus` below.
|
|
43
|
+
"frappe.client.insert",
|
|
44
|
+
"frappe.client.save",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileDownloadError(RuntimeError):
|
|
49
|
+
"""A site file could not be fetched, or was larger than the caller allows."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_FILE_PREFIXES = ("/files/", "/private/files/")
|
|
53
|
+
|
|
54
|
+
# A scheme's port when a URL names none explicitly. httpx2.URL already
|
|
55
|
+
# collapses an *explicit* default (":443" on an https URL) down to `None`
|
|
56
|
+
# for `.port`, so comparing `.port` alone already treats an implicit and an
|
|
57
|
+
# explicit default the same -- this table is what makes that comparison
|
|
58
|
+
# explicit and independent of that normalization, rather than relying on it.
|
|
59
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _effective_port(url: httpx2.URL) -> int | None:
|
|
63
|
+
"""`url`'s port, or its scheme's default when none was given -- so an implicit and an explicit default port compare equal."""
|
|
64
|
+
return url.port if url.port is not None else _DEFAULT_PORTS.get(url.scheme)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validate_file_url(file_url: str, site_url: str) -> None:
|
|
68
|
+
"""Validate a file_url before downloading.
|
|
69
|
+
|
|
70
|
+
Rejects paths with query strings, fragments, backslashes, or dot-segments
|
|
71
|
+
(both literal and percent-encoded), then builds the full URL and checks
|
|
72
|
+
that its normalized scheme, host, port, and path are safe.
|
|
73
|
+
"""
|
|
74
|
+
# The raw file_url must itself start with an allowed prefix -- before it
|
|
75
|
+
# is glued onto site_url and parsed. Without this, a file_url like
|
|
76
|
+
# ":8443/files/x" has no leading "/" at all: concatenated onto
|
|
77
|
+
# "https://site.example" it becomes "https://site.example:8443/files/x",
|
|
78
|
+
# a PORT change rather than a path. The *parsed* URL's host still matches
|
|
79
|
+
# and its path still starts with "/files/", so the checks below alone
|
|
80
|
+
# would wave it through onto a different origin at the same hostname.
|
|
81
|
+
if not file_url.startswith(_FILE_PREFIXES):
|
|
82
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
83
|
+
|
|
84
|
+
# Reject obviously bad characters before parsing
|
|
85
|
+
if "?" in file_url or "#" in file_url or "\\" in file_url:
|
|
86
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
87
|
+
|
|
88
|
+
# Reject literal dot-segments like /.. or /./ as path components
|
|
89
|
+
# (before URL parsing, to catch them before normalization)
|
|
90
|
+
parts = file_url.split("/")
|
|
91
|
+
if "." in parts or ".." in parts or any("%2e" in part for part in parts):
|
|
92
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
93
|
+
|
|
94
|
+
# Build the full URL and parse it
|
|
95
|
+
full_url = f"{site_url.rstrip('/')}{file_url}"
|
|
96
|
+
url = httpx2.URL(full_url)
|
|
97
|
+
|
|
98
|
+
# Extract the site's host from its URL
|
|
99
|
+
site_url_obj = httpx2.URL(site_url)
|
|
100
|
+
|
|
101
|
+
# Check scheme, host, and effective port all match the configured site.
|
|
102
|
+
# Host alone is not enough: an off-port file_url like ":8443/files/x"
|
|
103
|
+
# (caught above already, but the check stays here as the real boundary
|
|
104
|
+
# the request itself crosses) leaves the host unchanged while sending
|
|
105
|
+
# the credential to a different origin.
|
|
106
|
+
if (
|
|
107
|
+
url.scheme != site_url_obj.scheme
|
|
108
|
+
or url.host != site_url_obj.host
|
|
109
|
+
or _effective_port(url) != _effective_port(site_url_obj)
|
|
110
|
+
):
|
|
111
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
112
|
+
|
|
113
|
+
# Check normalized path starts with allowed prefixes and has no dot-segments
|
|
114
|
+
path = url.path
|
|
115
|
+
if not path.startswith(_FILE_PREFIXES):
|
|
116
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
117
|
+
|
|
118
|
+
# Check for dot-segments in the normalized path (catches %2e%2e that gets decoded)
|
|
119
|
+
path_parts = path.split("/")
|
|
120
|
+
if "." in path_parts or ".." in path_parts:
|
|
121
|
+
raise ValueError(f"not a site file path: {file_url!r}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclasses.dataclass(frozen=True)
|
|
125
|
+
class SiteConfig:
|
|
126
|
+
"""Where a Frappe site is and how to authenticate to it.
|
|
127
|
+
|
|
128
|
+
The URL is always supplied by the caller. Nothing here defaults to a
|
|
129
|
+
production host.
|
|
130
|
+
|
|
131
|
+
`may_submit` is off by default: a client must be deliberately constructed
|
|
132
|
+
with the capability before `submit`/`cancel`, a `call` naming a known
|
|
133
|
+
committing method, or an `insert`/`update` whose document body sets
|
|
134
|
+
`docstatus`, will run. This is a guardrail against an accidental
|
|
135
|
+
submission from client code, not a security boundary -- the real
|
|
136
|
+
boundary is the API key's role permissions on the server.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
url: str
|
|
140
|
+
# Kept out of the generated repr: a dataclass prints every field, and this
|
|
141
|
+
# object turns up in log lines, tracebacks, and failing pytest assertions.
|
|
142
|
+
# A reusable Frappe credential should not ride along in any of them.
|
|
143
|
+
api_key: str = dataclasses.field(repr=False)
|
|
144
|
+
api_secret: str = dataclasses.field(repr=False)
|
|
145
|
+
timeout: float = 30.0
|
|
146
|
+
may_submit: bool = False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _reject_reserved_pagination_kwargs(kwargs: dict[str, Any]) -> None:
|
|
150
|
+
"""Refuse query options `iter_all` supplies itself.
|
|
151
|
+
|
|
152
|
+
`iter_all` owns `limit` and `start` -- it walks the pages. Forwarding a
|
|
153
|
+
caller's copy alongside its own produced a duplicate-keyword `TypeError`
|
|
154
|
+
from deep inside the pager, which tells the caller nothing about what they
|
|
155
|
+
did wrong. `page_size` is the knob they wanted.
|
|
156
|
+
"""
|
|
157
|
+
reserved = sorted(k for k in ("limit", "start") if k in kwargs)
|
|
158
|
+
if reserved:
|
|
159
|
+
raise TypeError(f"iter_all controls {', '.join(reserved)}; use page_size to size each request")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_positive_page_size(page_size: int) -> None:
|
|
163
|
+
"""Refuse a page size that cannot page.
|
|
164
|
+
|
|
165
|
+
A zero or negative size asks the server for nothing, and the empty result
|
|
166
|
+
ends the loop on its first pass -- so `iter_all` returns silently having
|
|
167
|
+
fetched no documents, which reads exactly like an empty doctype.
|
|
168
|
+
"""
|
|
169
|
+
if page_size < 1:
|
|
170
|
+
raise ValueError(f"page_size must be at least 1, got {page_size}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _merge_headers(config: SiteConfig, request: req.Request) -> dict[str, str]:
|
|
174
|
+
"""Caller headers, then the credential, which nothing may displace.
|
|
175
|
+
|
|
176
|
+
Dropped case-insensitively: HTTP header names are case-insensitive, so a
|
|
177
|
+
request carrying `authorization` would otherwise sit alongside the real
|
|
178
|
+
`Authorization` rather than being overridden by it -- two credentials on
|
|
179
|
+
one request, with the server free to pick either.
|
|
180
|
+
"""
|
|
181
|
+
headers = {"Accept": "application/json"}
|
|
182
|
+
headers.update({k: v for k, v in request.headers.items() if k.lower() != "authorization"})
|
|
183
|
+
headers.update(req.auth_header(config.api_key, config.api_secret))
|
|
184
|
+
return headers
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _send_args(config: SiteConfig, request: req.Request) -> dict[str, Any]:
|
|
188
|
+
return {
|
|
189
|
+
"method": request.method,
|
|
190
|
+
"url": f"{config.url.rstrip('/')}{request.path}",
|
|
191
|
+
"params": request.params or None,
|
|
192
|
+
"headers": _merge_headers(config, request),
|
|
193
|
+
"json": request.json_body,
|
|
194
|
+
"data": request.data,
|
|
195
|
+
"files": request.files,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Client:
|
|
200
|
+
"""Synchronous Frappe client."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, config: SiteConfig, *, transport: httpx2.BaseTransport | None = None) -> None:
|
|
203
|
+
self._config = config
|
|
204
|
+
self._http = httpx2.Client(timeout=config.timeout, transport=transport)
|
|
205
|
+
|
|
206
|
+
def __enter__(self) -> Self:
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
def __exit__(self, *exc: object) -> None:
|
|
210
|
+
self.close()
|
|
211
|
+
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
"""Release the underlying connection pool."""
|
|
214
|
+
self._http.close()
|
|
215
|
+
|
|
216
|
+
def _run(self, request: req.Request) -> Any:
|
|
217
|
+
response = self._http.request(**_send_args(self._config, request))
|
|
218
|
+
return unwrap(response.status_code, response.text)
|
|
219
|
+
|
|
220
|
+
def list(self, doctype: str, **kwargs: Any) -> Any:
|
|
221
|
+
"""List documents of a doctype."""
|
|
222
|
+
return self._run(req.list_documents(doctype, **kwargs))
|
|
223
|
+
|
|
224
|
+
def get(self, doctype: str, name: str) -> Any:
|
|
225
|
+
"""Fetch one document by name."""
|
|
226
|
+
return self._run(req.get_document(doctype, name))
|
|
227
|
+
|
|
228
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any:
|
|
229
|
+
"""Create a document.
|
|
230
|
+
|
|
231
|
+
Refuses locally, before anything is sent, if `doc` sets `docstatus`
|
|
232
|
+
and this client lacks submit capability: Frappe's own insert path
|
|
233
|
+
(`Document.check_docstatus_transition`) submits a document created
|
|
234
|
+
with `docstatus: 1` directly, with no separate `submit()` call.
|
|
235
|
+
"""
|
|
236
|
+
self._require_no_docstatus(doc)
|
|
237
|
+
return self._run(req.insert_document(doctype, doc))
|
|
238
|
+
|
|
239
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any:
|
|
240
|
+
"""Update a document.
|
|
241
|
+
|
|
242
|
+
Refuses locally, before anything is sent, if `doc` sets `docstatus`
|
|
243
|
+
and this client lacks submit capability -- same mechanism as
|
|
244
|
+
`insert`.
|
|
245
|
+
"""
|
|
246
|
+
self._require_no_docstatus(doc)
|
|
247
|
+
return self._run(req.update_document(doctype, name, doc))
|
|
248
|
+
|
|
249
|
+
def delete(self, doctype: str, name: str) -> Any:
|
|
250
|
+
"""Delete a document."""
|
|
251
|
+
return self._run(req.delete_document(doctype, name))
|
|
252
|
+
|
|
253
|
+
def upload_file(
|
|
254
|
+
self,
|
|
255
|
+
doctype: str,
|
|
256
|
+
docname: str,
|
|
257
|
+
filename: str,
|
|
258
|
+
content: bytes,
|
|
259
|
+
*,
|
|
260
|
+
is_private: bool = True,
|
|
261
|
+
folder: str | None = None,
|
|
262
|
+
) -> Any:
|
|
263
|
+
"""Attach a file to a document, returning the created `File` document.
|
|
264
|
+
|
|
265
|
+
Takes the content as bytes rather than a path -- the common caller
|
|
266
|
+
already has it in memory (an email attachment, a generated PDF), and
|
|
267
|
+
routing that through a temp file first would be pure ceremony.
|
|
268
|
+
Private by default: an attachment is financial evidence more often
|
|
269
|
+
than not, and a public URL is the wrong default for that.
|
|
270
|
+
"""
|
|
271
|
+
return self._run(req.upload_file(doctype, docname, filename, content, is_private=is_private, folder=folder))
|
|
272
|
+
|
|
273
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any:
|
|
274
|
+
"""Call a whitelisted server method.
|
|
275
|
+
|
|
276
|
+
Refuses locally, before anything is sent, when `dotted_path` names a
|
|
277
|
+
known committing method (e.g. `frappe.client.submit`) and this client
|
|
278
|
+
lacks submit capability -- `submit`/`cancel` are sugar over this, and
|
|
279
|
+
the guard has to hold on this route too.
|
|
280
|
+
"""
|
|
281
|
+
self._require_submit(dotted_path)
|
|
282
|
+
return self._run(req.call_method(dotted_path, params))
|
|
283
|
+
|
|
284
|
+
def _require_submit(self, dotted_path: str | None = None) -> None:
|
|
285
|
+
if self._config.may_submit:
|
|
286
|
+
return
|
|
287
|
+
# Called with no path (from submit/cancel directly), this always
|
|
288
|
+
# refuses. Called from `call()`, it refuses only for a method known
|
|
289
|
+
# to commit -- everything else passes through unchecked.
|
|
290
|
+
if dotted_path is not None and dotted_path not in _COMMITTING_METHODS:
|
|
291
|
+
return
|
|
292
|
+
raise SubmitNotPermittedError(
|
|
293
|
+
"this client was constructed without submit capability; machines create drafts, humans submit"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _require_no_docstatus(self, doc: dict[str, Any]) -> None:
|
|
297
|
+
# A drafts-only client has no legitimate reason to set docstatus at
|
|
298
|
+
# all -- a draft is 0, the default when the key is absent -- so this
|
|
299
|
+
# refuses on the key's presence, not its value. `docstatus: 0` is
|
|
300
|
+
# refused too: the rule is "don't touch this field", not "don't set
|
|
301
|
+
# it to something dangerous".
|
|
302
|
+
if not self._config.may_submit and "docstatus" in doc:
|
|
303
|
+
raise SubmitNotPermittedError("this client cannot set docstatus; machines create drafts, humans submit")
|
|
304
|
+
|
|
305
|
+
def submit(self, doc: dict[str, Any]) -> Any:
|
|
306
|
+
"""Submit a draft document, moving it to docstatus 1.
|
|
307
|
+
|
|
308
|
+
Takes the whole document rather than a doctype and name, because
|
|
309
|
+
Frappe's `frappe.client.submit` does -- unlike `cancel`, which takes
|
|
310
|
+
the pair. The asymmetry is Frappe's, mirrored here rather than papered
|
|
311
|
+
over.
|
|
312
|
+
"""
|
|
313
|
+
self._require_submit()
|
|
314
|
+
return self.call("frappe.client.submit", {"doc": doc})
|
|
315
|
+
|
|
316
|
+
def cancel(self, doctype: str, name: str) -> Any:
|
|
317
|
+
"""Cancel a submitted document, moving it to docstatus 2."""
|
|
318
|
+
self._require_submit()
|
|
319
|
+
return self.call("frappe.client.cancel", {"doctype": doctype, "name": name})
|
|
320
|
+
|
|
321
|
+
def list_page(self, doctype: str, limit: int, start: int, **kwargs: Any) -> tuple[_Rows, bool | None]:
|
|
322
|
+
"""One page of documents, plus whether the server says more remain.
|
|
323
|
+
|
|
324
|
+
The primitive under `iter_all`, public for callers that page by cursor
|
|
325
|
+
rather than offset: the flag is how they tell a page cut short by a
|
|
326
|
+
server cap from the end of the result.
|
|
327
|
+
"""
|
|
328
|
+
request = req.list_documents(doctype, limit=limit, start=start, **kwargs)
|
|
329
|
+
response = self._http.request(**_send_args(self._config, request))
|
|
330
|
+
payload = envelope(response.status_code, response.text)
|
|
331
|
+
rows = payload["data"] if isinstance(payload, dict) and "data" in payload else payload
|
|
332
|
+
return rows, has_next_page(payload)
|
|
333
|
+
|
|
334
|
+
def iter_all(self, doctype: str, *, page_size: int = 100, **kwargs: Any) -> Iterator[dict[str, Any]]:
|
|
335
|
+
"""Yield every matching document, paging until the server says stop.
|
|
336
|
+
|
|
337
|
+
Callers that page by hand eventually forget to, and a truncated read
|
|
338
|
+
looks exactly like a small result set. v2 states whether more pages
|
|
339
|
+
exist, so prefer that; fall back to the short-page heuristic only when
|
|
340
|
+
the flag is absent, as on v1 responses.
|
|
341
|
+
"""
|
|
342
|
+
_require_positive_page_size(page_size)
|
|
343
|
+
_reject_reserved_pagination_kwargs(kwargs)
|
|
344
|
+
start = 0
|
|
345
|
+
while True:
|
|
346
|
+
rows, more = self.list_page(doctype, page_size, start, **kwargs)
|
|
347
|
+
if not rows:
|
|
348
|
+
return
|
|
349
|
+
yield from rows
|
|
350
|
+
if more is False:
|
|
351
|
+
return
|
|
352
|
+
if more is None and len(rows) < page_size:
|
|
353
|
+
return
|
|
354
|
+
# Advance by what the server actually sent, not what was asked
|
|
355
|
+
# for: a server that caps page size below `page_size` can return
|
|
356
|
+
# fewer rows while still reporting has_next_page: true, and
|
|
357
|
+
# advancing by page_size would skip the rows it never sent.
|
|
358
|
+
start += len(rows)
|
|
359
|
+
|
|
360
|
+
def download_file(self, file_url: str, *, max_bytes: int) -> bytes:
|
|
361
|
+
"""The bytes a `File` document's `file_url` names, private files included.
|
|
362
|
+
|
|
363
|
+
Only site-relative `/files/` and `/private/files/` paths: a `file_url`
|
|
364
|
+
is data a person can edit, and following it off-site would hand the
|
|
365
|
+
credential to whoever it names. Validates against dot-segments and
|
|
366
|
+
off-site URLs before sending any request. Streamed and capped at
|
|
367
|
+
`max_bytes`.
|
|
368
|
+
"""
|
|
369
|
+
_validate_file_url(file_url, self._config.url)
|
|
370
|
+
args = _send_args(self._config, req.Request("GET", file_url))
|
|
371
|
+
with self._http.stream("GET", args["url"], headers=args["headers"]) as response:
|
|
372
|
+
if response.status_code != 200:
|
|
373
|
+
raise FileDownloadError(f"{file_url}: HTTP {response.status_code}")
|
|
374
|
+
chunks: list[bytes] = []
|
|
375
|
+
size = 0
|
|
376
|
+
for chunk in response.iter_bytes():
|
|
377
|
+
size += len(chunk)
|
|
378
|
+
if size > max_bytes:
|
|
379
|
+
raise FileDownloadError(f"{file_url}: larger than {max_bytes} bytes")
|
|
380
|
+
chunks.append(chunk)
|
|
381
|
+
return b"".join(chunks)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class AsyncClient:
|
|
385
|
+
"""Asynchronous Frappe client. Same surface as `Client`, awaitable."""
|
|
386
|
+
|
|
387
|
+
def __init__(self, config: SiteConfig, *, transport: httpx2.AsyncBaseTransport | None = None) -> None:
|
|
388
|
+
self._config = config
|
|
389
|
+
self._http = httpx2.AsyncClient(timeout=config.timeout, transport=transport)
|
|
390
|
+
|
|
391
|
+
async def __aenter__(self) -> Self:
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
395
|
+
await self.close()
|
|
396
|
+
|
|
397
|
+
async def close(self) -> None:
|
|
398
|
+
"""Release the underlying connection pool."""
|
|
399
|
+
await self._http.aclose()
|
|
400
|
+
|
|
401
|
+
async def _run(self, request: req.Request) -> Any:
|
|
402
|
+
response = await self._http.request(**_send_args(self._config, request))
|
|
403
|
+
return unwrap(response.status_code, response.text)
|
|
404
|
+
|
|
405
|
+
async def list(self, doctype: str, **kwargs: Any) -> Any:
|
|
406
|
+
"""List documents of a doctype."""
|
|
407
|
+
return await self._run(req.list_documents(doctype, **kwargs))
|
|
408
|
+
|
|
409
|
+
async def get(self, doctype: str, name: str) -> Any:
|
|
410
|
+
"""Fetch one document by name."""
|
|
411
|
+
return await self._run(req.get_document(doctype, name))
|
|
412
|
+
|
|
413
|
+
async def insert(self, doctype: str, doc: dict[str, Any]) -> Any:
|
|
414
|
+
"""Create a document.
|
|
415
|
+
|
|
416
|
+
Refuses locally, before anything is sent, if `doc` sets `docstatus`
|
|
417
|
+
and this client lacks submit capability: Frappe's own insert path
|
|
418
|
+
(`Document.check_docstatus_transition`) submits a document created
|
|
419
|
+
with `docstatus: 1` directly, with no separate `submit()` call.
|
|
420
|
+
"""
|
|
421
|
+
self._require_no_docstatus(doc)
|
|
422
|
+
return await self._run(req.insert_document(doctype, doc))
|
|
423
|
+
|
|
424
|
+
async def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any:
|
|
425
|
+
"""Update a document.
|
|
426
|
+
|
|
427
|
+
Refuses locally, before anything is sent, if `doc` sets `docstatus`
|
|
428
|
+
and this client lacks submit capability -- same mechanism as
|
|
429
|
+
`insert`.
|
|
430
|
+
"""
|
|
431
|
+
self._require_no_docstatus(doc)
|
|
432
|
+
return await self._run(req.update_document(doctype, name, doc))
|
|
433
|
+
|
|
434
|
+
async def delete(self, doctype: str, name: str) -> Any:
|
|
435
|
+
"""Delete a document."""
|
|
436
|
+
return await self._run(req.delete_document(doctype, name))
|
|
437
|
+
|
|
438
|
+
async def upload_file(
|
|
439
|
+
self,
|
|
440
|
+
doctype: str,
|
|
441
|
+
docname: str,
|
|
442
|
+
filename: str,
|
|
443
|
+
content: bytes,
|
|
444
|
+
*,
|
|
445
|
+
is_private: bool = True,
|
|
446
|
+
folder: str | None = None,
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Attach a file to a document, returning the created `File` document.
|
|
449
|
+
|
|
450
|
+
Takes the content as bytes rather than a path -- the common caller
|
|
451
|
+
already has it in memory (an email attachment, a generated PDF), and
|
|
452
|
+
routing that through a temp file first would be pure ceremony.
|
|
453
|
+
Private by default: an attachment is financial evidence more often
|
|
454
|
+
than not, and a public URL is the wrong default for that.
|
|
455
|
+
"""
|
|
456
|
+
return await self._run(
|
|
457
|
+
req.upload_file(doctype, docname, filename, content, is_private=is_private, folder=folder)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any:
|
|
461
|
+
"""Call a whitelisted server method.
|
|
462
|
+
|
|
463
|
+
Refuses locally, before anything is sent, when `dotted_path` names a
|
|
464
|
+
known committing method (e.g. `frappe.client.submit`) and this client
|
|
465
|
+
lacks submit capability -- `submit`/`cancel` are sugar over this, and
|
|
466
|
+
the guard has to hold on this route too.
|
|
467
|
+
"""
|
|
468
|
+
self._require_submit(dotted_path)
|
|
469
|
+
return await self._run(req.call_method(dotted_path, params))
|
|
470
|
+
|
|
471
|
+
def _require_submit(self, dotted_path: str | None = None) -> None:
|
|
472
|
+
if self._config.may_submit:
|
|
473
|
+
return
|
|
474
|
+
# Called with no path (from submit/cancel directly), this always
|
|
475
|
+
# refuses. Called from `call()`, it refuses only for a method known
|
|
476
|
+
# to commit -- everything else passes through unchecked.
|
|
477
|
+
if dotted_path is not None and dotted_path not in _COMMITTING_METHODS:
|
|
478
|
+
return
|
|
479
|
+
raise SubmitNotPermittedError(
|
|
480
|
+
"this client was constructed without submit capability; machines create drafts, humans submit"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def _require_no_docstatus(self, doc: dict[str, Any]) -> None:
|
|
484
|
+
# A drafts-only client has no legitimate reason to set docstatus at
|
|
485
|
+
# all -- a draft is 0, the default when the key is absent -- so this
|
|
486
|
+
# refuses on the key's presence, not its value. `docstatus: 0` is
|
|
487
|
+
# refused too: the rule is "don't touch this field", not "don't set
|
|
488
|
+
# it to something dangerous".
|
|
489
|
+
if not self._config.may_submit and "docstatus" in doc:
|
|
490
|
+
raise SubmitNotPermittedError("this client cannot set docstatus; machines create drafts, humans submit")
|
|
491
|
+
|
|
492
|
+
async def submit(self, doc: dict[str, Any]) -> Any:
|
|
493
|
+
"""Submit a draft document, moving it to docstatus 1.
|
|
494
|
+
|
|
495
|
+
Takes the whole document rather than a doctype and name, because
|
|
496
|
+
Frappe's `frappe.client.submit` does -- unlike `cancel`, which takes
|
|
497
|
+
the pair. The asymmetry is Frappe's, mirrored here rather than papered
|
|
498
|
+
over.
|
|
499
|
+
"""
|
|
500
|
+
self._require_submit()
|
|
501
|
+
return await self.call("frappe.client.submit", {"doc": doc})
|
|
502
|
+
|
|
503
|
+
async def cancel(self, doctype: str, name: str) -> Any:
|
|
504
|
+
"""Cancel a submitted document, moving it to docstatus 2."""
|
|
505
|
+
self._require_submit()
|
|
506
|
+
return await self.call("frappe.client.cancel", {"doctype": doctype, "name": name})
|
|
507
|
+
|
|
508
|
+
async def list_page(self, doctype: str, limit: int, start: int, **kwargs: Any) -> tuple[_Rows, bool | None]:
|
|
509
|
+
"""One page of documents, plus whether the server says more remain."""
|
|
510
|
+
request = req.list_documents(doctype, limit=limit, start=start, **kwargs)
|
|
511
|
+
response = await self._http.request(**_send_args(self._config, request))
|
|
512
|
+
payload = envelope(response.status_code, response.text)
|
|
513
|
+
rows = payload["data"] if isinstance(payload, dict) and "data" in payload else payload
|
|
514
|
+
return rows, has_next_page(payload)
|
|
515
|
+
|
|
516
|
+
async def iter_all(self, doctype: str, *, page_size: int = 100, **kwargs: Any) -> AsyncIterator[dict[str, Any]]:
|
|
517
|
+
"""Yield every matching document, paging until the server says stop.
|
|
518
|
+
|
|
519
|
+
Mirrors the sync twin exactly: prefer the server's `has_next_page`, and
|
|
520
|
+
fall back to the short-page heuristic only when the flag is absent.
|
|
521
|
+
"""
|
|
522
|
+
_require_positive_page_size(page_size)
|
|
523
|
+
_reject_reserved_pagination_kwargs(kwargs)
|
|
524
|
+
start = 0
|
|
525
|
+
while True:
|
|
526
|
+
rows, more = await self.list_page(doctype, page_size, start, **kwargs)
|
|
527
|
+
if not rows:
|
|
528
|
+
return
|
|
529
|
+
for row in rows:
|
|
530
|
+
yield row
|
|
531
|
+
if more is False:
|
|
532
|
+
return
|
|
533
|
+
if more is None and len(rows) < page_size:
|
|
534
|
+
return
|
|
535
|
+
# Advance by what the server actually sent, not what was asked
|
|
536
|
+
# for: a server that caps page size below `page_size` can return
|
|
537
|
+
# fewer rows while still reporting has_next_page: true, and
|
|
538
|
+
# advancing by page_size would skip the rows it never sent.
|
|
539
|
+
start += len(rows)
|