acumatica-cli 0.6.0__tar.gz → 0.7.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 (32) hide show
  1. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/PKG-INFO +1 -1
  2. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/pyproject.toml +1 -1
  3. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/cli.py +37 -1
  4. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/client.py +13 -3
  5. acumatica_cli-0.7.0/src/acumatica_cli/run.py +369 -0
  6. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/README.md +0 -0
  7. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/__init__.py +0 -0
  8. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/bootstrap.py +0 -0
  9. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  10. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
  11. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/config.py +0 -0
  12. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/extract.py +0 -0
  13. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
  14. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/firstlogin.py +0 -0
  15. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/models.py +0 -0
  16. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/output.py +0 -0
  17. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/seed.py +0 -0
  18. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  19. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  20. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  21. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
  22. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
  23. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  24. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
  25. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
  26. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  27. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/env +0 -0
  28. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/gitignore +0 -0
  29. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
  30. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
  31. {acumatica_cli-0.6.0 → acumatica_cli-0.7.0}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
  32. {acumatica_cli-0.6.0 → acumatica_cli-0.7.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.6.0
3
+ Version: 0.7.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.6.0"
3
+ version = "0.7.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"
@@ -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, seed
15
+ from . import bootstrap, extract, firstlogin, output, run, seed
16
16
  from .client import AcumaticaClient
17
17
  from .config import (
18
18
  Instance,
@@ -557,6 +557,42 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
557
557
  _exit_on_drift(inst, drifts, len(paths))
558
558
 
559
559
 
560
+ @cli.command("run")
561
+ @click.argument(
562
+ "files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)
563
+ )
564
+ @click.option("--dry-run", is_flag=True, help="Parse and list steps without any HTTP")
565
+ @pass_instance
566
+ def run_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
567
+ """Execute transaction scenario YAML against the live tenant.
568
+
569
+ FILES are scenario YAML files or directories containing them. Omitted,
570
+ they default to the data repo's scenario/ directory. Transactions are
571
+ executed forward (the server assigns document numbers), never upserted;
572
+ delta expectations snapshot before the first step and compare after the
573
+ last, so a scenario re-runs safely on a warm tenant. Exit 0 when every
574
+ expectation holds, 1 on any step error or expectation miss.
575
+ """
576
+ if not files:
577
+ default = data_root() / "scenario"
578
+ if not default.is_dir():
579
+ raise SystemExit(f"{default}: scenario directory does not exist")
580
+ files = (Path(os.path.relpath(default)),)
581
+ paths = expand_files(files)
582
+ scenarios = [run.load_scenario(path) for path in paths]
583
+ ok = True
584
+ if dry_run:
585
+ for scenario in scenarios:
586
+ run.run(None, scenario, dry_run=True)
587
+ else:
588
+ with AcumaticaClient(inst) as client:
589
+ for scenario in scenarios:
590
+ ok = run.run(client, scenario) and ok
591
+ if not ok:
592
+ raise SystemExit(1)
593
+ output.success(f"{len(scenarios)} scenario(s) passed on {inst.tenant}")
594
+
595
+
560
596
  def _complete_only(
561
597
  _ctx: click.Context, _param: click.Parameter, incomplete: str
562
598
  ) -> list[str]:
@@ -230,11 +230,21 @@ class AcumaticaClient:
230
230
  return self._checked(self._http.get(self._url("swagger.json"))).content
231
231
 
232
232
  def put(
233
- self, entity: str, record: dict[str, Any], endpoint: str | None = None
233
+ self,
234
+ entity: str,
235
+ record: dict[str, Any],
236
+ endpoint: str | None = None,
237
+ params: dict[str, str] | None = None,
234
238
  ) -> dict[str, Any]:
235
- """Upsert by the entity's key fields — the idempotence primitive."""
239
+ """Upsert by the entity's key fields — the idempotence primitive.
240
+
241
+ `params` carries OData query options — the contract-inquiry idiom
242
+ (T62) is a PUT of the parameter record with `$expand=Results`.
243
+ """
236
244
  return self._checked(
237
- self._http.put(self._url(entity, endpoint), json=wrap(record))
245
+ self._http.put(
246
+ self._url(entity, endpoint), json=wrap(record), params=params
247
+ )
238
248
  ).json()
239
249
 
240
250
  # seconds between polls of a long-running action's status URL; module
@@ -0,0 +1,369 @@
1
+ """Transaction scenarios: scenario/*.yaml executed against the live tenant.
2
+
3
+ `acu run` is the third data-plane verb (SPEC I.cmd): apply/diff own
4
+ configuration (keyed idempotent upserts), run owns transactions - the
5
+ server assigns document numbers, so a scenario is executed forward,
6
+ never upserted. The scenario file is the contract for the future AI
7
+ scenario generator: declarative steps, checkable expectations.
8
+
9
+ Scenario file format (SPEC I.data):
10
+
11
+ scenario: buy-build-sell
12
+ description: Buy parts -> assemble kits -> sell -> collect
13
+ steps:
14
+ - id: po-shenzhen
15
+ put: PurchaseOrder
16
+ record:
17
+ VendorID: SHENZHEN
18
+ Details:
19
+ - { InventoryID: MB-CM4, OrderQty: 25 }
20
+ capture: { OrderNbr: po_shenzhen } # server-assigned -> ${var}
21
+ - id: release
22
+ action: { entity: PurchaseReceipt, name: ReleasePurchaseReceipt }
23
+ record: { ReceiptNbr: "${rcpt}" }
24
+ wait: # poll until field match
25
+ entity: PurchaseReceipt
26
+ keys: ["${rcpt}"]
27
+ until: { Status: Released }
28
+ expect:
29
+ - get: { entity: Payment, keys: [Payment, "${pmt}"] }
30
+ fields: { Status: Closed }
31
+ - inquire: AccountSummaryInquiry # delta = post - pre
32
+ parameters: { Ledger: ACTUAL, Period: "062026" }
33
+ match: { Account: "40000" }
34
+ delta: { EndingBalance: 4138.00 }
35
+
36
+ Steps run in order; `capture` lifts server-assigned fields into ${var}
37
+ tokens for later steps. `expect` delta assertions snapshot before the
38
+ first step and re-probe after the last, comparing the difference - the
39
+ scenario re-runs safely on a warm tenant (document numbers differ, the
40
+ deltas hold). `get` assertions are absolute (statuses of documents
41
+ created in this run). Exit 0 = every expectation holds; 1 = any step
42
+ error or expectation miss (2 stays diff's drift code).
43
+ """
44
+
45
+ import re
46
+ import time
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ import yaml
51
+ from pydantic import Field, ValidationError, model_validator
52
+
53
+ from . import output
54
+ from .client import AcumaticaClient, unwrap
55
+ from .models import Model, validation_summary
56
+ from .seed import _norm # pyright: ignore[reportPrivateUsage]
57
+
58
+ _VAR = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
59
+
60
+
61
+ class ActionSpec(Model):
62
+ """The contract action a step invokes: entity + action name."""
63
+
64
+ entity: str
65
+ name: str
66
+
67
+
68
+ class WaitSpec(Model):
69
+ """Poll a record by key URL until every `until` field matches."""
70
+
71
+ entity: str
72
+ keys: list[Any]
73
+ until: dict[str, Any]
74
+ timeout: float = 120.0
75
+ endpoint: str | None = None
76
+
77
+
78
+ class Step(Model):
79
+ """One scenario step: exactly one op (put | action | wait-only)."""
80
+
81
+ id: str
82
+ put: str | None = None
83
+ action: ActionSpec | None = None
84
+ record: dict[str, Any] | None = None
85
+ parameters: dict[str, Any] | None = None
86
+ capture: dict[str, str] | None = None # {Field: var_name}
87
+ wait: WaitSpec | None = None
88
+ endpoint: str | None = None
89
+
90
+ @model_validator(mode="after")
91
+ def _one_op(self) -> "Step":
92
+ ops = [op for op in (self.put, self.action) if op is not None]
93
+ if len(ops) > 1:
94
+ raise ValueError(f"step '{self.id}': put and action are exclusive")
95
+ if not ops and self.wait is None:
96
+ raise ValueError(f"step '{self.id}': needs one of put, action, wait")
97
+ if self.put is not None and self.record is None:
98
+ raise ValueError(f"step '{self.id}': put needs a record")
99
+ if self.action is not None and self.record is None:
100
+ raise ValueError(f"step '{self.id}': action needs a record")
101
+ if self.capture is not None and self.put is None:
102
+ raise ValueError(f"step '{self.id}': capture rides a put step")
103
+ return self
104
+
105
+
106
+ class GetSpec(Model):
107
+ """Key-URL record address for a get expectation."""
108
+
109
+ entity: str
110
+ keys: list[Any]
111
+ endpoint: str | None = None
112
+
113
+
114
+ class Expect(Model):
115
+ """One expectation: absolute `get` fields or `inquire` deltas."""
116
+
117
+ get: GetSpec | None = None
118
+ fields: dict[str, Any] | None = None
119
+ inquire: str | None = None
120
+ parameters: dict[str, Any] | None = None
121
+ match: dict[str, Any] | None = None
122
+ delta: dict[str, float] | None = None
123
+ endpoint: str | None = None
124
+
125
+ @model_validator(mode="after")
126
+ def _one_kind(self) -> "Expect":
127
+ if (self.get is None) == (self.inquire is None):
128
+ raise ValueError("expect: exactly one of get, inquire")
129
+ if self.get is not None and self.fields is None:
130
+ raise ValueError("expect: get needs fields")
131
+ if self.inquire is not None and self.delta is None:
132
+ raise ValueError("expect: inquire needs delta")
133
+ return self
134
+
135
+ def label(self) -> str:
136
+ """Human-readable expectation address for report lines."""
137
+ if self.get is not None:
138
+ keys = ", ".join(str(k) for k in self.get.keys)
139
+ return f"{self.get.entity} [{keys}]"
140
+ match = ""
141
+ if self.match:
142
+ match = f" [{', '.join(f'{k}={v}' for k, v in self.match.items())}]"
143
+ return f"{self.inquire}{match}"
144
+
145
+
146
+ class Scenario(Model):
147
+ """A parsed scenario YAML: named steps plus expectations."""
148
+
149
+ path: Path
150
+ scenario: str
151
+ description: str | None = None
152
+ steps: list[Step]
153
+ expect: list[Expect] = Field(default_factory=list)
154
+
155
+ @model_validator(mode="after")
156
+ def _unique_step_ids(self) -> "Scenario":
157
+ seen: set[str] = set()
158
+ for step in self.steps:
159
+ if step.id in seen:
160
+ raise ValueError(f"duplicate step id '{step.id}'")
161
+ seen.add(step.id)
162
+ return self
163
+
164
+
165
+ def load_scenario(path: Path) -> Scenario:
166
+ """Parse and validate one scenario YAML file."""
167
+ with open(path) as f:
168
+ data = yaml.safe_load(f)
169
+ if not isinstance(data, dict):
170
+ raise SystemExit(f"{path}: expected a mapping at the top level")
171
+ try:
172
+ return Scenario.model_validate({"path": path, **data})
173
+ except ValidationError as exc:
174
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
175
+
176
+
177
+ def _subst(value: Any, variables: dict[str, Any]) -> Any:
178
+ """Interpolate ${var} tokens; unknown names are a hard error."""
179
+
180
+ def _one(text: str) -> Any:
181
+ whole = _VAR.fullmatch(text)
182
+ if whole: # whole-value token keeps the captured value's type
183
+ return _lookup(whole.group(1))
184
+ return _VAR.sub(lambda m: str(_lookup(m.group(1))), text)
185
+
186
+ def _lookup(name: str) -> Any:
187
+ if name not in variables:
188
+ raise SystemExit(
189
+ f"unknown scenario variable '${{{name}}}' - captured so far: "
190
+ f"{', '.join(sorted(variables)) or '(none)'}"
191
+ )
192
+ return variables[name]
193
+
194
+ if isinstance(value, str):
195
+ return _one(value)
196
+ if isinstance(value, dict):
197
+ return {k: _subst(v, variables) for k, v in value.items()}
198
+ if isinstance(value, list):
199
+ return [_subst(v, variables) for v in value]
200
+ return value
201
+
202
+
203
+ def _inquire(client: AcumaticaClient, expect: Expect) -> dict[str, float]:
204
+ """Probe a contract inquiry; sum each delta field over matching rows."""
205
+ assert expect.inquire is not None
206
+ assert expect.delta is not None
207
+ body = client.put(
208
+ expect.inquire,
209
+ expect.parameters or {},
210
+ endpoint=expect.endpoint,
211
+ params={"$expand": "Results"},
212
+ )
213
+ totals = dict.fromkeys(expect.delta, 0.0)
214
+ for row in body.get("Results") or []:
215
+ values = unwrap(row)
216
+ if expect.match and any(
217
+ field not in values or _norm(values[field]) != _norm(want)
218
+ for field, want in expect.match.items()
219
+ ):
220
+ continue
221
+ for field in totals:
222
+ totals[field] += float(values.get(field) or 0.0)
223
+ return totals
224
+
225
+
226
+ def _wait(client: AcumaticaClient, spec: WaitSpec, variables: dict[str, Any]) -> None:
227
+ """Poll the record until every `until` field matches (live state, V4)."""
228
+ keys = _subst(spec.keys, variables)
229
+ until = _subst(spec.until, variables)
230
+ deadline = time.monotonic() + spec.timeout
231
+ state: dict[str, Any] | None = None
232
+ while time.monotonic() < deadline:
233
+ record = client.get_record(spec.entity, keys, spec.endpoint)
234
+ state = unwrap(record) if record is not None else None
235
+ if state is not None and all(
236
+ field in state and _norm(state[field]) == _norm(want)
237
+ for field, want in until.items()
238
+ ):
239
+ return
240
+ time.sleep(client.poll_interval)
241
+ raise RuntimeError(
242
+ f"wait timed out after {spec.timeout:g}s: {spec.entity} "
243
+ f"{keys} never reached {until} (last state: "
244
+ + (
245
+ ", ".join(f"{k}={state.get(k)!r}" for k in until)
246
+ if state is not None
247
+ else "record missing"
248
+ )
249
+ + ")"
250
+ )
251
+
252
+
253
+ def _dry_run(scenario: Scenario) -> None:
254
+ """List steps and expectations without any HTTP."""
255
+ for step in scenario.steps:
256
+ op = (
257
+ f"PUT {step.put}"
258
+ if step.put
259
+ else f"invoke {step.action.entity}/{step.action.name}"
260
+ if step.action
261
+ else f"wait {step.wait.entity}" # pyright: ignore[reportOptionalMemberAccess]
262
+ )
263
+ output.data(f" would {op} [{step.id}]")
264
+ for expect in scenario.expect:
265
+ output.data(f" would check {expect.label()}")
266
+
267
+
268
+ def _run_step(client: AcumaticaClient, step: Step, variables: dict[str, Any]) -> None:
269
+ """Execute one step, folding captured fields into the variable set."""
270
+ if step.put is not None:
271
+ assert step.record is not None
272
+ body = client.put(
273
+ step.put, _subst(step.record, variables), endpoint=step.endpoint
274
+ )
275
+ output.data(f" put {step.put} [{step.id}]")
276
+ for field, var in (step.capture or {}).items():
277
+ echoed = unwrap(body)
278
+ if field not in echoed:
279
+ raise RuntimeError(
280
+ f"step '{step.id}': capture field '{field}' not in the PUT response"
281
+ )
282
+ variables[var] = echoed[field]
283
+ elif step.action is not None:
284
+ assert step.record is not None
285
+ client.invoke(
286
+ step.action.entity,
287
+ step.action.name,
288
+ _subst(step.record, variables),
289
+ _subst(step.parameters, variables),
290
+ step.endpoint,
291
+ )
292
+ output.data(f" invoke {step.action.name} [{step.id}]")
293
+ if step.wait is not None:
294
+ _wait(client, step.wait, variables)
295
+ output.data(f" wait ok [{step.id}]")
296
+
297
+
298
+ def _check_get(
299
+ client: AcumaticaClient, expect: Expect, variables: dict[str, Any]
300
+ ) -> bool:
301
+ """Absolute field assertions on a key-URL record."""
302
+ assert expect.get is not None
303
+ assert expect.fields is not None
304
+ keys = _subst(expect.get.keys, variables)
305
+ # label from the substituted keys - the raw ${var} token is opaque
306
+ label = f"{expect.get.entity} [{', '.join(str(k) for k in keys)}]"
307
+ record = client.get_record(expect.get.entity, keys, expect.get.endpoint)
308
+ state = unwrap(record) if record is not None else {}
309
+ ok = True
310
+ for field, want in _subst(expect.fields, variables).items():
311
+ got = state.get(field)
312
+ if record is None or field not in state:
313
+ ok = False
314
+ output.data(f" x {label}.{field}: not found")
315
+ elif _norm(got) != _norm(want):
316
+ ok = False
317
+ output.data(f" x {label}.{field}: expected {want!r} got {got!r}")
318
+ else:
319
+ output.data(f" + {label}.{field} = {want!r}")
320
+ return ok
321
+
322
+
323
+ def _check_delta(
324
+ client: AcumaticaClient, expect: Expect, snapshot: dict[str, float]
325
+ ) -> bool:
326
+ """Delta assertions: post-run probe minus the pre-run snapshot."""
327
+ assert expect.delta is not None
328
+ after = _inquire(client, expect)
329
+ ok = True
330
+ for field, want in expect.delta.items():
331
+ got = after[field] - snapshot[field]
332
+ if abs(got - want) > 0.005:
333
+ ok = False
334
+ output.data(
335
+ f" x {expect.label()}.{field}: expected delta {want:+g} got {got:+g}"
336
+ )
337
+ else:
338
+ output.data(f" + {expect.label()}.{field} delta {want:+g}")
339
+ return ok
340
+
341
+
342
+ def run(
343
+ client: AcumaticaClient | None, scenario: Scenario, dry_run: bool = False
344
+ ) -> bool:
345
+ """Execute the scenario; True when every step and expectation held.
346
+
347
+ client may be None only under dry_run - parsing and step listing
348
+ never touch HTTP (a preview costs nothing live). Delta expectations
349
+ snapshot BEFORE the first step (V4): the comparison is post minus
350
+ pre, so a warm tenant re-runs clean.
351
+ """
352
+ output.data(f"{scenario.path} -> {scenario.scenario}")
353
+ if dry_run:
354
+ _dry_run(scenario)
355
+ return True
356
+ assert client is not None # non-dry-run callers pass a live session
357
+ before = [
358
+ _inquire(client, expect) if expect.inquire else {} for expect in scenario.expect
359
+ ]
360
+ variables: dict[str, Any] = {}
361
+ for step in scenario.steps:
362
+ _run_step(client, step, variables)
363
+ ok = True
364
+ for expect, snapshot in zip(scenario.expect, before, strict=True):
365
+ if expect.get is not None:
366
+ ok = _check_get(client, expect, variables) and ok
367
+ else:
368
+ ok = _check_delta(client, expect, snapshot) and ok
369
+ return ok
File without changes