acumatica-cli 0.12.0__tar.gz → 0.13.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.
Files changed (73) hide show
  1. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/PKG-INFO +10 -1
  2. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/README.md +9 -0
  3. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/pyproject.toml +1 -1
  4. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/cli.py +166 -34
  5. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/client.py +79 -9
  6. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/config.py +24 -9
  7. acumatica_cli-0.13.0/src/acumatica_cli/snapshot.py +573 -0
  8. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/README.md +29 -1
  9. acumatica_cli-0.13.0/src/acumatica_cli/templates/distribution/snapshot/10-trial-balance.yaml +20 -0
  10. acumatica_cli-0.13.0/src/acumatica_cli/templates/finance/snapshot/10-trial-balance.yaml +20 -0
  11. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/__init__.py +0 -0
  12. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap.py +0 -0
  13. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  14. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  15. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/extract.py +0 -0
  16. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  17. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/firstlogin.py +0 -0
  18. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/models.py +0 -0
  19. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/output.py +0 -0
  20. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/run.py +0 -0
  21. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/seed.py +0 -0
  22. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/target.py +0 -0
  23. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/20-accounts.yaml +0 -0
  24. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/60-ledger-company.yaml +0 -0
  25. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/90-uoms.yaml +0 -0
  26. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/company.yaml +0 -0
  27. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/features.yaml +0 -0
  28. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/10-reason-codes.yaml +0 -0
  29. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/20-in-preferences.yaml +0 -0
  30. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/30-availability-rules.yaml +0 -0
  31. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/40-posting-classes.yaml +0 -0
  32. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/50-warehouse.yaml +0 -0
  33. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/51-warehouse-locations.yaml +0 -0
  34. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/52-warehouse-defaults.yaml +0 -0
  35. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/53-tax-categories.yaml +0 -0
  36. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/54-item-classes.yaml +0 -0
  37. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/56-so-preferences.yaml +0 -0
  38. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/57-po-preferences.yaml +0 -0
  39. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/58-order-types.yaml +0 -0
  40. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/60-ar-preferences.yaml +0 -0
  41. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/61-ap-preferences.yaml +0 -0
  42. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/62-ca-preferences.yaml +0 -0
  43. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/63-cash-account.yaml +0 -0
  44. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/64-payment-methods.yaml +0 -0
  45. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/65-statement-cycles.yaml +0 -0
  46. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/70-vendor-classes.yaml +0 -0
  47. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/71-customer-classes.yaml +0 -0
  48. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/75-vendors.yaml +0 -0
  49. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/76-customers.yaml +0 -0
  50. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/80-stock-items-parts.yaml +0 -0
  51. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/82-stock-items-kits.yaml +0 -0
  52. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/85-kit-specifications.yaml +0 -0
  53. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/10-seed-capital.yaml +0 -0
  54. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/20-buy-gateways.yaml +0 -0
  55. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/30-build.yaml +0 -0
  56. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/40-sell.yaml +0 -0
  57. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/setup/30-open-periods.yaml +0 -0
  58. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/10-subaccounts.yaml +0 -0
  59. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/20-accounts.yaml +0 -0
  60. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/40-ledger.yaml +0 -0
  61. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/50-gl-preferences.yaml +0 -0
  62. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/60-ledger-company.yaml +0 -0
  63. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/90-uoms.yaml +0 -0
  64. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/company.yaml +0 -0
  65. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/credit-terms.yaml +0 -0
  66. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/features.yaml +0 -0
  67. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/env +0 -0
  68. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/gitignore +0 -0
  69. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/10-financial-year.yaml +0 -0
  70. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/20-master-calendar.yaml +0 -0
  71. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/30-open-periods.yaml +0 -0
  72. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/target +0 -0
  73. {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/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.12.0
3
+ Version: 0.13.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>
@@ -89,6 +89,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
89
89
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
90
90
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
91
91
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
92
+ ├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
93
+ │ capture derived state into state/ (not seed)
92
94
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
93
95
  │ dump live tenant state as seed YAML (inverse of apply)
94
96
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -103,6 +105,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
103
105
  `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
104
106
  A path like `config/` expands nested seed dirs in that fixed order.
105
107
  `run` without FILES defaults to `scenario/`.
108
+ `snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
106
109
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
107
110
  Run `acu <command> --help` for details on any command.
108
111
 
@@ -119,6 +122,8 @@ Your configuration lives in its own git repo.
119
122
  | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
120
123
  | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
121
124
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
125
+ | `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
126
+ | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
122
127
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
123
128
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
124
129
 
@@ -131,6 +136,10 @@ Scenario YAML is different — it describes transactions that flow forward.
131
136
  `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.
132
137
  `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).
133
138
 
139
+ `acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
140
+
141
+ **Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
142
+
134
143
  ### Seed `endpoint:` symbols
135
144
 
136
145
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
@@ -71,6 +71,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
71
71
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
72
72
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
73
73
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
74
+ ├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
75
+ │ capture derived state into state/ (not seed)
74
76
  ├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
75
77
  │ dump live tenant state as seed YAML (inverse of apply)
76
78
  ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
@@ -85,6 +87,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
85
87
  `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
86
88
  A path like `config/` expands nested seed dirs in that fixed order.
87
89
  `run` without FILES defaults to `scenario/`.
90
+ `snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
88
91
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
89
92
  Run `acu <command> --help` for details on any command.
90
93
 
@@ -101,6 +104,8 @@ Your configuration lives in its own git repo.
101
104
  | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
102
105
  | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
103
106
  | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
107
+ | `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
108
+ | `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
104
109
  | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
105
110
  | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
106
111
 
@@ -113,6 +118,10 @@ Scenario YAML is different — it describes transactions that flow forward.
113
118
  `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.
114
119
  `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).
115
120
 
121
+ `acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
122
+
123
+ **Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
124
+
116
125
  ### Seed `endpoint:` symbols
117
126
 
118
127
  Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.12.0"
3
+ version = "0.13.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
  license = "PolyForm-Noncommercial-1.0.0"
@@ -12,7 +12,7 @@ import click
12
12
  import httpx
13
13
  from click.shell_completion import get_completion_class
14
14
 
15
- from . import bootstrap, extract, firstlogin, output, run, seed
15
+ from . import bootstrap, extract, firstlogin, output, run, seed, snapshot
16
16
  from .client import AcumaticaClient
17
17
  from .config import (
18
18
  INIT_FLAVORS,
@@ -537,37 +537,73 @@ def config_check(ctx: click.Context, strict: bool) -> None:
537
537
  # both live probes run through the exact objects live commands use, so
538
538
  # a pass here proves the real code path, not a parallel one
539
539
  inst = _resolve_instance(ctx)
540
- failed = _probe_target(root, inst, strict=strict)
540
+ failed, claimed_erp = _probe_target(root, inst, strict=strict)
541
+ if not _probe_rest(inst, claimed_erp):
542
+ failed = True
543
+ if not _probe_ssh(inst):
544
+ failed = True
545
+ if failed:
546
+ raise SystemExit(1)
547
+
548
+
549
+ def _probe_rest(inst: Instance, claimed_erp: str | None) -> bool:
550
+ """REST login + endpoints + optional ERP build; True = all passed.
551
+
552
+ Entering the client is the probe: login + landed-tenant verify (V5);
553
+ context manager guarantees logout (V6). One GET /entity feeds endpoints
554
+ (T74/T90) and optional ERP build (T92).
555
+ """
541
556
  try:
542
- # entering the client is the whole probe: login + landed-tenant
543
- # verify (V5), and the context manager guarantees logout (V6);
544
- # endpoints probe (T74) reuses the same session when login succeeds
545
557
  with AcumaticaClient(inst) as client:
546
558
  output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
547
- if not _probe_endpoints(client, inst):
548
- failed = True
559
+ return _probe_entity_root(client, inst, claimed_erp)
549
560
  except (RuntimeError, httpx.HTTPError) as exc:
550
561
  output.data(f"fail rest: {exc}")
551
- failed = True
562
+ return False
563
+
564
+
565
+ def _probe_entity_root(
566
+ client: AcumaticaClient, inst: Instance, claimed_erp: str | None
567
+ ) -> bool:
568
+ """Endpoints (+ ERP when claimed); True = all passed."""
569
+ ok = True
570
+ try:
571
+ endpoints, live_build = client.entity_root()
572
+ except (RuntimeError, httpx.HTTPError) as exc:
573
+ output.data(f"fail endpoints: {exc}")
574
+ endpoints, live_build = [], None
575
+ ok = False
576
+ else:
577
+ if not _probe_endpoints(endpoints, inst):
578
+ ok = False
579
+ if claimed_erp is not None and not _probe_erp(claimed_erp, live_build):
580
+ ok = False
581
+ return ok
582
+
583
+
584
+ def _probe_ssh(inst: Instance) -> bool:
585
+ """SSH ping or skip when unset; True = pass/skip, False = fail."""
552
586
  if not inst.ssh:
553
587
  # V3/I.cmd: ACU_SSH optional — hosted data-plane path skips, never fails
554
588
  output.data("skip ssh (ACU_SSH not set)")
555
- else:
556
- try:
557
- TenantManager(inst).ping()
558
- output.data(f"ok ssh ({inst.ssh})")
559
- except RuntimeError as exc:
560
- output.data(f"fail ssh: {exc}")
561
- failed = True
562
- if failed:
563
- raise SystemExit(1)
589
+ return True
590
+ try:
591
+ TenantManager(inst).ping()
592
+ output.data(f"ok ssh ({inst.ssh})")
593
+ return True
594
+ except RuntimeError as exc:
595
+ output.data(f"fail ssh: {exc}")
596
+ return False
564
597
 
565
598
 
566
- def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
567
- """Emit the local target probe line; return True when it failed (V27).
599
+ def _probe_target(
600
+ root: Path | None, inst: Instance, *, strict: bool
601
+ ) -> tuple[bool, str | None]:
602
+ """Emit local target probe; return ``(failed, claimed_erp?)`` (V27).
568
603
 
569
604
  Invalid target hard-exits (any loader). Missing under data root warns
570
- unless --strict. No data root → skip. Match → ok (erp claimed-only).
605
+ unless --strict. No data root → skip. Match → ok + claimed ``erp`` for
606
+ the live ERP probe after REST (T92).
571
607
  """
572
608
  try:
573
609
  target = load_target(root)
@@ -576,7 +612,7 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
576
612
  raise SystemExit(1) from exc
577
613
  if root is None:
578
614
  output.data("skip target (no data root)")
579
- return False
615
+ return False, None
580
616
  if target is None:
581
617
  msg = (
582
618
  f"target: no target.yaml under {root} - dataset verified matrix "
@@ -584,35 +620,27 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
584
620
  "to require it"
585
621
  )
586
622
  output.data(f"{'fail' if strict else 'warn'} {msg}")
587
- return strict
623
+ return strict, None
588
624
  if target.default_api != inst.api_version:
589
625
  output.data(
590
626
  f"fail target: dataset default_api={target.default_api} vs "
591
627
  f"configured {inst.api_version}"
592
628
  )
593
- return True
629
+ return True, None
594
630
  output.data(
595
631
  f"ok target (default_api={target.default_api} matches configured; "
596
632
  f"erp={target.erp} claimed)"
597
633
  )
598
- # T76: no stable HTTP ERP-build discovery (V12) — keep erp claimed-only;
599
- # skip rather than invent SSH/sqlcmd (control-plane exclusion, V1)
600
- output.data(f"skip erp (live probe not available; claimed {target.erp})")
601
- return False
634
+ return False, target.erp
602
635
 
603
636
 
604
- def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
637
+ def _probe_endpoints(endpoints: list[tuple[str, str]], inst: Instance) -> bool:
605
638
  """Emit endpoints probe; return True on pass, False on fail (V12/V27).
606
639
 
607
640
  Exact match: a Default entry whose version half equals
608
- ``Instance.api_version``. Fail-closed when GET /entity is unparseable.
641
+ ``Instance.api_version``. Caller already fail-closed on unparseable GET.
609
642
  """
610
643
  want = f"Default/{inst.api_version}"
611
- try:
612
- endpoints = client.list_endpoints()
613
- except (RuntimeError, httpx.HTTPError) as exc:
614
- output.data(f"fail endpoints: {exc}")
615
- return False
616
644
  defaults = [v for name, v in endpoints if name == "Default"]
617
645
  if inst.api_version in defaults:
618
646
  output.data(f"ok endpoints ({want} present)")
@@ -625,6 +653,34 @@ def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
625
653
  return False
626
654
 
627
655
 
656
+ def _major_minor(version: str) -> str:
657
+ """First two dotted segments (T76/T92 major.minor match)."""
658
+ parts = version.split(".")
659
+ if len(parts) >= 2:
660
+ return f"{parts[0]}.{parts[1]}"
661
+ return version
662
+
663
+
664
+ def _probe_erp(claimed: str, live: str | None) -> bool:
665
+ """Emit ERP probe; return True on pass/skip, False on fail (T92).
666
+
667
+ Live id comes from 26.x ``GET /entity`` wrapper
668
+ ``version.acumaticaBuildVersion``. Bare array → skip (no HTTP surface).
669
+ Compare major.minor only — patch builds may drift within a claimed line.
670
+ """
671
+ if live is None:
672
+ output.data(f"skip erp (live probe not available; claimed {claimed})")
673
+ return True
674
+ if _major_minor(live) == _major_minor(claimed):
675
+ output.data(f"ok erp ({live} matches claimed {claimed})")
676
+ return True
677
+ output.data(
678
+ f"fail erp: live {live} vs claimed {claimed} "
679
+ f"(major.minor {_major_minor(live)} vs {_major_minor(claimed)})"
680
+ )
681
+ return False
682
+
683
+
628
684
  SEED_DIRS = ("bootstrap", "baseline", "setup", "master")
629
685
 
630
686
 
@@ -890,3 +946,79 @@ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
890
946
  output.data(f" {line}")
891
947
  raise SystemExit(2)
892
948
  output.success(f"no drift on {inst.tenant} ({inst.base_url}, {files} file(s))")
949
+
950
+
951
+ @cli.command("snapshot")
952
+ @click.argument(
953
+ "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)
954
+ )
955
+ @click.option(
956
+ "--out",
957
+ "out_dir",
958
+ type=click.Path(file_okay=False, path_type=Path),
959
+ default=None,
960
+ help="Observation output directory (default: state/)",
961
+ )
962
+ @click.option(
963
+ "--diff",
964
+ "do_diff",
965
+ is_flag=True,
966
+ help="Compare live vs disk; write nothing (exit 0 either way)",
967
+ )
968
+ @click.option(
969
+ "--assert-unchanged",
970
+ is_flag=True,
971
+ help="Like --diff, but exit 2 when state moved (idempotence gate)",
972
+ )
973
+ @click.option(
974
+ "--dry-run",
975
+ is_flag=True,
976
+ help="Resolve views and validate sources without HTTP",
977
+ )
978
+ @pass_instance
979
+ def snapshot_cmd(
980
+ inst: Instance,
981
+ files: tuple[Path, ...],
982
+ out_dir: Path | None,
983
+ do_diff: bool,
984
+ assert_unchanged: bool,
985
+ dry_run: bool,
986
+ ) -> None:
987
+ """Capture live derived state into committed observation files.
988
+
989
+ FILES are snapshot view YAML files or directories. Omitted, they default
990
+ to the data repo's config/snapshot/ directory (hard-cut; no root
991
+ snapshot/ fallback). Default write target is state/ (--out; no
992
+ snapshots/ fallback). Bare capture writes observations (change is
993
+ fine). --diff compares live to disk without writing.
994
+ --assert-unchanged is the warm-run idempotence gate (exit 2 when
995
+ moved). Never writes seed trees or endpoint: symbols (V32). Exit 0
996
+ ok, 1 op fail, 2 only under --assert-unchanged when state moved.
997
+ """
998
+ assert_target_compatible(inst)
999
+ if not files:
1000
+ default = data_root() / "config" / "snapshot"
1001
+ if not default.is_dir():
1002
+ raise SystemExit(f"{default}: snapshot directory does not exist")
1003
+ files = (Path(os.path.relpath(default)),)
1004
+ paths = snapshot.expand_view_files(files)
1005
+ views = [snapshot.load_view(path) for path in paths]
1006
+ dest = out_dir if out_dir is not None else Path("state")
1007
+ if dry_run:
1008
+ code = snapshot.run_views(None, views, out_dir=dest, mode="dry")
1009
+ else:
1010
+ mode = "assert" if assert_unchanged else "diff" if do_diff else "write"
1011
+ with AcumaticaClient(inst) as client:
1012
+ code = snapshot.run_views(client, views, out_dir=dest, mode=mode)
1013
+ if code:
1014
+ raise SystemExit(code)
1015
+ if dry_run:
1016
+ return
1017
+ if assert_unchanged:
1018
+ output.success(f"{len(views)} snapshot(s) unchanged on {inst.tenant}")
1019
+ elif do_diff:
1020
+ output.success(f"{len(views)} snapshot(s) compared on {inst.tenant}")
1021
+ else:
1022
+ output.success(
1023
+ f"{len(views)} snapshot(s) written under {dest} on {inst.tenant}"
1024
+ )
@@ -65,10 +65,22 @@ def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
65
65
  def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
66
66
  """Parse ``GET /entity`` into ``[(name, version), ...]`` (fail-closed).
67
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.
68
+ Dual shape (V31 / gh #20): top-level JSON array (legacy) or object with
69
+ an ``endpoints`` array (26.x wrapper). Each row needs string ``name``
70
+ and ``version``. Unparseable body → RuntimeError with status,
71
+ content-type, and a short raw hint so a shape change is re-verified
72
+ (V12) rather than silently skipped.
73
+ """
74
+ return parse_entity_response(response)[0]
75
+
76
+
77
+ def parse_entity_response(
78
+ response: httpx.Response,
79
+ ) -> tuple[list[tuple[str, str]], str | None]:
80
+ """Parse ``GET /entity`` → ``(endpoints, acumatica_build_version?)``.
81
+
82
+ Endpoints dual-shape per V31. Build id is ``version.acumaticaBuildVersion``
83
+ on the 26.x wrapper only; bare array → ``None`` (T92).
72
84
  """
73
85
  try:
74
86
  body = response.json()
@@ -80,15 +92,17 @@ def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
80
92
  f"content-type {response.headers.get('content-type', '?')}; "
81
93
  f"first 200 chars: {hint})"
82
94
  ) from exc
83
- if not isinstance(body, list) or not body:
95
+ rows = _entity_list_rows(body)
96
+ if rows is None:
84
97
  hint = str(body)[:200]
85
98
  raise RuntimeError(
86
99
  "GET /entity response not parseable as endpoint list "
87
- f"(status {response.status_code}; expected non-empty JSON array; "
100
+ f"(status {response.status_code}; expected non-empty JSON array "
101
+ "or object with non-empty 'endpoints' array; "
88
102
  f"first 200 chars: {hint})"
89
103
  )
90
104
  out: list[tuple[str, str]] = []
91
- for item in body:
105
+ for item in rows:
92
106
  if not isinstance(item, dict):
93
107
  raise RuntimeError(
94
108
  "GET /entity response not parseable as endpoint list "
@@ -102,7 +116,31 @@ def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
102
116
  f"(row missing string name/version: {item!r})"
103
117
  )
104
118
  out.append((name, version))
105
- return out
119
+ return out, _entity_build_version(body)
120
+
121
+
122
+ def _entity_list_rows(body: Any) -> list[Any] | None:
123
+ """Return endpoint row list from array or 26.x wrapper; else None."""
124
+ if isinstance(body, list) and body:
125
+ return body
126
+ if isinstance(body, dict):
127
+ endpoints = body.get("endpoints")
128
+ if isinstance(endpoints, list) and endpoints:
129
+ return endpoints
130
+ return None
131
+
132
+
133
+ def _entity_build_version(body: Any) -> str | None:
134
+ """``version.acumaticaBuildVersion`` from 26.x wrapper; else None."""
135
+ if not isinstance(body, dict):
136
+ return None
137
+ version = body.get("version")
138
+ if not isinstance(version, dict):
139
+ return None
140
+ build = version.get("acumaticaBuildVersion")
141
+ if isinstance(build, str) and build:
142
+ return build
143
+ return None
106
144
 
107
145
 
108
146
  # The list GET's optimized-export failure (B9): the contract API's list GET
@@ -245,8 +283,16 @@ class AcumaticaClient:
245
283
 
246
284
  Fail-closed on unparseable body — see ``parse_entity_list``.
247
285
  """
286
+ return self.entity_root()[0]
287
+
288
+ def entity_root(self) -> tuple[list[tuple[str, str]], str | None]:
289
+ """Authenticated ``GET /entity`` → endpoints + optional ERP build id.
290
+
291
+ Build id is present only on the 26.x wrapper
292
+ (``version.acumaticaBuildVersion``). Bare array → ``None`` (T92).
293
+ """
248
294
  r = self._checked(self._http.get("/entity"))
249
- return parse_entity_list(r)
295
+ return parse_entity_response(r)
250
296
 
251
297
  @staticmethod
252
298
  def _checked(r: httpx.Response) -> httpx.Response:
@@ -492,3 +538,27 @@ class AcumaticaClient:
492
538
  return self._checked(
493
539
  self._http.post("/CustomizationApi/publishEnd", json={})
494
540
  ).json()
541
+
542
+ # -- OData Generic Inquiry (snapshot gi: source; V33) --
543
+
544
+ def _odata_gi_root(self) -> str:
545
+ """24R2+ OData GI service root ``/t/<tenant>/api/odata/gi`` (V33)."""
546
+ tenant = quote(self.instance.tenant, safe="")
547
+ return f"/t/{tenant}/api/odata/gi"
548
+
549
+ def odata_gi_metadata(self, name: str) -> str:
550
+ """GET OData GI service ``$metadata`` XML (V33 param validation).
551
+
552
+ ``name`` is reserved for future per-GI metadata URLs; the 24R2
553
+ service exposes one EDMX document at the service root.
554
+ """
555
+ del name # service-level metadata; per-GI filter is validate_gi_params
556
+ r = self._checked(self._http.get(f"{self._odata_gi_root()}/$metadata"))
557
+ return r.text
558
+
559
+ def odata_gi(self, name: str, params: dict[str, str] | None = None) -> Any:
560
+ """GET OData GI rows as JSON (requires Expose via OData on the GI)."""
561
+ gi = quote(name, safe="")
562
+ return self._checked(
563
+ self._http.get(f"{self._odata_gi_root()}/{gi}", params=params)
564
+ ).json()
@@ -58,6 +58,11 @@ INIT_TEMPLATES = (
58
58
  ("finance/setup/10-financial-year.yaml", "setup/10-financial-year.yaml"),
59
59
  ("finance/setup/20-master-calendar.yaml", "setup/20-master-calendar.yaml"),
60
60
  ("finance/setup/30-open-periods.yaml", "setup/30-open-periods.yaml"),
61
+ # V28/V32: observer views under config/snapshot/ (not SEED_DIRS; lone config/ ok)
62
+ (
63
+ "finance/snapshot/10-trial-balance.yaml",
64
+ "config/snapshot/10-trial-balance.yaml",
65
+ ),
61
66
  )
62
67
 
63
68
  # Opt-in `--flavor distribution` overlays + extras (V28/V29/T87). Resource
@@ -168,6 +173,10 @@ DISTRIBUTION_TEMPLATES = (
168
173
  ),
169
174
  ("distribution/scenario/30-build.yaml", "scenario/30-build.yaml"),
170
175
  ("distribution/scenario/40-sell.yaml", "scenario/40-sell.yaml"),
176
+ (
177
+ "distribution/snapshot/10-trial-balance.yaml",
178
+ "config/snapshot/10-trial-balance.yaml",
179
+ ),
171
180
  ("distribution/README.md", "README.md"),
172
181
  )
173
182
 
@@ -237,10 +246,13 @@ class Instance(BaseSettings):
237
246
  def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
238
247
  """Resolve (resource, dest) pairs for ``config init`` (V28/T87).
239
248
 
240
- Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root. ``distribution``
241
- keeps root meta (``.env``/``.gitignore``/``target.yaml``), rehomes finance
242
- seed under ``config/``, then overlays ``DISTRIBUTION_TEMPLATES`` (config/
243
- seeds + lifecycle ``scenario/`` + README). Never dual root+config trees.
249
+ Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root (+ observer
250
+ ``config/snapshot/``). ``distribution`` keeps root meta
251
+ (``.env``/``.gitignore``/``target.yaml``), rehomes finance seed under
252
+ ``config/``, then overlays ``DISTRIBUTION_TEMPLATES`` (config/ seeds +
253
+ lifecycle ``scenario/`` + ``config/snapshot/`` + README). Never dual
254
+ root+config seed trees. Paths already under ``config/`` (snapshot
255
+ views) pass through unchanged.
244
256
  """
245
257
  if flavor is None:
246
258
  return INIT_TEMPLATES
@@ -256,11 +268,14 @@ def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
256
268
  by_dest[dest] = res
257
269
  order.append(dest)
258
270
  continue
259
- target = (
260
- f"config/{dest}"
261
- if any(dest.startswith(p) for p in _SEED_PREFIXES)
262
- else dest
263
- )
271
+ if dest.startswith("config/"):
272
+ target = dest
273
+ else:
274
+ target = (
275
+ f"config/{dest}"
276
+ if any(dest.startswith(p) for p in _SEED_PREFIXES)
277
+ else dest
278
+ )
264
279
  if target not in by_dest:
265
280
  order.append(target)
266
281
  by_dest[target] = res