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.
@@ -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}}}'")
@@ -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