lib-layered-config 1.0.0__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.

Potentially problematic release.


This version of lib-layered-config might be problematic. Click here for more details.

Files changed (39) hide show
  1. lib_layered_config/__init__.py +60 -0
  2. lib_layered_config/__main__.py +19 -0
  3. lib_layered_config/_layers.py +457 -0
  4. lib_layered_config/_platform.py +200 -0
  5. lib_layered_config/adapters/__init__.py +13 -0
  6. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  7. lib_layered_config/adapters/dotenv/default.py +438 -0
  8. lib_layered_config/adapters/env/__init__.py +5 -0
  9. lib_layered_config/adapters/env/default.py +509 -0
  10. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  11. lib_layered_config/adapters/file_loaders/structured.py +410 -0
  12. lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
  13. lib_layered_config/adapters/path_resolvers/default.py +727 -0
  14. lib_layered_config/application/__init__.py +12 -0
  15. lib_layered_config/application/merge.py +442 -0
  16. lib_layered_config/application/ports.py +109 -0
  17. lib_layered_config/cli/__init__.py +162 -0
  18. lib_layered_config/cli/common.py +232 -0
  19. lib_layered_config/cli/constants.py +12 -0
  20. lib_layered_config/cli/deploy.py +70 -0
  21. lib_layered_config/cli/fail.py +21 -0
  22. lib_layered_config/cli/generate.py +60 -0
  23. lib_layered_config/cli/info.py +31 -0
  24. lib_layered_config/cli/read.py +117 -0
  25. lib_layered_config/core.py +384 -0
  26. lib_layered_config/domain/__init__.py +7 -0
  27. lib_layered_config/domain/config.py +490 -0
  28. lib_layered_config/domain/errors.py +65 -0
  29. lib_layered_config/examples/__init__.py +29 -0
  30. lib_layered_config/examples/deploy.py +305 -0
  31. lib_layered_config/examples/generate.py +537 -0
  32. lib_layered_config/observability.py +306 -0
  33. lib_layered_config/py.typed +0 -0
  34. lib_layered_config/testing.py +55 -0
  35. lib_layered_config-1.0.0.dist-info/METADATA +366 -0
  36. lib_layered_config-1.0.0.dist-info/RECORD +39 -0
  37. lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
  38. lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
  39. lib_layered_config-1.0.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,162 @@
1
+ """Package exposing the lib_layered_config command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Iterable, Mapping, Optional, Sequence, cast
6
+
7
+ from importlib import metadata as _metadata
8
+
9
+ from pathlib import Path
10
+
11
+ import lib_cli_exit_tools
12
+ import rich_click as click
13
+
14
+ from ..core import default_env_prefix as _default_env_prefix # backward compatibility import
15
+ from .common import (
16
+ format_scalar,
17
+ json_paths,
18
+ load_distribution_metadata,
19
+ normalise_examples_platform_option,
20
+ normalise_platform_option,
21
+ normalise_prefer,
22
+ render_human,
23
+ toggle_traceback,
24
+ version_string,
25
+ )
26
+ from .constants import CLICK_CONTEXT_SETTINGS, TRACEBACK_SUMMARY, TRACEBACK_VERBOSE
27
+ from ..application.ports import SourceInfoPayload
28
+
29
+
30
+ from .read import read_command as cli_read_config, read_json_command as cli_read_config_json
31
+
32
+
33
+ @click.group(
34
+ help="Immutable layered configuration reader",
35
+ context_settings=CLICK_CONTEXT_SETTINGS,
36
+ invoke_without_command=False,
37
+ )
38
+ @click.version_option(
39
+ version=version_string(),
40
+ prog_name="lib_layered_config",
41
+ message="lib_layered_config version %(version)s",
42
+ )
43
+ @click.option(
44
+ "--traceback/--no-traceback",
45
+ is_flag=True,
46
+ default=False,
47
+ help="Show full Python traceback on errors",
48
+ )
49
+ def cli(traceback: bool) -> None:
50
+ """Root command that remembers whether tracebacks should flow."""
51
+
52
+ toggle_traceback(traceback)
53
+
54
+
55
+ def main(argv: Optional[Sequence[str]] = None, *, restore_traceback: bool = True) -> int:
56
+ """Entry point that restores traceback preferences on exit."""
57
+
58
+ previous_traceback = getattr(lib_cli_exit_tools.config, "traceback", False)
59
+ previous_force_color = getattr(lib_cli_exit_tools.config, "traceback_force_color", False)
60
+ try:
61
+ try:
62
+ run_cli = cast(Callable[..., int], lib_cli_exit_tools.run_cli) # pyright: ignore[reportUnknownMemberType]
63
+ return run_cli(cli, argv=list(argv) if argv is not None else None, prog_name="lib_layered_config")
64
+ except BaseException as exc: # noqa: BLE001
65
+ print_exception = cast(Callable[..., None], lib_cli_exit_tools.print_exception_message) # pyright: ignore[reportUnknownMemberType]
66
+ print_exception(
67
+ trace_back=lib_cli_exit_tools.config.traceback,
68
+ length_limit=TRACEBACK_VERBOSE if lib_cli_exit_tools.config.traceback else TRACEBACK_SUMMARY,
69
+ )
70
+ exit_code_fn = cast(Callable[[BaseException], int], lib_cli_exit_tools.get_system_exit_code) # pyright: ignore[reportUnknownMemberType]
71
+ return exit_code_fn(exc)
72
+ finally:
73
+ if restore_traceback:
74
+ lib_cli_exit_tools.config.traceback = previous_traceback
75
+ lib_cli_exit_tools.config.traceback_force_color = previous_force_color
76
+
77
+
78
+ def _register_commands() -> None:
79
+ from . import deploy, fail, generate, info, read
80
+
81
+ for module in (read, deploy, generate, info, fail):
82
+ module.register(cli)
83
+
84
+
85
+ _register_commands()
86
+
87
+ metadata = _metadata
88
+ _toggle_traceback = toggle_traceback
89
+ _version_string = version_string
90
+
91
+
92
+ def _normalise_platform(value: str | None) -> str | None: # pyright: ignore[reportUnusedFunction]
93
+ return normalise_platform_option(value)
94
+
95
+
96
+ def _normalise_examples_platform(value: str | None) -> str | None: # pyright: ignore[reportUnusedFunction]
97
+ return normalise_examples_platform_option(value)
98
+
99
+
100
+ def _json_paths(paths: Iterable[Path]) -> str: # pyright: ignore[reportUnusedFunction]
101
+ return json_paths(paths)
102
+
103
+
104
+ def _render_human(data: Mapping[str, object], provenance: Mapping[str, SourceInfoPayload]) -> str: # pyright: ignore[reportUnusedFunction]
105
+ return render_human(data, provenance)
106
+
107
+
108
+ def _format_scalar(value: object) -> str: # pyright: ignore[reportUnusedFunction]
109
+ return format_scalar(value)
110
+
111
+
112
+ def _normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None: # pyright: ignore[reportUnusedFunction]
113
+ return normalise_prefer(values)
114
+
115
+
116
+ def _load_distribution_metadata() -> _metadata.PackageMetadata | None:
117
+ """Wrapper that allows tests to monkeypatch metadata loading."""
118
+
119
+ return load_distribution_metadata()
120
+
121
+
122
+ def _describe_distribution() -> tuple[str, ...]:
123
+ """Yield human-readable metadata lines about the installed distribution."""
124
+
125
+ meta = _load_distribution_metadata()
126
+ if meta is None:
127
+ return ("lib_layered_config (metadata unavailable)",)
128
+
129
+ lines = [f"Info for {meta.get('Name', 'lib_layered_config')}:"]
130
+ lines.append(f" Version : {meta.get('Version', version_string())}")
131
+ lines.append(f" Requires-Python : {meta.get('Requires-Python', '>=3.13')}")
132
+ summary = meta.get("Summary")
133
+ if summary:
134
+ lines.append(f" Summary : {summary}")
135
+
136
+ def _no_urls(_: str) -> Iterable[str] | None:
137
+ return None
138
+
139
+ get_all = cast(Callable[[str], Iterable[str] | None], getattr(meta, "get_all", _no_urls))
140
+ for entry in get_all("Project-URL") or []:
141
+ lines.append(f" {entry}")
142
+ return tuple(lines)
143
+
144
+
145
+ __all__ = [
146
+ "cli",
147
+ "main",
148
+ "_default_env_prefix",
149
+ "metadata",
150
+ "_toggle_traceback",
151
+ "_version_string",
152
+ "_describe_distribution",
153
+ "_load_distribution_metadata",
154
+ "_normalise_platform",
155
+ "_normalise_examples_platform",
156
+ "_json_paths",
157
+ "_render_human",
158
+ "_format_scalar",
159
+ "_normalise_prefer",
160
+ "cli_read_config",
161
+ "cli_read_config_json",
162
+ ]
@@ -0,0 +1,232 @@
1
+ """Utilities shared by CLI command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+ from typing import Iterable, Mapping, Optional, Sequence, cast
10
+
11
+ import lib_cli_exit_tools
12
+ import rich_click as click
13
+
14
+ from .._platform import normalise_examples_platform as _normalise_examples_platform
15
+ from .._platform import normalise_resolver_platform as _normalise_resolver_platform
16
+ from ..application.ports import SourceInfoPayload
17
+ from ..core import default_env_prefix as compute_default_env_prefix
18
+ from .constants import DEFAULT_JSON_INDENT
19
+ from ..core import read_config, read_config_json, read_config_raw
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ReadQuery:
24
+ """Immutable bundle of parameters required to execute read commands."""
25
+
26
+ vendor: str
27
+ app: str
28
+ slug: str
29
+ prefer: tuple[str, ...] | None
30
+ start_dir: str | None
31
+ default_file: str | None
32
+
33
+
34
+ def toggle_traceback(show: bool) -> None:
35
+ """Synchronise ``lib_cli_exit_tools`` traceback flags with *show*."""
36
+
37
+ lib_cli_exit_tools.config.traceback = show
38
+ lib_cli_exit_tools.config.traceback_force_color = show
39
+
40
+
41
+ def version_string() -> str:
42
+ """Return the installed distribution version or a fallback placeholder."""
43
+
44
+ try:
45
+ return metadata.version("lib_layered_config")
46
+ except metadata.PackageNotFoundError:
47
+ return "0.0.0"
48
+
49
+
50
+ def describe_distribution() -> Iterable[str]:
51
+ """Yield human-readable metadata lines about the installed distribution."""
52
+
53
+ meta = load_distribution_metadata()
54
+ if meta is None:
55
+ yield "lib_layered_config (metadata unavailable)"
56
+ return
57
+ yield f"Info for {meta.get('Name', 'lib_layered_config')}:"
58
+ yield f" Version : {meta.get('Version', version_string())}"
59
+ yield f" Requires-Python : {meta.get('Requires-Python', '>=3.13')}"
60
+ summary = meta.get("Summary")
61
+ if summary:
62
+ yield f" Summary : {summary}"
63
+ for entry in meta.get_all("Project-URL") or []:
64
+ yield f" {entry}"
65
+
66
+
67
+ def load_distribution_metadata() -> metadata.PackageMetadata | None:
68
+ """Return importlib metadata when the package is installed locally."""
69
+
70
+ try:
71
+ return metadata.metadata("lib_layered_config")
72
+ except metadata.PackageNotFoundError:
73
+ return None
74
+
75
+
76
+ def build_read_query(
77
+ vendor: str,
78
+ app: str,
79
+ slug: str,
80
+ prefer: Sequence[str],
81
+ start_dir: Optional[Path],
82
+ default_file: Optional[Path],
83
+ ) -> ReadQuery:
84
+ """Shape CLI parameters into a read query."""
85
+
86
+ return ReadQuery(
87
+ vendor=vendor,
88
+ app=app,
89
+ slug=slug,
90
+ prefer=normalise_prefer(prefer),
91
+ start_dir=stringify(start_dir),
92
+ default_file=stringify(default_file),
93
+ )
94
+
95
+
96
+ def normalise_prefer(values: Sequence[str]) -> tuple[str, ...] | None:
97
+ """Lowercase supplied extensions and strip leading dots."""
98
+
99
+ if not values:
100
+ return None
101
+ return tuple(value.lower().lstrip(".") for value in values)
102
+
103
+
104
+ def normalise_targets(values: Sequence[str]) -> tuple[str, ...]:
105
+ """Normalise deployment targets to lowercase for resolver routing."""
106
+
107
+ return tuple(value.lower() for value in values)
108
+
109
+
110
+ def normalise_platform_option(value: Optional[str]) -> Optional[str]:
111
+ """Map user-friendly platform aliases to canonical resolver identifiers."""
112
+
113
+ try:
114
+ return _normalise_resolver_platform(value)
115
+ except ValueError as exc:
116
+ raise click.BadParameter(str(exc), param_hint="--platform") from exc
117
+
118
+
119
+ def normalise_examples_platform_option(value: Optional[str]) -> Optional[str]:
120
+ """Map example-generation platform aliases to canonical values."""
121
+
122
+ try:
123
+ return _normalise_examples_platform(value)
124
+ except ValueError as exc:
125
+ raise click.BadParameter(str(exc), param_hint="--platform") from exc
126
+
127
+
128
+ def stringify(path: Optional[Path]) -> Optional[str]:
129
+ """Return stringified path or ``None`` when *path* is ``None``."""
130
+
131
+ return None if path is None else str(path)
132
+
133
+
134
+ def wants_json(output_format: str) -> bool:
135
+ """Return ``True`` when JSON output was requested."""
136
+
137
+ return output_format.strip().lower() == "json"
138
+
139
+
140
+ def resolve_indent(enabled: bool) -> int | None:
141
+ """Return default JSON indentation when *enabled* is true."""
142
+
143
+ return DEFAULT_JSON_INDENT if enabled else None
144
+
145
+
146
+ def json_payload(query: ReadQuery, indent: int | None, include_provenance: bool) -> str:
147
+ """Build JSON payload for a query."""
148
+
149
+ if include_provenance:
150
+ return read_config_json(
151
+ vendor=query.vendor,
152
+ app=query.app,
153
+ slug=query.slug,
154
+ prefer=query.prefer,
155
+ start_dir=query.start_dir,
156
+ default_file=query.default_file,
157
+ indent=indent,
158
+ )
159
+ config = read_config(
160
+ vendor=query.vendor,
161
+ app=query.app,
162
+ slug=query.slug,
163
+ prefer=query.prefer,
164
+ start_dir=query.start_dir,
165
+ default_file=query.default_file,
166
+ )
167
+ return config.to_json(indent=indent)
168
+
169
+
170
+ def render_human(data: Mapping[str, object], provenance: Mapping[str, SourceInfoPayload]) -> str:
171
+ """Return a human-readable description of config values and provenance."""
172
+
173
+ entries = list(iter_leaf_items(data))
174
+ if not entries:
175
+ return "No configuration values were found."
176
+
177
+ lines: list[str] = []
178
+ for dotted, value in entries:
179
+ lines.append(f"{dotted}: {format_scalar(value)}")
180
+ info = provenance.get(dotted)
181
+ if info:
182
+ path = info["path"] or "(memory)"
183
+ lines.append(f" provenance: layer={info['layer']}, path={path}")
184
+ return "\n".join(lines)
185
+
186
+
187
+ def iter_leaf_items(mapping: Mapping[str, object], prefix: tuple[str, ...] = ()) -> Iterable[tuple[str, object]]:
188
+ """Yield dotted paths and values for every leaf entry in *mapping*."""
189
+
190
+ for key, value in mapping.items():
191
+ dotted = ".".join((*prefix, key))
192
+ if isinstance(value, Mapping):
193
+ nested = cast(Mapping[str, object], value)
194
+ yield from iter_leaf_items(nested, (*prefix, key))
195
+ else:
196
+ yield dotted, value
197
+
198
+
199
+ def format_scalar(value: object) -> str:
200
+ """Return string representation used in human output for *value*."""
201
+
202
+ if isinstance(value, bool):
203
+ return "true" if value else "false"
204
+ if value is None:
205
+ return "null"
206
+ return str(value)
207
+
208
+
209
+ def json_paths(paths: Iterable[Path]) -> str:
210
+ """Return JSON array of stringified paths written by helper commands."""
211
+
212
+ return json.dumps([str(path) for path in paths], indent=2)
213
+
214
+
215
+ def human_payload(query: ReadQuery) -> str:
216
+ """Return prose describing config values and provenance."""
217
+
218
+ data, meta = read_config_raw(
219
+ vendor=query.vendor,
220
+ app=query.app,
221
+ slug=query.slug,
222
+ prefer=query.prefer,
223
+ start_dir=query.start_dir,
224
+ default_file=query.default_file,
225
+ )
226
+ return render_human(data, meta)
227
+
228
+
229
+ def default_env_prefix(slug: str) -> str:
230
+ """Expose the canonical environment prefix for CLI/commands."""
231
+
232
+ return compute_default_env_prefix(slug)
@@ -0,0 +1,12 @@
1
+ """Shared CLI constants used across command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ CLICK_CONTEXT_SETTINGS: Final[dict[str, tuple[str, str]]] = {"help_option_names": ("-h", "--help")}
8
+ TRACEBACK_SUMMARY: Final[int] = 500
9
+ TRACEBACK_VERBOSE: Final[int] = 10_000
10
+ TARGET_CHOICES: Final[tuple[str, ...]] = ("app", "host", "user")
11
+ EXAMPLE_PLATFORM_CHOICES: Final[tuple[str, ...]] = ("posix", "windows")
12
+ DEFAULT_JSON_INDENT: Final[int] = 2
@@ -0,0 +1,70 @@
1
+ """CLI command for deploying configuration files into layer directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Sequence
7
+
8
+ import rich_click as click
9
+
10
+ from ..examples import deploy_config as deploy_config_impl
11
+ from .common import json_paths, normalise_platform_option, normalise_targets
12
+ from .constants import CLICK_CONTEXT_SETTINGS, TARGET_CHOICES
13
+
14
+
15
+ @click.command("deploy", context_settings=CLICK_CONTEXT_SETTINGS)
16
+ @click.option(
17
+ "--source",
18
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
19
+ required=True,
20
+ help="Path to the configuration file that should be copied",
21
+ )
22
+ @click.option("--vendor", required=True, help="Vendor namespace")
23
+ @click.option("--app", required=True, help="Application name")
24
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
25
+ @click.option(
26
+ "--target",
27
+ "targets",
28
+ multiple=True,
29
+ required=True,
30
+ type=click.Choice(TARGET_CHOICES, case_sensitive=False),
31
+ help="Layer targets to deploy to (repeatable)",
32
+ )
33
+ @click.option(
34
+ "--platform",
35
+ default=None,
36
+ help="Override auto-detected platform (linux, darwin, windows)",
37
+ )
38
+ @click.option(
39
+ "--force/--no-force",
40
+ default=False,
41
+ show_default=True,
42
+ help="Overwrite existing files at the destination",
43
+ )
44
+ def deploy_command(
45
+ source: Path,
46
+ vendor: str,
47
+ app: str,
48
+ slug: str,
49
+ targets: Sequence[str],
50
+ platform: Optional[str],
51
+ force: bool,
52
+ ) -> None:
53
+ """Copy a source file into the requested layered directories."""
54
+
55
+ created = deploy_config_impl(
56
+ source,
57
+ vendor=vendor,
58
+ app=app,
59
+ slug=slug,
60
+ targets=normalise_targets(targets),
61
+ platform=normalise_platform_option(platform),
62
+ force=force,
63
+ )
64
+ click.echo(json_paths(created))
65
+
66
+
67
+ def register(cli_group: click.Group) -> None:
68
+ """Register the deploy command with the root CLI group."""
69
+
70
+ cli_group.add_command(deploy_command)
@@ -0,0 +1,21 @@
1
+ """Debugging helpers exposed via the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rich_click as click
6
+
7
+ from ..testing import i_should_fail
8
+ from .constants import CLICK_CONTEXT_SETTINGS
9
+
10
+
11
+ @click.command("fail", context_settings=CLICK_CONTEXT_SETTINGS)
12
+ def fail_command() -> None:
13
+ """Intentionally raise a runtime error for test harnesses."""
14
+
15
+ i_should_fail()
16
+
17
+
18
+ def register(cli_group: click.Group) -> None:
19
+ """Register the fail command with the root CLI group."""
20
+
21
+ cli_group.add_command(fail_command)
@@ -0,0 +1,60 @@
1
+ """CLI command for generating example configuration trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import rich_click as click
9
+
10
+ from ..examples import generate_examples as generate_examples_impl
11
+ from .common import json_paths, normalise_examples_platform_option
12
+ from .constants import CLICK_CONTEXT_SETTINGS
13
+
14
+
15
+ @click.command("generate-examples", context_settings=CLICK_CONTEXT_SETTINGS)
16
+ @click.option(
17
+ "--destination",
18
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True, resolve_path=True),
19
+ required=True,
20
+ help="Directory that will receive the example tree",
21
+ )
22
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
23
+ @click.option("--vendor", required=True, help="Vendor namespace")
24
+ @click.option("--app", required=True, help="Application name")
25
+ @click.option(
26
+ "--platform",
27
+ default=None,
28
+ help="Override platform layout (posix/windows)",
29
+ )
30
+ @click.option(
31
+ "--force/--no-force",
32
+ default=False,
33
+ show_default=True,
34
+ help="Overwrite existing example files",
35
+ )
36
+ def generate_examples_command(
37
+ destination: Path,
38
+ slug: str,
39
+ vendor: str,
40
+ app: str,
41
+ platform: Optional[str],
42
+ force: bool,
43
+ ) -> None:
44
+ """Create reference example trees for documentation or onboarding."""
45
+
46
+ created = generate_examples_impl(
47
+ destination,
48
+ slug=slug,
49
+ vendor=vendor,
50
+ app=app,
51
+ force=force,
52
+ platform=normalise_examples_platform_option(platform),
53
+ )
54
+ click.echo(json_paths(created))
55
+
56
+
57
+ def register(cli_group: click.Group) -> None:
58
+ """Register the generate-examples command."""
59
+
60
+ cli_group.add_command(generate_examples_command)
@@ -0,0 +1,31 @@
1
+ """Metadata-related CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rich_click as click
6
+
7
+ from .common import default_env_prefix, describe_distribution
8
+ from .constants import CLICK_CONTEXT_SETTINGS
9
+
10
+
11
+ @click.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
12
+ def info_command() -> None:
13
+ """Print package metadata in friendly lines."""
14
+
15
+ for line in describe_distribution():
16
+ click.echo(line)
17
+
18
+
19
+ @click.command("env-prefix", context_settings=CLICK_CONTEXT_SETTINGS)
20
+ @click.argument("slug")
21
+ def env_prefix_command(slug: str) -> None:
22
+ """Echo the canonical environment variable prefix for a slug."""
23
+
24
+ click.echo(default_env_prefix(slug))
25
+
26
+
27
+ def register(cli_group: click.Group) -> None:
28
+ """Register metadata commands with the root CLI group."""
29
+
30
+ cli_group.add_command(info_command)
31
+ cli_group.add_command(env_prefix_command)