pyplines-cli-common 2026.9.3a1__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.
- pyplines_cli_common-2026.9.3a1/PKG-INFO +28 -0
- pyplines_cli_common-2026.9.3a1/README.md +18 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common/__init__.py +15 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common/application.py +79 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common/errors.py +24 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common/interaction.py +8 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common/presentation.py +164 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common.egg-info/PKG-INFO +28 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common.egg-info/SOURCES.txt +14 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common.egg-info/dependency_links.txt +1 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common.egg-info/requires.txt +3 -0
- pyplines_cli_common-2026.9.3a1/pyplines_cli_common.egg-info/top_level.txt +1 -0
- pyplines_cli_common-2026.9.3a1/pyproject.toml +21 -0
- pyplines_cli_common-2026.9.3a1/setup.cfg +4 -0
- pyplines_cli_common-2026.9.3a1/tests/test_application.py +68 -0
- pyplines_cli_common-2026.9.3a1/uv.lock +846 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyplines-cli-common
|
|
3
|
+
Version: 2026.9.3a1
|
|
4
|
+
Summary: Shared presentation and interaction contract for Pyplines command-line tools
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer<1,>=0.24
|
|
8
|
+
Requires-Dist: rich<15,>=13
|
|
9
|
+
Requires-Dist: PyYAML<7,>=6
|
|
10
|
+
|
|
11
|
+
# Pyplines CLI standard
|
|
12
|
+
|
|
13
|
+
`pyplines-cli-common` provides the shared Typer application boundary, safe
|
|
14
|
+
errors, and presentation used by the CLI and Builder. It has no Server dependency.
|
|
15
|
+
|
|
16
|
+
Human output is the default. `AUTOMATION_MODE=enabled` or `--json` selects
|
|
17
|
+
automation output: one JSON result on stdout, JSON errors and progress on stderr,
|
|
18
|
+
and no interactive prompts. Log-stream commands emit newline-delimited JSON.
|
|
19
|
+
Empty invocation and help show readable help and exit zero without credentials.
|
|
20
|
+
Redirecting stdout does not change the output format.
|
|
21
|
+
|
|
22
|
+
Exit codes: 0 success, 1 operation failure, 2 invalid usage, 130 interruption.
|
|
23
|
+
Unexpected exceptions never print arbitrary exception values; optional diagnostics
|
|
24
|
+
identify their type without leaking tokens or signed URLs.
|
|
25
|
+
|
|
26
|
+
The shared package follows the release's CalVer version and is published before
|
|
27
|
+
CLI and Builder. Configure its PyPI Trusted Publisher for repository
|
|
28
|
+
`pyplines/pyplines`, workflow `publish-release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Pyplines CLI standard
|
|
2
|
+
|
|
3
|
+
`pyplines-cli-common` provides the shared Typer application boundary, safe
|
|
4
|
+
errors, and presentation used by the CLI and Builder. It has no Server dependency.
|
|
5
|
+
|
|
6
|
+
Human output is the default. `AUTOMATION_MODE=enabled` or `--json` selects
|
|
7
|
+
automation output: one JSON result on stdout, JSON errors and progress on stderr,
|
|
8
|
+
and no interactive prompts. Log-stream commands emit newline-delimited JSON.
|
|
9
|
+
Empty invocation and help show readable help and exit zero without credentials.
|
|
10
|
+
Redirecting stdout does not change the output format.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 success, 1 operation failure, 2 invalid usage, 130 interruption.
|
|
13
|
+
Unexpected exceptions never print arbitrary exception values; optional diagnostics
|
|
14
|
+
identify their type without leaking tokens or signed URLs.
|
|
15
|
+
|
|
16
|
+
The shared package follows the release's CalVer version and is published before
|
|
17
|
+
CLI and Builder. Configure its PyPI Trusted Publisher for repository
|
|
18
|
+
`pyplines/pyplines`, workflow `publish-release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Common CLI behavior without platform or resource operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
|
|
6
|
+
current_automation = ContextVar("automation", default=False)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def automation_mode(json_output: bool = False) -> bool:
|
|
10
|
+
"""Explicit JSON or the standard environment flag enables automation."""
|
|
11
|
+
return (
|
|
12
|
+
json_output
|
|
13
|
+
or current_automation.get()
|
|
14
|
+
or os.environ.get("AUTOMATION_MODE", "").lower() == "enabled"
|
|
15
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Use the exception types belonging to Typer's active Click implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typer.core import TyperCommand, TyperGroup
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from typer import _click as click
|
|
10
|
+
except ImportError:
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from typer.exceptions import Abort
|
|
15
|
+
except ImportError:
|
|
16
|
+
from click.exceptions import Abort
|
|
17
|
+
|
|
18
|
+
from .errors import ClientError
|
|
19
|
+
from .presentation import Presenter
|
|
20
|
+
from . import current_automation
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Command(TyperCommand):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Group(TyperGroup):
|
|
28
|
+
package_name = None
|
|
29
|
+
|
|
30
|
+
def main(self, args=None, standalone_mode=True, **kwargs):
|
|
31
|
+
arguments = list(sys.argv[1:] if args is None else args)
|
|
32
|
+
options = arguments[: arguments.index("--")] if "--" in arguments else arguments
|
|
33
|
+
presenter = Presenter(sys.stdout, sys.stderr, "--json" in options)
|
|
34
|
+
verbose = "--verbose-errors" in options or os.environ.get(
|
|
35
|
+
"PYPLINES_VERBOSE_ERRORS", ""
|
|
36
|
+
).lower() in ("true", "enabled", "1")
|
|
37
|
+
if "--verbose-errors" in options:
|
|
38
|
+
arguments.remove("--verbose-errors")
|
|
39
|
+
if "--version" in options and self.package_name:
|
|
40
|
+
presenter.outcome(
|
|
41
|
+
{"version": version(self.package_name)}, version(self.package_name)
|
|
42
|
+
)
|
|
43
|
+
if standalone_mode:
|
|
44
|
+
raise SystemExit(0)
|
|
45
|
+
return 0
|
|
46
|
+
mode_token = current_automation.set(presenter.json_output)
|
|
47
|
+
try:
|
|
48
|
+
result = super().main(args=arguments, standalone_mode=False, **kwargs)
|
|
49
|
+
except click.exceptions.NoArgsIsHelpError as error:
|
|
50
|
+
sys.stdout.write(error.ctx.get_help() + "\n")
|
|
51
|
+
result = 0
|
|
52
|
+
except click.ClickException as error:
|
|
53
|
+
presenter.error(
|
|
54
|
+
ClientError("invalid-usage", error.format_message(), error.exit_code)
|
|
55
|
+
)
|
|
56
|
+
result = error.exit_code
|
|
57
|
+
except ClientError as error:
|
|
58
|
+
presenter.error(error)
|
|
59
|
+
result = error.exit_code
|
|
60
|
+
except (KeyboardInterrupt, EOFError, Abort):
|
|
61
|
+
presenter.error(
|
|
62
|
+
ClientError(
|
|
63
|
+
"interrupted", "Interrupted; remote work may still be running", 130
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
result = 130
|
|
67
|
+
except Exception as error:
|
|
68
|
+
# Arbitrary exception strings can contain passwords or signed URLs.
|
|
69
|
+
# Expose only the exception type in optional diagnostics.
|
|
70
|
+
message = "Operation failed unexpectedly"
|
|
71
|
+
if verbose:
|
|
72
|
+
message += f" ({type(error).__name__})"
|
|
73
|
+
presenter.error(ClientError("internal-error", message))
|
|
74
|
+
result = 1
|
|
75
|
+
finally:
|
|
76
|
+
current_automation.reset(mode_token)
|
|
77
|
+
if standalone_mode:
|
|
78
|
+
raise SystemExit(result if isinstance(result, int) else 0)
|
|
79
|
+
return result
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Safe failures shared by the client, commands, and renderers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ClientError(Exception):
|
|
9
|
+
code: str
|
|
10
|
+
message: str
|
|
11
|
+
exit_code: int = 1
|
|
12
|
+
violations: list[dict[str, Any]] = field(default_factory=list)
|
|
13
|
+
correlation_id: str | None = None
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self.message
|
|
17
|
+
|
|
18
|
+
def document(self) -> dict[str, Any]:
|
|
19
|
+
value: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
20
|
+
if self.violations:
|
|
21
|
+
value["violations"] = self.violations
|
|
22
|
+
if self.correlation_id:
|
|
23
|
+
value["correlation_id"] = self.correlation_id
|
|
24
|
+
return {"error": value}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""One static visual grammar; contains no API or Typer dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import unicodedata
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Iterable, TextIO
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .errors import ClientError
|
|
16
|
+
from . import automation_mode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_text(value: Any) -> str:
|
|
20
|
+
text = "—" if value is None or value == "" else str(value)
|
|
21
|
+
return "".join(
|
|
22
|
+
char if not unicodedata.category(char).startswith("C") else " " for char in text
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def display_width(text: str) -> int:
|
|
27
|
+
return sum(
|
|
28
|
+
(
|
|
29
|
+
0
|
|
30
|
+
if unicodedata.combining(char)
|
|
31
|
+
else 2 if unicodedata.east_asian_width(char) in "WF" else 1
|
|
32
|
+
)
|
|
33
|
+
for char in text
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pad(text: str, width: int, numeric: bool = False) -> str:
|
|
38
|
+
spaces = " " * max(0, width - display_width(text))
|
|
39
|
+
return spaces + text if numeric else text + spaces
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Column:
|
|
44
|
+
key: str
|
|
45
|
+
title: str
|
|
46
|
+
numeric: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Presenter:
|
|
50
|
+
def __init__(self, stdout: TextIO, stderr: TextIO, json_output: bool = False):
|
|
51
|
+
self.stdout = stdout
|
|
52
|
+
self.stderr = stderr
|
|
53
|
+
self.json_output = automation_mode(json_output)
|
|
54
|
+
|
|
55
|
+
def _json(self, value: Any, stream: TextIO) -> None:
|
|
56
|
+
stream.write(json.dumps(value, ensure_ascii=True, allow_nan=False) + "\n")
|
|
57
|
+
stream.flush()
|
|
58
|
+
|
|
59
|
+
def collection(
|
|
60
|
+
self, document: dict, columns: Iterable[Column], empty: str = "No results."
|
|
61
|
+
) -> None:
|
|
62
|
+
if self.json_output:
|
|
63
|
+
self._json(document, self.stdout)
|
|
64
|
+
return
|
|
65
|
+
items = document.get("items", [])
|
|
66
|
+
if not items:
|
|
67
|
+
self.stdout.write(safe_text(empty) + "\n")
|
|
68
|
+
return
|
|
69
|
+
columns = list(columns)
|
|
70
|
+
if getattr(self.stdout, "isatty", lambda: False)():
|
|
71
|
+
table = Table(box=None, pad_edge=False)
|
|
72
|
+
for column in columns:
|
|
73
|
+
table.add_column(
|
|
74
|
+
column.title, justify="right" if column.numeric else "left"
|
|
75
|
+
)
|
|
76
|
+
for item in items:
|
|
77
|
+
table.add_row(*(Text(safe_text(item.get(c.key))) for c in columns))
|
|
78
|
+
Console(file=self.stdout).print(table)
|
|
79
|
+
if document.get("next_cursor"):
|
|
80
|
+
self.stdout.write(
|
|
81
|
+
"Next cursor: " + safe_text(document["next_cursor"]) + "\n"
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
rows = [[safe_text(item.get(c.key)) for c in columns] for item in items]
|
|
85
|
+
widths = [
|
|
86
|
+
max(display_width(c.title), *(display_width(row[i]) for row in rows))
|
|
87
|
+
for i, c in enumerate(columns)
|
|
88
|
+
]
|
|
89
|
+
for row in [[c.title for c in columns], *rows]:
|
|
90
|
+
self.stdout.write(
|
|
91
|
+
" ".join(
|
|
92
|
+
pad(cell, widths[i], columns[i].numeric)
|
|
93
|
+
for i, cell in enumerate(row)
|
|
94
|
+
).rstrip()
|
|
95
|
+
+ "\n"
|
|
96
|
+
)
|
|
97
|
+
if document.get("next_cursor"):
|
|
98
|
+
self.stdout.write(
|
|
99
|
+
"Next cursor: " + safe_text(document["next_cursor"]) + "\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def detail(self, document: dict, fields: Iterable[tuple[str, Any]]) -> None:
|
|
103
|
+
if self.json_output:
|
|
104
|
+
self._json(document, self.stdout)
|
|
105
|
+
return
|
|
106
|
+
fields = list(fields)
|
|
107
|
+
width = max((display_width(label) for label, _ in fields), default=0)
|
|
108
|
+
for label, value in fields:
|
|
109
|
+
if isinstance(value, (dict, list)):
|
|
110
|
+
value = json.dumps(value, ensure_ascii=True)
|
|
111
|
+
self.stdout.write(
|
|
112
|
+
pad(safe_text(label), width) + " " + safe_text(value) + "\n"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def outcome(self, document: dict, message: str) -> None:
|
|
116
|
+
if self.json_output:
|
|
117
|
+
self._json(document, self.stdout)
|
|
118
|
+
else:
|
|
119
|
+
self.stdout.write(safe_text(message) + "\n")
|
|
120
|
+
|
|
121
|
+
def config(self, document: dict) -> None:
|
|
122
|
+
if self.json_output:
|
|
123
|
+
self._json(document, self.stdout)
|
|
124
|
+
else:
|
|
125
|
+
self.stdout.write(yaml.safe_dump(document, sort_keys=False))
|
|
126
|
+
|
|
127
|
+
def event(self, event: dict, *, logs: bool = False) -> None:
|
|
128
|
+
stream = self.stdout if logs else self.stderr
|
|
129
|
+
if self.json_output:
|
|
130
|
+
self._json(event, stream)
|
|
131
|
+
else:
|
|
132
|
+
context = dict(event)
|
|
133
|
+
if event.get("attempt") and event.get("step"):
|
|
134
|
+
context["step"] = f"{event['step']}@{event['attempt']}"
|
|
135
|
+
if event.get("name") and event.get("number"):
|
|
136
|
+
context["target"] = f"{event['name']}/{event['number']}"
|
|
137
|
+
prefix = " ".join(
|
|
138
|
+
safe_text(context[key])
|
|
139
|
+
for key in ("timestamp", "level", "target", "step")
|
|
140
|
+
if context.get(key)
|
|
141
|
+
)
|
|
142
|
+
message = str(event.get("message", event.get("status", "")))
|
|
143
|
+
for i, line in enumerate(message.splitlines() or [""]):
|
|
144
|
+
stream.write(
|
|
145
|
+
(prefix if i == 0 else " " * display_width(prefix))
|
|
146
|
+
+ " "
|
|
147
|
+
+ safe_text(line)
|
|
148
|
+
+ "\n"
|
|
149
|
+
)
|
|
150
|
+
stream.flush()
|
|
151
|
+
|
|
152
|
+
def error(self, error: ClientError) -> None:
|
|
153
|
+
if self.json_output:
|
|
154
|
+
self._json(error.document(), self.stderr)
|
|
155
|
+
else:
|
|
156
|
+
self.stderr.write("Error: " + safe_text(error.message) + "\n")
|
|
157
|
+
for violation in error.violations:
|
|
158
|
+
self.stderr.write(
|
|
159
|
+
" "
|
|
160
|
+
+ safe_text(violation.get("field"))
|
|
161
|
+
+ ": "
|
|
162
|
+
+ safe_text(violation.get("message"))
|
|
163
|
+
+ "\n"
|
|
164
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyplines-cli-common
|
|
3
|
+
Version: 2026.9.3a1
|
|
4
|
+
Summary: Shared presentation and interaction contract for Pyplines command-line tools
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer<1,>=0.24
|
|
8
|
+
Requires-Dist: rich<15,>=13
|
|
9
|
+
Requires-Dist: PyYAML<7,>=6
|
|
10
|
+
|
|
11
|
+
# Pyplines CLI standard
|
|
12
|
+
|
|
13
|
+
`pyplines-cli-common` provides the shared Typer application boundary, safe
|
|
14
|
+
errors, and presentation used by the CLI and Builder. It has no Server dependency.
|
|
15
|
+
|
|
16
|
+
Human output is the default. `AUTOMATION_MODE=enabled` or `--json` selects
|
|
17
|
+
automation output: one JSON result on stdout, JSON errors and progress on stderr,
|
|
18
|
+
and no interactive prompts. Log-stream commands emit newline-delimited JSON.
|
|
19
|
+
Empty invocation and help show readable help and exit zero without credentials.
|
|
20
|
+
Redirecting stdout does not change the output format.
|
|
21
|
+
|
|
22
|
+
Exit codes: 0 success, 1 operation failure, 2 invalid usage, 130 interruption.
|
|
23
|
+
Unexpected exceptions never print arbitrary exception values; optional diagnostics
|
|
24
|
+
identify their type without leaking tokens or signed URLs.
|
|
25
|
+
|
|
26
|
+
The shared package follows the release's CalVer version and is published before
|
|
27
|
+
CLI and Builder. Configure its PyPI Trusted Publisher for repository
|
|
28
|
+
`pyplines/pyplines`, workflow `publish-release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
uv.lock
|
|
4
|
+
pyplines_cli_common/__init__.py
|
|
5
|
+
pyplines_cli_common/application.py
|
|
6
|
+
pyplines_cli_common/errors.py
|
|
7
|
+
pyplines_cli_common/interaction.py
|
|
8
|
+
pyplines_cli_common/presentation.py
|
|
9
|
+
pyplines_cli_common.egg-info/PKG-INFO
|
|
10
|
+
pyplines_cli_common.egg-info/SOURCES.txt
|
|
11
|
+
pyplines_cli_common.egg-info/dependency_links.txt
|
|
12
|
+
pyplines_cli_common.egg-info/requires.txt
|
|
13
|
+
pyplines_cli_common.egg-info/top_level.txt
|
|
14
|
+
tests/test_application.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyplines_cli_common
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "setuptools-scm>=8", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyplines-cli-common"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Shared presentation and interaction contract for Pyplines command-line tools"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["typer>=0.24,<1", "rich>=13,<15", "PyYAML>=6,<7"]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
include = ["pyplines_cli_common*"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools_scm]
|
|
17
|
+
root = ".."
|
|
18
|
+
fallback_version = "0.0.dev0"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8", "black>=25", "twine>=6"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import io
|
|
3
|
+
import pytest
|
|
4
|
+
import typer
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
from pyplines_cli_common.application import Group
|
|
7
|
+
from pyplines_cli_common.presentation import Presenter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("automation", [False, True])
|
|
11
|
+
def test_help_and_usage(automation, monkeypatch):
|
|
12
|
+
monkeypatch.setenv("AUTOMATION_MODE", "enabled" if automation else "")
|
|
13
|
+
app = typer.Typer(cls=Group, no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
@app.callback()
|
|
16
|
+
def options():
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def sample():
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
result = CliRunner().invoke(app, [])
|
|
24
|
+
assert result.exit_code == 0
|
|
25
|
+
assert "Usage:" in result.stdout
|
|
26
|
+
assert "Traceback" not in result.output
|
|
27
|
+
result = CliRunner().invoke(app, ["unknown"])
|
|
28
|
+
assert result.exit_code == 2
|
|
29
|
+
if automation:
|
|
30
|
+
assert json.loads(result.stderr)["error"]["code"] == "invalid-usage"
|
|
31
|
+
assert result.stdout == ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_automation_streams(monkeypatch):
|
|
35
|
+
monkeypatch.setenv("AUTOMATION_MODE", "enabled")
|
|
36
|
+
out, err = io.StringIO(), io.StringIO()
|
|
37
|
+
presenter = Presenter(out, err)
|
|
38
|
+
presenter.event({"message": "progress"})
|
|
39
|
+
presenter.outcome({"ok": True}, "Done")
|
|
40
|
+
assert json.loads(out.getvalue()) == {"ok": True}
|
|
41
|
+
assert json.loads(err.getvalue()) == {"message": "progress"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_automation_disables_terminal_prompts(monkeypatch):
|
|
45
|
+
import sys
|
|
46
|
+
from pyplines_cli_common.interaction import can_prompt
|
|
47
|
+
|
|
48
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
|
49
|
+
monkeypatch.setenv("AUTOMATION_MODE", "enabled")
|
|
50
|
+
assert not can_prompt()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_unexpected_errors_do_not_leak(monkeypatch):
|
|
54
|
+
monkeypatch.setenv("AUTOMATION_MODE", "enabled")
|
|
55
|
+
app = typer.Typer(cls=Group, no_args_is_help=True)
|
|
56
|
+
|
|
57
|
+
@app.callback()
|
|
58
|
+
def options():
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def fail():
|
|
63
|
+
raise RuntimeError("private-token-in-url")
|
|
64
|
+
|
|
65
|
+
result = CliRunner().invoke(app, ["--verbose-errors", "fail"])
|
|
66
|
+
assert result.exit_code == 1
|
|
67
|
+
assert "private-token-in-url" not in result.output
|
|
68
|
+
assert "RuntimeError" in json.loads(result.stderr)["error"]["message"]
|