acumatica-cli 0.2.2__py3-none-any.whl

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.
@@ -0,0 +1,251 @@
1
+ """Contract-based REST API session (see docs/rest-api.md for verified quirks)."""
2
+
3
+ import base64
4
+ import html
5
+ import re
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .config import Instance
11
+
12
+
13
+ def wrap(record: dict[str, Any]) -> dict[str, Any]:
14
+ """Plain dict -> contract-API body: {"Field": {"value": ...}}."""
15
+ return {k: {"value": v} for k, v in record.items()}
16
+
17
+
18
+ def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
19
+ """Contract-API entity -> plain dict (top-level value fields only)."""
20
+ return {
21
+ k: v["value"] for k, v in entity.items() if isinstance(v, dict) and "value" in v
22
+ }
23
+
24
+
25
+ class AcumaticaClient:
26
+ """Cookie-session client for the contract-based endpoint.
27
+
28
+ Use as a context manager: sessions count against the license, so logout
29
+ must run even on failure.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ instance: Instance,
35
+ timeout: float = 120.0,
36
+ transport: httpx.BaseTransport | None = None,
37
+ ):
38
+ self.instance = instance
39
+ self._http = httpx.Client(
40
+ base_url=instance.base_url, timeout=timeout, transport=transport
41
+ )
42
+
43
+ def __enter__(self) -> "AcumaticaClient":
44
+ # tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
45
+ # the one login the server still routes silently — to the default
46
+ # tenant. Defense-in-depth vs wrong-tenant writes: every data-plane
47
+ # session must name its tenant, so refuse before any HTTP happens.
48
+ if not self.instance.tenant:
49
+ raise RuntimeError(
50
+ f"no tenant set for {self.instance.host} - a session without "
51
+ "an explicit tenant silently lands on the default tenant; "
52
+ "set tenant in acu.yaml or pass -t/--tenant"
53
+ )
54
+ creds: dict[str, str] = {
55
+ "name": self.instance.username,
56
+ "password": self.instance.password,
57
+ "tenant": self.instance.tenant,
58
+ }
59
+ self._checked(self._http.post("/entity/auth/login", json=creds))
60
+ # tenant guard, landed side (V5, B5): login accepting the name proves
61
+ # nothing - a stale tenant map reroutes named logins to the default
62
+ # tenant, and a single-tenant instance accepts ANY name (both verified
63
+ # live, docs/rest-api.md). Verify where the session actually landed
64
+ # and refuse on mismatch; logout first, since __exit__ never runs
65
+ # when __enter__ raises (V6 - sessions count against the license).
66
+ try:
67
+ landed = self._landed_tenant()
68
+ if landed.casefold() != self.instance.tenant.casefold():
69
+ raise RuntimeError(
70
+ f"tenant guard: asked for tenant {self.instance.tenant!r} "
71
+ f"but the session landed on {landed!r} - the instance "
72
+ "tenant map is stale or the tenant does not exist; check "
73
+ "acu tenant list and recycle the app pool"
74
+ )
75
+ except BaseException:
76
+ self.__exit__()
77
+ raise
78
+ return self
79
+
80
+ def __exit__(self, *exc: object) -> None:
81
+ try:
82
+ # empty body sets Content-Length: 0 — IIS 411s without it
83
+ self._http.post("/entity/auth/logout", content=b"")
84
+ finally:
85
+ self._http.close()
86
+
87
+ def _landed_tenant(self) -> str:
88
+ """The tenant this session actually landed on (login name).
89
+
90
+ Verified vs 26.101.0225 (docs/rest-api.md): the contract API exposes
91
+ nothing tenant-identifying, but an authenticated GET of the sign-in
92
+ page renders a hidden ``txtSingleCompany`` input whose value is the
93
+ session tenant's login name in every observed state - multi-tenant,
94
+ single-tenant, and mid-reroute under a stale tenant map. The probe
95
+ does not disturb the session.
96
+ """
97
+ r = self._checked(self._http.get("/Frames/Login.aspx", follow_redirects=True))
98
+ m = re.search(r'id="txtSingleCompany" value="([^"]*)"', r.text)
99
+ if not m:
100
+ raise RuntimeError(
101
+ "tenant guard: /Frames/Login.aspx did not expose the landed "
102
+ "tenant (txtSingleCompany missing - page shape changed on "
103
+ "this build?); refusing the session rather than risking "
104
+ "wrong-tenant writes"
105
+ )
106
+ return html.unescape(m.group(1))
107
+
108
+ def _url(self, entity: str, endpoint: str | None = None) -> str:
109
+ return f"/entity/{endpoint or self.instance.endpoint}/{entity}"
110
+
111
+ @staticmethod
112
+ def _checked(r: httpx.Response) -> httpx.Response:
113
+ """Surface Acumatica's exceptionMessage instead of a bare status code."""
114
+ if r.is_error:
115
+ detail = ""
116
+ try:
117
+ body = r.json()
118
+ detail = body.get("exceptionMessage") or body.get("message") or ""
119
+ except Exception:
120
+ pass
121
+ raise RuntimeError(
122
+ f"{r.request.method} {r.request.url.path} -> {r.status_code}"
123
+ + (f": {detail}" if detail else "")
124
+ )
125
+ return r
126
+
127
+ def get_list(
128
+ self,
129
+ entity: str,
130
+ params: dict[str, str] | None = None,
131
+ endpoint: str | None = None,
132
+ ) -> list[dict[str, Any]]:
133
+ """GET the entity's records, optionally narrowed with OData params.
134
+
135
+ endpoint overrides the instance's endpoint per call (bootstrap YAML
136
+ targets the custom Bootstrap endpoint; everything else defaults).
137
+ """
138
+ return self._checked(
139
+ self._http.get(self._url(entity, endpoint), params=params)
140
+ ).json()
141
+
142
+ def swagger(self) -> bytes:
143
+ """GET the endpoint's OpenAPI schema (swagger.json), raw bytes."""
144
+ return self._checked(self._http.get(self._url("swagger.json"))).content
145
+
146
+ def put(
147
+ self, entity: str, record: dict[str, Any], endpoint: str | None = None
148
+ ) -> dict[str, Any]:
149
+ """Upsert by the entity's key fields — the idempotence primitive."""
150
+ return self._checked(
151
+ self._http.put(self._url(entity, endpoint), json=wrap(record))
152
+ ).json()
153
+
154
+ # -- CustomizationApi (same cookie session; works on a virgin tenant) --
155
+
156
+ @staticmethod
157
+ def _checked_log(r: httpx.Response) -> dict[str, Any]:
158
+ """Raise on in-band CustomizationApi errors (verified vs 26.101.0225).
159
+
160
+ The CustomizationApi answers 200 even when an operation fails — the
161
+ failure only shows as ``log`` entries with ``logType: "error"``
162
+ (e.g. an import that rejects the package still returns 200).
163
+ """
164
+ AcumaticaClient._checked(r)
165
+ try:
166
+ body: dict[str, Any] = r.json()
167
+ except ValueError:
168
+ return {}
169
+ log = body.get("log")
170
+ errors = [
171
+ str(entry.get("message", ""))
172
+ for entry in (log if isinstance(log, list) else [])
173
+ if isinstance(entry, dict) and entry.get("logType") == "error"
174
+ ]
175
+ if errors:
176
+ raise RuntimeError(
177
+ f"POST {r.request.url.path} reported: " + "; ".join(errors)
178
+ )
179
+ return body
180
+
181
+ def customization_published(self) -> list[str]:
182
+ """Names of the customization projects published in the session tenant."""
183
+ body = self._checked_log(
184
+ self._http.post("/CustomizationApi/getPublished", json={})
185
+ )
186
+ projects = body.get("projects") or []
187
+ return [p["name"] for p in projects if isinstance(p, dict) and "name" in p]
188
+
189
+ def customization_project_exists(self, name: str) -> bool:
190
+ """True when the project's content rows exist in the session tenant.
191
+
192
+ getPublished alone is a false idempotence proxy: a tenant deleted and
193
+ recreated under the same CompanyID still LISTS the publication while
194
+ the project content (and everything the publish wrote) is gone
195
+ (verified live vs 26.101.0225).
196
+ """
197
+ try:
198
+ body = self._checked_log(
199
+ self._http.post(
200
+ "/CustomizationApi/getProject", json={"projectName": name}
201
+ )
202
+ )
203
+ except RuntimeError:
204
+ return False
205
+ return bool(body.get("projectContentBase64"))
206
+
207
+ def customization_import(
208
+ self, name: str, zip_bytes: bytes, description: str = ""
209
+ ) -> None:
210
+ """Upload a customization package zip (replacing any same-name project).
211
+
212
+ The content field is ``projectContentBase64`` — the live binder's
213
+ property (ImportParamsData, verified vs 26.101.0225 by reflection).
214
+ The widely documented ``projectContents`` binds nothing on this
215
+ build: the server deletes the existing project (isReplaceIfExists)
216
+ and then reports "The project is not found".
217
+ """
218
+ self._checked_log(
219
+ self._http.post(
220
+ "/CustomizationApi/import",
221
+ json={
222
+ "projectLevel": 0,
223
+ "isReplaceIfExists": True,
224
+ "projectName": name,
225
+ "projectDescription": description,
226
+ "projectContentBase64": base64.b64encode(zip_bytes).decode("ascii"),
227
+ },
228
+ )
229
+ )
230
+
231
+ def customization_publish_begin(self, names: list[str]) -> None:
232
+ """Start publishing the named projects into the current tenant."""
233
+ self._checked_log(
234
+ self._http.post(
235
+ "/CustomizationApi/publishBegin",
236
+ json={
237
+ "isMergeWithExistingPackages": False,
238
+ "isOnlyValidation": False,
239
+ "isOnlyDbUpdates": False,
240
+ "isReplayPreviouslyExecutedScripts": False,
241
+ "projectNames": names,
242
+ "tenantMode": "Current",
243
+ },
244
+ )
245
+ )
246
+
247
+ def customization_publish_end(self) -> dict[str, Any]:
248
+ """Poll the running publish: {isCompleted, isFailed, log: [...]}."""
249
+ return self._checked(
250
+ self._http.post("/CustomizationApi/publishEnd", json={})
251
+ ).json()
@@ -0,0 +1,113 @@
1
+ """The instance target (acu.yaml, found by walking up from cwd) + credentials (.env).
2
+
3
+ Layered defaults: ``host`` is the only required acu.yaml key. Everything else
4
+ is a code default transcribed from the verified references (docs/ac-exe.md,
5
+ docs/rest-api.md — V12), overridable per instance for nonstandard installs.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+ from dotenv import load_dotenv
14
+ from pydantic import ValidationError, field_validator, model_validator
15
+
16
+ from .models import Model, validation_summary
17
+
18
+
19
+ class Instance(Model):
20
+ """The resolved target: the acu.yaml top-level map + credentials.
21
+
22
+ ``host`` drives both planes (V1): REST ``base_url`` and control-plane
23
+ ``ssh`` derive from it unless the acu.yaml map overrides them
24
+ explicitly (split-horizon DNS, port forwards, jump hosts, nonroot sites).
25
+ """
26
+
27
+ host: str
28
+ tenant: str = ""
29
+ scheme: str = "http" # docs/rest-api.md: http://acu-dev1.vm.internal/...
30
+ ssh_user: str = "Administrator"
31
+ instance_name: str = "AcumaticaERP"
32
+ instance_path: str = "C:\\Acumatica\\AcumaticaERP"
33
+ ac_exe: str = "C:\\Program Files\\Acumatica ERP\\Data\\ac.exe"
34
+ db_name: str = "AcumaticaDB"
35
+ endpoint: str = "Default/25.200.001" # V11: versioned path only
36
+ base_url: str = "" # default derived: <scheme>://<host>/<instance_name>
37
+ ssh: str = "" # default derived: <ssh_user>@<host>
38
+ username: str
39
+ password: str
40
+
41
+ @field_validator("base_url")
42
+ @classmethod
43
+ def _no_trailing_slash(cls, v: str) -> str:
44
+ return v.rstrip("/")
45
+
46
+ @field_validator("endpoint")
47
+ @classmethod
48
+ def _no_surrounding_slashes(cls, v: str) -> str:
49
+ return v.strip("/")
50
+
51
+ @model_validator(mode="before")
52
+ @classmethod
53
+ def _derive_urls(cls, data: Any) -> Any:
54
+ """Construct base_url/ssh from host; an explicit override wins."""
55
+ if not isinstance(data, dict) or not data.get("host"):
56
+ return data # let field validation report the missing host
57
+
58
+ def resolved(key: str) -> object:
59
+ return data.get(key) or cls.model_fields[key].default
60
+
61
+ data = dict(data)
62
+ host = data["host"]
63
+ if not data.get("base_url"):
64
+ data["base_url"] = (
65
+ f"{resolved('scheme')}://{host}/{resolved('instance_name')}"
66
+ )
67
+ if not data.get("ssh"):
68
+ data["ssh"] = f"{resolved('ssh_user')}@{host}"
69
+ return data
70
+
71
+
72
+ def data_root() -> Path:
73
+ """Walk up from cwd to the first directory containing acu.yaml."""
74
+ for d in [Path.cwd(), *Path.cwd().parents]:
75
+ if (d / "acu.yaml").is_file():
76
+ return d
77
+ raise SystemExit(
78
+ "acu.yaml not found in the current directory or any parent - "
79
+ "run acu from inside a data repo (e.g. acumatica-baseline)"
80
+ )
81
+
82
+
83
+ def load_instance(host: str | None = None) -> Instance:
84
+ """Resolve the target from acu.yaml and merge credentials from .env/environment.
85
+
86
+ ``host`` (the global --host flag) replaces the acu.yaml host before the
87
+ Instance is built, so derived base_url/ssh follow it; a post-hoc
88
+ model_copy would leave them pointing at the old host. Explicit acu.yaml
89
+ base_url/ssh overrides still win, exactly as they do over the file's own
90
+ host.
91
+ """
92
+ root = data_root()
93
+ load_dotenv(root / ".env")
94
+
95
+ with open(root / "acu.yaml") as f:
96
+ config = yaml.safe_load(f)
97
+ if not isinstance(config, dict):
98
+ raise SystemExit("acu.yaml: expected a mapping (host + optional overrides)")
99
+ if host is not None:
100
+ config["host"] = host
101
+
102
+ password = os.environ.get("ACU_PASSWORD")
103
+ if not password:
104
+ raise SystemExit("ACU_PASSWORD not set (put it in .env or the environment)")
105
+
106
+ try:
107
+ return Instance(
108
+ username=os.environ.get("ACU_USER", "admin"),
109
+ password=password,
110
+ **config,
111
+ )
112
+ except ValidationError as exc:
113
+ raise SystemExit(f"acu.yaml: {validation_summary(exc)}") from exc
@@ -0,0 +1,171 @@
1
+ """First-login admin password initialization via the Login.aspx screen flow.
2
+
3
+ A freshly created tenant seeds ``admin``/``setup`` with a
4
+ must-change-on-first-login flag the contract REST API cannot clear (verified —
5
+ see docs/rest-api.md). The sign-in screen's WebForms flow can, with plain
6
+ HTTP: GET the page for the hidden fields, POST the seed credentials, then
7
+ POST again with the new password into the change view the server renders.
8
+ """
9
+
10
+ import time
11
+ from contextlib import suppress
12
+ from html.parser import HTMLParser
13
+
14
+ import httpx
15
+
16
+ from .config import Instance
17
+
18
+ LOGIN_PATH = "/Frames/Login.aspx"
19
+ SEED_PASSWORD = "setup" # what a new tenant's admin is born with
20
+
21
+ TENANT_FIELD = "ctl00$phUser$cmbCompany"
22
+ USER_FIELD = "ctl00$phUser$txtUser"
23
+ PASS_FIELD = "ctl00$phUser$txtPass"
24
+ NEW_PASS_FIELD = "ctl00$phUser$txtNewPassword"
25
+ CONFIRM_PASS_FIELD = "ctl00$phUser$txtConfirmPassword"
26
+ # The page has four submit inputs; this is the right one (mfLoginButton is the
27
+ # multi-factor flow). Both new-password fields must be posted together.
28
+ LOGIN_BUTTON = "ctl00$phUser$btnLogin"
29
+ REMEMBER_FIELD = "ctl00$phUser$rememberDevice"
30
+
31
+
32
+ class _LoginForm(HTMLParser):
33
+ """Collects the sign-in form's postable fields (inputs and selects)."""
34
+
35
+ def __init__(self) -> None:
36
+ super().__init__()
37
+ self.fields: dict[str, str] = {}
38
+ self._select: str | None = None
39
+
40
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
41
+ a = dict(attrs)
42
+ name = a.get("name")
43
+ if tag == "input" and name:
44
+ itype = (a.get("type") or "text").lower()
45
+ if itype in ("submit", "button"):
46
+ return # buttons are posted explicitly by the caller
47
+ if itype == "checkbox" and "checked" not in a:
48
+ return
49
+ self.fields[name] = a.get("value") or ""
50
+ elif tag == "select" and name:
51
+ self._select = name
52
+ self.fields[name] = ""
53
+ elif tag == "option" and self._select and "selected" in a:
54
+ self.fields[self._select] = a.get("value") or ""
55
+
56
+ def handle_endtag(self, tag: str) -> None:
57
+ if tag == "select":
58
+ self._select = None
59
+
60
+
61
+ def _form_fields(html: str) -> dict[str, str]:
62
+ parser = _LoginForm()
63
+ parser.feed(html)
64
+ return parser.fields
65
+
66
+
67
+ def _rest_login_works(instance: Instance, tenant: str) -> bool:
68
+ """One REST login attempt with the instance credentials (then logout)."""
69
+ with httpx.Client(base_url=instance.base_url, timeout=60) as http:
70
+ r = http.post(
71
+ "/entity/auth/login",
72
+ json={
73
+ "name": instance.username,
74
+ "password": instance.password,
75
+ "tenant": tenant,
76
+ },
77
+ )
78
+ if r.status_code == 204:
79
+ http.post("/entity/auth/logout", content=b"")
80
+ return True
81
+ return False
82
+
83
+
84
+ def _get_login_page(http: httpx.Client, retries: int, delay: float) -> httpx.Response:
85
+ """GET the sign-in page, retrying while the app warms up after a recycle."""
86
+ last = ""
87
+ for attempt in range(retries):
88
+ try:
89
+ r = http.get(LOGIN_PATH)
90
+ if r.status_code == 200:
91
+ return r
92
+ last = f"HTTP {r.status_code}"
93
+ except httpx.TransportError as e:
94
+ last = repr(e)
95
+ if attempt < retries - 1:
96
+ time.sleep(delay)
97
+ raise RuntimeError(f"sign-in page did not come back after recycle: {last}")
98
+
99
+
100
+ def initialize_admin_password(
101
+ instance: Instance, tenant: str, retries: int = 24, delay: float = 5.0
102
+ ) -> str:
103
+ """Change the tenant's seeded admin password to the instance password.
104
+
105
+ Returns ``"already initialized"`` if REST login already works (the normal
106
+ case — ``acu tenant create`` presets the password via ac.exe), or
107
+ ``"password changed"`` after a successful screen-flow change (verified by
108
+ a REST login). Raises RuntimeError when neither path succeeds.
109
+ """
110
+ with httpx.Client(
111
+ base_url=instance.base_url,
112
+ timeout=httpx.Timeout(10.0, read=300.0),
113
+ follow_redirects=True,
114
+ ) as http:
115
+ # fetch the page first: it doubles as the wait for the app to warm up
116
+ # after the post-create app-pool recycle
117
+ page = _get_login_page(http, retries, delay)
118
+ if _rest_login_works(instance, tenant):
119
+ return "already initialized"
120
+
121
+ fields = _form_fields(page.text)
122
+ fields.pop(REMEMBER_FIELD, None)
123
+ fields.update(
124
+ {
125
+ TENANT_FIELD: tenant,
126
+ USER_FIELD: instance.username,
127
+ PASS_FIELD: SEED_PASSWORD,
128
+ LOGIN_BUTTON: "Sign In",
129
+ }
130
+ )
131
+ page = http.post(LOGIN_PATH, data=fields)
132
+
133
+ fields = _form_fields(page.text)
134
+ if NEW_PASS_FIELD not in fields:
135
+ state = (
136
+ "seed password signed straight in - no change demanded; "
137
+ "the admin password is still the seed"
138
+ if "Main" in page.url.path
139
+ else f"landed on {page.url}"
140
+ )
141
+ raise RuntimeError(
142
+ f"tenant {tenant!r}: expected the password-change view after "
143
+ f"signing in with the seed password; {state}"
144
+ )
145
+ fields.pop(REMEMBER_FIELD, None)
146
+ fields.update(
147
+ {
148
+ TENANT_FIELD: tenant,
149
+ USER_FIELD: instance.username,
150
+ PASS_FIELD: SEED_PASSWORD,
151
+ NEW_PASS_FIELD: instance.password,
152
+ CONFIRM_PASS_FIELD: instance.password,
153
+ LOGIN_BUTTON: "Sign In",
154
+ }
155
+ )
156
+ page = http.post(LOGIN_PATH, data=fields)
157
+ with suppress(httpx.HTTPError):
158
+ # the screen session holds a license seat — best-effort logout
159
+ http.post("/entity/auth/logout", content=b"")
160
+ if "Main" not in page.url.path:
161
+ raise RuntimeError(
162
+ f"tenant {tenant!r}: password change not accepted "
163
+ f"(landed on {page.url})"
164
+ )
165
+
166
+ if not _rest_login_works(instance, tenant):
167
+ raise RuntimeError(
168
+ f"tenant {tenant!r}: password change looked accepted but REST "
169
+ "login with the new password failed"
170
+ )
171
+ return "password changed"
@@ -0,0 +1,21 @@
1
+ """pydantic is the repo's model standard.
2
+
3
+ Every structured value crossing a boundary (acu.yaml maps, baseline YAML,
4
+ sqlcmd rows) is a frozen pydantic model validated at parse time — unknown
5
+ fields are rejected, not silently carried.
6
+ """
7
+
8
+ from pydantic import BaseModel, ConfigDict, ValidationError
9
+
10
+
11
+ class Model(BaseModel):
12
+ """Base for all acu models: immutable, unknown fields are errors."""
13
+
14
+ model_config = ConfigDict(frozen=True, extra="forbid")
15
+
16
+
17
+ def validation_summary(exc: ValidationError) -> str:
18
+ """One line per error: dotted field path + pydantic's message."""
19
+ return "; ".join(
20
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
21
+ )
@@ -0,0 +1,70 @@
1
+ """CLI output helpers — the only place `acu` writes to the terminal.
2
+
3
+ Two audiences, one code path (SPEC V9): rich renders color, tables,
4
+ and spinners on a TTY and degrades to plain deterministic text when piped
5
+ (how LLM agents and scripts see it). stdout carries data and results;
6
+ stderr carries status, warnings, and errors.
7
+ """
8
+
9
+ from collections.abc import Generator, Iterable
10
+ from contextlib import contextmanager
11
+
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ out = Console(markup=False, emoji=False, highlight=False)
17
+ err = Console(stderr=True, markup=False, emoji=False, highlight=False)
18
+
19
+
20
+ def data(msg: str) -> None:
21
+ """Result line on stdout — what a script or agent consumes."""
22
+ # soft_wrap: a result line must stay one greppable line, never
23
+ # hard-wrapped at console width (long paths, drift lines)
24
+ out.print(msg, soft_wrap=True)
25
+
26
+
27
+ def info(msg: str) -> None:
28
+ """Progress line on stderr."""
29
+ err.print(msg, style="dim cyan")
30
+
31
+
32
+ def success(msg: str) -> None:
33
+ """Success line on stderr."""
34
+ err.print(f"+ {msg}", style="green")
35
+
36
+
37
+ def warn(msg: str) -> None:
38
+ """Warning line on stderr."""
39
+ err.print(f"! {msg}", style="yellow")
40
+
41
+
42
+ def error(msg: str) -> None:
43
+ """Error line on stderr."""
44
+ err.print(f"x {msg}", style="red")
45
+
46
+
47
+ def table(title: str, columns: Iterable[str], rows: Iterable[Iterable[str]]) -> None:
48
+ """Table on stdout: ASCII box on a TTY, plain aligned columns piped."""
49
+ t = Table(
50
+ title=title,
51
+ title_justify="left",
52
+ box=box.ASCII if out.is_terminal else None,
53
+ )
54
+ for column in columns:
55
+ t.add_column(column)
56
+ for row in rows:
57
+ t.add_row(*row)
58
+ out.print(t)
59
+
60
+
61
+ @contextmanager
62
+ def step(msg: str) -> Generator[None]:
63
+ """Long operation: spinner on a TTY, plain stderr line when piped."""
64
+ if err.is_terminal:
65
+ # "line" is the ASCII spinner (-\|/); the default "dots" is braille
66
+ with err.status(msg, spinner="line"):
67
+ yield
68
+ else:
69
+ info(msg)
70
+ yield