shtemplate 0.2.1__tar.gz

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.
Files changed (39) hide show
  1. shtemplate-0.2.1/.gitignore +16 -0
  2. shtemplate-0.2.1/CHANGELOG.md +60 -0
  3. shtemplate-0.2.1/GUIDE.md +163 -0
  4. shtemplate-0.2.1/LICENSE +21 -0
  5. shtemplate-0.2.1/PKG-INFO +173 -0
  6. shtemplate-0.2.1/README.md +138 -0
  7. shtemplate-0.2.1/pyproject.toml +78 -0
  8. shtemplate-0.2.1/scripts/build_corpus.py +114 -0
  9. shtemplate-0.2.1/scripts/corpus_cases.py +160 -0
  10. shtemplate-0.2.1/scripts/e2e_wheel.py +116 -0
  11. shtemplate-0.2.1/scripts/fetch_upstream.py +37 -0
  12. shtemplate-0.2.1/scripts/oracle.py +93 -0
  13. shtemplate-0.2.1/src/shtemplate/__init__.py +31 -0
  14. shtemplate-0.2.1/src/shtemplate/__main__.py +5 -0
  15. shtemplate-0.2.1/src/shtemplate/_cli.py +108 -0
  16. shtemplate-0.2.1/src/shtemplate/_core.py +159 -0
  17. shtemplate-0.2.1/src/shtemplate/_dotenv.py +78 -0
  18. shtemplate-0.2.1/src/shtemplate/_errors.py +69 -0
  19. shtemplate-0.2.1/src/shtemplate/_nodes.py +66 -0
  20. shtemplate-0.2.1/src/shtemplate/_parser.py +196 -0
  21. shtemplate-0.2.1/src/shtemplate/_pattern.py +193 -0
  22. shtemplate-0.2.1/src/shtemplate/_tools.py +48 -0
  23. shtemplate-0.2.1/src/shtemplate/py.typed +0 -0
  24. shtemplate-0.2.1/tests/conftest.py +34 -0
  25. shtemplate-0.2.1/tests/data/oracle_corpus.json +19744 -0
  26. shtemplate-0.2.1/tests/test_cli.py +141 -0
  27. shtemplate-0.2.1/tests/test_contract.py +160 -0
  28. shtemplate-0.2.1/tests/test_docs.py +75 -0
  29. shtemplate-0.2.1/tests/test_dotenv.py +59 -0
  30. shtemplate-0.2.1/tests/test_e2e_wheel.py +24 -0
  31. shtemplate-0.2.1/tests/test_errors.py +132 -0
  32. shtemplate-0.2.1/tests/test_expand.py +118 -0
  33. shtemplate-0.2.1/tests/test_fuzz.py +132 -0
  34. shtemplate-0.2.1/tests/test_operators.py +122 -0
  35. shtemplate-0.2.1/tests/test_oracle.py +81 -0
  36. shtemplate-0.2.1/tests/test_patterns.py +93 -0
  37. shtemplate-0.2.1/tests/test_spec_examples.py +68 -0
  38. shtemplate-0.2.1/tests/test_tools.py +48 -0
  39. shtemplate-0.2.1/tests/upstream/MANIFEST.json +11 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party test files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-10-13
10
+
11
+ ### Changed
12
+
13
+ - The excerpt of the POSIX standard is no longer committed or shipped in the sdist: the
14
+ cited example pairs stay in the tests, and `scripts/fetch_upstream.py` fetches the page,
15
+ checked against `tests/upstream/MANIFEST.json`, for the tests that prove each pair is on it.
16
+
17
+ ## [0.2.0]
18
+
19
+ ### Added
20
+
21
+ - `${#NAME}` length and the pattern removals `${NAME%word}`, `${NAME%%word}`,
22
+ `${NAME#word}` and `${NAME##word}` with shell pattern matching notation: `*`, `?`,
23
+ bracket expressions with ranges, negation and character classes, and quoting.
24
+ - `${NAME:=word}` and `${NAME=word}`: the assigned value is visible to later references in
25
+ the same template; the mapping is never mutated. The new `assigned=` argument collects
26
+ the assignments.
27
+ - Nested words: the word of an operator is expanded when needed (`${A:-${B:-x}}`), and the
28
+ closing brace is found by recursive scanning, honouring quotes and `\}`.
29
+ - Backslash escapes with here-document rules: `\$`, `` \` ``, `\\` and line continuation.
30
+ - Typed errors with a `position`: `TemplateSyntaxError`, `UnsetVariableError`,
31
+ `ParameterError` and `UnsafeTemplateError`, all subclasses of `ExpansionError`.
32
+ - `safe=True` for untrusted templates: rejects `$(...)`, `$((...))` and backquotes and caps
33
+ the output size; `max_output=` sets the cap explicitly.
34
+ - `list_vars()` lists the referenced names without expanding; `escape()` turns any text into
35
+ a template that expands to it.
36
+ - The `shtemplate` command (also `python -m shtemplate`): expands a file or stdin against the
37
+ environment, `--env-file` files and `--set` values, with `--strict`, `--safe`,
38
+ `--list-vars`, `-i` and `-o`.
39
+ - GUIDE.md with the exact rules, the design decisions and the differences from the shell and
40
+ from `string.Template`.
41
+ - A corpus of more than 1,600 templates checked against dash and bash --posix.
42
+
43
+ ### Changed (breaking)
44
+
45
+ These change the output of some 0.1.0 templates, most visibly ones with a backslash before
46
+ `$` (such as Windows paths) or a `$` inside an operator word.
47
+
48
+ - The word of `:-`, `-`, `:+`, `+`, `:?` and `?` is now expanded as in the shell
49
+ (`${X:-$HOME/bin}` uses the value of `HOME`). In 0.1.0 it was taken literally; write
50
+ `\$` for a literal dollar inside a word.
51
+ - A backslash before `$`, backquote or backslash is now an escape, as in the shell.
52
+ - `strict=True` now also applies to `${#NAME}` and the pattern removals, like `set -u`.
53
+ - The `:?` message is now `NAME: message`, as the shell prints it.
54
+
55
+ ## [0.1.0]
56
+
57
+ - Add `expand`: POSIX shell-style expansion of `$VAR` and `${VAR}` over a mapping.
58
+ - Support the `:-`, `-`, `:+`, `+`, `:?` and `?` operators and the `$$` literal.
59
+ - Add an opt-in strict mode that raises on an unset plain reference.
60
+ - Add `ExpansionError` for malformed templates and required variables.
@@ -0,0 +1,163 @@
1
+ # shtemplate guide
2
+
3
+ This guide states exactly what `shtemplate` does, which choices it makes where the shell
4
+ standard leaves room, and how it differs from a real shell and from `string.Template`.
5
+
6
+ The reference is POSIX.1-2024, Shell Command Language, section 2.6.2 Parameter Expansion,
7
+ section 2.14 Pattern Matching Notation and section 2.2 Quoting:
8
+ https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html
9
+
10
+ ## The model: a here-document body
11
+
12
+ A template is read the way a POSIX shell reads the body of an unquoted here-document
13
+ (`cat <<EOF`). That is the context where a shell expands parameters in free text without
14
+ splitting words or globbing, so it is the natural meaning of "shell-style template".
15
+
16
+ At the top level of a template:
17
+
18
+ - `$NAME` and `${...}` are expanded. Without braces the longest valid name is used, so `$Ab`
19
+ reads `Ab`. A name is an ASCII letter or underscore followed by letters, digits or
20
+ underscores.
21
+ - A backslash escapes `$`, backquote, backslash and newline: `\$` is `$`, `\\` is `\`,
22
+ and a backslash before a newline joins the two lines. Before any other character the
23
+ backslash is kept (`\n` stays two characters).
24
+ - Single and double quotes are ordinary characters.
25
+ - `$$` is a literal `$` (see "Differences from a shell").
26
+ - A `$` that does not start a name or a brace stays literal: `$5`, `$ `, `$-`, `$1`.
27
+
28
+ ## Operators
29
+
30
+ | Operator | Set and not empty | Set but empty | Unset |
31
+ | --- | --- | --- | --- |
32
+ | `${p:-w}` | value | `w` | `w` |
33
+ | `${p-w}` | value | empty | `w` |
34
+ | `${p:=w}` | value | assign `w` | assign `w` |
35
+ | `${p=w}` | value | empty | assign `w` |
36
+ | `${p:?w}` | value | error | error |
37
+ | `${p?w}` | value | empty | error |
38
+ | `${p:+w}` | `w` | empty | empty |
39
+ | `${p+w}` | `w` | `w` | empty |
40
+
41
+ `${#p}` is the length of the value in characters (0 when unset). `${p%w}`, `${p%%w}`,
42
+ `${p#w}` and `${p##w}` remove the shortest or longest suffix or prefix that matches the
43
+ pattern `w`; when nothing matches the value is unchanged.
44
+
45
+ The word `w` is expanded only when its value is needed. An unused word may contain a
46
+ `${X:?...}` that never fires or an assignment that never happens. Syntax errors are still
47
+ reported for the whole template, used or not, because the template is parsed first.
48
+
49
+ ### Words of the defaulting operators
50
+
51
+ Inside the word of `-`, `=`, `?` and `+` (written in the here-document context):
52
+
53
+ - `$NAME` and `${...}` expand, and nest to any depth up to 64 levels.
54
+ - A backslash escapes `$`, backquote, `"`, `\` and `}`. `${X:-a\}b}` gives `a}b`.
55
+ - Double quotes are removed and protect a `}`: `${X:-"x}"y}` gives `x}y`. The standard
56
+ leaves unescaped double quotes here unspecified; dash and bash both behave this way.
57
+ - Single quotes are ordinary characters: `${X:-'a'}` gives `'a'`, exactly as the shells do.
58
+
59
+ ### Words of the pattern operators
60
+
61
+ The standard says the enclosing double quotes have no effect on the four pattern
62
+ operators, so their word is read as unquoted shell text:
63
+
64
+ - `*`, `?` and `[...]` are pattern characters.
65
+ - A backslash quotes the next character, single quotes quote their content and double
66
+ quotes quote theirs (with `$` still expanding inside them). Quoted characters match
67
+ themselves: `${V#'*'}` removes a literal star.
68
+ - The result of an unquoted `$NAME` inside the pattern is itself a pattern, so
69
+ `${V#$PREFIX}` treats a `*` in `PREFIX` as a wildcard. Write `${V#"$PREFIX"}` to match it
70
+ literally.
71
+ - Words nested inside a pattern word follow the same unquoted rules.
72
+
73
+ If the variable is unset its pattern word is not expanded at all; dash and bash agree.
74
+
75
+ ### Pattern matching notation
76
+
77
+ - `?` matches one character, `*` any run of characters including none.
78
+ - `[abc]`, ranges `[a-z]`, negation with `[!...]` (also `[^...]`, like bash), a leading `]`
79
+ as a member (`[]a]`), character classes `[[:alpha:]]` (`alnum alpha blank cntrl digit
80
+ graph lower print punct space upper xdigit`) and the one-character forms `[=a=]` and
81
+ `[.a.]`. An unknown class matches nothing. A `[` that does not start a valid bracket
82
+ expression matches itself.
83
+ - Ranges compare code points. Classes use Python's Unicode string methods, except `digit`,
84
+ `xdigit` and `punct`, which are ASCII, and `blank`, which is space and tab.
85
+ - Matching runs as an automaton over all prefixes at once, in
86
+ `O(len(value) * len(pattern))` time. No pattern can make it backtrack exponentially.
87
+ That product can still be large when both sides are long, so whenever `max_output` is set
88
+ (always in safe mode) a removal that would need more than ten times `max_output` steps
89
+ raises `UnsafeTemplateError` before matching starts.
90
+
91
+ ## Decisions where a mapping is not a shell
92
+
93
+ - **Assignment.** `${p:=w}` and `${p=w}` cannot assign into a read-only mapping, and
94
+ `shtemplate` never mutates the mapping you pass. The assigned value lives in an overlay
95
+ for the rest of the expansion, so `${PORT:=80} $PORT` gives `80 80`, as in a shell. Pass
96
+ `assigned=` to receive the assignments after a successful expansion; it is not touched when
97
+ the expansion fails. Passing the same dict as `env` and `assigned` updates it in place.
98
+ - **`:?` and `?`.** A shell prints the message and exits. `shtemplate` raises
99
+ `ParameterError` with `name`, `detail` and `position`; `str(error)` is `NAME: message`, or
100
+ `NAME: parameter null or not set` (`NAME: parameter not set` for `?`) when the word is empty,
101
+ matching dash.
102
+ - **Strict mode** is `set -u`: a plain reference, `${#p}` or a pattern removal on an unset
103
+ variable raises `UnsetVariableError`. The defaulting operators still work, and an empty
104
+ value counts as set.
105
+ - **Command substitution and arithmetic** (`$(...)`, backquotes, `$((...))`) are never
106
+ executed. By default they are left in the output as literal text. In safe mode they raise
107
+ `UnsafeTemplateError`. Escaped forms (`\$(`, `` \` ``) are plain text in both modes.
108
+ - **Safe mode** also caps the output at one million characters (`max_output=` changes it,
109
+ and works without safe mode too). Assignments can double a value at every step, so a short
110
+ template could otherwise produce gigabytes. The same limit bounds pattern matching work, as
111
+ described above, so a few hundred bytes of template cannot keep the matcher busy for hours.
112
+ - **Errors carry positions.** Every error has a zero-based `position` into the template:
113
+ the `$` of the failing expansion, or the start of the malformed construct.
114
+
115
+ ## Differences from a shell
116
+
117
+ These are deliberate. The two that occur in the oracle corpus (`$-` and `${R#"$R"}`) are
118
+ listed in `tests/test_oracle.py` with their reasons.
119
+
120
+ - `$$` is a literal `$`, not a process id. A mapping has no process.
121
+ - Positional and special parameters (`$1`, `${10}`, `$@`, `$*`, `$#`, `$?`, `$-`, `$!`,
122
+ `$0`) do not exist in a mapping. Without braces they stay literal text; with braces
123
+ (`${1}`, `${#}`) they are a `TemplateSyntaxError`.
124
+ - `${V#"$P"}` matches the value of `P` literally, as POSIX requires and bash does. dash
125
+ still treats a bracket expression or a backslash coming from the quoted value as active.
126
+ - `[^...]` negates, as in bash; dash treats `^` as an ordinary member.
127
+ - `[[=a=]]` and `[[.a.]]` match `a`, as in bash; dash does not support these forms.
128
+ - A backslash just before the closing brace in a word (`${X:-\\}`) is an escaped backslash
129
+ followed by the closing brace. dash and bash both report a syntax error.
130
+ - When a variable is set but empty, its pattern word is expanded (POSIX and dash); bash skips
131
+ it.
132
+ - Lengths and `?` count characters (Unicode code points), as bash does; dash counts bytes.
133
+ - Tilde expansion does not apply: in a here-document a shell leaves `~` alone too.
134
+
135
+ ## Evidence
136
+
137
+ - `tests/data/oracle_corpus.json`: more than 1,600 templates from `scripts/build_corpus.py`
138
+ (hand-written generation rules with a fixed seed plus hand-written edge cases), with the
139
+ outputs of `dash` and `bash --posix` under WSL. Every case matches dash except `$-` and
140
+ `${R#"$R"}`, the two deviations above that the corpus exercises. A live test sends a fresh seeded batch to dash when WSL is
141
+ available.
142
+ - `tests/test_spec_examples.py` runs the informative examples of section 2.6.2, cited from
143
+ the standard; with the page fetched by `scripts/fetch_upstream.py`, it also proves each
144
+ example is on the page.
145
+ - `tests/test_fuzz.py` throws random templates at every entry point and checks that only
146
+ `ExpansionError` is raised, that nothing hangs and that no process is ever started.
147
+
148
+ ## Compared with `string.Template`
149
+
150
+ | Input | `string.Template.substitute` | `safe_substitute` | `shtemplate.expand` |
151
+ | --- | --- | --- | --- |
152
+ | `$x`, `${x}` (x set) | value | value | value |
153
+ | `$xy` (xy unset) | `KeyError` | `$xy` | empty, or `UnsetVariableError` in strict mode |
154
+ | `$$` | `$` | `$` | `$` |
155
+ | `price $5` | `ValueError` | `price $5` | `price $5` |
156
+ | `${x:-d}` | `ValueError` | `${x:-d}` | value of x, or `d` |
157
+ | `\$x` | `\` + value | `\` + value | `$x` |
158
+ | `${1}` | `ValueError` | `${1}` | `TemplateSyntaxError` |
159
+
160
+ Both use ASCII names and `$$` for a literal dollar. `string.Template` has no defaults,
161
+ required values, conditional text, pattern removal or length, and gives backslash no meaning.
162
+ Its identifier and delimiter can be customised by subclassing; `shtemplate` follows the shell
163
+ grammar and is not configurable that way.
@@ -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.
@@ -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,138 @@
1
+ # shtemplate
2
+
3
+ POSIX shell parameter expansion (`$VAR`, `${VAR:-default}`, `${VAR%pattern}`, ...) over
4
+ any mapping, with a command line tool. Typed, safe for untrusted templates, zero dependencies.
5
+
6
+ Outputs are cross-checked against `dash` and `bash --posix` on a corpus of more than 1,600
7
+ templates. Nothing in a template is ever executed.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install shtemplate
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ from shtemplate import expand
19
+
20
+ values = {"USER": "ada", "HOME": "/home/ada", "FILE": "report.tar.gz", "EMPTY": ""}
21
+
22
+ expand("$HOME/${APP:-demo}", values) # "/home/ada/demo"
23
+ expand("${EMPTY:-guest} [${EMPTY-guest}]", values) # "guest []"
24
+ expand("${USER:+signed in as $USER}", values) # "signed in as ada"
25
+ expand("${CONF:-${XDG:-$HOME/.config}}/app", values) # "/home/ada/.config/app"
26
+
27
+ # Pattern removal and length
28
+ expand("${FILE%.gz} ${FILE%%.*} ${FILE#*.} ${FILE##*.}", values) # "report.tar report tar.gz gz"
29
+ expand("${#USER}", values) # "3"
30
+
31
+ # Required values raise a typed error that names the variable
32
+ expand("${TOKEN:?token is required}", values) # ParameterError: TOKEN: token is required
33
+
34
+ # Escapes: \$ and $$ are a literal dollar
35
+ expand("price \\$5 or $$5", values) # "price $5 or $5"
36
+ ```
37
+
38
+ `env` is any `Mapping[str, str]` and is never mutated. It defaults to an empty mapping, so
39
+ nothing is read from the process environment unless you pass `os.environ` yourself.
40
+
41
+ ### Strict and safe modes
42
+
43
+ ```python
44
+ from shtemplate import expand
45
+
46
+ expand("$MISSING", {}, strict=True) # UnsetVariableError: undefined variable: MISSING ...
47
+ expand("${MISSING:-ok}", {}, strict=True) # "ok": the default handles the unset case
48
+
49
+ expand("$(rm -rf ~)", {}) # "$(rm -rf ~)": left as literal text, never run
50
+ expand("$(rm -rf ~)", {}, safe=True) # UnsafeTemplateError: command substitution ...
51
+ ```
52
+
53
+ `strict=True` behaves like `set -u`. `safe=True` is meant for templates you do not trust: it
54
+ rejects `$(...)`, `$((...))` and backquotes, caps the output at one million characters
55
+ (`max_output=` to change it) and bounds pattern matching work to match.
56
+
57
+ ### Inspecting and escaping
58
+
59
+ ```python
60
+ from shtemplate import escape, expand, list_vars
61
+
62
+ list_vars("$HOME/${APP:-demo}/${LOG:-$HOME/log}") # ["HOME", "APP", "LOG"]
63
+ expand(escape("cost: $5"), {}) # "cost: $5"
64
+ ```
65
+
66
+ ### Assignment
67
+
68
+ `${NAME:=word}` and `${NAME=word}` substitute `word` and make it visible to later references
69
+ in the same template. The mapping you pass is read-only to `shtemplate`; collect the
70
+ assignments with `assigned=` (pass the same dict as `env` to update it in place).
71
+
72
+ ```python
73
+ from shtemplate import expand
74
+
75
+ sink: dict[str, str] = {}
76
+ expand("${PORT:=8080} on $PORT", {}, assigned=sink) # "8080 on 8080"; sink == {"PORT": "8080"}
77
+ ```
78
+
79
+ ### Supported forms
80
+
81
+ | Form | Result |
82
+ | --- | --- |
83
+ | `${NAME}` | the value; empty if unset (an error in strict mode) |
84
+ | `$NAME` | same, using the longest valid name |
85
+ | `${NAME:-word}` | `word` if unset or empty |
86
+ | `${NAME-word}` | `word` if unset |
87
+ | `${NAME:=word}` | `word` if unset or empty, and assign it |
88
+ | `${NAME=word}` | `word` if unset, and assign it |
89
+ | `${NAME:+word}` | `word` if set and not empty, else empty |
90
+ | `${NAME+word}` | `word` if set, else empty |
91
+ | `${NAME:?word}` | raise `ParameterError` if unset or empty |
92
+ | `${NAME?word}` | raise `ParameterError` if unset |
93
+ | `${#NAME}` | the length in characters |
94
+ | `${NAME%word}` | remove the shortest suffix matching the pattern `word` |
95
+ | `${NAME%%word}` | remove the longest matching suffix |
96
+ | `${NAME#word}` | remove the shortest matching prefix |
97
+ | `${NAME##word}` | remove the longest matching prefix |
98
+ | `$$` | a literal `$` |
99
+ | `\$NAME` | a literal `$NAME` (backslash also escapes backquote and backslash) |
100
+
101
+ `word` is expanded only when it is needed, so it may contain other expansions. Patterns use
102
+ shell notation: `*`, `?`, `[a-z]`, `[!...]`, `[[:alpha:]]` and backslash or quotes for literal
103
+ characters. Malformed templates raise `TemplateSyntaxError` with the position. Every error is an
104
+ `ExpansionError`, which is a `ValueError`. The [GUIDE](GUIDE.md) covers the exact rules, the
105
+ design decisions and the differences from the shell and from `string.Template`.
106
+
107
+ ## Command line
108
+
109
+ ```
110
+ echo 'Hello ${USER:-world}' | shtemplate
111
+ shtemplate config.tpl -o config.ini
112
+ shtemplate -i --env-file prod.env --set PORT=8080 config.tpl
113
+ shtemplate --strict --safe untrusted.tpl
114
+ shtemplate --list-vars config.tpl
115
+ ```
116
+
117
+ Variables come from the process environment, then each `--env-file` (`NAME=VALUE` lines, never
118
+ expanded), then each `--set`. `-i` starts from an empty environment. Errors go to stderr with
119
+ exit status 1. `python -m shtemplate` works too.
120
+
121
+ ## Why
122
+
123
+ Python's `string.Template` understands `$NAME` and `${NAME}` only: no defaults, no required
124
+ values, no pattern removal. `os.path.expandvars` silently leaves unknown names in place and
125
+ reads only `os.environ`. `shtemplate` gives you the shell's full parameter expansion as a pure
126
+ function over a mapping you choose, with typed errors, a strict mode, a safe mode for
127
+ untrusted input and a CLI that does what `envsubst` does, plus defaults, required values and
128
+ pattern removal.
129
+
130
+ ## License
131
+
132
+ MIT. See [LICENSE](LICENSE).
133
+
134
+ ## Links
135
+
136
+ - Website: https://lianes8studio.com
137
+ - GitHub: https://github.com/JaviLianes8
138
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shtemplate"
7
+ version = "0.2.1"
8
+ description = "POSIX shell parameter expansion ($VAR, ${VAR:-default}, ${VAR%pattern}, ...) over any mapping, with a CLI; typed, safe, zero-dependency"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["template", "shell", "posix", "envsubst", "expandvars", "parameter-expansion", "environment", "dotenv"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ "Operating System :: OS Independent",
21
+ "Environment :: Console",
22
+ "Topic :: Text Processing",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.scripts]
32
+ shtemplate = "shtemplate._cli:main"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://lianes8studio.com"
39
+ Repository = "https://github.com/JaviLianes8/shtemplate"
40
+ Issues = "https://github.com/JaviLianes8/shtemplate/issues"
41
+ Changelog = "https://github.com/JaviLianes8/shtemplate/blob/main/CHANGELOG.md"
42
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
43
+ Funding = "https://buymeacoffee.com/jlianesglrs"
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ # Third-party test files are fetched locally and never shipped; only their manifest is.
47
+ include = [
48
+ "src/shtemplate",
49
+ "tests/*.py",
50
+ "tests/data/oracle_corpus.json",
51
+ "tests/upstream/MANIFEST.json",
52
+ "scripts/*.py",
53
+ "CHANGELOG.md",
54
+ "GUIDE.md",
55
+ "README.md",
56
+ "LICENSE",
57
+ "pyproject.toml",
58
+ ]
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["src/shtemplate"]
62
+
63
+ [tool.ruff]
64
+ line-length = 100
65
+ target-version = "py310"
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
69
+
70
+ [tool.mypy]
71
+ python_version = "3.10"
72
+ strict = true
73
+ mypy_path = "scripts"
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
77
+ pythonpath = ["src"]
78
+ addopts = "-q"