acumatica-cli 0.31.0__tar.gz → 0.33.1__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.
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/PKG-INFO +4 -4
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/README.md +3 -3
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/pyproject.toml +1 -1
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/pyproject.toml.orig +1 -1
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/client.py +25 -15
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/extract.py +28 -3
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/seed.py +62 -7
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/seed_catalog.yaml +4 -3
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/cli.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/config.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/inventory.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/reconcile.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/snapshot_map.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/state.py +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/README.md +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/bootstrap/segmented-key.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/05-numbering-sequences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/90-roles.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/91-users.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/master/92-role-users.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/README.md +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/default-24.200.001/README.md +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/default-24.200.001/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
- {acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/tenant.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: acumatica-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.33.1
|
|
4
4
|
Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
|
|
5
5
|
Author: Konstantin Borovik
|
|
6
6
|
Author-email: Konstantin Borovik <kb@lab5.ca>
|
|
@@ -257,10 +257,10 @@ Or clone and install editable for development:
|
|
|
257
257
|
```sh
|
|
258
258
|
git clone https://github.com/kborovik/acumatica-cli.git
|
|
259
259
|
cd acumatica-cli
|
|
260
|
-
gmake install #
|
|
260
|
+
gmake install # uv sync; does not replace the PyPI tool
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
-
Verify with `acu --version
|
|
263
|
+
Verify with `uv run acu --version` (`+dev`).
|
|
264
264
|
|
|
265
265
|
## Configuration
|
|
266
266
|
|
|
@@ -351,7 +351,7 @@ Elsewhere plain `make` is fine when it is GNU Make.
|
|
|
351
351
|
```sh
|
|
352
352
|
git clone https://github.com/kborovik/acumatica-cli.git
|
|
353
353
|
cd acumatica-cli
|
|
354
|
-
gmake install #
|
|
354
|
+
gmake install # uv sync; does not replace the PyPI tool
|
|
355
355
|
gmake check # offline gate: ruff, basedpyright strict, pytest
|
|
356
356
|
```
|
|
357
357
|
|
|
@@ -239,10 +239,10 @@ Or clone and install editable for development:
|
|
|
239
239
|
```sh
|
|
240
240
|
git clone https://github.com/kborovik/acumatica-cli.git
|
|
241
241
|
cd acumatica-cli
|
|
242
|
-
gmake install #
|
|
242
|
+
gmake install # uv sync; does not replace the PyPI tool
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
Verify with `acu --version
|
|
245
|
+
Verify with `uv run acu --version` (`+dev`).
|
|
246
246
|
|
|
247
247
|
## Configuration
|
|
248
248
|
|
|
@@ -333,7 +333,7 @@ Elsewhere plain `make` is fine when it is GNU Make.
|
|
|
333
333
|
```sh
|
|
334
334
|
git clone https://github.com/kborovik/acumatica-cli.git
|
|
335
335
|
cd acumatica-cli
|
|
336
|
-
gmake install #
|
|
336
|
+
gmake install # uv sync; does not replace the PyPI tool
|
|
337
337
|
gmake check # offline gate: ruff, basedpyright strict, pytest
|
|
338
338
|
```
|
|
339
339
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.33.1"
|
|
4
4
|
description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
|
|
5
5
|
authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
|
|
6
6
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
@@ -12,6 +12,11 @@ import httpx
|
|
|
12
12
|
|
|
13
13
|
from .config import Instance
|
|
14
14
|
|
|
15
|
+
# Contract control fields travel bare on wrap and stay on unwrap of
|
|
16
|
+
# value-field rows (V54). Files-style rows that unwrap to only these
|
|
17
|
+
# keys are elided so expanded `files` descriptors stay noise.
|
|
18
|
+
_BARE_FIELDS = frozenset({"id", "delete"})
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
def wrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
17
22
|
"""Plain dict -> contract-API body: {"Field": {"value": ...}}.
|
|
@@ -33,31 +38,35 @@ def wrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
33
38
|
return wrap(v)
|
|
34
39
|
return {"value": v}
|
|
35
40
|
|
|
36
|
-
return {k: v if k in
|
|
41
|
+
return {k: v if k in _BARE_FIELDS else _value(v) for k, v in record.items()}
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
|
|
40
45
|
"""Contract-API entity -> plain dict (value fields + detail arrays).
|
|
41
46
|
|
|
42
|
-
Inverse of wrap (T60/T65): a list of dicts unwraps row by row;
|
|
43
|
-
that are empty or whose rows carry no value fields are elided
|
|
44
|
-
detail array the source never claimed must not surface, and
|
|
45
|
-
`files` descriptors are plain dicts that would unwrap to
|
|
46
|
-
non-value dict is a linked entity — unwrapped recursively,
|
|
47
|
-
when something survives, so bookkeeping dicts (custom,
|
|
48
|
-
stay excluded exactly as before.
|
|
47
|
+
Inverse of wrap (T60/T65/V54): a list of dicts unwraps row by row;
|
|
48
|
+
lists that are empty or whose rows carry no value fields are elided
|
|
49
|
+
(a detail array the source never claimed must not surface, and
|
|
50
|
+
expanded `files` descriptors are plain dicts that would unwrap to
|
|
51
|
+
noise). A non-value dict is a linked entity — unwrapped recursively,
|
|
52
|
+
kept only when something survives, so bookkeeping dicts (custom,
|
|
53
|
+
_links, note) stay excluded exactly as before. `id` and `delete`
|
|
54
|
+
stay on records and on detail rows that also have value fields so a
|
|
55
|
+
later PUT updates the existing line (V54).
|
|
49
56
|
"""
|
|
50
57
|
out: dict[str, Any] = {}
|
|
51
58
|
for k, v in entity.items():
|
|
52
|
-
if isinstance(v, dict)
|
|
59
|
+
if k in _BARE_FIELDS and not isinstance(v, (dict, list)):
|
|
60
|
+
out[k] = v
|
|
61
|
+
elif isinstance(v, dict) and "value" in v:
|
|
53
62
|
out[k] = v["value"]
|
|
54
63
|
elif isinstance(v, dict):
|
|
55
64
|
nested = unwrap(v)
|
|
56
65
|
if nested:
|
|
57
66
|
out[k] = nested
|
|
58
67
|
elif isinstance(v, list) and v and all(isinstance(row, dict) for row in v):
|
|
59
|
-
rows = [unwrap(
|
|
60
|
-
if
|
|
68
|
+
rows = [row for row in (unwrap(r) for r in v) if set(row) - _BARE_FIELDS]
|
|
69
|
+
if rows:
|
|
61
70
|
out[k] = rows
|
|
62
71
|
return out
|
|
63
72
|
|
|
@@ -326,7 +335,7 @@ class AcumaticaClient:
|
|
|
326
335
|
|
|
327
336
|
@staticmethod
|
|
328
337
|
def _checked(r: httpx.Response) -> httpx.Response:
|
|
329
|
-
"""Surface exceptionMessage +
|
|
338
|
+
"""Surface exceptionMessage + innerException + Field.error (V46)."""
|
|
330
339
|
if r.is_error:
|
|
331
340
|
detail = ""
|
|
332
341
|
try:
|
|
@@ -338,12 +347,13 @@ class AcumaticaClient:
|
|
|
338
347
|
or body.get("error")
|
|
339
348
|
or ""
|
|
340
349
|
)
|
|
341
|
-
|
|
342
|
-
if not detail and isinstance(body.get("innerException"), dict):
|
|
350
|
+
if isinstance(body.get("innerException"), dict):
|
|
343
351
|
inner = body["innerException"]
|
|
344
|
-
|
|
352
|
+
inner_msg = (
|
|
345
353
|
inner.get("exceptionMessage") or inner.get("message") or ""
|
|
346
354
|
)
|
|
355
|
+
if inner_msg:
|
|
356
|
+
detail = f"{detail}; {inner_msg}" if detail else inner_msg
|
|
347
357
|
field_errs = AcumaticaClient._field_errors(body)
|
|
348
358
|
if field_errs:
|
|
349
359
|
joined = "; ".join(field_errs)
|
|
@@ -46,6 +46,8 @@ from .models import Model, validation_summary
|
|
|
46
46
|
from .seed import (
|
|
47
47
|
NUMBERING_RUNTIME_FIELDS,
|
|
48
48
|
PASSWORD_FIELDS,
|
|
49
|
+
_calendar_day, # pyright: ignore[reportPrivateUsage]
|
|
50
|
+
_drop_path_ignored, # pyright: ignore[reportPrivateUsage]
|
|
49
51
|
active_bootstrap,
|
|
50
52
|
resolve_endpoint,
|
|
51
53
|
)
|
|
@@ -263,14 +265,16 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
|
263
265
|
|
|
264
266
|
# Server-assigned / audit fields that must never enter seed (B11 class).
|
|
265
267
|
# Applied recursively under linked entities and detail rows after expand
|
|
266
|
-
# (T65/T119)
|
|
267
|
-
#
|
|
268
|
+
# (T65/T119). `id`/`delete` stay in unwrap for run-capture PUT (V54);
|
|
269
|
+
# extract still strips them so seed is tenant-portable.
|
|
268
270
|
_SERVER_DERIVED = frozenset(
|
|
269
271
|
{
|
|
270
272
|
"ContactID",
|
|
271
273
|
"CreatedDateTime",
|
|
272
274
|
"LastModifiedDateTime",
|
|
273
275
|
"NoteID",
|
|
276
|
+
"delete",
|
|
277
|
+
"id",
|
|
274
278
|
"tstamp",
|
|
275
279
|
}
|
|
276
280
|
)
|
|
@@ -293,6 +297,26 @@ def _elide_server_derived(value: Any) -> Any:
|
|
|
293
297
|
return value
|
|
294
298
|
|
|
295
299
|
|
|
300
|
+
def _date_strip(value: Any) -> Any:
|
|
301
|
+
"""DateTimeValue seed emit: keep YYYY-MM-DD prefix only (V55)."""
|
|
302
|
+
if isinstance(value, dict):
|
|
303
|
+
return {k: _date_strip(v) for k, v in value.items()}
|
|
304
|
+
if isinstance(value, list):
|
|
305
|
+
return [_date_strip(v) for v in value]
|
|
306
|
+
if isinstance(value, str):
|
|
307
|
+
day = _calendar_day(value)
|
|
308
|
+
if day is not None:
|
|
309
|
+
return day
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_write_only_get_omit(entity: str, field: str, value: Any) -> Any:
|
|
314
|
+
"""Drop path-qualified write-only GET-omit fields from detail rows (V56)."""
|
|
315
|
+
if not isinstance(value, list):
|
|
316
|
+
return value
|
|
317
|
+
return _drop_path_ignored(entity, field, value)
|
|
318
|
+
|
|
319
|
+
|
|
296
320
|
def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
297
321
|
"""Apply include/strip + nested server-derived elision to one unwrapped row."""
|
|
298
322
|
keep: dict[str, Any] = {}
|
|
@@ -313,6 +337,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
313
337
|
continue
|
|
314
338
|
# Nested ContactID / LastModifiedDateTime under expand (T65/T119).
|
|
315
339
|
cleaned = _elide_server_derived(value)
|
|
340
|
+
cleaned = _strip_write_only_get_omit(spec.entity, field, cleaned)
|
|
316
341
|
if cleaned is None or cleaned == "":
|
|
317
342
|
continue
|
|
318
343
|
if isinstance(cleaned, (dict, list)) and not cleaned:
|
|
@@ -320,7 +345,7 @@ def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
320
345
|
keep[field] = cleaned
|
|
321
346
|
ordered = {k: keep[k] for k in spec.keys if k in keep}
|
|
322
347
|
ordered |= {k: keep[k] for k in sorted(keep.keys() - set(spec.keys))}
|
|
323
|
-
return ordered
|
|
348
|
+
return _date_strip(ordered)
|
|
324
349
|
|
|
325
350
|
|
|
326
351
|
def _shape(spec: EntitySpec, live: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
@@ -52,6 +52,7 @@ Default Currency = CM201000 list), so an ambiguous file is a hard error,
|
|
|
52
52
|
never a silent Default-endpoint PUT.
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
+
import re
|
|
55
56
|
from pathlib import Path
|
|
56
57
|
from typing import Any
|
|
57
58
|
|
|
@@ -88,7 +89,40 @@ PASSWORD_FIELDS = frozenset({"Password", "b64__Password"})
|
|
|
88
89
|
# PUTs them (re-apply must not reset live counters).
|
|
89
90
|
NUMBERING_RUNTIME_FIELDS = frozenset({"LastNbr"})
|
|
90
91
|
# Fields omitted from source↔live compare (V39 write-only + V40 runtime).
|
|
92
|
+
# Field-name deny at every nesting level. Do not add bare ``Value`` here —
|
|
93
|
+
# that would hide real drift on unrelated fields (V56).
|
|
91
94
|
_DIFF_IGNORE_FIELDS = PASSWORD_FIELDS | NUMBERING_RUNTIME_FIELDS
|
|
95
|
+
# Path-qualified write-only GET-omit (V56): (entity, detail, field).
|
|
96
|
+
# Diff ignores; extract strips; apply still PUTs when present in YAML.
|
|
97
|
+
_DIFF_IGNORE_PATHS = frozenset({("LotSerialClass", "Segments", "Value")})
|
|
98
|
+
# Whole-detail skip (V57/B37): mapped GET Role.Users / User.Roles may be
|
|
99
|
+
# empty while UsersInRoles has session-company rows. Extra live members
|
|
100
|
+
# are not drift (AssignUser is additive-only).
|
|
101
|
+
_DIFF_SKIP_DETAILS = frozenset({("Role", "Users"), ("User", "Roles")})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _diff_ignored(entity: str, *path: str) -> bool:
|
|
105
|
+
"""True when source↔live compare skips this field (V39/V40/V56/V57)."""
|
|
106
|
+
if path and path[-1] in _DIFF_IGNORE_FIELDS:
|
|
107
|
+
return True
|
|
108
|
+
if len(path) == 1 and (entity, path[0]) in _DIFF_SKIP_DETAILS:
|
|
109
|
+
return True
|
|
110
|
+
return (entity, *path) in _DIFF_IGNORE_PATHS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _drop_path_ignored(entity: str, field: str, rows: list[Any]) -> list[Any]:
|
|
114
|
+
"""Strip path-qualified GET-omit fields from detail rows (V56)."""
|
|
115
|
+
drop = {
|
|
116
|
+
sub for (ent, det, sub) in _DIFF_IGNORE_PATHS if ent == entity and det == field
|
|
117
|
+
}
|
|
118
|
+
if not drop:
|
|
119
|
+
return rows
|
|
120
|
+
return [
|
|
121
|
+
{k: v for k, v in row.items() if k not in drop}
|
|
122
|
+
if isinstance(row, dict)
|
|
123
|
+
else row
|
|
124
|
+
for row in rows
|
|
125
|
+
]
|
|
92
126
|
|
|
93
127
|
|
|
94
128
|
def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
|
|
@@ -251,18 +285,33 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
|
|
|
251
285
|
return parsed
|
|
252
286
|
|
|
253
287
|
|
|
288
|
+
# ISO date or DateTimeValue: calendar-day prefix only (V55). Random strings
|
|
289
|
+
# that merely contain T stay untouched.
|
|
290
|
+
_ISO_CALENDAR_DAY = re.compile(r"(\d{4}-\d{2}-\d{2})(T.*)?")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _calendar_day(text: str) -> str | None:
|
|
294
|
+
"""YYYY-MM-DD when ``text`` is ISO date/datetime; else None. No TZ convert."""
|
|
295
|
+
m = _ISO_CALENDAR_DAY.fullmatch(text)
|
|
296
|
+
return None if m is None else m.group(1)
|
|
297
|
+
|
|
298
|
+
|
|
254
299
|
def _norm(value: Any) -> str:
|
|
255
|
-
"""Comparable form: bools case-folded, numbers by value,
|
|
300
|
+
"""Comparable form: bools case-folded, numbers by value, dates by day.
|
|
256
301
|
|
|
257
302
|
Numbers compare by value, not spelling - a YAML `0` against the
|
|
258
303
|
endpoint's `0.0` (DecimalValue fields come back as floats) is not
|
|
259
|
-
drift (T13).
|
|
304
|
+
drift (T13). Date-only ``YYYY-MM-DD`` matches a live DateTimeValue on
|
|
305
|
+
the same calendar day (strip ``T...offset``); a different day still
|
|
306
|
+
drifts (V55). No timezone conversion.
|
|
260
307
|
"""
|
|
261
308
|
if isinstance(value, bool):
|
|
262
309
|
return str(value).lower()
|
|
263
310
|
if isinstance(value, int | float):
|
|
264
311
|
return repr(float(value))
|
|
265
|
-
|
|
312
|
+
text = str(value).strip()
|
|
313
|
+
day = _calendar_day(text)
|
|
314
|
+
return text if day is None else day
|
|
266
315
|
|
|
267
316
|
|
|
268
317
|
def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
|
|
@@ -636,13 +685,19 @@ def diff(client: AcumaticaClient, baseline: BaselineFile | ActionFile) -> list[s
|
|
|
636
685
|
drifts.append(f"{label}: missing on tenant")
|
|
637
686
|
continue
|
|
638
687
|
actual = unwrap(live)
|
|
639
|
-
# V39/V40: never compare write-only
|
|
640
|
-
|
|
688
|
+
# V39/V40/V56/V57: never compare write-only, runtime, GET-omit,
|
|
689
|
+
# or membership details whose mapped GET may stay empty.
|
|
690
|
+
fields = {
|
|
691
|
+
k: v for k, v in record.items() if not _diff_ignored(baseline.entity, k)
|
|
692
|
+
}
|
|
641
693
|
for field, expected in fields.items():
|
|
642
694
|
if isinstance(expected, list):
|
|
643
695
|
key = (baseline.detail_keys or {})[field] # load-validated
|
|
644
|
-
|
|
645
|
-
|
|
696
|
+
want_rows = _drop_path_ignored(baseline.entity, field, expected)
|
|
697
|
+
live_rows = _drop_path_ignored(
|
|
698
|
+
baseline.entity, field, actual.get(field, [])
|
|
699
|
+
)
|
|
700
|
+
drifts.extend(_diff_details(label, field, key, want_rows, live_rows))
|
|
646
701
|
elif isinstance(expected, dict):
|
|
647
702
|
drifts.extend(
|
|
648
703
|
_diff_nested(f"{label}.{field}", expected, actual.get(field))
|
|
@@ -535,9 +535,10 @@ entities:
|
|
|
535
535
|
# config/master/ Role then User then Role.Users (V22 prefixes 90/91/92;
|
|
536
536
|
# T145/T146/T148/T228). Bootstrap only — Default has no Role/User surface.
|
|
537
537
|
# Persist = Role action AssignUser (PXDatabase UsersInRoles; T228/B33).
|
|
538
|
-
# User detail Roles
|
|
539
|
-
# B31/B32/B33).
|
|
540
|
-
#
|
|
538
|
+
# User detail Roles = GET/extract; diff skips Users/Roles (V57).
|
|
539
|
+
# PUT never writes UsersInRoles (B31/B32/B33).
|
|
540
|
+
# Package 90-roles = full pre-build CompanyID=1 Roles dump;
|
|
541
|
+
# 91-users demo soadmin identity; 92-role-users membership.
|
|
541
542
|
# Password is write-only on apply (V39/T147): include omits Password so
|
|
542
543
|
# extract never emits hashes (b64__Password also out of include).
|
|
543
544
|
|
|
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
|
|
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
|
|
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
|
|
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
|
{acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/overlays/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/20-buy.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/30-build.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.31.0 → acumatica_cli-0.33.1}/src/acumatica_cli/templates/scenario/40-sell.yaml
RENAMED
|
File without changes
|
|
File without changes
|