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,413 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from shellscriptor._argparse import param_name_to_var_name
6
+ from shellscriptor._code import indent
7
+ from shellscriptor._log import log
8
+ from shellscriptor._types import (
9
+ ParameterDef,
10
+ PathType,
11
+ ScriptType,
12
+ ValidationDef,
13
+ )
14
+
15
+
16
+ def create_validation_block(
17
+ parameters: dict[str, ParameterDef],
18
+ *,
19
+ local: bool,
20
+ script_type: ScriptType,
21
+ ) -> list[str]:
22
+ """Generate validation lines for all parameters.
23
+
24
+ Parameters
25
+ ----------
26
+ parameters:
27
+ Mapping of parameter name → :class:`~shellscriptor._types.ParameterDef`.
28
+ local:
29
+ Whether variables are function-local.
30
+ script_type:
31
+ Controls whether informational log calls are emitted.
32
+
33
+ Returns
34
+ -------
35
+ List of shell lines covering all parameter validations.
36
+ """
37
+ lines: list[str] = []
38
+ for param_name, param_data in sorted((parameters or {}).items()):
39
+ lines.extend(
40
+ validate_variable(
41
+ var_name=param_name_to_var_name(param_name, local=local),
42
+ var_type=param_data.type,
43
+ default=param_data.default,
44
+ required=param_data.required,
45
+ validations=param_data.validation,
46
+ script_type=script_type,
47
+ )
48
+ )
49
+ return lines
50
+
51
+
52
+ def validate_variable(
53
+ var_name: str,
54
+ var_type: Literal["string", "boolean", "array", "integer"],
55
+ default: str | list[str] | None,
56
+ required: bool | None,
57
+ validations: ValidationDef | None,
58
+ script_type: ScriptType,
59
+ ) -> list[str]:
60
+ """Generate all validation checks for a single variable.
61
+
62
+ Applies, in order:
63
+
64
+ 1. Missing-argument / default assignment check — **unless** the
65
+ parameter is explicitly optional (``required=False``) and has no
66
+ default, in which case the variable is allowed to be empty after
67
+ argument parsing and the check is skipped entirely.
68
+ Boolean variables are always normalised to ``"false"`` when unset
69
+ regardless of ``required``.
70
+ 2. For scalars/integers: enum, path-existence, integer-range, and regex
71
+ checks directly on the variable.
72
+ 3. For arrays with structural validators (enum / path-existence /
73
+ integer-range / regex): wraps checks in a ``for elem in …`` loop.
74
+ 4. Custom shell lines verbatim.
75
+
76
+ Parameters
77
+ ----------
78
+ var_name:
79
+ Shell variable name.
80
+ var_type:
81
+ One of ``"string"``, ``"boolean"``, ``"array"``, ``"integer"``.
82
+ default:
83
+ Default value(s). ``None`` with ``required`` unset or ``True``
84
+ makes the parameter required.
85
+ required:
86
+ Explicit required flag.
87
+
88
+ * ``True`` — exit if missing (``default`` is ignored).
89
+ * ``False`` — optional; if ``default`` is also ``None`` the check
90
+ is skipped entirely and the variable may remain empty.
91
+ * ``None`` — inferred: required when ``default is None``, optional
92
+ otherwise.
93
+ validations:
94
+ Optional validation rules applied after the missing-arg check.
95
+ script_type:
96
+ Controls whether informational log calls are emitted.
97
+
98
+ Returns
99
+ -------
100
+ List of shell validation lines.
101
+ """
102
+ # Determine effective required flag
103
+ is_required = required if required is not None else (default is None)
104
+
105
+ # Boolean: always normalise "" → "false"; never treated as "skip".
106
+ # Other types: only emit check when required OR a default is provided.
107
+ # When required=False and default=None the variable is genuinely optional
108
+ # (empty after parsing is acceptable) — skip the check.
109
+ emit_missing_check = var_type == "boolean" or is_required or default is not None
110
+
111
+ out: list[str] = []
112
+ if emit_missing_check:
113
+ out.append(
114
+ validate_missing_arg(
115
+ var_name=var_name,
116
+ var_type=var_type,
117
+ default=default if not is_required else None,
118
+ script_type=script_type,
119
+ )
120
+ )
121
+
122
+ if var_type == "boolean":
123
+ return out
124
+
125
+ validations = validations or ValidationDef()
126
+ has_structural = bool(
127
+ validations.enum
128
+ or validations.path_existence
129
+ or validations.integer_range
130
+ or validations.regex
131
+ )
132
+ in_for_loop = has_structural and var_type == "array"
133
+
134
+ if in_for_loop:
135
+ out.append(f'for elem in "${{{var_name}[@]}}"; do')
136
+
137
+ check_var = "elem" if in_for_loop else var_name
138
+ level = 1 if in_for_loop else 0
139
+
140
+ if validations.enum:
141
+ out.extend(indent(validate_enum(var_name=check_var, enum=validations.enum), level))
142
+ if validations.path_existence:
143
+ pe = validations.path_existence
144
+ out.extend(indent(validate_path_existence(var_name=check_var, must_exist=pe.must_exist, path_type=pe.type), level))
145
+ if validations.integer_range:
146
+ ir = validations.integer_range
147
+ out.extend(indent(validate_integer_range(var_name=check_var, min_val=ir.min, max_val=ir.max), level))
148
+ if validations.regex:
149
+ out.extend(indent(validate_regex(var_name=check_var, pattern=validations.regex), level))
150
+
151
+ if in_for_loop:
152
+ out.append("done")
153
+
154
+ if validations.custom:
155
+ out.extend(validations.custom)
156
+
157
+ return out
158
+
159
+
160
+ def validate_missing_arg(
161
+ var_name: str,
162
+ var_type: Literal["string", "boolean", "array", "integer"],
163
+ default: str | list[str] | None,
164
+ script_type: ScriptType,
165
+ ) -> str:
166
+ """Generate a one-liner that enforces presence or applies a default.
167
+
168
+ * When *default* is ``None`` the variable is treated as **required** and
169
+ the generated code calls ``exit 1`` if the variable is unset or empty.
170
+ * When *default* is provided, the generated code assigns that value and
171
+ logs the action (in ``shell_exec`` mode only).
172
+ * For ``boolean`` variables the default is always ``"false"``; the
173
+ *default* argument is therefore ignored and this function should be
174
+ called unconditionally for booleans to normalise ``""`` → ``"false"``.
175
+
176
+ Parameters
177
+ ----------
178
+ var_name:
179
+ Shell variable name.
180
+ var_type:
181
+ One of ``"string"``, ``"boolean"``, ``"array"``, ``"integer"``.
182
+ default:
183
+ Default value, or ``None`` to require the argument.
184
+ script_type:
185
+ Controls whether informational log calls are emitted.
186
+
187
+ Returns
188
+ -------
189
+ A single shell line (one-liner).
190
+ """
191
+
192
+ def _info_default(default_repr: str) -> str:
193
+ if script_type == "shell_src":
194
+ return ""
195
+ msg = f"Argument '{var_name}' set to default value '{default_repr}'."
196
+ return f"{log(msg, 'info')}; "
197
+
198
+ err = log(f"Missing required argument '{var_name}'.", "critical")
199
+
200
+ if var_type in ("string", "integer"):
201
+ check = f'[ -z "${{{var_name}-}}" ]'
202
+ if default is None:
203
+ action = err
204
+ else:
205
+ action = f'{_info_default(str(default))}{var_name}="{default}"'
206
+ elif var_type == "boolean":
207
+ check = f'[ -z "${{{var_name}-}}" ]'
208
+ action = f"{_info_default('false')}{var_name}=false"
209
+ elif var_type == "array":
210
+ check = f'{{ [ "${{{var_name}+isset}}" != "isset" ] || [ ${{#{var_name}[@]}} -eq 0 ]; }}'
211
+ if default is None:
212
+ action = err
213
+ else:
214
+ default_list: list[str] = (
215
+ default if isinstance(default, list) else [default]
216
+ )
217
+ quoted = " ".join('"' + e + '"' for e in default_list)
218
+ default_str = f"({quoted})"
219
+ action = f"{_info_default(default_str)}{var_name}={default_str}"
220
+ else:
221
+ raise ValueError(f"Unsupported var_type: {var_type!r}")
222
+
223
+ return f"{check} && {{ {action}; }}"
224
+
225
+
226
+ def validate_enum(var_name: str, enum: list[str]) -> list[str]:
227
+ """Generate a ``case`` statement that rejects values not in *enum*.
228
+
229
+ The empty string is intentionally excluded from the allowed set; the
230
+ missing-arg check (which must run first) already handles unset/empty
231
+ variables.
232
+
233
+ Parameters
234
+ ----------
235
+ var_name:
236
+ Shell variable name to check.
237
+ enum:
238
+ Allowed values. Must contain at least one entry.
239
+
240
+ Returns
241
+ -------
242
+ List of shell lines.
243
+
244
+ Raises
245
+ ------
246
+ ValueError
247
+ If *enum* is empty.
248
+ """
249
+ if not enum:
250
+ raise ValueError("validate_enum: 'enum' must contain at least one value")
251
+ enum_str = "|".join('"' + v + '"' for v in enum)
252
+ _invalid_msg = f"Invalid value for argument '--{var_name}': '${{{var_name}}}'"
253
+ return [
254
+ f'case "${{{var_name}}}" in',
255
+ indent(f"{enum_str});;", 1),
256
+ indent(
257
+ f'*) {log(_invalid_msg, "critical")};;',
258
+ 1,
259
+ ),
260
+ "esac",
261
+ ]
262
+
263
+
264
+ def validate_path_existence(
265
+ var_name: str,
266
+ must_exist: bool,
267
+ path_type: PathType | None = None,
268
+ ) -> list[str]:
269
+ """Generate a check that a path exists (or does not exist).
270
+
271
+ The check is guarded by ``[ -n "${VAR-}" ]`` so that it is silently
272
+ skipped when the variable is empty. This means the missing-arg check
273
+ (``validate_missing_arg``) must run before this one if a non-empty value
274
+ is required.
275
+
276
+ Parameters
277
+ ----------
278
+ var_name:
279
+ Shell variable holding the path.
280
+ must_exist:
281
+ ``True`` → path must exist; ``False`` → path must *not* exist.
282
+ path_type:
283
+ Kind of filesystem entry to test, or ``None`` for any entry
284
+ (uses ``-e``).
285
+
286
+ Returns
287
+ -------
288
+ A single-element list containing the shell check line.
289
+ """
290
+ _op: dict[PathType | None, str] = {
291
+ None: "e",
292
+ "dir": "d",
293
+ "exec": "x",
294
+ "file": "f",
295
+ "symlink": "L",
296
+ }
297
+ _name: dict[PathType | None, str] = {
298
+ None: "Path",
299
+ "dir": "Directory",
300
+ "exec": "Executable",
301
+ "file": "File",
302
+ "symlink": "Symbolic link",
303
+ }
304
+ condition = "not found" if must_exist else "already exists"
305
+ err_msg = (
306
+ f"{_name[path_type]} argument to parameter '{var_name}' "
307
+ f"{condition}: '${{{var_name}}}'"
308
+ )
309
+ negation = "! " if must_exist else ""
310
+ cmd = (
311
+ f'[ -n "${{{var_name}-}}" ] && '
312
+ f'[ {negation}-{_op[path_type]} "${{{var_name}}}" ] && '
313
+ f'{{ {log(err_msg, "critical")}; }}'
314
+ )
315
+ return [cmd]
316
+
317
+
318
+ def validate_integer_range(
319
+ var_name: str,
320
+ min_val: int | None,
321
+ max_val: int | None,
322
+ ) -> list[str]:
323
+ """Generate a check that an integer variable is within [*min_val*, *max_val*].
324
+
325
+ Always emits a regex guard to ensure the value is a valid integer (matches
326
+ ``^-?[0-9]+$``) before comparing bounds. Either bound may be ``None`` to
327
+ indicate no bound in that direction.
328
+
329
+ Parameters
330
+ ----------
331
+ var_name:
332
+ Shell variable holding the integer string.
333
+ min_val:
334
+ Inclusive lower bound, or ``None``.
335
+ max_val:
336
+ Inclusive upper bound, or ``None``.
337
+
338
+ Returns
339
+ -------
340
+ List of shell lines.
341
+ """
342
+ lines: list[str] = [
343
+ f'if ! [[ "${{{var_name}}}" =~ ^-?[0-9]+$ ]]; then',
344
+ indent(
345
+ log(
346
+ f"Argument '{var_name}' is not an integer: '${{{var_name}}}'",
347
+ "critical",
348
+ ),
349
+ 1,
350
+ ),
351
+ "fi",
352
+ ]
353
+ if min_val is not None:
354
+ lines.extend(
355
+ [
356
+ f'if [[ "${{{var_name}}}" -lt {min_val} ]]; then',
357
+ indent(
358
+ log(
359
+ f"Argument '{var_name}' must be >= {min_val}, got '${{{var_name}}}'",
360
+ "critical",
361
+ ),
362
+ 1,
363
+ ),
364
+ "fi",
365
+ ]
366
+ )
367
+ if max_val is not None:
368
+ lines.extend(
369
+ [
370
+ f'if [[ "${{{var_name}}}" -gt {max_val} ]]; then',
371
+ indent(
372
+ log(
373
+ f"Argument '{var_name}' must be <= {max_val}, got '${{{var_name}}}'",
374
+ "critical",
375
+ ),
376
+ 1,
377
+ ),
378
+ "fi",
379
+ ]
380
+ )
381
+ return lines
382
+
383
+
384
+ def validate_regex(var_name: str, pattern: str) -> list[str]:
385
+ """Generate a check that a variable's value matches *pattern*.
386
+
387
+ Uses bash's ``[[ … =~ … ]]`` extended regex operator (ERE syntax).
388
+ The check exits the script with code 1 if the value does not match.
389
+
390
+ Parameters
391
+ ----------
392
+ var_name:
393
+ Shell variable to check.
394
+ pattern:
395
+ ERE pattern passed directly into ``[[ "${VAR}" =~ … ]]``.
396
+ Must not contain unescaped shell-special characters that would
397
+ break the surrounding ``if`` construct.
398
+
399
+ Returns
400
+ -------
401
+ List of shell lines.
402
+ """
403
+ return [
404
+ f'if ! [[ "${{{var_name}}}" =~ {pattern} ]]; then',
405
+ indent(
406
+ log(
407
+ f"Argument '{var_name}' does not match pattern '{pattern}': '${{{var_name}}}'",
408
+ "critical",
409
+ ),
410
+ 1,
411
+ ),
412
+ "fi",
413
+ ]
shellscriptor/py.typed ADDED
File without changes