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.
- shtemplate-0.2.1/.gitignore +16 -0
- shtemplate-0.2.1/CHANGELOG.md +60 -0
- shtemplate-0.2.1/GUIDE.md +163 -0
- shtemplate-0.2.1/LICENSE +21 -0
- shtemplate-0.2.1/PKG-INFO +173 -0
- shtemplate-0.2.1/README.md +138 -0
- shtemplate-0.2.1/pyproject.toml +78 -0
- shtemplate-0.2.1/scripts/build_corpus.py +114 -0
- shtemplate-0.2.1/scripts/corpus_cases.py +160 -0
- shtemplate-0.2.1/scripts/e2e_wheel.py +116 -0
- shtemplate-0.2.1/scripts/fetch_upstream.py +37 -0
- shtemplate-0.2.1/scripts/oracle.py +93 -0
- shtemplate-0.2.1/src/shtemplate/__init__.py +31 -0
- shtemplate-0.2.1/src/shtemplate/__main__.py +5 -0
- shtemplate-0.2.1/src/shtemplate/_cli.py +108 -0
- shtemplate-0.2.1/src/shtemplate/_core.py +159 -0
- shtemplate-0.2.1/src/shtemplate/_dotenv.py +78 -0
- shtemplate-0.2.1/src/shtemplate/_errors.py +69 -0
- shtemplate-0.2.1/src/shtemplate/_nodes.py +66 -0
- shtemplate-0.2.1/src/shtemplate/_parser.py +196 -0
- shtemplate-0.2.1/src/shtemplate/_pattern.py +193 -0
- shtemplate-0.2.1/src/shtemplate/_tools.py +48 -0
- shtemplate-0.2.1/src/shtemplate/py.typed +0 -0
- shtemplate-0.2.1/tests/conftest.py +34 -0
- shtemplate-0.2.1/tests/data/oracle_corpus.json +19744 -0
- shtemplate-0.2.1/tests/test_cli.py +141 -0
- shtemplate-0.2.1/tests/test_contract.py +160 -0
- shtemplate-0.2.1/tests/test_docs.py +75 -0
- shtemplate-0.2.1/tests/test_dotenv.py +59 -0
- shtemplate-0.2.1/tests/test_e2e_wheel.py +24 -0
- shtemplate-0.2.1/tests/test_errors.py +132 -0
- shtemplate-0.2.1/tests/test_expand.py +118 -0
- shtemplate-0.2.1/tests/test_fuzz.py +132 -0
- shtemplate-0.2.1/tests/test_operators.py +122 -0
- shtemplate-0.2.1/tests/test_oracle.py +81 -0
- shtemplate-0.2.1/tests/test_patterns.py +93 -0
- shtemplate-0.2.1/tests/test_spec_examples.py +68 -0
- shtemplate-0.2.1/tests/test_tools.py +48 -0
- 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.
|
shtemplate-0.2.1/LICENSE
ADDED
|
@@ -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"
|