shtemplate 0.2.1__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.
- shtemplate/__init__.py +31 -0
- shtemplate/__main__.py +5 -0
- shtemplate/_cli.py +108 -0
- shtemplate/_core.py +159 -0
- shtemplate/_dotenv.py +78 -0
- shtemplate/_errors.py +69 -0
- shtemplate/_nodes.py +66 -0
- shtemplate/_parser.py +196 -0
- shtemplate/_pattern.py +193 -0
- shtemplate/_tools.py +48 -0
- shtemplate/py.typed +0 -0
- shtemplate-0.2.1.dist-info/METADATA +173 -0
- shtemplate-0.2.1.dist-info/RECORD +16 -0
- shtemplate-0.2.1.dist-info/WHEEL +4 -0
- shtemplate-0.2.1.dist-info/entry_points.txt +2 -0
- shtemplate-0.2.1.dist-info/licenses/LICENSE +21 -0
shtemplate/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""POSIX shell-style string expansion over any mapping, typed and zero-dependency.
|
|
2
|
+
|
|
3
|
+
>>> from shtemplate import expand
|
|
4
|
+
>>> expand("$HOME/${APP:-demo}", {"HOME": "/home/ada"})
|
|
5
|
+
'/home/ada/demo'
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._core import expand
|
|
11
|
+
from ._errors import (
|
|
12
|
+
ExpansionError,
|
|
13
|
+
ParameterError,
|
|
14
|
+
TemplateSyntaxError,
|
|
15
|
+
UnsafeTemplateError,
|
|
16
|
+
UnsetVariableError,
|
|
17
|
+
)
|
|
18
|
+
from ._tools import escape, list_vars
|
|
19
|
+
|
|
20
|
+
__version__ = "0.2.1"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ExpansionError",
|
|
24
|
+
"ParameterError",
|
|
25
|
+
"TemplateSyntaxError",
|
|
26
|
+
"UnsafeTemplateError",
|
|
27
|
+
"UnsetVariableError",
|
|
28
|
+
"escape",
|
|
29
|
+
"expand",
|
|
30
|
+
"list_vars",
|
|
31
|
+
]
|
shtemplate/__main__.py
ADDED
shtemplate/_cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command line tool: expand a template from a file or stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from ._core import expand
|
|
13
|
+
from ._dotenv import EnvFileError, parse_env, valid_name
|
|
14
|
+
from ._errors import ExpansionError
|
|
15
|
+
from ._tools import list_vars
|
|
16
|
+
|
|
17
|
+
_EPILOG = """\
|
|
18
|
+
examples:
|
|
19
|
+
echo 'Hello ${USER:-world}' | shtemplate
|
|
20
|
+
shtemplate config.tpl -o config.ini
|
|
21
|
+
shtemplate -i --env-file prod.env --set PORT=8080 config.tpl
|
|
22
|
+
shtemplate --strict --safe untrusted.tpl
|
|
23
|
+
shtemplate --list-vars config.tpl
|
|
24
|
+
|
|
25
|
+
variables come from the process environment, then each --env-file in order,
|
|
26
|
+
then each --set; later sources win. -i starts from an empty environment.
|
|
27
|
+
errors go to stderr and the exit status is 1.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Parser(argparse.ArgumentParser):
|
|
32
|
+
def error(self, message: str) -> NoReturn:
|
|
33
|
+
self.print_usage(sys.stderr)
|
|
34
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = _Parser(
|
|
39
|
+
prog="shtemplate",
|
|
40
|
+
description="Expand POSIX shell parameter expansions ($VAR, ${VAR:-default},\n"
|
|
41
|
+
"${VAR%pattern}, ...) in a template. Nothing is ever executed.",
|
|
42
|
+
epilog=_EPILOG,
|
|
43
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
44
|
+
)
|
|
45
|
+
add = parser.add_argument
|
|
46
|
+
add("template", nargs="?", default="-", help="template file, or - for stdin (default)")
|
|
47
|
+
add("-o", "--output", metavar="FILE", help="write the result to FILE instead of stdout")
|
|
48
|
+
add("-e", "--env-file", metavar="FILE", action="append", default=[], help="read NAME=VALUE")
|
|
49
|
+
add("-s", "--set", metavar="NAME=VALUE", action="append", default=[], help="set one variable")
|
|
50
|
+
add("-i", "--ignore-environment", action="store_true", help="do not read os.environ")
|
|
51
|
+
add("--strict", action="store_true", help="fail on unset variables, like set -u")
|
|
52
|
+
add("--safe", action="store_true", help="reject $(...), $((...)), backquotes; cap output")
|
|
53
|
+
add("--list-vars", action="store_true", help="print referenced names, one per line, and exit")
|
|
54
|
+
add("--version", action="version", version=f"%(prog)s {__version__}")
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read(path: str) -> str:
|
|
59
|
+
if path == "-":
|
|
60
|
+
data, label = sys.stdin.buffer.read(), "stdin"
|
|
61
|
+
else:
|
|
62
|
+
with open(path, "rb") as handle:
|
|
63
|
+
data, label = handle.read(), path
|
|
64
|
+
try:
|
|
65
|
+
return data.decode("utf-8")
|
|
66
|
+
except UnicodeDecodeError as exc:
|
|
67
|
+
raise EnvFileError(f"{label}: not valid UTF-8 (byte {exc.start})") from None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _environment(args: argparse.Namespace) -> dict[str, str]:
|
|
71
|
+
env: dict[str, str] = {} if args.ignore_environment else dict(os.environ)
|
|
72
|
+
for path in args.env_file:
|
|
73
|
+
env.update(parse_env(_read(path), path))
|
|
74
|
+
for item in args.set:
|
|
75
|
+
name, sep, value = item.partition("=")
|
|
76
|
+
if not sep or not valid_name(name):
|
|
77
|
+
raise EnvFileError(f"--set expects NAME=VALUE with a valid name, got {item!r}")
|
|
78
|
+
env[name] = value
|
|
79
|
+
return env
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write(text: str, output: str | None) -> None:
|
|
83
|
+
# surrogateescape writes back undecodable bytes taken from os.environ or argv.
|
|
84
|
+
data = text.encode("utf-8", errors="surrogateescape")
|
|
85
|
+
if output is None:
|
|
86
|
+
sys.stdout.buffer.write(data)
|
|
87
|
+
sys.stdout.buffer.flush()
|
|
88
|
+
else:
|
|
89
|
+
with open(output, "wb") as handle:
|
|
90
|
+
handle.write(data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
94
|
+
"""Run the command line tool; return the exit status."""
|
|
95
|
+
parser = _build_parser()
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
try:
|
|
98
|
+
template = _read(args.template)
|
|
99
|
+
if args.list_vars:
|
|
100
|
+
names = list_vars(template)
|
|
101
|
+
_write("".join(name + "\n" for name in names), args.output)
|
|
102
|
+
return 0
|
|
103
|
+
result = expand(template, _environment(args), strict=args.strict, safe=args.safe)
|
|
104
|
+
_write(result, args.output)
|
|
105
|
+
except (ExpansionError, EnvFileError, OSError, UnicodeError) as exc:
|
|
106
|
+
print(f"shtemplate: error: {exc}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
return 0
|
shtemplate/_core.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Expand a parsed template against a mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, MutableMapping
|
|
6
|
+
|
|
7
|
+
from ._errors import ParameterError, UnsafeTemplateError, UnsetVariableError
|
|
8
|
+
from ._nodes import PATTERN_OPS, Node, Param, Text
|
|
9
|
+
from ._parser import parse
|
|
10
|
+
from ._pattern import remove
|
|
11
|
+
|
|
12
|
+
SAFE_MAX_OUTPUT = 1_000_000
|
|
13
|
+
WORK_FACTOR = 10
|
|
14
|
+
Segment = tuple[str, bool]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def expand(
|
|
18
|
+
template: str,
|
|
19
|
+
env: Mapping[str, str] | None = None,
|
|
20
|
+
*,
|
|
21
|
+
strict: bool = False,
|
|
22
|
+
safe: bool = False,
|
|
23
|
+
assigned: MutableMapping[str, str] | None = None,
|
|
24
|
+
max_output: int | None = None,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Expand POSIX shell parameter expansions in ``template`` from ``env``.
|
|
27
|
+
|
|
28
|
+
Supported forms: ``$NAME``, ``${NAME}``, ``${#NAME}``, the defaulting
|
|
29
|
+
operators ``:-``, ``-``, ``:=``, ``=``, ``:?``, ``?``, ``:+``, ``+`` and the
|
|
30
|
+
pattern removals ``#``, ``##``, ``%``, ``%%``. Operator words are expanded
|
|
31
|
+
only when needed, so they may nest (``${A:-${B:-x}}``). Backslash escapes
|
|
32
|
+
``$``, backquote and backslash (``\\$HOME`` stays literal) and ``$$`` is a
|
|
33
|
+
literal dollar. Nothing is ever executed: ``$(...)`` and backquotes are left
|
|
34
|
+
as literal text, or rejected in safe mode.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
template: The text to expand.
|
|
38
|
+
env: The variables. Defaults to an empty mapping; pass ``os.environ``
|
|
39
|
+
explicitly to read the process environment. Never mutated.
|
|
40
|
+
strict: Behave like ``set -u``: a plain reference, ``${#NAME}`` or a
|
|
41
|
+
pattern removal on an unset variable raises ``UnsetVariableError``.
|
|
42
|
+
safe: For untrusted templates: reject ``$(...)``, ``$((...))`` and
|
|
43
|
+
backquotes with ``UnsafeTemplateError`` and cap the output at
|
|
44
|
+
``max_output`` (one million characters unless given).
|
|
45
|
+
assigned: If given, receives the values assigned by ``:=`` and ``=``
|
|
46
|
+
after a successful expansion. Pass ``env`` itself to update it.
|
|
47
|
+
max_output: Raise ``UnsafeTemplateError`` if any expansion grows past
|
|
48
|
+
this many characters, or if a pattern removal would need more than
|
|
49
|
+
ten times this many matching steps (value length times pattern
|
|
50
|
+
length), which bounds the time an untrusted template can take.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The expanded string.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TemplateSyntaxError: The template is malformed.
|
|
57
|
+
UnsetVariableError: Strict mode met an unset variable.
|
|
58
|
+
ParameterError: A ``:?`` or ``?`` expansion found its variable missing.
|
|
59
|
+
UnsafeTemplateError: Safe mode rejected the template or the output limit
|
|
60
|
+
was reached.
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
>>> expand("${USER:-guest}@${HOST:-localhost}", {"USER": "ada"})
|
|
64
|
+
'ada@localhost'
|
|
65
|
+
>>> expand("${FILE%.*}.o ${#FILE}", {"FILE": "main.c"})
|
|
66
|
+
'main.o 6'
|
|
67
|
+
>>> expand("${URL#*://}", {"URL": "https://example.org"})
|
|
68
|
+
'example.org'
|
|
69
|
+
"""
|
|
70
|
+
parsed = parse(template)
|
|
71
|
+
if safe:
|
|
72
|
+
if parsed.unsafe:
|
|
73
|
+
position, kind = parsed.unsafe[0]
|
|
74
|
+
raise UnsafeTemplateError(
|
|
75
|
+
f"{kind} is not allowed in safe mode (at position {position})", position
|
|
76
|
+
)
|
|
77
|
+
if max_output is None:
|
|
78
|
+
max_output = SAFE_MAX_OUTPUT
|
|
79
|
+
evaluator = _Evaluator(env if env is not None else {}, strict, max_output)
|
|
80
|
+
result = evaluator.text(parsed.nodes)
|
|
81
|
+
if assigned is not None:
|
|
82
|
+
assigned.update(evaluator.overlay)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Evaluator:
|
|
87
|
+
def __init__(self, env: Mapping[str, str], strict: bool, max_output: int | None) -> None:
|
|
88
|
+
self.env = env
|
|
89
|
+
self.strict = strict
|
|
90
|
+
self.max_output = max_output
|
|
91
|
+
self.overlay: dict[str, str] = {}
|
|
92
|
+
|
|
93
|
+
def lookup(self, name: str) -> str | None:
|
|
94
|
+
if name in self.overlay:
|
|
95
|
+
return self.overlay[name]
|
|
96
|
+
return self.env.get(name)
|
|
97
|
+
|
|
98
|
+
def _check(self, size: int) -> None:
|
|
99
|
+
if self.max_output is not None and size > self.max_output:
|
|
100
|
+
raise UnsafeTemplateError(
|
|
101
|
+
f"expansion exceeds the limit of {self.max_output} characters"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def segments(self, nodes: tuple[Node, ...]) -> list[Segment]:
|
|
105
|
+
"""Expand ``nodes`` into ``(text, literal)`` pieces; literal pieces match as-is."""
|
|
106
|
+
parts: list[Segment] = []
|
|
107
|
+
for node in nodes:
|
|
108
|
+
if isinstance(node, Text):
|
|
109
|
+
parts.append((node.value, node.quoted))
|
|
110
|
+
else:
|
|
111
|
+
found = self.param(node)
|
|
112
|
+
if node.quoted:
|
|
113
|
+
found = [(value, True) for value, _ in found]
|
|
114
|
+
parts.extend(found)
|
|
115
|
+
self._check(sum(len(value) for value, _ in parts))
|
|
116
|
+
return parts
|
|
117
|
+
|
|
118
|
+
def text(self, nodes: tuple[Node, ...]) -> str:
|
|
119
|
+
return "".join(value for value, _ in self.segments(nodes))
|
|
120
|
+
|
|
121
|
+
def pattern(self, nodes: tuple[Node, ...]) -> str:
|
|
122
|
+
return "".join(
|
|
123
|
+
"".join("\\" + c for c in value) if literal else value
|
|
124
|
+
for value, literal in self.segments(nodes)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _unset(self, node: Param) -> str:
|
|
128
|
+
if self.strict:
|
|
129
|
+
raise UnsetVariableError(node.name, node.position)
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
def param(self, node: Param) -> list[Segment]:
|
|
133
|
+
value = self.lookup(node.name)
|
|
134
|
+
op = node.op
|
|
135
|
+
if op == "":
|
|
136
|
+
return [(self._unset(node) if value is None else value, False)]
|
|
137
|
+
if op == "len":
|
|
138
|
+
return [(str(len(self._unset(node) if value is None else value)), False)]
|
|
139
|
+
if op in PATTERN_OPS:
|
|
140
|
+
# dash and bash both skip the pattern word of an unset variable.
|
|
141
|
+
if value is None:
|
|
142
|
+
return [(self._unset(node), False)]
|
|
143
|
+
limit = None if self.max_output is None else self.max_output * WORK_FACTOR
|
|
144
|
+
return [(remove(value, self.pattern(node.word), op, limit), False)]
|
|
145
|
+
missing = value is None or (node.colon and value == "")
|
|
146
|
+
if op == "+":
|
|
147
|
+
return [] if missing else self.segments(node.word)
|
|
148
|
+
if not missing:
|
|
149
|
+
return [(value or "", False)]
|
|
150
|
+
if op == "-":
|
|
151
|
+
return self.segments(node.word)
|
|
152
|
+
if op == "=":
|
|
153
|
+
word = self.segments(node.word)
|
|
154
|
+
self.overlay[node.name] = "".join(text for text, _ in word)
|
|
155
|
+
return word
|
|
156
|
+
detail = self.text(node.word)
|
|
157
|
+
if not detail:
|
|
158
|
+
detail = "parameter null or not set" if node.colon else "parameter not set"
|
|
159
|
+
raise ParameterError(node.name, detail, node.position)
|
shtemplate/_dotenv.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read ``.env``-style files for the command line tool.
|
|
2
|
+
|
|
3
|
+
One ``NAME=VALUE`` per line. Blank lines and lines starting with ``#`` are
|
|
4
|
+
skipped and an optional ``export`` prefix is ignored. Values may be bare (a
|
|
5
|
+
blank followed by ``#`` starts a comment; surrounding blanks are trimmed),
|
|
6
|
+
single-quoted (taken literally) or double-quoted (``\\n``, ``\\t``, ``\\r``,
|
|
7
|
+
``\\"``, ``\\\\`` and ``\\$`` are unescaped). Values are never expanded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ._nodes import NAME_REST, NAME_START
|
|
15
|
+
|
|
16
|
+
_COMMENT = re.compile(r"\s#")
|
|
17
|
+
_DQ_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\", "$": "$"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvFileError(ValueError):
|
|
21
|
+
"""A line of an env file cannot be parsed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_env(text: str, source: str = "<env>") -> dict[str, str]:
|
|
25
|
+
"""Parse ``.env``-style ``text`` into a dict; ``source`` names it in errors."""
|
|
26
|
+
values: dict[str, str] = {}
|
|
27
|
+
for number, raw in enumerate(text.splitlines(), start=1):
|
|
28
|
+
line = raw.strip()
|
|
29
|
+
if not line or line.startswith("#"):
|
|
30
|
+
continue
|
|
31
|
+
if line.startswith("export ") or line.startswith("export\t"):
|
|
32
|
+
line = line[7:].lstrip()
|
|
33
|
+
name, sep, rest = line.partition("=")
|
|
34
|
+
name = name.rstrip()
|
|
35
|
+
if not sep or not valid_name(name):
|
|
36
|
+
raise EnvFileError(f"{source}:{number}: expected NAME=VALUE")
|
|
37
|
+
try:
|
|
38
|
+
values[name] = _value(rest.strip())
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise EnvFileError(f"{source}:{number}: {exc}") from None
|
|
41
|
+
return values
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def valid_name(name: str) -> bool:
|
|
45
|
+
"""Return True when ``name`` is a POSIX shell variable name."""
|
|
46
|
+
return bool(name) and name[0] in NAME_START and all(c in NAME_REST for c in name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _value(rest: str) -> str:
|
|
50
|
+
if rest.startswith("'"):
|
|
51
|
+
end = rest.find("'", 1)
|
|
52
|
+
if end == -1:
|
|
53
|
+
raise ValueError("unterminated single quote")
|
|
54
|
+
_trailing(rest[end + 1 :])
|
|
55
|
+
return rest[1:end]
|
|
56
|
+
if rest.startswith('"'):
|
|
57
|
+
out: list[str] = []
|
|
58
|
+
i = 1
|
|
59
|
+
while i < len(rest):
|
|
60
|
+
char = rest[i]
|
|
61
|
+
if char == "\\" and i + 1 < len(rest):
|
|
62
|
+
out.append(_DQ_ESCAPES.get(rest[i + 1], "\\" + rest[i + 1]))
|
|
63
|
+
i += 2
|
|
64
|
+
continue
|
|
65
|
+
if char == '"':
|
|
66
|
+
_trailing(rest[i + 1 :])
|
|
67
|
+
return "".join(out)
|
|
68
|
+
out.append(char)
|
|
69
|
+
i += 1
|
|
70
|
+
raise ValueError("unterminated double quote")
|
|
71
|
+
comment = _COMMENT.search(rest)
|
|
72
|
+
return (rest[: comment.start()] if comment else rest).strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _trailing(tail: str) -> None:
|
|
76
|
+
tail = tail.strip()
|
|
77
|
+
if tail and not tail.startswith("#"):
|
|
78
|
+
raise ValueError("unexpected text after the closing quote")
|
shtemplate/_errors.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Errors raised while parsing or expanding a template.
|
|
2
|
+
|
|
3
|
+
Every error is an :class:`ExpansionError`, which is a :class:`ValueError`, so a
|
|
4
|
+
single ``except ExpansionError`` (or ``except ValueError``) catches them all.
|
|
5
|
+
Each error records the zero-based ``position`` in the template where the
|
|
6
|
+
offending construct starts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExpansionError(ValueError):
|
|
13
|
+
"""A template is malformed or a required variable is unset.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
position: Zero-based index into the template of the construct that
|
|
17
|
+
failed, or ``None`` when no single position applies.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, position: int | None = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.position = position
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TemplateSyntaxError(ExpansionError):
|
|
26
|
+
"""The template cannot be parsed.
|
|
27
|
+
|
|
28
|
+
Raised for an unterminated ``${``, an invalid or unsupported parameter name
|
|
29
|
+
such as ``${}`` or ``${1}``, an unknown operator such as ``${X@y}``, an
|
|
30
|
+
unterminated quote inside a pattern, or nesting deeper than the parser allows.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnsetVariableError(ExpansionError):
|
|
35
|
+
"""Strict mode met a reference to a variable that is not set.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
name: The variable name.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, name: str, position: int) -> None:
|
|
42
|
+
super().__init__(f"undefined variable: {name} (at position {position})", position)
|
|
43
|
+
self.name = name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ParameterError(ExpansionError):
|
|
47
|
+
"""A ``${NAME:?word}`` or ``${NAME?word}`` expansion found its variable missing.
|
|
48
|
+
|
|
49
|
+
The message is ``"NAME: word"``, or the shell's default text when ``word``
|
|
50
|
+
is empty.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
name: The variable name.
|
|
54
|
+
detail: The expanded ``word`` (or the default text), without the name.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, name: str, detail: str, position: int) -> None:
|
|
58
|
+
super().__init__(f"{name}: {detail}", position)
|
|
59
|
+
self.name = name
|
|
60
|
+
self.detail = detail
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnsafeTemplateError(ExpansionError):
|
|
64
|
+
"""Safe mode rejected the template or stopped an oversized expansion.
|
|
65
|
+
|
|
66
|
+
Raised for command substitution (``$(...)``), backquotes and arithmetic
|
|
67
|
+
expansion (``$((...))``), none of which is ever executed, and when the output
|
|
68
|
+
would exceed the configured ``max_output``.
|
|
69
|
+
"""
|
shtemplate/_nodes.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Node types produced by the parser and consumed by the evaluator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
NAME_START = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_")
|
|
8
|
+
NAME_REST = NAME_START | frozenset("0123456789")
|
|
9
|
+
PATTERN_OPS = frozenset({"#", "##", "%", "%%"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Text:
|
|
14
|
+
"""Literal characters. ``quoted`` marks pattern characters that match literally."""
|
|
15
|
+
|
|
16
|
+
value: str
|
|
17
|
+
quoted: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Param:
|
|
22
|
+
"""One ``$NAME`` or ``${...}`` expansion.
|
|
23
|
+
|
|
24
|
+
``op`` is ``""`` (plain), ``"len"``, one of ``- = ? +`` or a pattern operator.
|
|
25
|
+
``quoted`` marks an expansion whose result must match literally in a pattern.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
position: int
|
|
30
|
+
op: str = ""
|
|
31
|
+
colon: bool = False
|
|
32
|
+
word: tuple[Node, ...] = ()
|
|
33
|
+
quoted: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Node = Text | Param
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Parsed:
|
|
41
|
+
"""A parsed template plus the positions of constructs that are never executed."""
|
|
42
|
+
|
|
43
|
+
nodes: tuple[Node, ...]
|
|
44
|
+
unsafe: tuple[tuple[int, str], ...] = field(default=())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge(nodes: list[Node]) -> tuple[Node, ...]:
|
|
48
|
+
"""Join runs of adjacent ``Text`` nodes that share the same ``quoted`` flag."""
|
|
49
|
+
merged: list[Node] = []
|
|
50
|
+
run: list[str] = []
|
|
51
|
+
quoted = False
|
|
52
|
+
for node in nodes:
|
|
53
|
+
if isinstance(node, Text) and (not run or node.quoted == quoted):
|
|
54
|
+
run.append(node.value)
|
|
55
|
+
quoted = node.quoted
|
|
56
|
+
continue
|
|
57
|
+
if run:
|
|
58
|
+
merged.append(Text("".join(run), quoted))
|
|
59
|
+
run = []
|
|
60
|
+
if isinstance(node, Text):
|
|
61
|
+
run, quoted = [node.value], node.quoted
|
|
62
|
+
else:
|
|
63
|
+
merged.append(node)
|
|
64
|
+
if run:
|
|
65
|
+
merged.append(Text("".join(run), quoted))
|
|
66
|
+
return tuple(merged)
|
shtemplate/_parser.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Parse a template into literal text and parameter-expansion nodes.
|
|
2
|
+
|
|
3
|
+
The template is read like the body of an unquoted shell here-document: only
|
|
4
|
+
``$``, backquote and backslash are special at the top level. Operator words are
|
|
5
|
+
read with the rules of POSIX.1-2024 2.6.2 and 2.2.3, so the closing brace is
|
|
6
|
+
found by recursive scanning and quotes inside a word are honoured.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
|
|
14
|
+
from ._errors import TemplateSyntaxError
|
|
15
|
+
from ._nodes import NAME_START, PATTERN_OPS, Node, Param, Parsed, Text, merge
|
|
16
|
+
|
|
17
|
+
MAX_DEPTH = 64
|
|
18
|
+
CACHE_LIMIT = 4096
|
|
19
|
+
_WORD_ESCAPES = frozenset('$`"\\}')
|
|
20
|
+
_TOP_ESCAPES = frozenset("$`\\")
|
|
21
|
+
_TOP_SPECIAL = re.compile(r"[$`\\]")
|
|
22
|
+
_WORD_SPECIAL = re.compile(r"[}\\\"'$`]")
|
|
23
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse(template: str) -> Parsed:
|
|
27
|
+
"""Parse ``template`` (cached up to ``CACHE_LIMIT`` characters); raise on bad syntax."""
|
|
28
|
+
return _parse_cached(template) if len(template) <= CACHE_LIMIT else _parse(template)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse(template: str) -> Parsed:
|
|
32
|
+
parser = _Parser(template)
|
|
33
|
+
return Parsed(parser.top(), tuple(parser.unsafe))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_parse_cached = lru_cache(maxsize=512)(_parse)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Parser:
|
|
40
|
+
def __init__(self, text: str) -> None:
|
|
41
|
+
self.text = text
|
|
42
|
+
self.pos = 0
|
|
43
|
+
self.depth = 0
|
|
44
|
+
self.unsafe: list[tuple[int, str]] = []
|
|
45
|
+
|
|
46
|
+
def _peek(self, offset: int = 0) -> str:
|
|
47
|
+
index = self.pos + offset
|
|
48
|
+
return self.text[index] if index < len(self.text) else ""
|
|
49
|
+
|
|
50
|
+
def top(self) -> tuple[Node, ...]:
|
|
51
|
+
nodes: list[Node] = []
|
|
52
|
+
while self.pos < len(self.text):
|
|
53
|
+
char = self.text[self.pos]
|
|
54
|
+
if char == "\\" and self._peek(1) in _TOP_ESCAPES and self._peek(1):
|
|
55
|
+
nodes.append(Text(self._peek(1)))
|
|
56
|
+
self.pos += 2
|
|
57
|
+
elif char == "\\" and self._peek(1) == "\n":
|
|
58
|
+
self.pos += 2
|
|
59
|
+
elif char == "$":
|
|
60
|
+
nodes.append(self._dollar(quoted=False, unquoted=False))
|
|
61
|
+
else:
|
|
62
|
+
nodes.append(Text(self._run(_TOP_SPECIAL)))
|
|
63
|
+
return merge(nodes)
|
|
64
|
+
|
|
65
|
+
def _run(self, special: re.Pattern[str]) -> str:
|
|
66
|
+
"""Consume one special character, or literal text up to the next special one."""
|
|
67
|
+
if self.text[self.pos] == "`":
|
|
68
|
+
self.unsafe.append((self.pos, "backquote command substitution"))
|
|
69
|
+
found = special.search(self.text, self.pos + 1)
|
|
70
|
+
end = found.start() if found else len(self.text)
|
|
71
|
+
if self.text[self.pos] in "`'":
|
|
72
|
+
end = self.pos + 1
|
|
73
|
+
chunk = self.text[self.pos : end]
|
|
74
|
+
self.pos = end
|
|
75
|
+
return chunk
|
|
76
|
+
|
|
77
|
+
def _read_name(self) -> str:
|
|
78
|
+
found = _NAME.match(self.text, self.pos)
|
|
79
|
+
if found is None:
|
|
80
|
+
return ""
|
|
81
|
+
self.pos = found.end()
|
|
82
|
+
return found.group()
|
|
83
|
+
|
|
84
|
+
def _dollar(self, quoted: bool, unquoted: bool) -> Node:
|
|
85
|
+
start = self.pos
|
|
86
|
+
nxt = self._peek(1)
|
|
87
|
+
if nxt == "{":
|
|
88
|
+
return self._braced(start, quoted, unquoted)
|
|
89
|
+
if nxt and nxt in NAME_START:
|
|
90
|
+
self.pos += 1
|
|
91
|
+
return Param(self._read_name(), start, quoted=quoted)
|
|
92
|
+
if nxt == "$":
|
|
93
|
+
self.pos += 2
|
|
94
|
+
return Text("$", quoted)
|
|
95
|
+
if nxt == "(":
|
|
96
|
+
kind = "arithmetic expansion" if self._peek(2) == "(" else "command substitution"
|
|
97
|
+
self.unsafe.append((start, kind))
|
|
98
|
+
self.pos += 1
|
|
99
|
+
return Text("$", quoted)
|
|
100
|
+
|
|
101
|
+
def _error(self, message: str, position: int) -> TemplateSyntaxError:
|
|
102
|
+
return TemplateSyntaxError(f"{message} at position {position}", position)
|
|
103
|
+
|
|
104
|
+
def _braced(self, start: int, quoted: bool, unquoted: bool) -> Param:
|
|
105
|
+
self.depth += 1
|
|
106
|
+
if self.depth > MAX_DEPTH:
|
|
107
|
+
raise self._error(f"expansions nested deeper than {MAX_DEPTH}", start)
|
|
108
|
+
self.pos += 2
|
|
109
|
+
if self._peek() == "#" and self._peek(1) in NAME_START and self._peek(1):
|
|
110
|
+
self.pos += 1
|
|
111
|
+
name = self._read_name()
|
|
112
|
+
if self._peek() != "}":
|
|
113
|
+
raise self._error("invalid length expansion", start)
|
|
114
|
+
self.pos += 1
|
|
115
|
+
self.depth -= 1
|
|
116
|
+
return Param(name, start, "len", quoted=quoted)
|
|
117
|
+
name = self._read_name()
|
|
118
|
+
if not name:
|
|
119
|
+
if not self._peek():
|
|
120
|
+
raise self._error("unterminated expansion: missing '}'", start)
|
|
121
|
+
raise self._error("invalid variable name in expansion", start)
|
|
122
|
+
op, colon = self._operator(start)
|
|
123
|
+
word: tuple[Node, ...] = ()
|
|
124
|
+
if op:
|
|
125
|
+
word = self._word(unquoted or op in PATTERN_OPS, start)
|
|
126
|
+
self.depth -= 1
|
|
127
|
+
return Param(name, start, op, colon, word, quoted)
|
|
128
|
+
|
|
129
|
+
def _operator(self, start: int) -> tuple[str, bool]:
|
|
130
|
+
char = self._peek()
|
|
131
|
+
if char == "}":
|
|
132
|
+
self.pos += 1
|
|
133
|
+
return "", False
|
|
134
|
+
if not char:
|
|
135
|
+
raise self._error("unterminated expansion: missing '}'", start)
|
|
136
|
+
colon = char == ":"
|
|
137
|
+
if colon:
|
|
138
|
+
self.pos += 1
|
|
139
|
+
char = self._peek()
|
|
140
|
+
if char and char in "-=?+":
|
|
141
|
+
self.pos += 1
|
|
142
|
+
return char, colon
|
|
143
|
+
if not colon and char in "#%":
|
|
144
|
+
double = self._peek(1) == char
|
|
145
|
+
self.pos += 2 if double else 1
|
|
146
|
+
return char * 2 if double else char, False
|
|
147
|
+
if not char:
|
|
148
|
+
raise self._error("unterminated expansion: missing '}'", start)
|
|
149
|
+
raise self._error(f"unknown operator {char!r} in expansion", start)
|
|
150
|
+
|
|
151
|
+
def _word(self, unquoted: bool, start: int) -> tuple[Node, ...]:
|
|
152
|
+
nodes: list[Node] = []
|
|
153
|
+
in_dq = False
|
|
154
|
+
while True:
|
|
155
|
+
char = self._peek()
|
|
156
|
+
if not char:
|
|
157
|
+
raise self._error("unterminated expansion: missing '}'", start)
|
|
158
|
+
if char == "}" and not in_dq:
|
|
159
|
+
self.pos += 1
|
|
160
|
+
return merge(nodes)
|
|
161
|
+
literal = unquoted and in_dq
|
|
162
|
+
if char == "\\":
|
|
163
|
+
nodes.extend(self._escape(unquoted, in_dq, start))
|
|
164
|
+
elif char == '"':
|
|
165
|
+
in_dq = not in_dq
|
|
166
|
+
self.pos += 1
|
|
167
|
+
elif char == "'" and unquoted and not in_dq:
|
|
168
|
+
nodes.append(Text(self._single_quoted(start), True))
|
|
169
|
+
elif char == "$":
|
|
170
|
+
nodes.append(self._dollar(literal, unquoted and not in_dq))
|
|
171
|
+
else:
|
|
172
|
+
nodes.append(Text(self._run(_WORD_SPECIAL), literal))
|
|
173
|
+
|
|
174
|
+
def _escape(self, unquoted: bool, in_dq: bool, start: int) -> list[Node]:
|
|
175
|
+
nxt = self._peek(1)
|
|
176
|
+
if not nxt:
|
|
177
|
+
raise self._error("unterminated expansion: missing '}'", start)
|
|
178
|
+
if nxt == "\n":
|
|
179
|
+
self.pos += 2
|
|
180
|
+
return []
|
|
181
|
+
if unquoted and not in_dq:
|
|
182
|
+
self.pos += 2
|
|
183
|
+
return [Text(nxt, True)]
|
|
184
|
+
if nxt in _WORD_ESCAPES:
|
|
185
|
+
self.pos += 2
|
|
186
|
+
return [Text(nxt, unquoted)]
|
|
187
|
+
self.pos += 1
|
|
188
|
+
return [Text("\\", unquoted)]
|
|
189
|
+
|
|
190
|
+
def _single_quoted(self, start: int) -> str:
|
|
191
|
+
end = self.text.find("'", self.pos + 1)
|
|
192
|
+
if end == -1:
|
|
193
|
+
raise self._error("unterminated single quote", start)
|
|
194
|
+
content = self.text[self.pos + 1 : end]
|
|
195
|
+
self.pos = end + 1
|
|
196
|
+
return content
|
shtemplate/_pattern.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Shell pattern matching notation (POSIX.1-2024 2.14) for prefix and suffix removal.
|
|
2
|
+
|
|
3
|
+
A pattern is held as a string where a backslash makes the next character
|
|
4
|
+
literal. ``?`` matches one character, ``*`` any run of characters and ``[...]``
|
|
5
|
+
a bracket expression. Matching simulates the pattern as a small automaton, so
|
|
6
|
+
every prefix of a value is tested in one pass: ``O(len(value) * len(pattern))``
|
|
7
|
+
time with no backtracking blowups, whatever the pattern.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import string
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
from ._errors import UnsafeTemplateError
|
|
17
|
+
|
|
18
|
+
Matcher = Callable[[str], bool]
|
|
19
|
+
_STAR: Matcher = bool # sentinel identity; never called
|
|
20
|
+
|
|
21
|
+
_CLASSES: dict[str, Matcher] = {
|
|
22
|
+
"alnum": str.isalnum,
|
|
23
|
+
"alpha": str.isalpha,
|
|
24
|
+
"blank": lambda c: c in " \t",
|
|
25
|
+
"cntrl": lambda c: ord(c) < 32 or ord(c) == 127,
|
|
26
|
+
"digit": lambda c: c in string.digits,
|
|
27
|
+
"graph": lambda c: c.isprintable() and not c.isspace(),
|
|
28
|
+
"lower": str.islower,
|
|
29
|
+
"print": str.isprintable,
|
|
30
|
+
"punct": lambda c: c in string.punctuation,
|
|
31
|
+
"space": str.isspace,
|
|
32
|
+
"upper": str.isupper,
|
|
33
|
+
"xdigit": lambda c: c in string.hexdigits,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _never(_: str) -> bool:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bracket(pattern: str, start: int) -> tuple[Matcher, int] | None:
|
|
42
|
+
"""Parse a bracket expression at ``pattern[start] == "["``; None if invalid."""
|
|
43
|
+
j = start + 1
|
|
44
|
+
negate = j < len(pattern) and pattern[j] in "!^"
|
|
45
|
+
j += 1 if negate else 0
|
|
46
|
+
chars: set[str] = set()
|
|
47
|
+
ranges: list[tuple[str, str]] = []
|
|
48
|
+
tests: list[Matcher] = []
|
|
49
|
+
first = True
|
|
50
|
+
while j < len(pattern):
|
|
51
|
+
char = pattern[j]
|
|
52
|
+
if char == "]" and not first:
|
|
53
|
+
return _member(negate, frozenset(chars), tuple(ranges), tuple(tests)), j + 1
|
|
54
|
+
first = False
|
|
55
|
+
if char == "[" and pattern[j + 1 : j + 2] in (":", "=", "."):
|
|
56
|
+
kind = pattern[j + 1]
|
|
57
|
+
end = pattern.find(kind + "]", j + 2)
|
|
58
|
+
if end != -1:
|
|
59
|
+
name = pattern[j + 2 : end]
|
|
60
|
+
if kind == ":":
|
|
61
|
+
tests.append(_CLASSES.get(name, _never))
|
|
62
|
+
elif len(name) == 1:
|
|
63
|
+
chars.add(name)
|
|
64
|
+
j = end + 2
|
|
65
|
+
continue
|
|
66
|
+
low, j = _bracket_char(pattern, j)
|
|
67
|
+
if pattern[j : j + 1] == "-" and j + 1 < len(pattern) and pattern[j + 1] != "]":
|
|
68
|
+
high, j = _bracket_char(pattern, j + 1)
|
|
69
|
+
ranges.append((low, high))
|
|
70
|
+
else:
|
|
71
|
+
chars.add(low)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bracket_char(pattern: str, j: int) -> tuple[str, int]:
|
|
76
|
+
if pattern[j] == "\\" and j + 1 < len(pattern):
|
|
77
|
+
return pattern[j + 1], j + 2
|
|
78
|
+
return pattern[j], j + 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _member(
|
|
82
|
+
negate: bool,
|
|
83
|
+
chars: frozenset[str],
|
|
84
|
+
ranges: tuple[tuple[str, str], ...],
|
|
85
|
+
tests: tuple[Matcher, ...],
|
|
86
|
+
) -> Matcher:
|
|
87
|
+
def matches(char: str) -> bool:
|
|
88
|
+
found = (
|
|
89
|
+
char in chars
|
|
90
|
+
or any(low <= char <= high for low, high in ranges)
|
|
91
|
+
or any(test(char) for test in tests)
|
|
92
|
+
)
|
|
93
|
+
return found != negate
|
|
94
|
+
|
|
95
|
+
return matches
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _literal(expected: str) -> Matcher:
|
|
99
|
+
return expected.__eq__
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _any(_: str) -> bool:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def compile_pattern(pattern: str) -> tuple[Matcher, ...]:
|
|
107
|
+
"""Turn ``pattern`` into a tuple of single-character matchers and ``_STAR`` marks."""
|
|
108
|
+
if len(pattern) <= 1024:
|
|
109
|
+
return _compile_cached(pattern)
|
|
110
|
+
return _compile(pattern)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _compile(pattern: str) -> tuple[Matcher, ...]:
|
|
114
|
+
items: list[Matcher] = []
|
|
115
|
+
i = 0
|
|
116
|
+
while i < len(pattern):
|
|
117
|
+
char = pattern[i]
|
|
118
|
+
if char == "\\" and i + 1 < len(pattern):
|
|
119
|
+
items.append(_literal(pattern[i + 1]))
|
|
120
|
+
i += 2
|
|
121
|
+
continue
|
|
122
|
+
if char == "*":
|
|
123
|
+
if not items or items[-1] is not _STAR:
|
|
124
|
+
items.append(_STAR)
|
|
125
|
+
elif char == "?":
|
|
126
|
+
items.append(_any)
|
|
127
|
+
elif char == "[" and (parsed := _bracket(pattern, i)) is not None:
|
|
128
|
+
items.append(parsed[0])
|
|
129
|
+
i = parsed[1]
|
|
130
|
+
continue
|
|
131
|
+
else:
|
|
132
|
+
items.append(_literal(char))
|
|
133
|
+
i += 1
|
|
134
|
+
return tuple(items)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_compile_cached = lru_cache(maxsize=512)(_compile)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _closure(states: set[int], items: tuple[Matcher, ...]) -> set[int]:
|
|
141
|
+
for state in sorted(states):
|
|
142
|
+
index = state
|
|
143
|
+
while index < len(items) and items[index] is _STAR:
|
|
144
|
+
index += 1
|
|
145
|
+
states.add(index)
|
|
146
|
+
return states
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def match_lengths(items: tuple[Matcher, ...], text: str) -> list[int]:
|
|
150
|
+
"""Return every ``n`` (ascending) such that ``text[:n]`` matches the whole pattern."""
|
|
151
|
+
end = len(items)
|
|
152
|
+
states = _closure({0}, items)
|
|
153
|
+
found = [0] if end in states else []
|
|
154
|
+
for count, char in enumerate(text, start=1):
|
|
155
|
+
step: set[int] = set()
|
|
156
|
+
for state in states:
|
|
157
|
+
if state == end:
|
|
158
|
+
continue
|
|
159
|
+
item = items[state]
|
|
160
|
+
if item is _STAR:
|
|
161
|
+
step.add(state)
|
|
162
|
+
elif item(char):
|
|
163
|
+
step.add(state + 1)
|
|
164
|
+
if not step:
|
|
165
|
+
break
|
|
166
|
+
states = _closure(step, items)
|
|
167
|
+
if end in states:
|
|
168
|
+
found.append(count)
|
|
169
|
+
return found
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def remove(value: str, pattern: str, op: str, work_limit: int | None = None) -> str:
|
|
173
|
+
"""Apply ``#``, ``##``, ``%`` or ``%%`` with ``pattern`` to ``value``.
|
|
174
|
+
|
|
175
|
+
With ``work_limit``, raise :class:`UnsafeTemplateError` instead of matching
|
|
176
|
+
when ``len(value) * len(pattern)`` (the worst-case step count) exceeds it.
|
|
177
|
+
"""
|
|
178
|
+
items = compile_pattern(pattern)
|
|
179
|
+
if work_limit is not None and len(value) * max(len(items), 1) > work_limit:
|
|
180
|
+
raise UnsafeTemplateError(
|
|
181
|
+
f"pattern matching would take more than {work_limit} steps "
|
|
182
|
+
f"({len(value)} characters against {len(items)} pattern items)"
|
|
183
|
+
)
|
|
184
|
+
if op.startswith("#"):
|
|
185
|
+
lengths = match_lengths(items, value)
|
|
186
|
+
if not lengths:
|
|
187
|
+
return value
|
|
188
|
+
return value[(lengths[0] if op == "#" else lengths[-1]) :]
|
|
189
|
+
lengths = match_lengths(items[::-1], value[::-1])
|
|
190
|
+
if not lengths:
|
|
191
|
+
return value
|
|
192
|
+
cut = lengths[0] if op == "%" else lengths[-1]
|
|
193
|
+
return value[: len(value) - cut]
|
shtemplate/_tools.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Helpers that work on a template without expanding it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._nodes import Node, Param
|
|
6
|
+
from ._parser import parse
|
|
7
|
+
|
|
8
|
+
_ESCAPES = {"\\": "\\\\", "$": "\\$", "`": "\\`"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_vars(template: str) -> list[str]:
|
|
12
|
+
"""Return every variable name ``template`` references, in order of first use.
|
|
13
|
+
|
|
14
|
+
Names inside operator words are included even when that word would not be
|
|
15
|
+
needed, so the result is the full set of names the template may read.
|
|
16
|
+
Nothing is expanded and no mapping is needed.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
TemplateSyntaxError: The template is malformed.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> list_vars("$HOME/${APP:-demo}/${APP_DIR:-$HOME/.app}")
|
|
23
|
+
['HOME', 'APP', 'APP_DIR']
|
|
24
|
+
"""
|
|
25
|
+
seen: dict[str, None] = {}
|
|
26
|
+
_collect(parse(template).nodes, seen)
|
|
27
|
+
return list(seen)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _collect(nodes: tuple[Node, ...], seen: dict[str, None]) -> None:
|
|
31
|
+
for node in nodes:
|
|
32
|
+
if isinstance(node, Param):
|
|
33
|
+
seen.setdefault(node.name, None)
|
|
34
|
+
_collect(node.word, seen)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def escape(text: str) -> str:
|
|
38
|
+
r"""Return a template that expands to exactly ``text``.
|
|
39
|
+
|
|
40
|
+
Backslash, dollar and backquote are escaped with a backslash, so
|
|
41
|
+
``expand(escape(text)) == text`` for every string, and a POSIX shell reading
|
|
42
|
+
the result as a here-document body prints ``text`` too.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> print(escape(r"cost: $5 \o/"))
|
|
46
|
+
cost: \$5 \\o/
|
|
47
|
+
"""
|
|
48
|
+
return "".join(_ESCAPES.get(char, char) for char in text)
|
shtemplate/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shtemplate
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: POSIX shell parameter expansion ($VAR, ${VAR:-default}, ${VAR%pattern}, ...) over any mapping, with a CLI; typed, safe, zero-dependency
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/shtemplate
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/shtemplate/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/shtemplate/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: dotenv,environment,envsubst,expandvars,parameter-expansion,posix,shell,template
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Topic :: Text Processing
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
33
|
+
Requires-Dist: twine; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# shtemplate
|
|
37
|
+
|
|
38
|
+
POSIX shell parameter expansion (`$VAR`, `${VAR:-default}`, `${VAR%pattern}`, ...) over
|
|
39
|
+
any mapping, with a command line tool. Typed, safe for untrusted templates, zero dependencies.
|
|
40
|
+
|
|
41
|
+
Outputs are cross-checked against `dash` and `bash --posix` on a corpus of more than 1,600
|
|
42
|
+
templates. Nothing in a template is ever executed.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install shtemplate
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from shtemplate import expand
|
|
54
|
+
|
|
55
|
+
values = {"USER": "ada", "HOME": "/home/ada", "FILE": "report.tar.gz", "EMPTY": ""}
|
|
56
|
+
|
|
57
|
+
expand("$HOME/${APP:-demo}", values) # "/home/ada/demo"
|
|
58
|
+
expand("${EMPTY:-guest} [${EMPTY-guest}]", values) # "guest []"
|
|
59
|
+
expand("${USER:+signed in as $USER}", values) # "signed in as ada"
|
|
60
|
+
expand("${CONF:-${XDG:-$HOME/.config}}/app", values) # "/home/ada/.config/app"
|
|
61
|
+
|
|
62
|
+
# Pattern removal and length
|
|
63
|
+
expand("${FILE%.gz} ${FILE%%.*} ${FILE#*.} ${FILE##*.}", values) # "report.tar report tar.gz gz"
|
|
64
|
+
expand("${#USER}", values) # "3"
|
|
65
|
+
|
|
66
|
+
# Required values raise a typed error that names the variable
|
|
67
|
+
expand("${TOKEN:?token is required}", values) # ParameterError: TOKEN: token is required
|
|
68
|
+
|
|
69
|
+
# Escapes: \$ and $$ are a literal dollar
|
|
70
|
+
expand("price \\$5 or $$5", values) # "price $5 or $5"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`env` is any `Mapping[str, str]` and is never mutated. It defaults to an empty mapping, so
|
|
74
|
+
nothing is read from the process environment unless you pass `os.environ` yourself.
|
|
75
|
+
|
|
76
|
+
### Strict and safe modes
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from shtemplate import expand
|
|
80
|
+
|
|
81
|
+
expand("$MISSING", {}, strict=True) # UnsetVariableError: undefined variable: MISSING ...
|
|
82
|
+
expand("${MISSING:-ok}", {}, strict=True) # "ok": the default handles the unset case
|
|
83
|
+
|
|
84
|
+
expand("$(rm -rf ~)", {}) # "$(rm -rf ~)": left as literal text, never run
|
|
85
|
+
expand("$(rm -rf ~)", {}, safe=True) # UnsafeTemplateError: command substitution ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`strict=True` behaves like `set -u`. `safe=True` is meant for templates you do not trust: it
|
|
89
|
+
rejects `$(...)`, `$((...))` and backquotes, caps the output at one million characters
|
|
90
|
+
(`max_output=` to change it) and bounds pattern matching work to match.
|
|
91
|
+
|
|
92
|
+
### Inspecting and escaping
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from shtemplate import escape, expand, list_vars
|
|
96
|
+
|
|
97
|
+
list_vars("$HOME/${APP:-demo}/${LOG:-$HOME/log}") # ["HOME", "APP", "LOG"]
|
|
98
|
+
expand(escape("cost: $5"), {}) # "cost: $5"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Assignment
|
|
102
|
+
|
|
103
|
+
`${NAME:=word}` and `${NAME=word}` substitute `word` and make it visible to later references
|
|
104
|
+
in the same template. The mapping you pass is read-only to `shtemplate`; collect the
|
|
105
|
+
assignments with `assigned=` (pass the same dict as `env` to update it in place).
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from shtemplate import expand
|
|
109
|
+
|
|
110
|
+
sink: dict[str, str] = {}
|
|
111
|
+
expand("${PORT:=8080} on $PORT", {}, assigned=sink) # "8080 on 8080"; sink == {"PORT": "8080"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Supported forms
|
|
115
|
+
|
|
116
|
+
| Form | Result |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `${NAME}` | the value; empty if unset (an error in strict mode) |
|
|
119
|
+
| `$NAME` | same, using the longest valid name |
|
|
120
|
+
| `${NAME:-word}` | `word` if unset or empty |
|
|
121
|
+
| `${NAME-word}` | `word` if unset |
|
|
122
|
+
| `${NAME:=word}` | `word` if unset or empty, and assign it |
|
|
123
|
+
| `${NAME=word}` | `word` if unset, and assign it |
|
|
124
|
+
| `${NAME:+word}` | `word` if set and not empty, else empty |
|
|
125
|
+
| `${NAME+word}` | `word` if set, else empty |
|
|
126
|
+
| `${NAME:?word}` | raise `ParameterError` if unset or empty |
|
|
127
|
+
| `${NAME?word}` | raise `ParameterError` if unset |
|
|
128
|
+
| `${#NAME}` | the length in characters |
|
|
129
|
+
| `${NAME%word}` | remove the shortest suffix matching the pattern `word` |
|
|
130
|
+
| `${NAME%%word}` | remove the longest matching suffix |
|
|
131
|
+
| `${NAME#word}` | remove the shortest matching prefix |
|
|
132
|
+
| `${NAME##word}` | remove the longest matching prefix |
|
|
133
|
+
| `$$` | a literal `$` |
|
|
134
|
+
| `\$NAME` | a literal `$NAME` (backslash also escapes backquote and backslash) |
|
|
135
|
+
|
|
136
|
+
`word` is expanded only when it is needed, so it may contain other expansions. Patterns use
|
|
137
|
+
shell notation: `*`, `?`, `[a-z]`, `[!...]`, `[[:alpha:]]` and backslash or quotes for literal
|
|
138
|
+
characters. Malformed templates raise `TemplateSyntaxError` with the position. Every error is an
|
|
139
|
+
`ExpansionError`, which is a `ValueError`. The [GUIDE](GUIDE.md) covers the exact rules, the
|
|
140
|
+
design decisions and the differences from the shell and from `string.Template`.
|
|
141
|
+
|
|
142
|
+
## Command line
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
echo 'Hello ${USER:-world}' | shtemplate
|
|
146
|
+
shtemplate config.tpl -o config.ini
|
|
147
|
+
shtemplate -i --env-file prod.env --set PORT=8080 config.tpl
|
|
148
|
+
shtemplate --strict --safe untrusted.tpl
|
|
149
|
+
shtemplate --list-vars config.tpl
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Variables come from the process environment, then each `--env-file` (`NAME=VALUE` lines, never
|
|
153
|
+
expanded), then each `--set`. `-i` starts from an empty environment. Errors go to stderr with
|
|
154
|
+
exit status 1. `python -m shtemplate` works too.
|
|
155
|
+
|
|
156
|
+
## Why
|
|
157
|
+
|
|
158
|
+
Python's `string.Template` understands `$NAME` and `${NAME}` only: no defaults, no required
|
|
159
|
+
values, no pattern removal. `os.path.expandvars` silently leaves unknown names in place and
|
|
160
|
+
reads only `os.environ`. `shtemplate` gives you the shell's full parameter expansion as a pure
|
|
161
|
+
function over a mapping you choose, with typed errors, a strict mode, a safe mode for
|
|
162
|
+
untrusted input and a CLI that does what `envsubst` does, plus defaults, required values and
|
|
163
|
+
pattern removal.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT. See [LICENSE](LICENSE).
|
|
168
|
+
|
|
169
|
+
## Links
|
|
170
|
+
|
|
171
|
+
- Website: https://lianes8studio.com
|
|
172
|
+
- GitHub: https://github.com/JaviLianes8
|
|
173
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
shtemplate/__init__.py,sha256=MAqpeNWuMsxf1aJ8o9b7-iI6DAJubvC-AHVXHWmkOTA,642
|
|
2
|
+
shtemplate/__main__.py,sha256=rJerH6mauVj6yv2u18MuZJmbqerga-STXhWvEw50Uf4,113
|
|
3
|
+
shtemplate/_cli.py,sha256=Fu9mNH98TncIwrO350CO_AUL7R3knOR2aXFL462frHM,4130
|
|
4
|
+
shtemplate/_core.py,sha256=B7xSENPShNfbEhMucQTHnDvFJYb6hw_jYvRsxYoFc-U,6483
|
|
5
|
+
shtemplate/_dotenv.py,sha256=su9GRV0yBPsD1zlQDrG9iZHKfxhJp3rsQkz4pm1Nff0,2753
|
|
6
|
+
shtemplate/_errors.py,sha256=da580QX0EPNF9CNdangEWVUk6OMOJubc03Elfq1Y1fQ,2310
|
|
7
|
+
shtemplate/_nodes.py,sha256=6TixWsvR_K4M12PXghKVbaL0Eb3uS7aHQcG0IhXFgt0,1825
|
|
8
|
+
shtemplate/_parser.py,sha256=IspFEeKJCwSF0iwHz_zVJSkUAEIUYBLdtS31wT0TU5s,7289
|
|
9
|
+
shtemplate/_pattern.py,sha256=aeY5z-qCBeKHfCicE_VF2xzMOFxkm45vrYzZ68ldHQ8,6247
|
|
10
|
+
shtemplate/_tools.py,sha256=Lr2W5EhZIUJu0waPrOOTi6tLFkecFINgo-GMqxMGBr8,1494
|
|
11
|
+
shtemplate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
shtemplate-0.2.1.dist-info/METADATA,sha256=6sI4e8suUKdCVHLiLD479efefPPlnPl8aFJtA-xK1qk,6895
|
|
13
|
+
shtemplate-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
shtemplate-0.2.1.dist-info/entry_points.txt,sha256=pJ8Nuq7I_ckFLCxktkDXWllefHcpkscg4MnJSNV4Xtk,52
|
|
15
|
+
shtemplate-0.2.1.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
16
|
+
shtemplate-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|