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 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
@@ -0,0 +1,5 @@
1
+ """Run the command line tool with ``python -m shtemplate``."""
2
+
3
+ from ._cli import main
4
+
5
+ raise SystemExit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shtemplate = shtemplate._cli:main
@@ -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.