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.
@@ -0,0 +1,201 @@
1
+ """Typed exceptions for Frappe's error envelopes.
2
+
3
+ Frappe reports failures two different ways, and both are live. The v2 API sends
4
+ `{"errors": [{"type": ..., "message": ..., "exception": <traceback>}]}`. Older
5
+ apps and `/api/method/` endpoints send `exc_type` alongside `_server_messages`,
6
+ a JSON string holding a list of JSON strings -- two decodes, frequently absent
7
+ or malformed.
8
+
9
+ Everything here degrades to a usable exception rather than raising while
10
+ handling an error. A parser that throws while reporting a failure replaces a
11
+ diagnosable problem with a mysterious one.
12
+ """
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+
18
+ class FrappeError(Exception):
19
+ """Base for every error returned by a Frappe site."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ error_type: str | None = None,
26
+ status: int | None = None,
27
+ messages: list[str] | None = None,
28
+ server_traceback: str | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.error_type = error_type
33
+ self.status = status
34
+ self.messages = messages or []
35
+ self.server_traceback = server_traceback
36
+
37
+
38
+ class ValidationError(FrappeError):
39
+ """A document failed Frappe's validation.
40
+
41
+ Raised for the named types below and for any error the site answers with
42
+ 417 under a name this module does not know -- apps subclass
43
+ `frappe.ValidationError` for their own rules, and the status is the part
44
+ that stays put.
45
+ """
46
+
47
+
48
+ class PermissionError_(FrappeError):
49
+ """The calling user lacks permission. Trailing underscore avoids the builtin."""
50
+
51
+
52
+ class DoesNotExistError(FrappeError):
53
+ """The named document does not exist."""
54
+
55
+
56
+ class DuplicateEntryError(FrappeError):
57
+ """A document with that name already exists."""
58
+
59
+
60
+ class LinkExistsError(FrappeError):
61
+ """The document is linked from elsewhere and cannot be deleted."""
62
+
63
+
64
+ class ChildExistsError(FrappeError):
65
+ """The document is a node in a tree and still has children.
66
+
67
+ Frappe's nested-set model (used by ERPNext's Account, among others)
68
+ refuses to delete a node with children -- delete or move the children
69
+ first. Measured against a live site as `error_type ==
70
+ "NestedSetChildExistsError"`, status 417, message like "Cannot delete
71
+ X as it has child nodes"; distinct from both `LinkExistsError` (a
72
+ document elsewhere references this one) and `ValidationError` (nothing
73
+ to do with the tree at all).
74
+ """
75
+
76
+
77
+ class TimestampMismatchError(FrappeError):
78
+ """The document changed after it was read.
79
+
80
+ Frappe detects conflicts by comparing the `modified` timestamp sent with a
81
+ write against the stored one. This is optimistic concurrency failing, not a
82
+ server fault -- callers should re-read and retry.
83
+ """
84
+
85
+
86
+ class RateLimitError(FrappeError):
87
+ """The site is rate limiting us."""
88
+
89
+
90
+ # Frappe's status for a validation failure: `frappe.ValidationError` and every
91
+ # subclass an app defines answer 417 (`http_status_code` on the exception).
92
+ _VALIDATION_STATUS = 417
93
+
94
+ _BY_TYPE: dict[str, type[FrappeError]] = {
95
+ "ValidationError": ValidationError,
96
+ "MandatoryError": ValidationError,
97
+ "PermissionError": PermissionError_,
98
+ "DoesNotExistError": DoesNotExistError,
99
+ "DuplicateEntryError": DuplicateEntryError,
100
+ "LinkExistsError": LinkExistsError,
101
+ "NestedSetChildExistsError": ChildExistsError,
102
+ "TimestampMismatch": TimestampMismatchError,
103
+ "TimestampMismatchError": TimestampMismatchError,
104
+ }
105
+
106
+
107
+ def parse_errors(payload: Any) -> list[dict[str, Any]]:
108
+ """Extract the v2 `errors` list, or an empty list if there isn't one."""
109
+ if not isinstance(payload, dict):
110
+ return []
111
+ errors = payload.get("errors")
112
+ if not isinstance(errors, list):
113
+ return []
114
+ return [item for item in errors if isinstance(item, dict)]
115
+
116
+
117
+ def parse_server_messages(raw: str | None) -> list[str]:
118
+ """Extract human-readable strings from Frappe's `_server_messages`.
119
+
120
+ The value is a JSON string holding a list of JSON strings, each usually an
121
+ object with a `message` key. Returns an empty list for anything it cannot
122
+ decode -- a malformed diagnostic must never mask the error it describes.
123
+ """
124
+ if not raw:
125
+ return []
126
+ try:
127
+ outer = json.loads(raw)
128
+ except json.JSONDecodeError, TypeError:
129
+ return []
130
+ if not isinstance(outer, list):
131
+ return []
132
+
133
+ messages: list[str] = []
134
+ for item in outer:
135
+ if not isinstance(item, str):
136
+ continue
137
+ try:
138
+ inner: Any = json.loads(item)
139
+ except json.JSONDecodeError:
140
+ messages.append(item)
141
+ continue
142
+ if isinstance(inner, dict) and "message" in inner:
143
+ messages.append(str(inner["message"]))
144
+ elif isinstance(inner, str):
145
+ messages.append(inner)
146
+ return messages
147
+
148
+
149
+ def error_from_response(status: int, body: str) -> FrappeError:
150
+ """Build the most specific exception the response supports."""
151
+ payload: dict[str, Any] = {}
152
+ try:
153
+ decoded = json.loads(body)
154
+ if isinstance(decoded, dict):
155
+ payload = decoded
156
+ except json.JSONDecodeError:
157
+ payload = {}
158
+
159
+ error_type: str | None = None
160
+ messages: list[str] = []
161
+ server_traceback: str | None = None
162
+
163
+ errors = parse_errors(payload)
164
+ if errors:
165
+ error_type = errors[0].get("type")
166
+ messages = [str(item["message"]) for item in errors if item.get("message")]
167
+ # Kept for debugging, deliberately never used as the message: it is a
168
+ # server-side Python traceback, not something to show a person.
169
+ raw_traceback = errors[0].get("exception")
170
+ server_traceback = str(raw_traceback) if raw_traceback else None
171
+ else:
172
+ error_type = payload.get("exc_type")
173
+ messages = parse_server_messages(payload.get("_server_messages"))
174
+
175
+ message = messages[0] if messages else str(payload.get("message") or body[:500]).strip()
176
+
177
+ if status == 429:
178
+ cls: type[FrappeError] = RateLimitError
179
+ else:
180
+ cls = _BY_TYPE.get(str(error_type), FrappeError)
181
+ if cls is FrappeError and status == _VALIDATION_STATUS:
182
+ # Frappe answers a validation failure with 417 whatever the
183
+ # exception is called, and apps raise their own
184
+ # `frappe.ValidationError` subclasses freely -- ERPNext's
185
+ # `StockAccountInvalidTransaction` ("Account: Stock In Hand - AB
186
+ # can only be updated via Stock Transactions", measured live), and
187
+ # one per app after that. Naming them all is a losing race, so an
188
+ # unrecognised name at this status is read as what the status
189
+ # means. The map still wins where it has an entry: a
190
+ # `NestedSetChildExistsError` is a 417 that is not a validation
191
+ # failure, and a `PermissionError` is a 403 that must never
192
+ # become one.
193
+ cls = ValidationError
194
+
195
+ return cls(
196
+ message,
197
+ error_type=error_type,
198
+ status=status,
199
+ messages=messages,
200
+ server_traceback=server_traceback,
201
+ )
@@ -0,0 +1,30 @@
1
+ """The draft-submit-cancel lifecycle of submittable doctypes.
2
+
3
+ A plain insert creates a draft at docstatus 0. Submitting is a separate call,
4
+ which makes "machines draft, humans submit" enforceable rather than merely
5
+ agreed.
6
+ """
7
+
8
+ from enum import IntEnum
9
+
10
+
11
+ class DocStatus(IntEnum):
12
+ """Frappe's document states."""
13
+
14
+ DRAFT = 0
15
+ SUBMITTED = 1
16
+ CANCELLED = 2
17
+
18
+
19
+ class SubmitNotPermittedError(RuntimeError):
20
+ """Raised when a client without submit capability touches the submit path.
21
+
22
+ Covers `submit`/`cancel` directly, a `call` naming a known committing
23
+ method, and an `insert`/`update` whose document body sets `docstatus`.
24
+
25
+ Not a server error -- the call is refused locally, before anything is
26
+ sent. This is a guardrail against an accidental submission from client
27
+ code, not a security boundary: the real boundary is the API key's role
28
+ permissions on the server, since a Frappe app can whitelist its own
29
+ committing methods that this client has no way to know about.
30
+ """
@@ -0,0 +1,145 @@
1
+ """Frappe requests as pure data.
2
+
3
+ Nothing here performs I/O. Both the sync and async clients build their calls
4
+ through these functions, so request shaping is tested once and cannot drift
5
+ between the two.
6
+ """
7
+
8
+ import dataclasses
9
+ import json
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+
14
+ API_V2 = "/api/v2"
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Request:
19
+ """A fully described HTTP call, ready for either client to send.
20
+
21
+ `json_body` covers every request but one: Frappe's file upload endpoint
22
+ takes multipart form data, not JSON. `data` and `files` carry that case --
23
+ kept as distinct fields rather than overloading `json_body`, so a request
24
+ is unambiguously either JSON or multipart and the send side can tell which
25
+ by checking `files`.
26
+ """
27
+
28
+ method: str
29
+ path: str
30
+ params: dict[str, str] = dataclasses.field(default_factory=dict)
31
+ headers: dict[str, str] = dataclasses.field(default_factory=dict)
32
+ json_body: dict[str, Any] | None = None
33
+ data: dict[str, Any] | None = None
34
+ files: dict[str, tuple[str, bytes]] | None = None
35
+
36
+
37
+ def auth_header(key: str, secret: str) -> dict[str, str]:
38
+ """Frappe's API-key scheme: `Authorization: token <key>:<secret>`."""
39
+ return {"Authorization": f"token {key}:{secret}"}
40
+
41
+
42
+ # A dotted path is a Python import path and nothing else. Validating it before
43
+ # it reaches a URL closes a bypass: `frappe.client.submit#x` is not a member of
44
+ # any denylist, but HTTP drops the fragment, so the server sees the bare method.
45
+ # The same holds for a `?query` suffix.
46
+ _DOTTED_PATH = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$")
47
+
48
+
49
+ def _document_path(doctype: str, name: str | None = None) -> str:
50
+ path = f"{API_V2}/document/{quote(doctype, safe='')}"
51
+ if name is None:
52
+ return path
53
+ if not name:
54
+ # An empty name is falsy, and treating it as absent would silently turn
55
+ # a single-document request into a list of the whole doctype.
56
+ raise ValueError("document name must not be empty")
57
+ return f"{path}/{quote(name, safe='')}"
58
+
59
+
60
+ def list_documents(
61
+ doctype: str,
62
+ *,
63
+ fields: list[str] | None = None,
64
+ filters: list[list[Any]] | dict[str, Any] | None = None,
65
+ limit: int | None = None,
66
+ start: int | None = None,
67
+ order_by: str | None = None,
68
+ ) -> Request:
69
+ """List documents. Absent options are omitted rather than sent empty."""
70
+ params: dict[str, str] = {}
71
+ if fields is not None:
72
+ params["fields"] = json.dumps(fields)
73
+ if filters is not None:
74
+ params["filters"] = json.dumps(filters)
75
+ if limit is not None:
76
+ params["limit"] = str(limit)
77
+ if start is not None:
78
+ params["start"] = str(start)
79
+ if order_by is not None:
80
+ params["order_by"] = order_by
81
+ return Request("GET", _document_path(doctype), params)
82
+
83
+
84
+ def get_document(doctype: str, name: str) -> Request:
85
+ """Fetch one document by name."""
86
+ return Request("GET", _document_path(doctype, name))
87
+
88
+
89
+ def insert_document(doctype: str, doc: dict[str, Any]) -> Request:
90
+ """Create a document. Submittable doctypes land as drafts at docstatus 0."""
91
+ return Request("POST", _document_path(doctype), json_body=doc)
92
+
93
+
94
+ def update_document(doctype: str, name: str, doc: dict[str, Any]) -> Request:
95
+ """Update a document in place."""
96
+ return Request("PUT", _document_path(doctype, name), json_body=doc)
97
+
98
+
99
+ def delete_document(doctype: str, name: str) -> Request:
100
+ """Delete a document."""
101
+ return Request("DELETE", _document_path(doctype, name))
102
+
103
+
104
+ def call_method(dotted_path: str, params: dict[str, Any] | None = None) -> Request:
105
+ """Call a whitelisted server method.
106
+
107
+ Rejects anything that is not a plain dotted identifier path. A URL fragment
108
+ or query suffix would survive a string comparison against a denylist while
109
+ HTTP discarded it, so the server would receive a method the caller was
110
+ supposed to be refused.
111
+ """
112
+ # fullmatch, not match: `$` also matches before a trailing newline, so
113
+ # `frappe.client.submit\n` would pass while failing the exact denylist
114
+ # comparison the guard makes.
115
+ if not _DOTTED_PATH.fullmatch(dotted_path):
116
+ raise ValueError(f"not a dotted method path: {dotted_path!r}")
117
+ return Request("POST", f"{API_V2}/method/{dotted_path}", json_body=params or {})
118
+
119
+
120
+ def upload_file(
121
+ doctype: str,
122
+ docname: str,
123
+ filename: str,
124
+ content: bytes,
125
+ *,
126
+ is_private: bool = True,
127
+ folder: str | None = None,
128
+ ) -> Request:
129
+ """Attach a file to a document via Frappe's multipart upload endpoint.
130
+
131
+ `/api/v2/method/upload_file` is the one server route this client sends
132
+ multipart to -- everything else is JSON. Confirmed against Frappe's
133
+ `develop` branch (`frappe/handler.py:upload_file`, routed at
134
+ `frappe/api/v2.py`): the file must ride in a part literally named `file`
135
+ (the handler reads it from `frappe.request.files["file"]`), and
136
+ `doctype`/`docname`/`folder`/`is_private` are plain form fields, not JSON.
137
+ `is_private` defaults to true here -- Frappe's own server-side default is
138
+ also private (`frappe.form_dict.get("is_private", 1)`), but a client for
139
+ financial documents states that choice rather than leaning on the
140
+ server's default.
141
+ """
142
+ data: dict[str, Any] = {"doctype": doctype, "docname": docname, "is_private": 1 if is_private else 0}
143
+ if folder is not None:
144
+ data["folder"] = folder
145
+ return Request("POST", f"{API_V2}/method/upload_file", data=data, files={"file": (filename, content)})
@@ -0,0 +1,76 @@
1
+ """Response parsing, shared by both clients.
2
+
3
+ Frappe's v2 surface includes empty fields in its responses, which makes a
4
+ cleared field distinguishable from one that was never selected. That
5
+ distinction is the difference between "set this to null" and "leave it alone"
6
+ during a sync, so it is preserved rather than flattened onto None.
7
+ """
8
+
9
+ import json
10
+ from typing import Any, Final
11
+
12
+ from icedcoffee_sync.frappe.errors import FrappeError, error_from_response
13
+
14
+
15
+ class _Missing:
16
+ """Sentinel for a key that was not present at all."""
17
+
18
+ _instance: _Missing | None = None
19
+
20
+ def __new__(cls) -> _Missing:
21
+ if cls._instance is None:
22
+ cls._instance = super().__new__(cls)
23
+ return cls._instance
24
+
25
+ def __bool__(self) -> bool:
26
+ return False
27
+
28
+ def __repr__(self) -> str:
29
+ return "MISSING"
30
+
31
+
32
+ MISSING: Final[_Missing] = _Missing()
33
+
34
+
35
+ def envelope(status: int, body: str) -> Any:
36
+ """Return the whole parsed payload, or raise a typed error.
37
+
38
+ v2 list responses carry pagination alongside the rows --
39
+ `{"has_next_page": true, "data": [...]}` -- so anything that pages needs
40
+ the envelope rather than just its contents.
41
+ """
42
+ if status >= 400:
43
+ raise error_from_response(status, body)
44
+ try:
45
+ return json.loads(body)
46
+ except json.JSONDecodeError as exc:
47
+ raise FrappeError(f"expected JSON, got {body[:200]!r}", status=status) from exc
48
+
49
+
50
+ def unwrap(status: int, body: str) -> Any:
51
+ """Return the response's `data` payload, or raise a typed error."""
52
+ payload = envelope(status, body)
53
+ if isinstance(payload, dict) and "data" in payload:
54
+ return payload["data"]
55
+ return payload
56
+
57
+
58
+ def has_next_page(payload: Any) -> bool | None:
59
+ """Whether the server says more pages exist.
60
+
61
+ Returns None both when the payload carries no such flag -- v1 responses
62
+ do not -- and when the flag is present but explicitly null. Either way
63
+ the response cannot answer the question, and treating that as "no more
64
+ pages" would stop a listing early.
65
+ """
66
+ if not isinstance(payload, dict) or "has_next_page" not in payload:
67
+ return None
68
+ value = payload["has_next_page"]
69
+ if value is None:
70
+ return None
71
+ return bool(value)
72
+
73
+
74
+ def field(doc: dict[str, Any], name: str) -> Any:
75
+ """Read a field, distinguishing absent (MISSING) from null (None)."""
76
+ return doc.get(name, MISSING)
File without changes
@@ -0,0 +1,65 @@
1
+ """The checks every ERPNext site URL passes before a credential is sent to it.
2
+
3
+ No allowlist lives here. A connector that only reaches allowlisted hosts
4
+ connects to nobody; a demo-data tool that reaches any host is a hazard. So
5
+ this module refuses what is always wrong -- a credential in the URL, no
6
+ hostname, plain HTTP, a port that is not 443, anything past the site's root,
7
+ and any host the caller names as refused -- and
8
+ leaves "which hosts may I write to" to the caller.
9
+
10
+ Refusal order is deliberate: userinfo first, because every later message
11
+ quotes the URL back and a password typed into it must never reach stderr.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from types import MappingProxyType
18
+ from urllib.parse import urlsplit
19
+
20
+ HTTPS_PORT = 443
21
+
22
+
23
+ class SiteUrlRefusedError(ValueError):
24
+ """The URL is one no ERPNext credential should be sent to."""
25
+
26
+
27
+ def hostname(url: str) -> str:
28
+ """The URL's host, lowercased, without a trailing dot.
29
+
30
+ `host.` resolves to the same site as `host`; an equality check that
31
+ missed the dot would let a refused host through.
32
+ """
33
+ return (urlsplit(url).hostname or "").lower().rstrip(".")
34
+
35
+
36
+ def require_safe_site_url(url: str, *, refused_hosts: Mapping[str, str] = MappingProxyType({})) -> None:
37
+ """Refuse a URL that carries a credential, is not https on 443, or names a refused host.
38
+
39
+ Args:
40
+ url: The site URL as the operator typed it.
41
+ refused_hosts: Hosts refused by name, ahead of every other check, so
42
+ the message says why rather than "not allowed". Maps each host to
43
+ the words that name it in the refusal ("the ledger").
44
+
45
+ Raises:
46
+ SiteUrlRefusedError: With a message that never contains userinfo.
47
+ """
48
+ parts = urlsplit(url)
49
+ if parts.username is not None or parts.password is not None:
50
+ raise SiteUrlRefusedError("refusing a site URL carrying a credential; pass the key and secret separately")
51
+ host = hostname(url)
52
+ if not host:
53
+ raise SiteUrlRefusedError(f"refusing {url!r}: no hostname")
54
+ if host in refused_hosts:
55
+ raise SiteUrlRefusedError(f"refusing {refused_hosts[host]} {host!r}")
56
+ if parts.scheme != "https":
57
+ raise SiteUrlRefusedError(f"refusing {url!r}: the API key rides in a header, so the scheme must be https")
58
+ try:
59
+ port = parts.port
60
+ except ValueError:
61
+ raise SiteUrlRefusedError(f"refusing {url!r}: unreadable port") from None
62
+ if port not in (None, HTTPS_PORT):
63
+ raise SiteUrlRefusedError(f"refusing {url!r}: port {port} is not {HTTPS_PORT}")
64
+ if parts.path not in ("", "/") or parts.query or parts.fragment:
65
+ raise SiteUrlRefusedError(f"refusing {url!r}: a site URL is the site's root, with no path, query, or fragment")
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: icedcoffee-sync
3
+ Version: 0.1.0.dev5
4
+ Summary: Typed sync and async client for the Frappe and ERPNext REST API
5
+ Author-email: Gavin Carothers <gavin@carothers.name>
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: httpx2>=2.0.0
@@ -0,0 +1,29 @@
1
+ icedcoffee_sync/__init__.py,sha256=bTtUXwyYtPngMUe6sJTqdXDmLqH0yTmppPgWka3eNFE,69
2
+ icedcoffee_sync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ icedcoffee_sync/site.py,sha256=l7a2zEde67aI5qTort9Ce59Bsf1a5sXTKswlWfe4ZsY,2789
4
+ icedcoffee_sync/apply/__init__.py,sha256=eCNOPTYcIHmxSiasjpJ7uvJxpMC3d3Ezbb3g2Yw2ysg,266
5
+ icedcoffee_sync/apply/diff.py,sha256=OhmX76P6lNsA34pPGMdpgRmQ8AUqxUzVCtiKanSrZKs,21080
6
+ icedcoffee_sync/apply/execute.py,sha256=Fjxh2tPin-a5HRtXgCAxPyYRdEr0D2MDOpb3nT_lay0,7324
7
+ icedcoffee_sync/apply/model.py,sha256=h5WS8NYGf1QcQvNI3r71WzGCfd_OWJ05sVZFnufxpm0,1454
8
+ icedcoffee_sync/apply/order.py,sha256=kTWO7zsiFYJXzMTHOR3k0PWqKvTidDDYspzhcy0foiA,4862
9
+ icedcoffee_sync/apply/park.py,sha256=wdiH2GeXj2M26Xrn76qHWUigJNoRPBzBkgiZPCt_gr0,4309
10
+ icedcoffee_sync/apply/prune.py,sha256=5BBxGTkyKMXfAOkVMECihEzuHXm5bE8ctLclWe1oFDU,7809
11
+ icedcoffee_sync/erpnext/__init__.py,sha256=1QbW1mjO1swPYLaKW1ECn6uuN5pkk3KYH77Gv4emJKY,72
12
+ icedcoffee_sync/erpnext/accounts.py,sha256=J1uoLR6ArRUdPRjxngPnTyK8V6jEJA-EaqVDPFxUKQ4,6335
13
+ icedcoffee_sync/erpnext/comment.py,sha256=CCGI4tdrtVQa9SZ_ftZP3Iokpy3w12PzqOx7XBami9g,2054
14
+ icedcoffee_sync/erpnext/company.py,sha256=W4NoZ94waq0cgmtqPjJGS5HttcEbbXGaSBc3X8cIvpY,3710
15
+ icedcoffee_sync/erpnext/documents.py,sha256=Rz-Qbd1kjKISKR0kq-5LVfZqxkgYzqze6vAqSCGa_QA,6439
16
+ icedcoffee_sync/erpnext/ensure.py,sha256=fNphRV04k7Fr806lEldV_gBfQ-6uHkdPponZvziJ5fo,1131
17
+ icedcoffee_sync/erpnext/purchase.py,sha256=Kfjy786vSWnUvIq6n3mwbKKsPqYto0uqo7SwpYUsyBM,9466
18
+ icedcoffee_sync/erpnext/webhook.py,sha256=856caFbqvP8bSQUuzR-MKQ1u8WiHQpq-9crg_YqBHy0,5320
19
+ icedcoffee_sync/frappe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ icedcoffee_sync/frappe/access.py,sha256=B7Zcyl_B_WBA1JbieaSmlrVRWnS9TyKq_4n87t6tSMI,15732
21
+ icedcoffee_sync/frappe/client.py,sha256=yNeU2nh_b24J-xcSFy4kPsVW4xa_5nVuTkeEYouLUWE,24341
22
+ icedcoffee_sync/frappe/errors.py,sha256=o4ldcVOAMhexoReS43YfUWVe70bdCd3VQU9DBFL5gBw,7135
23
+ icedcoffee_sync/frappe/lifecycle.py,sha256=Tr6rNeoprp4u1OhBMZM9e6FcQXviDT420GJhQoz6sFQ,1016
24
+ icedcoffee_sync/frappe/request.py,sha256=hAHcMU3aYbsqMqGu_yDiNnKKCWltQHZfa8bfO02IdkY,5557
25
+ icedcoffee_sync/frappe/response.py,sha256=d-OWoA-Xw7ySskKlakrkDr0KABUFut7ZdQcLfa_jwS0,2402
26
+ icedcoffee_sync-0.1.0.dev5.dist-info/METADATA,sha256=2tmno21964o6zgN4sVI-bC7i7kzarvgPsNMD5Qho24Q,296
27
+ icedcoffee_sync-0.1.0.dev5.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
28
+ icedcoffee_sync-0.1.0.dev5.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
29
+ icedcoffee_sync-0.1.0.dev5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any