diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 97c64a2..0000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -max-line-length = 120 -ignore = E203, E266, W503, E501 -exclude = - .git/*, - __pycache__, - demo.py \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d307ef9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.3.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - run: uv sync + - run: make lint + - run: make test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2c5e22a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,18 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # PyPI trusted publishing + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.3.0 + - run: uv build + - run: uv publish diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index 447bfd8..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,45 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python library w/ poetry - -on: [push] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install Poetry - uses: snok/install-poetry@v1.3.3 - - name: Cache Poetry virtualenv - uses: actions/cache@v1 - id: cache - with: - path: ~/.virtualenvs - key: poetry-${{ hashFiles('**/poetry.lock') }} - restore-keys: | - poetry-${{ hashFiles('**/poetry.lock') }} - - name: Set Poetry config - run: | - poetry config virtualenvs.in-project false - poetry config virtualenvs.path ~/.virtualenvs - - - name: Install Dependencies - run: poetry install - if: steps.cache.outputs.cache-hit != 'true' - - - name: Code Quality - run: poetry run make lint - - - name: Test with pytest - run: poetry run make test diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index bdaab28..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,39 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/Makefile b/Makefile index a26b88a..5c5d54e 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,30 @@ -check: format lint test clean +check: format lint test -SOURCE_FILES=pybash test_pybash.py +SOURCE_FILES=pybash tests install: - pip install -e . + uv sync test: - pytest test_pybash.py -vv -rs - -dev: - python run.py - -clean: - rm -rf build/ dist/ *.egg-info .pytest_cache - find . -name '*.pyc' -type f -exec rm -rf {} + - find . -name '__pycache__' -exec rm -rf {} + - -package: clean - python -m build - -publish: package - twine upload dist/* + uv run pytest -vv -rs format: - autoflake --in-place --recursive --remove-all-unused-imports --ignore-init-module-imports ${SOURCE_FILES} - isort --project=pybash ${SOURCE_FILES} - black ${SOURCE_FILES} + uv run ruff format ${SOURCE_FILES} + uv run ruff check ${SOURCE_FILES} --fix lint: - isort --check --diff --project=pybash ${SOURCE_FILES} - black --check --diff ${SOURCE_FILES} - flake8 $(SOURCE_FILES) --count --show-source --statistics - flake8 $(SOURCE_FILES) --count --exit-zero --statistics + uv run ruff format --check --diff ${SOURCE_FILES} + uv run ruff check ${SOURCE_FILES} + uv run mypy -shell: - source $(poetry env info --path)/bin/activate +demo: + cd examples && uv run pybash demo.py -debug: - python -m ideas examples.demo -a pybash.hook -s +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache .ruff_cache + find . -name '__pycache__' -exec rm -rf {} + + +package: clean + uv build -.PHONY: test clean \ No newline at end of file +.PHONY: check install test format lint demo clean package diff --git a/README.md b/README.md index a9b0f6d..f141e06 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,179 @@ # PyBash -![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/jaykv/pybash/python-app.yml?branch=main) +![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/jaykv/pybash/ci.yml?branch=main) ![PyPI - Downloads](https://img.shields.io/pypi/dm/pybash) ![PyPI](https://img.shields.io/pypi/v/pybash) ![GitHub](https://img.shields.io/github/license/jaykv/pybash) -Streamline bash-command execution from python with a new syntax. It combines the simplicity of writing bash scripts with the flexibility of python. Under the hood, any line or variable assignment starting with `$` or surrounded by parentheses is transformed to python `subprocess` calls and then injected into `sys.meta_path` as an import hook. All possible thanks to the wonderful [ideas](https://github.com/aroberge/ideas) project! +Write shell commands directly in Python. PyBash rewrites lines that start with `$` or `>` into +`subprocess` calls, so you get the brevity of a shell script with the rest of Python around it. -For security and performance reasons, PyBash will NOT execute as shell, unless explicitly specified with a `>` instead of a single `$` before the command. While running commands as shell can be convenient, it can also spawn security risks if you're not too careful. If you're curious about the transformations, look at the [unit tests](test_pybash.py) for some quick examples. +```python +branch = ($git rev-parse --abbrev-ref HEAD).decode().strip() +$git fetch origin && git rebase origin/{{branch}} || echo "rebase failed" +count = $git log --oneline | wc -l +print(f"{branch}: {int(count)} commits") +``` -Note: this is a mainly experimental library. +- `$` commands never go through a shell. Arguments are split at transform time, and pipes, redirects + and `&&` / `||` / `;` are run by PyBash itself, so a value can never turn into shell syntax. +- `>` commands are handed to the shell as written, for globbing and expansion. Interpolated values are + quoted for you. +- One line in, one line out: tracebacks point at the line you wrote. +- No dependencies. Python 3.11+. -# Setup +## Install -## As standalone transformer -`pip install pybash` +```bash +pip install pybash +``` +## Use -```python -from pybash.transformer import transform +### Run a script -transform("$echo hello world") # returns the python code for the bash command as string +```bash +pybash script.py [args...] # or: python -m pybash script.py +pybash --show script.py # print the generated Python instead of running it +pybash -c '$echo hello' ``` -## As script runner -`pip install "pybash[script]"` +Modules the script imports from its own project may use the syntax too. +### Transform source yourself -### Example -```py -text = "HELLO WORLD" -$echo f{text} -``` +```python +from pybash import transform -### Run script: -```bash -python -m pybash hello.py +transform("$echo hello world") # 'subprocess.run(["echo", "hello", "world"])' ``` -# Supported transforms +Plain commands compile to bare `subprocess` calls. Commands with operators call a small helper in +`pybash.runtime`; by default the generated code imports it in place, so the output only needs +`import subprocess`. For tidier output, name the helper yourself and add the header: -### 1. Simple execution with output ```python -$python --version -$echo \\nthis is an echo -``` -outputs: -``` -Python 3.9.15 +import pybash -this is an echo +body = pybash.transform(source, runtime=pybash.RUNTIME_NAME) +code = pybash.header() + body # imports pybash.runtime +code = pybash.header(standalone=True) + body # pastes the helper in: no pybash needed at run time ``` -### 2. Set output to variable and parse -```python -out = $cat test.txt -test_data = out.decode('utf-8').strip() -print(test_data.replace("HELLO", "HOWDY")) -``` -outputs: -``` -HOWDY WORLD -``` +`pybash.compile_source(source, filename)` returns a code object ready for `exec`, and +`pybash.add_hook()` installs an import hook for the current process. -### 3. Wrapped, in-line execution and parsing -```python -print(($cat test.txt).decode('utf-8').strip()) -``` -outputs: -``` -HELLO WORLD -``` +Mistakes raise `PybashSyntaxError`, a `SyntaxError` whose `filename`, `lineno`, `offset` and `text` +point at the original line. -### 4. Redirection -```python -$echo "hello" >> test4.txt -``` +## Syntax -### 5. Pipe chaining -```python -$cat test.txt | sed 's/HELLO/HOWDY/g' | sed 's/HOW/WHY/g' | sed 's/WHY/WHEN/g' -``` -outputs: -``` -WHENDY WORLD -``` +### Run: `$command` -### 6. Redirection chaining ```python -$cat test.txt | sed 's/HELLO/HOWDY\\n/g' > test1.txt >> test2.txt > test3.txt +$python --version +$cp test.txt test_copy.txt ``` -### 7. Chaining pipes and redirection- works in tandem! -```python -$cat test.txt | sed 's/HELLO/HOWDY\\n/g' > test5.txt -``` +Returns the `subprocess.CompletedProcess`. A non-zero exit does not raise. -### 8. Input redirection -```python -$sort < test.txt >> sorted_test.txt -``` +### Capture: `x = $command`, `($command)`, `$(command)` ```python -$sort < test.txt | sed 's/SORT/TEST\\n/g' +out = $cat test.txt +print(($cat test.txt).decode().strip()) +lines = $(wc -l test.txt).decode().split()[0] ``` -### 9. Glob patterns with shell + +Captures are `bytes`, and raise `subprocess.CalledProcessError` on a non-zero exit. +A capture not wrapped in brackets runs to the end of the line. + +### Operators + +| Operator | Meaning | +|---|---| +| `a \| b` | pipe stdout of `a` into `b` | +| `a \|& b` | pipe stdout and stderr | +| `a && b`, `a \|\| b`, `a ; b` | run `b` if `a` succeeded / failed / either way | +| `< file` | read stdin from a file | +| `> file`, `>> file` | write / append stdout | +| `2> file`, `2>> file` | write / append stderr | +| `&> file`, `&>> file` | write / append both | +| `2>&1`, `>&2` | send stderr to stdout / stdout to stderr | + ```python ->ls .github/* +$sort < in.txt | uniq -c | sort -rn > counts.txt +$make build > build.log 2>&1 && echo ok || echo failed +newest = $ls -t | head -n 1 ``` -### 10. Direct interpolation -Denoted by {{code here}}. Interpolated as direct code replace. The value/output of the variable, function call, or the expression must not include spaces. +Operators work without surrounding spaces (`a|b`, `2>err.log`). Quote or escape one to pass it as +text: `$echo 'a && b' \| c`. Several output files on one command all receive the output +(`$echo hi > a.txt >> b.txt`). -```python -## GOOD -command = "status" -def get_option(command): - return "-s" if command == "status" else "-v" -$git {{command}} {{get_option(command)}} +Background jobs (`&`), here-documents (`<<`) and other file descriptors are not supported in `$` +commands; use a `>` shell command for those. -display_type = "labels" -$kubectl get pods --show-{{display_type}}=true +### Quoting -## BAD -option = "-s -v" -$git status {{option}} +`$` commands follow POSIX shell quoting: single quotes are literal, double quotes allow `\"` and `\\`, +and a backslash escapes the next character. An unquoted `#` at the start of a word begins a comment. +There is no globbing, `~` or `$VAR` expansion; use `>` when you need the shell. -options = ['-s', '-v'] -$git status {{" ".join(options)}} +A trailing backslash continues the command on the next line. -# use dynamic interpolation -options = {'version': '-v'} -$git status {{options['version']}} -``` +### Interpolation -### 11. f-string interpolation -Denoted by f{ any python variable, function call, or expression here }. Interpolated as f-string. The output of the variable, function call, or the expression must still not include spaces. +| Form | Meaning | +|---|---| +| `{{ expr }}` | the value of any Python expression, converted with `str()` | +| `f{ expr }` | the same, with f-string formatting available: `f{n:03d}`, `f{name!r}` | ```python -## GOOD +$git {{command}} {{get_option(command)}} +$kubectl get pods --show-{{display_type}}=true -n f{namespace} +$echo "{{user}} has f{count:,} files" +``` -# git -h -options = {'version': '-v', 'help': '-h'} -$git f{options['h']} +In a `$` command an interpolated value is always exactly one argument, whatever it contains. +Interpolation also applies inside quotes. To write the markers literally, escape them: `\{{` and `\f{`. -# kubectl get pods --show-labels -n coffee -namespace = "coffee" -$kubectl get pods f{"--" + "-".join(['show', 'labels'])} -n f{namespace} +### Shell: `>command` -## BAD -option = "-s -v" -$git status f{option} +```python +>ls .github/* | wc -l +>echo "hello {{name}}" > out.txt ``` -#### Also works inside methods! -```python -# PYBASH DEMO # -def cp_test(): - $cp test.txt test_copy.txt +The line is passed to the shell untouched. Interpolated values are quoted for where they appear +(bare, inside `"..."` or inside `'...'`), so they are always data. To splice shell syntax in on +purpose, use `{{! expr }}`: -cp_test() +```python +pattern = "*.txt" +>ls {{! pattern }} ``` -# Dev +## Upgrading from 0.3 -#### Demo -`python -m pybash examples/hello.py` -`python -m pybash demo` +- Python 3.11+ is required; the `ideas` and `token-utils` dependencies are gone. +- Arguments reach the command exactly as a shell would pass them. Previously backslashes were + re-interpreted by Python (`'a\\nb'` became a newline) and a `"` inside an argument produced invalid code. +- `{{ x }}` is converted with `str()`, may contain spaces and quotes, and no longer raises `InvalidInterpolation` + for them. +- Interpolated values in `>` commands are now quoted. Use `{{! x }}` for the old raw behavior. +- `>` commands keep their quoting; previously quotes were stripped before the shell saw them. +- `a = $x | y` now captures the pipeline's output instead of assigning a process object, and no + `cmd1` / `fout` variables leak into your code. +- `$a > f1 > f2` writes the output to both files; previously the second file received a copy of the first. +- `&&`, `||`, `;`, `2>`, `&>`, `2>&1`, `|&` and `$( )` are new. `&&` used to raise `NotImplementedError`. +- Generated lists are formatted `["ls", "-la"]` and the result no longer ends with an added newline. +- Text such as `"total = $5"` or `"($x)"` inside a Python string is no longer mistaken for a command. -#### Debug -`make debug` to view the transformed source code +## Development + +```bash +uv sync +make check # format, lint (ruff + mypy), test +make demo +``` diff --git a/examples/demo.py b/examples/demo.py index f0be85f..448247d 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,60 +1,40 @@ # PYBASH DEMO # -# run with either: python run.py or python -m pybash demo.py +# run with: pybash demo.py (or: python -m pybash demo.py) +# see the generated Python with: pybash --show demo.py -# dynamic interpolation -options = {'version': '-v', 'help': '-h'} -$git f{options['help']} +# 1. run a command; works anywhere a statement does +$printf 'SORT TEST\nHELLO WORLD\n' > test.txt -namespace = "coffee" -$kubectl get pods f{"--" + "-".join(['show', 'labels'])} --namespace f{namespace} - -# static interpolation -git_command = "status" -option = "-v" - -$git {{git_command}} {{option}} - -#TODO: -#a = $echo "hello" >> test.txt -#a = $sort < test.txt | grep "HELLO" -#print(f"test: {a}") - -# 1. use inside methods -$echo "SORT TEST\nHELLO WORLD" > test.txt def cp_test(): - print("1. >cp test.txt test_copy.txt") $cp test.txt test_copy.txt cp_test() -# 2. simple bash command execution with output -print("2. >python --version\n >echo \\nthis is an echo") -$python --version -$echo \\nthis is an echo - -# 3. set output to python variable directly +# 2. capture output as bytes out = $cat test.txt -test_data = out.decode('utf-8').strip() -print(test_data.replace("HELLO", "HOWDY")) +print(out.decode().strip().replace("HELLO", "HOWDY")) -# 4. wrapped, in-line execution -print(($cat test.txt).decode('utf-8').strip()) +# 3. capture inline, with ($ ...) or $(...) +print(($cat test.txt).decode().strip()) +print($(wc -l test.txt).decode().split()[0], "lines") -# 5. redirection -$echo "hello" >> test4.txt - -# 6. pipe chaining -$cat test.txt | sed 's/HELLO/HOWDY/g' | sed 's/HOW/WHY/g' | sed 's/WHY/WHEN/g' - -# 7. chaining pipes and redirection- works with all! -$cat test.txt | sed 's/HELLO/HOWDY\\n/g' > test5.txt - -# 8. chained redirection -$cat test.txt | sed 's/HELLO/HOWDY\\n/g' > test1.txt >> test2.txt > test3.txt - -# 9. input redirection +# 4. pipes, redirects and && || ; run without a shell +$cat test.txt | sed 's/HELLO/HOWDY/g' | sed 's/HOW/WHY/g' $sort < test.txt >> sorted_test.txt -$sort < test.txt | sed 's/SORT/TEST\\n/g' - -# 10. Glob patterns ->ls ./* +$test -f sorted_test.txt && echo "sorted" || echo "not sorted" +$ls no-such-file 2> errors.txt; echo "kept going" +count = $sort test.txt | uniq | wc -l +print("unique lines:", int(count)) + +# 5. interpolate Python values; each one is always a single argument +name = "world; echo not-a-second-command" +$echo hello {{name}} +$echo f{len(name):04d} chars + +# 6. a real shell when you want globbing or expansion: start the line with > +>ls ./*.txt | head -n 3 +>echo "safely quoted: {{name}}" + +# 7. long commands can continue onto the next line +$rm -f test.txt test_copy.txt \ + sorted_test.txt errors.txt diff --git a/examples/run.py b/examples/run.py deleted file mode 100755 index 4ab57fe..0000000 --- a/examples/run.py +++ /dev/null @@ -1,11 +0,0 @@ -# Setup hook -from pybash.hook import add_hook - -add_hook() - -## -# Import demo script to run -if __name__ == "__main__": - import demo - - print(f"~ completed running {demo}") diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index c5be4a7..0000000 --- a/poetry.lock +++ /dev/null @@ -1,328 +0,0 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. - -[[package]] -name = "autoflake" -version = "2.1.1" -description = "Removes unused imports and unused variables" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "autoflake-2.1.1-py3-none-any.whl", hash = "sha256:94e330a2bcf5ac01384fb2bf98bea60c6383eaa59ea62be486e376622deba985"}, - {file = "autoflake-2.1.1.tar.gz", hash = "sha256:75524b48d42d6537041d91f17573b8a98cb645642f9f05c7fcc68de10b1cade3"}, -] - -[package.dependencies] -pyflakes = ">=3.0.0" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[[package]] -name = "black" -version = "22.12.0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, - {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, - {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, - {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, - {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, - {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, - {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, - {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, - {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, - {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, - {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, - {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "click" -version = "8.1.3" -description = "Composable command line interface toolkit" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.1.1" -description = "Backport of PEP 654 (exception groups)" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "flake8" -version = "6.0.0" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" - -[[package]] -name = "ideas" -version = "0.1.5" -description = "Easy creation of import hooks to test ideas." -category = "main" -optional = true -python-versions = ">=3.6" -files = [ - {file = "ideas-0.1.5-py3-none-any.whl", hash = "sha256:894f9db904fd15eb7735d06ecdf77b268f658df778760f8e1ce7bb1e08ac81ad"}, - {file = "ideas-0.1.5.tar.gz", hash = "sha256:20edb786a6f1028e0659e9e51497b395014359baac7ffe74f1d3a89a42414cc0"}, -] - -[package.dependencies] -token-utils = "*" - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "isort" -version = "5.12.0" -description = "A Python utility / library to sort Python imports." -category = "dev" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, -] - -[package.extras] -colors = ["colorama (>=0.4.3)"] -pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] -plugins = ["setuptools"] -requirements-deprecated-finder = ["pip-api", "pipreqs"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "packaging" -version = "23.1" -description = "Core utilities for Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] - -[[package]] -name = "pathspec" -version = "0.11.1" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, -] - -[[package]] -name = "platformdirs" -version = "3.5.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, - {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, -] - -[package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] - -[[package]] -name = "pluggy" -version = "1.0.0" -description = "plugin and hook calling mechanisms for python" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pycodestyle" -version = "2.10.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, -] - -[[package]] -name = "pyflakes" -version = "3.0.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, -] - -[[package]] -name = "pytest" -version = "7.3.1" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] - -[[package]] -name = "token-utils" -version = "0.1.8" -description = "Small utility to work with Python tokens" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "token-utils-0.1.8.tar.gz", hash = "sha256:7f4e2ff973822ce1e1829ba70e17d00b315a570c5be625b01a3f361933c2f86a"}, - {file = "token_utils-0.1.8-py3-none-any.whl", hash = "sha256:8bdca11ae8aacab3e47dacbb21165c2d118167d2a0a59b93e61aa8af03f62481"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.5.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, -] - -[extras] -ideas = ["ideas"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "1a821c8672a6a61a07e63c61bd66cdd5cf586e782e43e5dd70ae2e697b33b80f" diff --git a/pybash/__init__.py b/pybash/__init__.py index e69de29..f85ef13 100644 --- a/pybash/__init__.py +++ b/pybash/__init__.py @@ -0,0 +1,41 @@ +"""Run shell commands from Python source with `$` and `>` lines.""" + +from __future__ import annotations + +import inspect + +from pybash import runtime as _runtime +from pybash.errors import InvalidInterpolation, PybashSyntaxError +from pybash.hook import add_hook, compile_source, remove_hook +from pybash.transformer import transform + +__all__ = [ + "RUNTIME_NAME", + "InvalidInterpolation", + "PybashSyntaxError", + "add_hook", + "compile_source", + "header", + "remove_hook", + "runtime_source", + "transform", +] + +#: Pass as `transform(..., runtime=RUNTIME_NAME)` when the output is prefixed with `header()`. +RUNTIME_NAME = "_pybash" + + +def runtime_source() -> str: + """Source of the stdlib-only helper class that generated code calls for operators.""" + return inspect.getsource(_runtime._pybash) + + +def header(*, standalone: bool = False) -> str: + """Lines to put above code produced by `transform(..., runtime=RUNTIME_NAME)`. + + With `standalone=True` the helper is pasted in, so the result runs without + pybash installed. + """ + if standalone: + return f"import subprocess\nfrom typing import Any\n\n\n{runtime_source()}\n" + return f"import subprocess\n\nimport pybash.runtime as {RUNTIME_NAME}\n" diff --git a/pybash/__main__.py b/pybash/__main__.py index c8edba6..8453877 100644 --- a/pybash/__main__.py +++ b/pybash/__main__.py @@ -1,26 +1,70 @@ -## -# Setup hook +"""`pybash script.py [args...]`: run a Python file that contains pybash commands.""" + +from __future__ import annotations + +import argparse import importlib.util +import runpy import sys from pathlib import Path -from pybash.hook import add_hook +from pybash.errors import PybashSyntaxError +from pybash.hook import add_hook, compile_source +from pybash.transformer import transform + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="pybash", description="Run Python with $ and > shell commands.") + parser.add_argument("-s", "--show", action="store_true", help="print the transformed Python instead of running it") + parser.add_argument("-c", dest="code", metavar="CODE", help="run code passed as a string") + parser.add_argument("target", nargs="?", help="script path or module name") + parser.add_argument("args", nargs=argparse.REMAINDER, help="arguments passed to the script") + options = parser.parse_args(argv) + + if (options.code is None) == (options.target is None): + parser.error("give either a script/module or -c CODE") + + try: + if options.code is not None: + return _run_source(options.code, "", options, script_dir="") + path = Path(options.target) + if path.is_file(): + return _run_source(path.read_text(encoding="utf-8"), str(path), options, script_dir=str(path.parent)) + if options.show: + parser.error(f"no such file: {options.target}") + sys.path.insert(0, "") + add_hook() + if not _module_exists(options.target): + print(f"pybash: no such file or module: {options.target}", file=sys.stderr) + return 2 + sys.argv = [options.target, *options.args] + runpy.run_module(options.target, run_name="__main__", alter_sys=True) + except PybashSyntaxError as error: + print(f"{error.filename}:{error.lineno}:{error.offset}: {error.msg}", file=sys.stderr) + if error.text: + print(f" {error.text.strip()}", file=sys.stderr) + return 2 + return 0 + + +def _module_exists(name: str) -> bool: + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def _run_source(source: str, filename: str, options: argparse.Namespace, *, script_dir: str) -> int: + if options.show: + print(transform(source, filename=filename)) + return 0 + code = compile_source(source, filename) + sys.path.insert(0, script_dir) + add_hook() + sys.argv = [filename if options.code is None else "-c", *options.args] + exec(code, {"__name__": "__main__", "__file__": filename, "__builtins__": __builtins__}) + return 0 -hook = add_hook() -# Run script dynamically if __name__ == "__main__": - spec = None - - if path_or_module := sys.argv[1]: - file_path = Path(path_or_module) - if file_path.exists() and file_path.is_file(): - spec = hook.find_spec(path_or_module.split('.py')[0], None) - else: - spec = importlib.util.find_spec(path_or_module) - - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - else: - print(f"Could not import {path_or_module}") + sys.exit(main()) diff --git a/pybash/errors.py b/pybash/errors.py new file mode 100644 index 0000000..15fe7a4 --- /dev/null +++ b/pybash/errors.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class PybashSyntaxError(SyntaxError): + """A `$` or `>` command that pybash cannot translate. + + `transform` fills in `filename`, `lineno`, `offset` and `text` like any + SyntaxError, so hosts can point at the offending line of the original source. + """ + + def __init__(self, message: str, *, text: str = "", offset: int = 0) -> None: + super().__init__(message) + self.msg = message + self.text = text + self.column = offset # 0-based, relative to `text` + + +class InvalidInterpolation(PybashSyntaxError): + """An empty or malformed `{{ }}` / `f{ }` interpolation.""" diff --git a/pybash/hook.py b/pybash/hook.py index dfee57b..3d9d1fe 100644 --- a/pybash/hook.py +++ b/pybash/hook.py @@ -1,22 +1,80 @@ -from ideas import import_hook +"""Import and execute Python files that contain pybash commands.""" + +from __future__ import annotations + +import ast +import importlib.abc +import importlib.machinery +import importlib.util +import sys +import sysconfig +from collections.abc import Sequence +from pathlib import Path +from types import CodeType, ModuleType from pybash.transformer import transform -def source_init(): - """Adds subprocess import""" - return "import subprocess\n" +def compile_source(source: str, filename: str = "") -> CodeType: + """Transform and compile source, making `subprocess` available to it. + + The import is added to the syntax tree rather than the text, so line + numbers match the original file. + """ + tree = ast.parse(transform(source, filename=filename), filename) + position = 0 + for position, node in enumerate(tree.body): + is_docstring = position == 0 and isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) + is_future = isinstance(node, ast.ImportFrom) and node.module == "__future__" + if not (is_docstring or is_future): + break + else: + position = len(tree.body) + tree.body.insert(position, ast.Import(names=[ast.alias(name="subprocess")])) + ast.fix_missing_locations(tree) + code: CodeType = compile(tree, filename, "exec") + return code + + +class PybashLoader(importlib.machinery.SourceFileLoader): + def get_code(self, fullname: str) -> CodeType: + # Never read or write .pyc files: they would not record that the source was transformed. + path = self.get_filename(fullname) + return compile_source(importlib.util.decode_source(self.get_data(path)), path) + + +class PybashFinder(importlib.abc.MetaPathFinder): + """Finds project modules (not the stdlib or installed packages) and loads them through pybash.""" + + def __init__(self) -> None: + names = ("stdlib", "platstdlib", "purelib", "platlib") + self._library_dirs = {Path(path).resolve() for name in names if (path := sysconfig.get_path(name))} + + def _is_library(self, origin: str) -> bool: + resolved = Path(origin).resolve() + return any(directory in resolved.parents for directory in self._library_dirs) + + def find_spec( + self, fullname: str, path: Sequence[str] | None, target: ModuleType | None = None + ) -> importlib.machinery.ModuleSpec | None: + spec = importlib.machinery.PathFinder.find_spec(fullname, path, target) + if spec is None or spec.origin is None or type(spec.loader) is not importlib.machinery.SourceFileLoader: + return None + if self._is_library(spec.origin): + return None + spec.loader = PybashLoader(spec.loader.name, spec.loader.path) + return spec -def add_hook(): - """Creates and automatically adds the import hook in sys.meta_path""" - return import_hook.create_hook( - hook_name=__name__, - transform_source=transform_source, - source_init=source_init, - ) +def add_hook() -> PybashFinder: + """Install the import hook (once) so `import module` works for files with pybash commands.""" + for finder in sys.meta_path: + if isinstance(finder, PybashFinder): + return finder + finder = PybashFinder() + sys.meta_path.insert(0, finder) + return finder -def transform_source(source, **_kwargs): - """Convert >bash commands to subprocess calls""" - return transform(source, **_kwargs) +def remove_hook() -> None: + sys.meta_path[:] = [finder for finder in sys.meta_path if not isinstance(finder, PybashFinder)] diff --git a/pybash/py.typed b/pybash/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pybash/runtime.py b/pybash/runtime.py new file mode 100644 index 0000000..7a0020e --- /dev/null +++ b/pybash/runtime.py @@ -0,0 +1,185 @@ +"""Runtime support for the code pybash generates. + +Plain commands compile to bare `subprocess` calls and need nothing from here. +Commands that use shell operators (`|`, `&&`, `>`, ...) or interpolate into a +`>` shell line compile to calls on `_pybash`, which is this module's class. + +The class is self-contained (stdlib only, imports inside the methods) so that +its source can be pasted into generated code that must not depend on pybash; +see `pybash.runtime_source()`. +""" + +from __future__ import annotations + +from typing import Any + + +class _pybash: + """Namespace for the helpers referenced by generated code.""" + + PIPES = ("|", "|&") + CONNECTORS = ("&&", "||", ";") + DUPLICATES = ("2>&1", ">&2") + + @staticmethod + def quote(value: Any) -> str: + """Quote a value so a shell reads it as exactly one word.""" + import shlex + + return shlex.quote(str(value)) + + @staticmethod + def dquote(value: Any) -> str: + """Escape a value for use inside a double-quoted shell string.""" + import re + + return re.sub(r'([\\"$`])', r"\\\1", str(value)) + + @staticmethod + def squote(value: Any) -> str: + """Escape a value for use inside a single-quoted shell string.""" + return str(value).replace("'", "'\\''") + + @staticmethod + def run(parts: list[Any], capture: bool = False) -> Any: + """Run a command list written the way it reads in a shell. + + `parts` alternates argument lists with operator strings, for example + `[["sort"], "<", "in.txt", "|", ["uniq", "-c"], "&&", ["echo", "ok"]]`. + A redirect operator is followed by its file name. + + Returns the `subprocess.CompletedProcess` of the last command that ran. + With `capture=True`, returns everything written to stdout as bytes and + raises `subprocess.CalledProcessError` if the final status is non-zero. + """ + import subprocess + + jobs: list[tuple[str, list[dict[str, Any]]]] = [(";", [])] + position = 0 + while position < len(parts): + part = parts[position] + position += 1 + pipeline = jobs[-1][1] + if not isinstance(part, str): + pipeline.append({"argv": [str(arg) for arg in part], "redirects": [], "pipe_stderr": False}) + elif part in _pybash.CONNECTORS: + jobs.append((part, [])) + elif part in _pybash.PIPES: + pipeline[-1]["pipe_stderr"] = part == "|&" + elif part in _pybash.DUPLICATES: + pipeline[-1]["redirects"].append((part, None)) + else: + pipeline[-1]["redirects"].append((part, parts[position])) + position += 1 + + captured: list[bytes] = [] + result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess([], 0) + for connector, pipeline in jobs: + if not pipeline: + continue + if connector == "&&" and result.returncode != 0: + continue + if connector == "||" and result.returncode == 0: + continue + result = _pybash._pipeline(pipeline, captured if capture else None) + + if not capture: + return result + output = b"".join(captured) + if result.returncode: + raise subprocess.CalledProcessError(result.returncode, result.args, output=output) + return output + + @staticmethod + def _pipeline(commands: list[dict[str, Any]], sink: list[bytes] | None) -> Any: + import contextlib + import subprocess + + procs: list[subprocess.Popen[bytes]] = [] + with contextlib.ExitStack() as files: + upstream: Any = None + collect: list[Any] = [] + try: + for index, command in enumerate(commands): + last = index == len(commands) - 1 + stdin: Any = upstream + stderr: Any = None + targets: list[Any] = [] + to_stderr = False + stderr_to_original = False + for op, name in command["redirects"]: + if op == "<": + stdin = files.enter_context(open(name, "rb")) + elif op in (">", ">>"): + targets.append(files.enter_context(open(name, "ab" if op == ">>" else "wb"))) + elif op in ("2>", "2>>"): + stderr = files.enter_context(open(name, "ab" if op == "2>>" else "wb")) + elif op in ("&>", "&>>"): + targets.append(files.enter_context(open(name, "ab" if op == "&>>" else "wb"))) + stderr = subprocess.STDOUT + elif op == "2>&1": + # Like a shell, follow stdout as it is right now: `2>&1 > f` keeps stderr out of f. + stderr = subprocess.STDOUT + stderr_to_original = not targets + elif op == ">&2": + to_stderr = True + else: + raise ValueError(f"pybash: unsupported operator {op!r}") + if command["pipe_stderr"]: + stderr = subprocess.STDOUT + + if stderr_to_original and targets and last and sink is None: + stderr = 1 + + # Several `>` targets on one command all receive the output. + fan_out = len(targets) > 1 + stdout: Any + if fan_out: + stdout = subprocess.PIPE + elif targets: + stdout = targets[0] + elif to_stderr: + stdout = 2 if stderr is None or stderr == subprocess.STDOUT else stderr + elif not last or sink is not None: + stdout = subprocess.PIPE + else: + stdout = None + + proc = subprocess.Popen(command["argv"], stdin=stdin, stdout=stdout, stderr=stderr) + procs.append(proc) + if upstream is not None and upstream != subprocess.DEVNULL: + # The child holds its own copy; ours would keep the writer from seeing EOF/SIGPIPE. + upstream.close() + if fan_out: + collect.append((proc, targets)) + upstream = subprocess.DEVNULL + elif stdout == subprocess.PIPE: + if last: + collect.append((proc, None)) + upstream = proc.stdout + else: + upstream = subprocess.DEVNULL + + for proc, targets in collect: + assert proc.stdout is not None + data = proc.stdout.read() + if targets is None: + assert sink is not None + sink.append(data) + else: + for target in targets: + target.write(data) + finally: + for proc in procs: + if proc.stdout is not None: + proc.stdout.close() + for proc in procs: + proc.wait() + + return subprocess.CompletedProcess(procs[-1].args, procs[-1].returncode) + + +quote = _pybash.quote +dquote = _pybash.dquote +squote = _pybash.squote +run = _pybash.run diff --git a/pybash/shell.py b/pybash/shell.py new file mode 100644 index 0000000..8a6daf4 --- /dev/null +++ b/pybash/shell.py @@ -0,0 +1,379 @@ +"""Turn the text of one command into a Python expression. + +`$` commands are lexed into words and operators here (quotes, escapes, +redirects, pipes, `&&`/`||`/`;`) and never reach a shell. `>` commands are +handed to the shell verbatim, with interpolated values quoted for the context +they appear in. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from pybash.errors import InvalidInterpolation, PybashSyntaxError + +DEFAULT_RUNTIME = '__import__("pybash.runtime").runtime' + +PIPES = ("|", "|&") +CONNECTORS = ("&&", "||", ";") +DUPLICATES = ("2>&1", ">&2") +FILE_REDIRECTS = ("<", ">", ">>", "2>", "2>>", "&>", "&>>") + + +@dataclass +class Lit: + text: str + + +@dataclass +class Expr: + code: str + kind: str # "f" (f-string), "direct" ({{ }}) or "raw" ({{! }}) + quote: str = "" # shell quote context the interpolation appeared in + + +@dataclass +class Word: + pos: int + parts: list[Lit | Expr] = field(default_factory=list) + plain: bool = True # only unquoted literal characters so far + + +@dataclass +class Op: + pos: int + text: str + + +def _skip_python_string(text: str, start: int) -> int: + """Index just past the Python string literal starting at `start`.""" + quote = text[start] + delim = quote * 3 if text.startswith(quote * 3, start) else quote + i = start + len(delim) + while i < len(text): + if text[i] == "\\": + i += 2 + elif text.startswith(delim, i): + return i + len(delim) + else: + i += 1 + return len(text) + + +def _balanced_end(text: str, start: int, closer: str) -> int: + """Index of the `closer` that ends the Python expression starting at `start`, or -1.""" + depth = 0 + i = start + while i < len(text): + char = text[i] + if char in "'\"": + i = _skip_python_string(text, i) + continue + if depth == 0 and text.startswith(closer, i): + return i + if char in "{[(": + depth += 1 + elif char in "}])" and depth: + depth -= 1 + i += 1 + return -1 + + +def scan_interpolation(text: str, i: int, quote: str = "") -> tuple[Expr, int] | None: + """Parse a `{{ expr }}` or `f{ expr }` interpolation starting at `i`, if there is one.""" + if text.startswith("{{", i): + end = _balanced_end(text, i + 2, "}}") + if end < 0: + return None + code = text[i + 2 : end].strip() + kind = "direct" + if code.startswith("!") and not code.startswith("!="): + code, kind = code[1:].strip(), "raw" + if not code: + raise InvalidInterpolation("empty {{ }} interpolation", text=text, offset=i) + return Expr(code, kind, quote), end + 2 + if text.startswith("f{", i) and not (i and (text[i - 1].isalnum() or text[i - 1] == "_")): + end = _balanced_end(text, i + 2, "}") + if end < 0: + return None + code = text[i + 2 : end] + if not code.strip(): + raise InvalidInterpolation("empty f{ } interpolation", text=text, offset=i) + return Expr(code, "f", quote), end + 1 + return None + + +def _escaped_marker(text: str, i: int) -> str | None: + r"""`\{{` and `\f{` are the literal texts `{{` and `f{`.""" + if text[i] == "\\": + for marker in ("{{", "f{"): + if text.startswith(marker, i + 1): + return marker + return None + + +def lex(text: str) -> list[Word | Op]: + """Split a `$` command into words and operators using POSIX shell quoting rules.""" + tokens: list[Word | Op] = [] + word: Word | None = None + quote = "" + i = 0 + + def literal(chars: str, *, plain: bool) -> None: + nonlocal word + if word is None: + word = Word(i) + word.plain = word.plain and plain + if word.parts and isinstance(word.parts[-1], Lit): + word.parts[-1].text += chars + else: + word.parts.append(Lit(chars)) + + def flush() -> None: + nonlocal word + if word is not None: + tokens.append(word) + word = None + + while i < len(text): + char = text[i] + marker = _escaped_marker(text, i) + if marker: + literal(marker, plain=False) + i += 3 + continue + found = scan_interpolation(text, i, quote) + if found: + if word is None: + word = Word(i) + word.plain = False + word.parts.append(found[0]) + i = found[1] + continue + + if quote == "'": + if char == "'": + quote = "" + else: + literal(char, plain=False) + i += 1 + elif quote == '"': + if char == '"': + quote = "" + elif char == "\\" and text[i + 1 : i + 2] in ('"', "\\", "$", "`"): + literal(text[i + 1], plain=False) + i += 1 + else: + literal(char, plain=False) + i += 1 + elif char.isspace(): + flush() + i += 1 + elif char == "#" and word is None: + break + elif char in "'\"": + quote = char + if word is None: + word = Word(i) + word.plain = False + i += 1 + elif char == "\\": + if i + 1 < len(text): + literal(text[i + 1], plain=False) + i += 2 + elif char in "|&;<>": + fd, start = "", i + if char == ">" and word is not None and word.plain and len(word.parts) == 1: + digits = word.parts[0] + if isinstance(digits, Lit) and digits.text.isdigit(): + fd, start = digits.text, word.pos + word = None + flush() + op, i = _lex_operator(text, i, fd, start) + tokens.append(Op(start, op)) + else: + literal(char, plain=True) + i += 1 + + if quote: + raise PybashSyntaxError(f"unterminated {quote} quote in command", text=text, offset=len(text) - 1) + flush() + return tokens + + +def _lex_operator(text: str, i: int, fd: str, start: int) -> tuple[str, int]: + def unsupported(what: str) -> PybashSyntaxError: + return PybashSyntaxError( + f"{what} is not supported in a $ command; use a > shell command", text=text, offset=start + ) + + for op in ("||", "|&", "|", "&&", "&>>", "&>", ";"): + if text.startswith(op, i): + return op, i + len(op) + if text[i] == "&": + raise unsupported("running in the background with &") + if text[i] == "<": + if text.startswith("<<", i): + raise unsupported("a here-document (<<)") + return "<", i + 1 + + if fd not in ("", "1", "2"): + raise unsupported(f"redirecting file descriptor {fd}") + if text.startswith(">&", i): + j = i + 2 + while j < len(text) and text[j].isdigit(): + j += 1 + op = (fd or "1") + ">&" + text[i + 2 : j] + if op == "2>&1": + return op, j + if op == "1>&2": + return ">&2", j + raise unsupported(f"the redirect {fd}>&{text[i + 2 : j]}") + prefix = "2" if fd == "2" else "" + if text.startswith(">>", i): + return prefix + ">>", i + 2 + return prefix + ">", i + 1 + + +def _string(text: str) -> str: + return json.dumps(text, ensure_ascii=False) + + +def _fstring(code: str) -> str: + return 'f"""{' + code + '}"""' + + +def _word_expr(word: Word) -> str: + """The Python expression that evaluates to this word's text.""" + if not word.parts: + return '""' + if len(word.parts) == 1 and isinstance(word.parts[0], Expr) and word.parts[0].kind != "f": + return f"str({word.parts[0].code})" + pieces = [] + for part in word.parts: + if isinstance(part, Lit): + pieces.append(_string(part.text)) + elif part.kind == "f": + pieces.append(_fstring(part.code)) + else: + pieces.append(f"str({part.code})") + return " + ".join(pieces) + + +def compile_exec(text: str, *, capture: bool, runtime: str = DEFAULT_RUNTIME) -> str: + """Compile a `$` command to an expression that runs it without a shell.""" + tokens = lex(text) + if not tokens: + raise PybashSyntaxError("empty command", text=text, offset=0) + + simple = all(isinstance(token, Word) for token in tokens) + if simple: + args = ", ".join(_word_expr(token) for token in tokens if isinstance(token, Word)) + return f"subprocess.{'check_output' if capture else 'run'}([{args}])" + + parts: list[str] = [] + argv: list[str] = [] + redirects: list[str] = [] + expects = "" # what the previous operator still needs: "command" or "file" + last_op: Op | None = None + + def end_command(op: Op | None) -> None: + if not argv: + where = op.pos if op else last_op.pos if last_op else len(text) + what = f"before {op.text!r}" if op else f"after {last_op.text!r}" if last_op else "" + raise PybashSyntaxError(f"missing command {what}".strip(), text=text, offset=where) + parts.append("[" + ", ".join(argv) + "]") + parts.extend(redirects) + argv.clear() + redirects.clear() + + for token in tokens: + if isinstance(token, Word): + if expects == "file": + redirects.append(_word_expr(token)) + else: + argv.append(_word_expr(token)) + expects = "" + continue + if expects == "file": + raise PybashSyntaxError(f"missing file name before {token.text!r}", text=text, offset=token.pos) + if token.text in FILE_REDIRECTS: + redirects.append(_string(token.text)) + expects = "file" + elif token.text in DUPLICATES: + redirects.append(_string(token.text)) + else: + end_command(token) + parts.append(_string(token.text)) + expects = "command" + last_op = token + + if expects == "file": + assert last_op is not None + raise PybashSyntaxError(f"missing file name after {last_op.text!r}", text=text, offset=last_op.pos) + if argv or redirects or not (last_op and last_op.text == ";"): + end_command(None) + else: + parts.pop() # a trailing `;` is harmless + + call = f"{runtime}.run([{', '.join(parts)}]" + return call + (", capture=True)" if capture else ")") + + +def compile_shell(text: str, *, runtime: str = DEFAULT_RUNTIME) -> str: + """Compile a `>` command to an expression that runs it through the shell. + + The command text is passed through untouched. Interpolated values are + quoted for where they sit (bare, inside "..." or inside '...') so a value + is always data and never shell syntax; `{{! expr }}` opts out. + """ + text = text.strip() + if not text: + raise PybashSyntaxError("empty command", text=text, offset=0) + + pieces: list[str] = [] + buffer = "" + quote = "" + i = 0 + while i < len(text): + char = text[i] + marker = _escaped_marker(text, i) + if marker: + buffer += marker + i += 3 + continue + found = scan_interpolation(text, i, quote) + if found: + expr, i = found + if buffer: + pieces.append(_string(buffer)) + buffer = "" + value = _fstring(expr.code) if expr.kind == "f" else expr.code + if expr.kind == "raw": + pieces.append(f"str({value})") + else: + helper = {"": "quote", '"': "dquote", "'": "squote"}[quote] + pieces.append(f"{runtime}.{helper}({value})") + continue + if quote == "'": + quote = "" if char == "'" else quote + elif quote == '"': + if char == "\\" and i + 1 < len(text): + buffer += char + i += 1 + char = text[i] + elif char == '"': + quote = "" + elif char == "\\" and i + 1 < len(text): + buffer += char + i += 1 + char = text[i] + elif char in "'\"": + quote = char + buffer += char + i += 1 + + if buffer: + pieces.append(_string(buffer)) + return f"subprocess.run({' + '.join(pieces)}, shell=True)" diff --git a/pybash/transformer.py b/pybash/transformer.py index 45c9bb5..096c217 100644 --- a/pybash/transformer.py +++ b/pybash/transformer.py @@ -1,457 +1,204 @@ -import re -import shlex -from typing import Callable, Union +"""Rewrite Python source that contains `$` and `>` commands into plain Python. -import token_utils +Every input line maps to exactly one output line, so line numbers in +tracebacks still point at the original source. +""" +from __future__ import annotations -class InvalidInterpolation(Exception): - pass +from dataclasses import dataclass, field +from pybash.errors import InvalidInterpolation, PybashSyntaxError +from pybash.shell import DEFAULT_RUNTIME, _escaped_marker, compile_exec, compile_shell, scan_interpolation -class Processor: - command_char = "$" +__all__ = ["InvalidInterpolation", "PybashSyntaxError", "transform"] - def __init__(self, token: token_utils.Token) -> None: - self.token = token - self.token_line = token.line - self.parse() +_OPENERS = {"(": ")", "[": "]", "{": "}"} +_CLOSERS = {closer: opener for opener, closer in _OPENERS.items()} - def parse(self) -> None: - self.parsed_line = shlex.split(self.token_line) - self.command = Commander.get_bash_command(self.parsed_line, command_char=self.command_char) - def transform(self) -> token_utils.Token: - raise NotImplementedError +@dataclass +class _State: + """What carries over from one physical line to the next.""" - def interpolate(self) -> None: - self.token.string = Processor.fstring_interpolate(self.token_line, self.token.string) - self.token.string = Processor.direct_interpolate(self.token.string) + string: str = "" # delimiter of an open triple-quoted string + brackets: list[str] = field(default_factory=list) + continued: bool = False # previous line ended with a backslash - @staticmethod - def fstring_interpolate(token_string: str, parsed_command: str) -> str: - """Process f{ dynamic interpolations } and substitute. - Dynamic interpolations are denotated by a f{ } with any expression inside. - Substitution in the parsed command string happens relative to the order of the - interpolations in the original command string. + @property + def at_statement_start(self) -> bool: + return not (self.string or self.brackets or self.continued) - Args: - token_string (str): Original command string - parsed_command (str): Parsed command string - Returns: - str: Interpolated parsed command string - """ - pattern = r'f{(.+?)}' - subs = re.findall(pattern, token_string) - - if not subs: - return parsed_command - - for sub in subs: - parsed_command = re.sub(pattern, '" + f\"\"\"{' + sub + '}\"\"\" + "', parsed_command, 1) - - return parsed_command.replace('"" + f"""', 'f"""').replace('""" + ""', '"""') - - @staticmethod - def direct_interpolate(token_string: str) -> str: - """Process {{ static interpolations }} and substitute. - Static interpolations are denotated by a {{ }} with a variable or a function call inside. - Substitution happens directly on the parsed command string. Therefore, certain characters - cannot be interpolated as they get parsed out before substitution. - - Args: - string (str): String to interpolate - - Returns: - str: Interpolated or given token string - """ - - # validate interpolation - invalid_chars = [' ', '"', "'"] - matches = re.findall(r'{{(.+?)}}', token_string) - if not matches: - return token_string - - if any(any(bad_char in match for bad_char in invalid_chars) for match in matches): - raise InvalidInterpolation - - interpolated = re.sub(r'{{(.+?)}}', r'" + \1 + "', token_string) - return interpolated.replace(',"" + ', ',').replace(' + ""', '') - - -class Shelled(Processor): - # >ls .github/* - command_char = ">" - - def transform(self) -> token_utils.Token: - command_str = " ".join(self.command) - self.token.string = Commander.build_subprocess_str_cmd("run", command_str, shell=True) + '\n' - return self.token - - -class Execed(Processor): - # $ls -la - def transform(self) -> token_utils.Token: - pipeline_command = Pipeline(self.command).parse_command() - if pipeline_command != self.command: - self.token.string = pipeline_command + '\n' - else: - # no pipers - self.token.string = Commander.build_subprocess_list_cmd("run", self.command) + '\n' - return self.token - - -class Variablized(Processor): - # a = $cat test.txt - def parse(self) -> None: - self.parsed_line = shlex.split(self.token.line) - self.start_index = Commander.get_start_index(self.parsed_line) - self.command = Commander.get_bash_command(self.parsed_line, start_index=self.start_index) - - def transform(self) -> None: - pipeline_command = Pipeline(self.command).parse_command(variablized=True) - - if pipeline_command != self.command: - self.token.string = pipeline_command - self.token.string += ';' if pipeline_command[-1] != ';' else '' - self.token.string += ' '.join(self.parsed_line[: self.start_index]) + ' cmd1\n' - else: - self.token.string = ' '.join(self.parsed_line[: self.start_index]) + ' ' - self.token.string += Commander.build_subprocess_list_cmd("check_output", self.command) + '\n' - - -class Wrapped(Processor): - # print($cat test.txt) - def parse(self) -> None: - self.parsed_line = shlex.split(self.token.line) - self.raw_line = [tok for tok in self.token.line.split(' ') if tok] - self.start_index = Commander.get_start_index(self.parsed_line) - self.command = Commander.get_bash_command(self.parsed_line, start_index=self.start_index, wrapped=True) - - def transform(self) -> token_utils.Token: - # shlex strips out single quotes and double quotes-- use raw_line for the code around the wrapped command - self.token.string = ( - ' '.join(self.raw_line[: self.start_index]) - + self.raw_line[self.start_index][: self.raw_line[self.start_index].index('$')] - ) - self.token.string += ( - Commander.build_subprocess_list_cmd("check_output", self.command) - + self.raw_line[-1][self.raw_line[-1].index(')') :] - + '\n' - ) - - return self.token - - -class Pipers: - """Handles the logic of chaining operators""" - - OPS = ['|', '>', '>>', '<', '&&'] - - @classmethod - def get_piper(cls, op: str) -> Callable: - if op == '|': - # Pipe output to next command - return cls.chain_pipe_command - elif op == '>': - # Write to out file - return cls.chain_sredirect_command - elif op == '>>': - # Append to out file - return cls.chain_dredirect_command - elif op == '<': - # Redirect file as input to command - return cls.chain_iredirect_command - elif op == '&&': - # Run next command only if previous succeeds - return cls.chain_and_command - - raise NotImplementedError - - @classmethod - def chain_iredirect_command( - cls, command: list, pipeline: list, start_index: int = 0, fmode: str = "r", fvar: str = "fout", **kwargs - ) -> str: - first_idx, _ = pipeline.pop(0) - pre_command = command[start_index:first_idx] - filename = command[first_idx + 1 : first_idx + 2][0] - - fout = f'open("{filename}", "{fmode}")' - - if not pipeline: - # out to file - cmd1 = Commander.build_subprocess_list_cmd("run", pre_command, stdin=fvar, **kwargs) - return f"{fvar} = {fout}; cmd1 = {cmd1}" - - cmd1 = Commander.build_subprocess_list_cmd("Popen", pre_command, stdin=fvar, stdout="subprocess.PIPE", **kwargs) - - out = f"{fvar} = {fout}; cmd1 = {cmd1};" - while pipeline: - idx, piper = pipeline[0] - fvar = f"fout{idx}" - cmd = "" - if piper == '>': - # $sort < test.txt > test2.txt - cmd = cls.write_to_file( - command, pipeline, reader='cmd1.stdout.read()', start_index=first_idx + 1, fmode="wb" - ) - elif piper == '>>': - # $sort < test.txt >> test2.txt - cmd = cls.write_to_file( - command, pipeline, reader='cmd1.stdout.read()', start_index=first_idx + 1, fmode="ab" - ) - elif piper == '|': - # $sort < test.txt | grep "HELLO" - cmd = cls.get_piper(piper)( - command, pipeline, start_index=first_idx + 1, stdin="cmd1.stdout", chained=True - ) - out += cmd - first_idx = idx - - return out - - @classmethod - def write_to_file( - cls, command: list, pipeline: list, reader: str, start_index: int = 0, fvar: str = 'fout', fmode: str = 'wb' - ) -> str: - first_idx, _ = pipeline.pop(0) - filename = command[first_idx + 1 : first_idx + 2][0] - return f'{fvar} = open("{filename}", "{fmode}"); {fvar}.write({reader});' - - @classmethod - def chain_and_command(cls, command: list, pipeline: list, **kwargs): - raise NotImplementedError - - @classmethod - def chain_pipe_command( - cls, command: list, pipeline: list, start_index: int = 0, chained: bool = False, **kwargs - ) -> str: - first_idx, _ = pipeline.pop(0) - pre_command = command[start_index:first_idx] - cmd1 = ( - "" - if chained - else Commander.build_subprocess_list_cmd('Popen', pre_command, stdout='subprocess.PIPE', **kwargs) - ) - cmd2 = "" - if not pipeline: - ## No other pipes - post_command = command[first_idx + 1 :] - - cmd2 = Commander.build_subprocess_list_cmd('run', post_command, stdin='cmd1.stdout') - - return f"cmd2 = {cmd2}" if chained else f"cmd1 = {cmd1}; cmd2 = {cmd2}" - out = "" if chained else f"cmd1 = {cmd1};" - while pipeline: - idx, piper = pipeline[0] - cmd = cls.get_piper(piper)(command, pipeline, start_index=first_idx + 1, stdin="cmd1.stdout") - out += cmd - first_idx = idx - - return out - - @classmethod - def chain_redirect( - cls, - command: list, - pipeline: list, - start_index: int = 0, - fvar: str = "fout", - fmode: str = "wb", - chained: bool = False, - **kwargs, - ) -> str: - first_idx, _ = pipeline.pop(0) - pre_command = command[start_index:first_idx] - filename = command[first_idx + 1 : first_idx + 2][0] - - if chained: - # file-to-file redirection so cat from source file - pre_command.insert(0, 'cat') - - # out to file - fout = f'open("{filename}", "{fmode}")' - cmd1 = Commander.build_subprocess_list_cmd("run", pre_command, stdout=fvar, **kwargs) - - if not pipeline: - return f"{fvar} = {fout}; cmd1 = {cmd1}" - - out = f"{fvar} = {fout}; cmd1 = {cmd1};" - while pipeline: - idx, piper = pipeline[0] - fvar = f"fout{idx}" - if piper in ['>', '>>']: - cmd = cls.get_piper(piper)(command, pipeline, start_index=first_idx + 1, fvar=fvar, chained=True) - else: - cmd = cls.get_piper(piper)(command, pipeline, start_index=first_idx + 1, stdin=fvar) - out += cmd - first_idx = idx - - return out - - @classmethod - def chain_sredirect_command( - cls, command: list, pipeline: list, start_index: int = 0, fvar: str = "fout", chained: bool = False, **kwargs - ) -> str: - return cls.chain_redirect(command, pipeline, start_index, fmode="wb", fvar=fvar, chained=chained, **kwargs) - - @classmethod - def chain_dredirect_command( - cls, command: list, pipeline: list, start_index: int = 0, fvar: str = "fout", chained: bool = False, **kwargs - ) -> str: - return cls.chain_redirect(command, pipeline, start_index, fmode="ab", fvar=fvar, chained=chained, **kwargs) - - -class Pipeline: - """Parses and transformers command chainings by generating pipers""" - - __slots__ = ['command', 'pipeline'] - - def __init__(self, command: list[str]): - self.command = command - self.pipeline = [(i, arg) for i, arg in enumerate(self.command) if arg in Pipers.OPS] - - def parse_command(self, variablized: bool = False, **kwargs) -> Union[list[str], str]: - if not self.pipeline: - return self.command - - _, first_piper = self.pipeline[0] - return Pipers.get_piper(first_piper)(self.command, self.pipeline, **kwargs) - - -class Commander: - """Methods related to building and parsing commands""" - - @staticmethod - def get_start_index(parsed_line: list) -> int: - """Get the start index of first matching > - - Args: - parsed_line (list): line to parse - - Returns: - int: starting index - """ - for i, val in enumerate(parsed_line): - if '$' in val: +def _command_end(line: str, start: int, closer: str) -> int: + """Index of the unbalanced `closer` that ends the command starting at `start`, or -1.""" + opener = _CLOSERS[closer] + depth = 0 + quote = "" + i = start + while i < len(line): + char = line[i] + if _escaped_marker(line, i): + i += 3 + continue + found = scan_interpolation(line, i) + if found: + i = found[1] + continue + if quote: + if char == "\\" and quote == '"': + i += 1 + elif char == quote: + quote = "" + elif char == "\\": + i += 1 + elif char in "'\"": + quote = char + elif char == opener: + depth += 1 + elif char == closer: + if depth == 0: return i - - return 0 - - @staticmethod - def get_bash_command( - parsed_line: list, - start_index: Union[int, None] = None, - wrapped: Union[bool, None] = None, - command_char: str = "$", - ) -> list: - """Parses line to bash command - - Args: - parsed_line (list): line to parse - start_index (int, optional): index to start parsing command from. Defaults to None. - wrapped (bool, optional): input is surrounded by parentheses - - Returns: - list: parsed command list - """ - # find which arg index the $ is at - if not start_index: - start_index = Commander.get_start_index(parsed_line) - - # strip everything before that index-- not part of the command - command = parsed_line[start_index:] - - # $ may be at the beginning or somewhere in the middle of this arg - # examples: $ls, print(>cat => strip up to and including > - command[0] = command[0][command[0].index(command_char) + 1 :].strip() - if command[0] == '': - del command[0] - - # remove everything after and including first )- not part of the command - if wrapped: - if ')' not in command[-1]: - raise SyntaxError("Missing end parentheses") - - command[-1] = command[-1][: command[-1].index(')')] - - return command - - @staticmethod - def build_subprocess_str_cmd(method: str, arg: str, **kwargs) -> str: - """Builds subprocess command with string arg - - Args: - method (str): subprocess method name - arg (str): string arg - - Returns: - str: subprocess command - """ - command = f'subprocess.{method}("{arg}"' - if kwargs: - for k, v in kwargs.items(): - command += f", {k}={v}" - command += ")" - return command - - @staticmethod - def build_subprocess_list_cmd(method: str, args: list, **kwargs) -> str: - """Builds subprocess command with list args - - Args: - method (str): subprocess method name - args (list): list of args - - Returns: - str: subprocess command - """ - command = f'subprocess.{method}([' - for arg in args: - command += '\"' + arg + '\",' - command = command[:-1] - command += ']' - if kwargs: - for k, v in kwargs.items(): - command += f", {k}={v}" - command += ")" - return command - - -TOKENIZERS = {">": Shelled, "$": Execed} -GREEDY_TOKENIZERS = {"= $": Variablized, "($": Wrapped} - - -def transform(source, **_kwargs): - """Convert >bash commands to subprocess calls""" - new_tokens = [] - for line in token_utils.get_lines(source): - token = token_utils.get_first(line) - if not token: - new_tokens.extend(line) + depth -= 1 + i += 1 + return -1 + + +def _rewrite_code(line: str, state: _State, runtime: str) -> str: + """Rewrite the `$` captures in a line of Python, tracking strings and brackets.""" + out: list[str] = [] + i = 0 + last_code = "" + while i < len(line): + if state.string: + end = line.find(state.string, i) + while end > 0 and line[end - 1] == "\\": + end = line.find(state.string, end + 1) + if end < 0: + out.append(line[i:]) + state.continued = False + return "".join(out) + out.append(line[i : end + 3]) + i = end + 3 + state.string = "" + last_code = '"' continue - if token_match := [tokenizer for match, tokenizer in TOKENIZERS.items() if token == match]: - parser = token_match[0](token) - parser.transform() - parser.interpolate() - new_tokens.append(parser.token) + char = line[i] + if char == "#": + out.append(line[i:]) + break + if char in "'\"": + delim = char * 3 if line.startswith(char * 3, i) else char + j = i + len(delim) + while j < len(line) and not line.startswith(delim, j): + j += 2 if line[j] == "\\" else 1 + if j >= len(line) and len(delim) == 3: + state.string = delim + j = min(j + len(delim), len(line)) + out.append(line[i:j]) + i = j + last_code = '"' continue - - if greedy_match := [tokenizer for match, tokenizer in GREEDY_TOKENIZERS.items() if match in token.line]: - parser = greedy_match[0](token) - parser.transform() - parser.interpolate() - new_tokens.append(parser.token) + if char == "$": + missing = PybashSyntaxError("Missing end parentheses", text=line, offset=i) + if line.startswith("(", i + 1): + # $(command) + column = i + 2 + end = _command_end(line, column, ")") + if end < 0: + raise missing + i = end + 1 + elif state.brackets: + # ($command): the command runs up to the bracket that encloses it + column = i + 1 + end = _command_end(line, column, _OPENERS[state.brackets[-1]]) + if end < 0: + if state.brackets[-1] == "(": + raise missing + end = len(line) + i = end + else: + # x = $command + column, end = i + 1, len(line) + i = end + try: + out.append(compile_exec(line[column:end], capture=True, runtime=runtime)) + except PybashSyntaxError as error: + error.column += column + raise + last_code = ")" continue - - # no match - new_tokens.extend(line) - - return token_utils.untokenize(new_tokens) - - -if __name__ == "__main__": - pyscript = transform("test = $ echo hi") - print(pyscript) + if char in _OPENERS: + state.brackets.append(char) + elif char in _CLOSERS and state.brackets: + state.brackets.pop() + if not char.isspace(): + last_code = char + out.append(char) + i += 1 + + state.continued = last_code == "\\" and not state.string + return "".join(out) + + +def _may_change_state(line: str) -> bool: + return any(char in line for char in "([{\\") or '"""' in line or "'''" in line + + +def transform(source: str, *, runtime: str | None = None, filename: str = "", **_ignored: object) -> str: + """Convert `$` and `>` commands in Python source to `subprocess` calls. + + Args: + source: Python source that may contain pybash commands. + runtime: Expression the generated code uses to reach `pybash.runtime` + for commands with operators. The default imports it in place, so the + output only needs `import subprocess`. Pass a name such as + `"_pybash"` when the host provides it (see `pybash.header`). + filename: Reported in syntax errors. + + Raises: + PybashSyntaxError: A command could not be translated. `lineno`, `offset` + and `text` point at the original source. + """ + runtime = runtime or DEFAULT_RUNTIME + lines = source.split("\n") + out: list[str] = [] + state = _State() + index = 0 + while index < len(lines): + line = lines[index] + lineno = index + 1 + index += 1 + stripped = line.lstrip() + indent = line[: len(line) - len(stripped)] + is_exec = stripped.startswith("$") and not stripped.startswith("$(") + try: + if state.at_statement_start and (is_exec or stripped.startswith(">")): + command = stripped[1:] + joined = 0 + while command.endswith("\\") and not command.endswith("\\\\") and index < len(lines): + command = command[:-1] + " " + lines[index].strip() + index += 1 + joined += 1 + try: + if is_exec: + code = compile_exec(command, capture=False, runtime=runtime) + else: + code = compile_shell(command, runtime=runtime) + except PybashSyntaxError as error: + error.column += len(indent) + 1 + raise + out.append(indent + code) + out.extend([""] * joined) + elif "$" in line or not state.at_statement_start or _may_change_state(line): + out.append(_rewrite_code(line, state, runtime)) + else: + out.append(line) + except PybashSyntaxError as error: + error.filename = filename + error.lineno = lineno + error.text = line + error.offset = error.column + 1 + raise + return "\n".join(out) diff --git a/pyproject.toml b/pyproject.toml index cf2e9e4..1ad28cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,37 +1,66 @@ -[tool.poetry] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] name = "PyBash" -version = "0.3.5" +version = "0.4.0" description = ">execute bash commands from python easily" -authors = ["Jay "] +authors = [{ name = "Jay", email = "jay.github0@gmail.com" }] readme = "README.md" -packages = [{include = "pybash"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.11" +keywords = ["bash", "shell", "subprocess", "transpiler"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Topic :: System :: Shells", + "Typing :: Typed", +] +dependencies = [] -[tool.poetry.dependencies] -python = "^3.9" -ideas = { version = "^0.1.5", optional = true } -token-utils = "^0.1.8" +[project.optional-dependencies] +# Kept so `pip install "pybash[script]"` still works; the runner no longer needs extra packages. +script = [] -[tool.poetry.extras] -script = ["ideas"] +[project.urls] +Repository = "https://github.com/jaykv/PyBash" -[tool.poetry.group.dev.dependencies] -black = "^22.12.0" -pytest = "^7.2.1" -isort = "^5.11.4" -flake8 = "^6.0.0" -autoflake = "^2.0.0" +[project.scripts] +pybash = "pybash.__main__:main" -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[dependency-groups] +dev = [ + "mypy>=1.18", + "pytest>=8.4", + "ruff>=0.14", +] -[tool.black] +[tool.hatch.build.targets.wheel] +packages = ["pybash"] + +[tool.ruff] line-length = 120 -skip-string-normalization = 1 - -[tool.isort] -profile = "black" -line_length = 120 -default_section = "THIRDPARTY" -known_first_party = "pybash" -known_third_party = ["ideas", "token_utils"] \ No newline at end of file + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["E", "F"] + +[tool.ruff.format] +exclude = ["examples"] + +[tool.mypy] +strict = true +files = ["pybash", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/test_pybash.py b/test_pybash.py deleted file mode 100644 index 0c12800..0000000 --- a/test_pybash.py +++ /dev/null @@ -1,142 +0,0 @@ -import pytest - -from pybash.transformer import InvalidInterpolation -from pybash.transformer import transform as run_bash - - -def test_single_exec(): - assert run_bash('$ls -la') == 'subprocess.run(["ls","-la"])\n' - assert run_bash('$python --version') == 'subprocess.run(["python","--version"])\n' - assert run_bash('$ python --version') == 'subprocess.run(["python","--version"])\n' - - -def test_var_exec(): - assert run_bash('a = $echo "test 123"') == 'a = subprocess.check_output(["echo","test 123"])\n' - assert run_bash('a = $ echo "test 123"') == 'a = subprocess.check_output(["echo","test 123"])\n' - - -def test_wrapped_exec(): - assert ( - run_bash('print(($ cat test.txt).decode("utf-8").strip())') - == 'print((subprocess.check_output(["cat","test.txt"])).decode("utf-8").strip())\n' - ) - - -def test_inline_exec(): - assert run_bash('print(str($echo test 123))') == 'print(str(subprocess.check_output(["echo","test","123"])))\n' - - -def test_inline_dot_exec(): - assert ( - run_bash("print(($cat test.txt).decode('utf-8').strip())") - == 'print((subprocess.check_output(["cat","test.txt"])).decode(\'utf-8\').strip())\n' - ) - - -def test_method_exec(): - src = '''def cp_test(): - $cp test.txt test_copy.txt - ''' - assert ( - run_bash(src) - == '''def cp_test(): - subprocess.run(["cp","test.txt","test_copy.txt"]) - ''' - ) - - -def test_pipe_basic(): - assert ( - run_bash("$cat test.txt | sed 's/HELLO/HOWDY/g'") - == 'cmd1 = subprocess.Popen(["cat","test.txt"], stdout=subprocess.PIPE); cmd2 = subprocess.run(["sed","s/HELLO/HOWDY/g"], stdin=cmd1.stdout)\n' - ) - assert ( - run_bash("$cat test.txt > test2.txt") - == 'fout = open("test2.txt", "wb"); cmd1 = subprocess.run(["cat","test.txt"], stdout=fout)\n' - ) - - -def test_pipe_redirect(): - assert ( - run_bash("$cat test.txt | sed 's/HELLO/HOWDY/g' > test2.txt") - == 'cmd1 = subprocess.Popen(["cat","test.txt"], stdout=subprocess.PIPE);fout = open("test2.txt", "wb"); cmd1 = subprocess.run(["sed","s/HELLO/HOWDY/g"], stdout=fout, stdin=cmd1.stdout)\n' - ) - - -def test_pipe_pipe_pipe(): - assert ( - run_bash("$cat test.txt | sed 's/HELLO/HOWDY/g' | sed 's/HOW/WHY/g' | sed 's/WHY/WHEN/g'") - == 'cmd1 = subprocess.Popen(["cat","test.txt"], stdout=subprocess.PIPE);cmd1 = subprocess.Popen(["sed","s/HELLO/HOWDY/g"], stdout=subprocess.PIPE, stdin=cmd1.stdout);cmd1 = subprocess.Popen(["sed","s/HOW/WHY/g"], stdout=subprocess.PIPE, stdin=cmd1.stdout); cmd2 = subprocess.run(["sed","s/WHY/WHEN/g"], stdin=cmd1.stdout)\n' - ) - - -def test_pipe_chained_redirect(): - assert ( - run_bash("$cat test.txt | sed 's/HELLO/HOWDY\\n/g' > test1.txt >> test2.txt > test3.txt") - == 'cmd1 = subprocess.Popen(["cat","test.txt"], stdout=subprocess.PIPE);fout = open("test1.txt", "wb"); cmd1 = subprocess.run(["sed","s/HELLO/HOWDY\\n/g"], stdout=fout, stdin=cmd1.stdout);fout7 = open("test2.txt", "ab"); cmd1 = subprocess.run(["cat","test1.txt"], stdout=fout7);fout9 = open("test3.txt", "wb"); cmd1 = subprocess.run(["cat","test2.txt"], stdout=fout9)\n' - ) - - -def test_input_redirect(): - assert run_bash("$sort < test.txt") == 'fout = open("test.txt", "r"); cmd1 = subprocess.run(["sort"], stdin=fout)\n' - assert ( - run_bash("$sort < test.txt | sed 's/SORT/WHAT/g'") - == 'fout = open("test.txt", "r"); cmd1 = subprocess.Popen(["sort"], stdin=fout, stdout=subprocess.PIPE);cmd2 = subprocess.run(["sed","s/SORT/WHAT/g"], stdin=cmd1.stdout)\n' - ) - assert ( - run_bash("$sort < test.txt | sed 's/SORT/WHAT/g' | sed 's/WHAT/WHY/g'") - == 'fout = open("test.txt", "r"); cmd1 = subprocess.Popen(["sort"], stdin=fout, stdout=subprocess.PIPE);cmd1 = subprocess.Popen(["sed","s/SORT/WHAT/g"], stdout=subprocess.PIPE, stdin=cmd1.stdout); cmd2 = subprocess.run(["sed","s/WHAT/WHY/g"], stdin=cmd1.stdout)\n' - ) - assert ( - run_bash("$sort < test.txt | sed 's/SORT/WHAT/g' | sed 's/WHAT/WHY/g' > iredirect_end.txt") - == 'fout = open("test.txt", "r"); cmd1 = subprocess.Popen(["sort"], stdin=fout, stdout=subprocess.PIPE);cmd1 = subprocess.Popen(["sed","s/SORT/WHAT/g"], stdout=subprocess.PIPE, stdin=cmd1.stdout);fout = open("iredirect_end.txt", "wb"); cmd1 = subprocess.run(["sed","s/WHAT/WHY/g"], stdout=fout, stdin=cmd1.stdout)\n' - ) - assert ( - run_bash("$sort < test.txt > test_wb_redirect.txt") - == 'fout = open("test.txt", "r"); cmd1 = subprocess.Popen(["sort"], stdin=fout, stdout=subprocess.PIPE);fout = open("test_wb_redirect.txt", "wb"); fout.write(cmd1.stdout.read());\n' - ) - assert ( - run_bash("$sort < test.txt >> test_ab_redirect.txt") - == 'fout = open("test.txt", "r"); cmd1 = subprocess.Popen(["sort"], stdin=fout, stdout=subprocess.PIPE);fout = open("test_ab_redirect.txt", "ab"); fout.write(cmd1.stdout.read());\n' - ) - - -def test_shell_commands(): - assert run_bash(">ls .github/*") == 'subprocess.run("ls .github/*", shell=True)\n' - - -def test_direct_interpolate(): - assert run_bash("$git {{command}} {{option}}") == 'subprocess.run(["git",command,option])\n' - assert run_bash("$git {{command}} {{process(option)}}") == 'subprocess.run(["git",command,process(option)])\n' - assert ( - run_bash("$k get pods --show-{{display_type}}=true") - == 'subprocess.run(["k","get","pods","--show-" + display_type + "=true"])\n' - ) - - -def test_fstring_interpolate(): - assert ( - run_bash("$kubectl get pods f{\"--\" + \"-\".join(['show', 'labels'])} -n f{ namespace }") - == 'subprocess.run(["kubectl","get","pods",f"""{"--" + "-".join([\'show\', \'labels\'])}""","-n",f"""{ namespace }"""])\n' - ) - assert run_bash("$git f{options['h']}") == 'subprocess.run(["git",f"""{options[\'h\']}"""])\n' - assert run_bash("$f{HOME_DIR}/bin/python -sE") == 'subprocess.run([f"""{HOME_DIR}""" + "/bin/python","-sE"])\n' - - -def test_interpolate_combo(): - assert run_bash("$echo 'f{PODS} 123' {{ARGS}}") == 'subprocess.run(["echo",f"""{PODS}""" + " 123",ARGS])\n' - assert run_bash("$git {{COMMAND}} f{ARGS} -v") == 'subprocess.run(["git",COMMAND,f"""{ARGS}""","-v"])\n' - - -def test_invalid_interpolate(): - with pytest.raises(InvalidInterpolation): - assert run_bash("$git {{command}} {{ option }}") - assert run_bash("$git {{command}} {{'\"'.join(option)}}") - assert run_bash("$git {{command}} {{a['key']}}") - - -def test_no_parse(): - assert run_bash('if 5 > 4:') == 'if 5 > 4:' - assert run_bash('if (pred1 and pred2) > 0:') == 'if (pred1 and pred2) > 0:' - assert run_bash('print("$(echo hi)")') == 'print("$(echo hi)")' - assert run_bash('a =$(echo "test 123")') == 'a =$(echo "test 123")' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..8458698 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,191 @@ +"""Run the generated code for real: the transform is only right if the commands behave like a shell's.""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +import pybash +from pybash import compile_source, header, transform + + +@pytest.fixture(autouse=True) +def workdir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.chdir(tmp_path) + (tmp_path / "in.txt").write_text("pear\napple\npear\nfig\n") + return tmp_path + + +def run(source: str, **variables: Any) -> dict[str, Any]: + namespace: dict[str, Any] = dict(variables) + exec(compile_source(textwrap.dedent(source)), namespace) + return namespace + + +def read(name: str) -> str: + return Path(name).read_text() + + +def test_capture_returns_bytes() -> None: + assert run("out = $echo hello")["out"] == b"hello\n" + assert run("out = ($printf '%s-%s' a b).decode()")["out"] == "a-b" + + +def test_arguments_arrive_exactly_as_written(capfd: pytest.CaptureFixture[str]) -> None: + run("""$printf '%s|' 'say "hi"' it\\'s 'a\\nb' "x y" ''""") + assert capfd.readouterr().out == 'say "hi"|it\'s|a\\nb|x y||' + + +def test_interpolated_value_is_one_argument() -> None: + namespace = run("out = $printf '[%s]' {{value}} f{number:03d} pre-{{value}}", value="a b; rm -rf /", number=7) + assert namespace["out"] == b"[a b; rm -rf /][007][pre-a b; rm -rf /]" + + +def test_pipeline_and_capture() -> None: + assert run("out = $sort in.txt | uniq -c | sort -rn | head -n 1")["out"].split() == [b"2", b"pear"] + + +def test_pipeline_stops_upstream_when_downstream_exits() -> None: + assert run("out = $yes | head -n 2")["out"] == b"y\ny\n" + + +def test_redirects() -> None: + run( + """ + $sort < in.txt > sorted.txt + $echo more >> sorted.txt + $sh -c 'echo out; echo err >&2' > out.txt 2> err.txt + $sh -c 'echo out; echo err >&2' > both.txt 2>&1 + $sh -c 'echo out; echo err >&2' &> amp.txt + $sort in.txt | uniq > uniq.txt + """ + ) + assert read("sorted.txt") == "apple\nfig\npear\npear\nmore\n" + assert (read("out.txt"), read("err.txt")) == ("out\n", "err\n") + assert sorted(read("both.txt").split()) == sorted(read("amp.txt").split()) == ["err", "out"] + assert read("uniq.txt") == "apple\nfig\npear\n" + + +def test_stderr_redirect_follows_shell_ordering(capfd: pytest.CaptureFixture[str]) -> None: + run("$sh -c 'echo out; echo err >&2' 2>&1 > only_out.txt") + assert read("only_out.txt") == "out\n" + assert capfd.readouterr().out == "err\n" + + run("$echo oops >&2") + assert capfd.readouterr().err == "oops\n" + + assert run("out = $sh -c 'echo out; echo err >&2' |& sort")["out"] == b"err\nout\n" + + +def test_several_output_files_all_receive_the_output() -> None: + Path("b.txt").write_text("old\n") + run("$echo hi > a.txt >> b.txt > c.txt") + assert (read("a.txt"), read("b.txt"), read("c.txt")) == ("hi\n", "old\nhi\n", "hi\n") + + +def test_connectors() -> None: + namespace = run( + """ + a = $true && echo yes || echo no + b = $false && echo yes || echo no + c = $false; echo after + """ + ) + assert (namespace["a"], namespace["b"], namespace["c"]) == (b"yes\n", b"no\n", b"after\n") + + +def test_statement_returns_status_and_capture_raises() -> None: + status = pybash.runtime.run([["true"], "&&", ["false"]]) + assert status.returncode == 1 + with pytest.raises(subprocess.CalledProcessError) as info: + run("out = $echo partial && false") + assert info.value.output == b"partial\n" + + +def test_missing_command_raises_and_cleans_up() -> None: + with pytest.raises(FileNotFoundError): + run("$yes | pybash-no-such-command") + + +def test_shell_mode() -> None: + run(">ls *.txt | wc -l > count.txt") + assert read("count.txt").strip() == "1" + + +def test_shell_interpolation_cannot_inject() -> None: + evil = "x'; touch pwned; echo \"$(touch pwned2)`touch pwned3`" + run( + """ + >printf '%s' {{value}} > bare.txt + >printf '%s' "f{value}" > double.txt + >printf '%s' '{{value}}' > single.txt + """, + value=evil, + ) + assert read("bare.txt") == read("double.txt") == read("single.txt") == evil + assert not list(Path().glob("pwned*")) + run(">ls {{! pattern }} > raw.txt", pattern="*.txt") + assert "in.txt" in read("raw.txt") + + +def test_line_numbers_match_the_original_source() -> None: + source = '"""doc"""\nfrom __future__ import annotations\n$true \\\n \n\nraise RuntimeError("line 6")\n' + with pytest.raises(RuntimeError) as info: + exec(compile_source(source, "prog.py"), {}) + assert info.traceback[-1].lineno == 5 # 0-based + + +@pytest.mark.parametrize("standalone", [False, True]) +def test_header_makes_transformed_code_runnable(standalone: bool, workdir: Path) -> None: + body = transform('out = $sort in.txt | head -n 1\n>echo {{out.decode().strip()}} > "first.txt"', runtime="_pybash") + script = workdir / "generated.py" + script.write_text(header(standalone=standalone) + body + "\n") + if standalone: + assert "import pybash" not in script.read_text() + subprocess.run([sys.executable, "-I", str(script)] if standalone else [sys.executable, str(script)], check=True) + assert read("first.txt") == "apple\n" + + +def test_import_hook(workdir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (workdir / "hooked_mod.py").write_text("def name():\n return ($echo hooked).decode().strip()\n") + monkeypatch.syspath_prepend(str(workdir)) + assert pybash.add_hook() is pybash.add_hook() + try: + import hooked_mod # type: ignore[import-not-found] + + assert hooked_mod.name() == "hooked" + assert not (workdir / "__pycache__").exists() + finally: + pybash.remove_hook() + sys.modules.pop("hooked_mod", None) + + +def cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, "-m", "pybash", *args], capture_output=True, text=True) + + +def test_cli_runs_scripts_and_modules(workdir: Path) -> None: + (workdir / "helper.py").write_text("def shout():\n $echo from helper\n") + (workdir / "script.py").write_text( + "import sys\nimport helper\nhelper.shout()\nif __name__ == '__main__':\n $echo f{sys.argv[1:]}\n" + ) + result = cli("script.py", "a", "--b") + assert (result.returncode, result.stdout) == (0, "from helper\n['a', '--b']\n") + assert cli("script", "c").stdout == "from helper\n['c']\n" + assert cli("-c", "$echo inline").stdout == "inline\n" + + +def test_cli_show_and_errors(workdir: Path) -> None: + (workdir / "script.py").write_text("x = 1\n$echo hi |\n") + result = cli("script.py") + assert result.returncode == 2 + assert "script.py:2:10: missing command after '|'" in result.stderr + (workdir / "ok.py").write_text("$echo hi\n") + assert cli("--show", "ok.py").stdout == 'subprocess.run(["echo", "hi"])\n\n' + assert cli("nope").returncode == 2 + assert cli().returncode == 2 diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..ee293db --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import pytest + +from pybash import InvalidInterpolation, PybashSyntaxError, transform + + +def t(source: str) -> str: + return transform(source, runtime="_pybash") + + +def test_exec() -> None: + assert t("$ls -la") == 'subprocess.run(["ls", "-la"])' + assert t("$ python --version") == 'subprocess.run(["python", "--version"])' + + +def test_capture_assignment() -> None: + assert t('a = $echo "test 123"') == 'a = subprocess.check_output(["echo", "test 123"])' + assert t("a += $ echo hi") == 'a += subprocess.check_output(["echo", "hi"])' + assert t(" return $whoami") == ' return subprocess.check_output(["whoami"])' + + +def test_capture_wrapped() -> None: + assert ( + t('print(($ cat test.txt).decode("utf-8").strip())') + == 'print((subprocess.check_output(["cat", "test.txt"])).decode("utf-8").strip())' + ) + assert t("print(str($echo test 123))") == 'print(str(subprocess.check_output(["echo", "test", "123"])))' + assert t("x = [$echo a]") == 'x = [subprocess.check_output(["echo", "a"])]' + assert t("f(1, $echo (a) b)") == 'f(1, subprocess.check_output(["echo", "(a)", "b"]))' + + +def test_capture_dollar_paren() -> None: + assert ( + t("out = $(echo hi).decode() + $(date)") + == 'out = subprocess.check_output(["echo", "hi"]).decode() + subprocess.check_output(["date"])' + ) + assert t('a =$(echo "test 123")') == 'a =subprocess.check_output(["echo", "test 123"])' + + +def test_indentation_and_line_count_are_preserved() -> None: + source = "def cp_test():\n $cp test.txt test_copy.txt\n " + assert t(source) == 'def cp_test():\n subprocess.run(["cp", "test.txt", "test_copy.txt"])\n ' + source = "$docker run \\\n --rm \\\n alpine\nprint(1)\n" + assert t(source) == 'subprocess.run(["docker", "run", "--rm", "alpine"])\n\n\nprint(1)\n' + + +def test_quoting_and_escapes_survive() -> None: + assert t("""$echo 'say "hi"' it\\'s a\\\\b""") == r"""subprocess.run(["echo", "say \"hi\"", "it's", "a\\b"])""" + assert t("$sed 's/HELLO/HOWDY\\n/g'") == 'subprocess.run(["sed", "s/HELLO/HOWDY\\\\n/g"])' + assert t("""$echo "" ''""") == 'subprocess.run(["echo", "", ""])' + assert t("""$echo "a b"'c'd""") == 'subprocess.run(["echo", "a bcd"])' + assert t("""$curl -d '{"a": 1}' url""") == r"""subprocess.run(["curl", "-d", "{\"a\": 1}", "url"])""" + + +def test_trailing_comment() -> None: + assert t("$echo hi # greet") == 'subprocess.run(["echo", "hi"])' + assert t("$echo a#b '# c'") == 'subprocess.run(["echo", "a#b", "# c"])' + + +def test_python_is_left_alone() -> None: + for source in ( + "if 5 > 4:", + "if (pred1 and pred2) > 0:", + 'print("$(echo hi)")', + 'x = "a = $5 and ($b)"', + "x = 'cost: ($5)' # ($ comment)", + "x = [\n 1\n > 2]", + "y = (1\n> 2)", + "y = 1 + \\\n 2\nz = 3 \\\n> 2", + 's = """\n$ not a command\n> nor this\n"""', + "s = '''a\n $ls\n'''; t = 1", + 'x = f"{a}" > b', + ): + assert t(source) == source + + +def test_commands_after_multiline_constructs() -> None: + source = 's = """\n$ no\n"""\n$ls\nx = (\n 1,\n)\n>ls *' + assert ( + t(source) == 's = """\n$ no\n"""\nsubprocess.run(["ls"])\nx = (\n 1,\n)\nsubprocess.run("ls *", shell=True)' + ) + + +def test_pipes() -> None: + assert t("$cat a.txt | sed 's/A/B/g'") == '_pybash.run([["cat", "a.txt"], "|", ["sed", "s/A/B/g"]])' + assert t("$a|b|&c") == '_pybash.run([["a"], "|", ["b"], "|&", ["c"]])' + assert t("x = $cat a | grep b") == 'x = _pybash.run([["cat", "a"], "|", ["grep", "b"]], capture=True)' + assert t("print($cat a | wc -l)") == 'print(_pybash.run([["cat", "a"], "|", ["wc", "-l"]], capture=True))' + + +def test_redirects() -> None: + assert t("$cat a > b") == '_pybash.run([["cat", "a"], ">", "b"])' + assert t("$cat a>>b") == '_pybash.run([["cat", "a"], ">>", "b"])' + assert t("$sort < a.txt >> b.txt") == '_pybash.run([["sort"], "<", "a.txt", ">>", "b.txt"])' + assert t("$ > out.txt echo hi") == '_pybash.run([["echo", "hi"], ">", "out.txt"])' + assert t("$make 2> err.log") == '_pybash.run([["make"], "2>", "err.log"])' + assert t("$make 2>>err.log 1>out.log") == '_pybash.run([["make"], "2>>", "err.log", ">", "out.log"])' + assert t("$make > all.log 2>&1") == '_pybash.run([["make"], ">", "all.log", "2>&1"])' + assert t("$make &> all.log") == '_pybash.run([["make"], "&>", "all.log"])' + assert t("$echo oops >&2") == '_pybash.run([["echo", "oops"], ">&2"])' + assert t("$echo 2 > f") == '_pybash.run([["echo", "2"], ">", "f"])' + assert t("$echo a2>f") == '_pybash.run([["echo", "a2"], ">", "f"])' + + +def test_connectors() -> None: + assert ( + t("$test -f a && echo yes || echo no; echo done") + == '_pybash.run([["test", "-f", "a"], "&&", ["echo", "yes"], "||", ["echo", "no"], ";", ["echo", "done"]])' + ) + assert t("$echo a;") == '_pybash.run([["echo", "a"]])' + assert t("$echo 'a && b' \\| c") == 'subprocess.run(["echo", "a && b", "|", "c"])' + + +def test_default_runtime_needs_only_subprocess() -> None: + assert transform("$a | b") == '__import__("pybash.runtime").runtime.run([["a"], "|", ["b"]])' + + +def test_shell() -> None: + assert t(">ls .github/*") == 'subprocess.run("ls .github/*", shell=True)' + assert t('> echo "a b" | wc -c') == r"""subprocess.run("echo \"a b\" | wc -c", shell=True)""" + + +def test_shell_interpolation_is_quoted_for_its_context() -> None: + assert t(">echo {{name}}") == 'subprocess.run("echo " + _pybash.quote(name), shell=True)' + assert ( + t('>echo "hi f{name}"') + == r'''subprocess.run("echo \"hi " + _pybash.dquote(f"""{name}""") + "\"", shell=True)''' + ) + assert t(">echo '{{name}}'") == """subprocess.run("echo '" + _pybash.squote(name) + "'", shell=True)""" + assert t(">ls {{! pattern }}") == 'subprocess.run("ls " + str(pattern), shell=True)' + + +def test_direct_interpolation() -> None: + assert t("$git {{command}} {{option}}") == 'subprocess.run(["git", str(command), str(option)])' + assert t("$git {{ process(option) }}") == 'subprocess.run(["git", str(process(option))])' + assert ( + t("$git {{a['key']}} {{ {'x': 1}['x'] }}") == """subprocess.run(["git", str(a['key']), str({'x': 1}['x'])])""" + ) + assert ( + t("$k get pods --show-{{display_type}}=true") + == 'subprocess.run(["k", "get", "pods", "--show-" + str(display_type) + "=true"])' + ) + + +def test_fstring_interpolation() -> None: + assert ( + t("$k get f{\"--\" + \"-\".join(['show', 'labels'])} -n f{ namespace }") + == 'subprocess.run(["k", "get", f"""{"--" + "-".join([\'show\', \'labels\'])}""", "-n", f"""{ namespace }"""])' + ) + assert t("$f{HOME_DIR}/bin/python -sE") == 'subprocess.run([f"""{HOME_DIR}""" + "/bin/python", "-sE"])' + assert t("$echo f{n:>5} --f{flag}") == 'subprocess.run(["echo", f"""{n:>5}""", "--" + f"""{flag}"""])' + + +def test_interpolation_inside_quotes_and_combined() -> None: + assert t("$echo 'f{PODS} 123' {{ARGS}}") == 'subprocess.run(["echo", f"""{PODS}""" + " 123", str(ARGS)])' + assert t('$echo "{{a}} f{b}"') == 'subprocess.run(["echo", str(a) + " " + f"""{b}"""])' + + +def test_interpolation_can_be_escaped_or_left_literal() -> None: + assert t(r"$docker inspect -f '\{{.State}}' c") == 'subprocess.run(["docker", "inspect", "-f", "{{.State}}", "c"])' + assert t(r"$echo \f{x} conf{a}") == 'subprocess.run(["echo", "f{x}", "conf{a}"])' + assert t("$echo {{ f{") == 'subprocess.run(["echo", "{{", "f{"])' + + +@pytest.mark.parametrize( + ("source", "message", "offset"), + [ + ("$a |", "missing command after '|'", 4), + ("$| b", "missing command before '|'", 2), + ("$a && ; b", "missing command before ';'", 7), + ("$a > ", "missing file name after '>'", 4), + ("$a > | b", "missing file name before '|'", 6), + ("$a &", "background", 4), + ("$cat < f", "file descriptor 3", 4), + ("$a 2>&3", "2>&3", 4), + ("$echo 'x", "unterminated ' quote", 8), + ("$", "empty command", 2), + (">", "empty command", 2), + ("print($ls", "Missing end parentheses", 7), + ("x = $(ls", "Missing end parentheses", 5), + ], +) +def test_syntax_errors_point_at_the_source(source: str, message: str, offset: int) -> None: + with pytest.raises(PybashSyntaxError) as info: + transform("x = 1\n " + source, filename="cmd.py") + error = info.value + assert message in error.msg + assert (error.filename, error.lineno, error.text) == ("cmd.py", 2, " " + source) + assert error.offset == offset + 4 + + +@pytest.mark.parametrize("source", ["$git {{ }}", "$git f{ }", ">echo {{}}"]) +def test_invalid_interpolation(source: str) -> None: + with pytest.raises(InvalidInterpolation): + transform(source) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8034f93 --- /dev/null +++ b/uv.lock @@ -0,0 +1,382 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pybash" +version = "0.4.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +provides-extras = ["script"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18" }, + { name = "pytest", specifier = ">=8.4" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]