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.
Files changed (68) hide show
  1. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/PKG-INFO +16 -4
  2. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/README.md +15 -3
  3. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/pyproject.toml +1 -1
  4. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/cli.py +14 -13
  5. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/config.py +0 -4
  6. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/extract.py +203 -52
  7. acumatica_cli-0.15.1/src/acumatica_cli/seed_catalog.yaml +450 -0
  8. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/README.md +5 -1
  9. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/80-stock-items-parts.yaml +2 -21
  10. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/82-stock-items-kits.yaml +2 -9
  11. acumatica_cli-0.14.0/src/acumatica_cli/extract_manifest.yaml +0 -152
  12. acumatica_cli-0.14.0/src/acumatica_cli/templates/config/baseline/91-company-packaging.yaml +0 -14
  13. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/__init__.py +0 -0
  14. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap.py +0 -0
  15. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  16. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
  17. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/client.py +0 -0
  18. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/firstlogin.py +0 -0
  19. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/models.py +0 -0
  20. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/output.py +0 -0
  21. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/run.py +0 -0
  22. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/seed.py +0 -0
  23. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/state.py +0 -0
  24. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/target.py +0 -0
  25. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/10-subaccounts.yaml +0 -0
  26. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/20-accounts.yaml +0 -0
  27. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/40-ledger.yaml +0 -0
  28. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/50-gl-preferences.yaml +0 -0
  29. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/60-ledger-company.yaml +0 -0
  30. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/baseline/90-uoms.yaml +0 -0
  31. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/company.yaml +0 -0
  32. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/credit-terms.yaml +0 -0
  33. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/bootstrap/features.yaml +0 -0
  34. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/10-reason-codes.yaml +0 -0
  35. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/20-in-preferences.yaml +0 -0
  36. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/30-availability-rules.yaml +0 -0
  37. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/40-posting-classes.yaml +0 -0
  38. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/50-warehouse.yaml +0 -0
  39. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/51-warehouse-locations.yaml +0 -0
  40. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/52-warehouse-defaults.yaml +0 -0
  41. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/53-tax-categories.yaml +0 -0
  42. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/54-item-classes.yaml +0 -0
  43. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/56-so-preferences.yaml +0 -0
  44. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/57-po-preferences.yaml +0 -0
  45. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/58-order-types.yaml +0 -0
  46. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/60-ar-preferences.yaml +0 -0
  47. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/61-ap-preferences.yaml +0 -0
  48. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/62-ca-preferences.yaml +0 -0
  49. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/63-cash-account.yaml +0 -0
  50. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/64-payment-methods.yaml +0 -0
  51. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/65-statement-cycles.yaml +0 -0
  52. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/70-vendor-classes.yaml +0 -0
  53. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/71-customer-classes.yaml +0 -0
  54. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/75-vendors.yaml +0 -0
  55. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/76-customers.yaml +0 -0
  56. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/master/85-kit-specifications.yaml +0 -0
  57. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/10-financial-year.yaml +0 -0
  58. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/20-master-calendar.yaml +0 -0
  59. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/setup/30-open-periods.yaml +0 -0
  60. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/config/views/10-trial-balance.yaml +0 -0
  61. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/env +0 -0
  62. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/gitignore +0 -0
  63. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/10-seed-capital.yaml +0 -0
  64. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/20-buy.yaml +0 -0
  65. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/30-build.yaml +0 -0
  66. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/scenario/40-sell.yaml +0 -0
  67. {acumatica_cli-0.14.0 → acumatica_cli-0.15.1}/src/acumatica_cli/templates/target +0 -0
  68. {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.14.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
- │ dump live tenant state as seed YAML (inverse of apply)
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, packaging |
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
- Scenario YAML is different — it describes transactions that flow forward.
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
- │ dump live tenant state as seed YAML (inverse of apply)
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, packaging |
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
- Scenario YAML is different — it describes transactions that flow forward.
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.14.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 manifest.
867
+ """--only value completion: entity names off the packaged seed catalog.
868
868
 
869
- Fires per keystroke, so it stays local-only (V23): the manifest is
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 manifest rows (entity name or file stem); repeatable",
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 files (the inverse of apply).
906
-
907
- Manifest-driven (the packaged extract manifest carries the verified
908
- entity set): each entity is read from the live tenant and written as a
909
- seed file under bootstrap/ or baseline/ that apply and diff consume
910
- unchanged. Existing files are skipped unless --force; an entity with
911
- no live records produces no file. A failing row is reported and the
912
- run continues to the next (a virgin tenant extracts whole). Exit 0
913
- when every row wrote or skipped clean, 1 when any row failed - drift
914
- detection stays with diff.
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 extract_manifest.yaml - per entity: the source
4
- endpoint, key fields, destination file, an optional record filter, and a
5
- strip deny-list or include allow-list shaping the extracted records.
6
- Emitted files parse via seed.load_baseline by construction (V20:
7
- bootstrap-entity rows must carry an endpoint) and re-extract
8
- byte-identically: records sort by key tuple,
9
- fields order key-first then alphabetical, None and empty-string values
10
- are elided.
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 manifest setup row's kind-dispatched
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. bootstrap/features.yaml is
16
- the feature closure (V22/B15): the built-in six plus the union of the
17
- manifest features: gates over record-producing entities - a live
18
- FeaturesSet read is not available over the contract API (keyless
19
- BqlDelegate view), so the closure derives from what the tenant serves.
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-manifest destination: the feature-closure file (V22/B15).
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 manifest row: how a live entity becomes a seed file."""
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 extract manifest: entity rows plus setup synthesis rows."""
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 extract manifest."""
127
+ """Parse and validate the packaged seed catalog."""
121
128
  raw = yaml.safe_load(
122
- (resources.files("acumatica_cli") / "extract_manifest.yaml").read_text(
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"extract_manifest.yaml: {validation_summary(exc)}") from exc
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 manifest filter rides both list reads, so the two paths serve the
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
- narrowed = {"$filter": spec.filter} if spec.filter else {}
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=narrowed or None, endpoint=endpoint)
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)} | narrowed,
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, [values[k] for k in spec.keys], endpoint
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, order fields key
172
- fields first (manifest order) then alphabetical, and sort records by
173
- key tuple - server order never leaks into the emitted bytes.
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
- keep = {
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 manifest kinds against this registry
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 manifest row either way.
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 manifest file set plus the feature closure under out_dir.
645
+ """Extract the catalog file set plus the feature closure under out_dir.
496
646
 
497
- Per file: skip when it exists (--force overwrites), skip when the
498
- tenant has no records, report-only under --dry-run. `only` filters
499
- rows by entity name, synthesizer kind, or file stem.
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