pyplines-cli 2026.7.0a1__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.
cli/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from cli.apps.config import app as config_app
9
+ from cli.apps.function import app as function_app
10
+ from cli.config.loader import ConfigurationError, create_settings, get_config_path
11
+ from cli.context import AppContext
12
+ from cli.errors import exit_with_error
13
+ from cli.output.mode import OutputMode, resolve_output_mode
14
+ from cli.output.render import emit_data
15
+
16
+ app = typer.Typer(
17
+ name="pyplines",
18
+ help="Build and operate Pyplines automation",
19
+ add_completion=False,
20
+ no_args_is_help=True,
21
+ )
22
+ app.add_typer(config_app, name="config")
23
+ app.add_typer(function_app, name="function")
24
+
25
+
26
+ @app.callback()
27
+ def root_callback(
28
+ ctx: typer.Context,
29
+ output_json: Annotated[
30
+ bool,
31
+ typer.Option("--json", help="Emit machine-readable JSON output"),
32
+ ] = False,
33
+ verbose: Annotated[
34
+ bool,
35
+ typer.Option("--verbose", "-v", help="Show verbose diagnostics"),
36
+ ] = False,
37
+ ) -> None:
38
+ try:
39
+ config_path = get_config_path()
40
+ settings = create_settings(config_path)
41
+ output_mode = resolve_output_mode(
42
+ force_json=output_json,
43
+ configured_mode=str(settings.get("mode", "interactive")),
44
+ )
45
+ ctx.obj = AppContext(
46
+ settings=settings,
47
+ config_path=config_path,
48
+ output_mode=output_mode,
49
+ verbose=verbose,
50
+ )
51
+ except Exception as exc:
52
+ mode = OutputMode.JSON if output_json else OutputMode.TABLE
53
+ exit_with_error(exc, mode, kind="configuration")
54
+
55
+
56
+ @app.command("version")
57
+ def version_command(ctx: typer.Context) -> None:
58
+ context: AppContext = ctx.find_root().obj
59
+ try:
60
+ cli_version = version("pyplines-cli")
61
+ except PackageNotFoundError:
62
+ cli_version = "development"
63
+ emit_data({"pyplines-cli": cli_version}, context.output_mode)
64
+
65
+
66
+ __all__ = ["app"]
cli/apps/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI command groups."""
cli/apps/config.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+ import yaml
7
+
8
+ from cli.config.loader import effective_settings
9
+ from cli.config.mutation import delete_dotted, set_dotted
10
+ from cli.context import AppContext
11
+ from cli.errors import exit_with_error
12
+ from cli.output.render import emit_data
13
+
14
+ app = typer.Typer(help="Manage Pyplines CLI configuration", no_args_is_help=True)
15
+
16
+
17
+ def _context(ctx: typer.Context) -> AppContext:
18
+ return ctx.find_root().obj
19
+
20
+
21
+ @app.command("path")
22
+ def path_command(ctx: typer.Context) -> None:
23
+ context = _context(ctx)
24
+ emit_data({"path": str(context.config_path)}, context.output_mode)
25
+
26
+
27
+ @app.command("show")
28
+ def show_command(ctx: typer.Context) -> None:
29
+ context = _context(ctx)
30
+ emit_data(effective_settings(context.settings), context.output_mode)
31
+
32
+
33
+ @app.command("get")
34
+ def get_command(ctx: typer.Context, key: str) -> None:
35
+ context = _context(ctx)
36
+ sentinel = object()
37
+ value = context.settings.get(key, sentinel)
38
+ if value is sentinel:
39
+ exit_with_error(KeyError(f"setting does not exist: {key}"), context.output_mode)
40
+ emit_data({key: value}, context.output_mode)
41
+
42
+
43
+ @app.command("set")
44
+ def set_command(ctx: typer.Context, key: str, value: str) -> None:
45
+ context = _context(ctx)
46
+ try:
47
+ parsed: Any = yaml.safe_load(value)
48
+ set_dotted(context.config_path, key, parsed)
49
+ emit_data({key: parsed}, context.output_mode)
50
+ except Exception as exc:
51
+ exit_with_error(exc, context.output_mode, kind="configuration")
52
+
53
+
54
+ @app.command("rm")
55
+ def remove_command(ctx: typer.Context, key: str) -> None:
56
+ context = _context(ctx)
57
+ try:
58
+ delete_dotted(context.config_path, key)
59
+ emit_data({"removed": key}, context.output_mode)
60
+ except KeyError:
61
+ exit_with_error(
62
+ ValueError(f"setting does not exist in config file: {key}"),
63
+ context.output_mode,
64
+ kind="configuration",
65
+ )
66
+ except Exception as exc:
67
+ exit_with_error(exc, context.output_mode, kind="configuration")
cli/apps/function.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+
8
+ from cli.context import AppContext
9
+ from cli.development.client import FunctionDevelopmentClient
10
+ from cli.development.events import DevelopmentEvent
11
+ from cli.errors import CliError, exit_with_error
12
+ from cli.function.inputs import load_inputs
13
+ from cli.function.scaffold import initialize_function
14
+ from cli.output.mode import OutputMode
15
+ from cli.output.render import emit_data, emit_json_event
16
+
17
+ app = typer.Typer(help="Develop Pyplines Functions", no_args_is_help=True)
18
+
19
+
20
+ class RemoteOperationFailed(Exception):
21
+ pass
22
+
23
+
24
+ def _context(ctx: typer.Context) -> AppContext:
25
+ return ctx.find_root().obj
26
+
27
+
28
+ def _client(context: AppContext) -> FunctionDevelopmentClient:
29
+ settings = context.settings
30
+ return FunctionDevelopmentClient(
31
+ base_url=str(settings.get("function_development.url")),
32
+ connect_timeout=float(settings.get("function_development.connect_timeout")),
33
+ operation_timeout=float(settings.get("function_development.operation_timeout")),
34
+ )
35
+
36
+
37
+ @app.command("init")
38
+ def init_command(
39
+ ctx: typer.Context,
40
+ name: str,
41
+ path: Annotated[
42
+ Path, typer.Option(help="Parent directory for the Function")
43
+ ] = Path("."),
44
+ template: Annotated[
45
+ str | None, typer.Option(help="Git repository containing the Function template")
46
+ ] = None,
47
+ template_ref: Annotated[
48
+ str | None, typer.Option(help="Template Git tag, branch, or commit")
49
+ ] = None,
50
+ ) -> None:
51
+ context = _context(ctx)
52
+ repository = template or str(context.settings.get("function.template.repository"))
53
+ ref = template_ref or str(context.settings.get("function.template.ref"))
54
+ try:
55
+ destination = initialize_function(
56
+ slug=name,
57
+ parent=path,
58
+ repository=repository,
59
+ ref=ref,
60
+ )
61
+ emit_data(
62
+ {
63
+ "function": name,
64
+ "path": str(destination),
65
+ "template": repository,
66
+ "template_ref": ref,
67
+ },
68
+ context.output_mode,
69
+ )
70
+ except Exception as exc:
71
+ exit_with_error(exc, context.output_mode, kind="function-init")
72
+
73
+
74
+ @app.command("status")
75
+ def status_command(ctx: typer.Context) -> None:
76
+ context = _context(ctx)
77
+ try:
78
+ with _client(context) as client:
79
+ emit_data(client.status(), context.output_mode)
80
+ except Exception as exc:
81
+ exit_with_error(exc, context.output_mode, kind="function-development")
82
+
83
+
84
+ @app.command("check")
85
+ def check_command(
86
+ ctx: typer.Context,
87
+ path: Annotated[Path, typer.Argument(help="Mounted Function project path")] = Path(
88
+ "."
89
+ ),
90
+ ) -> None:
91
+ context = _context(ctx)
92
+ try:
93
+ _require_project(path)
94
+ with _client(context) as client:
95
+ _render_stream(client.check(), context.output_mode)
96
+ except RemoteOperationFailed:
97
+ raise typer.Exit(code=1)
98
+ except Exception as exc:
99
+ exit_with_error(exc, context.output_mode, kind="function-check")
100
+
101
+
102
+ @app.command("invoke")
103
+ def invoke_command(
104
+ ctx: typer.Context,
105
+ path: Annotated[Path, typer.Argument(help="Mounted Function project path")] = Path(
106
+ "."
107
+ ),
108
+ inputs: Annotated[
109
+ list[str] | None,
110
+ typer.Option("--input", help="Function input in key=value form"),
111
+ ] = None,
112
+ input_file: Annotated[
113
+ Path | None,
114
+ typer.Option(help="JSON or YAML input file"),
115
+ ] = None,
116
+ sensitive_paths: Annotated[
117
+ list[str] | None,
118
+ typer.Option("--sensitive-path", help="Sensitive input JSON pointer"),
119
+ ] = None,
120
+ ) -> None:
121
+ context = _context(ctx)
122
+ try:
123
+ _require_project(path)
124
+ payload = load_inputs(
125
+ assignments=inputs or [],
126
+ input_file=input_file,
127
+ )
128
+ with _client(context) as client:
129
+ _render_stream(
130
+ client.invoke(payload, sensitive_paths or []),
131
+ context.output_mode,
132
+ )
133
+ except RemoteOperationFailed:
134
+ raise typer.Exit(code=1)
135
+ except Exception as exc:
136
+ exit_with_error(exc, context.output_mode, kind="function-invoke")
137
+
138
+
139
+ @app.command("package")
140
+ def package_command(
141
+ ctx: typer.Context,
142
+ path: Annotated[Path, typer.Argument(help="Mounted Function project path")] = Path(
143
+ "."
144
+ ),
145
+ ) -> None:
146
+ context = _context(ctx)
147
+ try:
148
+ project = _require_project(path)
149
+ with _client(context) as client:
150
+ result = _render_stream(client.package(), context.output_mode)
151
+ if isinstance(result, dict) and isinstance(result.get("path"), str):
152
+ local_archive = project / "dist" / Path(result["path"]).name
153
+ if not local_archive.is_file():
154
+ raise CliError(
155
+ f"Function Package was not created locally: {local_archive}"
156
+ )
157
+ except RemoteOperationFailed:
158
+ raise typer.Exit(code=1)
159
+ except Exception as exc:
160
+ exit_with_error(exc, context.output_mode, kind="function-package")
161
+
162
+
163
+ def _render_stream(events: Any, mode: OutputMode) -> Any:
164
+ terminal_result: Any = None
165
+ failed = False
166
+ for event in events:
167
+ if mode is OutputMode.JSON:
168
+ emit_json_event(event.raw)
169
+ else:
170
+ _render_table_event(event)
171
+ if event.type == "result":
172
+ terminal_result = event.payload
173
+ elif event.type == "error":
174
+ failed = True
175
+ if failed:
176
+ raise RemoteOperationFailed()
177
+ if terminal_result is None:
178
+ raise CliError("development operation ended without a result")
179
+ return terminal_result
180
+
181
+
182
+ def _render_table_event(event: DevelopmentEvent) -> None:
183
+ payload = event.payload
184
+ if event.type == "progress":
185
+ stage = (
186
+ payload.get("stage", "progress") if isinstance(payload, dict) else payload
187
+ )
188
+ typer.echo(f"[{stage}]")
189
+ elif event.type == "log":
190
+ if isinstance(payload, dict):
191
+ level = payload.get("level", "info")
192
+ message = payload.get("message", "")
193
+ fields = payload.get("fields") or {}
194
+ suffix = " ".join(f"{key}={value}" for key, value in fields.items())
195
+ typer.echo(f"[{level}] {message}" + (f" {suffix}" if suffix else ""))
196
+ else:
197
+ typer.echo(str(payload))
198
+ elif event.type == "diagnostic":
199
+ message = (
200
+ payload.get("message", payload) if isinstance(payload, dict) else payload
201
+ )
202
+ typer.echo(str(message), err=True)
203
+ elif event.type == "error":
204
+ message = (
205
+ payload.get("message", payload) if isinstance(payload, dict) else payload
206
+ )
207
+ typer.echo(f"Error: {message}", err=True)
208
+ elif event.type == "result":
209
+ emit_data(payload, OutputMode.TABLE)
210
+
211
+
212
+ def _require_project(path: Path) -> Path:
213
+ project = path.expanduser().resolve()
214
+ if not (project / "pyproject.toml").is_file():
215
+ raise CliError(f"Function project does not contain pyproject.toml: {project}")
216
+ return project
cli/config/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from cli.config.loader import create_settings, get_config_path
2
+
3
+ __all__ = ["create_settings", "get_config_path"]
cli/config/loader.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from dynaconf import Dynaconf
9
+ from platformdirs import user_config_dir
10
+
11
+ DEFAULTS: dict[str, Any] = {
12
+ "mode": "interactive",
13
+ "function_development": {
14
+ "url": "http://127.0.0.1:7331",
15
+ "connect_timeout": 5,
16
+ "operation_timeout": 1800,
17
+ },
18
+ "function": {
19
+ "template": {
20
+ "repository": "https://github.com/pyplines/function-template.git",
21
+ "ref": "v1",
22
+ }
23
+ },
24
+ }
25
+
26
+
27
+ class ConfigurationError(ValueError):
28
+ pass
29
+
30
+
31
+ def get_config_path() -> Path:
32
+ override = os.getenv("PYPLINES_CONFIG_FILE")
33
+ if override:
34
+ return Path(override).expanduser().resolve()
35
+ return Path(user_config_dir("pyplines")) / "config.yaml"
36
+
37
+
38
+ def create_settings(path: Path | None = None, *, load_env: bool = True) -> Dynaconf:
39
+ config_path = (path or get_config_path()).expanduser().resolve()
40
+ settings = Dynaconf(
41
+ envvar_prefix="PYPLINES" if load_env else False,
42
+ settings_files=[str(config_path)],
43
+ environments=False,
44
+ load_dotenv=False,
45
+ core_loaders=["YAML"],
46
+ merge_enabled=True,
47
+ )
48
+ _apply_defaults(settings, DEFAULTS)
49
+ validate_settings(settings)
50
+ return settings
51
+
52
+
53
+ def validate_settings(settings: Dynaconf) -> None:
54
+ mode = str(settings.get("mode", "interactive")).lower()
55
+ if mode not in {"interactive", "automation"}:
56
+ raise ConfigurationError("mode must be interactive or automation")
57
+
58
+ url = str(settings.get("function_development.url", ""))
59
+ parsed = urlparse(url)
60
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
61
+ raise ConfigurationError(
62
+ "function_development.url must be an HTTP or HTTPS URL"
63
+ )
64
+
65
+ for key in (
66
+ "function_development.connect_timeout",
67
+ "function_development.operation_timeout",
68
+ ):
69
+ value = settings.get(key)
70
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
71
+ raise ConfigurationError(f"{key} must be a positive number")
72
+
73
+ repository = str(settings.get("function.template.repository", "")).strip()
74
+ if not repository:
75
+ raise ConfigurationError("function.template.repository must not be empty")
76
+ template_ref = str(settings.get("function.template.ref", "")).strip()
77
+ if not template_ref:
78
+ raise ConfigurationError("function.template.ref must not be empty")
79
+
80
+
81
+ def effective_settings(settings: Dynaconf) -> dict[str, Any]:
82
+ ignored = {"LOAD_DOTENV", "CONFIG_FILE"}
83
+ return {
84
+ str(key).lower(): _normalize_effective(value)
85
+ for key, value in settings.as_dict().items()
86
+ if key not in ignored
87
+ }
88
+
89
+
90
+ def _apply_defaults(
91
+ settings: Dynaconf, values: dict[str, Any], prefix: str = ""
92
+ ) -> None:
93
+ missing = object()
94
+ for key, value in values.items():
95
+ dotted = f"{prefix}.{key}" if prefix else key
96
+ if isinstance(value, dict):
97
+ _apply_defaults(settings, value, dotted)
98
+ elif settings.get(dotted, missing) is missing:
99
+ settings.set(dotted, value)
100
+
101
+
102
+ def _normalize_effective(value: Any) -> Any:
103
+ if isinstance(value, dict):
104
+ normalized = {}
105
+ for key, item in value.items():
106
+ name = str(key).lower()
107
+ if any(marker in name for marker in ("token", "password", "secret")):
108
+ normalized[name] = "[REDACTED]"
109
+ else:
110
+ normalized[name] = _normalize_effective(item)
111
+ return normalized
112
+ if isinstance(value, list):
113
+ return [_normalize_effective(item) for item in value]
114
+ return value
cli/config/mutation.py ADDED
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from cli.config.loader import create_settings
11
+
12
+
13
+ def read_source(path: Path) -> dict[str, Any]:
14
+ if not path.exists():
15
+ return {}
16
+ try:
17
+ value = yaml.safe_load(path.read_text(encoding="utf-8"))
18
+ except yaml.YAMLError as exc:
19
+ raise ValueError(f"configuration is not valid YAML: {exc}") from exc
20
+ if value is None:
21
+ return {}
22
+ if not isinstance(value, dict):
23
+ raise ValueError("configuration root must be an object")
24
+ return value
25
+
26
+
27
+ def get_dotted(value: dict[str, Any], key: str) -> Any:
28
+ current: Any = value
29
+ for segment in _segments(key):
30
+ if not isinstance(current, dict) or segment not in current:
31
+ raise KeyError(key)
32
+ current = current[segment]
33
+ return current
34
+
35
+
36
+ def set_dotted(path: Path, key: str, value: Any) -> None:
37
+ source = read_source(path)
38
+ current = source
39
+ segments = _segments(key)
40
+ for segment in segments[:-1]:
41
+ child = current.setdefault(segment, {})
42
+ if not isinstance(child, dict):
43
+ raise ValueError(f"cannot set nested key below scalar: {segment}")
44
+ current = child
45
+ current[segments[-1]] = value
46
+ write_source(path, source)
47
+
48
+
49
+ def delete_dotted(path: Path, key: str) -> None:
50
+ source = read_source(path)
51
+ current = source
52
+ segments = _segments(key)
53
+ parents: list[tuple[dict[str, Any], str]] = []
54
+ for segment in segments[:-1]:
55
+ child = current.get(segment)
56
+ if not isinstance(child, dict):
57
+ raise KeyError(key)
58
+ parents.append((current, segment))
59
+ current = child
60
+ if segments[-1] not in current:
61
+ raise KeyError(key)
62
+ del current[segments[-1]]
63
+ for parent, segment in reversed(parents):
64
+ if parent[segment] == {}:
65
+ del parent[segment]
66
+ write_source(path, source)
67
+
68
+
69
+ def write_source(path: Path, source: dict[str, Any]) -> None:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ encoded = yaml.safe_dump(source, sort_keys=False, default_flow_style=False)
72
+ descriptor, temporary = tempfile.mkstemp(
73
+ prefix=f".{path.name}.", suffix=".yaml", dir=path.parent
74
+ )
75
+ temporary_path = Path(temporary)
76
+ try:
77
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
78
+ handle.write(encoded)
79
+ os.chmod(temporary_path, 0o600)
80
+ create_settings(temporary_path, load_env=False)
81
+ os.replace(temporary_path, path)
82
+ except Exception:
83
+ temporary_path.unlink(missing_ok=True)
84
+ raise
85
+
86
+
87
+ def _segments(key: str) -> list[str]:
88
+ segments = [segment.strip().lower() for segment in key.split(".")]
89
+ if not segments or any(not segment for segment in segments):
90
+ raise ValueError("configuration key must use nonempty dotted segments")
91
+ return segments
cli/context.py ADDED
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from dynaconf import Dynaconf
5
+
6
+ from cli.output.mode import OutputMode
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AppContext:
11
+ settings: Dynaconf
12
+ config_path: Path
13
+ output_mode: OutputMode
14
+ verbose: bool = False
@@ -0,0 +1,3 @@
1
+ from cli.development.client import FunctionDevelopmentClient
2
+
3
+ __all__ = ["FunctionDevelopmentClient"]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from cli.development.events import DevelopmentEvent
11
+ from cli.errors import CliError
12
+
13
+ TOKEN_ENV = "PYPLINES_FUNCTION_DEVELOPMENT_TOKEN"
14
+
15
+
16
+ class FunctionDevelopmentClient:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ base_url: str,
21
+ connect_timeout: float,
22
+ operation_timeout: float,
23
+ token: str | None = None,
24
+ transport: httpx.BaseTransport | None = None,
25
+ ):
26
+ resolved_token = token or os.getenv(TOKEN_ENV)
27
+ if not resolved_token:
28
+ raise CliError(f"development token is required in {TOKEN_ENV}")
29
+ self._token = resolved_token
30
+ self._client = httpx.Client(
31
+ base_url=base_url.rstrip("/"),
32
+ headers={"Authorization": f"Bearer {resolved_token}"},
33
+ timeout=httpx.Timeout(operation_timeout, connect=connect_timeout),
34
+ transport=transport,
35
+ )
36
+
37
+ def __enter__(self) -> "FunctionDevelopmentClient":
38
+ return self
39
+
40
+ def __exit__(self, *_: object) -> None:
41
+ self.close()
42
+
43
+ def close(self) -> None:
44
+ self._client.close()
45
+
46
+ def status(self) -> dict[str, Any]:
47
+ try:
48
+ response = self._client.get("/v1/status")
49
+ response.raise_for_status()
50
+ value = response.json()
51
+ except (httpx.HTTPError, ValueError) as exc:
52
+ raise CliError(self._safe_error(exc)) from exc
53
+ if not isinstance(value, dict):
54
+ raise CliError("development status response must be an object")
55
+ return value
56
+
57
+ def check(self) -> Iterator[DevelopmentEvent]:
58
+ return self._stream("/v1/functions/check")
59
+
60
+ def invoke(
61
+ self, inputs: dict[str, Any], sensitive_paths: list[str]
62
+ ) -> Iterator[DevelopmentEvent]:
63
+ return self._stream(
64
+ "/v1/functions/invoke",
65
+ payload={
66
+ "input": inputs,
67
+ "sensitive_input_paths": sensitive_paths,
68
+ },
69
+ )
70
+
71
+ def package(self) -> Iterator[DevelopmentEvent]:
72
+ return self._stream("/v1/functions/package")
73
+
74
+ def _stream(
75
+ self, path: str, payload: dict[str, Any] | None = None
76
+ ) -> Iterator[DevelopmentEvent]:
77
+ try:
78
+ with self._client.stream("POST", path, json=payload) as response:
79
+ response.raise_for_status()
80
+ for line in response.iter_lines():
81
+ if not line:
82
+ continue
83
+ try:
84
+ value = json.loads(line)
85
+ yield DevelopmentEvent.parse(value)
86
+ except (json.JSONDecodeError, ValueError) as exc:
87
+ raise CliError(f"invalid development event: {exc}") from exc
88
+ except httpx.HTTPError as exc:
89
+ raise CliError(self._safe_error(exc)) from exc
90
+
91
+ def _safe_error(self, error: Exception) -> str:
92
+ return str(error).replace(self._token, "[REDACTED]")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ EVENT_TYPES = {"progress", "log", "diagnostic", "result", "error"}
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DevelopmentEvent:
11
+ type: str
12
+ payload: Any
13
+ raw: dict[str, Any]
14
+
15
+ @classmethod
16
+ def parse(cls, value: Any) -> "DevelopmentEvent":
17
+ if not isinstance(value, dict):
18
+ raise ValueError("development event must be an object")
19
+ event_type = value.get("type")
20
+ if event_type not in EVENT_TYPES:
21
+ raise ValueError(f"unknown development event type: {event_type}")
22
+ if event_type not in value:
23
+ raise ValueError(f"development event is missing {event_type} payload")
24
+ return cls(type=event_type, payload=value[event_type], raw=value)
cli/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+
7
+ from cli.output.mode import OutputMode
8
+
9
+
10
+ class CliError(Exception):
11
+ pass
12
+
13
+
14
+ def exit_with_error(error: Exception, mode: OutputMode, *, kind: str = "cli") -> None:
15
+ message = str(error)
16
+ if mode is OutputMode.JSON:
17
+ typer.echo(
18
+ json.dumps(
19
+ {"error": {"kind": kind, "message": message}},
20
+ separators=(",", ":"),
21
+ ),
22
+ err=True,
23
+ )
24
+ else:
25
+ typer.echo(f"Error: {message}", err=True)
26
+ raise typer.Exit(code=1)
@@ -0,0 +1 @@
1
+ """Function development helpers."""
cli/function/inputs.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, TextIO
7
+
8
+ import yaml
9
+
10
+
11
+ def load_inputs(
12
+ *,
13
+ assignments: list[str],
14
+ input_file: Path | None,
15
+ stream: TextIO | None = None,
16
+ ) -> dict[str, Any]:
17
+ source = stream or sys.stdin
18
+ stdin_value = None
19
+ if not source.isatty():
20
+ content = source.read()
21
+ if content.strip():
22
+ stdin_value = _decode(content, "stdin")
23
+ if input_file is not None and stdin_value is not None:
24
+ raise ValueError("stdin and --input-file cannot both provide input")
25
+ if input_file is not None:
26
+ if not input_file.is_file():
27
+ raise ValueError(f"input file does not exist: {input_file}")
28
+ base = _decode(input_file.read_text(encoding="utf-8"), str(input_file))
29
+ else:
30
+ base = stdin_value or {}
31
+ if not isinstance(base, dict):
32
+ raise ValueError("Function input must be an object")
33
+ result = dict(base)
34
+ for assignment in assignments:
35
+ key, separator, raw = assignment.partition("=")
36
+ key = key.strip()
37
+ if not separator or not key:
38
+ raise ValueError("--input must use key=value syntax")
39
+ result[key] = yaml.safe_load(raw)
40
+ return result
41
+
42
+
43
+ def _decode(content: str, source: str) -> Any:
44
+ try:
45
+ return yaml.safe_load(content)
46
+ except yaml.YAMLError as exc:
47
+ raise ValueError(f"invalid JSON or YAML from {source}: {exc}") from exc
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ from cli.errors import CliError
11
+
12
+ SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
13
+
14
+
15
+ def initialize_function(
16
+ *,
17
+ slug: str,
18
+ parent: Path,
19
+ repository: str,
20
+ ref: str,
21
+ ) -> Path:
22
+ if not SLUG.fullmatch(slug):
23
+ raise CliError("Function name must use lowercase letters, numbers, and hyphens")
24
+ parent = parent.expanduser().resolve()
25
+ destination = parent / slug
26
+ if destination.exists() and any(destination.iterdir()):
27
+ raise CliError(f"destination is not empty: {destination}")
28
+ parent.mkdir(parents=True, exist_ok=True)
29
+ temporary = Path(tempfile.mkdtemp(prefix=f".{slug}.", dir=parent))
30
+ try:
31
+ _clone(repository, ref, temporary)
32
+ shutil.rmtree(temporary / ".git", ignore_errors=True)
33
+ _customize(temporary, slug)
34
+ _validate(temporary, slug)
35
+ if destination.exists():
36
+ destination.rmdir()
37
+ temporary.rename(destination)
38
+ return destination
39
+ except Exception:
40
+ shutil.rmtree(temporary, ignore_errors=True)
41
+ raise
42
+
43
+
44
+ def _clone(repository: str, ref: str, destination: Path) -> None:
45
+ commands = [
46
+ [
47
+ "git",
48
+ "clone",
49
+ "--depth",
50
+ "1",
51
+ "--no-checkout",
52
+ repository,
53
+ str(destination),
54
+ ],
55
+ [
56
+ "git",
57
+ "-C",
58
+ str(destination),
59
+ "fetch",
60
+ "--depth",
61
+ "1",
62
+ "origin",
63
+ ref,
64
+ ],
65
+ ["git", "-C", str(destination), "checkout", "--detach", "FETCH_HEAD"],
66
+ ]
67
+ try:
68
+ for command in commands:
69
+ subprocess.run(command, capture_output=True, text=True, check=True)
70
+ except FileNotFoundError as exc:
71
+ raise CliError("Git is required to initialize a Function") from exc
72
+ except subprocess.CalledProcessError as exc:
73
+ message = exc.stderr.strip() or "Git clone failed"
74
+ raise CliError(message) from exc
75
+
76
+
77
+ def _customize(root: Path, slug: str) -> None:
78
+ module = slug.replace("-", "_")
79
+ candidates = [root / "src" / "function_template", root / "function_template"]
80
+ for candidate in candidates:
81
+ if candidate.is_dir():
82
+ candidate.rename(candidate.with_name(module))
83
+ replacements = {
84
+ "function-template": slug,
85
+ "function_template": module,
86
+ "Function Template": _display_name(slug),
87
+ }
88
+ for path in root.rglob("*"):
89
+ if not path.is_file() or ".git" in path.parts:
90
+ continue
91
+ try:
92
+ content = path.read_text(encoding="utf-8")
93
+ except UnicodeDecodeError:
94
+ continue
95
+ for old, new in replacements.items():
96
+ content = content.replace(old, new)
97
+ path.write_text(content, encoding="utf-8")
98
+
99
+
100
+ def _validate(root: Path, slug: str) -> None:
101
+ pyproject = root / "pyproject.toml"
102
+ if not pyproject.is_file():
103
+ raise CliError("Function template does not contain pyproject.toml")
104
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
105
+ if data.get("project", {}).get("name") != slug:
106
+ raise CliError("Function template did not produce the requested project name")
107
+ handler = (
108
+ data.get("tool", {}).get("pyplines", {}).get("function", {}).get("handler")
109
+ )
110
+ if not isinstance(handler, str) or ":" not in handler:
111
+ raise CliError("Function template does not declare a valid handler")
112
+
113
+
114
+ def _display_name(slug: str) -> str:
115
+ return " ".join(part.capitalize() for part in slug.split("-"))
cli/output/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from cli.output.mode import OutputMode
2
+
3
+ __all__ = ["OutputMode"]
cli/output/mode.py ADDED
@@ -0,0 +1,12 @@
1
+ from enum import Enum
2
+
3
+
4
+ class OutputMode(str, Enum):
5
+ TABLE = "table"
6
+ JSON = "json"
7
+
8
+
9
+ def resolve_output_mode(*, force_json: bool, configured_mode: str) -> OutputMode:
10
+ if force_json or configured_mode.lower() == "automation":
11
+ return OutputMode.JSON
12
+ return OutputMode.TABLE
cli/output/render.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from cli.output.mode import OutputMode
10
+
11
+
12
+ def emit_data(value: Any, mode: OutputMode) -> None:
13
+ if mode is OutputMode.JSON:
14
+ typer.echo(json.dumps(value, separators=(",", ":"), ensure_ascii=False))
15
+ return
16
+ if isinstance(value, Mapping):
17
+ width = max((len(str(key)) for key in value), default=0)
18
+ for key, item in value.items():
19
+ typer.echo(f"{str(key):<{width}} {_display(item)}")
20
+ return
21
+ typer.echo(_display(value))
22
+
23
+
24
+ def emit_json_event(event: dict[str, Any]) -> None:
25
+ typer.echo(json.dumps(event, separators=(",", ":"), ensure_ascii=False))
26
+
27
+
28
+ def _display(value: Any) -> str:
29
+ if isinstance(value, (dict, list)):
30
+ return json.dumps(value, ensure_ascii=False)
31
+ if value is None:
32
+ return ""
33
+ if isinstance(value, bool):
34
+ return "true" if value else "false"
35
+ return str(value)
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyplines-cli
3
+ Version: 2026.7.0a1
4
+ Summary: The official command-line interface for Pyplines
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: dynaconf[yaml]<4,>=3.2.7
8
+ Requires-Dist: httpx<1,>=0.28
9
+ Requires-Dist: platformdirs<5,>=4
10
+ Requires-Dist: pyyaml<7,>=6
11
+ Requires-Dist: typer<1,>=0.24
12
+
13
+ # pyplines-cli
14
+
15
+ `pyplines-cli` is the command-line interface for Pyplines. The initial command
16
+ surface supports local Function development through a running Pyplines Function
17
+ development container. Server commands will use the same CLI in later releases.
18
+
19
+ ## Install
20
+
21
+ ```console
22
+ pip install pyplines-cli
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Configuration is YAML stored in the platform-native user configuration directory.
28
+ Find the exact path with:
29
+
30
+ ```console
31
+ pyplines config path
32
+ ```
33
+
34
+ Initial shape:
35
+
36
+ ```yaml
37
+ mode: interactive
38
+
39
+ function_development:
40
+ url: http://127.0.0.1:7331
41
+ connect_timeout: 5
42
+ operation_timeout: 1800
43
+
44
+ function:
45
+ template:
46
+ repository: https://github.com/pyplines/function-template.git
47
+ ref: v1
48
+ ```
49
+
50
+ Every setting can be overridden with a `PYPLINES_` environment variable. Use
51
+ double underscores for nested keys:
52
+
53
+ ```console
54
+ export PYPLINES_MODE=automation
55
+ export PYPLINES_FUNCTION_DEVELOPMENT__URL=http://127.0.0.1:7331
56
+ export PYPLINES_FUNCTION_DEVELOPMENT_TOKEN="<local-container-token>"
57
+ ```
58
+
59
+ The token is environment-only and is not written to the configuration file.
60
+
61
+ ## Output
62
+
63
+ Table output is the interactive default. JSON is selected by either:
64
+
65
+ ```console
66
+ pyplines --json function status
67
+ PYPLINES_MODE=automation pyplines function status
68
+ ```
69
+
70
+ Streaming operations emit NDJSON in JSON mode.
71
+
72
+ ## Function Development
73
+
74
+ Initialize from the configured, pinned Git template:
75
+
76
+ ```console
77
+ pyplines function init hello-world
78
+ ```
79
+
80
+ Check, invoke, and package the Function mounted into the development container:
81
+
82
+ ```console
83
+ cd hello-world
84
+ pyplines function status
85
+ pyplines function check
86
+ pyplines function invoke --input name=Ada
87
+ pyplines function invoke --input-file inputs.yaml
88
+ pyplines function package
89
+ ```
90
+
91
+ `function init` uses the system Git client and honors existing credential helpers,
92
+ SSH agents, proxies, and enterprise Git configuration. It does not execute template
93
+ hooks or retain the template repository's `.git` directory.
@@ -0,0 +1,23 @@
1
+ cli/__init__.py,sha256=tNi1zWe9MGYmfKOyoKXus7u_57-N22GJawPtoys5fb0,1970
2
+ cli/context.py,sha256=s_09baiQXrgdBjeSlLGt6LcjAjJaHSBD0Zbny5njKbg,273
3
+ cli/errors.py,sha256=SgG3LTcxgFpWUDWUWJ7-z3FG0lfgRTkBC8-XaD7NWKI,568
4
+ cli/apps/__init__.py,sha256=Rb-d2e6JpWZ5pPUP-TjKMWGtNCwpZCMYc0GulfEXbRs,26
5
+ cli/apps/config.py,sha256=jQeLbaQD6gPZOmyHYqSfUfKvFwgM87P_yrPr71kOuGM,2057
6
+ cli/apps/function.py,sha256=6TzlHcSQEXWh0kUAGbNOhJTcGmv4dfGIkYFaiXAyY-I,7091
7
+ cli/config/__init__.py,sha256=2oV2Z2aVBxGredp49OGqJODBuVL9i2Gl2KchrNg_8xQ,113
8
+ cli/config/loader.py,sha256=35rgbQtz_DqN23OZpYKddUB8SN81-rIG92q1fLATNYI,3692
9
+ cli/config/mutation.py,sha256=fH2U18n6AMozBa9JUkmXWnwLuinCLDDvIDyiKowB4CY,2898
10
+ cli/development/__init__.py,sha256=Lm_ilX4YH0wZ5WAN0Gculf0ekotTIKkvnLu-c6gHR7M,102
11
+ cli/development/client.py,sha256=Dxyd9EvIV5A4YLaUtPHncDYMcbBIjh09cRzdonrhUos,3036
12
+ cli/development/events.py,sha256=h-HHQERDMdH8Ao4bEf-A7vJnnfgvf5UeGsG62DJBIqs,798
13
+ cli/function/__init__.py,sha256=_jagVutGo1iq10oUsVf_5s4Qhk5zAOSPahurWzM3v_o,36
14
+ cli/function/inputs.py,sha256=ipCcjbom4HJe-YysIytPAXrS5AZKTUVd9oN8oXmb57w,1473
15
+ cli/function/scaffold.py,sha256=oCJ3BpFzIZA41zUJd849nOsfayhYX3VeOMI_i7Zjwcs,3664
16
+ cli/output/__init__.py,sha256=q4Dxyb4G7phdIls-ACZnswTliv_6JJ6hAEHOSxY8WI0,65
17
+ cli/output/mode.py,sha256=SsQt0V0gS7bFK77zECsh53L21UELn8LdwjpvCCEb0nc,296
18
+ cli/output/render.py,sha256=K40u8_Ac8hz572UI2CjsbMVpuxDB-V2-l-93wGG91kE,993
19
+ pyplines_cli-2026.7.0a1.dist-info/METADATA,sha256=oCo68wQPDF-hTi0S7a4phLTzAHMBAlWoJRKrsEScqKs,2301
20
+ pyplines_cli-2026.7.0a1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
21
+ pyplines_cli-2026.7.0a1.dist-info/entry_points.txt,sha256=atYawf14g53pcY4Fk5t00DpqTSv0zpp3MNEmeJcs3Mo,37
22
+ pyplines_cli-2026.7.0a1.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
23
+ pyplines_cli-2026.7.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyplines = cli:app
@@ -0,0 +1 @@
1
+ cli