shellscriptor 0.1.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.
- shellscriptor/__init__.py +23 -0
- shellscriptor/__main__.py +142 -0
- shellscriptor/_argparse.py +213 -0
- shellscriptor/_code.py +76 -0
- shellscriptor/_log.py +125 -0
- shellscriptor/_output.py +72 -0
- shellscriptor/_script.py +268 -0
- shellscriptor/_types.py +271 -0
- shellscriptor/_validate.py +413 -0
- shellscriptor/py.typed +0 -0
- shellscriptor-0.1.0.dist-info/METADATA +761 -0
- shellscriptor-0.1.0.dist-info/RECORD +15 -0
- shellscriptor-0.1.0.dist-info/WHEEL +5 -0
- shellscriptor-0.1.0.dist-info/entry_points.txt +2 -0
- shellscriptor-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from shellscriptor._code import indent, sanitize_code
|
|
3
|
+
from shellscriptor._log import log, log_arg_read, log_arg_write, log_endpoint
|
|
4
|
+
from shellscriptor._output import create_output
|
|
5
|
+
from shellscriptor._script import create_function, create_script
|
|
6
|
+
from shellscriptor._types import (
|
|
7
|
+
BodySection, FunctionDef, IntegerRangeDef, LogLevel,
|
|
8
|
+
ParameterDef, ParamType, PathExistenceDef, PathType,
|
|
9
|
+
ReturnDef, ScriptDef, ScriptType, ValidationDef,
|
|
10
|
+
)
|
|
11
|
+
from shellscriptor._validate import (
|
|
12
|
+
create_validation_block, validate_enum, validate_integer_range,
|
|
13
|
+
validate_missing_arg, validate_path_existence, validate_regex, validate_variable,
|
|
14
|
+
)
|
|
15
|
+
__all__ = [
|
|
16
|
+
"create_script", "create_function", "create_validation_block",
|
|
17
|
+
"validate_variable", "validate_missing_arg", "validate_enum",
|
|
18
|
+
"validate_path_existence", "validate_integer_range", "validate_regex",
|
|
19
|
+
"create_output", "log", "log_endpoint", "log_arg_read", "log_arg_write",
|
|
20
|
+
"indent", "sanitize_code", "ScriptType", "ParamType", "PathType",
|
|
21
|
+
"LogLevel", "ScriptDef", "FunctionDef", "ParameterDef", "ValidationDef",
|
|
22
|
+
"ReturnDef", "BodySection", "PathExistenceDef", "IntegerRangeDef",
|
|
23
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command-line interface for shellscriptor.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
shellscriptor <definition-file> [--output <path>] [--type <shell_src|shell_exec>]
|
|
6
|
+
|
|
7
|
+
*definition-file* must be a YAML or JSON file whose top level is either:
|
|
8
|
+
|
|
9
|
+
* A **script definition** dict (containing ``"body"``, ``"parameter"``, etc.) —
|
|
10
|
+
in this case ``--type`` and ``--name`` must also be supplied, or
|
|
11
|
+
* A wrapper object with keys:
|
|
12
|
+
|
|
13
|
+
.. code-block:: yaml
|
|
14
|
+
|
|
15
|
+
name: my-script
|
|
16
|
+
type: shell_exec # or shell_src
|
|
17
|
+
script: # ScriptDef
|
|
18
|
+
interpreter: /bin/bash
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
--------
|
|
23
|
+
shellscriptor my_script.yaml
|
|
24
|
+
shellscriptor my_script.json --output dist/my_script.sh
|
|
25
|
+
python -m shellscriptor my_script.yaml --type shell_exec --name deploy
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_definition(path: Path) -> dict:
|
|
36
|
+
"""Load a YAML or JSON definition file."""
|
|
37
|
+
suffix = path.suffix.lower()
|
|
38
|
+
text = path.read_text(encoding="utf-8")
|
|
39
|
+
if suffix in (".yaml", ".yml"):
|
|
40
|
+
try:
|
|
41
|
+
import yaml # type: ignore[import-untyped]
|
|
42
|
+
except ImportError as exc:
|
|
43
|
+
print(
|
|
44
|
+
"PyYAML is required to read YAML files: pip install pyyaml",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
raise SystemExit(1) from exc
|
|
48
|
+
return yaml.safe_load(text)
|
|
49
|
+
if suffix == ".json":
|
|
50
|
+
return json.loads(text)
|
|
51
|
+
# Attempt YAML then JSON as fallback
|
|
52
|
+
try:
|
|
53
|
+
import yaml # type: ignore[import-untyped]
|
|
54
|
+
return yaml.safe_load(text)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return json.loads(text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
"""Entry point for the ``shellscriptor`` command."""
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="shellscriptor",
|
|
64
|
+
description="Generate a shell script from a YAML/JSON definition file.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"definition",
|
|
68
|
+
metavar="DEFINITION_FILE",
|
|
69
|
+
type=Path,
|
|
70
|
+
help="Path to a YAML or JSON script definition file.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--output",
|
|
74
|
+
"-o",
|
|
75
|
+
metavar="PATH",
|
|
76
|
+
type=Path,
|
|
77
|
+
default=None,
|
|
78
|
+
help="Write generated script to this file (default: stdout).",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--type",
|
|
82
|
+
"-t",
|
|
83
|
+
dest="script_type",
|
|
84
|
+
choices=["shell_src", "shell_exec"],
|
|
85
|
+
default=None,
|
|
86
|
+
help=(
|
|
87
|
+
"Script type override. Required when the definition file does not "
|
|
88
|
+
"contain a top-level 'type' key."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--name",
|
|
93
|
+
"-n",
|
|
94
|
+
dest="name",
|
|
95
|
+
default=None,
|
|
96
|
+
help=(
|
|
97
|
+
"Script name used in log messages. Defaults to the definition "
|
|
98
|
+
"file stem."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
|
|
103
|
+
definition_path: Path = args.definition
|
|
104
|
+
if not definition_path.exists():
|
|
105
|
+
print(f"Error: file not found: {definition_path}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
raw = _load_definition(definition_path)
|
|
109
|
+
|
|
110
|
+
# Support both wrapper format and bare ScriptDef format
|
|
111
|
+
if "script" in raw:
|
|
112
|
+
script_def = raw["script"]
|
|
113
|
+
script_type = args.script_type or raw.get("type")
|
|
114
|
+
name = args.name or raw.get("name") or definition_path.stem
|
|
115
|
+
else:
|
|
116
|
+
script_def = raw
|
|
117
|
+
script_type = args.script_type or raw.get("type")
|
|
118
|
+
name = args.name or definition_path.stem
|
|
119
|
+
|
|
120
|
+
if script_type not in ("shell_src", "shell_exec"):
|
|
121
|
+
print(
|
|
122
|
+
f"Error: script type must be 'shell_src' or 'shell_exec'; "
|
|
123
|
+
f"got {script_type!r}. Pass --type to override.",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
from shellscriptor import create_script
|
|
129
|
+
|
|
130
|
+
result = create_script(name=name, data=script_def, script_type=script_type)
|
|
131
|
+
|
|
132
|
+
if args.output:
|
|
133
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
args.output.write_text(result + "\n", encoding="utf-8")
|
|
135
|
+
else:
|
|
136
|
+
print(result)
|
|
137
|
+
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from shellscriptor._code import indent
|
|
4
|
+
from shellscriptor._log import log, log_arg_read
|
|
5
|
+
from shellscriptor._types import ParameterDef, ScriptType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def param_name_to_var_name(param_name: str, *, local: bool) -> str:
|
|
9
|
+
"""Convert a parameter name to its shell variable name.
|
|
10
|
+
|
|
11
|
+
Hyphens are replaced with underscores. Global (non-local) variables are
|
|
12
|
+
uppercased, matching conventional shell style.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
param_name:
|
|
17
|
+
The parameter name as given in the definition dict.
|
|
18
|
+
local:
|
|
19
|
+
``True`` for function-local variables (lowercase preserved);
|
|
20
|
+
``False`` for script-global variables (uppercased).
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
The derived shell variable name.
|
|
25
|
+
"""
|
|
26
|
+
var_name = param_name.replace("-", "_")
|
|
27
|
+
return var_name if local else var_name.upper()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_argparse(
|
|
31
|
+
parameters: dict[str, ParameterDef],
|
|
32
|
+
*,
|
|
33
|
+
local: bool,
|
|
34
|
+
script_type: ScriptType,
|
|
35
|
+
with_help: bool = False,
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Generate a ``while/case`` argument-parsing block.
|
|
38
|
+
|
|
39
|
+
Produces variable initialisation lines followed by a ``while [[ $# -gt 0
|
|
40
|
+
]]; do … done`` loop that maps ``--flag`` arguments to shell variables.
|
|
41
|
+
|
|
42
|
+
* Boolean flags are set to ``true`` when present and require no value.
|
|
43
|
+
* String/integer flags consume the next positional argument (``$1``
|
|
44
|
+
after the dispatch ``shift``).
|
|
45
|
+
* Array flags accumulate all following non-``--`` arguments.
|
|
46
|
+
|
|
47
|
+
Short aliases (single-char ``short`` field) generate an additional
|
|
48
|
+
``case`` arm alongside the long form.
|
|
49
|
+
|
|
50
|
+
If *with_help* is ``True``, a ``--help``/``-h`` arm is added that calls
|
|
51
|
+
the caller-supplied ``__usage__`` function.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
parameters:
|
|
56
|
+
Mapping of parameter name → :class:`~shellscriptor._types.ParameterDef`.
|
|
57
|
+
local:
|
|
58
|
+
Whether to declare variables as ``local``.
|
|
59
|
+
script_type:
|
|
60
|
+
Controls whether read-logging calls are emitted.
|
|
61
|
+
with_help:
|
|
62
|
+
Emit a ``--help|-h) __usage__;;`` arm when ``True``. The caller is
|
|
63
|
+
responsible for ensuring ``__usage__`` is defined before the loop.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
List of shell lines (variable declarations + parsing loop).
|
|
68
|
+
"""
|
|
69
|
+
if not parameters:
|
|
70
|
+
return []
|
|
71
|
+
def_lines: list[str] = []
|
|
72
|
+
case_lines: list[str] = [
|
|
73
|
+
"while [[ $# -gt 0 ]]; do",
|
|
74
|
+
indent("case $1 in", 1),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
for param_name, param_data in sorted(parameters.items()):
|
|
78
|
+
var_name = param_name_to_var_name(param_name, local=local)
|
|
79
|
+
param_type = param_data.type
|
|
80
|
+
short = param_data.short
|
|
81
|
+
|
|
82
|
+
scalar_log = (
|
|
83
|
+
"" if script_type == "shell_src"
|
|
84
|
+
else f" {log_arg_read(param_name, var_name)};"
|
|
85
|
+
)
|
|
86
|
+
array_log = (
|
|
87
|
+
"" if script_type == "shell_src"
|
|
88
|
+
else f" {log_arg_read(param_name, '1')};"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if param_type == "boolean":
|
|
92
|
+
initial_value = '""'
|
|
93
|
+
argparse_cmd = f"{var_name}=true;{scalar_log}"
|
|
94
|
+
elif param_type in ("string", "integer"):
|
|
95
|
+
initial_value = '""'
|
|
96
|
+
# After the dispatch `shift` the flag is consumed, so $1 is now the value;
|
|
97
|
+
# a second shift at the end of argparse_cmd consumes it.
|
|
98
|
+
argparse_cmd = f'{var_name}="$1";{scalar_log} shift'
|
|
99
|
+
elif param_type == "array":
|
|
100
|
+
initial_value = "()"
|
|
101
|
+
argparse_cmd = (
|
|
102
|
+
f'while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do '
|
|
103
|
+
f'{var_name}+=("$1");{array_log} shift; done'
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"Unsupported parameter type: {param_type!r}")
|
|
107
|
+
|
|
108
|
+
local_prefix = "local " if local else ""
|
|
109
|
+
def_lines.append(f"{local_prefix}{var_name}={initial_value}")
|
|
110
|
+
|
|
111
|
+
# Build arms: long form, and optionally short form
|
|
112
|
+
long_arm = f"--{param_name}"
|
|
113
|
+
short_arm = f"-{short}" if short else None
|
|
114
|
+
flag_pattern = f"{long_arm}|{short_arm}" if short_arm else long_arm
|
|
115
|
+
|
|
116
|
+
case_lines.append(
|
|
117
|
+
indent(
|
|
118
|
+
f"{flag_pattern}) shift; {argparse_cmd.removesuffix(';')};;",
|
|
119
|
+
2,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Unknown / unexpected catch-alls
|
|
124
|
+
if with_help:
|
|
125
|
+
case_lines.append(indent("--help|-h) __usage__;;", 2))
|
|
126
|
+
_unknown_opt_log = log("Unknown option: '${1}'", "critical")
|
|
127
|
+
_unexpected_arg_log = log("Unexpected argument: '${1}'", "critical")
|
|
128
|
+
case_lines.extend(
|
|
129
|
+
[
|
|
130
|
+
indent(f"--*) {_unknown_opt_log};;", 2),
|
|
131
|
+
indent(f"*) {_unexpected_arg_log};;", 2),
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
case_lines.extend([indent("esac", 1), "done"])
|
|
135
|
+
|
|
136
|
+
return def_lines + case_lines
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_usage_function(parameters: dict[str, ParameterDef]) -> list[str]:
|
|
140
|
+
"""Generate a ``__usage__`` function that prints a help summary to stderr.
|
|
141
|
+
|
|
142
|
+
Only emitted when at least one parameter has a ``"description"`` field.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
parameters:
|
|
147
|
+
Mapping of parameter name → :class:`~shellscriptor._types.ParameterDef`.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
Lines defining the ``__usage__`` shell function, or an empty list.
|
|
152
|
+
"""
|
|
153
|
+
if not any(p.description for p in parameters.values()):
|
|
154
|
+
return []
|
|
155
|
+
lines: list[str] = ["__usage__() {", indent('echo "Usage:" >&2', 1)]
|
|
156
|
+
for param_name, param_data in sorted(parameters.items()):
|
|
157
|
+
desc = param_data.description
|
|
158
|
+
param_type = param_data.type
|
|
159
|
+
short = param_data.short
|
|
160
|
+
flag = f"--{param_name}"
|
|
161
|
+
if short:
|
|
162
|
+
flag = f"-{short}, {flag}"
|
|
163
|
+
lines.append(indent(f'echo " {flag} ({param_type}): {desc}" >&2', 1))
|
|
164
|
+
lines.extend([indent("exit 0", 1), "}"])
|
|
165
|
+
return lines
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_env_var_parse(parameters: dict[str, ParameterDef]) -> list[str]:
|
|
169
|
+
"""Generate a block that reads parameter values from environment variables.
|
|
170
|
+
|
|
171
|
+
Used in ``shell_exec`` scripts when they are called with no arguments —
|
|
172
|
+
the script falls back to reading exported environment variables.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
parameters:
|
|
177
|
+
Mapping of parameter name → :class:`~shellscriptor._types.ParameterDef`.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
List of shell lines.
|
|
182
|
+
"""
|
|
183
|
+
lines: list[str] = []
|
|
184
|
+
for param_name, param_data in sorted(parameters.items()):
|
|
185
|
+
var_name = param_name_to_var_name(param_name, local=False)
|
|
186
|
+
param_type = param_data.type
|
|
187
|
+
if param_type == "array":
|
|
188
|
+
delimiter = param_data.array_delimiter
|
|
189
|
+
lines.extend(
|
|
190
|
+
[
|
|
191
|
+
f'if [ "${{{var_name}+defined}}" ]; then',
|
|
192
|
+
indent(
|
|
193
|
+
log(f"Parse '{param_name}' into array: '${{{var_name}}}'", "info"),
|
|
194
|
+
1,
|
|
195
|
+
),
|
|
196
|
+
indent(
|
|
197
|
+
f'IFS="{delimiter}" read -r -a _tmp_array <<< "${{{var_name}}}"',
|
|
198
|
+
1,
|
|
199
|
+
),
|
|
200
|
+
indent(f'{var_name}=("${{_tmp_array[@]}}")', 1),
|
|
201
|
+
indent(f'for _item in "${{{var_name}[@]}}"; do', 1),
|
|
202
|
+
indent(log_arg_read(param_name, "_item"), 2),
|
|
203
|
+
indent("done", 1),
|
|
204
|
+
indent("unset _item", 1),
|
|
205
|
+
indent("unset _tmp_array", 1),
|
|
206
|
+
"fi",
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
lines.append(
|
|
211
|
+
f'[ "${{{var_name}+defined}}" ] && {log_arg_read(param_name, var_name)}'
|
|
212
|
+
)
|
|
213
|
+
return lines
|
shellscriptor/_code.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, overload
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@overload
|
|
10
|
+
def indent(code: str, level: int) -> str: ...
|
|
11
|
+
@overload
|
|
12
|
+
def indent(code: list[str], level: int) -> list[str]: ...
|
|
13
|
+
def indent(code: str | list[str], level: int = 0) -> str | list[str]:
|
|
14
|
+
"""Indent each line of shell code by *level* steps (2 spaces each).
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
code:
|
|
19
|
+
A single string or a list of lines to indent.
|
|
20
|
+
level:
|
|
21
|
+
Number of indentation levels. Each level adds 2 spaces.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
The same type as *code* was passed in, with indentation applied.
|
|
26
|
+
"""
|
|
27
|
+
if not level:
|
|
28
|
+
return code
|
|
29
|
+
prefix = " " * level * 2
|
|
30
|
+
input_is_str = isinstance(code, str)
|
|
31
|
+
lines = code.splitlines() if input_is_str else code
|
|
32
|
+
indented = [f"{prefix}{line}" for line in lines]
|
|
33
|
+
return "\n".join(indented) if input_is_str else indented
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sanitize_code(
|
|
37
|
+
code: str | list[str],
|
|
38
|
+
remove_comments: bool = True,
|
|
39
|
+
remove_empty_lines: bool = True,
|
|
40
|
+
remove_trailing_whitespace: bool = True,
|
|
41
|
+
indent_level: int = 0,
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""Sanitize shell code by stripping noise and optionally indenting.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
code:
|
|
48
|
+
Shell code as a single string or list of lines.
|
|
49
|
+
remove_comments:
|
|
50
|
+
Drop lines whose first non-whitespace character is ``#``.
|
|
51
|
+
remove_empty_lines:
|
|
52
|
+
Drop blank (or whitespace-only) lines.
|
|
53
|
+
remove_trailing_whitespace:
|
|
54
|
+
Strip trailing whitespace from each line.
|
|
55
|
+
indent_level:
|
|
56
|
+
Indentation levels to add to each surviving line.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
List of sanitized lines.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(code, str):
|
|
63
|
+
code = code.splitlines()
|
|
64
|
+
prefix = " " * indent_level * 2
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for line in code or []:
|
|
67
|
+
if remove_comments and line.lstrip().startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
if remove_empty_lines and not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
if remove_trailing_whitespace:
|
|
72
|
+
line = line.rstrip()
|
|
73
|
+
if indent_level:
|
|
74
|
+
line = f"{prefix}{line}"
|
|
75
|
+
out.append(line)
|
|
76
|
+
return out
|
shellscriptor/_log.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from shellscriptor._code import indent
|
|
6
|
+
from shellscriptor._types import LogLevel, ScriptType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_EMOJI: dict[LogLevel, str] = {
|
|
10
|
+
"info": "ℹ️",
|
|
11
|
+
"warn": "⚠️",
|
|
12
|
+
"error": "❌",
|
|
13
|
+
"critical": "⛔",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log(
|
|
18
|
+
msg: str,
|
|
19
|
+
level: LogLevel | None = None,
|
|
20
|
+
code: int = 1,
|
|
21
|
+
indent_level: int = 0,
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Generate a shell command that logs *msg* to stderr and optionally exits.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
msg:
|
|
28
|
+
Message text. May contain shell variable references (e.g. ``$VAR``).
|
|
29
|
+
level:
|
|
30
|
+
Severity. ``"error"`` appends ``return <code>``; ``"critical"``
|
|
31
|
+
appends ``exit <code>``; other levels only print.
|
|
32
|
+
code:
|
|
33
|
+
Exit / return code appended when *level* is ``"error"`` or
|
|
34
|
+
``"critical"``.
|
|
35
|
+
indent_level:
|
|
36
|
+
Number of indentation levels (2 spaces each) to prepend.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
A single-line shell command string.
|
|
41
|
+
|
|
42
|
+
Examples
|
|
43
|
+
--------
|
|
44
|
+
>>> log("Done.", level="info")
|
|
45
|
+
'echo "ℹ️ Done." >&2'
|
|
46
|
+
>>> log("Missing arg.", level="critical")
|
|
47
|
+
'echo "⛔ Missing arg." >&2; exit 1'
|
|
48
|
+
"""
|
|
49
|
+
prefix = f"{_EMOJI[level]} " if level else ""
|
|
50
|
+
echo = f'echo "{prefix}{msg}" >&2'
|
|
51
|
+
if level == "error":
|
|
52
|
+
cmd = f"{echo}; return {code}"
|
|
53
|
+
elif level == "critical":
|
|
54
|
+
cmd = f"{echo}; exit {code}"
|
|
55
|
+
else:
|
|
56
|
+
cmd = echo
|
|
57
|
+
return indent(cmd, indent_level)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_endpoint(
|
|
61
|
+
name: str,
|
|
62
|
+
typ: Literal["function", "script"],
|
|
63
|
+
stage: Literal["entry", "exit"],
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Generate a log line marking entry into or exit from a function/script.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
name:
|
|
70
|
+
Name of the function or script.
|
|
71
|
+
typ:
|
|
72
|
+
``"function"`` or ``"script"``.
|
|
73
|
+
stage:
|
|
74
|
+
``"entry"`` or ``"exit"``.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
A shell ``echo`` command string.
|
|
79
|
+
"""
|
|
80
|
+
emoji = {"entry": "↪️", "exit": "↩️"}
|
|
81
|
+
return log(f"{emoji[stage]} {typ.capitalize()} {stage}: {name}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def log_arg_read(param_name: str, var_name: str) -> str:
|
|
85
|
+
"""Generate a log line announcing that argument *param_name* was read.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
param_name:
|
|
90
|
+
Logical parameter name (used in the message text).
|
|
91
|
+
var_name:
|
|
92
|
+
Shell variable name whose value will be interpolated.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
A shell ``echo`` command string.
|
|
97
|
+
|
|
98
|
+
Examples
|
|
99
|
+
--------
|
|
100
|
+
>>> log_arg_read("my-param", "MY_PARAM")
|
|
101
|
+
"echo \"📩 Read argument 'my-param': '${MY_PARAM}'\" >&2"
|
|
102
|
+
"""
|
|
103
|
+
return log(f"📩 Read argument '{param_name}': '${{{var_name}}}'")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log_arg_write(param_name: str, var_name: str) -> str:
|
|
107
|
+
"""Generate a log line announcing that output *param_name* was written.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
param_name:
|
|
112
|
+
Logical output name (used in the message text).
|
|
113
|
+
var_name:
|
|
114
|
+
Shell variable name whose value will be interpolated.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
A shell ``echo`` command string.
|
|
119
|
+
|
|
120
|
+
Examples
|
|
121
|
+
--------
|
|
122
|
+
>>> log_arg_write("result", "RESULT")
|
|
123
|
+
"echo \"📤 Write output 'result': '${RESULT}'\" >&2"
|
|
124
|
+
"""
|
|
125
|
+
return log(f"📤 Write output '{param_name}': '${{{var_name}}}'")
|
shellscriptor/_output.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from shellscriptor._code import indent
|
|
4
|
+
from shellscriptor._log import log_arg_write
|
|
5
|
+
from shellscriptor._types import ReturnDef, ScriptType
|
|
6
|
+
|
|
7
|
+
_TOP_SEP = "\\0"
|
|
8
|
+
_SUB_SEP = "\\x1F"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_output(
|
|
12
|
+
returns: list[ReturnDef] | None,
|
|
13
|
+
script_type: ScriptType,
|
|
14
|
+
) -> list[str]:
|
|
15
|
+
"""Generate shell code that writes return values to stdout.
|
|
16
|
+
|
|
17
|
+
Encoding scheme (consistent with ``mapfile``/``read`` callers):
|
|
18
|
+
|
|
19
|
+
* **Single scalar** — ``echo "$VAR"``; caller uses ``val=$(fn)``.
|
|
20
|
+
* **Single array** — ``printf '%s\\0' "${VAR[@]}"``; caller uses
|
|
21
|
+
``mapfile -d '' -t arr < <(fn)``.
|
|
22
|
+
* **Multiple values** — each value is separated by ``\\0`` at the top
|
|
23
|
+
level. Array values within a multi-return are internally delimited
|
|
24
|
+
by ``\\x1F`` so the caller can split them after unpacking.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
returns:
|
|
29
|
+
List of :class:`~shellscriptor._types.ReturnDef` dicts, or ``None``.
|
|
30
|
+
script_type:
|
|
31
|
+
Controls whether write-logging calls are emitted.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
List of shell lines.
|
|
36
|
+
"""
|
|
37
|
+
if not returns:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
def _output_array(label: str, var: str, sep: str) -> list[str]:
|
|
41
|
+
lines = [f'for elem in "${{{var}[@]}}"; do']
|
|
42
|
+
if script_type == "shell_exec":
|
|
43
|
+
lines.append(indent(log_arg_write(label, var), 1))
|
|
44
|
+
lines.extend([indent(f"printf '%s{sep}' \"$elem\"", 1), "done"])
|
|
45
|
+
return lines
|
|
46
|
+
|
|
47
|
+
lines: list[str] = []
|
|
48
|
+
n = len(returns)
|
|
49
|
+
|
|
50
|
+
if n == 1:
|
|
51
|
+
ret = returns[0]
|
|
52
|
+
rtype, rvar, rlabel = ret.type, ret.variable, ret.name
|
|
53
|
+
if rtype != "array":
|
|
54
|
+
if script_type == "shell_exec":
|
|
55
|
+
lines.append(log_arg_write(rlabel, rvar))
|
|
56
|
+
lines.append(f'echo "${{{rvar}}}"')
|
|
57
|
+
else:
|
|
58
|
+
lines.extend(_output_array(rlabel, rvar, _TOP_SEP))
|
|
59
|
+
return lines
|
|
60
|
+
|
|
61
|
+
# Multiple returns: top-level NUL, arrays use SUB_SEP internally
|
|
62
|
+
for ret in returns:
|
|
63
|
+
rtype, rvar, rlabel = ret.type, ret.variable, ret.name
|
|
64
|
+
if rtype == "array":
|
|
65
|
+
lines.extend(_output_array(rlabel, rvar, _SUB_SEP))
|
|
66
|
+
lines.append(f"printf '{_TOP_SEP}'")
|
|
67
|
+
else:
|
|
68
|
+
if script_type == "shell_exec":
|
|
69
|
+
lines.append(log_arg_write(rlabel, rvar))
|
|
70
|
+
lines.append(f"printf '%s{_TOP_SEP}' \"${{{rvar}}}\"")
|
|
71
|
+
|
|
72
|
+
return lines
|