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.
Files changed (33) hide show
  1. pyplines_cli-2026.7.0a1/PKG-INFO +93 -0
  2. pyplines_cli-2026.7.0a1/README.md +81 -0
  3. pyplines_cli-2026.7.0a1/cli/__init__.py +66 -0
  4. pyplines_cli-2026.7.0a1/cli/apps/__init__.py +1 -0
  5. pyplines_cli-2026.7.0a1/cli/apps/config.py +67 -0
  6. pyplines_cli-2026.7.0a1/cli/apps/function.py +216 -0
  7. pyplines_cli-2026.7.0a1/cli/config/__init__.py +3 -0
  8. pyplines_cli-2026.7.0a1/cli/config/loader.py +114 -0
  9. pyplines_cli-2026.7.0a1/cli/config/mutation.py +91 -0
  10. pyplines_cli-2026.7.0a1/cli/context.py +14 -0
  11. pyplines_cli-2026.7.0a1/cli/development/__init__.py +3 -0
  12. pyplines_cli-2026.7.0a1/cli/development/client.py +92 -0
  13. pyplines_cli-2026.7.0a1/cli/development/events.py +24 -0
  14. pyplines_cli-2026.7.0a1/cli/errors.py +26 -0
  15. pyplines_cli-2026.7.0a1/cli/function/__init__.py +1 -0
  16. pyplines_cli-2026.7.0a1/cli/function/inputs.py +47 -0
  17. pyplines_cli-2026.7.0a1/cli/function/scaffold.py +115 -0
  18. pyplines_cli-2026.7.0a1/cli/output/__init__.py +3 -0
  19. pyplines_cli-2026.7.0a1/cli/output/mode.py +12 -0
  20. pyplines_cli-2026.7.0a1/cli/output/render.py +35 -0
  21. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/PKG-INFO +93 -0
  22. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/SOURCES.txt +31 -0
  23. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/dependency_links.txt +1 -0
  24. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/entry_points.txt +2 -0
  25. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/requires.txt +5 -0
  26. pyplines_cli-2026.7.0a1/pyplines_cli.egg-info/top_level.txt +1 -0
  27. pyplines_cli-2026.7.0a1/pyproject.toml +36 -0
  28. pyplines_cli-2026.7.0a1/setup.cfg +4 -0
  29. pyplines_cli-2026.7.0a1/tests/test_commands.py +107 -0
  30. pyplines_cli-2026.7.0a1/tests/test_config.py +110 -0
  31. pyplines_cli-2026.7.0a1/tests/test_development_client.py +76 -0
  32. pyplines_cli-2026.7.0a1/tests/test_inputs.py +42 -0
  33. 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,3 @@
1
+ from cli.config.loader import create_settings, get_config_path
2
+
3
+ __all__ = ["create_settings", "get_config_path"]
@@ -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