acumatica-cli 0.9.0__tar.gz → 0.10.0__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.9.0 → acumatica_cli-0.10.0}/PKG-INFO +50 -15
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/README.md +49 -14
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/pyproject.toml +1 -1
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/cli.py +104 -17
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/client.py +107 -23
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/config.py +15 -2
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/extract.py +4 -2
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/seed.py +61 -11
- acumatica_cli-0.10.0/src/acumatica_cli/target.py +100 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/env +3 -0
- acumatica_cli-0.10.0/src/acumatica_cli/templates/target +6 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/gitignore +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/tenant.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: acumatica-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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>
|
|
@@ -38,8 +38,9 @@ uv tool install acumatica-cli
|
|
|
38
38
|
|
|
39
39
|
acu config init --host erp.example.com my-erp
|
|
40
40
|
cd my-erp # edit .env: set ACU_PASSWORD
|
|
41
|
+
# keep ACU_API_VERSION in sync with target.yaml
|
|
41
42
|
|
|
42
|
-
acu config check # read-only preflight
|
|
43
|
+
acu config check # read-only preflight (incl. target.yaml)
|
|
43
44
|
acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
|
|
44
45
|
acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
|
|
45
46
|
acu --tenant DEV diff # prove zero drift (exit 2 on drift)
|
|
@@ -79,9 +80,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
79
80
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
80
81
|
│
|
|
81
82
|
└── config configuration ops
|
|
82
|
-
├── init [--host HOST] [DIR] scaffold a data repo (.env
|
|
83
|
+
├── init [--host HOST] [DIR] scaffold a data repo (.env, target.yaml, example YAML)
|
|
83
84
|
├── show print the resolved config as a complete .env
|
|
84
|
-
└── check
|
|
85
|
+
└── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
|
|
85
86
|
```
|
|
86
87
|
|
|
87
88
|
`apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
|
|
@@ -93,20 +94,36 @@ Run `acu <command> --help` for details on any command.
|
|
|
93
94
|
|
|
94
95
|
Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
|
|
95
96
|
|
|
96
|
-
| Path
|
|
97
|
-
|
|
|
98
|
-
| `bootstrap/`
|
|
99
|
-
| `baseline/`
|
|
100
|
-
| `setup/`
|
|
101
|
-
| `scenario/`
|
|
102
|
-
|
|
|
97
|
+
| Path | What it holds |
|
|
98
|
+
| ------------- | -------------------------------------------------------------------------- |
|
|
99
|
+
| `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
|
|
100
|
+
| `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
|
|
101
|
+
| `setup/` | one-time actions: financial year, master calendar, open periods |
|
|
102
|
+
| `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
|
|
103
|
+
| `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
|
|
104
|
+
| `.env` | where to apply and who signs in, every key an `ACU_*` variable |
|
|
103
105
|
|
|
104
|
-
Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
|
|
106
|
+
Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
|
|
107
|
+
The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
|
|
108
|
+
Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
|
|
105
109
|
|
|
106
110
|
Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
|
|
107
111
|
Scenario YAML is different — it describes transactions that flow forward.
|
|
108
112
|
`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 a scenario re-runs safely on a warm tenant.
|
|
109
113
|
|
|
114
|
+
### Seed `endpoint:` symbols
|
|
115
|
+
|
|
116
|
+
Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
|
|
117
|
+
|
|
118
|
+
| Value | Resolves to |
|
|
119
|
+
| ----- | ----------- |
|
|
120
|
+
| omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
|
|
121
|
+
| `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
|
|
122
|
+
| `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
|
|
123
|
+
| `Bootstrap/1.9.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
|
|
124
|
+
|
|
125
|
+
Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
|
|
126
|
+
|
|
110
127
|
## Installation
|
|
111
128
|
|
|
112
129
|
Requires Python 3.14 or newer.
|
|
@@ -125,16 +142,32 @@ Verify with `acu --version`.
|
|
|
125
142
|
|
|
126
143
|
## Configuration
|
|
127
144
|
|
|
128
|
-
Everything lives in one `.env` file: *where* to apply and *who* signs in.
|
|
145
|
+
Everything lives in one `.env` file: *where* to apply and *who* signs in.
|
|
146
|
+
Three values are required; everything else has a code default matching a stock Acumatica install:
|
|
129
147
|
|
|
130
148
|
```sh
|
|
131
149
|
ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
|
|
132
150
|
ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
|
|
133
151
|
ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
|
|
152
|
+
ACU_API_VERSION=25.200.001 # Default contract version half only
|
|
134
153
|
ACU_USER=admin # optional, defaults to admin
|
|
135
154
|
ACU_PASSWORD=... # required for live commands
|
|
136
155
|
```
|
|
137
156
|
|
|
157
|
+
`ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
|
|
158
|
+
A full path would nest as `/entity/Default/Default/...`.
|
|
159
|
+
|
|
160
|
+
The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
erp: "26.101.0225" # claimed product line/build (README-level detail)
|
|
164
|
+
default_api: "25.200.001" # must match ACU_API_VERSION
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
|
|
168
|
+
`acu config check` reports the same match as a probe line.
|
|
169
|
+
Missing `target.yaml` only warns on check unless you pass `--strict`.
|
|
170
|
+
|
|
138
171
|
Worth knowing:
|
|
139
172
|
|
|
140
173
|
- The file is found by walking up from the current directory, so any subdirectory of the data repo works.
|
|
@@ -143,13 +176,15 @@ Worth knowing:
|
|
|
143
176
|
- Leave it blank on hosted instances; only `acu tenant` needs it.
|
|
144
177
|
- Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
|
|
145
178
|
- `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
|
|
179
|
+
- When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
|
|
146
180
|
- Redirect it to turn resolved state into a working config: `acu config show > .env`.
|
|
147
181
|
|
|
148
182
|
Verify before touching anything live:
|
|
149
183
|
|
|
150
184
|
```sh
|
|
151
|
-
acu config check
|
|
152
|
-
acu
|
|
185
|
+
acu config check # discovery, secrets, target, REST, endpoints, SSH
|
|
186
|
+
acu config check --strict # missing target.yaml becomes fail
|
|
187
|
+
acu apply --dry-run # show what would be written, write nothing
|
|
153
188
|
```
|
|
154
189
|
|
|
155
190
|
## Control and Data Planes
|
|
@@ -21,8 +21,9 @@ uv tool install acumatica-cli
|
|
|
21
21
|
|
|
22
22
|
acu config init --host erp.example.com my-erp
|
|
23
23
|
cd my-erp # edit .env: set ACU_PASSWORD
|
|
24
|
+
# keep ACU_API_VERSION in sync with target.yaml
|
|
24
25
|
|
|
25
|
-
acu config check # read-only preflight
|
|
26
|
+
acu config check # read-only preflight (incl. target.yaml)
|
|
26
27
|
acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
|
|
27
28
|
acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
|
|
28
29
|
acu --tenant DEV diff # prove zero drift (exit 2 on drift)
|
|
@@ -62,9 +63,9 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
62
63
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
63
64
|
│
|
|
64
65
|
└── config configuration ops
|
|
65
|
-
├── init [--host HOST] [DIR] scaffold a data repo (.env
|
|
66
|
+
├── init [--host HOST] [DIR] scaffold a data repo (.env, target.yaml, example YAML)
|
|
66
67
|
├── show print the resolved config as a complete .env
|
|
67
|
-
└── check
|
|
68
|
+
└── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
`apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`.
|
|
@@ -76,20 +77,36 @@ Run `acu <command> --help` for details on any command.
|
|
|
76
77
|
|
|
77
78
|
Your configuration lives in its own git repo. `acu config init` scaffolds everything except `scenario/`, which you author by hand:
|
|
78
79
|
|
|
79
|
-
| Path
|
|
80
|
-
|
|
|
81
|
-
| `bootstrap/`
|
|
82
|
-
| `baseline/`
|
|
83
|
-
| `setup/`
|
|
84
|
-
| `scenario/`
|
|
85
|
-
|
|
|
80
|
+
| Path | What it holds |
|
|
81
|
+
| ------------- | -------------------------------------------------------------------------- |
|
|
82
|
+
| `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
|
|
83
|
+
| `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
|
|
84
|
+
| `setup/` | one-time actions: financial year, master calendar, open periods |
|
|
85
|
+
| `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
|
|
86
|
+
| `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
|
|
87
|
+
| `.env` | where to apply and who signs in, every key an `ACU_*` variable |
|
|
86
88
|
|
|
87
|
-
Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
|
|
89
|
+
Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
|
|
90
|
+
The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
|
|
91
|
+
Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
|
|
88
92
|
|
|
89
93
|
Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
|
|
90
94
|
Scenario YAML is different — it describes transactions that flow forward.
|
|
91
95
|
`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 a scenario re-runs safely on a warm tenant.
|
|
92
96
|
|
|
97
|
+
### Seed `endpoint:` symbols
|
|
98
|
+
|
|
99
|
+
Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
|
|
100
|
+
|
|
101
|
+
| Value | Resolves to |
|
|
102
|
+
| ----- | ----------- |
|
|
103
|
+
| omitted | `Default/<ACU_API_VERSION>` for Default-only entities |
|
|
104
|
+
| `bootstrap` | active `Bootstrap/<ver>` from `bootstrap/project.xml` or the packaged contract |
|
|
105
|
+
| `default` | `Default/<ACU_API_VERSION>` — tracks the operator API version |
|
|
106
|
+
| `Bootstrap/1.9.0` or `Default/25.200.001` | literal pin (ignores `ACU_API_VERSION` for Default) |
|
|
107
|
+
|
|
108
|
+
Prefer symbolic `default` over a pinned `Default/25.200.001` so the seed tree travels with the configured API generation.
|
|
109
|
+
|
|
93
110
|
## Installation
|
|
94
111
|
|
|
95
112
|
Requires Python 3.14 or newer.
|
|
@@ -108,16 +125,32 @@ Verify with `acu --version`.
|
|
|
108
125
|
|
|
109
126
|
## Configuration
|
|
110
127
|
|
|
111
|
-
Everything lives in one `.env` file: *where* to apply and *who* signs in.
|
|
128
|
+
Everything lives in one `.env` file: *where* to apply and *who* signs in.
|
|
129
|
+
Three values are required; everything else has a code default matching a stock Acumatica install:
|
|
112
130
|
|
|
113
131
|
```sh
|
|
114
132
|
ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
|
|
115
133
|
ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
|
|
116
134
|
ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
|
|
135
|
+
ACU_API_VERSION=25.200.001 # Default contract version half only
|
|
117
136
|
ACU_USER=admin # optional, defaults to admin
|
|
118
137
|
ACU_PASSWORD=... # required for live commands
|
|
119
138
|
```
|
|
120
139
|
|
|
140
|
+
`ACU_API_VERSION` is the version half only (`25.200.001`), never `Default/25.200.001`.
|
|
141
|
+
A full path would nest as `/entity/Default/Default/...`.
|
|
142
|
+
|
|
143
|
+
The committed `target.yaml` next to `.env` declares the verified matrix (what, not where):
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
erp: "26.101.0225" # claimed product line/build (README-level detail)
|
|
147
|
+
default_api: "25.200.001" # must match ACU_API_VERSION
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
When `target.yaml` is present, `apply` / `diff` / `run` / `extract` / `schema` / `bootstrap` hard-fail if `default_api` does not match the configured API version.
|
|
151
|
+
`acu config check` reports the same match as a probe line.
|
|
152
|
+
Missing `target.yaml` only warns on check unless you pass `--strict`.
|
|
153
|
+
|
|
121
154
|
Worth knowing:
|
|
122
155
|
|
|
123
156
|
- The file is found by walking up from the current directory, so any subdirectory of the data repo works.
|
|
@@ -126,13 +159,15 @@ Worth knowing:
|
|
|
126
159
|
- Leave it blank on hosted instances; only `acu tenant` needs it.
|
|
127
160
|
- Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
|
|
128
161
|
- `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
|
|
162
|
+
- When `target.yaml` is present, `config show` also comments `erp` / `default_api`.
|
|
129
163
|
- Redirect it to turn resolved state into a working config: `acu config show > .env`.
|
|
130
164
|
|
|
131
165
|
Verify before touching anything live:
|
|
132
166
|
|
|
133
167
|
```sh
|
|
134
|
-
acu config check
|
|
135
|
-
acu
|
|
168
|
+
acu config check # discovery, secrets, target, REST, endpoints, SSH
|
|
169
|
+
acu config check --strict # missing target.yaml becomes fail
|
|
170
|
+
acu apply --dry-run # show what would be written, write nothing
|
|
136
171
|
```
|
|
137
172
|
|
|
138
173
|
## Control and Data Planes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
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
|
readme = "README.md"
|
|
@@ -22,6 +22,7 @@ from .config import (
|
|
|
22
22
|
read_env_values,
|
|
23
23
|
scaffold,
|
|
24
24
|
)
|
|
25
|
+
from .target import assert_target_compatible, load_target
|
|
25
26
|
from .tenant import TenantManager
|
|
26
27
|
|
|
27
28
|
|
|
@@ -370,6 +371,7 @@ def bootstrap_cmd(ctx: click.Context, export_path: Path | None) -> None:
|
|
|
370
371
|
output.success(f"wrote {export_path}")
|
|
371
372
|
return
|
|
372
373
|
inst = _resolve_instance(ctx)
|
|
374
|
+
assert_target_compatible(inst)
|
|
373
375
|
if not inst.tenant:
|
|
374
376
|
raise SystemExit(
|
|
375
377
|
"tenant not set (pass --tenant, "
|
|
@@ -433,29 +435,45 @@ def config_show(inst: Instance) -> None:
|
|
|
433
435
|
so the printed values are exactly what a live command would trust -
|
|
434
436
|
global flag overrides (--url, --ssh, ...) included. The password is
|
|
435
437
|
never emitted in any form (V2): no ACU_PASSWORD key, no value.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
+
When target.yaml is present, surfaces erp/default_api as comments
|
|
439
|
+
(mismatch noted, still exit 0 — no hard gate). Redirect to a file and
|
|
440
|
+
edit: the output loads back through load_instance unchanged, the
|
|
441
|
+
password supplied out of band.
|
|
438
442
|
"""
|
|
439
443
|
output.data("# resolved by `acu config show` - a complete .env")
|
|
440
444
|
output.data("# ACU_PASSWORD comes from .env or the environment, never from here")
|
|
441
445
|
for field, value in inst.model_dump(exclude={"password"}).items():
|
|
442
446
|
output.data(f"ACU_{field.upper()}={value}")
|
|
447
|
+
target = load_target()
|
|
448
|
+
if target is not None:
|
|
449
|
+
output.data(f"# target.yaml: erp={target.erp} default_api={target.default_api}")
|
|
450
|
+
if target.default_api != inst.api_version:
|
|
451
|
+
output.data(
|
|
452
|
+
f"# warn: default_api={target.default_api} != "
|
|
453
|
+
f"ACU_API_VERSION={inst.api_version}"
|
|
454
|
+
)
|
|
443
455
|
|
|
444
456
|
|
|
445
457
|
@config_group.command("check")
|
|
458
|
+
@click.option(
|
|
459
|
+
"--strict",
|
|
460
|
+
is_flag=True,
|
|
461
|
+
help="Promote warn classes (missing target.yaml) to fail",
|
|
462
|
+
)
|
|
446
463
|
@click.pass_context
|
|
447
|
-
def config_check(ctx: click.Context) -> None:
|
|
448
|
-
"""Read-only preflight of the resolved target, one ok/fail line
|
|
464
|
+
def config_check(ctx: click.Context, strict: bool) -> None:
|
|
465
|
+
"""Read-only preflight of the resolved target, one ok/fail/warn/skip line.
|
|
449
466
|
|
|
450
467
|
Dependency order: discovery (.env walk-up + parse + ACU_BASE_URL), then
|
|
451
|
-
secrets (ACU_PASSWORD resolved), then
|
|
452
|
-
logout) and ssh probed independently -
|
|
453
|
-
unset → skip (ACU_SSH optional, V3 hosted
|
|
454
|
-
or secrets failure stops; a REST failure
|
|
455
|
-
vice versa. Discovery is lax (V3): no
|
|
456
|
-
base_url, and flags-only runs (no .env
|
|
457
|
-
nothing: no PUTs, no tenant CRUD. Exit 0
|
|
458
|
-
|
|
468
|
+
secrets (ACU_PASSWORD resolved), then local target.yaml probe, then REST
|
|
469
|
+
(login, landed-tenant verify, logout) and ssh probed independently -
|
|
470
|
+
ssh set → trivial remote; ssh unset → skip (ACU_SSH optional, V3 hosted
|
|
471
|
+
path), never fail. A discovery or secrets failure stops; a REST failure
|
|
472
|
+
still probes ssh when set and vice versa. Discovery is lax (V3): no
|
|
473
|
+
.env passes when --url covers base_url, and flags-only runs (no .env
|
|
474
|
+
anywhere) are valid. Writes nothing: no PUTs, no tenant CRUD. Exit 0
|
|
475
|
+
when no fail line (warns allowed); --strict promotes missing target to
|
|
476
|
+
fail; exit 1 on any failure.
|
|
459
477
|
"""
|
|
460
478
|
overrides: dict[str, str] = ctx.obj or {}
|
|
461
479
|
# discovery (V3): lax walk-up + parse; base_url (the primary identity
|
|
@@ -493,13 +511,15 @@ def config_check(ctx: click.Context) -> None:
|
|
|
493
511
|
# both live probes run through the exact objects live commands use, so
|
|
494
512
|
# a pass here proves the real code path, not a parallel one
|
|
495
513
|
inst = _resolve_instance(ctx)
|
|
496
|
-
failed =
|
|
514
|
+
failed = _probe_target(root, inst, strict=strict)
|
|
497
515
|
try:
|
|
498
516
|
# entering the client is the whole probe: login + landed-tenant
|
|
499
|
-
# verify (V5), and the context manager guarantees logout (V6)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
517
|
+
# verify (V5), and the context manager guarantees logout (V6);
|
|
518
|
+
# endpoints probe (T74) reuses the same session when login succeeds
|
|
519
|
+
with AcumaticaClient(inst) as client:
|
|
520
|
+
output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
|
|
521
|
+
if not _probe_endpoints(client, inst):
|
|
522
|
+
failed = True
|
|
503
523
|
except (RuntimeError, httpx.HTTPError) as exc:
|
|
504
524
|
output.data(f"fail rest: {exc}")
|
|
505
525
|
failed = True
|
|
@@ -517,6 +537,68 @@ def config_check(ctx: click.Context) -> None:
|
|
|
517
537
|
raise SystemExit(1)
|
|
518
538
|
|
|
519
539
|
|
|
540
|
+
def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
|
|
541
|
+
"""Emit the local target probe line; return True when it failed (V27).
|
|
542
|
+
|
|
543
|
+
Invalid target hard-exits (any loader). Missing under data root warns
|
|
544
|
+
unless --strict. No data root → skip. Match → ok (erp claimed-only).
|
|
545
|
+
"""
|
|
546
|
+
try:
|
|
547
|
+
target = load_target(root)
|
|
548
|
+
except SystemExit as exc:
|
|
549
|
+
output.data(f"fail target: {exc}")
|
|
550
|
+
raise SystemExit(1) from exc
|
|
551
|
+
if root is None:
|
|
552
|
+
output.data("skip target (no data root)")
|
|
553
|
+
return False
|
|
554
|
+
if target is None:
|
|
555
|
+
msg = (
|
|
556
|
+
f"target: no target.yaml under {root} - dataset verified matrix "
|
|
557
|
+
"unknown; add target.yaml (see acu config init) or pass --strict "
|
|
558
|
+
"to require it"
|
|
559
|
+
)
|
|
560
|
+
output.data(f"{'fail' if strict else 'warn'} {msg}")
|
|
561
|
+
return strict
|
|
562
|
+
if target.default_api != inst.api_version:
|
|
563
|
+
output.data(
|
|
564
|
+
f"fail target: dataset default_api={target.default_api} vs "
|
|
565
|
+
f"configured {inst.api_version}"
|
|
566
|
+
)
|
|
567
|
+
return True
|
|
568
|
+
output.data(
|
|
569
|
+
f"ok target (default_api={target.default_api} matches configured; "
|
|
570
|
+
f"erp={target.erp} claimed)"
|
|
571
|
+
)
|
|
572
|
+
# T76: no stable HTTP ERP-build discovery (V12) — keep erp claimed-only;
|
|
573
|
+
# skip rather than invent SSH/sqlcmd (control-plane exclusion, V1)
|
|
574
|
+
output.data(f"skip erp (live probe not available; claimed {target.erp})")
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
|
|
579
|
+
"""Emit endpoints probe; return True on pass, False on fail (V12/V27).
|
|
580
|
+
|
|
581
|
+
Exact match: a Default entry whose version half equals
|
|
582
|
+
``Instance.api_version``. Fail-closed when GET /entity is unparseable.
|
|
583
|
+
"""
|
|
584
|
+
want = f"Default/{inst.api_version}"
|
|
585
|
+
try:
|
|
586
|
+
endpoints = client.list_endpoints()
|
|
587
|
+
except (RuntimeError, httpx.HTTPError) as exc:
|
|
588
|
+
output.data(f"fail endpoints: {exc}")
|
|
589
|
+
return False
|
|
590
|
+
defaults = [v for name, v in endpoints if name == "Default"]
|
|
591
|
+
if inst.api_version in defaults:
|
|
592
|
+
output.data(f"ok endpoints ({want} present)")
|
|
593
|
+
return True
|
|
594
|
+
present = ", ".join(f"Default/{v}" for v in defaults) or "(none)"
|
|
595
|
+
output.data(
|
|
596
|
+
f"fail endpoints: configured {want} not listed; "
|
|
597
|
+
f"present Default versions: {present}"
|
|
598
|
+
)
|
|
599
|
+
return False
|
|
600
|
+
|
|
601
|
+
|
|
520
602
|
SEED_DIRS = ("bootstrap", "baseline", "setup")
|
|
521
603
|
|
|
522
604
|
|
|
@@ -571,6 +653,7 @@ def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
|
|
|
571
653
|
they default to the data repo's existing init-scaffolded directories in
|
|
572
654
|
fixed order: bootstrap/, baseline/, setup/.
|
|
573
655
|
"""
|
|
656
|
+
assert_target_compatible(inst)
|
|
574
657
|
with AcumaticaClient(inst) as client:
|
|
575
658
|
for path in expand_files(files or default_seed_dirs()):
|
|
576
659
|
baseline = seed.load_baseline(path)
|
|
@@ -596,6 +679,7 @@ def schema_cmd(inst: Instance, out_dir: Path | None) -> None:
|
|
|
596
679
|
The schema is the authoritative field-level reference for the exact
|
|
597
680
|
build - regenerate rather than version (the file is ~3 MB).
|
|
598
681
|
"""
|
|
682
|
+
assert_target_compatible(inst)
|
|
599
683
|
if out_dir is None:
|
|
600
684
|
out_dir = data_root() / "schemas"
|
|
601
685
|
out_file = out_dir / f"swagger-Default-{inst.api_version}.json"
|
|
@@ -623,6 +707,7 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
|
|
|
623
707
|
they default to the data repo's existing init-scaffolded directories in
|
|
624
708
|
fixed order: bootstrap/, baseline/, setup/.
|
|
625
709
|
"""
|
|
710
|
+
assert_target_compatible(inst)
|
|
626
711
|
paths = expand_files(files or default_seed_dirs())
|
|
627
712
|
drifts: list[str] = []
|
|
628
713
|
with AcumaticaClient(inst) as client:
|
|
@@ -659,6 +744,7 @@ def run_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
|
|
|
659
744
|
last, so a scenario re-runs safely on a warm tenant. Exit 0 when every
|
|
660
745
|
expectation holds, 1 on any step error or expectation miss.
|
|
661
746
|
"""
|
|
747
|
+
assert_target_compatible(inst)
|
|
662
748
|
if not files:
|
|
663
749
|
default = data_root() / "scenario"
|
|
664
750
|
if not default.is_dir():
|
|
@@ -731,6 +817,7 @@ def extract_cmd(
|
|
|
731
817
|
when every row wrote or skipped clean, 1 when any row failed - drift
|
|
732
818
|
detection stays with diff.
|
|
733
819
|
"""
|
|
820
|
+
assert_target_compatible(inst)
|
|
734
821
|
with AcumaticaClient(inst) as client:
|
|
735
822
|
failed = extract.run(
|
|
736
823
|
client,
|
|
@@ -62,6 +62,49 @@ def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
|
|
|
62
62
|
return out
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
|
|
66
|
+
"""Parse ``GET /entity`` into ``[(name, version), ...]`` (fail-closed).
|
|
67
|
+
|
|
68
|
+
Vendor contract shape (Acumatica help + docs/rest-api.md): a JSON array
|
|
69
|
+
of objects with ``name`` and ``version`` strings. Unparseable body →
|
|
70
|
+
RuntimeError with status, content-type, and a short raw hint so a
|
|
71
|
+
shape change is re-verified (V12) rather than silently skipped.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
body = response.json()
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
hint = response.text[:200].replace("\n", " ")
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
"GET /entity response not parseable as endpoint list "
|
|
79
|
+
f"(status {response.status_code}; "
|
|
80
|
+
f"content-type {response.headers.get('content-type', '?')}; "
|
|
81
|
+
f"first 200 chars: {hint})"
|
|
82
|
+
) from exc
|
|
83
|
+
if not isinstance(body, list) or not body:
|
|
84
|
+
hint = str(body)[:200]
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"GET /entity response not parseable as endpoint list "
|
|
87
|
+
f"(status {response.status_code}; expected non-empty JSON array; "
|
|
88
|
+
f"first 200 chars: {hint})"
|
|
89
|
+
)
|
|
90
|
+
out: list[tuple[str, str]] = []
|
|
91
|
+
for item in body:
|
|
92
|
+
if not isinstance(item, dict):
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"GET /entity response not parseable as endpoint list "
|
|
95
|
+
f"(row is not an object: {item!r})"
|
|
96
|
+
)
|
|
97
|
+
name = item.get("name")
|
|
98
|
+
version = item.get("version")
|
|
99
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"GET /entity response not parseable as endpoint list "
|
|
102
|
+
f"(row missing string name/version: {item!r})"
|
|
103
|
+
)
|
|
104
|
+
out.append((name, version))
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
65
108
|
# The list GET's optimized-export failure (B9): the contract API's list GET
|
|
66
109
|
# 500s with this marker when any field in scope maps to a BQL-delegate view.
|
|
67
110
|
# The one error read paths retry around (seed.diff via the key-URL GET,
|
|
@@ -93,6 +136,9 @@ class AcumaticaClient:
|
|
|
93
136
|
self._http = httpx.Client(
|
|
94
137
|
base_url=instance.base_url, timeout=timeout, transport=transport
|
|
95
138
|
)
|
|
139
|
+
# V5/B24: first successful Company PUT this session → one re-login
|
|
140
|
+
# so later branch selectors resolve; warm re-PUT is re-login-safe
|
|
141
|
+
self._refreshed_after_company = False
|
|
96
142
|
|
|
97
143
|
def __enter__(self) -> AcumaticaClient:
|
|
98
144
|
# tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
|
|
@@ -105,27 +151,10 @@ class AcumaticaClient:
|
|
|
105
151
|
"an explicit tenant silently lands on the default tenant; "
|
|
106
152
|
"set ACU_TENANT in .env or pass --tenant"
|
|
107
153
|
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"password": self.instance.password,
|
|
111
|
-
"tenant": self.instance.tenant,
|
|
112
|
-
}
|
|
113
|
-
self._checked(self._http.post("/entity/auth/login", json=creds))
|
|
114
|
-
# tenant guard, landed side (V5, B5): login accepting the name proves
|
|
115
|
-
# nothing - a stale tenant map reroutes named logins to the default
|
|
116
|
-
# tenant, and a single-tenant instance accepts ANY name (both verified
|
|
117
|
-
# live, docs/rest-api.md). Verify where the session actually landed
|
|
118
|
-
# and refuse on mismatch; logout first, since __exit__ never runs
|
|
119
|
-
# when __enter__ raises (V6 - sessions count against the license).
|
|
154
|
+
# logout first on failure: __exit__ never runs when __enter__ raises
|
|
155
|
+
# (V6 - sessions count against the license)
|
|
120
156
|
try:
|
|
121
|
-
|
|
122
|
-
if landed.casefold() != self.instance.tenant.casefold():
|
|
123
|
-
raise RuntimeError(
|
|
124
|
-
f"tenant guard: asked for tenant {self.instance.tenant!r} "
|
|
125
|
-
f"but the session landed on {landed!r} - the instance "
|
|
126
|
-
"tenant map is stale or the tenant does not exist; check "
|
|
127
|
-
"acu tenant list and recycle the app pool"
|
|
128
|
-
)
|
|
157
|
+
self._login()
|
|
129
158
|
except BaseException:
|
|
130
159
|
self.__exit__()
|
|
131
160
|
raise
|
|
@@ -138,6 +167,50 @@ class AcumaticaClient:
|
|
|
138
167
|
finally:
|
|
139
168
|
self._http.close()
|
|
140
169
|
|
|
170
|
+
def _login(self) -> None:
|
|
171
|
+
"""POST login + refuse on landed-tenant mismatch (V5, B5).
|
|
172
|
+
|
|
173
|
+
Login accepting the name proves nothing - a stale tenant map
|
|
174
|
+
reroutes named logins to the default tenant, and a single-tenant
|
|
175
|
+
instance accepts ANY name (both verified live, docs/rest-api.md).
|
|
176
|
+
"""
|
|
177
|
+
creds: dict[str, str] = {
|
|
178
|
+
"name": self.instance.user,
|
|
179
|
+
"password": self.instance.password,
|
|
180
|
+
"tenant": self.instance.tenant,
|
|
181
|
+
}
|
|
182
|
+
self._checked(self._http.post("/entity/auth/login", json=creds))
|
|
183
|
+
landed = self._landed_tenant()
|
|
184
|
+
if landed.casefold() != self.instance.tenant.casefold():
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"tenant guard: asked for tenant {self.instance.tenant!r} "
|
|
187
|
+
f"but the session landed on {landed!r} - the instance "
|
|
188
|
+
"tenant map is stale or the tenant does not exist; check "
|
|
189
|
+
"acu tenant list and recycle the app pool"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def relogin(self) -> None:
|
|
193
|
+
"""Drop the cookie session and open a new one without closing transport.
|
|
194
|
+
|
|
195
|
+
Mid-session Company create leaves branch selectors empty until a
|
|
196
|
+
fresh login (V5, B24). Unlike ``__exit__``, the httpx client stays
|
|
197
|
+
open so the outer context manager still owns the session lifecycle.
|
|
198
|
+
"""
|
|
199
|
+
# empty body → Content-Length: 0 (V6 / IIS 411)
|
|
200
|
+
self._http.post("/entity/auth/logout", content=b"")
|
|
201
|
+
self._login()
|
|
202
|
+
|
|
203
|
+
def refresh_after_company(self) -> None:
|
|
204
|
+
"""Re-login once after the first successful Company PUT this session.
|
|
205
|
+
|
|
206
|
+
Warm-tenant Company re-PUT is re-login-safe (V5); further Company
|
|
207
|
+
records in the same run skip the second bounce.
|
|
208
|
+
"""
|
|
209
|
+
if self._refreshed_after_company:
|
|
210
|
+
return
|
|
211
|
+
self.relogin()
|
|
212
|
+
self._refreshed_after_company = True
|
|
213
|
+
|
|
141
214
|
def _landed_tenant(self) -> str:
|
|
142
215
|
"""The tenant this session actually landed on (login name).
|
|
143
216
|
|
|
@@ -160,9 +233,20 @@ class AcumaticaClient:
|
|
|
160
233
|
return html.unescape(m.group(1))
|
|
161
234
|
|
|
162
235
|
def _url(self, entity: str, endpoint: str | None = None) -> str:
|
|
163
|
-
# V11: versioned path only
|
|
164
|
-
# Default -
|
|
165
|
-
|
|
236
|
+
# V11: versioned path only. Omitted or symbolic "default" (V20) →
|
|
237
|
+
# Default/<Instance.api_version> so dual-serve seeds track the
|
|
238
|
+
# configured API generation; literals pass through unchanged.
|
|
239
|
+
if endpoint is None or endpoint == "default":
|
|
240
|
+
endpoint = f"Default/{self.instance.api_version}"
|
|
241
|
+
return f"/entity/{endpoint}/{entity}"
|
|
242
|
+
|
|
243
|
+
def list_endpoints(self) -> list[tuple[str, str]]:
|
|
244
|
+
"""Authenticated ``GET /entity`` → ``[(name, version), ...]`` (V12).
|
|
245
|
+
|
|
246
|
+
Fail-closed on unparseable body — see ``parse_entity_list``.
|
|
247
|
+
"""
|
|
248
|
+
r = self._checked(self._http.get("/entity"))
|
|
249
|
+
return parse_entity_list(r)
|
|
166
250
|
|
|
167
251
|
@staticmethod
|
|
168
252
|
def _checked(r: httpx.Response) -> httpx.Response:
|
|
@@ -34,6 +34,7 @@ DB_NAME = "AcumaticaDB"
|
|
|
34
34
|
INIT_TEMPLATES = (
|
|
35
35
|
("env", ".env"),
|
|
36
36
|
("gitignore", ".gitignore"),
|
|
37
|
+
("target", "target.yaml"),
|
|
37
38
|
("baseline/10-subaccounts.yaml", "baseline/10-subaccounts.yaml"),
|
|
38
39
|
("baseline/20-accounts.yaml", "baseline/20-accounts.yaml"),
|
|
39
40
|
("baseline/40-ledger.yaml", "baseline/40-ledger.yaml"),
|
|
@@ -93,8 +94,20 @@ class Instance(BaseSettings):
|
|
|
93
94
|
|
|
94
95
|
@field_validator("api_version")
|
|
95
96
|
@classmethod
|
|
96
|
-
def
|
|
97
|
-
|
|
97
|
+
def _api_version_half_only(cls, v: str) -> str:
|
|
98
|
+
# V11: version half only (e.g. 25.200.001). A full path like
|
|
99
|
+
# Default/25.200.001 would nest as /entity/Default/Default/...
|
|
100
|
+
v = v.strip().strip("/")
|
|
101
|
+
if not v:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"api_version must be the version half only (e.g. 25.200.001)"
|
|
104
|
+
)
|
|
105
|
+
if "/" in v or v.lower().startswith("default"):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"api_version must be the version half only "
|
|
108
|
+
f"(e.g. 25.200.001), not a path like Default/{v}"
|
|
109
|
+
)
|
|
110
|
+
return v
|
|
98
111
|
|
|
99
112
|
|
|
100
113
|
def scaffold(directory: Path, host: str | None = None) -> Iterator[tuple[str, Path]]:
|
|
@@ -141,7 +141,7 @@ def _fetch(client: AcumaticaClient, spec: EntitySpec) -> list[dict[str, Any]]:
|
|
|
141
141
|
A manifest filter rides both list reads, so the two paths serve the
|
|
142
142
|
same record set and the per-key walk only visits filtered keys.
|
|
143
143
|
"""
|
|
144
|
-
endpoint = resolve_endpoint(spec.endpoint)
|
|
144
|
+
endpoint = resolve_endpoint(spec.endpoint, api_version=client.instance.api_version)
|
|
145
145
|
narrowed = {"$filter": spec.filter} if spec.filter else {}
|
|
146
146
|
try:
|
|
147
147
|
return client.get_list(spec.entity, params=narrowed or None, endpoint=endpoint)
|
|
@@ -418,7 +418,9 @@ class _Extraction:
|
|
|
418
418
|
# the active package does not serve cannot be read - skip clean
|
|
419
419
|
# rather than 404 (full company surface lives in the data-repo
|
|
420
420
|
# contract; minimal packaged fallback has config-init only).
|
|
421
|
-
resolved = resolve_endpoint(
|
|
421
|
+
resolved = resolve_endpoint(
|
|
422
|
+
spec.endpoint, api_version=self.client.instance.api_version
|
|
423
|
+
)
|
|
422
424
|
if resolved == active_name and spec.entity not in active_entities:
|
|
423
425
|
self._skip(target, "entity not in active Bootstrap contract")
|
|
424
426
|
continue
|
|
@@ -60,7 +60,7 @@ from pydantic import Field, ValidationError, field_validator, model_validator
|
|
|
60
60
|
|
|
61
61
|
from . import bootstrap, output
|
|
62
62
|
from .client import OPTIMIZATION_500, AcumaticaClient, unwrap
|
|
63
|
-
from .config import
|
|
63
|
+
from .config import find_data_root
|
|
64
64
|
from .models import Model, validation_summary
|
|
65
65
|
|
|
66
66
|
# Packaged-fallback defaults (no data-repo contract). Prefer active_bootstrap()
|
|
@@ -68,10 +68,15 @@ from .models import Model, validation_summary
|
|
|
68
68
|
BOOTSTRAP_ENDPOINT, BOOTSTRAP_ENTITIES = bootstrap.parse_endpoint(
|
|
69
69
|
bootstrap.packaged_contract_xml()
|
|
70
70
|
)
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
71
|
+
# Dual-serve error text prefers the symbolic Default form (V20) so operators
|
|
72
|
+
# do not read the code-default version as their configured ACU_API_VERSION.
|
|
73
|
+
_DEFAULT_ENDPOINT_NAME = "Default"
|
|
74
74
|
_SYMBOLIC_BOOTSTRAP = "bootstrap"
|
|
75
|
+
_SYMBOLIC_DEFAULT = "default"
|
|
76
|
+
# Mid-session branch-selector failure (V5/B24): virgin-tenant apply opens
|
|
77
|
+
# the REST session pre-Company; INPreferences TransitBranchID (and kin)
|
|
78
|
+
# 500 with this message until a fresh login sees the new branch.
|
|
79
|
+
_BRANCH_EMPTY = "'Branch' cannot be empty"
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
|
|
@@ -86,10 +91,30 @@ def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
|
|
|
86
91
|
return bootstrap.parse_endpoint(bootstrap.load_contract_xml(root))
|
|
87
92
|
|
|
88
93
|
|
|
89
|
-
def resolve_endpoint(
|
|
90
|
-
|
|
94
|
+
def resolve_endpoint(
|
|
95
|
+
endpoint: str | None,
|
|
96
|
+
root: Path | None = None,
|
|
97
|
+
*,
|
|
98
|
+
api_version: str | None = None,
|
|
99
|
+
) -> str | None:
|
|
100
|
+
"""Map symbolic endpoint names to versioned paths (V20).
|
|
101
|
+
|
|
102
|
+
``bootstrap`` → active ``Bootstrap/<ver>`` from contract XML (load-time
|
|
103
|
+
what). ``default`` → ``Default/<api_version>``; ``api_version`` required
|
|
104
|
+
when endpoint is default. Other values pass through (literals or None).
|
|
105
|
+
|
|
106
|
+
Apply/diff primarily resolve symbolic default in ``client._url``; this
|
|
107
|
+
helper serves extract pre-HTTP path building and offline unit tests.
|
|
108
|
+
"""
|
|
91
109
|
if endpoint == _SYMBOLIC_BOOTSTRAP:
|
|
92
110
|
return active_bootstrap(root)[0]
|
|
111
|
+
if endpoint == _SYMBOLIC_DEFAULT:
|
|
112
|
+
if not api_version:
|
|
113
|
+
raise SystemExit(
|
|
114
|
+
"endpoint: default requires a configured api_version "
|
|
115
|
+
"(ACU_API_VERSION or --api-version)"
|
|
116
|
+
)
|
|
117
|
+
return f"{_DEFAULT_ENDPOINT_NAME}/{api_version}"
|
|
93
118
|
return endpoint
|
|
94
119
|
|
|
95
120
|
|
|
@@ -186,6 +211,8 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
|
|
|
186
211
|
|
|
187
212
|
Symbolic ``endpoint: bootstrap`` resolves to the active package version
|
|
188
213
|
at load (data-repo contract when present, else packaged minimal — V20).
|
|
214
|
+
Symbolic ``endpoint: default`` stays on the model and resolves at HTTP
|
|
215
|
+
time via ``client._url`` (never load-rewritten — version is where).
|
|
189
216
|
An entity the active Bootstrap contract serves still needs an explicit
|
|
190
217
|
endpoint (literal or symbolic); silent Default-endpoint PUTs are the
|
|
191
218
|
B8 class.
|
|
@@ -204,10 +231,10 @@ def load_baseline(path: Path) -> BaselineFile | ActionFile:
|
|
|
204
231
|
parsed = parsed.model_copy(update={"endpoint": name})
|
|
205
232
|
elif parsed.endpoint is None and parsed.entity in entities:
|
|
206
233
|
raise SystemExit(
|
|
207
|
-
f"{path}: entity '{parsed.entity}' is served by both
|
|
208
|
-
f"
|
|
209
|
-
f"{name} - add an explicit 'endpoint:' line to
|
|
210
|
-
f"(literal or symbolic 'bootstrap')"
|
|
234
|
+
f"{path}: entity '{parsed.entity}' is served by both "
|
|
235
|
+
f"Default (use endpoint: default -> Default/<ACU_API_VERSION>) "
|
|
236
|
+
f"and the active {name} - add an explicit 'endpoint:' line to "
|
|
237
|
+
f"pick one (literal or symbolic 'bootstrap' | 'default')"
|
|
211
238
|
)
|
|
212
239
|
return parsed
|
|
213
240
|
|
|
@@ -303,6 +330,10 @@ def apply(
|
|
|
303
330
|
) -> int:
|
|
304
331
|
"""PUT every record (upsert by key); an action file invokes its action.
|
|
305
332
|
|
|
333
|
+
After the first successful Company PUT this session, re-login once so
|
|
334
|
+
branch selectors resolve for later files (V5/B24). A PUT that still
|
|
335
|
+
500s with ``'Branch' cannot be empty`` gets one re-login + retry.
|
|
336
|
+
|
|
306
337
|
Returns the record count.
|
|
307
338
|
"""
|
|
308
339
|
if isinstance(baseline, ActionFile):
|
|
@@ -315,11 +346,30 @@ def apply(
|
|
|
315
346
|
body = record
|
|
316
347
|
if any(isinstance(v, list) for v in record.values()):
|
|
317
348
|
body = _with_detail_ids(client, baseline, record)
|
|
318
|
-
client
|
|
349
|
+
_put(client, baseline.entity, body, baseline.endpoint)
|
|
350
|
+
if baseline.entity == "Company":
|
|
351
|
+
client.refresh_after_company()
|
|
319
352
|
output.data(f" PUT {baseline.entity} [{label}]")
|
|
320
353
|
return len(baseline.records)
|
|
321
354
|
|
|
322
355
|
|
|
356
|
+
def _put(
|
|
357
|
+
client: AcumaticaClient,
|
|
358
|
+
entity: str,
|
|
359
|
+
body: dict[str, Any],
|
|
360
|
+
endpoint: str | None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""PUT one record; one re-login + retry on branch-empty 500 (V5/B24)."""
|
|
363
|
+
try:
|
|
364
|
+
client.put(entity, body, endpoint=endpoint)
|
|
365
|
+
except RuntimeError as err:
|
|
366
|
+
if _BRANCH_EMPTY not in str(err):
|
|
367
|
+
raise
|
|
368
|
+
output.info(f"re-login and retry {entity} (branch empty)")
|
|
369
|
+
client.relogin()
|
|
370
|
+
client.put(entity, body, endpoint=endpoint)
|
|
371
|
+
|
|
372
|
+
|
|
323
373
|
def _with_detail_ids(
|
|
324
374
|
client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
|
|
325
375
|
) -> dict[str, Any]:
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Dataset target matrix: committed verified ERP + Default API versions (V27).
|
|
2
|
+
|
|
3
|
+
``target.yaml`` is *what* (V2) — co-located with the data-repo root found by
|
|
4
|
+
``.env`` discovery. Never secrets. Present → hard-match ``default_api`` to
|
|
5
|
+
``Instance.api_version`` on allowlisted data-plane cmds and ``config check``;
|
|
6
|
+
missing → warn on check unless ``--strict``; invalid → hard-fail any loader.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import ValidationError, field_validator
|
|
13
|
+
|
|
14
|
+
from .config import Instance, find_data_root
|
|
15
|
+
from .models import Model, validation_summary
|
|
16
|
+
|
|
17
|
+
TARGET_FILENAME = "target.yaml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DatasetTarget(Model):
|
|
21
|
+
"""Committed verified target for a data repo (V2 what — never secrets)."""
|
|
22
|
+
|
|
23
|
+
erp: str # claimed product line/build; live compare only when a probe exists
|
|
24
|
+
default_api: str # Default contract version half only (e.g. 25.200.001)
|
|
25
|
+
|
|
26
|
+
@field_validator("default_api")
|
|
27
|
+
@classmethod
|
|
28
|
+
def _api_version_half_only(cls, v: str) -> str:
|
|
29
|
+
v = v.strip().strip("/")
|
|
30
|
+
if not v:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"default_api must be the version half only (e.g. 25.200.001)"
|
|
33
|
+
)
|
|
34
|
+
if "/" in v or v.lower().startswith("default"):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"default_api must be the version half only "
|
|
37
|
+
f"(e.g. 25.200.001), not a path like Default/{v}"
|
|
38
|
+
)
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
@field_validator("erp")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _erp_nonempty(cls, v: str) -> str:
|
|
44
|
+
v = v.strip()
|
|
45
|
+
if not v:
|
|
46
|
+
raise ValueError("erp must be a non-empty build id (e.g. 26.101.0225)")
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_target_path(root: Path | None = None) -> Path | None:
|
|
51
|
+
"""``{data-root}/target.yaml`` when the file exists; else None."""
|
|
52
|
+
root = root if root is not None else find_data_root()
|
|
53
|
+
if root is None:
|
|
54
|
+
return None
|
|
55
|
+
path = root / TARGET_FILENAME
|
|
56
|
+
return path if path.is_file() else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_target(root: Path | None = None) -> DatasetTarget | None:
|
|
60
|
+
"""Return DatasetTarget, None if absent, or SystemExit on unreadable/invalid.
|
|
61
|
+
|
|
62
|
+
Invalid file is always a hard error for any caller that loads it — never
|
|
63
|
+
silently ignored on apply while only failing on config check (V27).
|
|
64
|
+
"""
|
|
65
|
+
path = find_target_path(root)
|
|
66
|
+
if path is None:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
with open(path) as f:
|
|
70
|
+
data = yaml.safe_load(f)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise SystemExit(f"{path}: cannot read target.yaml: {exc}") from exc
|
|
73
|
+
if data is None:
|
|
74
|
+
raise SystemExit(f"{path}: target.yaml is empty")
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise SystemExit(f"{path}: expected a mapping at the top level")
|
|
77
|
+
try:
|
|
78
|
+
return DatasetTarget.model_validate(data)
|
|
79
|
+
except ValidationError as exc:
|
|
80
|
+
raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assert_target_compatible(inst: Instance, root: Path | None = None) -> None:
|
|
84
|
+
"""Hard-fail when target.yaml present and default_api mismatches api_version.
|
|
85
|
+
|
|
86
|
+
Missing target is not an error here (config check --strict owns that).
|
|
87
|
+
Invalid target always SystemExit. Call only from the allowlisted
|
|
88
|
+
data-plane commands (V27) — never from bare pass_instance.
|
|
89
|
+
"""
|
|
90
|
+
target = load_target(root)
|
|
91
|
+
if target is None:
|
|
92
|
+
return
|
|
93
|
+
if target.default_api != inst.api_version:
|
|
94
|
+
raise SystemExit(
|
|
95
|
+
"Default API version mismatch:\n"
|
|
96
|
+
f" dataset target (target.yaml): default_api={target.default_api}\n"
|
|
97
|
+
f" configured (ACU_API_VERSION/--api-version): {inst.api_version}\n"
|
|
98
|
+
f"Fix: set ACU_API_VERSION={target.default_api} to match this "
|
|
99
|
+
f"dataset, or use a dataset verified for {inst.api_version}."
|
|
100
|
+
)
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# ACU_BASE_URL (REST root) is required; ACU_SSH (control-plane user@host)
|
|
5
5
|
# is optional - leave blank for hosted / data-plane-only (tenant CRUD still
|
|
6
6
|
# needs it). The rest are code defaults you can override here.
|
|
7
|
+
# ACU_API_VERSION is the Default contract version half only (e.g. 25.200.001),
|
|
8
|
+
# never "Default/25.200.001". Must match target.yaml default_api when that
|
|
9
|
+
# file exists (see acu config check).
|
|
7
10
|
# Keep this file out of git (the scaffolded .gitignore already lists it;
|
|
8
11
|
# encrypt at rest, e.g. .env.gpg).
|
|
9
12
|
ACU_BASE_URL=http://erp.example.com/AcumaticaERP
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# scaffolded by `acu config init` — verified target matrix for this dataset.
|
|
2
|
+
# Edit when you re-verify against a new ERP build or Default API generation.
|
|
3
|
+
# default_api is the version half only (ACU_API_VERSION spelling), not Default/<ver>.
|
|
4
|
+
# Must match ACU_API_VERSION / --api-version (default 25.200.001) or live cmds fail.
|
|
5
|
+
erp: "26.101.0225"
|
|
6
|
+
default_api: "25.200.001"
|
|
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.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/company.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml
RENAMED
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/bootstrap/features.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{acumatica_cli-0.9.0 → acumatica_cli-0.10.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml
RENAMED
|
File without changes
|
|
File without changes
|