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 +66 -0
- cli/apps/__init__.py +1 -0
- cli/apps/config.py +67 -0
- cli/apps/function.py +216 -0
- cli/config/__init__.py +3 -0
- cli/config/loader.py +114 -0
- cli/config/mutation.py +91 -0
- cli/context.py +14 -0
- cli/development/__init__.py +3 -0
- cli/development/client.py +92 -0
- cli/development/events.py +24 -0
- cli/errors.py +26 -0
- cli/function/__init__.py +1 -0
- cli/function/inputs.py +47 -0
- cli/function/scaffold.py +115 -0
- cli/output/__init__.py +3 -0
- cli/output/mode.py +12 -0
- cli/output/render.py +35 -0
- pyplines_cli-2026.7.0a1.dist-info/METADATA +93 -0
- pyplines_cli-2026.7.0a1.dist-info/RECORD +23 -0
- pyplines_cli-2026.7.0a1.dist-info/WHEEL +5 -0
- pyplines_cli-2026.7.0a1.dist-info/entry_points.txt +2 -0
- pyplines_cli-2026.7.0a1.dist-info/top_level.txt +1 -0
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
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,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)
|
cli/function/__init__.py
ADDED
|
@@ -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
|
cli/function/scaffold.py
ADDED
|
@@ -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
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 @@
|
|
|
1
|
+
cli
|