pyplines-cli 2026.7.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyplines_cli-2026.7.0a1/PKG-INFO +93 -0
- pyplines_cli-2026.7.0a1/README.md +81 -0
- pyplines_cli-2026.7.0a1/cli/__init__.py +66 -0
- pyplines_cli-2026.7.0a1/cli/apps/__init__.py +1 -0
- pyplines_cli-2026.7.0a1/cli/apps/config.py +67 -0
- pyplines_cli-2026.7.0a1/cli/apps/function.py +216 -0
- pyplines_cli-2026.7.0a1/cli/config/__init__.py +3 -0
- pyplines_cli-2026.7.0a1/cli/config/loader.py +114 -0
- pyplines_cli-2026.7.0a1/cli/config/mutation.py +91 -0
- pyplines_cli-2026.7.0a1/cli/context.py +14 -0
- pyplines_cli-2026.7.0a1/cli/development/__init__.py +3 -0
- pyplines_cli-2026.7.0a1/cli/development/client.py +92 -0
- pyplines_cli-2026.7.0a1/cli/development/events.py +24 -0
- pyplines_cli-2026.7.0a1/cli/errors.py +26 -0
- pyplines_cli-2026.7.0a1/cli/function/__init__.py +1 -0
- pyplines_cli-2026.7.0a1/cli/function/inputs.py +47 -0
- pyplines_cli-2026.7.0a1/cli/function/scaffold.py +115 -0
- pyplines_cli-2026.7.0a1/cli/output/__init__.py +3 -0
- pyplines_cli-2026.7.0a1/cli/output/mode.py +12 -0
- pyplines_cli-2026.7.0a1/cli/output/render.py +35 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/PKG-INFO +93 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/SOURCES.txt +31 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/dependency_links.txt +1 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/entry_points.txt +2 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/requires.txt +5 -0
- pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/top_level.txt +1 -0
- pyplines_cli-2026.7.0a1/pyproject.toml +36 -0
- pyplines_cli-2026.7.0a1/setup.cfg +4 -0
- pyplines_cli-2026.7.0a1/tests/test_commands.py +107 -0
- pyplines_cli-2026.7.0a1/tests/test_config.py +110 -0
- pyplines_cli-2026.7.0a1/tests/test_development_client.py +76 -0
- pyplines_cli-2026.7.0a1/tests/test_inputs.py +42 -0
- pyplines_cli-2026.7.0a1/tests/test_scaffold.py +93 -0
|
@@ -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,81 @@
|
|
|
1
|
+
# pyplines-cli
|
|
2
|
+
|
|
3
|
+
`pyplines-cli` is the command-line interface for Pyplines. The initial command
|
|
4
|
+
surface supports local Function development through a running Pyplines Function
|
|
5
|
+
development container. Server commands will use the same CLI in later releases.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
pip install pyplines-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Configuration is YAML stored in the platform-native user configuration directory.
|
|
16
|
+
Find the exact path with:
|
|
17
|
+
|
|
18
|
+
```console
|
|
19
|
+
pyplines config path
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Initial shape:
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
mode: interactive
|
|
26
|
+
|
|
27
|
+
function_development:
|
|
28
|
+
url: http://127.0.0.1:7331
|
|
29
|
+
connect_timeout: 5
|
|
30
|
+
operation_timeout: 1800
|
|
31
|
+
|
|
32
|
+
function:
|
|
33
|
+
template:
|
|
34
|
+
repository: https://github.com/pyplines/function-template.git
|
|
35
|
+
ref: v1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every setting can be overridden with a `PYPLINES_` environment variable. Use
|
|
39
|
+
double underscores for nested keys:
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
export PYPLINES_MODE=automation
|
|
43
|
+
export PYPLINES_FUNCTION_DEVELOPMENT__URL=http://127.0.0.1:7331
|
|
44
|
+
export PYPLINES_FUNCTION_DEVELOPMENT_TOKEN="<local-container-token>"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The token is environment-only and is not written to the configuration file.
|
|
48
|
+
|
|
49
|
+
## Output
|
|
50
|
+
|
|
51
|
+
Table output is the interactive default. JSON is selected by either:
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
pyplines --json function status
|
|
55
|
+
PYPLINES_MODE=automation pyplines function status
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Streaming operations emit NDJSON in JSON mode.
|
|
59
|
+
|
|
60
|
+
## Function Development
|
|
61
|
+
|
|
62
|
+
Initialize from the configured, pinned Git template:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
pyplines function init hello-world
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Check, invoke, and package the Function mounted into the development container:
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
cd hello-world
|
|
72
|
+
pyplines function status
|
|
73
|
+
pyplines function check
|
|
74
|
+
pyplines function invoke --input name=Ada
|
|
75
|
+
pyplines function invoke --input-file inputs.yaml
|
|
76
|
+
pyplines function package
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`function init` uses the system Git client and honors existing credential helpers,
|
|
80
|
+
SSH agents, proxies, and enterprise Git configuration. It does not execute template
|
|
81
|
+
hooks or retain the template repository's `.git` directory.
|
|
@@ -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"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command groups."""
|
|
@@ -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")
|
|
@@ -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
|
|
@@ -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
|