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,268 @@
1
+ from __future__ import annotations
2
+
3
+ from shellscriptor._argparse import (
4
+ create_argparse,
5
+ create_env_var_parse,
6
+ create_usage_function,
7
+ )
8
+ from shellscriptor._code import indent, sanitize_code
9
+ from shellscriptor._log import log, log_endpoint
10
+ from shellscriptor._output import create_output
11
+ from shellscriptor._types import FunctionDef, ScriptDef, ScriptType
12
+ from shellscriptor._validate import create_validation_block
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Cleanup function augmentation (internal)
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def _augment_cleanup_function(data: FunctionDef | None) -> FunctionDef:
21
+ """Return a copy of *data* with logging/teardown lines appended to the body.
22
+
23
+ Only called for ``shell_exec`` scripts to ensure the ``__cleanup__``
24
+ trap handler flushes the captured log to ``$LOGFILE`` if set.
25
+
26
+ Parameters
27
+ ----------
28
+ data:
29
+ Existing function definition, or ``None`` for a new one.
30
+
31
+ Returns
32
+ -------
33
+ A new ``FunctionDef`` (never mutates *data*).
34
+ """
35
+ body_lines = list(data.body) if data else []
36
+ log_cleanup_lines = [
37
+ 'if [ -n "${LOGFILE-}" ]; then',
38
+ indent("exec 1>&3 2>&4", 1), # restore original stdout/stderr
39
+ indent("wait 2>/dev/null", 1), # let tee flush
40
+ indent(log("Write logs to file '$LOGFILE'", "info"), 1),
41
+ indent('mkdir -p "$(dirname "$LOGFILE")"', 1),
42
+ indent('cat "$_LOGFILE_TMP" >> "$LOGFILE"', 1),
43
+ indent('rm -f "$_LOGFILE_TMP"', 1),
44
+ "fi",
45
+ ]
46
+ body_lines.extend(log_cleanup_lines)
47
+ if data is None:
48
+ return FunctionDef(body=body_lines)
49
+ return data.model_copy(update={"body": body_lines})
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Function generator
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def create_function(
58
+ name: str,
59
+ data: dict | FunctionDef,
60
+ script_type: ScriptType,
61
+ ) -> list[str]:
62
+ """Generate a complete shell function definition.
63
+
64
+ Parameters
65
+ ----------
66
+ name:
67
+ Function name.
68
+ data:
69
+ Function definition dict or :class:`~shellscriptor._types.FunctionDef`
70
+ model. Recognised input keys: ``"parameter"``, ``"body"``,
71
+ ``"return"`` / ``"return_"``.
72
+ script_type:
73
+ Controls log call emission.
74
+
75
+ Returns
76
+ -------
77
+ Lines defining ``name() { … }``.
78
+ """
79
+ if isinstance(data, dict):
80
+ data = FunctionDef.model_validate(data)
81
+
82
+ parameters = data.parameter or None
83
+
84
+ body_lines: list[str] = []
85
+ if script_type == "shell_exec":
86
+ body_lines.append(log_endpoint(name, typ="function", stage="entry"))
87
+
88
+ if parameters:
89
+ usage_lines = create_usage_function(parameters)
90
+ body_lines.extend(usage_lines)
91
+ body_lines.extend(
92
+ create_argparse(
93
+ parameters,
94
+ local=True,
95
+ script_type=script_type,
96
+ with_help=bool(usage_lines),
97
+ )
98
+ )
99
+ body_lines.extend(
100
+ create_validation_block(parameters, local=True, script_type=script_type)
101
+ )
102
+
103
+ body_lines.extend(sanitize_code(data.body))
104
+ body_lines.extend(create_output(data.returns, script_type=script_type))
105
+
106
+ if script_type == "shell_exec":
107
+ body_lines.append(log_endpoint(name, typ="function", stage="exit"))
108
+
109
+ return [f"{name}() {{", *indent(body_lines, 1), "}"]
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Script generator
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def create_script(
118
+ name: str,
119
+ data: dict | ScriptDef,
120
+ script_type: ScriptType,
121
+ global_functions: dict | None = None,
122
+ ) -> str:
123
+ """Generate a complete shell script from a definition dict.
124
+
125
+ Parameters
126
+ ----------
127
+ name:
128
+ Script name (used in log messages).
129
+ data:
130
+ Script definition. Recognised keys:
131
+
132
+ * ``"interpreter"`` — shebang path (``str``).
133
+ * ``"flags"`` — ``set`` flags string (e.g. ``"-euo pipefail"``).
134
+ * ``"import"`` / ``"import_"`` — list of global function names to include.
135
+ * ``"function"`` — ``dict[str, FunctionDef]`` of locally defined functions.
136
+ * ``"parameter"`` — ``dict[str, ParameterDef]``.
137
+ * ``"body"`` — script body (``str | list[str] | list[BodySection]``).
138
+ * ``"return"`` / ``"return_"`` — ``list[ReturnDef]``.
139
+ script_type:
140
+ ``"shell_src"`` — library script (no boilerplate).
141
+ ``"shell_exec"`` — executable script (tee logging, trap, arg parsing).
142
+ global_functions:
143
+ Pool of reusable function definitions; only those listed in
144
+ ``data["import"]`` are included.
145
+
146
+ Returns
147
+ -------
148
+ Generated script as a single string.
149
+ """
150
+ if isinstance(data, dict):
151
+ data = ScriptDef.model_validate(data)
152
+
153
+ lines: list[str] = []
154
+
155
+ # Shebang
156
+ if data.interpreter:
157
+ lines.append(
158
+ "#!/" + data.interpreter.removeprefix("#").removeprefix("!").removeprefix("/")
159
+ )
160
+
161
+ # set flags
162
+ if data.flags:
163
+ lines.append(f"set {data.flags}")
164
+
165
+ # Collect functions: imported globals + locally defined
166
+ functions: dict[str, FunctionDef] = {
167
+ fn: FunctionDef.model_validate(fd) if isinstance(fd, dict) else fd
168
+ for fn, fd in (global_functions or {}).items()
169
+ if fn in data.imports
170
+ }
171
+ functions.update(data.function)
172
+
173
+ # shell_exec: inject/augment __cleanup__
174
+ if script_type == "shell_exec":
175
+ functions["__cleanup__"] = _augment_cleanup_function(
176
+ functions.get("__cleanup__")
177
+ )
178
+
179
+ # Emit function definitions separated by blank lines (F7)
180
+ func_blocks: list[list[str]] = [
181
+ create_function(fn, fd, script_type)
182
+ for fn, fd in sorted(functions.items())
183
+ ]
184
+ # Auto-generate __usage__ if any parameters have descriptions
185
+ parameters = data.parameter or None
186
+ _with_help = False
187
+ if parameters:
188
+ usage_lines = create_usage_function(parameters)
189
+ if usage_lines:
190
+ func_blocks.insert(0, usage_lines)
191
+ _with_help = bool(usage_lines)
192
+
193
+ for block in func_blocks:
194
+ lines.extend(block)
195
+ lines.append("") # F7: blank line between functions
196
+
197
+ # shell_exec boilerplate: log capture setup + trap
198
+ if script_type == "shell_exec":
199
+ lines.extend(
200
+ [
201
+ '_LOGFILE_TMP="$(mktemp)"',
202
+ "exec 3>&1 4>&2",
203
+ 'exec > >(tee -a "$_LOGFILE_TMP" >&3) 2>&1',
204
+ log_endpoint(name, typ="script", stage="entry"),
205
+ "trap __cleanup__ EXIT",
206
+ ]
207
+ )
208
+
209
+ # Argument / env-var parsing + validation
210
+ if parameters:
211
+ if script_type == "shell_exec":
212
+ lines.extend(
213
+ [
214
+ 'if [ "$#" -gt 0 ]; then',
215
+ indent(log("Script called with arguments: $@", "info"), 1),
216
+ *indent(
217
+ create_argparse(
218
+ parameters,
219
+ local=False,
220
+ script_type=script_type,
221
+ with_help=_with_help,
222
+ ),
223
+ 1,
224
+ ),
225
+ "else",
226
+ indent(
227
+ log(
228
+ "Script called with no arguments. Read environment variables.",
229
+ "info",
230
+ ),
231
+ 1,
232
+ ),
233
+ *indent(create_env_var_parse(parameters), 1),
234
+ "fi",
235
+ '[[ "$DEBUG" == true ]] && set -x',
236
+ *create_validation_block(
237
+ parameters, local=False, script_type=script_type
238
+ ),
239
+ ]
240
+ )
241
+ else:
242
+ # shell_src: still parse args if defined
243
+ lines.extend(
244
+ create_argparse(
245
+ parameters,
246
+ local=False,
247
+ script_type=script_type,
248
+ with_help=_with_help,
249
+ )
250
+ )
251
+ lines.extend(
252
+ create_validation_block(
253
+ parameters, local=False, script_type=script_type
254
+ )
255
+ )
256
+
257
+ # Body
258
+ lines.extend(sanitize_code(data.body))
259
+
260
+ # Output / return
261
+ if script_type == "shell_exec" and data.returns:
262
+ lines.extend(create_output(data.returns, script_type=script_type))
263
+
264
+ # Exit log
265
+ if script_type == "shell_exec":
266
+ lines.append(log_endpoint(name, typ="script", stage="exit"))
267
+
268
+ return "\n".join(lines)
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
6
+ from typing import Annotated
7
+ from pydantic.functional_validators import BeforeValidator
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Primitive type aliases
12
+ # ---------------------------------------------------------------------------
13
+
14
+ ScriptType = Literal["shell_src", "shell_exec"]
15
+ """Whether the script is a sourced library or a directly executed script."""
16
+
17
+ ParamType = Literal["string", "boolean", "array", "integer"]
18
+ """Supported parameter/variable types."""
19
+
20
+ PathType = Literal["dir", "exec", "file", "symlink"]
21
+ """Path kinds for path-existence validation."""
22
+
23
+ LogLevel = Literal["info", "warn", "error", "critical"]
24
+ """Severity levels for generated log statements."""
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Body normalisation (runs at validation time)
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _normalize_body_input(v: Any) -> list[str]:
33
+ """Normalise any accepted body form to a flat list of strings.
34
+
35
+ Accepted forms: ``str``, ``list[str]``, ``list[dict]`` (each dict must
36
+ have a ``"content"`` key), ``list[BodySection]``, or any object with a
37
+ ``content`` attribute.
38
+ """
39
+ if not v:
40
+ return []
41
+ if isinstance(v, str):
42
+ return v.splitlines()
43
+ if isinstance(v, list):
44
+ lines: list[str] = []
45
+ for item in v:
46
+ if isinstance(item, str):
47
+ lines.append(item)
48
+ elif isinstance(item, dict):
49
+ lines.extend(item.get("content", "").splitlines())
50
+ else:
51
+ lines.extend(str(getattr(item, "content", "")).splitlines())
52
+ return lines
53
+ raise ValueError(f"Unsupported body type: {type(v)!r}")
54
+
55
+
56
+ _Body = Annotated[list[str], BeforeValidator(_normalize_body_input)]
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Validation sub-models
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class PathExistenceDef(BaseModel):
65
+ """Validation rule: assert that a path exists (or does not exist)."""
66
+
67
+ must_exist: bool = Field(
68
+ description="When true the path must exist; when false it must not exist.",
69
+ )
70
+ type: PathType | None = Field(
71
+ default=None,
72
+ description=(
73
+ "Kind of filesystem entry to test. "
74
+ "One of 'dir', 'exec', 'file', 'symlink', or null to accept any entry."
75
+ ),
76
+ )
77
+
78
+
79
+ class IntegerRangeDef(BaseModel):
80
+ """Validation rule: assert that an integer falls within a numeric range."""
81
+
82
+ min: int | None = Field(
83
+ default=None,
84
+ description="Inclusive lower bound. Omit or set to null for no lower bound.",
85
+ )
86
+ max: int | None = Field(
87
+ default=None,
88
+ description="Inclusive upper bound. Omit or set to null for no upper bound.",
89
+ )
90
+
91
+ @model_validator(mode="after")
92
+ def _check_range_order(self) -> "IntegerRangeDef":
93
+ """Ensure min <= max when both bounds are provided."""
94
+ if self.min is not None and self.max is not None and self.min > self.max:
95
+ raise ValueError(
96
+ f"IntegerRangeDef: min ({self.min}) must be <= max ({self.max})"
97
+ )
98
+ return self
99
+
100
+
101
+ class ValidationDef(BaseModel):
102
+ """Validation rules for a single parameter."""
103
+
104
+ enum: list[str] = Field(
105
+ default_factory=list,
106
+ description="Exhaustive list of accepted values. Rejected values cause a fatal error.",
107
+ )
108
+ path_existence: PathExistenceDef | None = Field(
109
+ default=None,
110
+ description="Assert that the value is a path that exists (or does not exist).",
111
+ )
112
+ integer_range: IntegerRangeDef | None = Field(
113
+ default=None,
114
+ description="Assert that the integer value falls within an inclusive numeric range.",
115
+ )
116
+ regex: str | None = Field(
117
+ default=None,
118
+ description="ERE pattern the value must match in full. Rejection is fatal.",
119
+ )
120
+ custom: list[str] = Field(
121
+ default_factory=list,
122
+ description="Verbatim shell lines appended after all other validation checks.",
123
+ )
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Parameter / return / function / script models
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ class ParameterDef(BaseModel):
132
+ """Definition of a single script/function parameter."""
133
+
134
+ type: ParamType = Field(
135
+ description="Shell type of the parameter: 'string', 'boolean', 'array', or 'integer'.",
136
+ )
137
+ default: str | list[str] | None = Field(
138
+ default=None,
139
+ description=(
140
+ "Default value applied when the argument is absent. "
141
+ "Use a list for array-typed parameters. "
142
+ "Omit or set to null to make the parameter required."
143
+ ),
144
+ )
145
+ required: bool | None = Field(
146
+ default=None,
147
+ description=(
148
+ "Explicit required flag. When null, the parameter is considered required "
149
+ "if and only if 'default' is also null."
150
+ ),
151
+ )
152
+ description: str = Field(
153
+ default="",
154
+ description=(
155
+ "Human-readable description shown in the auto-generated --help output. "
156
+ "Triggers --help / __usage__ generation when non-empty on any parameter."
157
+ ),
158
+ )
159
+ short: str = Field(
160
+ default="",
161
+ description=(
162
+ "Single-character short flag alias (without the leading dash). "
163
+ "For example 'n' generates a '-n' arm alongside '--<param-name>'."
164
+ ),
165
+ )
166
+ array_delimiter: str = Field(
167
+ default=" ",
168
+ description=(
169
+ "Delimiter used when reading an array parameter from a single environment "
170
+ "variable (env-var fallback path). Defaults to a single space."
171
+ ),
172
+ )
173
+ validation: ValidationDef | None = Field(
174
+ default=None,
175
+ description="Optional set of validation rules applied after argument parsing.",
176
+ )
177
+
178
+
179
+ class ReturnDef(BaseModel):
180
+ """Definition of a single return value."""
181
+
182
+ name: str = Field(
183
+ description="Human-readable label for the return value, used in log messages.",
184
+ )
185
+ variable: str = Field(
186
+ description="Name of the shell variable whose value is written to stdout.",
187
+ )
188
+ type: ParamType = Field(
189
+ description="Type of the return value; drives the output encoding scheme.",
190
+ )
191
+
192
+
193
+ class BodySection(BaseModel):
194
+ """A single body section (the dict form used in script/function definitions)."""
195
+
196
+ content: str = Field(
197
+ description="Raw shell code for this section. May contain newlines.",
198
+ )
199
+
200
+
201
+ class FunctionDef(BaseModel):
202
+ """Definition of a shell function."""
203
+
204
+ model_config = ConfigDict(populate_by_name=True)
205
+
206
+ parameter: dict[str, ParameterDef] = Field(
207
+ default_factory=dict,
208
+ description="Named parameters accepted by this function, keyed by parameter name.",
209
+ )
210
+ body: _Body = Field(
211
+ default_factory=list,
212
+ description=(
213
+ "Shell code forming the function body. "
214
+ "Accepts a plain string, a list of strings, or a list of {content: ...} objects."
215
+ ),
216
+ )
217
+ returns: list[ReturnDef] = Field(
218
+ default_factory=list,
219
+ validation_alias=AliasChoices("return", "return_"),
220
+ description="Values written to stdout when the function exits.",
221
+ )
222
+
223
+
224
+ class ScriptDef(BaseModel):
225
+ """Top-level script definition."""
226
+
227
+ model_config = ConfigDict(populate_by_name=True)
228
+
229
+ interpreter: str | None = Field(
230
+ default=None,
231
+ description=(
232
+ "Interpreter path written as the shebang line (without the leading '#!'). "
233
+ "For example 'usr/bin/env bash' produces '#!/usr/bin/env bash'. "
234
+ "Omit to produce a script with no shebang."
235
+ ),
236
+ )
237
+ flags: str | None = Field(
238
+ default=None,
239
+ description=(
240
+ "Argument string passed to 'set' immediately after the shebang. "
241
+ "For example '-euo pipefail'."
242
+ ),
243
+ )
244
+ imports: list[str] = Field(
245
+ default_factory=list,
246
+ validation_alias=AliasChoices("import", "import_"),
247
+ description=(
248
+ "Names of functions from the global_functions pool to include verbatim "
249
+ "in this script."
250
+ ),
251
+ )
252
+ function: dict[str, FunctionDef] = Field(
253
+ default_factory=dict,
254
+ description="Local function definitions, keyed by function name.",
255
+ )
256
+ parameter: dict[str, ParameterDef] = Field(
257
+ default_factory=dict,
258
+ description="Script-level parameters accepted on the command line or from environment variables.",
259
+ )
260
+ body: _Body = Field(
261
+ default_factory=list,
262
+ description=(
263
+ "Main script body executed after argument parsing and validation. "
264
+ "Accepts a plain string, a list of strings, or a list of {content: ...} objects."
265
+ ),
266
+ )
267
+ returns: list[ReturnDef] = Field(
268
+ default_factory=list,
269
+ validation_alias=AliasChoices("return", "return_"),
270
+ description="Values written to stdout before the script exits (shell_exec only).",
271
+ )