PyBash 0.3.5__tar.gz → 0.4.0__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.
- pybash-0.4.0/.github/workflows/ci.yml +27 -0
- pybash-0.4.0/.github/workflows/publish.yml +18 -0
- pybash-0.4.0/.gitignore +129 -0
- pybash-0.4.0/Makefile +30 -0
- pybash-0.4.0/PKG-INFO +202 -0
- pybash-0.4.0/README.md +179 -0
- pybash-0.4.0/examples/demo.py +40 -0
- pybash-0.4.0/examples/hello.py +2 -0
- pybash-0.4.0/pybash/__init__.py +41 -0
- pybash-0.4.0/pybash/__main__.py +70 -0
- pybash-0.4.0/pybash/errors.py +19 -0
- pybash-0.4.0/pybash/hook.py +80 -0
- pybash-0.4.0/pybash/runtime.py +185 -0
- pybash-0.4.0/pybash/shell.py +379 -0
- pybash-0.4.0/pybash/transformer.py +204 -0
- pybash-0.4.0/pyproject.toml +66 -0
- pybash-0.4.0/tests/__init__.py +0 -0
- pybash-0.4.0/tests/test_run.py +191 -0
- pybash-0.4.0/tests/test_transform.py +197 -0
- pybash-0.4.0/uv.lock +382 -0
- pybash-0.3.5/PKG-INFO +0 -190
- pybash-0.3.5/README.md +0 -172
- pybash-0.3.5/pybash/__main__.py +0 -26
- pybash-0.3.5/pybash/hook.py +0 -22
- pybash-0.3.5/pybash/transformer.py +0 -457
- pybash-0.3.5/pyproject.toml +0 -37
- {pybash-0.3.5 → pybash-0.4.0}/LICENSE +0 -0
- /pybash-0.3.5/pybash/__init__.py → /pybash-0.4.0/pybash/py.typed +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ${{ matrix.os }}
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
os: [ubuntu-latest, macos-latest]
|
|
18
|
+
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v7
|
|
21
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
enable-cache: true
|
|
25
|
+
- run: uv sync
|
|
26
|
+
- run: make lint
|
|
27
|
+
- run: make test
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write # PyPI trusted publishing
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
17
|
+
- run: uv build
|
|
18
|
+
- run: uv publish
|
pybash-0.4.0/.gitignore
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
target/
|
|
76
|
+
|
|
77
|
+
# Jupyter Notebook
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
|
|
80
|
+
# IPython
|
|
81
|
+
profile_default/
|
|
82
|
+
ipython_config.py
|
|
83
|
+
|
|
84
|
+
# pyenv
|
|
85
|
+
.python-version
|
|
86
|
+
|
|
87
|
+
# pipenv
|
|
88
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
89
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
90
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
91
|
+
# install all needed dependencies.
|
|
92
|
+
#Pipfile.lock
|
|
93
|
+
|
|
94
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
95
|
+
__pypackages__/
|
|
96
|
+
|
|
97
|
+
# Celery stuff
|
|
98
|
+
celerybeat-schedule
|
|
99
|
+
celerybeat.pid
|
|
100
|
+
|
|
101
|
+
# SageMath parsed files
|
|
102
|
+
*.sage.py
|
|
103
|
+
|
|
104
|
+
# Environments
|
|
105
|
+
.env
|
|
106
|
+
.venv
|
|
107
|
+
env/
|
|
108
|
+
venv/
|
|
109
|
+
ENV/
|
|
110
|
+
env.bak/
|
|
111
|
+
venv.bak/
|
|
112
|
+
|
|
113
|
+
# Spyder project settings
|
|
114
|
+
.spyderproject
|
|
115
|
+
.spyproject
|
|
116
|
+
|
|
117
|
+
# Rope project settings
|
|
118
|
+
.ropeproject
|
|
119
|
+
|
|
120
|
+
# mkdocs documentation
|
|
121
|
+
/site
|
|
122
|
+
|
|
123
|
+
# mypy
|
|
124
|
+
.mypy_cache/
|
|
125
|
+
.dmypy.json
|
|
126
|
+
dmypy.json
|
|
127
|
+
|
|
128
|
+
# Pyre type checker
|
|
129
|
+
.pyre/
|
pybash-0.4.0/Makefile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
check: format lint test
|
|
2
|
+
|
|
3
|
+
SOURCE_FILES=pybash tests
|
|
4
|
+
|
|
5
|
+
install:
|
|
6
|
+
uv sync
|
|
7
|
+
|
|
8
|
+
test:
|
|
9
|
+
uv run pytest -vv -rs
|
|
10
|
+
|
|
11
|
+
format:
|
|
12
|
+
uv run ruff format ${SOURCE_FILES}
|
|
13
|
+
uv run ruff check ${SOURCE_FILES} --fix
|
|
14
|
+
|
|
15
|
+
lint:
|
|
16
|
+
uv run ruff format --check --diff ${SOURCE_FILES}
|
|
17
|
+
uv run ruff check ${SOURCE_FILES}
|
|
18
|
+
uv run mypy
|
|
19
|
+
|
|
20
|
+
demo:
|
|
21
|
+
cd examples && uv run pybash demo.py
|
|
22
|
+
|
|
23
|
+
clean:
|
|
24
|
+
rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache .ruff_cache
|
|
25
|
+
find . -name '__pycache__' -exec rm -rf {} +
|
|
26
|
+
|
|
27
|
+
package: clean
|
|
28
|
+
uv build
|
|
29
|
+
|
|
30
|
+
.PHONY: check install test format lint demo clean package
|
pybash-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: PyBash
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: >execute bash commands from python easily
|
|
5
|
+
Project-URL: Repository, https://github.com/jaykv/PyBash
|
|
6
|
+
Author-email: Jay <jay.github0@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: bash,shell,subprocess,transpiler
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
|
+
Classifier: Topic :: System :: Shells
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Provides-Extra: script
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# PyBash
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
Write shell commands directly in Python. PyBash rewrites lines that start with `$` or `>` into
|
|
32
|
+
`subprocess` calls, so you get the brevity of a shell script with the rest of Python around it.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
branch = ($git rev-parse --abbrev-ref HEAD).decode().strip()
|
|
36
|
+
$git fetch origin && git rebase origin/{{branch}} || echo "rebase failed"
|
|
37
|
+
count = $git log --oneline | wc -l
|
|
38
|
+
print(f"{branch}: {int(count)} commits")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `$` commands never go through a shell. Arguments are split at transform time, and pipes, redirects
|
|
42
|
+
and `&&` / `||` / `;` are run by PyBash itself, so a value can never turn into shell syntax.
|
|
43
|
+
- `>` commands are handed to the shell as written, for globbing and expansion. Interpolated values are
|
|
44
|
+
quoted for you.
|
|
45
|
+
- One line in, one line out: tracebacks point at the line you wrote.
|
|
46
|
+
- No dependencies. Python 3.11+.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install pybash
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Use
|
|
55
|
+
|
|
56
|
+
### Run a script
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pybash script.py [args...] # or: python -m pybash script.py
|
|
60
|
+
pybash --show script.py # print the generated Python instead of running it
|
|
61
|
+
pybash -c '$echo hello'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Modules the script imports from its own project may use the syntax too.
|
|
65
|
+
|
|
66
|
+
### Transform source yourself
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pybash import transform
|
|
70
|
+
|
|
71
|
+
transform("$echo hello world") # 'subprocess.run(["echo", "hello", "world"])'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Plain commands compile to bare `subprocess` calls. Commands with operators call a small helper in
|
|
75
|
+
`pybash.runtime`; by default the generated code imports it in place, so the output only needs
|
|
76
|
+
`import subprocess`. For tidier output, name the helper yourself and add the header:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import pybash
|
|
80
|
+
|
|
81
|
+
body = pybash.transform(source, runtime=pybash.RUNTIME_NAME)
|
|
82
|
+
code = pybash.header() + body # imports pybash.runtime
|
|
83
|
+
code = pybash.header(standalone=True) + body # pastes the helper in: no pybash needed at run time
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`pybash.compile_source(source, filename)` returns a code object ready for `exec`, and
|
|
87
|
+
`pybash.add_hook()` installs an import hook for the current process.
|
|
88
|
+
|
|
89
|
+
Mistakes raise `PybashSyntaxError`, a `SyntaxError` whose `filename`, `lineno`, `offset` and `text`
|
|
90
|
+
point at the original line.
|
|
91
|
+
|
|
92
|
+
## Syntax
|
|
93
|
+
|
|
94
|
+
### Run: `$command`
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
$python --version
|
|
98
|
+
$cp test.txt test_copy.txt
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Returns the `subprocess.CompletedProcess`. A non-zero exit does not raise.
|
|
102
|
+
|
|
103
|
+
### Capture: `x = $command`, `($command)`, `$(command)`
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
out = $cat test.txt
|
|
107
|
+
print(($cat test.txt).decode().strip())
|
|
108
|
+
lines = $(wc -l test.txt).decode().split()[0]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Captures are `bytes`, and raise `subprocess.CalledProcessError` on a non-zero exit.
|
|
112
|
+
A capture not wrapped in brackets runs to the end of the line.
|
|
113
|
+
|
|
114
|
+
### Operators
|
|
115
|
+
|
|
116
|
+
| Operator | Meaning |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `a \| b` | pipe stdout of `a` into `b` |
|
|
119
|
+
| `a \|& b` | pipe stdout and stderr |
|
|
120
|
+
| `a && b`, `a \|\| b`, `a ; b` | run `b` if `a` succeeded / failed / either way |
|
|
121
|
+
| `< file` | read stdin from a file |
|
|
122
|
+
| `> file`, `>> file` | write / append stdout |
|
|
123
|
+
| `2> file`, `2>> file` | write / append stderr |
|
|
124
|
+
| `&> file`, `&>> file` | write / append both |
|
|
125
|
+
| `2>&1`, `>&2` | send stderr to stdout / stdout to stderr |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
$sort < in.txt | uniq -c | sort -rn > counts.txt
|
|
129
|
+
$make build > build.log 2>&1 && echo ok || echo failed
|
|
130
|
+
newest = $ls -t | head -n 1
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Operators work without surrounding spaces (`a|b`, `2>err.log`). Quote or escape one to pass it as
|
|
134
|
+
text: `$echo 'a && b' \| c`. Several output files on one command all receive the output
|
|
135
|
+
(`$echo hi > a.txt >> b.txt`).
|
|
136
|
+
|
|
137
|
+
Background jobs (`&`), here-documents (`<<`) and other file descriptors are not supported in `$`
|
|
138
|
+
commands; use a `>` shell command for those.
|
|
139
|
+
|
|
140
|
+
### Quoting
|
|
141
|
+
|
|
142
|
+
`$` commands follow POSIX shell quoting: single quotes are literal, double quotes allow `\"` and `\\`,
|
|
143
|
+
and a backslash escapes the next character. An unquoted `#` at the start of a word begins a comment.
|
|
144
|
+
There is no globbing, `~` or `$VAR` expansion; use `>` when you need the shell.
|
|
145
|
+
|
|
146
|
+
A trailing backslash continues the command on the next line.
|
|
147
|
+
|
|
148
|
+
### Interpolation
|
|
149
|
+
|
|
150
|
+
| Form | Meaning |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `{{ expr }}` | the value of any Python expression, converted with `str()` |
|
|
153
|
+
| `f{ expr }` | the same, with f-string formatting available: `f{n:03d}`, `f{name!r}` |
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
$git {{command}} {{get_option(command)}}
|
|
157
|
+
$kubectl get pods --show-{{display_type}}=true -n f{namespace}
|
|
158
|
+
$echo "{{user}} has f{count:,} files"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
In a `$` command an interpolated value is always exactly one argument, whatever it contains.
|
|
162
|
+
Interpolation also applies inside quotes. To write the markers literally, escape them: `\{{` and `\f{`.
|
|
163
|
+
|
|
164
|
+
### Shell: `>command`
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
>ls .github/* | wc -l
|
|
168
|
+
>echo "hello {{name}}" > out.txt
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The line is passed to the shell untouched. Interpolated values are quoted for where they appear
|
|
172
|
+
(bare, inside `"..."` or inside `'...'`), so they are always data. To splice shell syntax in on
|
|
173
|
+
purpose, use `{{! expr }}`:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
pattern = "*.txt"
|
|
177
|
+
>ls {{! pattern }}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Upgrading from 0.3
|
|
181
|
+
|
|
182
|
+
- Python 3.11+ is required; the `ideas` and `token-utils` dependencies are gone.
|
|
183
|
+
- Arguments reach the command exactly as a shell would pass them. Previously backslashes were
|
|
184
|
+
re-interpreted by Python (`'a\\nb'` became a newline) and a `"` inside an argument produced invalid code.
|
|
185
|
+
- `{{ x }}` is converted with `str()`, may contain spaces and quotes, and no longer raises `InvalidInterpolation`
|
|
186
|
+
for them.
|
|
187
|
+
- Interpolated values in `>` commands are now quoted. Use `{{! x }}` for the old raw behavior.
|
|
188
|
+
- `>` commands keep their quoting; previously quotes were stripped before the shell saw them.
|
|
189
|
+
- `a = $x | y` now captures the pipeline's output instead of assigning a process object, and no
|
|
190
|
+
`cmd1` / `fout` variables leak into your code.
|
|
191
|
+
- `$a > f1 > f2` writes the output to both files; previously the second file received a copy of the first.
|
|
192
|
+
- `&&`, `||`, `;`, `2>`, `&>`, `2>&1`, `|&` and `$( )` are new. `&&` used to raise `NotImplementedError`.
|
|
193
|
+
- Generated lists are formatted `["ls", "-la"]` and the result no longer ends with an added newline.
|
|
194
|
+
- Text such as `"total = $5"` or `"($x)"` inside a Python string is no longer mistaken for a command.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
uv sync
|
|
200
|
+
make check # format, lint (ruff + mypy), test
|
|
201
|
+
make demo
|
|
202
|
+
```
|
pybash-0.4.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# PyBash
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Write shell commands directly in Python. PyBash rewrites lines that start with `$` or `>` into
|
|
9
|
+
`subprocess` calls, so you get the brevity of a shell script with the rest of Python around it.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
branch = ($git rev-parse --abbrev-ref HEAD).decode().strip()
|
|
13
|
+
$git fetch origin && git rebase origin/{{branch}} || echo "rebase failed"
|
|
14
|
+
count = $git log --oneline | wc -l
|
|
15
|
+
print(f"{branch}: {int(count)} commits")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `$` commands never go through a shell. Arguments are split at transform time, and pipes, redirects
|
|
19
|
+
and `&&` / `||` / `;` are run by PyBash itself, so a value can never turn into shell syntax.
|
|
20
|
+
- `>` commands are handed to the shell as written, for globbing and expansion. Interpolated values are
|
|
21
|
+
quoted for you.
|
|
22
|
+
- One line in, one line out: tracebacks point at the line you wrote.
|
|
23
|
+
- No dependencies. Python 3.11+.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install pybash
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
### Run a script
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pybash script.py [args...] # or: python -m pybash script.py
|
|
37
|
+
pybash --show script.py # print the generated Python instead of running it
|
|
38
|
+
pybash -c '$echo hello'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Modules the script imports from its own project may use the syntax too.
|
|
42
|
+
|
|
43
|
+
### Transform source yourself
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pybash import transform
|
|
47
|
+
|
|
48
|
+
transform("$echo hello world") # 'subprocess.run(["echo", "hello", "world"])'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Plain commands compile to bare `subprocess` calls. Commands with operators call a small helper in
|
|
52
|
+
`pybash.runtime`; by default the generated code imports it in place, so the output only needs
|
|
53
|
+
`import subprocess`. For tidier output, name the helper yourself and add the header:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import pybash
|
|
57
|
+
|
|
58
|
+
body = pybash.transform(source, runtime=pybash.RUNTIME_NAME)
|
|
59
|
+
code = pybash.header() + body # imports pybash.runtime
|
|
60
|
+
code = pybash.header(standalone=True) + body # pastes the helper in: no pybash needed at run time
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`pybash.compile_source(source, filename)` returns a code object ready for `exec`, and
|
|
64
|
+
`pybash.add_hook()` installs an import hook for the current process.
|
|
65
|
+
|
|
66
|
+
Mistakes raise `PybashSyntaxError`, a `SyntaxError` whose `filename`, `lineno`, `offset` and `text`
|
|
67
|
+
point at the original line.
|
|
68
|
+
|
|
69
|
+
## Syntax
|
|
70
|
+
|
|
71
|
+
### Run: `$command`
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
$python --version
|
|
75
|
+
$cp test.txt test_copy.txt
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Returns the `subprocess.CompletedProcess`. A non-zero exit does not raise.
|
|
79
|
+
|
|
80
|
+
### Capture: `x = $command`, `($command)`, `$(command)`
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
out = $cat test.txt
|
|
84
|
+
print(($cat test.txt).decode().strip())
|
|
85
|
+
lines = $(wc -l test.txt).decode().split()[0]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Captures are `bytes`, and raise `subprocess.CalledProcessError` on a non-zero exit.
|
|
89
|
+
A capture not wrapped in brackets runs to the end of the line.
|
|
90
|
+
|
|
91
|
+
### Operators
|
|
92
|
+
|
|
93
|
+
| Operator | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `a \| b` | pipe stdout of `a` into `b` |
|
|
96
|
+
| `a \|& b` | pipe stdout and stderr |
|
|
97
|
+
| `a && b`, `a \|\| b`, `a ; b` | run `b` if `a` succeeded / failed / either way |
|
|
98
|
+
| `< file` | read stdin from a file |
|
|
99
|
+
| `> file`, `>> file` | write / append stdout |
|
|
100
|
+
| `2> file`, `2>> file` | write / append stderr |
|
|
101
|
+
| `&> file`, `&>> file` | write / append both |
|
|
102
|
+
| `2>&1`, `>&2` | send stderr to stdout / stdout to stderr |
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
$sort < in.txt | uniq -c | sort -rn > counts.txt
|
|
106
|
+
$make build > build.log 2>&1 && echo ok || echo failed
|
|
107
|
+
newest = $ls -t | head -n 1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Operators work without surrounding spaces (`a|b`, `2>err.log`). Quote or escape one to pass it as
|
|
111
|
+
text: `$echo 'a && b' \| c`. Several output files on one command all receive the output
|
|
112
|
+
(`$echo hi > a.txt >> b.txt`).
|
|
113
|
+
|
|
114
|
+
Background jobs (`&`), here-documents (`<<`) and other file descriptors are not supported in `$`
|
|
115
|
+
commands; use a `>` shell command for those.
|
|
116
|
+
|
|
117
|
+
### Quoting
|
|
118
|
+
|
|
119
|
+
`$` commands follow POSIX shell quoting: single quotes are literal, double quotes allow `\"` and `\\`,
|
|
120
|
+
and a backslash escapes the next character. An unquoted `#` at the start of a word begins a comment.
|
|
121
|
+
There is no globbing, `~` or `$VAR` expansion; use `>` when you need the shell.
|
|
122
|
+
|
|
123
|
+
A trailing backslash continues the command on the next line.
|
|
124
|
+
|
|
125
|
+
### Interpolation
|
|
126
|
+
|
|
127
|
+
| Form | Meaning |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `{{ expr }}` | the value of any Python expression, converted with `str()` |
|
|
130
|
+
| `f{ expr }` | the same, with f-string formatting available: `f{n:03d}`, `f{name!r}` |
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
$git {{command}} {{get_option(command)}}
|
|
134
|
+
$kubectl get pods --show-{{display_type}}=true -n f{namespace}
|
|
135
|
+
$echo "{{user}} has f{count:,} files"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
In a `$` command an interpolated value is always exactly one argument, whatever it contains.
|
|
139
|
+
Interpolation also applies inside quotes. To write the markers literally, escape them: `\{{` and `\f{`.
|
|
140
|
+
|
|
141
|
+
### Shell: `>command`
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
>ls .github/* | wc -l
|
|
145
|
+
>echo "hello {{name}}" > out.txt
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The line is passed to the shell untouched. Interpolated values are quoted for where they appear
|
|
149
|
+
(bare, inside `"..."` or inside `'...'`), so they are always data. To splice shell syntax in on
|
|
150
|
+
purpose, use `{{! expr }}`:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
pattern = "*.txt"
|
|
154
|
+
>ls {{! pattern }}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Upgrading from 0.3
|
|
158
|
+
|
|
159
|
+
- Python 3.11+ is required; the `ideas` and `token-utils` dependencies are gone.
|
|
160
|
+
- Arguments reach the command exactly as a shell would pass them. Previously backslashes were
|
|
161
|
+
re-interpreted by Python (`'a\\nb'` became a newline) and a `"` inside an argument produced invalid code.
|
|
162
|
+
- `{{ x }}` is converted with `str()`, may contain spaces and quotes, and no longer raises `InvalidInterpolation`
|
|
163
|
+
for them.
|
|
164
|
+
- Interpolated values in `>` commands are now quoted. Use `{{! x }}` for the old raw behavior.
|
|
165
|
+
- `>` commands keep their quoting; previously quotes were stripped before the shell saw them.
|
|
166
|
+
- `a = $x | y` now captures the pipeline's output instead of assigning a process object, and no
|
|
167
|
+
`cmd1` / `fout` variables leak into your code.
|
|
168
|
+
- `$a > f1 > f2` writes the output to both files; previously the second file received a copy of the first.
|
|
169
|
+
- `&&`, `||`, `;`, `2>`, `&>`, `2>&1`, `|&` and `$( )` are new. `&&` used to raise `NotImplementedError`.
|
|
170
|
+
- Generated lists are formatted `["ls", "-la"]` and the result no longer ends with an added newline.
|
|
171
|
+
- Text such as `"total = $5"` or `"($x)"` inside a Python string is no longer mistaken for a command.
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
uv sync
|
|
177
|
+
make check # format, lint (ruff + mypy), test
|
|
178
|
+
make demo
|
|
179
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# PYBASH DEMO #
|
|
2
|
+
# run with: pybash demo.py (or: python -m pybash demo.py)
|
|
3
|
+
# see the generated Python with: pybash --show demo.py
|
|
4
|
+
|
|
5
|
+
# 1. run a command; works anywhere a statement does
|
|
6
|
+
$printf 'SORT TEST\nHELLO WORLD\n' > test.txt
|
|
7
|
+
|
|
8
|
+
def cp_test():
|
|
9
|
+
$cp test.txt test_copy.txt
|
|
10
|
+
|
|
11
|
+
cp_test()
|
|
12
|
+
|
|
13
|
+
# 2. capture output as bytes
|
|
14
|
+
out = $cat test.txt
|
|
15
|
+
print(out.decode().strip().replace("HELLO", "HOWDY"))
|
|
16
|
+
|
|
17
|
+
# 3. capture inline, with ($ ...) or $(...)
|
|
18
|
+
print(($cat test.txt).decode().strip())
|
|
19
|
+
print($(wc -l test.txt).decode().split()[0], "lines")
|
|
20
|
+
|
|
21
|
+
# 4. pipes, redirects and && || ; run without a shell
|
|
22
|
+
$cat test.txt | sed 's/HELLO/HOWDY/g' | sed 's/HOW/WHY/g'
|
|
23
|
+
$sort < test.txt >> sorted_test.txt
|
|
24
|
+
$test -f sorted_test.txt && echo "sorted" || echo "not sorted"
|
|
25
|
+
$ls no-such-file 2> errors.txt; echo "kept going"
|
|
26
|
+
count = $sort test.txt | uniq | wc -l
|
|
27
|
+
print("unique lines:", int(count))
|
|
28
|
+
|
|
29
|
+
# 5. interpolate Python values; each one is always a single argument
|
|
30
|
+
name = "world; echo not-a-second-command"
|
|
31
|
+
$echo hello {{name}}
|
|
32
|
+
$echo f{len(name):04d} chars
|
|
33
|
+
|
|
34
|
+
# 6. a real shell when you want globbing or expansion: start the line with >
|
|
35
|
+
>ls ./*.txt | head -n 3
|
|
36
|
+
>echo "safely quoted: {{name}}"
|
|
37
|
+
|
|
38
|
+
# 7. long commands can continue onto the next line
|
|
39
|
+
$rm -f test.txt test_copy.txt \
|
|
40
|
+
sorted_test.txt errors.txt
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Run shell commands from Python source with `$` and `>` lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from pybash import runtime as _runtime
|
|
8
|
+
from pybash.errors import InvalidInterpolation, PybashSyntaxError
|
|
9
|
+
from pybash.hook import add_hook, compile_source, remove_hook
|
|
10
|
+
from pybash.transformer import transform
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RUNTIME_NAME",
|
|
14
|
+
"InvalidInterpolation",
|
|
15
|
+
"PybashSyntaxError",
|
|
16
|
+
"add_hook",
|
|
17
|
+
"compile_source",
|
|
18
|
+
"header",
|
|
19
|
+
"remove_hook",
|
|
20
|
+
"runtime_source",
|
|
21
|
+
"transform",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
#: Pass as `transform(..., runtime=RUNTIME_NAME)` when the output is prefixed with `header()`.
|
|
25
|
+
RUNTIME_NAME = "_pybash"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def runtime_source() -> str:
|
|
29
|
+
"""Source of the stdlib-only helper class that generated code calls for operators."""
|
|
30
|
+
return inspect.getsource(_runtime._pybash)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def header(*, standalone: bool = False) -> str:
|
|
34
|
+
"""Lines to put above code produced by `transform(..., runtime=RUNTIME_NAME)`.
|
|
35
|
+
|
|
36
|
+
With `standalone=True` the helper is pasted in, so the result runs without
|
|
37
|
+
pybash installed.
|
|
38
|
+
"""
|
|
39
|
+
if standalone:
|
|
40
|
+
return f"import subprocess\nfrom typing import Any\n\n\n{runtime_source()}\n"
|
|
41
|
+
return f"import subprocess\n\nimport pybash.runtime as {RUNTIME_NAME}\n"
|