Skip to content

Commit 97aa2ec

Browse files
committed
feat: add support for environment variable shell syntax
1 parent caf5d21 commit 97aa2ec

9 files changed

Lines changed: 480 additions & 96 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ cython_debug/
157157
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160-
#.idea/
160+
.idea/
161161

162162
# setuptools_scm
163163
_version.py

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to
66
[Semantic Versioning](http://semver.org/).
77

8+
## [1.1.0] - 2023-09-30
9+
10+
### Added
11+
12+
- Add `parse_help` setting, to disable parsing script templates in the `dev` CLI help page
13+
- Add `Template Parsing` subsection to the `Caveats` section of `README.md`
14+
- Add caching for `Scripts.context` property, to avoid rebuilding the context dictionary on every access
15+
16+
### Changed
17+
18+
- Modify `Scripts.__resolve()` to use `os.path.expandvars()` for parsing environment variables in script templates
19+
- Modify boolean property setters in `Settings` to correctly parse string values as booleans
20+
- Update `README.md` with new `parse_help` setting and information about parsing environment variables
21+
22+
### Fixed
23+
24+
- Raise `ModuleNotFoundError` when attempting to import a missing module, instead of raising `TypeError`
25+
- Only raise an exception and show stack trace if `-d` or `--debug` flag is set in `dev` CLI
26+
827
## [1.0.5] - 2023-09-25
928

1029
### Fixed

README.md

Lines changed: 195 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pip install python-dev-cli
2626
Define custom scripts in your `pyproject.toml` file:
2727

2828
```toml
29+
# pyproject.toml
2930
[tool.python-dev-cli.scripts]
3031
up = "docker compose up -d"
3132
down = "docker compose down -v --remove-orphans"
@@ -62,6 +63,7 @@ dev --help
6263
Any script that is prefixed with an underscore (`_`) will be hidden from the help page and cannot be run directly:
6364

6465
```toml
66+
# pyproject.toml
6567
[tool.python-dev-cli.scripts]
6668
_foo = "foo"
6769
_bar = "bar"
@@ -85,6 +87,7 @@ dev -h
8587
You can also define scripts as a list of script references, which will be run in order:
8688

8789
```toml
90+
# pyproject.toml
8891
[tool.python-dev-cli.scripts]
8992
black = "black --check --config pyproject.toml ."
9093
black_fix = "black --config pyproject.toml ."
@@ -108,13 +111,13 @@ By default, scripts can utilize [Jinja2] template syntax, enabling you to refere
108111
Python modules, and even other scripts:
109112

110113
```toml
114+
# pyproject.toml
111115
[tool.python-dev-cli.settings]
112-
include = ["os", "uuid:uuid4 as uuid"]
116+
include = ["uuid:uuid4 as uuid"]
113117

114118
[tool.python-dev-cli.scripts]
115119
python = "echo {{ 1 + 1 }}"
116120
module = "echo {{ uuid() }}"
117-
env_vars = "echo PATH={{ os.getenv('PATH') }} PWD={{ os.getenv('PWD') }} HOME={{ os.getenv('HOME') }}"
118121
other_scripts = "echo {{ dev._foo }}{{ dev._bar }}"
119122
_foo = "foo"
120123
_bar = "bar"
@@ -127,24 +130,24 @@ dev python
127130
dev module
128131
# 2b2e0b9e-0b9e-4a4a-9a9a-9a9a9a9a9a9a
129132

130-
dev env_vars
131-
# PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin PWD=/Users/username/Projects HOME=/Users/username
132-
133133
dev other_scripts
134134
# foobar
135135
```
136136

137-
Script template functionality can be disabled, if you prefer to keep things simple. See the [Settings]
138-
section below for more information.
137+
Script template functionality can be disabled, if you prefer to keep things simple. See the [Settings] section below for
138+
more information.
139139

140140
## Settings
141141

142-
You can configure this package by adding a `tool.python-dev-cli.settings` section to your `pyproject.toml` file:
142+
You can configure this package by adding a `tool.python-dev-cli.settings` section to your `pyproject.toml` file (default
143+
values shown):
143144

144145
```toml
146+
# pyproject.toml
145147
[tool.python-dev-cli.settings]
146148
enable_templates = true
147-
include = ["os", "sys"]
149+
parse_help = true
150+
include = []
148151
script_refs = "dev"
149152
```
150153

@@ -159,6 +162,7 @@ and scripts will be run as-is, without any preprocessing.
159162
For example, this will still work:
160163

161164
```toml
165+
# pyproject.toml
162166
[tool.python-dev-cli.settings]
163167
enable_templates = false
164168

@@ -177,6 +181,7 @@ dev foobar
177181
However, this will not work as intended because the template will not be parsed:
178182

179183
```toml
184+
# pyproject.toml
180185
[tool.python-dev-cli.settings]
181186
enable_templates = false
182187

@@ -191,12 +196,43 @@ dev foobar
191196
# {{ dev._foo }}{{ dev._bar }}
192197
```
193198

199+
### parse_help
200+
201+
Enable or disable the parsing of script templates in the `dev` CLI help page. If you disable this, the help page will
202+
show the raw script template, rather than the parsed script command.
203+
204+
```toml
205+
# pyproject.toml
206+
[tool.python-dev-cli.settings]
207+
parse_help = false
208+
209+
[tool.python-dev-cli.scripts]
210+
_foo = "foo"
211+
_bar = "bar"
212+
foobar = "echo {{ dev._foo }}{{ dev._bar }}"
213+
```
214+
215+
```shell
216+
dev -h
217+
# usage: dev [-h] [-d] {foobar} ...
218+
# Python developer CLI for running custom scripts defined in pyproject.toml
219+
#
220+
# options:
221+
# -h, --help show this help message and exit
222+
# -d, --debug enable debug logging
223+
#
224+
# available scripts:
225+
# {foobar}
226+
# foobar ['echo {{ dev._foo }}{{ dev._bar }}']
227+
```
228+
194229
### include
195230

196231
A list of modules to include in the [Jinja2] environment, when parsing scripts. This enables you to reference Python
197232
modules in your scripts:
198233

199234
```toml
235+
# pyproject.toml
200236
[tool.python-dev-cli.settings]
201237
include = ["os:getcwd", "os:getenv as env"]
202238

@@ -229,6 +265,7 @@ Valid formats for including modules are:
229265
Scripts can contain references to other scripts, using the `{{ dev.my_script }}` syntax:
230266

231267
```toml
268+
# pyproject.toml
232269
[tool.python-dev-cli.scripts]
233270
_foo = "foo"
234271
_bar = "bar"
@@ -244,6 +281,7 @@ The name of the `dev` object is configurable using the `script_refs` setting. Fo
244281
`scripts`:
245282

246283
```toml
284+
# pyproject.toml
247285
[tool.python-dev-cli.settings]
248286
script_refs = "scripts"
249287

@@ -263,37 +301,34 @@ dev foobar
263301
### Shell Syntax
264302

265303
Scripts are run in a Python subprocess using [subprocess.run()], not the shell interpreter that the `dev` CLI is being
266-
run in. As a result, it has the following limitations:
304+
run in. As a result, shell features such as pipes `|`; filename wildcards `*`; redirects `>`, `>>`, `<`; backgrounding
305+
`&`; and expansion of `~` to a user's home directory are not supported.
267306

268-
- Environment variables cannot be referenced as you would in a shell script (e.g. `$HOME` or `${HOME}`).
269-
- Shell syntax (e.g. pipes `|`; redirects `>`, `>>`, `<`; backgrounding [`&`]) is not supported.
307+
> **NOTE:** Environment variables can still be referenced as you would in a shell script (e.g. `$HOME` or `${HOME}`),
308+
> because the `dev` CLI uses [os.path.expandvars()] when resolving scripts.
270309
271-
To work around these limitations, you can use the `os` module to reference environment variables, and the `subprocess`
272-
module to run shell commands:
310+
To work around this limitation, you can use the `subprocess` module to run shell commands with the `shell=True` flag:
273311

274312
```toml
313+
# pyproject.toml
275314
[tool.python-dev-cli.settings]
276-
include = ["os", "subprocess"]
315+
include = ["subprocess"]
277316

278317
[tool.python-dev-cli.scripts]
279-
env_vars = "echo PATH={{ os.getenv('PATH') }} PWD={{ os.getenv('PWD') }} HOME={{ os.getenv('HOME') }}"
280318
shell = "echo {{ subprocess.run('echo foo | tr a-z A-Z', shell=True, capture_output=True).stdout.decode().strip() }}"
281319
```
282320

283321
```shell
284-
dev env_vars
285-
# PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin PWD=/Users/username/Projects HOME=/Users/username
286-
287322
dev shell
288323
# FOO
289324
```
290325

291-
These constraints actually have the benefit of making your `dev` scripts more cross-platform compatible, as they do not
292-
rely on any shell-specific syntax (unless you use the `subprocess` workaround, as in the example above). This means your
293-
scripts should work on Windows, Linux, and macOS.
326+
However, by not running scripts with the `shell=True` flag, the `dev` CLI has the benefit of making your scripts more
327+
cross-platform compatible, as they do not rely on any shell-specific syntax. This means your scripts will typically work
328+
on Windows, Linux, and macOS (unless you do something like in the example above).
294329

295-
Also, it's worth pointing out that the `shell` example above would be better written as a Python script, rather than a
296-
`dev` script in your `pyproject.toml` file:
330+
Also, it's worth pointing out that the example above would be better written as a Python script, rather than a `dev`
331+
script in your `pyproject.toml` file:
297332

298333
```python
299334
# scripts/shell.py
@@ -310,13 +345,18 @@ if __name__ == '__main__':
310345
Then, you could reference it in your script definition:
311346

312347
```toml
348+
# pyproject.toml
313349
[tool.python-dev-cli.scripts]
314350
shell = "python -m scripts.shell"
315351
```
316352

317353
This is a better design pattern, as it keeps your Python logic in a Python file rather than a TOML file, where it cannot
318354
be easily tested or linted.
319355

356+
An even better approach would be to use the various Python implementations of shell-like features (e.g. [glob],
357+
[fnmatch], [os.walk()], [os.path.expanduser()], and [shutil]) to achieve the desired result, rather than calling
358+
out to a shell command.
359+
320360
### Script Names
321361

322362
Script names must be valid Python identifiers, which means they must start with a letter or underscore (`_`), and can
@@ -326,6 +366,131 @@ only contain letters, numbers, and underscores (`_`). This is because script na
326366
Also, keep in mind that any scripts prefixed with an underscore (`_`) will be hidden from the help page and cannot be
327367
run directly. Think of these as "private" script variables, which can only be referenced by other scripts.
328368

369+
### Template Parsing
370+
371+
By default, script templates are parsed using [Jinja2] before being run. They are also parsed whenever the `dev` CLI
372+
help page is displayed, to generate examples of the actual commands that will be run for each script. This means that
373+
any function calls or other Python syntax in your scripts will be evaluated **even when a script is not being run**
374+
(potentially multiple times, depending on your configuration). This is important to understand, because it could have
375+
unintended consequences.
376+
377+
For example, let's say you have one script that generates a random UUID and another script that runs the first script
378+
three times:
379+
380+
```toml
381+
# pyproject.toml
382+
[tool.python-dev-cli.settings]
383+
include = ["uuid:uuid4 as uuid"]
384+
385+
[tool.python-dev-cli.scripts]
386+
uuid = "echo {{ uuid() }}"
387+
uuids = ["uuid", "uuid", "uuid"]
388+
```
389+
390+
When you run `dev uuids`, the `uuid()` function gets called three times, generating three unique IDs as expected.
391+
392+
```shell
393+
dev uuids
394+
# affa5cf5-8d1d-43c6-806f-c561d91f7d05
395+
# d8dd276d-1cc8-4dcf-9e0d-6f3fcbdbcc22
396+
# ac5a0a59-4510-4609-9734-7c238652f59a
397+
```
398+
399+
When you run `dev --help`, the same thing happens, this time to generate the help page examples:
400+
401+
```shell
402+
dev -h
403+
# usage: dev [-h] [-d] {foobar} ...
404+
# Python developer CLI for running custom scripts defined in pyproject.toml
405+
#
406+
# options:
407+
# -h, --help show this help message and exit
408+
# -d, --debug enable debug logging
409+
#
410+
# available scripts:
411+
# {uuid,uuids}
412+
# uuid ['echo 2afa5b31-159c-45e5-b7e3-ad935a48cef0']
413+
# uuids ['echo 2c7fe6e2-01a0-4cd9-90fe-50816a2ed316', 'echo 51ca85c8-b69b-4151-ab3f-5bf2a148a058', 'echo 6595e224-3e24-463c-9cc4-98c9bf0ecddb']
414+
```
415+
416+
Now, consider the following scripts that make API calls to a third-party service:
417+
418+
```toml
419+
# pyproject.toml
420+
[tool.python-dev-cli.settings]
421+
include = ["json", "requests"]
422+
423+
[tool.python-dev-cli.scripts]
424+
curl = "curl https://cat-fact.herokuapp.com/facts/random?amount=1"
425+
req = "echo {{ json.loads(requests.get('https://cat-fact.herokuapp.com/facts/random?amount=1').content).text }}"
426+
```
427+
428+
When you run `dev curl`, the API call is made as expected:
429+
430+
```shell
431+
dev curl
432+
# {"status":{"verified":true,"sentCount":1},"_id":"591f98783b90f7150a19c1c5","__v":0,"text":"Baking chocolate is the most dangerous chocolate to your cat.","source":"api","updatedAt":"2020-08-23T20:20:01.611Z","type":"cat","createdAt":"2018-04-17T20:20:02.627Z","deleted":false,"used":false,"user":"5a9ac18c7478810ea6c06381"}
433+
```
434+
435+
When you run `dev req`, the API call is also made as expected:
436+
437+
```shell
438+
dev req
439+
# Cats are amazing animals.
440+
```
441+
442+
When you run `dev --help`, the API call is made while parsing the `req` script template, even though the script is not
443+
being run:
444+
445+
```shell
446+
dev --help
447+
# usage: dev [-h] [-d] {curl,req} ...
448+
# Python developer CLI for running custom scripts defined in pyproject.toml
449+
#
450+
# options:
451+
# -h, --help show this help message and exit
452+
# -d, --debug enable debug logging
453+
#
454+
# available scripts:
455+
# {curl,req}
456+
# curl ['curl https://cat-fact.herokuapp.com/facts/random?amount=1']
457+
# req ['echo When asked if her husband had any hobbies, Mary Todd Lincoln is said to have replied cats.']
458+
```
459+
460+
If that were an API call that had a side effect, such as creating a new record in a database, then that side effect
461+
would happen every time the `dev --help` command was run. This is probably not what you want.
462+
463+
To prevent this from happening, you can disable template parsing in the `dev` CLI help page by setting the `parse_help`
464+
value to `false`:
465+
466+
```toml
467+
# pyproject.toml
468+
[tool.python-dev-cli.settings]
469+
parse_help = false
470+
```
471+
472+
However, a better solution in most cases would be to use the `curl` command, or move the API call to a Python script
473+
that can be run using the `python -m` syntax:
474+
475+
```toml
476+
# pyproject.toml
477+
[tool.python-dev-cli.scripts]
478+
req = "python -m scripts.req"
479+
```
480+
481+
```python
482+
# scripts/req.py
483+
import json
484+
import requests
485+
486+
def main() -> str:
487+
return json.loads(requests.get('https://cat-fact.herokuapp.com/facts/random?amount=1').content).text
488+
489+
if __name__ == '__main__':
490+
output: str = main()
491+
print(output)
492+
```
493+
329494
## License
330495

331496
This open source project is licensed under the terms of the [BSD 3-Clause License].
@@ -344,7 +509,13 @@ page.
344509
[Code of Conduct]: https://github.com/sscovil/devblob/master/CODE_OF_CONDUCT.md
345510
[CONTRIBUTING.md]: https://github.com/sscovil/devblob/master/CONTRIBUTING.md
346511
[Contributor Covenant]: https://contributor-covenant.org/
512+
[fnmatch]: https://docs.python.org/3/library/fnmatch.html#module-fnmatch
513+
[glob]: https://docs.python.org/3/library/glob.html#module-glob
347514
[Jinja2]: https://jinja.palletsprojects.com/en/3.0.x/
515+
[os.path.expanduser()]: https://docs.python.org/3/library/os.path.html#os.path.expanduser
516+
[os.path.expandvars()]: https://docs.python.org/3/library/os.path.html#os.path.expandvars
517+
[os.walk()]: https://docs.python.org/3/library/os.html#os.walk
348518
[pyproject.toml]: https://peps.python.org/pep-0518/#tool-table
349519
[Settings]: https://github.com/sscovil/python-dev-cli/blob/main/README.md#settings
520+
[shutil]: https://docs.python.org/3/library/shutil.html#module-shutil
350521
[subprocess.run()]: https://docs.python.org/3/library/subprocess.html#subprocess.run

0 commit comments

Comments
 (0)