icedcoffee-sync 0.1.0.dev5__tar.gz → 0.1.0.dev11__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.
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/.gitignore +1 -0
- icedcoffee_sync-0.1.0.dev11/.kata.toml +4 -0
- icedcoffee_sync-0.1.0.dev11/PKG-INFO +178 -0
- icedcoffee_sync-0.1.0.dev11/README.md +167 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/pyproject.toml +2 -1
- icedcoffee_sync-0.1.0.dev5/PKG-INFO +0 -9
- icedcoffee_sync-0.1.0.dev5/README.md +0 -17
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/.github/workflows/ci.yml +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/.python-version +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/LICENSE +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/justfile +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/diff.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/execute.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/model.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/order.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/park.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/prune.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/accounts.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/comment.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/company.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/documents.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/ensure.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/purchase.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/webhook.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/access.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/client.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/errors.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/lifecycle.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/request.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/response.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/py.typed +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/site.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/__init__.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/capture_fixtures.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/README.md +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/duplicate_not_provoked.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/error_not_found.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/error_validation.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/get_ok.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/list_ok.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/permission_not_provoked.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/purchase_order_submitted.json +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_apply_diff.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_apply_execute.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_apply_order.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_apply_park.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_apply_prune.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_client.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_download_file.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_accounts.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_accounts_reads.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_comment.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_company.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_documents.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_ensure.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_purchase.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_webhook.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_errors.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_frappe_access.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_lifecycle.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_live_frappe.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_package.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_pagination.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_request.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_response.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_site.py +0 -0
- {icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/uv.lock +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: icedcoffee-sync
|
|
3
|
+
Version: 0.1.0.dev11
|
|
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
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# icedcoffee-sync
|
|
13
|
+
|
|
14
|
+
icedcoffee-sync has three layers: a typed Frappe/ERPNext REST client,
|
|
15
|
+
idempotent document provisioning, and reconciliation of a declared set against
|
|
16
|
+
a site. This is version 0.1.0; today the diff/apply/prune engine handles charts
|
|
17
|
+
of accounts only. The intention is to extend that process to other document types.
|
|
18
|
+
|
|
19
|
+
[Source on GitHub](https://github.com/transpire-dev/icedcoffee-sync)
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Requires Python 3.14. To include pre-releases:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install --pre icedcoffee-sync
|
|
27
|
+
# or
|
|
28
|
+
uv add --prerelease allow icedcoffee-sync
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Client
|
|
32
|
+
|
|
33
|
+
`icedcoffee_sync.frappe.client` provides typed sync and async clients.
|
|
34
|
+
`SiteConfig.may_submit` defaults to false, guarding against accidental submits,
|
|
35
|
+
cancellations, known committing methods, and document writes that set `docstatus`.
|
|
36
|
+
The API key's server-side permissions enforce access.
|
|
37
|
+
|
|
38
|
+
Replace the site URL, API credentials, and document names in these examples.
|
|
39
|
+
Both clients return document dictionaries from reads and close connections on exit.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
43
|
+
|
|
44
|
+
config = SiteConfig(
|
|
45
|
+
url="https://erp.example.com",
|
|
46
|
+
api_key="your-api-key",
|
|
47
|
+
api_secret="your-api-secret",
|
|
48
|
+
)
|
|
49
|
+
with Client(config) as client:
|
|
50
|
+
company = client.get("Company", "Example Company")
|
|
51
|
+
print(company["name"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The async equivalent:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from icedcoffee_sync.frappe.client import AsyncClient, SiteConfig
|
|
59
|
+
|
|
60
|
+
config = SiteConfig(
|
|
61
|
+
url="https://erp.example.com",
|
|
62
|
+
api_key="your-api-key",
|
|
63
|
+
api_secret="your-api-secret",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
async with AsyncClient(config) as client:
|
|
69
|
+
company = await client.get("Company", "Example Company")
|
|
70
|
+
print(company["name"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `icedcoffee_sync.frappe.access`: provision roles, permission grants, users and API keys, and probe that a key cannot submit.
|
|
77
|
+
- `icedcoffee_sync.site.require_safe_site_url`: call before sending credentials. It refuses a URL that carries a credential, has no hostname, is not HTTPS on port 443, has a path, query or fragment past the site root, or names a host the caller refused. The clients do not call it for you.
|
|
78
|
+
|
|
79
|
+
## Document provisioning
|
|
80
|
+
|
|
81
|
+
`icedcoffee_sync.erpnext.ensure.ensure_by_name` gets a document by name and
|
|
82
|
+
inserts it only if missing, never rewriting one a person changed. It returns
|
|
83
|
+
true for an insert and false for an existing document. Supply a dict whose
|
|
84
|
+
inserted document will have the name you check. This get-or-insert pattern is
|
|
85
|
+
the provisioning layer for Supplier, Item, Purchase Order, Custom Field and
|
|
86
|
+
Webhook documents today.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from icedcoffee_sync.erpnext.ensure import ensure_by_name
|
|
90
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
91
|
+
|
|
92
|
+
config = SiteConfig(
|
|
93
|
+
url="https://erp.example.com",
|
|
94
|
+
api_key="your-api-key",
|
|
95
|
+
api_secret="your-api-secret",
|
|
96
|
+
)
|
|
97
|
+
with Client(config) as client:
|
|
98
|
+
created = ensure_by_name(
|
|
99
|
+
client,
|
|
100
|
+
"Item",
|
|
101
|
+
"EXAMPLE-ITEM",
|
|
102
|
+
{
|
|
103
|
+
"item_code": "EXAMPLE-ITEM",
|
|
104
|
+
"item_name": "Example Item",
|
|
105
|
+
"item_group": "All Item Groups",
|
|
106
|
+
"stock_uom": "Nos",
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
print(created)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `icedcoffee_sync.erpnext.purchase`: typed Purchase Order reads and draft Purchase Receipt documents.
|
|
113
|
+
- `icedcoffee_sync.erpnext.company`: validate, set and clear Company default-account links.
|
|
114
|
+
- `icedcoffee_sync.erpnext.comment`: add and list comments on documents.
|
|
115
|
+
|
|
116
|
+
## Reconciliation
|
|
117
|
+
|
|
118
|
+
`icedcoffee_sync.apply` reconciles charts through the synchronous client.
|
|
119
|
+
Declare account numbers and parent numbers; roots must already exist and are
|
|
120
|
+
matched by root type. This example declares an expense root and one child.
|
|
121
|
+
Before pruning, declare **every account you want to keep** in the company.
|
|
122
|
+
|
|
123
|
+
**Warning:** `apply_chart` and `execute_prune` write to the site. Review each
|
|
124
|
+
printed plan before continuing; the example pauses before each write step.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from icedcoffee_sync.apply.diff import diff
|
|
128
|
+
from icedcoffee_sync.apply.execute import apply_chart
|
|
129
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
130
|
+
from icedcoffee_sync.apply.prune import execute_prune, plan_prune
|
|
131
|
+
from icedcoffee_sync.erpnext.accounts import accounts_with_entries, company_abbr, read_chart
|
|
132
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
133
|
+
|
|
134
|
+
config = SiteConfig(
|
|
135
|
+
url="https://erp.example.com",
|
|
136
|
+
api_key="your-api-key",
|
|
137
|
+
api_secret="your-api-secret",
|
|
138
|
+
)
|
|
139
|
+
company = "Example Company"
|
|
140
|
+
desired = [
|
|
141
|
+
DesiredAccount("5000", "Expenses", None, True, "Expense"),
|
|
142
|
+
DesiredAccount("5202", "Bank Charges", "5000", False, "Expense"),
|
|
143
|
+
]
|
|
144
|
+
with Client(config) as client:
|
|
145
|
+
live = read_chart(client, company)
|
|
146
|
+
entries = accounts_with_entries(client, [a.document_name for a in live])
|
|
147
|
+
plan = diff(desired, live, entries)
|
|
148
|
+
print(plan)
|
|
149
|
+
input("Review the diff; Enter to apply, Ctrl-C to stop: ")
|
|
150
|
+
print(apply_chart(client, plan, live, {a.number: a for a in desired}, company_abbr(client, company), company))
|
|
151
|
+
|
|
152
|
+
live = read_chart(client, company)
|
|
153
|
+
entries = accounts_with_entries(client, [a.document_name for a in live])
|
|
154
|
+
plan = diff(desired, live, entries)
|
|
155
|
+
pruning = plan_prune([extra.live for extra in plan.extras], live, entries)
|
|
156
|
+
print(pruning)
|
|
157
|
+
input("Review the prune plan; Enter to execute, Ctrl-C to stop: ")
|
|
158
|
+
print(execute_prune(client, pruning))
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`diff` reports creates, renames, type changes, re-enables, extras and refusals
|
|
162
|
+
without writes. `apply_chart` runs changes in dependency order; any refusal
|
|
163
|
+
blocks the entire apply, and extras are left alone. `plan_prune` protects roots,
|
|
164
|
+
accounts with ledger entries and parents with surviving children.
|
|
165
|
+
`execute_prune` deletes selected extras, falling back to disabling them for
|
|
166
|
+
certain ERPNext business-rule errors.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
Requires Python 3.14 and [uv](https://docs.astral.sh/uv/).
|
|
171
|
+
|
|
172
|
+
just install # create the virtualenv
|
|
173
|
+
just check # lock, lint, typecheck, deptry
|
|
174
|
+
just test # pytest, excluding tests that need a live site
|
|
175
|
+
just lint-fix # apply ruff fixes
|
|
176
|
+
|
|
177
|
+
Tests marked `live_frappe` hit a real Frappe site and need the
|
|
178
|
+
`ICEDCOFFEE_TEST_*` environment variables; `just test` skips them.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# icedcoffee-sync
|
|
2
|
+
|
|
3
|
+
icedcoffee-sync has three layers: a typed Frappe/ERPNext REST client,
|
|
4
|
+
idempotent document provisioning, and reconciliation of a declared set against
|
|
5
|
+
a site. This is version 0.1.0; today the diff/apply/prune engine handles charts
|
|
6
|
+
of accounts only. The intention is to extend that process to other document types.
|
|
7
|
+
|
|
8
|
+
[Source on GitHub](https://github.com/transpire-dev/icedcoffee-sync)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Requires Python 3.14. To include pre-releases:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install --pre icedcoffee-sync
|
|
16
|
+
# or
|
|
17
|
+
uv add --prerelease allow icedcoffee-sync
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Client
|
|
21
|
+
|
|
22
|
+
`icedcoffee_sync.frappe.client` provides typed sync and async clients.
|
|
23
|
+
`SiteConfig.may_submit` defaults to false, guarding against accidental submits,
|
|
24
|
+
cancellations, known committing methods, and document writes that set `docstatus`.
|
|
25
|
+
The API key's server-side permissions enforce access.
|
|
26
|
+
|
|
27
|
+
Replace the site URL, API credentials, and document names in these examples.
|
|
28
|
+
Both clients return document dictionaries from reads and close connections on exit.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
32
|
+
|
|
33
|
+
config = SiteConfig(
|
|
34
|
+
url="https://erp.example.com",
|
|
35
|
+
api_key="your-api-key",
|
|
36
|
+
api_secret="your-api-secret",
|
|
37
|
+
)
|
|
38
|
+
with Client(config) as client:
|
|
39
|
+
company = client.get("Company", "Example Company")
|
|
40
|
+
print(company["name"])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The async equivalent:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
from icedcoffee_sync.frappe.client import AsyncClient, SiteConfig
|
|
48
|
+
|
|
49
|
+
config = SiteConfig(
|
|
50
|
+
url="https://erp.example.com",
|
|
51
|
+
api_key="your-api-key",
|
|
52
|
+
api_secret="your-api-secret",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
async with AsyncClient(config) as client:
|
|
58
|
+
company = await client.get("Company", "Example Company")
|
|
59
|
+
print(company["name"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `icedcoffee_sync.frappe.access`: provision roles, permission grants, users and API keys, and probe that a key cannot submit.
|
|
66
|
+
- `icedcoffee_sync.site.require_safe_site_url`: call before sending credentials. It refuses a URL that carries a credential, has no hostname, is not HTTPS on port 443, has a path, query or fragment past the site root, or names a host the caller refused. The clients do not call it for you.
|
|
67
|
+
|
|
68
|
+
## Document provisioning
|
|
69
|
+
|
|
70
|
+
`icedcoffee_sync.erpnext.ensure.ensure_by_name` gets a document by name and
|
|
71
|
+
inserts it only if missing, never rewriting one a person changed. It returns
|
|
72
|
+
true for an insert and false for an existing document. Supply a dict whose
|
|
73
|
+
inserted document will have the name you check. This get-or-insert pattern is
|
|
74
|
+
the provisioning layer for Supplier, Item, Purchase Order, Custom Field and
|
|
75
|
+
Webhook documents today.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from icedcoffee_sync.erpnext.ensure import ensure_by_name
|
|
79
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
80
|
+
|
|
81
|
+
config = SiteConfig(
|
|
82
|
+
url="https://erp.example.com",
|
|
83
|
+
api_key="your-api-key",
|
|
84
|
+
api_secret="your-api-secret",
|
|
85
|
+
)
|
|
86
|
+
with Client(config) as client:
|
|
87
|
+
created = ensure_by_name(
|
|
88
|
+
client,
|
|
89
|
+
"Item",
|
|
90
|
+
"EXAMPLE-ITEM",
|
|
91
|
+
{
|
|
92
|
+
"item_code": "EXAMPLE-ITEM",
|
|
93
|
+
"item_name": "Example Item",
|
|
94
|
+
"item_group": "All Item Groups",
|
|
95
|
+
"stock_uom": "Nos",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
print(created)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `icedcoffee_sync.erpnext.purchase`: typed Purchase Order reads and draft Purchase Receipt documents.
|
|
102
|
+
- `icedcoffee_sync.erpnext.company`: validate, set and clear Company default-account links.
|
|
103
|
+
- `icedcoffee_sync.erpnext.comment`: add and list comments on documents.
|
|
104
|
+
|
|
105
|
+
## Reconciliation
|
|
106
|
+
|
|
107
|
+
`icedcoffee_sync.apply` reconciles charts through the synchronous client.
|
|
108
|
+
Declare account numbers and parent numbers; roots must already exist and are
|
|
109
|
+
matched by root type. This example declares an expense root and one child.
|
|
110
|
+
Before pruning, declare **every account you want to keep** in the company.
|
|
111
|
+
|
|
112
|
+
**Warning:** `apply_chart` and `execute_prune` write to the site. Review each
|
|
113
|
+
printed plan before continuing; the example pauses before each write step.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from icedcoffee_sync.apply.diff import diff
|
|
117
|
+
from icedcoffee_sync.apply.execute import apply_chart
|
|
118
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
119
|
+
from icedcoffee_sync.apply.prune import execute_prune, plan_prune
|
|
120
|
+
from icedcoffee_sync.erpnext.accounts import accounts_with_entries, company_abbr, read_chart
|
|
121
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
122
|
+
|
|
123
|
+
config = SiteConfig(
|
|
124
|
+
url="https://erp.example.com",
|
|
125
|
+
api_key="your-api-key",
|
|
126
|
+
api_secret="your-api-secret",
|
|
127
|
+
)
|
|
128
|
+
company = "Example Company"
|
|
129
|
+
desired = [
|
|
130
|
+
DesiredAccount("5000", "Expenses", None, True, "Expense"),
|
|
131
|
+
DesiredAccount("5202", "Bank Charges", "5000", False, "Expense"),
|
|
132
|
+
]
|
|
133
|
+
with Client(config) as client:
|
|
134
|
+
live = read_chart(client, company)
|
|
135
|
+
entries = accounts_with_entries(client, [a.document_name for a in live])
|
|
136
|
+
plan = diff(desired, live, entries)
|
|
137
|
+
print(plan)
|
|
138
|
+
input("Review the diff; Enter to apply, Ctrl-C to stop: ")
|
|
139
|
+
print(apply_chart(client, plan, live, {a.number: a for a in desired}, company_abbr(client, company), company))
|
|
140
|
+
|
|
141
|
+
live = read_chart(client, company)
|
|
142
|
+
entries = accounts_with_entries(client, [a.document_name for a in live])
|
|
143
|
+
plan = diff(desired, live, entries)
|
|
144
|
+
pruning = plan_prune([extra.live for extra in plan.extras], live, entries)
|
|
145
|
+
print(pruning)
|
|
146
|
+
input("Review the prune plan; Enter to execute, Ctrl-C to stop: ")
|
|
147
|
+
print(execute_prune(client, pruning))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`diff` reports creates, renames, type changes, re-enables, extras and refusals
|
|
151
|
+
without writes. `apply_chart` runs changes in dependency order; any refusal
|
|
152
|
+
blocks the entire apply, and extras are left alone. `plan_prune` protects roots,
|
|
153
|
+
accounts with ledger entries and parents with surviving children.
|
|
154
|
+
`execute_prune` deletes selected extras, falling back to disabling them for
|
|
155
|
+
certain ERPNext business-rule errors.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
Requires Python 3.14 and [uv](https://docs.astral.sh/uv/).
|
|
160
|
+
|
|
161
|
+
just install # create the virtualenv
|
|
162
|
+
just check # lock, lint, typecheck, deptry
|
|
163
|
+
just test # pytest, excluding tests that need a live site
|
|
164
|
+
just lint-fix # apply ruff fixes
|
|
165
|
+
|
|
166
|
+
Tests marked `live_frappe` hit a real Frappe site and need the
|
|
167
|
+
`ICEDCOFFEE_TEST_*` environment variables; `just test` skips them.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "icedcoffee-sync"
|
|
3
|
-
version = "0.1.0.
|
|
3
|
+
version = "0.1.0.dev11"
|
|
4
4
|
description = "Typed sync and async client for the Frappe and ERPNext REST API"
|
|
5
|
+
readme = "README.md"
|
|
5
6
|
authors = [{ name = "Gavin Carothers", email = "gavin@carothers.name" }]
|
|
6
7
|
license = "Apache-2.0"
|
|
7
8
|
license-files = ["LICENSE"]
|
|
@@ -1,9 +0,0 @@
|
|
|
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
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
icedcoffee-sync is a typed client and sync engine for Frappe/ERPNext sites.
|
|
2
|
-
|
|
3
|
-
It provides a sync and async client for the Frappe REST API and a
|
|
4
|
-
diff/apply/prune engine that brings a site's documents in line with a declared
|
|
5
|
-
set.
|
|
6
|
-
|
|
7
|
-
## Development
|
|
8
|
-
|
|
9
|
-
Requires Python 3.14 and [uv](https://docs.astral.sh/uv/).
|
|
10
|
-
|
|
11
|
-
just install # create the virtualenv
|
|
12
|
-
just check # lock, lint, typecheck, deptry
|
|
13
|
-
just test # pytest, excluding tests that need a live site
|
|
14
|
-
just lint-fix # apply ruff fixes
|
|
15
|
-
|
|
16
|
-
Tests marked `live_frappe` hit a real Frappe site and need the
|
|
17
|
-
`ICEDCOFFEE_TEST_*` environment variables; `just test` skips them.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/diff.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/execute.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/model.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/order.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/park.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/apply/prune.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/accounts.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/comment.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/company.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/documents.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/ensure.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/purchase.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/erpnext/webhook.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/__init__.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/access.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/client.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/errors.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/lifecycle.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/request.py
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/src/icedcoffee_sync/frappe/response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/error_not_found.json
RENAMED
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/fixtures/error_validation.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{icedcoffee_sync-0.1.0.dev5 → icedcoffee_sync-0.1.0.dev11}/tests/test_erpnext_accounts_reads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|