acumatica-cli 0.9.0__tar.gz → 0.9.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 (32) hide show
  1. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/PKG-INFO +1 -1
  2. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/pyproject.toml +1 -1
  3. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/client.py +50 -20
  4. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/seed.py +28 -1
  5. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/README.md +0 -0
  6. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/__init__.py +0 -0
  7. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/bootstrap.py +0 -0
  8. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  9. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
  10. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/cli.py +0 -0
  11. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/config.py +0 -0
  12. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/extract.py +0 -0
  13. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/extract_manifest.yaml +0 -0
  14. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/firstlogin.py +0 -0
  15. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/models.py +0 -0
  16. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/output.py +0 -0
  17. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/run.py +0 -0
  18. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  19. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  20. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  21. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
  22. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
  23. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  24. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
  25. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
  26. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  27. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/env +0 -0
  28. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/gitignore +0 -0
  29. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
  30. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
  31. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
  32. {acumatica_cli-0.9.0 → acumatica_cli-0.9.1}/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.9.0
3
+ Version: 0.9.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.9.0"
3
+ version = "0.9.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
  readme = "README.md"
@@ -93,6 +93,9 @@ class AcumaticaClient:
93
93
  self._http = httpx.Client(
94
94
  base_url=instance.base_url, timeout=timeout, transport=transport
95
95
  )
96
+ # V5/B24: first successful Company PUT this session → one re-login
97
+ # so later branch selectors resolve; warm re-PUT is re-login-safe
98
+ self._refreshed_after_company = False
96
99
 
97
100
  def __enter__(self) -> AcumaticaClient:
98
101
  # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
@@ -105,27 +108,10 @@ class AcumaticaClient:
105
108
  "an explicit tenant silently lands on the default tenant; "
106
109
  "set ACU_TENANT in .env or pass --tenant"
107
110
  )
108
- creds: dict[str, str] = {
109
- "name": self.instance.user,
110
- "password": self.instance.password,
111
- "tenant": self.instance.tenant,
112
- }
113
- self._checked(self._http.post("/entity/auth/login", json=creds))
114
- # tenant guard, landed side (V5, B5): login accepting the name proves
115
- # nothing - a stale tenant map reroutes named logins to the default
116
- # tenant, and a single-tenant instance accepts ANY name (both verified
117
- # live, docs/rest-api.md). Verify where the session actually landed
118
- # and refuse on mismatch; logout first, since __exit__ never runs
119
- # when __enter__ raises (V6 - sessions count against the license).
111
+ # logout first on failure: __exit__ never runs when __enter__ raises
112
+ # (V6 - sessions count against the license)
120
113
  try:
121
- landed = self._landed_tenant()
122
- if landed.casefold() != self.instance.tenant.casefold():
123
- raise RuntimeError(
124
- f"tenant guard: asked for tenant {self.instance.tenant!r} "
125
- f"but the session landed on {landed!r} - the instance "
126
- "tenant map is stale or the tenant does not exist; check "
127
- "acu tenant list and recycle the app pool"
128
- )
114
+ self._login()
129
115
  except BaseException:
130
116
  self.__exit__()
131
117
  raise
@@ -138,6 +124,50 @@ class AcumaticaClient:
138
124
  finally:
139
125
  self._http.close()
140
126
 
127
+ def _login(self) -> None:
128
+ """POST login + refuse on landed-tenant mismatch (V5, B5).
129
+
130
+ Login accepting the name proves nothing - a stale tenant map
131
+ reroutes named logins to the default tenant, and a single-tenant
132
+ instance accepts ANY name (both verified live, docs/rest-api.md).
133
+ """
134
+ creds: dict[str, str] = {
135
+ "name": self.instance.user,
136
+ "password": self.instance.password,
137
+ "tenant": self.instance.tenant,
138
+ }
139
+ self._checked(self._http.post("/entity/auth/login", json=creds))
140
+ landed = self._landed_tenant()
141
+ if landed.casefold() != self.instance.tenant.casefold():
142
+ raise RuntimeError(
143
+ f"tenant guard: asked for tenant {self.instance.tenant!r} "
144
+ f"but the session landed on {landed!r} - the instance "
145
+ "tenant map is stale or the tenant does not exist; check "
146
+ "acu tenant list and recycle the app pool"
147
+ )
148
+
149
+ def relogin(self) -> None:
150
+ """Drop the cookie session and open a new one without closing transport.
151
+
152
+ Mid-session Company create leaves branch selectors empty until a
153
+ fresh login (V5, B24). Unlike ``__exit__``, the httpx client stays
154
+ open so the outer context manager still owns the session lifecycle.
155
+ """
156
+ # empty body → Content-Length: 0 (V6 / IIS 411)
157
+ self._http.post("/entity/auth/logout", content=b"")
158
+ self._login()
159
+
160
+ def refresh_after_company(self) -> None:
161
+ """Re-login once after the first successful Company PUT this session.
162
+
163
+ Warm-tenant Company re-PUT is re-login-safe (V5); further Company
164
+ records in the same run skip the second bounce.
165
+ """
166
+ if self._refreshed_after_company:
167
+ return
168
+ self.relogin()
169
+ self._refreshed_after_company = True
170
+
141
171
  def _landed_tenant(self) -> str:
142
172
  """The tenant this session actually landed on (login name).
143
173
 
@@ -72,6 +72,10 @@ BOOTSTRAP_ENDPOINT, BOOTSTRAP_ENTITIES = bootstrap.parse_endpoint(
72
72
  # the Instance field default rather than hand-synced (V11: one spelling).
73
73
  _DEFAULT_ENDPOINT: str = f"Default/{Instance.model_fields['api_version'].default}"
74
74
  _SYMBOLIC_BOOTSTRAP = "bootstrap"
75
+ # Mid-session branch-selector failure (V5/B24): virgin-tenant apply opens
76
+ # the REST session pre-Company; INPreferences TransitBranchID (and kin)
77
+ # 500 with this message until a fresh login sees the new branch.
78
+ _BRANCH_EMPTY = "'Branch' cannot be empty"
75
79
 
76
80
 
77
81
  def active_bootstrap(root: Path | None = None) -> tuple[str, frozenset[str]]:
@@ -303,6 +307,10 @@ def apply(
303
307
  ) -> int:
304
308
  """PUT every record (upsert by key); an action file invokes its action.
305
309
 
310
+ After the first successful Company PUT this session, re-login once so
311
+ branch selectors resolve for later files (V5/B24). A PUT that still
312
+ 500s with ``'Branch' cannot be empty`` gets one re-login + retry.
313
+
306
314
  Returns the record count.
307
315
  """
308
316
  if isinstance(baseline, ActionFile):
@@ -315,11 +323,30 @@ def apply(
315
323
  body = record
316
324
  if any(isinstance(v, list) for v in record.values()):
317
325
  body = _with_detail_ids(client, baseline, record)
318
- client.put(baseline.entity, body, endpoint=baseline.endpoint)
326
+ _put(client, baseline.entity, body, baseline.endpoint)
327
+ if baseline.entity == "Company":
328
+ client.refresh_after_company()
319
329
  output.data(f" PUT {baseline.entity} [{label}]")
320
330
  return len(baseline.records)
321
331
 
322
332
 
333
+ def _put(
334
+ client: AcumaticaClient,
335
+ entity: str,
336
+ body: dict[str, Any],
337
+ endpoint: str | None,
338
+ ) -> None:
339
+ """PUT one record; one re-login + retry on branch-empty 500 (V5/B24)."""
340
+ try:
341
+ client.put(entity, body, endpoint=endpoint)
342
+ except RuntimeError as err:
343
+ if _BRANCH_EMPTY not in str(err):
344
+ raise
345
+ output.info(f"re-login and retry {entity} (branch empty)")
346
+ client.relogin()
347
+ client.put(entity, body, endpoint=endpoint)
348
+
349
+
323
350
  def _with_detail_ids(
324
351
  client: AcumaticaClient, baseline: BaselineFile, record: dict[str, Any]
325
352
  ) -> dict[str, Any]:
File without changes