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.
@@ -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,8 @@
1
+ """Shared prompt eligibility; callers supply operation-specific missing-input errors."""
2
+
3
+ import sys
4
+ from . import automation_mode
5
+
6
+
7
+ def can_prompt():
8
+ return not automation_mode() and sys.stdin.isatty()
@@ -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,3 @@
1
+ typer<1,>=0.24
2
+ rich<15,>=13
3
+ PyYAML<7,>=6
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]