acumatica-cli 0.14.0__tar.gz → 0.15.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.14.0 → acumatica_cli-0.15.1}/PKG-INFO +16 -4
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/README.md +15 -3
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/pyproject.toml +1 -1
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/cli.py +14 -13
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/config.py +0 -4
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/extract.py +203 -52
- acumatica_cli-0.15.1/src/acumatica_cli/seed_catalog.yaml +450 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/README.md +5 -1
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +2 -21
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +2 -9
- acumatica_cli-0.14.0/src/acumatica_cli/extract_manifest.yaml +0 -152
- acumatica_cli-0.14.0/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +0 -14
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/client.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/seed.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/state.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/target.py +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/env +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/target +0 -0
- {acumatica_cli-0.14.0 → acumatica_cli-0.15.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.15.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>
|
|
@@ -88,7 +88,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
88
88
|
├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
89
89
|
│ capture derived state into state/ (not seed)
|
|
90
90
|
├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
91
|
-
│
|
|
91
|
+
│ inverse of apply into config/{bootstrap,baseline,setup,master}/
|
|
92
92
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
93
93
|
│
|
|
94
94
|
└── config configuration ops
|
|
@@ -101,6 +101,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
101
101
|
A path like `config/` expands nested seed dirs in that fixed order.
|
|
102
102
|
`run` without FILES defaults to `scenario/`.
|
|
103
103
|
`state` without FILES defaults to `config/views/`; writes go to `state/` (`--out`).
|
|
104
|
+
`extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
104
105
|
`acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
|
|
105
106
|
Run `acu <command> --help` for details on any command.
|
|
106
107
|
|
|
@@ -112,7 +113,7 @@ Your configuration lives in its own git repo.
|
|
|
112
113
|
| Path | What it holds |
|
|
113
114
|
| ---- | ------------- |
|
|
114
115
|
| `config/bootstrap/` | virgin-tenant config: features, company, credit terms, `project.xml` |
|
|
115
|
-
| `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs
|
|
116
|
+
| `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
|
|
116
117
|
| `config/setup/` | one-time actions: financial year, master calendar, open periods |
|
|
117
118
|
| `config/master/` | inventory/distribution masters (prefs, warehouse, items, parties) |
|
|
118
119
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
|
|
@@ -128,7 +129,18 @@ The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for
|
|
|
128
129
|
Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
|
|
129
130
|
|
|
130
131
|
Seed YAML is state: `apply` upserts it, `diff` proves it.
|
|
131
|
-
|
|
132
|
+
`acu extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut). Packaged `seed_catalog.yaml` is the sole extract registry (entity, endpoint, keys, file, strip/include, filter-split); the demo entity map in [docs/demo-seed.md](docs/demo-seed.md) mirrors those catalog paths. Features synthesize to `config/bootstrap/features.yaml`. Existing files skip unless `--force`; empty live sets skip; row failures continue (exit 1 only if any row failed — drift stays with `diff`).
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
acu --tenant DEV extract --out . --force # refresh config/** from live tenant
|
|
136
|
+
git diff config/ # review extract delta before commit
|
|
137
|
+
acu --tenant DEV apply config/ # replay extracted seed
|
|
138
|
+
acu --tenant DEV diff config/ # expect exit 0
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Migration (T115–T120 extract hard-cut):** extract no longer writes root `bootstrap/` / `baseline/` / `setup/` / `master/`. Paths are always `config/…`. There is no `--layout`. Move any root-layout extract output under `config/`, or re-extract into a modern data repo. Catalog rename: `extract_manifest.yaml` becomes `seed_catalog.yaml` (package data only; operators do not author it).
|
|
142
|
+
|
|
143
|
+
Scenario YAML is different — it describes transactions that flow forward (not extractable seed).
|
|
132
144
|
`acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
|
|
133
145
|
`once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
|
|
134
146
|
|
|
@@ -70,7 +70,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
70
70
|
├── state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
71
71
|
│ capture derived state into state/ (not seed)
|
|
72
72
|
├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
73
|
-
│
|
|
73
|
+
│ inverse of apply into config/{bootstrap,baseline,setup,master}/
|
|
74
74
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
75
75
|
│
|
|
76
76
|
└── config configuration ops
|
|
@@ -83,6 +83,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
83
83
|
A path like `config/` expands nested seed dirs in that fixed order.
|
|
84
84
|
`run` without FILES defaults to `scenario/`.
|
|
85
85
|
`state` without FILES defaults to `config/views/`; writes go to `state/` (`--out`).
|
|
86
|
+
`extract` always writes under `config/{bootstrap,baseline,setup,master}/` (catalog-driven; never root SEED_DIRS).
|
|
86
87
|
`acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
|
|
87
88
|
Run `acu <command> --help` for details on any command.
|
|
88
89
|
|
|
@@ -94,7 +95,7 @@ Your configuration lives in its own git repo.
|
|
|
94
95
|
| Path | What it holds |
|
|
95
96
|
| ---- | ------------- |
|
|
96
97
|
| `config/bootstrap/` | virgin-tenant config: features, company, credit terms, `project.xml` |
|
|
97
|
-
| `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs
|
|
98
|
+
| `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
|
|
98
99
|
| `config/setup/` | one-time actions: financial year, master calendar, open periods |
|
|
99
100
|
| `config/master/` | inventory/distribution masters (prefs, warehouse, items, parties) |
|
|
100
101
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build, sell |
|
|
@@ -110,7 +111,18 @@ The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for
|
|
|
110
111
|
Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
|
|
111
112
|
|
|
112
113
|
Seed YAML is state: `apply` upserts it, `diff` proves it.
|
|
113
|
-
|
|
114
|
+
`acu extract` is the inverse of `apply`: GET live tenant rows into seed YAML under `config/{bootstrap,baseline,setup,master}/` (hard-cut). Packaged `seed_catalog.yaml` is the sole extract registry (entity, endpoint, keys, file, strip/include, filter-split); the demo entity map in [docs/demo-seed.md](docs/demo-seed.md) mirrors those catalog paths. Features synthesize to `config/bootstrap/features.yaml`. Existing files skip unless `--force`; empty live sets skip; row failures continue (exit 1 only if any row failed — drift stays with `diff`).
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
acu --tenant DEV extract --out . --force # refresh config/** from live tenant
|
|
118
|
+
git diff config/ # review extract delta before commit
|
|
119
|
+
acu --tenant DEV apply config/ # replay extracted seed
|
|
120
|
+
acu --tenant DEV diff config/ # expect exit 0
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Migration (T115–T120 extract hard-cut):** extract no longer writes root `bootstrap/` / `baseline/` / `setup/` / `master/`. Paths are always `config/…`. There is no `--layout`. Move any root-layout extract output under `config/`, or re-extract into a modern data repo. Catalog rename: `extract_manifest.yaml` becomes `seed_catalog.yaml` (package data only; operators do not author it).
|
|
124
|
+
|
|
125
|
+
Scenario YAML is different — it describes transactions that flow forward (not extractable seed).
|
|
114
126
|
`acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
|
|
115
127
|
`once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
|
|
116
128
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.15.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"
|
|
@@ -864,9 +864,9 @@ def run_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
|
|
|
864
864
|
def _complete_only(
|
|
865
865
|
_ctx: click.Context, _param: click.Parameter, incomplete: str
|
|
866
866
|
) -> list[str]:
|
|
867
|
-
"""--only value completion: entity names off the packaged
|
|
867
|
+
"""--only value completion: entity names off the packaged seed catalog.
|
|
868
868
|
|
|
869
|
-
Fires per keystroke, so it stays local-only (V23): the
|
|
869
|
+
Fires per keystroke, so it stays local-only (V23): the catalog is
|
|
870
870
|
package data - never REST, never SSH, never a live instance.
|
|
871
871
|
"""
|
|
872
872
|
return [
|
|
@@ -888,7 +888,7 @@ def _complete_only(
|
|
|
888
888
|
"--only",
|
|
889
889
|
multiple=True,
|
|
890
890
|
shell_complete=_complete_only,
|
|
891
|
-
help="Limit to matching
|
|
891
|
+
help="Limit to matching catalog rows (entity name or file stem); repeatable",
|
|
892
892
|
)
|
|
893
893
|
@click.option("--force", is_flag=True, help="Overwrite existing files")
|
|
894
894
|
@click.option(
|
|
@@ -902,16 +902,17 @@ def extract_cmd(
|
|
|
902
902
|
force: bool,
|
|
903
903
|
dry_run: bool,
|
|
904
904
|
) -> None:
|
|
905
|
-
"""Extract live tenant state into seed YAML
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
905
|
+
"""Extract live tenant state into seed YAML under config/ (inverse of apply).
|
|
906
|
+
|
|
907
|
+
Catalog-driven (packaged ``seed_catalog.yaml`` is the verified entity
|
|
908
|
+
registry): each row is read from the live tenant and written under
|
|
909
|
+
``config/{bootstrap,baseline,setup,master}/`` (hard-cut; never root
|
|
910
|
+
SEED_DIRS; no ``--layout``). Apply and diff consume those files
|
|
911
|
+
unchanged. Features synthesize to ``config/bootstrap/features.yaml``.
|
|
912
|
+
Existing files are skipped unless --force; an entity with no live
|
|
913
|
+
records produces no file. A failing row is reported and the run
|
|
914
|
+
continues (a virgin tenant extracts whole). Exit 0 when every row
|
|
915
|
+
wrote or skipped clean, 1 when any row failed - drift stays with diff.
|
|
915
916
|
"""
|
|
916
917
|
assert_target_compatible(inst)
|
|
917
918
|
with AcumaticaClient(inst) as client:
|
|
@@ -61,10 +61,6 @@ INIT_TEMPLATES = (
|
|
|
61
61
|
"config/baseline/60-ledger-company.yaml",
|
|
62
62
|
),
|
|
63
63
|
("config/baseline/90-uoms.yaml", "config/baseline/90-uoms.yaml"),
|
|
64
|
-
(
|
|
65
|
-
"config/baseline/91-company-packaging.yaml",
|
|
66
|
-
"config/baseline/91-company-packaging.yaml",
|
|
67
|
-
),
|
|
68
64
|
("config/setup/10-financial-year.yaml", "config/setup/10-financial-year.yaml"),
|
|
69
65
|
("config/setup/20-master-calendar.yaml", "config/setup/20-master-calendar.yaml"),
|
|
70
66
|
("config/setup/30-open-periods.yaml", "config/setup/30-open-periods.yaml"),
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
"""Live tenant state to seed YAML: the inverse of apply.
|
|
2
2
|
|
|
3
|
-
Driven by the packaged
|
|
4
|
-
endpoint, key fields, destination file, an optional record filter,
|
|
5
|
-
strip deny-list or include allow-list shaping the extracted records
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Driven by the packaged seed_catalog.yaml - per entity: the source
|
|
4
|
+
endpoint, key fields, destination file, an optional record filter, a
|
|
5
|
+
strip deny-list or include allow-list shaping the extracted records, and
|
|
6
|
+
optional detail_keys for list fields (T60 load_baseline).
|
|
7
|
+
Hard-cut emit under config/{bootstrap,baseline,setup,master}/ (V30/V34):
|
|
8
|
+
no root SEED_DIRS paths, no --layout. Emitted files parse via
|
|
9
|
+
seed.load_baseline by construction (V20: bootstrap-entity rows must
|
|
10
|
+
carry an endpoint) and re-extract byte-identically: records sort by key
|
|
11
|
+
tuple, fields order key-first then alphabetical, None and empty-string
|
|
12
|
+
values are elided.
|
|
11
13
|
|
|
12
14
|
setup/ action files are synthesized, not dumped: an action leaves no
|
|
13
|
-
keyed record to extract, so each
|
|
15
|
+
keyed record to extract, so each catalog setup row's kind-dispatched
|
|
14
16
|
synthesizer reads the live state the action created (the done_when
|
|
15
|
-
surface) and derives the action file back.
|
|
16
|
-
the feature closure (V22/B15): the
|
|
17
|
-
|
|
18
|
-
FeaturesSet read is not available
|
|
19
|
-
BqlDelegate view), so the closure
|
|
17
|
+
surface) and derives the action file back.
|
|
18
|
+
config/bootstrap/features.yaml is the feature closure (V22/B15): the
|
|
19
|
+
built-in six plus the union of the catalog features: gates over
|
|
20
|
+
record-producing entities - a live FeaturesSet read is not available
|
|
21
|
+
over the contract API (keyless BqlDelegate view), so the closure
|
|
22
|
+
derives from what the tenant serves.
|
|
20
23
|
|
|
21
24
|
Extract reads live state and writes local files only - drift stays diff's
|
|
22
25
|
job (exit 2 never happens here).
|
|
@@ -42,13 +45,14 @@ from .client import (
|
|
|
42
45
|
from .models import Model, validation_summary
|
|
43
46
|
from .seed import active_bootstrap, resolve_endpoint
|
|
44
47
|
|
|
45
|
-
# The one non-
|
|
46
|
-
FEATURES_FILE = "bootstrap/features.yaml"
|
|
48
|
+
# The one non-catalog destination: the feature-closure file (V22/B15).
|
|
49
|
+
FEATURES_FILE = "config/bootstrap/features.yaml"
|
|
47
50
|
_SYMBOLIC_BOOTSTRAP = "bootstrap"
|
|
51
|
+
_CATALOG_NAME = "seed_catalog.yaml"
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
class EntitySpec(Model):
|
|
51
|
-
"""One
|
|
55
|
+
"""One catalog row: how a live entity becomes a seed file."""
|
|
52
56
|
|
|
53
57
|
entity: str
|
|
54
58
|
keys: list[str] = Field(min_length=1)
|
|
@@ -61,6 +65,9 @@ class EntitySpec(Model):
|
|
|
61
65
|
strip: list[str] = Field(default_factory=list)
|
|
62
66
|
include: list[str] = Field(default_factory=list)
|
|
63
67
|
features: list[str] = Field(default_factory=list)
|
|
68
|
+
# {ListField: RowKeyField} — emitted into seed so load_baseline accepts
|
|
69
|
+
# detail arrays (T60); required when include/strip keeps a list field.
|
|
70
|
+
detail_keys: dict[str, str] | None = None
|
|
64
71
|
|
|
65
72
|
@model_validator(mode="after")
|
|
66
73
|
def _strip_include_exclusive(self) -> EntitySpec:
|
|
@@ -87,7 +94,7 @@ class SetupSynth(Model):
|
|
|
87
94
|
|
|
88
95
|
|
|
89
96
|
class Manifest(Model):
|
|
90
|
-
"""The parsed
|
|
97
|
+
"""The parsed seed catalog: entity rows plus setup synthesis rows."""
|
|
91
98
|
|
|
92
99
|
entities: list[EntitySpec]
|
|
93
100
|
setup: list[SetupSynth] = Field(default_factory=list)
|
|
@@ -117,16 +124,89 @@ class Manifest(Model):
|
|
|
117
124
|
|
|
118
125
|
|
|
119
126
|
def load_manifest() -> Manifest:
|
|
120
|
-
"""Parse and validate the packaged
|
|
127
|
+
"""Parse and validate the packaged seed catalog."""
|
|
121
128
|
raw = yaml.safe_load(
|
|
122
|
-
(resources.files("acumatica_cli") /
|
|
123
|
-
encoding="utf-8"
|
|
124
|
-
)
|
|
129
|
+
(resources.files("acumatica_cli") / _CATALOG_NAME).read_text(encoding="utf-8")
|
|
125
130
|
)
|
|
126
131
|
try:
|
|
127
132
|
return Manifest.model_validate(raw)
|
|
128
133
|
except ValidationError as exc:
|
|
129
|
-
raise RuntimeError(f"
|
|
134
|
+
raise RuntimeError(f"{_CATALOG_NAME}: {validation_summary(exc)}") from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# V34 exclusions: features synthesis, contract XML, observer views — not seed.
|
|
138
|
+
_TEMPLATE_SEED_EXEMPT_NAMES = frozenset({"features.yaml", "project.xml"})
|
|
139
|
+
_TEMPLATE_SEED_EXEMPT_PREFIXES = ("config/views/",)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def packaged_template_seed_files() -> frozenset[str]:
|
|
143
|
+
"""Packaged ``templates/config/**`` seed paths that V34 requires catalog rows for.
|
|
144
|
+
|
|
145
|
+
Paths are data-repo relative (``config/...``). Excludes features synthesis,
|
|
146
|
+
``project.xml``, and ``config/views/`` observer defs.
|
|
147
|
+
"""
|
|
148
|
+
root = resources.files("acumatica_cli").joinpath("templates", "config")
|
|
149
|
+
out: set[str] = set()
|
|
150
|
+
|
|
151
|
+
def walk(node: Any, prefix: str) -> None:
|
|
152
|
+
for child in node.iterdir():
|
|
153
|
+
name = child.name
|
|
154
|
+
rel = f"{prefix}{name}" if not prefix else f"{prefix}/{name}"
|
|
155
|
+
path = f"config/{rel}"
|
|
156
|
+
if child.is_dir():
|
|
157
|
+
walk(child, rel)
|
|
158
|
+
continue
|
|
159
|
+
if not name.endswith(".yaml"):
|
|
160
|
+
continue
|
|
161
|
+
if name in _TEMPLATE_SEED_EXEMPT_NAMES:
|
|
162
|
+
continue
|
|
163
|
+
if any(path.startswith(p) for p in _TEMPLATE_SEED_EXEMPT_PREFIXES):
|
|
164
|
+
continue
|
|
165
|
+
out.add(path)
|
|
166
|
+
|
|
167
|
+
walk(root, "")
|
|
168
|
+
return frozenset(out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def catalog_seed_files(manifest: Manifest | None = None) -> frozenset[str]:
|
|
172
|
+
"""Catalog entity + setup emit paths (features synthesis is not a catalog row)."""
|
|
173
|
+
m = manifest if manifest is not None else load_manifest()
|
|
174
|
+
return frozenset(s.file for s in m.entities) | frozenset(s.file for s in m.setup)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def catalog_completeness_gap(
|
|
178
|
+
manifest: Manifest | None = None,
|
|
179
|
+
) -> tuple[frozenset[str], frozenset[str]]:
|
|
180
|
+
"""V34 completeness: (missing from catalog, extra in catalog).
|
|
181
|
+
|
|
182
|
+
Empty pair means template seed set equals catalog file set.
|
|
183
|
+
"""
|
|
184
|
+
templates = packaged_template_seed_files()
|
|
185
|
+
catalog = catalog_seed_files(manifest)
|
|
186
|
+
return templates - catalog, catalog - templates
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _expand_for(spec: EntitySpec) -> list[str]:
|
|
190
|
+
"""``$expand`` paths the catalog row needs for details + linked entities.
|
|
191
|
+
|
|
192
|
+
Detail arrays travel only under ``$expand=<ListField>`` (T60); linked
|
|
193
|
+
entities under ``$expand=<Field>`` / nested slash paths (T65). Without
|
|
194
|
+
expand the plain list GET returns top-level scalars alone — extract
|
|
195
|
+
would drop ``Locations`` / ``MainContact`` and re-apply on a virgin
|
|
196
|
+
tenant 422s (Vendor ``Country`` cannot be empty) or loses warehouse
|
|
197
|
+
bins. Detail fields come from ``detail_keys``; ``MainContact`` is the
|
|
198
|
+
packaged linked-entity include (Address nested for Country write-path).
|
|
199
|
+
"""
|
|
200
|
+
paths: list[str] = []
|
|
201
|
+
if spec.detail_keys:
|
|
202
|
+
paths.extend(spec.detail_keys)
|
|
203
|
+
for field in spec.include or ():
|
|
204
|
+
if field in (spec.detail_keys or {}):
|
|
205
|
+
continue
|
|
206
|
+
if field == "MainContact":
|
|
207
|
+
paths.append("MainContact")
|
|
208
|
+
paths.append("MainContact/Address")
|
|
209
|
+
return sorted(set(paths))
|
|
130
210
|
|
|
131
211
|
|
|
132
212
|
def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
@@ -138,39 +218,110 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
|
138
218
|
reads each record through the key-URL single-record GET, which skips
|
|
139
219
|
the optimizer (V4: read-back must survive delegate-view entities).
|
|
140
220
|
|
|
141
|
-
A
|
|
221
|
+
A catalog filter rides both list reads, so the two paths serve the
|
|
142
222
|
same record set and the per-key walk only visits filtered keys.
|
|
223
|
+
``$expand`` rides both paths when the catalog claims detail lists or
|
|
224
|
+
linked entities (T60/T65) — same class of expand as seed.diff.
|
|
143
225
|
"""
|
|
144
226
|
endpoint = resolve_endpoint(spec.endpoint, api_version=client.instance.api_version)
|
|
145
|
-
|
|
227
|
+
params: dict[str, str] = {}
|
|
228
|
+
if spec.filter:
|
|
229
|
+
params["$filter"] = spec.filter
|
|
230
|
+
expand = _expand_for(spec)
|
|
231
|
+
if expand:
|
|
232
|
+
params["$expand"] = ",".join(expand)
|
|
146
233
|
try:
|
|
147
|
-
return client.get_list(spec.entity, params=
|
|
234
|
+
return client.get_list(spec.entity, params=params or None, endpoint=endpoint)
|
|
148
235
|
except RuntimeError as err:
|
|
149
236
|
if OPTIMIZATION_500 not in str(err):
|
|
150
237
|
raise
|
|
151
238
|
key_rows = client.get_list(
|
|
152
239
|
spec.entity,
|
|
153
|
-
params={"$select": ",".join(spec.keys)}
|
|
240
|
+
params={"$select": ",".join(spec.keys)}
|
|
241
|
+
| ({"$filter": spec.filter} if spec.filter else {}),
|
|
154
242
|
endpoint=endpoint,
|
|
155
243
|
)
|
|
244
|
+
expand_params = {"$expand": ",".join(expand)} if expand else None
|
|
156
245
|
records: list[dict[str, Any]] = []
|
|
157
246
|
for row in key_rows:
|
|
158
247
|
values = unwrap(row)
|
|
159
248
|
record = client.get_record(
|
|
160
|
-
spec.entity,
|
|
249
|
+
spec.entity,
|
|
250
|
+
[values[k] for k in spec.keys],
|
|
251
|
+
endpoint,
|
|
252
|
+
params=expand_params,
|
|
161
253
|
)
|
|
162
254
|
if record is not None:
|
|
163
255
|
records.append(record)
|
|
164
256
|
return records
|
|
165
257
|
|
|
166
258
|
|
|
259
|
+
# Server-assigned / audit fields that must never enter seed (B11 class).
|
|
260
|
+
# Applied recursively under linked entities and detail rows after expand
|
|
261
|
+
# (T65/T119): MainContact.ContactID and AllowedCashAccounts.LastModified*
|
|
262
|
+
# would otherwise permanent-red-diff after re-apply on a fresh tenant.
|
|
263
|
+
_SERVER_DERIVED = frozenset(
|
|
264
|
+
{
|
|
265
|
+
"ContactID",
|
|
266
|
+
"CreatedDateTime",
|
|
267
|
+
"LastModifiedDateTime",
|
|
268
|
+
"NoteID",
|
|
269
|
+
"tstamp",
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _elide_server_derived(value: Any) -> Any:
|
|
275
|
+
"""Drop server-derived keys recursively; elide empty nested containers."""
|
|
276
|
+
if isinstance(value, dict):
|
|
277
|
+
cleaned = {
|
|
278
|
+
k: _elide_server_derived(v)
|
|
279
|
+
for k, v in value.items()
|
|
280
|
+
if k not in _SERVER_DERIVED
|
|
281
|
+
and v is not None
|
|
282
|
+
and v != ""
|
|
283
|
+
and not (isinstance(v, (dict, list)) and not v)
|
|
284
|
+
}
|
|
285
|
+
return cleaned
|
|
286
|
+
if isinstance(value, list):
|
|
287
|
+
return [_elide_server_derived(v) for v in value]
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _kept_fields(spec: EntitySpec, record: dict[str, Any]) -> dict[str, Any]:
|
|
292
|
+
"""Apply include/strip + nested server-derived elision to one unwrapped row."""
|
|
293
|
+
keep: dict[str, Any] = {}
|
|
294
|
+
for field, value in record.items():
|
|
295
|
+
if field in spec.keys:
|
|
296
|
+
keep[field] = value
|
|
297
|
+
continue
|
|
298
|
+
if field in _SERVER_DERIVED:
|
|
299
|
+
continue
|
|
300
|
+
if spec.include:
|
|
301
|
+
if field not in spec.include:
|
|
302
|
+
continue
|
|
303
|
+
elif field in spec.strip:
|
|
304
|
+
continue
|
|
305
|
+
# Nested ContactID / LastModifiedDateTime under expand (T65/T119).
|
|
306
|
+
cleaned = _elide_server_derived(value)
|
|
307
|
+
if cleaned is None or cleaned == "":
|
|
308
|
+
continue
|
|
309
|
+
if isinstance(cleaned, (dict, list)) and not cleaned:
|
|
310
|
+
continue
|
|
311
|
+
keep[field] = cleaned
|
|
312
|
+
ordered = {k: keep[k] for k in spec.keys if k in keep}
|
|
313
|
+
ordered |= {k: keep[k] for k in sorted(keep.keys() - set(spec.keys))}
|
|
314
|
+
return ordered
|
|
315
|
+
|
|
316
|
+
|
|
167
317
|
def _shape(spec: EntitySpec, live: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
168
318
|
"""Live records -> byte-stable seed records.
|
|
169
319
|
|
|
170
320
|
Unwrap, apply the strip deny-list or include allow-list (key fields
|
|
171
|
-
always survive), elide None and empty-string values,
|
|
172
|
-
fields
|
|
173
|
-
|
|
321
|
+
always survive), elide None and empty-string values, drop nested
|
|
322
|
+
server-derived fields (B11), order fields key fields first (catalog
|
|
323
|
+
order) then alphabetical, and sort records by key tuple - server
|
|
324
|
+
order never leaks into the emitted bytes.
|
|
174
325
|
|
|
175
326
|
Records duplicating the declared key tuple are a hard error (V25): an
|
|
176
327
|
under-keyed file diffs as permanent false drift and apply collapses
|
|
@@ -185,19 +336,7 @@ def _shape(spec: EntitySpec, live: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|
|
185
336
|
raise RuntimeError(
|
|
186
337
|
f"{spec.entity}: live record missing key field(s) {', '.join(missing)}"
|
|
187
338
|
)
|
|
188
|
-
|
|
189
|
-
field: value
|
|
190
|
-
for field, value in record.items()
|
|
191
|
-
if field in spec.keys
|
|
192
|
-
or (
|
|
193
|
-
(field in spec.include if spec.include else field not in spec.strip)
|
|
194
|
-
and value is not None
|
|
195
|
-
and value != ""
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
ordered = {k: keep[k] for k in spec.keys}
|
|
199
|
-
ordered |= {k: keep[k] for k in sorted(keep.keys() - set(spec.keys))}
|
|
200
|
-
shaped.append(ordered)
|
|
339
|
+
shaped.append(_kept_fields(spec, record))
|
|
201
340
|
shaped.sort(key=lambda r: tuple(str(r[k]) for k in spec.keys))
|
|
202
341
|
idents = [tuple(str(r[k]) for k in spec.keys) for r in shaped]
|
|
203
342
|
for prev, cur in itertools.pairwise(idents):
|
|
@@ -218,6 +357,8 @@ def _render(spec: EntitySpec, records: list[dict[str, Any]]) -> str:
|
|
|
218
357
|
}
|
|
219
358
|
if spec.endpoint is not None:
|
|
220
359
|
doc["endpoint"] = spec.endpoint
|
|
360
|
+
if spec.detail_keys:
|
|
361
|
+
doc["detail_keys"] = dict(spec.detail_keys)
|
|
221
362
|
doc["records"] = records
|
|
222
363
|
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
|
|
223
364
|
|
|
@@ -289,7 +430,7 @@ def _synth_open_periods(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
|
289
430
|
return None
|
|
290
431
|
years = _years(live)
|
|
291
432
|
# OrganizationID = the extracted Company's AcctCD: the reference
|
|
292
|
-
# resolves inside the emitted set (V22 - bootstrap/company.yaml
|
|
433
|
+
# resolves inside the emitted set (V22 - config/bootstrap/company.yaml
|
|
293
434
|
# creates the organization the action names)
|
|
294
435
|
companies = client.get_list("Company", endpoint=ep)
|
|
295
436
|
if not companies:
|
|
@@ -316,7 +457,7 @@ def _synth_open_periods(client: AcumaticaClient) -> dict[str, Any] | None:
|
|
|
316
457
|
|
|
317
458
|
|
|
318
459
|
# kind -> (synthesizer, skip reason when the live state is absent);
|
|
319
|
-
# SetupSynth validates
|
|
460
|
+
# SetupSynth validates catalog kinds against this registry
|
|
320
461
|
type Synthesizer = Callable[[AcumaticaClient], dict[str, Any] | None]
|
|
321
462
|
SYNTHESIZERS: dict[str, tuple[Synthesizer, str]] = {
|
|
322
463
|
"financial-year": (_synth_financial_year, "no financial year setup"),
|
|
@@ -326,7 +467,7 @@ SYNTHESIZERS: dict[str, tuple[Synthesizer, str]] = {
|
|
|
326
467
|
|
|
327
468
|
|
|
328
469
|
def render_features(gates: Iterable[str]) -> str:
|
|
329
|
-
"""The feature-closure bootstrap/features.yaml: built-in six + gates.
|
|
470
|
+
"""The feature-closure config/bootstrap/features.yaml: built-in six + gates.
|
|
330
471
|
|
|
331
472
|
Deterministic order (byte-stable re-extract): the built-in six in
|
|
332
473
|
their bootstrap.DEFAULT_FEATURES spelling, then the extra gates
|
|
@@ -368,6 +509,11 @@ class _Extraction:
|
|
|
368
509
|
"""The --only filter: row name (entity or kind) or file stem."""
|
|
369
510
|
return not self.only or name in self.only or Path(file).stem in self.only
|
|
370
511
|
|
|
512
|
+
def _progress(self, target: Path, name: str) -> None:
|
|
513
|
+
"""Per-row progress banner matching apply/diff (V9 / I.cmd extract)."""
|
|
514
|
+
inst = self.client.instance
|
|
515
|
+
output.data(f"{target} -> {inst.tenant} on {inst.base_url} ({name})")
|
|
516
|
+
|
|
371
517
|
def _skip(self, target: Path, reason: str) -> None:
|
|
372
518
|
"""Report one clean per-file skip and tally it."""
|
|
373
519
|
output.data(f"skip {target} ({reason})")
|
|
@@ -386,7 +532,7 @@ class _Extraction:
|
|
|
386
532
|
A PXSetupNotEnteredException 500 is the virgin-tenant empty-state
|
|
387
533
|
class — the screen has no data to extract, same answer as 200 [] —
|
|
388
534
|
so it skips clean. Anything else is a reported row failure; the
|
|
389
|
-
run continues to the next
|
|
535
|
+
run continues to the next catalog row either way.
|
|
390
536
|
"""
|
|
391
537
|
if SETUP_NOT_ENTERED_500 in str(err):
|
|
392
538
|
self._skip(target, "screen setup not entered")
|
|
@@ -412,6 +558,8 @@ class _Extraction:
|
|
|
412
558
|
if not self._selected(spec.entity, spec.file):
|
|
413
559
|
continue
|
|
414
560
|
target = self.out_dir / spec.file
|
|
561
|
+
# Banner before any outcome (write/skip/would write) — apply shape
|
|
562
|
+
self._progress(target, spec.entity)
|
|
415
563
|
if self._skip_existing(target):
|
|
416
564
|
continue
|
|
417
565
|
# Hybrid contract (T69/T81): a bootstrap-endpoint row for an
|
|
@@ -443,6 +591,7 @@ class _Extraction:
|
|
|
443
591
|
if not self._selected(synth.kind, synth.file):
|
|
444
592
|
continue
|
|
445
593
|
target = self.out_dir / synth.file
|
|
594
|
+
self._progress(target, synth.kind)
|
|
446
595
|
if self._skip_existing(target):
|
|
447
596
|
continue
|
|
448
597
|
synthesize, skip_reason = SYNTHESIZERS[synth.kind]
|
|
@@ -473,6 +622,7 @@ class _Extraction:
|
|
|
473
622
|
if not self._selected("features", FEATURES_FILE):
|
|
474
623
|
return
|
|
475
624
|
target = self.out_dir / FEATURES_FILE
|
|
625
|
+
self._progress(target, "features")
|
|
476
626
|
if self._skip_existing(target):
|
|
477
627
|
return
|
|
478
628
|
gates = [
|
|
@@ -492,11 +642,12 @@ def run(
|
|
|
492
642
|
force: bool = False,
|
|
493
643
|
dry_run: bool = False,
|
|
494
644
|
) -> int:
|
|
495
|
-
"""Extract the
|
|
645
|
+
"""Extract the catalog file set plus the feature closure under out_dir.
|
|
496
646
|
|
|
497
|
-
|
|
498
|
-
tenant has no records,
|
|
499
|
-
rows by entity name,
|
|
647
|
+
Paths hard-cut under config/ SEED_DIRS (V30). Per file: skip when it
|
|
648
|
+
exists (--force overwrites), skip when the tenant has no records,
|
|
649
|
+
report-only under --dry-run. `only` filters rows by entity name,
|
|
650
|
+
synthesizer kind, or file stem.
|
|
500
651
|
|
|
501
652
|
A failing row is reported and the run continues (V24) - the return
|
|
502
653
|
value is the failed-row count, 0 when every row wrote or skipped clean
|