diff --git a/.github/workflows/package-test.yml b/.github/workflows/package-test.yml index 7944761..9bdf2f5 100644 --- a/.github/workflows/package-test.yml +++ b/.github/workflows/package-test.yml @@ -1,4 +1,5 @@ -name: CI Test +name: CI + on: push: branches: @@ -7,28 +8,82 @@ on: branches: - main - nightly - - 'releases/*' + - "releases/*" + +permissions: + contents: read jobs: - Unit-Tests: - timeout-minutes: 10 - runs-on: ${{ matrix.os }} + lint: + name: Lint & format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: curl -sSL https://install.python-poetry.org | python - -y + + - name: Update PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Ruff (lint) + run: poetry run ruff check python_obfuscator tests + + - name: Black (format check) + run: poetry run black --check python_obfuscator tests + + - name: isort (import order check) + run: poetry run isort --check-only python_obfuscator tests + + - name: Pyright (type check) + run: poetry run pyright python_obfuscator + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-latest] - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Install Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Setup dependencies + allow-prereleases: true + + - name: Install Poetry + run: curl -sSL https://install.python-poetry.org | python - -y + + - name: Update PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Type check (mypy) + run: poetry run mypy python_obfuscator + + - name: Run tests with coverage run: | - python -m pip install --upgrade pip - pip install pytest - python setup.py install + poetry run coverage run -m pytest + poetry run coverage report + poetry run coverage xml - - name: Run Tests - run: pytest tests \ No newline at end of file + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: davidteather/python-obfuscator + files: ./coverage.xml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1af736a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,44 @@ +name: Publish Package + +on: + release: + types: [created] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python 3.x + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y + + - name: Update PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Update Poetry configuration + run: poetry config virtualenvs.create false + + - name: Install dependencies + run: poetry install --sync --no-interaction + + - name: Package project + run: poetry build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index d9ec21f..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist - twine upload dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7e390b2..f01447f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,10 @@ __pycache__ dist build python_obfuscator.egg-info -.pytest_cache \ No newline at end of file +.pytest_cache +.mypy_cache +.ruff_cache +obfuscated/ +.coverage +coverage.xml +htmlcov/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6bbc00d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3 + args: [--line-length=88] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black] + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: + [ + "--max-line-length=88", + "--extend-ignore=E203,W503,E501,F401,F541", + ] + + - repo: local + hooks: + - id: mypy + name: mypy + entry: poetry run mypy python_obfuscator + language: system + pass_filenames: false + always_run: true + stages: [commit] + + - id: pytest-unit + name: Run unit tests + entry: poetry run pytest tests/ -v + language: system + pass_filenames: false + always_run: true + stages: [commit] + + - id: pytest-coverage-report + name: pytest coverage report (manual) + entry: poetry run pytest tests --cov=python_obfuscator --cov-report=term-missing --cov-report=html + language: system + pass_filenames: false + always_run: true + stages: [manual] + +default_language_version: + python: python3 + +fail_fast: true + +default_stages: [commit] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15c67dd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.1.0] — AST-Based Rewrite + +Complete rewrite from a regex-based implementation to a fully AST-based pipeline. + +### Added + +- **`ObfuscationConfig`** — immutable frozen dataclass for selecting techniques. + Factory methods: `all_enabled()`, `only(*names)`, `.without(*names)`, `.with_added(*names)`. +- **Technique registry** — `@register` class decorator; techniques self-register by name. + `all_technique_names()` and `get_transforms(enabled)` for pipeline construction. +- **`variable_renamer`** — two-pass AST renamer. First pass collects renameable names + (excludes builtins, imports, dunders, and attribute-accessed names); second pass applies + the mapping. Also renames `nonlocal` and `global` statement name lists. +- **`string_hex_encoder`** — converts string literals to `bytes.fromhex(…).decode('utf-8')` + call nodes. Skips f-strings where replacing a `Constant` with a `Call` is invalid. +- **`dead_code_injector`** — recursively injects dead variable assignments at every scope + level: module body, function bodies, class bodies, if/for/while/try/with branches. + Some assignments reference earlier dead variables (intra-scope cross-references) to + simulate computation. Accepts `InjectionParams` and a seeded `random.Random` for + reproducible output. +- **`exec_wrapper`** — wraps the entire module in `exec(ast.unparse(tree))`, reducing the + top-level AST to one statement. +- **`Obfuscator` class** — caches the transform pipeline across multiple `obfuscate()` calls. +- **`obfuscate()` module-level helper** — convenience wrapper for one-shot use. +- **CLI** (`pyobfuscate`) — `--disable/-d` flag accepts technique names; `--stdout`; `--version/-V`. +- **E2E test suite** with correctness and benchmark tests across six complex programs. +- **Per-technique runtime benchmarks** showing individual overhead contribution. +- 100 % branch coverage enforced in CI. +- Python 3.10–3.14 test matrix. + +### Changed + +- Priority ordering now encoded in `TechniqueMetadata.priority` rather than list position. +- `VariableNameGenerator` and `RandomDataTypeGenerator` accept an optional `rng` argument + for deterministic testing. +- `_NameCollector` exposes `all_bound_names` (assigned + imported + builtins) for use by + the dead-code injector's exclusion set. + +### Removed + +- All regex-based technique implementations (`techniques.py`). +- `regex` runtime dependency (was used only for the old hex-encoding approach). +- `SourceTransform` base class and `source_transforms` package. + +### Fixed + +- `VariableRenamer` now updates `nonlocal` and `global` statement name lists, preventing + `SyntaxError: no binding for nonlocal '…' found` after renaming. +- `VariableRenamer` excludes attribute-accessed names from renaming to prevent + `AttributeError` when calling methods by the original name after their definition is renamed. +- `_interleave` preserves relative ordering of injected statements (sorted random slots) + so intra-scope cross-references never appear before their definitions. + +--- + +## [0.0.2] — prior release + +Initial public release with regex-based obfuscation techniques. + +- `one_liner` — collapsed newlines into semicolons (superceded by `exec_wrapper`). +- `variable_renamer` — regex-based name replacement. +- `add_random_variables` — prepended/appended random assignments at module level. +- `str_to_hex_bytes` — regex-based string-to-hex conversion. diff --git a/README.md b/README.md index fd18f6f..d9cb99d 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,201 @@ -# Python-Obfuscator +# python-obfuscator -One night I got bored of writing good code, so I made good code to make bad code. +[![CI](https://github.com/davidteather/python-obfuscator/actions/workflows/package-test.yml/badge.svg)](https://github.com/davidteather/python-obfuscator/actions/workflows/package-test.yml) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/davidteather/python-obfuscator?style=flat-square)](https://github.com/davidteather/python-obfuscator/releases) +[![Downloads](https://static.pepy.tech/personalized-badge/python-obfuscator?period=total&units=international_system&left_color=grey&right_color=orange&left_text=Downloads)](https://pypi.org/project/python-obfuscator/) +[![Codecov](https://codecov.io/gh/davidteather/python-obfuscator/branch/main/graph/badge.svg)](https://codecov.io/gh/davidteather/python-obfuscator) -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/davidteather/python-obfuscator?style=flat-square)](https://github.com/davidteather/python-obfuscator/releases) [![Downloads](https://static.pepy.tech/personalized-badge/python-obfuscator?period=total&units=international_system&left_color=grey&right_color=orange&left_text=Downloads)](https://pypi.org/project/python-obfuscator/) ![](https://visitor-badge.laobi.icu/badge?page_id=davidteather.python-obfuscator) [![Linkedin](https://img.shields.io/badge/LinkedIn-0077B5?style=flat-square&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/david-teather-4400a37a/) +A Python source-code obfuscator built on the standard-library `ast` module. It applies multiple independent techniques — each individually togglable — to make code harder to read while keeping it fully executable. See [Known limitations](#known-limitations) before use. -### **DONT USE IN PRODUCTION** +If this project is useful to you, consider [sponsoring development](https://github.com/sponsors/davidteather). -**I just made this because it was interesting to me. I do plan on making this more official in the future, but currently don't have the time!** - -Consider sponsoring me [here](https://github.com/sponsors/davidteather) +--- ## Installing -``` +```bash pip install python-obfuscator ``` -## Quickstart +Requires Python ≥ 3.10. -Print out obfuscated code +### From source (contributors) + +```bash +pip install -e ".[dev]" ``` -pyobfuscate -i your_file.py + +Or with [Poetry](https://python-poetry.org/): + +```bash +poetry install ``` -Apply changes to the input file +(`poetry install` pulls dev dependencies from the lockfile; use `poetry run ` to run tools inside that environment.) + +--- + +## Quick start — CLI + +```bash +# Writes obfuscated/your_file.py (path structure is preserved) +pyobfuscate -i your_file.py + +# Print to stdout +pyobfuscate -i your_file.py --stdout + +# Disable specific techniques +pyobfuscate -i your_file.py --disable dead_code_injector --disable exec_wrapper + +# Show version +pyobfuscate --version ``` -pyobfuscate -i your_file.py -r True + +--- + +## Python API + +### One-shot helper + +```python +from python_obfuscator import obfuscate + +source = "x = 1\nprint(x + 2)" +result = obfuscate(source) +print(result) ``` -## More Detailed Documentation +### Selective techniques + +```python +from python_obfuscator import obfuscate, ObfuscationConfig -You can use this as a module if you want +# All techniques except dead-code injection +config = ObfuscationConfig.all_enabled().without("dead_code_injector") +result = obfuscate(source, config=config) + +# Only string encoding +config = ObfuscationConfig.only("string_hex_encoder") +result = obfuscate(source, config=config) ``` -import python_obfuscator -obfuscator = python_obfuscator.obfuscator() -code_to_obfuscate = "print('hello world')" +### Reusing across multiple files (caches the pipeline) + +```python +from python_obfuscator import Obfuscator, ObfuscationConfig + +obf = Obfuscator(ObfuscationConfig.all_enabled()) +for path in my_files: + path.write_text(obf.obfuscate(path.read_text())) ``` -You can also exclude certain techniques applied for obfuscation +### Config combinators + +```python +cfg = ObfuscationConfig.all_enabled() # every registered technique +cfg = ObfuscationConfig.only("variable_renamer", "exec_wrapper") +cfg = cfg.without("exec_wrapper") # returns a new frozen config +cfg = cfg.with_added("dead_code_injector") ``` -import python_obfuscator -from python_obfuscator.techniques import add_random_variables -obfuscator = python_obfuscator.obfuscator() -code_to_obfuscate = "print('hello world')" -obfuscated_code = obfuscator.obfuscate(code_to_obfuscate, remove_techniques=[add_random_variables]) +--- + +## Techniques + +| Name | Priority | What it does | +|------|----------|--------------| +| `variable_renamer` | 10 | Renames local variables, function names, parameters, and class names to visually ambiguous identifiers (`lIIllIlI…`). Excludes builtins, imports, dunders, and attribute-accessed names. | +| `string_hex_encoder` | 20 | Replaces every string literal `"hi"` with `bytes.fromhex('6869').decode('utf-8')`. Skips f-strings. | +| `dead_code_injector` | 30 | Injects dead variable assignments at **every scope level** — module body, function bodies, class bodies, if/for/while/try/with branches. Some assignments reference other dead variables to simulate computation. | +| `exec_wrapper` | 100 | Wraps the entire module in a single `exec("…")` call, reducing the top-level AST to one statement. Runs last. | + +Techniques are applied in priority order (lowest first). + +--- + +## Example + +**Input** + +```python +def greet(name): + msg = "Hello, " + name + print(msg) + +greet("world") ``` -Find a list of all techniques [here](https://github.com/davidteather/python-obfuscator/blob/210da2d3dfb96ab7653fad869a43cb67aeb0fe67/python_obfuscator/techniques.py#L87) -## Example Obfuscated Code +**Output** (all techniques enabled — abridged) -Input +```python +exec('def lIlIllI(IIlIlII):\n lIllIlI = bytes.fromhex(\'48656c6c6f2c20\').decode(\'utf-8\') + IIlIlII\n ...\nlIlIllI(bytes.fromhex(\'776f726c64\').decode(\'utf-8\'))') ``` -y = input("what's your favorite number") -user_value = int(y) -print("{} that's a great number!".format(user_value)) +--- + +## Performance overhead + +Benchmarks run on an Apple M-series machine, 20 iterations each. The test programs cover OOP, algorithms, functional patterns, number theory, and string processing. + +### Total overhead (all techniques) + +| Program | Original | Obfuscated | Overhead | +|---------|----------|------------|---------| +| `algorithms.py` | 0.94 ms | 1.98 ms | +112% | +| `cipher.py` | 1.37 ms | 2.67 ms | +95% | +| `data_structures.py` | 0.72 ms | 2.20 ms | +207% | +| `functional.py` | 0.67 ms | 1.71 ms | +155% | +| `number_theory.py` | 1.84 ms | 3.16 ms | +72% | +| `oop_simulation.py` | 0.68 ms | 1.66 ms | +144% | + +### Per-technique contribution (average across all programs) + +| Technique | Avg overhead | Notes | +|-----------|-------------|-------| +| `variable_renamer` | ~5% | Pure rename — negligible at runtime | +| `string_hex_encoder` | ~12% | `bytes.fromhex` call per string literal | +| `dead_code_injector` | ~85% | Dominant cost — dead assignments execute every iteration | +| `exec_wrapper` | ~2% | Single extra `exec` layer | + +The dead-code injector's overhead scales with the number of scopes and loop iterations in the original program. Programs with tight inner loops see the most overhead. + +--- + +## Known limitations + +- **Class method names are not renamed.** Attribute-accessed names (`obj.method`) cannot be safely renamed without full type-inference, so the renamer conservatively excludes them. +- **Keyword argument names are not renamed.** `fn(key=val)` call-site keyword strings are bare AST strings, not `Name` nodes, and are not updated when a parameter is renamed. +- **No scope-aware renaming.** The same identifier used in two independent function scopes maps to the same obfuscated name (which is semantically correct but less obfuscated than it could be). +- **No control-flow obfuscation.** Opaque predicates, bogus branches, and integer encoding are not implemented. + +--- + +## Running the test suite + +After a dev install ([from source](#from-source-contributors)): + +```bash +pytest ``` -[With `pyobfuscate -i file.py`](https://gist.github.com/davidteather/b6ff932140d8c174b9c6f50c9b42fdaf) +Coverage is enforced at ≥ 95% on every CI run. + +```bash +# With coverage report +coverage run -m pytest && coverage report +# E2E tests with benchmark output +pytest tests/e2e/ -v -s +``` -[With `--one-liner True`](https://gist.github.com/davidteather/75e48c04bf74f0262fe2919239a74295) +With Poetry, run the same commands through the project environment, for example `poetry run pytest`, `poetry run coverage run -m pytest`, and `poetry run pytest tests/e2e/ -v -s`. -## Authors +--- -* **David Teather** - *Initial work* - [davidteather](https://github.com/davidteather) +## Authors -See also the list of [contributors](https://github.com/davidteather/python-obfuscator) who participated in this project. +**David Teather** — [davidteather](https://github.com/davidteather) ## License -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details +MIT — see [LICENSE](LICENSE). diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..63b6e66 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,836 @@ +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. + +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "black" +version = "25.11.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, + {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, + {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, + {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, + {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, + {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, + {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, + {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, + {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, + {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, + {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, + {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, + {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, + {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, + {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, + {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, + {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, + {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, + {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, + {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, + {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, + {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, + {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, + {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, + {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, + {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +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 = "coverage" +version = "7.10.7" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.1.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["main", "dev"] +files = [ + {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, + {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "librt" +version = "0.8.1" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, + {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, + {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, + {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, + {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, + {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, + {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, + {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, + {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, + {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, + {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, + {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, + {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, + {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, + {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, + {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, + {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, + {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, + {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, + {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, + {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, + {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, + {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, + {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, + {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, + {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, + {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, + {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, + {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, + {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, + {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, + {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, + {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, + {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, + {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, + {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, + {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, + {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, + {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, + {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, + {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, + {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, + {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "1.19.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, +] + +[package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyright" +version = "1.1.408" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1"}, + {file = "pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "rich" +version = "14.3.3" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.15.9" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1"}, + {file = "ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7"}, + {file = "ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c"}, + {file = "ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec"}, + {file = "ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d"}, + {file = "ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53"}, + {file = "ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2"}, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "typer" +version = "0.24.1" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e"}, + {file = "typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +click = ">=8.2.1" +rich = ">=12.3.0" +shellingham = ">=1.3.0" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[extras] +dev = ["black", "coverage", "isort", "mypy", "pyright", "pytest", "pytest-cov", "ruff"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "c32f6a7026a11e7d4fad3be092ab5623fcfa67db20afe99e4221506d78853c32" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6787932 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +packages = [{ include = "python_obfuscator" }] + +[tool.poetry.dependencies] +python = ">=3.10" +typer = "^0.24.1" + +[tool.poetry.group.dev.dependencies] +mypy = "^1.15.0" +black = "^25.1.0" +isort = "^6.0.0" +ruff = ">=0.9.0" +pytest = "^8.3.4" +coverage = "^7.6.12" +pytest-cov = "^7.1.0" +pyright = "^1.1.408" + +[project] +name = "python_obfuscator" +version = "0.1.0" +description = "A Python source-code obfuscator built on the ast module." +readme = "README.md" +authors = [{ name = "David Teather", email = "contact.davidteather@gmail.com" }] +license = { file = "LICENSE" } +dependencies = ["typer>=0.24.1,<0.25.0"] +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "mypy", + "ruff", + "coverage", + "isort", + "pyright", +] + +[project.scripts] +pyobfuscate = "python_obfuscator.cli:cli" + +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "W"] +ignore = [ + "E501", # line-too-long — black handles this + "UP007", # use X | Y for union types — conflicts with from __future__ import annotations +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.10" +packages = ["python_obfuscator"] +explicit_package_bases = true +warn_unused_ignores = true +warn_return_any = true +show_error_codes = true +strict = false +disallow_untyped_defs = true +check_untyped_defs = true +ignore_missing_imports = false + +[tool.pyright] +pythonVersion = "3.10" +include = ["python_obfuscator"] +exclude = ["tests", "build", "dist", ".venv"] +typeCheckingMode = "basic" +reportMissingImports = true +reportMissingTypeStubs = false + +[tool.coverage.run] +branch = true +source = ["python_obfuscator"] +omit = ["tests/*", "python_obfuscator/cli/*"] + +[tool.coverage.report] +show_missing = true +skip_empty = true +precision = 1 +fail_under = 95 diff --git a/python_obfuscator/__init__.py b/python_obfuscator/__init__.py index 53747a1..75bf479 100644 --- a/python_obfuscator/__init__.py +++ b/python_obfuscator/__init__.py @@ -1 +1,5 @@ -from .obfuscator import obfuscator +from .config import ObfuscationConfig +from .obfuscator import Obfuscator, obfuscate +from .version import __version__ + +__all__ = ["Obfuscator", "ObfuscationConfig", "obfuscate", "__version__"] diff --git a/python_obfuscator/cli/__init__.py b/python_obfuscator/cli/__init__.py index 1db187c..741683d 100644 --- a/python_obfuscator/cli/__init__.py +++ b/python_obfuscator/cli/__init__.py @@ -1,50 +1,79 @@ import sys -import python_obfuscator -from python_obfuscator.techniques import one_liner -import argparse +from pathlib import Path +from typing import Annotated +import typer -def convert_file(args): - file_path = args.input - obfuscate = python_obfuscator.obfuscator() +from python_obfuscator.config import ObfuscationConfig +from python_obfuscator.obfuscator import Obfuscator +from python_obfuscator.techniques import all_technique_names +from python_obfuscator.version import __version__ - # removed techniques - remove = [] - if not args.one_liner: - remove.append(one_liner) +DEFAULT_OUTPUT_DIR = "obfuscated" - with open(file_path, "r") as f: - data = f.read() - obfuscated_data = obfuscate.obfuscate(data, remove_techniques=remove) - if args.replace: - with open(file_path, "w+") as f: - f.write(obfuscated_data) +def _resolved_output_path(input_path: Path) -> Path: + """Write under ./obfuscated/, preserving path relative to cwd when possible.""" + cwd = Path.cwd().resolve() + try: + rel = input_path.resolve().relative_to(cwd) + except ValueError: + rel = Path(input_path.name) + out = cwd / DEFAULT_OUTPUT_DIR / rel + out.parent.mkdir(parents=True, exist_ok=True) + return out + + +def main( + input_path: Annotated[ + Path, + typer.Option( + ..., + "--input", + "-i", + help="File to obfuscate", + exists=True, + dir_okay=False, + readable=True, + ), + ], + stdout: Annotated[ + bool, + typer.Option( + "--stdout", + help="Print obfuscated code to stdout instead of writing a file.", + ), + ] = False, + disable: Annotated[ + list[str], + typer.Option( + "--disable", + "-d", + help=( + "Disable a technique by name. May be repeated. " + f"Available: {', '.join(sorted(all_technique_names()))}" + ), + ), + ] = [], +) -> None: + resolved = input_path.expanduser().resolve() + + config = ObfuscationConfig.all_enabled().without(*disable) + obfuscator = Obfuscator(config) + + data = resolved.read_text() + obfuscated_data = obfuscator.obfuscate(data) + + if stdout: + typer.echo(obfuscated_data) else: - print(obfuscated_data) - - -def cli(): - parser = argparse.ArgumentParser(description="Process CLI args") - parser.add_argument( - "-i", "--input", help="File to obfuscate", required=True, type=str - ) - parser.add_argument( - "-r", - "--replace", - help="Replace the file specified", - required=False, - default=False, - type=bool, - ) - parser.add_argument( - "-ol", - "--one-liner", - help="Add the one liner technique", - required=False, - default=False, - type=bool, - ) - args = parser.parse_args() - - convert_file(args) + out_path = _resolved_output_path(resolved) + out_path.write_text(obfuscated_data) + typer.secho(f"Wrote {out_path}", err=True) + + +def cli() -> None: + if len(sys.argv) == 2 and sys.argv[1] in ("--version", "-V"): + typer.echo(__version__) + raise SystemExit(0) + typer.run(main) diff --git a/python_obfuscator/config.py b/python_obfuscator/config.py new file mode 100644 index 0000000..9d2785f --- /dev/null +++ b/python_obfuscator/config.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ObfuscationConfig: + """Immutable configuration describing which techniques are enabled. + + Prefer the factory classmethods over constructing directly:: + + # Enable everything registered + cfg = ObfuscationConfig.all_enabled() + + # Enable a specific subset + cfg = ObfuscationConfig.only("variable_renamer", "string_hex_encoder") + + # Start from all-enabled and exclude one + cfg = ObfuscationConfig.all_enabled().without("string_hex_encoder") + """ + + enabled_techniques: frozenset[str] + + @classmethod + def all_enabled(cls) -> ObfuscationConfig: + from .techniques.registry import all_technique_names + + return cls(enabled_techniques=all_technique_names()) + + @classmethod + def only(cls, *names: str) -> ObfuscationConfig: + return cls(enabled_techniques=frozenset(names)) + + def without(self, *names: str) -> ObfuscationConfig: + return ObfuscationConfig( + enabled_techniques=self.enabled_techniques - frozenset(names) + ) + + def with_added(self, *names: str) -> ObfuscationConfig: + return ObfuscationConfig( + enabled_techniques=self.enabled_techniques | frozenset(names) + ) diff --git a/python_obfuscator/helpers/__init__.py b/python_obfuscator/helpers/__init__.py index a5cbba9..6130fea 100644 --- a/python_obfuscator/helpers/__init__.py +++ b/python_obfuscator/helpers/__init__.py @@ -1,2 +1,2 @@ -from .variable_name_generator import VariableNameGenerator -from .random_datatype import RandomDataTypeGenerator +from .random_datatype import RandomDataTypeGenerator as RandomDataTypeGenerator +from .variable_name_generator import VariableNameGenerator as VariableNameGenerator diff --git a/python_obfuscator/helpers/random_datatype.py b/python_obfuscator/helpers/random_datatype.py index 6aa31c6..ba0c7a5 100644 --- a/python_obfuscator/helpers/random_datatype.py +++ b/python_obfuscator/helpers/random_datatype.py @@ -1,23 +1,34 @@ +from __future__ import annotations + import random import string -import time +from collections.abc import Callable class RandomDataTypeGenerator: - def __init__(self): - self.generator_options = [self.random_string, self.random_int] + """Generates random Python literal values (str or int). + + Pass a seeded :class:`random.Random` instance for reproducible output:: + + gen = RandomDataTypeGenerator(rng=random.Random(42)) + """ + + def __init__(self, rng: random.Random | None = None) -> None: + self._rng = rng or random.Random() + self._generator_options: list[Callable[[], str | int]] = [ + self.random_string, + self.random_int, + ] - def get_random(self): - return random.choice(self.generator_options)() + def get_random(self) -> str | int: + return self._rng.choice(self._generator_options)() - def random_string(self, length=79): - # Why is it 79 by default? - # See: https://stackoverflow.com/a/16920876/11472374 - # As kirelagin commented readability is very important + def random_string(self, length: int = 79) -> str: + # 79 chars: see https://stackoverflow.com/a/16920876/11472374 return "".join( - random.choice(string.ascii_lowercase + string.ascii_uppercase) - for i in range(length) + self._rng.choice(string.ascii_lowercase + string.ascii_uppercase) + for _ in range(length) ) - def random_int(self): - return random.randint(random.randint(0, 300), random.randint(300, 999)) + def random_int(self) -> int: + return self._rng.randint(self._rng.randint(0, 300), self._rng.randint(300, 999)) diff --git a/python_obfuscator/helpers/variable_name_generator.py b/python_obfuscator/helpers/variable_name_generator.py index 81013fb..e1019cb 100644 --- a/python_obfuscator/helpers/variable_name_generator.py +++ b/python_obfuscator/helpers/variable_name_generator.py @@ -1,11 +1,21 @@ +from __future__ import annotations + import random import string -import time +from collections.abc import Callable class VariableNameGenerator: - def __init__(self): - self.generator_options = [ + """Generates obfuscated-looking variable names. + + Pass a seeded :class:`random.Random` instance for reproducible output:: + + gen = VariableNameGenerator(rng=random.Random(42)) + """ + + def __init__(self, rng: random.Random | None = None) -> None: + self._rng = rng or random.Random() + self._generator_options: list[Callable[[int], str]] = [ self.random_string, self.l_and_i, self.time_based, @@ -14,33 +24,29 @@ def __init__(self): self.single_letter_a_lot, ] - def get_random(self, id): - return random.choice(self.generator_options)(id) + def get_random(self, id: int) -> str: + return self._rng.choice(self._generator_options)(id) - def random_string(self, id, length=79): - # Why is it 79 by default? - # See: https://stackoverflow.com/a/16920876/11472374 - # As kirelagin commented readability is very important + def random_string(self, id: int, length: int = 79) -> str: + # 79 chars: see https://stackoverflow.com/a/16920876/11472374 return "".join( - random.choice(string.ascii_letters) for i in range(length) + self._rng.choice(string.ascii_letters) for _ in range(length) ) + str(id) - def l_and_i(self, id): - return "".join(random.choice("Il") for i in range(id)) + def l_and_i(self, id: int) -> str: + return "".join(self._rng.choice("Il") for _ in range(id)) - def time_based(self, id): - return ( - random.choice(string.ascii_letters) - + str(time.time()).replace(".", "") - + str(id) - ) + def time_based(self, id: int) -> str: + # Use the rng to produce a large pseudo-time value so that this + # generator is fully deterministic when the rng is seeded. + pseudo_time = str(self._rng.randint(10**12, 10**13)) + return self._rng.choice(string.ascii_letters) + pseudo_time + str(id) - def just_id(self, id): - # python doesn't work with numbers for variable names - return random.choice(string.ascii_letters) + str(id) + def just_id(self, id: int) -> str: + return self._rng.choice(string.ascii_letters) + str(id) - def scream(self, id): - return "".join(random.choice("Aa") for i in range(id)) + def scream(self, id: int) -> str: + return "".join(self._rng.choice("Aa") for _ in range(id)) - def single_letter_a_lot(self, id): - return random.choice(string.ascii_letters) * id + def single_letter_a_lot(self, id: int) -> str: + return self._rng.choice(string.ascii_letters) * id diff --git a/python_obfuscator/obfuscator.py b/python_obfuscator/obfuscator.py index f5ff383..1590e39 100644 --- a/python_obfuscator/obfuscator.py +++ b/python_obfuscator/obfuscator.py @@ -1,10 +1,53 @@ -import logging -from .techniques import obfuscate +from __future__ import annotations +import ast -class obfuscator: - def __init__(self, logging_level=logging.error): - pass +from .config import ObfuscationConfig +from .techniques.registry import all_technique_names, get_transforms - def obfuscate(self, code, remove_techniques=[]): - return obfuscate(code, remove_techniques) + +def _validate_config(config: ObfuscationConfig) -> None: + unknown = config.enabled_techniques - all_technique_names() + if unknown: + raise ValueError( + f"Unknown technique(s): {sorted(unknown)}. " + f"Available: {sorted(all_technique_names())}" + ) + + +def obfuscate(source: str, config: ObfuscationConfig | None = None) -> str: + """Obfuscate *source* using the techniques described by *config*. + + When *config* is ``None`` all registered techniques are applied. + """ + resolved = config if config is not None else ObfuscationConfig.all_enabled() + _validate_config(resolved) + + tree = ast.parse(source) + for transform_cls in get_transforms(resolved.enabled_techniques): + tree = transform_cls().apply(tree) + return ast.unparse(tree) + + +class Obfuscator: + """Stateful wrapper that pre-builds and caches the transform pipeline. + + Prefer this over the module-level :func:`obfuscate` when processing many + files with the same configuration, since the pipeline is validated and + sorted once at construction time. + """ + + def __init__(self, config: ObfuscationConfig | None = None) -> None: + self._config = config if config is not None else ObfuscationConfig.all_enabled() + _validate_config(self._config) + self._transforms = get_transforms(self._config.enabled_techniques) + + @property + def config(self) -> ObfuscationConfig: + return self._config + + def obfuscate(self, source: str) -> str: + tree = ast.parse(source) + for transform_cls in self._transforms: + tree = transform_cls().apply(tree) + return ast.unparse(tree) diff --git a/python_obfuscator/techniques.py b/python_obfuscator/techniques.py deleted file mode 100644 index 9ada11f..0000000 --- a/python_obfuscator/techniques.py +++ /dev/null @@ -1,87 +0,0 @@ -import re -import ast -import random -import time -from .helpers import VariableNameGenerator, RandomDataTypeGenerator -import regex - - -def one_liner(code): - # TODO: strings with \n at top - formatted_code = re.sub( - r"(;)\1+", - ";", - """exec(\"\"\"{};\"\"\")""".format( - code.replace("\n", ";").replace('"""', '\\"\\"\\"') - ), - ) - - if formatted_code[0] == ';': - return formatted_code[1:] - return formatted_code - -def variable_renamer(code): - # add \n so regex picks it up - code = "\n" + code - variable_names = re.findall(r"(\w+)(?=( |)=( |))", code) - name_generator = VariableNameGenerator() - for i in range(len(variable_names)): - obfuscated_name = name_generator.get_random(i + 1) - code = re.sub( - r"(?<=[^.])(\b{}\b)".format(variable_names[i][0]), obfuscated_name, code - ) - return code - - -def add_random_variables(code): - useless_variables_to_add = random.randint(100, 400) - name_generator = VariableNameGenerator() - data_generator = RandomDataTypeGenerator() - for v in range(1, useless_variables_to_add): - rand_data = data_generator.get_random() - if type(rand_data) == str: - rand_data = '"{}"'.format(rand_data) - if v % 2 == 0: - code = "{} = {}\n".format(name_generator.get_random(v), rand_data) + code - else: - code = code + "\n{} = {}".format(name_generator.get_random(v), rand_data) - return code - - -def str_to_hex_bytes(code): - # ((?<=(( | |)\w+( |)=( |))("""|"|'))[\W\w]*?(?=("""|"|'))) - # TODO: Fix still buggy and kinda just wanna publish this to github rn - python_string_decoraters = ['"""', "'''", '"', "'"] - - for s in python_string_decoraters: - pattern = r"((?<=(( | |\n)\w+( |)=( |))({}))[\W\w]*?(?=({})))".format(s, s) - t = regex.findall(pattern, code) - for v in t: - string_contents = v[0] - if s == '"' and string_contents == '"': - continue - if s == "'" and string_contents == "'": - continue - hex_bytes = "\\" + "\\".join( - x.encode("utf-8").hex() for x in string_contents - ) - code = regex.sub(pattern, str(hex_bytes).replace("\\", "\\\\"), code) - - return code - - -def obfuscate(code, remove_techniques=[]): - if len(remove_techniques) == 0: - methods = all_methods - else: - methods = all_methods.copy() - for technique in remove_techniques: - methods.remove(technique) - - for technique in methods: - code = technique(code) - - return code - - -all_methods = [variable_renamer, add_random_variables, one_liner] diff --git a/python_obfuscator/techniques/__init__.py b/python_obfuscator/techniques/__init__.py new file mode 100644 index 0000000..a5eb466 --- /dev/null +++ b/python_obfuscator/techniques/__init__.py @@ -0,0 +1,20 @@ +"""Obfuscation techniques package. + +Importing this package triggers registration of all built-in techniques via +the ``@register`` decorator. Third-party techniques can be registered by +importing their module before calling :func:`python_obfuscator.obfuscate`. +""" + +# Side-effect import: populates the registry +from . import ast_transforms +from .base import ASTTransform, TechniqueMetadata +from .registry import all_technique_names, get_transforms, register + +__all__ = [ + "ASTTransform", + "TechniqueMetadata", + "register", + "all_technique_names", + "get_transforms", + "ast_transforms", +] diff --git a/python_obfuscator/techniques/ast_transforms/__init__.py b/python_obfuscator/techniques/ast_transforms/__init__.py new file mode 100644 index 0000000..7f16989 --- /dev/null +++ b/python_obfuscator/techniques/ast_transforms/__init__.py @@ -0,0 +1,6 @@ +from .dead_code_injector import DeadCodeInjector +from .exec_wrapper import ExecWrapper +from .string_hex_encoder import StringHexEncoder +from .variable_renamer import VariableRenamer + +__all__ = ["DeadCodeInjector", "ExecWrapper", "StringHexEncoder", "VariableRenamer"] diff --git a/python_obfuscator/techniques/ast_transforms/dead_code_injector.py b/python_obfuscator/techniques/ast_transforms/dead_code_injector.py new file mode 100644 index 0000000..ab9fb74 --- /dev/null +++ b/python_obfuscator/techniques/ast_transforms/dead_code_injector.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import ast +import random +from dataclasses import dataclass +from typing import ClassVar + +from ...helpers import RandomDataTypeGenerator, VariableNameGenerator +from ..base import ASTTransform, TechniqueMetadata +from ..registry import register +from .variable_renamer import _NameCollector + +_MAX_NAME_ATTEMPTS = 200 + + +def _interleave( + original: list[ast.stmt], + injected: list[ast.stmt], + rng: random.Random, +) -> list[ast.stmt]: + """Return a copy of *original* with *injected* statements scattered throughout. + + Each injected statement is assigned a random slot (gap between real + statements) independently. Critically, the *relative order* of injected + statements is preserved: if injected[i] references a variable defined by + injected[j] where j < i, injected[j] will always appear first, preventing + NameErrors when cross-scope references are used. + """ + if not injected: + return list(original) + n = len(original) + # Sort slots to preserve relative order among injected stmts. + slots = sorted(rng.randint(0, n) for _ in injected) + + junk_by_slot: list[list[ast.stmt]] = [[] for _ in range(n + 1)] + for stmt, slot in zip(injected, slots): + junk_by_slot[slot].append(stmt) + + result: list[ast.stmt] = [] + for i, real_stmt in enumerate(original): + result.extend(junk_by_slot[i]) + result.append(real_stmt) + result.extend(junk_by_slot[n]) + return result + + +@dataclass(frozen=True) +class InjectionParams: + """Controls dead code injection density and cross-reference behaviour. + + ``stmts_per_scope_min`` / ``stmts_per_scope_max`` + Range of junk statements injected into each discovered scope + (module body, function body, if-branch, for-body, …). + + ``cross_ref_probability`` + Probability [0, 1] that a new junk assignment reads from an already- + created junk variable (from the same scope *or* an outer scope) rather + than a fresh literal. This creates convincing inter-variable "flow" + that is still entirely dead. + """ + + stmts_per_scope_min: int = 3 + stmts_per_scope_max: int = 10 + cross_ref_probability: float = 0.35 + + +@register +class DeadCodeInjector(ASTTransform): + """Recursively injects dead variable assignments at every scope level. + + Unlike a flat module-level approach this transform walks the entire AST + and injects into every statement list it finds — function bodies, class + bodies, if/elif/else branches, for/while bodies, with-blocks, and + try/except/finally handlers. + + Outer-scope junk names are passed *into* nested scopes so that inner dead + code can reference them (read-only), creating the illusion that values flow + through the program. Junk names themselves are globally unique across all + scopes to avoid any accidental shadowing. + + Pass a seeded :class:`random.Random` and/or custom :class:`InjectionParams` + for reproducible output or tuned density:: + + injector = DeadCodeInjector( + rng=random.Random(42), + params=InjectionParams(stmts_per_scope_min=1, stmts_per_scope_max=4), + ) + """ + + metadata: ClassVar[TechniqueMetadata] = TechniqueMetadata( + name="dead_code_injector", + description="Recursively injects dead code at every scope level.", + priority=30, + ) + + def __init__( + self, + rng: random.Random | None = None, + params: InjectionParams | None = None, + ) -> None: + self._rng = rng or random.Random() + self._params = params or InjectionParams() + + # ------------------------------------------------------------------ + # ASTTransform entry point + # ------------------------------------------------------------------ + + def apply(self, tree: ast.Module) -> ast.Module: + collector = _NameCollector() + collector.visit(tree) + + # Initialise per-apply state (a new instance is created per pipeline + # run, so this is safe to store on self). + self._excluded: frozenset[str] = collector.all_bound_names + self._seen: set[str] = set() + self._counter: int = 0 + self._name_gen = VariableNameGenerator(rng=self._rng) + self._data_gen = RandomDataTypeGenerator(rng=self._rng) + + self._inject_stmts(tree.body, outer_junk=frozenset()) + return super().apply(tree) + + # ------------------------------------------------------------------ + # Core injection helpers + # ------------------------------------------------------------------ + + def _fresh_name(self) -> str | None: + """Return a name not already used anywhere in the module.""" + for _ in range(_MAX_NAME_ATTEMPTS): + name = self._name_gen.get_random(self._counter + 1) + self._counter += 1 + if name not in self._excluded and name not in self._seen: + self._seen.add(name) + return name + return None # safety guard — essentially unreachable in practice + + def _make_junk_stmt( + self, + local_junk: set[str], + ) -> ast.stmt | None: + """Build one dead assignment, optionally referencing existing local junk. + + Cross-references are intentionally limited to ``local_junk`` (names + already injected in this same scope during the current call to + ``_inject_stmts``). Referencing ``outer_junk`` names would risk a + ``NameError`` at runtime: module-level junk is randomly interleaved + with real statements, so it may not yet be defined at the point where + an inner scope (function, if-branch, …) reads it. Since + ``_interleave`` uses sorted slots, local junk stmts always appear in + generation order, making intra-scope cross-references safe. + """ + name = self._fresh_name() + if name is None: + return None + + available = list(local_junk) + if available and self._rng.random() < self._params.cross_ref_probability: + ref = self._rng.choice(available) + value: ast.expr = ast.Name(id=ref, ctx=ast.Load()) + else: + value = ast.Constant(value=self._data_gen.get_random()) + + local_junk.add(name) + return ast.Assign( + targets=[ast.Name(id=name, ctx=ast.Store())], + value=value, + ) + + def _inject_stmts( + self, + stmts: list[ast.stmt], + outer_junk: frozenset[str], + ) -> None: + """Inject junk into *stmts* in-place, then recurse into nested scopes.""" + count = self._rng.randint( + self._params.stmts_per_scope_min, + self._params.stmts_per_scope_max, + ) + local_junk: set[str] = set() + new_stmts: list[ast.stmt] = [] + + for _ in range(count * 10): + if len(new_stmts) >= count: + break + stmt = self._make_junk_stmt(local_junk) + if stmt is None: + break + new_stmts.append(stmt) + + stmts[:] = _interleave(stmts, new_stmts, self._rng) + + # Pass combined pool into nested scopes (read-only for outer names). + combined = outer_junk | frozenset(local_junk) + for stmt in stmts: + self._recurse_into(stmt, combined) + + def _recurse_into(self, stmt: ast.stmt, outer_junk: frozenset[str]) -> None: + """Dispatch injection into all statement lists nested inside *stmt*.""" + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + self._inject_stmts(stmt.body, outer_junk) + + elif isinstance(stmt, ast.If): + self._inject_stmts(stmt.body, outer_junk) + if stmt.orelse: + self._inject_stmts(stmt.orelse, outer_junk) + + elif isinstance(stmt, (ast.For, ast.While)): + self._inject_stmts(stmt.body, outer_junk) + if stmt.orelse: + self._inject_stmts(stmt.orelse, outer_junk) + + elif isinstance(stmt, ast.With): + self._inject_stmts(stmt.body, outer_junk) + + elif isinstance(stmt, ast.Try): + self._inject_stmts(stmt.body, outer_junk) + for handler in stmt.handlers: + self._inject_stmts(handler.body, outer_junk) + if stmt.orelse: + self._inject_stmts(stmt.orelse, outer_junk) + if stmt.finalbody: + self._inject_stmts(stmt.finalbody, outer_junk) diff --git a/python_obfuscator/techniques/ast_transforms/exec_wrapper.py b/python_obfuscator/techniques/ast_transforms/exec_wrapper.py new file mode 100644 index 0000000..c3b9eee --- /dev/null +++ b/python_obfuscator/techniques/ast_transforms/exec_wrapper.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import ast +from typing import ClassVar + +from ..base import ASTTransform, TechniqueMetadata +from ..registry import register + + +@register +class ExecWrapper(ASTTransform): + """Replaces the module body with a single ``exec('...')`` call. + + This reduces the program to one top-level statement. Because the inner + source is stored as an ``ast.Constant``, all quoting and escaping is + handled by ``ast.unparse`` — no manual string escaping is needed. + + Priority 100 ensures this runs last so that all other AST transforms + (variable renaming, string encoding, etc.) have already been applied to + the inner code before it is embedded in the exec call. + """ + + metadata: ClassVar[TechniqueMetadata] = TechniqueMetadata( + name="exec_wrapper", + description="Replaces the module body with a single exec('...') call.", + priority=100, + ) + + def apply(self, tree: ast.Module) -> ast.Module: + inner_source = ast.unparse(tree) + + new_tree = ast.Module( + body=[ + ast.Expr( + value=ast.Call( + func=ast.Name(id="exec", ctx=ast.Load()), + args=[ast.Constant(value=inner_source)], + keywords=[], + ) + ) + ], + type_ignores=[], + ) + ast.fix_missing_locations(new_tree) + return new_tree diff --git a/python_obfuscator/techniques/ast_transforms/string_hex_encoder.py b/python_obfuscator/techniques/ast_transforms/string_hex_encoder.py new file mode 100644 index 0000000..4e8d9e6 --- /dev/null +++ b/python_obfuscator/techniques/ast_transforms/string_hex_encoder.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import ast +from typing import ClassVar + +from ..base import ASTTransform, TechniqueMetadata +from ..registry import register + + +def _str_to_hex_call(value: str) -> ast.Call: + """Build a ``bytes.fromhex('...').decode('utf-8')`` AST node. + + This is the canonical replacement for a string literal: it produces + valid Python that evaluates to the original string while making the + content unreadable without execution. + """ + hex_value = value.encode("utf-8").hex() + return ast.Call( + func=ast.Attribute( + value=ast.Call( + func=ast.Attribute( + value=ast.Name(id="bytes", ctx=ast.Load()), + attr="fromhex", + ctx=ast.Load(), + ), + args=[ast.Constant(value=hex_value)], + keywords=[], + ), + attr="decode", + ctx=ast.Load(), + ), + args=[ast.Constant(value="utf-8")], + keywords=[], + ) + + +@register +class StringHexEncoder(ASTTransform): + """Replaces string literals with ``bytes.fromhex(...).decode('utf-8')``.""" + + metadata: ClassVar[TechniqueMetadata] = TechniqueMetadata( + name="string_hex_encoder", + description=( + "Encodes string literals to bytes.fromhex(...).decode('utf-8') " + "calls, hiding their content." + ), + priority=20, + ) + + def visit_Constant(self, node: ast.Constant) -> ast.AST: + if not isinstance(node.value, str): + return node + return ast.copy_location(_str_to_hex_call(node.value), node) + + def visit_JoinedStr(self, node: ast.JoinedStr) -> ast.JoinedStr: + # f-strings embed Constant nodes for their literal parts, but the + # semantics differ from top-level string constants. Replacing them + # with Call nodes would produce invalid AST inside a JoinedStr, so we + # skip f-strings entirely. + # TODO: we could split this out to a new technique in the future. + # consider also over-doing f strings wiht some random strings/args places around + return node diff --git a/python_obfuscator/techniques/ast_transforms/variable_renamer.py b/python_obfuscator/techniques/ast_transforms/variable_renamer.py new file mode 100644 index 0000000..ec33a7b --- /dev/null +++ b/python_obfuscator/techniques/ast_transforms/variable_renamer.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import ast +import builtins +import re +from typing import ClassVar + +from ...helpers import VariableNameGenerator +from ..base import ASTTransform, TechniqueMetadata +from ..registry import register + +_BUILTIN_NAMES: frozenset[str] = frozenset(dir(builtins)) +_DUNDER_RE: re.Pattern[str] = re.compile(r"^__\w+__$") + + +class _NameCollector(ast.NodeVisitor): + """First-pass visitor that identifies every name eligible for renaming. + + A name is *ineligible* if it is: + - a Python builtin (``print``, ``len``, etc.) + - bound by an import statement + - a dunder (``__name__``, ``__doc__``, etc.) + + Limitation: scope is not tracked. The same identifier appearing in + different scopes will map to the same obfuscated name. This is + semantically correct for non-shadowing code but may produce surprising + results when the same name is reused in sibling scopes. + """ + + def __init__(self) -> None: + self._imported: set[str] = set() + self._assigned: set[str] = set() + self._attr_accessed: set[str] = set() + + @property + def renameable(self) -> frozenset[str]: + return frozenset( + name + for name in self._assigned + if name not in _BUILTIN_NAMES + and name not in self._imported + and not _DUNDER_RE.match(name) + and name not in self._attr_accessed + ) + + @property + def all_bound_names(self) -> frozenset[str]: + """Every name that is assigned or imported, plus all builtins. + + Use this as the exclusion set when generating junk names so that + injected variables never shadow real ones. + """ + return frozenset(self._assigned) | frozenset(self._imported) | _BUILTIN_NAMES + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + bound = alias.asname or alias.name.split(".")[0] + self._imported.add(bound) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + for alias in node.names: + if alias.name == "*": + continue # can't know statically what names are introduced + bound = alias.asname or alias.name + self._imported.add(bound) + self.generic_visit(node) + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Store): + self._assigned.add(node.id) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._assigned.add(node.name) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._assigned.add(node.name) + self.generic_visit(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._assigned.add(node.name) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + # Attribute names (obj.name) are stored as bare strings in the AST. + # We cannot update call sites without full type analysis, so any name + # that appears as an attribute accessor is excluded from renaming. + self._attr_accessed.add(node.attr) + self.generic_visit(node) + + def visit_arg(self, node: ast.arg) -> None: + self._assigned.add(node.arg) + # Do not recurse: we intentionally skip renaming annotation names + + +@register +class VariableRenamer(ASTTransform): + """Renames variables, functions, classes, and parameters to unreadable names. + + Uses a two-pass approach: + 1. :class:`_NameCollector` walks the tree to build the rename mapping. + 2. The :class:`ast.NodeTransformer` machinery applies the mapping. + """ + + metadata: ClassVar[TechniqueMetadata] = TechniqueMetadata( + name="variable_renamer", + description="Renames local variables, functions, and parameters to hard-to-read identifiers.", + priority=10, + ) + + def __init__(self) -> None: + self._rename_map: dict[str, str] = {} + + def apply(self, tree: ast.Module) -> ast.Module: + collector = _NameCollector() + collector.visit(tree) + + name_gen = VariableNameGenerator() + # Sort for deterministic ordering given the same input + self._rename_map = { + name: name_gen.get_random(i + 1) + for i, name in enumerate(sorted(collector.renameable)) + } + + return super().apply(tree) + + def visit_Name(self, node: ast.Name) -> ast.Name: + if node.id in self._rename_map: + node.id = self._rename_map[node.id] + return node + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: + if node.name in self._rename_map: + node.name = self._rename_map[node.name] + self.generic_visit(node) + return node + + def visit_AsyncFunctionDef( + self, node: ast.AsyncFunctionDef + ) -> ast.AsyncFunctionDef: + if node.name in self._rename_map: + node.name = self._rename_map[node.name] + self.generic_visit(node) + return node + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: + if node.name in self._rename_map: + node.name = self._rename_map[node.name] + self.generic_visit(node) + return node + + def visit_arg(self, node: ast.arg) -> ast.arg: + if node.arg in self._rename_map: + node.arg = self._rename_map[node.arg] + return node + + def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.Nonlocal: + node.names = [self._rename_map.get(n, n) for n in node.names] + return node + + def visit_Global(self, node: ast.Global) -> ast.Global: + node.names = [self._rename_map.get(n, n) for n in node.names] + return node diff --git a/python_obfuscator/techniques/base.py b/python_obfuscator/techniques/base.py new file mode 100644 index 0000000..f01a512 --- /dev/null +++ b/python_obfuscator/techniques/base.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import ast +from abc import ABC +from dataclasses import dataclass +from typing import ClassVar + + +@dataclass(frozen=True) +class TechniqueMetadata: + name: str + description: str + priority: int # lower numbers run first + + +class ASTTransform(ast.NodeTransformer, ABC): + """Base for all obfuscation transforms. + + Subclasses must define a class-level ``metadata`` attribute and implement + one or more ``visit_*`` methods. The entry point is :meth:`apply`, not + :meth:`visit` directly — this ensures ``ast.fix_missing_locations`` is + always called after the transform. + """ + + metadata: ClassVar[TechniqueMetadata] + + def apply(self, tree: ast.Module) -> ast.Module: + result = self.visit(tree) + if not isinstance(result, ast.Module): + raise RuntimeError( + f"{type(self).__name__}.visit() returned a non-Module node; " + "transforms must not replace the top-level Module" + ) + ast.fix_missing_locations(result) + return result diff --git a/python_obfuscator/techniques/registry.py b/python_obfuscator/techniques/registry.py new file mode 100644 index 0000000..5f9f012 --- /dev/null +++ b/python_obfuscator/techniques/registry.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from .base import ASTTransform + +_REGISTRY: dict[str, type[ASTTransform]] = {} + + +def register(cls: type[ASTTransform]) -> type[ASTTransform]: + """Class decorator that registers a transform with the global registry. + + Usage:: + + @register + class MyTransform(ASTTransform): + metadata = TechniqueMetadata(name="my_transform", ...) + ... + """ + if not (isinstance(cls, type) and issubclass(cls, ASTTransform)): + raise TypeError(f"{cls!r} must be a subclass of ASTTransform") + _REGISTRY[cls.metadata.name] = cls + return cls + + +def all_technique_names() -> frozenset[str]: + """Return the names of every registered technique.""" + return frozenset(_REGISTRY) + + +def get_transforms(enabled: frozenset[str]) -> list[type[ASTTransform]]: + """Return enabled transforms sorted by ascending priority.""" + transforms = [_REGISTRY[name] for name in enabled if name in _REGISTRY] + return sorted(transforms, key=lambda cls: cls.metadata.priority) diff --git a/python_obfuscator/version.py b/python_obfuscator/version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/python_obfuscator/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8765be9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +[bumpversion] +current_version = 0.1.0 + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:python_obfuscator/version.py] +search = __version__ = "{current_version}" +replace = __version__ = "{new_version}" diff --git a/setup.py b/setup.py deleted file mode 100644 index aceb9e9..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -from distutils.core import setup -import os.path -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="python_obfuscator", - packages=setuptools.find_packages(), - version="0.0.2", - license="MIT", - description="It's a python obfuscator.", - author="David Teather", - author_email="contact.davidteather@gmail.com", - url="https://github.com/davidteather/python-obfuscator", - long_description=long_description, - long_description_content_type="text/markdown", - download_url="https://github.com/davidteather/python-obfuscator/tarball/master", - keywords=["obfuscator"], - install_requires=["regex"], - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Software Development :: Build Tools", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], - entry_points={"console_scripts": ["pyobfuscate=python_obfuscator.cli:cli"]}, -) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/files/algorithms.py b/tests/e2e/files/algorithms.py new file mode 100644 index 0000000..03113ac --- /dev/null +++ b/tests/e2e/files/algorithms.py @@ -0,0 +1,166 @@ +"""Sorting and searching algorithms on a variety of datasets.""" + + +def merge_sort(arr): + if len(arr) <= 1: + return list(arr) + mid = len(arr) // 2 + left = merge_sort(arr[:mid]) + right = merge_sort(arr[mid:]) + merged = [] + i = j = 0 + while i < len(left) and j < len(right): + if left[i] <= right[j]: + merged.append(left[i]) + i += 1 + else: + merged.append(right[j]) + j += 1 + merged.extend(left[i:]) + merged.extend(right[j:]) + return merged + + +def heap_sort(arr): + lst = list(arr) + n = len(lst) + + def sift_down(i, end): + while True: + largest = i + left = 2 * i + 1 + right = 2 * i + 2 + if left <= end and lst[left] > lst[largest]: + largest = left + if right <= end and lst[right] > lst[largest]: + largest = right + if largest == i: + break + lst[i], lst[largest] = lst[largest], lst[i] + i = largest + + for i in range((n - 1) // 2, -1, -1): + sift_down(i, n - 1) + for end in range(n - 1, 0, -1): + lst[0], lst[end] = lst[end], lst[0] + sift_down(0, end - 1) + return lst + + +def quick_sort(arr): + if len(arr) <= 1: + return list(arr) + pivot = arr[len(arr) // 2] + left = [x for x in arr if x < pivot] + mid = [x for x in arr if x == pivot] + right = [x for x in arr if x > pivot] + return quick_sort(left) + mid + quick_sort(right) + + +def counting_sort(arr, key=None): + if not arr: + return [] + mapped = [key(x) for x in arr] if key else list(arr) + lo, hi = min(mapped), max(mapped) + counts = [0] * (hi - lo + 1) + for v in mapped: + counts[v - lo] += 1 + result = [] + for i, c in enumerate(counts): + result.extend([i + lo] * c) + return result + + +def binary_search(arr, target): + lo, hi = 0, len(arr) - 1 + while lo <= hi: + mid = (lo + hi) // 2 + if arr[mid] == target: + return mid + elif arr[mid] < target: + lo = mid + 1 + else: + hi = mid - 1 + return -1 + + +def interpolation_search(arr, target): + lo, hi = 0, len(arr) - 1 + while lo <= hi and arr[lo] <= target <= arr[hi]: + if arr[lo] == arr[hi]: + return lo if arr[lo] == target else -1 + pos = lo + (target - arr[lo]) * (hi - lo) // (arr[hi] - arr[lo]) + if arr[pos] == target: + return pos + elif arr[pos] < target: + lo = pos + 1 + else: + hi = pos - 1 + return -1 + + +# ── correctness tests ──────────────────────────────────────────────────────── + +DATA = [ + 82, + 3, + 47, + 15, + 91, + 64, + 28, + 73, + 9, + 56, + 40, + 17, + 85, + 32, + 60, + 7, + 99, + 21, + 44, + 68, + 1, + 77, + 53, + 36, + 12, + 89, + 24, + 71, + 49, + 5, +] + +EXPECTED = sorted(DATA) + +for label, fn in [ + ("merge", merge_sort), + ("heap", heap_sort), + ("quick", quick_sort), + ("counting", counting_sort), +]: + result = fn(DATA) + ok = "OK" if result == EXPECTED else "FAIL" + print(f"{label:10s} {ok} first={result[:5]}") + +sorted_data = EXPECTED +for target in [1, 12, 49, 99, 100, 3, 50]: + bi = binary_search(sorted_data, target) + ii = interpolation_search(sorted_data, target) + match = "match" if bi == ii else f"MISMATCH bi={bi} ii={ii}" + found = "found" if bi != -1 else "absent" + print(f"search({target:3d}): {found:6s} {match}") + +# ── multi-pass on larger data ───────────────────────────────────────────────── + +big = list(range(200, 0, -1)) # 200 down to 1 +big_sorted = sorted(big) + +for fn in (merge_sort, heap_sort, quick_sort): + assert fn(big) == big_sorted + +print(f"large-data: all three sorts agree, n={len(big)}") +print(f"sum of sorted: {sum(big_sorted)}") diff --git a/tests/e2e/files/cipher.py b/tests/e2e/files/cipher.py new file mode 100644 index 0000000..d16baf9 --- /dev/null +++ b/tests/e2e/files/cipher.py @@ -0,0 +1,180 @@ +"""Classical ciphers: Caesar, Vigenère, Playfair, and Rail-fence.""" + +import string + +# ── Caesar ──────────────────────────────────────────────────────────────────── + + +def caesar_encrypt(text, shift): + result = [] + for ch in text: + if ch.isupper(): + result.append(chr((ord(ch) - ord("A") + shift) % 26 + ord("A"))) + elif ch.islower(): + result.append(chr((ord(ch) - ord("a") + shift) % 26 + ord("a"))) + else: + result.append(ch) + return "".join(result) + + +def caesar_decrypt(text, shift): + return caesar_encrypt(text, -shift) + + +# ── Vigenère ────────────────────────────────────────────────────────────────── + + +def vigenere_encrypt(text, key): + key = key.upper() + key_len = len(key) + result = [] + ki = 0 + for ch in text: + if ch.isalpha(): + base = ord("A") if ch.isupper() else ord("a") + shift = ord(key[ki % key_len]) - ord("A") + result.append(chr((ord(ch) - base + shift) % 26 + base)) + ki += 1 + else: + result.append(ch) + return "".join(result) + + +def vigenere_decrypt(text, key): + key = key.upper() + key_len = len(key) + result = [] + ki = 0 + for ch in text: + if ch.isalpha(): + base = ord("A") if ch.isupper() else ord("a") + shift = ord(key[ki % key_len]) - ord("A") + result.append(chr((ord(ch) - base - shift) % 26 + base)) + ki += 1 + else: + result.append(ch) + return "".join(result) + + +# ── Rail-fence ──────────────────────────────────────────────────────────────── + + +def rail_fence_encrypt(text, rails): + fence = [[] for _ in range(rails)] + rail = 0 + direction = 1 + for ch in text: + fence[rail].append(ch) + if rail == 0: + direction = 1 + elif rail == rails - 1: + direction = -1 + rail += direction + return "".join(ch for row in fence for ch in row) + + +def rail_fence_decrypt(text, rails): + n = len(text) + pattern = [] + rail = 0 + direction = 1 + for i in range(n): + pattern.append(rail) + if rail == 0: + direction = 1 + elif rail == rails - 1: + direction = -1 + rail += direction + indices = sorted(range(n), key=lambda i: (pattern[i], i)) + result = [""] * n + for pos, ch in zip(indices, text): + result[pos] = ch + return "".join(result) + + +# ── Frequency analysis ──────────────────────────────────────────────────────── + + +def letter_freq(text): + counts = {c: 0 for c in string.ascii_lowercase} + for ch in text.lower(): + if ch in counts: + counts[ch] += 1 + total = sum(counts.values()) or 1 + return {c: round(v / total * 100, 2) for c, v in counts.items() if v > 0} + + +def coincidence_index(text): + """Index of coincidence — useful for identifying poly-alphabetic ciphers.""" + filtered = [ch for ch in text.lower() if ch.isalpha()] + n = len(filtered) + if n < 2: + return 0.0 + freq = {} + for ch in filtered: + freq[ch] = freq.get(ch, 0) + 1 + return sum(f * (f - 1) for f in freq.values()) / (n * (n - 1)) + + +# ── Brute-force Caesar cracker ───────────────────────────────────────────────── + +ENGLISH_FREQ_ORDER = "etaoinshrdlcumwfgypbvkjxqz" + + +def crack_caesar(ciphertext): + """Return most-likely plaintext by frequency analysis.""" + best_shift = 0 + best_score = float("inf") + for shift in range(26): + candidate = caesar_decrypt(ciphertext, shift) + freq = letter_freq(candidate) + top = sorted(freq, key=freq.get, reverse=True)[:6] + score = sum(ENGLISH_FREQ_ORDER.index(c) for c in top if c in ENGLISH_FREQ_ORDER) + if score < best_score: + best_score = score + best_shift = shift + return caesar_decrypt(ciphertext, best_shift), best_shift + + +# ── Outputs ──────────────────────────────────────────────────────────────────── + +PLAINTEXT = "The quick brown fox jumps over the lazy dog" +print("original:", PLAINTEXT) + +for shift in [3, 13, 25]: + enc = caesar_encrypt(PLAINTEXT, shift) + dec = caesar_decrypt(enc, shift) + ok = "OK" if dec == PLAINTEXT else "FAIL" + print(f"caesar({shift:2d}): {enc[:30]}... round-trip={ok}") + +KEY = "LEMON" +vig_enc = vigenere_encrypt(PLAINTEXT, KEY) +vig_dec = vigenere_decrypt(vig_enc, KEY) +print(f"vigenere enc: {vig_enc}") +print(f"vigenere dec: {vig_dec}") +print(f"vigenere round-trip: {'OK' if vig_dec == PLAINTEXT else 'FAIL'}") + +for rails in [2, 3, 4]: + enc = rail_fence_encrypt(PLAINTEXT, rails) + dec = rail_fence_decrypt(enc, rails) + ok = "OK" if dec == PLAINTEXT else "FAIL" + print(f"rail-fence({rails}): {enc[:30]}... round-trip={ok}") + +MESSAGES = [ + "Hello World from Python", + "Attack at dawn", + "Never gonna give you up", +] +for msg in MESSAGES: + shift = 7 + enc = caesar_encrypt(msg, shift) + cracked, found_shift = crack_caesar(enc) + ok = "OK" if cracked == msg else "FAIL" + print(f"crack shift={found_shift}: {ok} '{cracked}'") + +sample = "secretmessagehiddeninplainsight" * 3 +ic = coincidence_index(sample) +print(f"IC(sample): {ic:.4f}") +freq = letter_freq(sample) +top5 = sorted(freq, key=freq.get, reverse=True)[:5] +print(f"top-5 letters: {top5}") diff --git a/tests/e2e/files/data_structures.py b/tests/e2e/files/data_structures.py new file mode 100644 index 0000000..7d97720 --- /dev/null +++ b/tests/e2e/files/data_structures.py @@ -0,0 +1,238 @@ +"""Linked list, binary search tree, and deque implemented from scratch.""" + +import random as _rnd + +# ── Doubly-linked list ──────────────────────────────────────────────────────── + + +class Node: + def __init__(self, value): + self.value = value + self.prev = None + self.next = None + + +class DoublyLinkedList: + def __init__(self): + self.head = None + self.tail = None + self.size = 0 + + def append(self, value): + node = Node(value) + if self.tail is None: + self.head = self.tail = node + else: + node.prev = self.tail + self.tail.next = node + self.tail = node + self.size += 1 + + def prepend(self, value): + node = Node(value) + if self.head is None: + self.head = self.tail = node + else: + node.next = self.head + self.head.prev = node + self.head = node + self.size += 1 + + def remove(self, value): + cur = self.head + while cur: + if cur.value == value: + if cur.prev: + cur.prev.next = cur.next + else: + self.head = cur.next + if cur.next: + cur.next.prev = cur.prev + else: + self.tail = cur.prev + self.size -= 1 + return True + cur = cur.next + return False + + def to_list(self): + result = [] + cur = self.head + while cur: + result.append(cur.value) + cur = cur.next + return result + + def reverse(self): + cur = self.head + while cur: + cur.prev, cur.next = cur.next, cur.prev + cur = cur.prev + self.head, self.tail = self.tail, self.head + + +# ── Binary search tree ──────────────────────────────────────────────────────── + + +class BSTNode: + def __init__(self, key): + self.key = key + self.left = None + self.right = None + self.height = 1 + + +class BST: + def __init__(self): + self.root = None + + def insert(self, key): + self.root = self._insert(self.root, key) + + def _insert(self, node, key): + if node is None: + return BSTNode(key) + if key < node.key: + node.left = self._insert(node.left, key) + elif key > node.key: + node.right = self._insert(node.right, key) + return node + + def search(self, key): + node = self.root + while node: + if key == node.key: + return True + node = node.left if key < node.key else node.right + return False + + def inorder(self): + result = [] + stack = [] + cur = self.root + while cur or stack: + while cur: + stack.append(cur) + cur = cur.left + cur = stack.pop() + result.append(cur.key) + cur = cur.right + return result + + def height(self): + def _h(node): + if node is None: + return 0 + return 1 + max(_h(node.left), _h(node.right)) + + return _h(self.root) + + def min_value(self): + node = self.root + while node and node.left: + node = node.left + return node.key if node else None + + def max_value(self): + node = self.root + while node and node.right: + node = node.right + return node.key if node else None + + +# ── Circular deque ──────────────────────────────────────────────────────────── + + +class Deque: + def __init__(self, capacity=16): + self._buf = [None] * capacity + self._head = 0 + self._size = 0 + + def _grow(self): + cap = len(self._buf) + new_buf = [None] * (cap * 2) + for i in range(self._size): + new_buf[i] = self._buf[(self._head + i) % cap] + self._buf = new_buf + self._head = 0 + + def push_back(self, item): + if self._size == len(self._buf): + self._grow() + tail = (self._head + self._size) % len(self._buf) + self._buf[tail] = item + self._size += 1 + + def push_front(self, item): + if self._size == len(self._buf): + self._grow() + self._head = (self._head - 1) % len(self._buf) + self._buf[self._head] = item + self._size += 1 + + def pop_back(self): + if self._size == 0: + raise IndexError("deque is empty") + tail = (self._head + self._size - 1) % len(self._buf) + val = self._buf[tail] + self._size -= 1 + return val + + def pop_front(self): + if self._size == 0: + raise IndexError("deque is empty") + val = self._buf[self._head] + self._head = (self._head + 1) % len(self._buf) + self._size -= 1 + return val + + def __len__(self): + return self._size + + def to_list(self): + return [self._buf[(self._head + i) % len(self._buf)] for i in range(self._size)] + + +# ── Exercises ───────────────────────────────────────────────────────────────── + +dll = DoublyLinkedList() +for v in [10, 20, 30, 40, 50]: + dll.append(v) +dll.prepend(5) +dll.remove(30) +print("dll forward:", dll.to_list()) +dll.reverse() +print("dll reversed:", dll.to_list()) +print("dll size:", dll.size) + +bst = BST() +keys = [50, 30, 70, 20, 40, 60, 80, 10, 25, 35, 45] +for k in keys: + bst.insert(k) +print("bst inorder:", bst.inorder()) +print("bst height:", bst.height()) +print("bst min:", bst.min_value(), "max:", bst.max_value()) +for k in [25, 55, 80, 1]: + print(f" search({k}): {bst.search(k)}") + +dq = Deque(4) +for v in range(1, 9): + dq.push_back(v) +for v in [0, -1]: + dq.push_front(v) +print("deque:", dq.to_list()) +print("pop_back:", dq.pop_back()) +print("pop_front:", dq.pop_front()) +print("deque after pops:", dq.to_list()) + +# stress: sort using BST (tree sort) +_rnd.seed(42) +random_vals = [_rnd.randint(0, 1000) for _ in range(50)] +tree = BST() +for v in random_vals: + tree.insert(v) +tree_sorted = tree.inorder() +direct_sorted = sorted(set(random_vals)) +print("tree-sort matches sorted(set):", tree_sorted == direct_sorted) +print("tree-sort length:", len(tree_sorted)) diff --git a/tests/e2e/files/functional.py b/tests/e2e/files/functional.py new file mode 100644 index 0000000..3558967 --- /dev/null +++ b/tests/e2e/files/functional.py @@ -0,0 +1,221 @@ +"""Functional programming patterns: higher-order functions, generators, lazy pipelines.""" + +from functools import reduce + +# ── Core combinators ────────────────────────────────────────────────────────── + + +def compose(*fns): + """Right-to-left function composition.""" + + def composed(x): + for fn in reversed(fns): + x = fn(x) + return x + + return composed + + +def pipe(*fns): + """Left-to-right function composition.""" + return compose(*reversed(fns)) + + +def curry(fn): + """Simple two-argument currying.""" + + def curried(a): + def inner(b): + return fn(a, b) + + return inner + + return curried + + +def memoize(fn): + cache = {} + + def wrapper(*args): + if args not in cache: + cache[args] = fn(*args) + return cache[args] + + return wrapper + + +def trampoline(fn): + """Run a thunk-returning function iteratively to avoid stack overflow.""" + + def run(*args): + result = fn(*args) + while callable(result): + result = result() + return result + + return run + + +# ── Lazy sequences ───────────────────────────────────────────────────────────── + + +def take(n, iterable): + result = [] + for i, item in enumerate(iterable): + if i >= n: + break + result.append(item) + return result + + +def drop(n, iterable): + it = iter(iterable) + for _ in range(n): + next(it, None) + return list(it) + + +def naturals(start=0): + n = start + while True: + yield n + n += 1 + + +def fibs(): + a, b = 0, 1 + while True: + yield a + a, b = b, a + b + + +def primes_gen(): + """Infinite prime generator using a sieve dict.""" + composites = {} + n = 2 + while True: + if n not in composites: + yield n + composites[n * n] = [n] + else: + for p in composites[n]: + composites.setdefault(n + p, []).append(p) + del composites[n] + n += 1 + + +def scan(fn, iterable, initial=None): + """Running accumulation (like Haskell's scanl).""" + acc = initial + result = [] if initial is None else [initial] + for item in iterable: + acc = fn(acc, item) if acc is not None else item + result.append(acc) + return result + + +def zip_with(fn, *iterables): + return [fn(*args) for args in zip(*iterables)] + + +def flatten(nested, depth=1): + result = [] + for item in nested: + if isinstance(item, (list, tuple)) and depth > 0: + result.extend(flatten(item, depth - 1)) + else: + result.append(item) + return result + + +def group_by(fn, iterable): + groups = {} + for item in iterable: + key = fn(item) + groups.setdefault(key, []).append(item) + return groups + + +def partition(pred, iterable): + yes, no = [], [] + for item in iterable: + (yes if pred(item) else no).append(item) + return yes, no + + +# ── Memoized recursive algorithms ───────────────────────────────────────────── + + +@memoize +def fib(n): + if n < 2: + return n + return fib(n - 1) + fib(n - 2) + + +@memoize +def catalan(n): + if n == 0: + return 1 + return sum(catalan(i) * catalan(n - 1 - i) for i in range(n)) + + +# ── Outputs ──────────────────────────────────────────────────────────────────── + +double = curry(lambda a, b: a * b)(2) +add10 = curry(lambda a, b: a + b)(10) + + +def square(x): + return x * x + + +pipeline = pipe(double, add10, square) +inputs = [1, 2, 3, 4, 5] +print("pipeline results:", [pipeline(x) for x in inputs]) + +composed = compose(str, square, double) +print("composed:", [composed(x) for x in inputs]) + +fib_list = take(15, fibs()) +print("fibs:", fib_list) +print("fib(30):", fib(30)) + +prime_list = take(20, primes_gen()) +print("primes:", prime_list) +print("50th prime:", take(50, primes_gen())[-1]) + +evens, odds = partition(lambda x: x % 2 == 0, range(20)) +print("evens:", evens) +print("odds:", odds) + +by_mod = group_by(lambda x: x % 3, range(15)) +for k in sorted(by_mod): + print(f"mod3={k}: {by_mod[k]}") + +running_sum = scan(lambda a, b: a + b, range(1, 11)) +print("running sum:", running_sum) + +pairs = zip_with(lambda a, b: a * b, range(1, 6), range(6, 11)) +print("zip_with mul:", pairs) + +nested = [[1, [2, 3]], [4, [5, [6]]]] +print("flatten(depth=1):", flatten(nested, 1)) +print("flatten(depth=2):", flatten(nested, 2)) + +catalan_list = [catalan(n) for n in range(10)] +print("catalan:", catalan_list) + +# Transducer-style pipeline using reduce +data = list(range(1, 21)) +result = reduce( + lambda acc, x: acc + [x * x] if x % 2 == 0 else acc, + data, + [], +) +print("squares of evens:", result) +print("sum:", sum(result)) + +# Triangle numbers via scan over a finite range +triangles = scan(lambda a, b: a + b, range(1, 12)) +print("triangle numbers:", triangles) diff --git a/tests/e2e/files/number_theory.py b/tests/e2e/files/number_theory.py new file mode 100644 index 0000000..e0392f3 --- /dev/null +++ b/tests/e2e/files/number_theory.py @@ -0,0 +1,179 @@ +"""Number theory: primes, factorisation, modular arithmetic, combinatorics.""" + + +def sieve(limit): + """Return all primes up to limit via Sieve of Eratosthenes.""" + if limit < 2: + return [] + is_prime = bytearray([1]) * (limit + 1) + is_prime[0] = is_prime[1] = 0 + for i in range(2, int(limit**0.5) + 1): + if is_prime[i]: + is_prime[i * i :: i] = bytearray(len(is_prime[i * i :: i])) + return [i for i in range(2, limit + 1) if is_prime[i]] + + +def factorize(n): + """Return prime factorisation as list (with repetition).""" + factors = [] + d = 2 + while d * d <= n: + while n % d == 0: + factors.append(d) + n //= d + d += 1 + if n > 1: + factors.append(n) + return factors + + +def gcd(a, b): + while b: + a, b = b, a % b + return a + + +def lcm(a, b): + return a * b // gcd(a, b) + + +def extended_gcd(a, b): + """Return (g, x, y) such that a*x + b*y = g = gcd(a, b).""" + if b == 0: + return a, 1, 0 + g, x1, y1 = extended_gcd(b, a % b) + return g, y1, x1 - (a // b) * y1 + + +def mod_inverse(a, m): + g, x, _ = extended_gcd(a % m, m) + if g != 1: + return None + return x % m + + +def pow_mod(base, exp, mod): + """Fast modular exponentiation.""" + result = 1 + base %= mod + while exp > 0: + if exp % 2 == 1: + result = result * base % mod + exp //= 2 + base = base * base % mod + return result + + +def miller_rabin(n, witnesses=None): + """Deterministic Miller-Rabin for n < 3_215_031_751.""" + if n < 2: + return False + if n in (2, 3, 5, 7): + return True + if n % 2 == 0: + return False + d, r = n - 1, 0 + while d % 2 == 0: + d //= 2 + r += 1 + for a in witnesses or [2, 3, 5, 7]: + if a >= n: + continue + x = pow_mod(a, d, n) + if x in (1, n - 1): + continue + for _ in range(r - 1): + x = pow_mod(x, 2, n) + if x == n - 1: + break + else: + return False + return True + + +def goldbach(n): + """Express even n >= 4 as sum of two primes (Goldbach).""" + primes = set(sieve(n)) + for p in sorted(primes): + if n - p in primes: + return p, n - p + return None + + +def totient(n): + """Euler's totient function phi(n).""" + result = n + p = 2 + temp = n + while p * p <= temp: + if temp % p == 0: + while temp % p == 0: + temp //= p + result -= result // p + p += 1 + if temp > 1: + result -= result // temp + return result + + +def factorial(n): + result = 1 + for i in range(2, n + 1): + result *= i + return result + + +def choose(n, k): + if k > n: + return 0 + k = min(k, n - k) + result = 1 + for i in range(k): + result = result * (n - i) // (i + 1) + return result + + +# ── outputs ─────────────────────────────────────────────────────────────────── + +primes_100 = sieve(100) +print(f"primes<=100: {len(primes_100)} primes, sum={sum(primes_100)}") +print(f"first 10: {primes_100[:10]}") + +for n in [12, 360, 1024, 9973]: + facs = factorize(n) + product = 1 + for f in facs: + product *= f + print(f"factors({n}): {facs} product={product}") + +pairs = [(12, 8), (100, 75), (17, 5), (1001, 77)] +for a, b in pairs: + print(f"gcd({a},{b})={gcd(a,b)} lcm={lcm(a,b)}") + +print("miller-rabin:") +for n in [2, 17, 100, 997, 998, 7919]: + result = miller_rabin(n) + naive = n in set(sieve(8000)) + match = "OK" if result == naive else "FAIL" + print(f" {n}: {result} {match}") + +print("goldbach:") +for n in [28, 100, 200]: + p, q = goldbach(n) + print(f" {n} = {p} + {q}") + +print("totient:") +for n in [1, 6, 10, 36, 100]: + print(f" phi({n}) = {totient(n)}") + +print("mod-inverse:") +for a, m in [(3, 7), (10, 17), (6, 35)]: + inv = mod_inverse(a, m) + print(f" {a}^-1 mod {m} = {inv}") + +print("binomial:") +for n, k in [(5, 2), (10, 3), (20, 10)]: + print(f" C({n},{k}) = {choose(n,k)}") + +print(f"10! = {factorial(10)}") +print(f"pow_mod(2,100,1000000007) = {pow_mod(2, 100, 1000000007)}") diff --git a/tests/e2e/files/oop_simulation.py b/tests/e2e/files/oop_simulation.py new file mode 100644 index 0000000..5c0757c --- /dev/null +++ b/tests/e2e/files/oop_simulation.py @@ -0,0 +1,174 @@ +"""Bank account simulation exercising OOP: inheritance, properties, decorators.""" + + +def validate_positive(fn): + def wrapper(self, amount): + if amount <= 0: + raise ValueError(f"Amount must be positive, got {amount}") + return fn(self, amount) + + return wrapper + + +class Transaction: + def __init__(self, kind, amount, balance_after): + self.kind = kind + self.amount = amount + self.balance_after = balance_after + + def __repr__(self): + return ( + f"Transaction({self.kind}, {self.amount:.2f}, bal={self.balance_after:.2f})" + ) + + +class Account: + _next_id = 1000 + + def __init__(self, owner, initial_balance=0.0): + self.owner = owner + self._balance = float(initial_balance) + self._history = [] + self.account_id = Account._next_id + Account._next_id += 1 + + @property + def balance(self): + return self._balance + + @validate_positive + def deposit(self, amount): + self._balance += amount + self._history.append(Transaction("deposit", amount, self._balance)) + + @validate_positive + def withdraw(self, amount): + if amount > self._balance: + raise ValueError( + f"Insufficient funds: need {amount:.2f}, have {self._balance:.2f}" + ) + self._balance -= amount + self._history.append(Transaction("withdraw", amount, self._balance)) + + def statement(self): + lines = [f"Account #{self.account_id} ({self.owner})"] + for tx in self._history: + lines.append(f" {tx.kind:10s} {tx.amount:8.2f} → {tx.balance_after:.2f}") + lines.append(f" Balance: {self._balance:.2f}") + return "\n".join(lines) + + def __repr__(self): + return f"Account({self.owner!r}, balance={self._balance:.2f})" + + +class SavingsAccount(Account): + def __init__(self, owner, initial_balance=0.0, rate=0.05): + super().__init__(owner, initial_balance) + self.rate = rate + + def apply_interest(self): + interest = round(self._balance * self.rate, 2) + if interest > 0: + self.deposit(interest) + return interest + + @validate_positive + def withdraw(self, amount): + penalty = 0.0 + if amount > self._balance * 0.5: + penalty = round(amount * 0.02, 2) + super().withdraw(amount) + if penalty > 0: + super().withdraw(penalty) + + +class CheckingAccount(Account): + def __init__(self, owner, initial_balance=0.0, overdraft_limit=100.0): + super().__init__(owner, initial_balance) + self.overdraft_limit = overdraft_limit + + @validate_positive + def withdraw(self, amount): + if amount > self._balance + self.overdraft_limit: + raise ValueError("Exceeds overdraft limit") + self._balance -= amount + self._history.append(Transaction("withdraw", amount, self._balance)) + + +class Bank: + def __init__(self, name): + self.name = name + self._accounts = {} + + def open(self, account): + self._accounts[account.account_id] = account + return account + + def transfer(self, from_id, to_id, amount): + src = self._accounts[from_id] + dst = self._accounts[to_id] + src.withdraw(amount) + dst.deposit(amount) + + def total_deposits(self): + return sum(a.balance for a in self._accounts.values()) + + def richest(self): + return max(self._accounts.values(), key=lambda a: a.balance) + + def summary(self): + lines = [f"Bank: {self.name} ({len(self._accounts)} accounts)"] + for acc in sorted(self._accounts.values(), key=lambda a: a.account_id): + lines.append(f" {acc}") + lines.append(f" Total deposits: {self.total_deposits():.2f}") + return "\n".join(lines) + + +# ── Simulation ───────────────────────────────────────────────────────────────── + +bank = Bank("PyBank") + +alice = bank.open(Account("Alice", 500)) +bob = bank.open(SavingsAccount("Bob", 1000, rate=0.10)) +carol = bank.open(CheckingAccount("Carol", 200, overdraft_limit=150)) + +alice.deposit(300) +alice.withdraw(100) +bob.deposit(500) +interest = bob.apply_interest() +print(f"Bob interest: {interest:.2f}") +carol.deposit(50) +carol.withdraw(380) # uses overdraft + +bank.transfer(alice.account_id, bob.account_id, 200) +bank.transfer(bob.account_id, carol.account_id, 100) + +print(bank.summary()) +print("richest:", bank.richest().owner) + +# Test error paths +errors = [] +for desc, fn in [ + ("deposit zero", lambda: alice.deposit(0)), + ("withdraw overdraft", lambda: carol.withdraw(500)), +]: + try: + fn() + except ValueError: + errors.append(f"{desc}: caught ValueError") +print("errors caught:", len(errors)) +for e in errors: + print(" ", e) + +print(alice.statement()) +print(bob.statement()) + +# Batch operations +accounts = [bank.open(Account(f"User{i}", i * 100)) for i in range(1, 6)] +for acc in accounts: + acc.deposit(50) + if acc.balance > 200: + acc.withdraw(80) +totals = [acc.balance for acc in accounts] +print("batch balances:", totals) +print("batch total:", sum(totals)) diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py new file mode 100644 index 0000000..2741eb3 --- /dev/null +++ b/tests/e2e/test_e2e.py @@ -0,0 +1,152 @@ +"""End-to-end tests: run each file in tests/e2e/files/ before and after full +obfuscation and assert that stdout is identical. + +A separate benchmark test measures the runtime overhead introduced by +obfuscation. Benchmark tests always pass — they are informational and are +printed to the terminal when pytest is run with ``-s``. + +Usage:: + + pytest tests/e2e/ -v # correctness only (fast) + pytest tests/e2e/ -v -s # correctness + benchmark output + pytest tests/e2e/ -v -s -k benchmark # benchmarks only +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from python_obfuscator import ObfuscationConfig, obfuscate + +if TYPE_CHECKING: + pass + +# ── configuration ───────────────────────────────────────────────────────────── + +E2E_DIR = Path(__file__).parent / "files" +E2E_FILES = sorted(E2E_DIR.glob("*.py")) + +_BASELINE = ObfuscationConfig.only() # no transforms — plain re-parse/unparse +_ALL = ObfuscationConfig.all_enabled() + +#: Number of timed repetitions per file. Enough for stable means without +#: making the suite slow under CI. +_BENCHMARK_RUNS = 20 + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def _capture(code: str) -> list[str]: + """Execute *code* and return every string passed to print(), in order. + + The fake ``print`` is injected into the exec namespace so it works even + when ``exec_wrapper`` is active (the inner exec inherits the outer globals). + """ + captured: list[str] = [] + + def fake_print(*args: object, sep: str = " ", end: str = "\n", **_: object) -> None: + captured.append(sep.join(str(a) for a in args)) + + exec(code, {"print": fake_print}) # noqa: S102 + return captured + + +def _time_runs(code: str, n: int) -> float: + """Execute *code* n times and return the total wall-clock seconds.""" + captured: list[str] = [] + + def fake_print(*args: object, **_: object) -> None: + captured.append(str(args)) + + start = time.perf_counter() + for _ in range(n): + exec(code, {"print": fake_print}) # noqa: S102 + return time.perf_counter() - start + + +# ── correctness ─────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("py_file", E2E_FILES, ids=[f.stem for f in E2E_FILES]) +def test_e2e_correctness(py_file: Path) -> None: + """Obfuscated output must be byte-for-byte identical to the original.""" + source = py_file.read_text() + expected = _capture(obfuscate(source, config=_BASELINE)) + actual = _capture(obfuscate(source, config=_ALL)) + assert actual == expected, ( + f"\nOutput mismatch for {py_file.name}:\n" + f" expected {len(expected)} line(s), got {len(actual)}\n" + f" first expected: {expected[:5]}\n" + f" first actual: {actual[:5]}" + ) + + +# ── benchmark ───────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("py_file", E2E_FILES, ids=[f.stem for f in E2E_FILES]) +def test_e2e_benchmark(py_file: Path) -> None: + """Measure runtime overhead introduced by all obfuscation techniques. + + This test always passes. Run with ``-s`` to see the timing table printed + to stdout. + """ + source = py_file.read_text() + original_code = obfuscate(source, config=_BASELINE) + obfuscated_code = obfuscate(source, config=_ALL) + + orig_t = _time_runs(original_code, _BENCHMARK_RUNS) + obf_t = _time_runs(obfuscated_code, _BENCHMARK_RUNS) + + orig_ms = orig_t * 1000 / _BENCHMARK_RUNS + obf_ms = obf_t * 1000 / _BENCHMARK_RUNS + overhead_pct = (obf_ms / orig_ms - 1.0) * 100 if orig_ms > 0 else 0.0 + + print( + f"\n{'─' * 62}\n" + f" {py_file.name}\n" + f" original : {orig_ms:8.3f} ms/run\n" + f" obfuscated : {obf_ms:8.3f} ms/run\n" + f" overhead : {overhead_pct:+8.1f} %\n" + f" runs : {_BENCHMARK_RUNS}\n" + ) + + +# ── per-technique breakdown ─────────────────────────────────────────────────── + + +_TECHNIQUE_CONFIGS: list[tuple[str, ObfuscationConfig]] = [ + ("baseline (none)", ObfuscationConfig.only()), + ("variable_renamer", ObfuscationConfig.only("variable_renamer")), + ("string_hex_encoder", ObfuscationConfig.only("string_hex_encoder")), + ("dead_code_injector", ObfuscationConfig.only("dead_code_injector")), + ("exec_wrapper", ObfuscationConfig.only("exec_wrapper")), + ("all techniques", ObfuscationConfig.all_enabled()), +] + + +@pytest.mark.parametrize("py_file", E2E_FILES, ids=[f.stem for f in E2E_FILES]) +def test_e2e_per_technique_benchmark(py_file: Path) -> None: + """Break down the runtime overhead contributed by each technique. + + Always passes; prints a table to stdout when run with ``-s``. + """ + source = py_file.read_text() + + rows: list[tuple[str, float]] = [] + for label, cfg in _TECHNIQUE_CONFIGS: + code = obfuscate(source, config=cfg) + t = _time_runs(code, _BENCHMARK_RUNS) + rows.append((label, t * 1000 / _BENCHMARK_RUNS)) + + baseline_ms = rows[0][1] + lines = [f"\n{'─' * 62}", f" Per-technique breakdown: {py_file.name}"] + for label, ms in rows: + overhead = (ms / baseline_ms - 1.0) * 100 if baseline_ms > 0 else 0.0 + bar = "█" * max(0, int(overhead / 5)) + lines.append(f" {label:22s} {ms:8.3f} ms {overhead:+7.1f}% {bar}") + print("\n".join(lines)) diff --git a/tests/techniques/__init__.py b/tests/techniques/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/techniques/test_base.py b/tests/techniques/test_base.py new file mode 100644 index 0000000..be95821 --- /dev/null +++ b/tests/techniques/test_base.py @@ -0,0 +1,57 @@ +"""Tests for the ASTTransform base class.""" + +import ast + +import pytest + +from python_obfuscator.techniques.base import ASTTransform, TechniqueMetadata + + +class _IdentityTransform(ASTTransform): + """No-op transform that returns the tree unchanged.""" + + metadata = TechniqueMetadata(name="_identity", description="identity", priority=0) + + +class _BadTransform(ASTTransform): + """Deliberately returns the wrong node type from visit_Module.""" + + metadata = TechniqueMetadata(name="_bad_transform", description="bad", priority=0) + + def visit_Module(self, node: ast.Module) -> ast.Name: # type: ignore[override] + return ast.Name(id="x", ctx=ast.Load()) + + +class TestASTTransformApply: + def test_apply_returns_module(self) -> None: + tree = ast.parse("x = 1") + result = _IdentityTransform().apply(tree) + assert isinstance(result, ast.Module) + + def test_apply_calls_fix_missing_locations(self) -> None: + tree = ast.parse("x = 1") + result = _IdentityTransform().apply(tree) + compile(result, "", "exec") # fails if locations missing + + def test_apply_raises_runtime_error_on_non_module_return(self) -> None: + tree = ast.parse("x = 1") + with pytest.raises(RuntimeError, match="returned a non-Module node"): + _BadTransform().apply(tree) + + def test_apply_error_message_includes_class_name(self) -> None: + tree = ast.parse("x = 1") + with pytest.raises(RuntimeError, match="_BadTransform"): + _BadTransform().apply(tree) + + +class TestTechniqueMetadata: + def test_fields_are_accessible(self) -> None: + m = TechniqueMetadata(name="foo", description="bar", priority=5) + assert m.name == "foo" + assert m.description == "bar" + assert m.priority == 5 + + def test_metadata_is_frozen(self) -> None: + m = TechniqueMetadata(name="foo", description="bar", priority=5) + with pytest.raises((AttributeError, TypeError)): + m.name = "other" # type: ignore[misc] diff --git a/tests/techniques/test_dead_code_injector.py b/tests/techniques/test_dead_code_injector.py new file mode 100644 index 0000000..a9c4f55 --- /dev/null +++ b/tests/techniques/test_dead_code_injector.py @@ -0,0 +1,381 @@ +"""Tests for DeadCodeInjector.""" + +import ast +import random +import textwrap + +import pytest + +from python_obfuscator.techniques.ast_transforms.dead_code_injector import ( + DeadCodeInjector, + InjectionParams, + _interleave, +) +from python_obfuscator.techniques.registry import all_technique_names + +# Small params so tests run quickly and counts are predictable. +_FAST = InjectionParams(stmts_per_scope_min=2, stmts_per_scope_max=4) + + +def _apply( + source: str, + rng: random.Random | None = None, + params: InjectionParams | None = None, +) -> str: + tree = ast.parse(source) + return ast.unparse(DeadCodeInjector(rng=rng, params=params).apply(tree)) + + +def _all_assigns(source: str) -> list[str]: + """Return every assigned name in *source* (module-wide walk).""" + return [ + node.targets[0].id + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + ] + + +class TestInterleave: + def test_result_is_sum_of_lengths(self) -> None: + orig = [ast.parse("x = 1").body[0], ast.parse("y = 2").body[0]] + inj = [ast.parse("j = 0").body[0]] + assert len(_interleave(orig, inj, random.Random(0))) == 3 + + def test_empty_injected_returns_copy(self) -> None: + orig = [ast.parse("x = 1").body[0]] + result = _interleave(orig, [], random.Random(0)) + assert len(result) == 1 + + def test_empty_original_receives_injected(self) -> None: + inj = [ast.parse("j = 0").body[0]] + assert len(_interleave([], inj, random.Random(0))) == 1 + + def test_does_not_mutate_original(self) -> None: + orig = [ast.parse("x = 1").body[0]] + _interleave(orig, [ast.parse("j = 0").body[0]], random.Random(0)) + assert len(orig) == 1 + + def test_seeded_order_is_reproducible(self) -> None: + orig = [ast.parse("x = 1").body[0], ast.parse("y = 2").body[0]] + inj = [ast.parse(f"j{i} = {i}").body[0] for i in range(5)] + r1 = [ast.unparse(n) for n in _interleave(orig, inj[:], random.Random(7))] + r2 = [ast.unparse(n) for n in _interleave(orig, inj[:], random.Random(7))] + assert r1 == r2 + + +class TestDeadCodeInjector: + def test_is_registered(self) -> None: + assert "dead_code_injector" in all_technique_names() + + def test_output_is_valid_python(self) -> None: + ast.parse(_apply("x = 1", params=_FAST)) + + def test_output_has_more_assignments_than_input(self) -> None: + result = _apply("x = 1\ny = 2\n", rng=random.Random(0), params=_FAST) + assert len(_all_assigns(result)) > 2 + + def test_seeded_rng_produces_identical_output(self) -> None: + r1 = _apply("x = 1", rng=random.Random(42), params=_FAST) + r2 = _apply("x = 1", rng=random.Random(42), params=_FAST) + assert r1 == r2 + + def test_different_seeds_produce_different_output(self) -> None: + r1 = _apply("x = 1", rng=random.Random(1), params=_FAST) + r2 = _apply("x = 1", rng=random.Random(2), params=_FAST) + assert r1 != r2 + + def test_original_variable_not_clobbered(self) -> None: + source = "sentinel_val = 999\n" + ns: dict = {} + exec(_apply(source, rng=random.Random(0), params=_FAST), ns) + assert ns["sentinel_val"] == 999 + + def test_builtin_not_shadowed(self) -> None: + source = "result = len([1, 2, 3])\n" + ns: dict = {} + exec(_apply(source, rng=random.Random(0), params=_FAST), ns) + assert ns["result"] == 3 + + def test_imported_name_not_shadowed(self) -> None: + source = "import os\npath = os.path.sep\n" + ns: dict = {} + exec(_apply(source, rng=random.Random(0), params=_FAST), ns) + assert isinstance(ns["path"], str) + + def test_function_behavior_preserved(self) -> None: + source = textwrap.dedent( + """\ + def add(a, b): + return a + b + result = add(3, 4) + """ + ) + ns: dict = {} + exec(_apply(source, rng=random.Random(7), params=_FAST), ns) + assert ns["result"] == 7 + + def test_junk_names_are_valid_identifiers(self) -> None: + for name in _all_assigns(_apply("x = 1", rng=random.Random(0), params=_FAST)): + assert name.isidentifier(), f"{name!r} is not a valid identifier" + + def test_junk_names_globally_unique(self) -> None: + result = _apply("x = 1", rng=random.Random(0), params=_FAST) + names = _all_assigns(result) + assert len(names) == len(set(names)) + + def test_apply_returns_module(self) -> None: + tree = ast.parse("x = 1") + assert isinstance( + DeadCodeInjector(rng=random.Random(0)).apply(tree), ast.Module + ) + + def test_apply_produces_compilable_tree(self) -> None: + tree = ast.parse("x = 1\ny = 2") + compile(DeadCodeInjector(rng=random.Random(0)).apply(tree), "", "exec") + + def test_max_attempts_guard_with_all_names_colliding( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from python_obfuscator.helpers.variable_name_generator import ( + VariableNameGenerator, + ) + + monkeypatch.setattr(VariableNameGenerator, "get_random", lambda self, id: "x") + tree = ast.parse("x = 1") + result = DeadCodeInjector(rng=random.Random(0)).apply(tree) + assert isinstance(result, ast.Module) + ns: dict = {} + exec(ast.unparse(result), ns) + assert ns["x"] == 1 + + def test_zero_stmts_per_scope_produces_no_junk(self) -> None: + # Covers the for-loop-exhausted-naturally branch (count * 10 = 0 + # iterations so the loop body never executes). + params = InjectionParams(stmts_per_scope_min=0, stmts_per_scope_max=0) + source = "x = 1\n" + result = _apply(source, rng=random.Random(0), params=params) + # No new Assign nodes should have been injected + assigns = _all_assigns(result) + assert assigns == ["x"] + + +class TestRecursiveInjection: + """Verify that injection happens inside nested scopes.""" + + def test_injects_into_function_body(self) -> None: + source = textwrap.dedent( + """\ + def foo(): + return 1 + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + func = next(n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)) + assigns_in_func = [ + n + for n in ast.walk(ast.Module(body=func.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns_in_func) > 0 + + def test_injects_into_class_body(self) -> None: + source = textwrap.dedent( + """\ + class Foo: + x = 1 + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) + assigns = [ + n + for n in ast.walk(ast.Module(body=cls.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 1 # original 'x = 1' plus junk + + def test_injects_into_if_body(self) -> None: + source = "if True:\n x = 1\n" + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + if_node = next(n for n in ast.walk(tree) if isinstance(n, ast.If)) + assigns = [ + n + for n in ast.walk(ast.Module(body=if_node.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 1 + + def test_injects_into_for_body(self) -> None: + source = "total = 0\nfor i in range(3):\n total += i\n" + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + for_node = next(n for n in ast.walk(tree) if isinstance(n, ast.For)) + assigns = [ + n + for n in ast.walk(ast.Module(body=for_node.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 0 + + def test_injects_into_for_orelse(self) -> None: + source = textwrap.dedent( + """\ + for i in range(3): + pass + else: + found = True + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + for_node = next(n for n in ast.walk(tree) if isinstance(n, ast.For)) + assert for_node.orelse, "for/else should be preserved" + assigns = [ + n + for n in ast.walk(ast.Module(body=for_node.orelse, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + # orelse has 'found = True' plus injected junk + assert len(assigns) > 1 + + def test_injects_into_while_body(self) -> None: + source = "i = 0\nwhile i < 1:\n i += 1\n" + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + while_node = next(n for n in ast.walk(tree) if isinstance(n, ast.While)) + assigns = [ + n + for n in ast.walk(ast.Module(body=while_node.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 0 + + def test_injects_into_try_body(self) -> None: + source = textwrap.dedent( + """\ + try: + x = 1 + except Exception: + pass + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + try_node = next(n for n in ast.walk(tree) if isinstance(n, ast.Try)) + assigns = [ + n + for n in ast.walk(ast.Module(body=try_node.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 1 + + def test_injects_into_except_handler(self) -> None: + source = textwrap.dedent( + """\ + try: + x = 1 + except Exception: + pass + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + try_node = next(n for n in ast.walk(tree) if isinstance(n, ast.Try)) + handler_assigns = [ + n + for h in try_node.handlers + for n in ast.walk(ast.Module(body=h.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(handler_assigns) > 0 + + def test_injects_into_with_body(self) -> None: + source = textwrap.dedent( + """\ + import contextlib + with contextlib.suppress(Exception): + x = 1 + """ + ) + result = _apply(source, rng=random.Random(0), params=_FAST) + tree = ast.parse(result) + with_node = next(n for n in ast.walk(tree) if isinstance(n, ast.With)) + assigns = [ + n + for n in ast.walk(ast.Module(body=with_node.body, type_ignores=[])) + if isinstance(n, ast.Assign) + ] + assert len(assigns) > 1 + + def test_nested_function_behavior_preserved(self) -> None: + source = textwrap.dedent( + """\ + def outer(x): + def inner(y): + return x + y + return inner + result = outer(3)(4) + """ + ) + ns: dict = {} + exec(_apply(source, rng=random.Random(0), params=_FAST), ns) + assert ns["result"] == 7 + + +class TestCrossReferences: + """Verify that cross-scope junk references work correctly.""" + + def test_cross_ref_code_executes_without_error(self) -> None: + # With cross_ref_probability=1.0 all junk vars reference earlier ones. + # The first injected var has no pool to draw from so is a literal; + # subsequent ones reference earlier junk. + params = InjectionParams( + stmts_per_scope_min=5, + stmts_per_scope_max=5, + cross_ref_probability=1.0, + ) + source = "x = 1\n" + ns: dict = {} + exec(_apply(source, rng=random.Random(0), params=params), ns) + + def test_zero_cross_ref_produces_only_literals(self) -> None: + params = InjectionParams( + stmts_per_scope_min=3, + stmts_per_scope_max=3, + cross_ref_probability=0.0, + ) + result = _apply("x = 1", rng=random.Random(0), params=params) + tree = ast.parse(result) + # All junk assignment values should be constants (no Name references) + junk_values = [ + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Assign) + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id != "x" + ] + for val in junk_values: + assert isinstance( + val, ast.Constant + ), f"Expected Constant, got {ast.dump(val)}" + + def test_inner_scope_intra_cross_ref_executes_without_error(self) -> None: + # Cross-refs within a function body's own local junk must work. + params = InjectionParams( + stmts_per_scope_min=5, + stmts_per_scope_max=5, + cross_ref_probability=1.0, + ) + source = textwrap.dedent( + """\ + def foo(): + return 42 + result = foo() + """ + ) + ns: dict = {} + exec(_apply(source, rng=random.Random(5), params=params), ns) + assert ns["result"] == 42 diff --git a/tests/techniques/test_exec_wrapper.py b/tests/techniques/test_exec_wrapper.py new file mode 100644 index 0000000..5ef92e3 --- /dev/null +++ b/tests/techniques/test_exec_wrapper.py @@ -0,0 +1,100 @@ +"""Tests for ExecWrapper.""" + +import ast +import textwrap + +from python_obfuscator.techniques.ast_transforms.exec_wrapper import ExecWrapper +from python_obfuscator.techniques.registry import all_technique_names + + +def _apply(source: str) -> str: + tree = ast.parse(source) + return ast.unparse(ExecWrapper().apply(tree)) + + +class TestExecWrapper: + def test_is_registered(self) -> None: + assert "exec_wrapper" in all_technique_names() + + def test_output_is_one_statement(self) -> None: + tree = ast.parse(_apply("x = 1\ny = 2")) + assert len(tree.body) == 1 + assert isinstance(tree.body[0], ast.Expr) + + def test_output_is_exec_call(self) -> None: + tree = ast.parse(_apply("x = 1")) + call = tree.body[0].value + assert isinstance(call, ast.Call) + assert isinstance(call.func, ast.Name) + assert call.func.id == "exec" + + def test_simple_source_executes_correctly(self) -> None: + source = "x = 10\ny = 20\nz = x + y\n" + ns: dict = {} + exec(_apply(source), ns) + assert ns["z"] == 30 + + def test_multiline_function_executes_correctly(self) -> None: + source = textwrap.dedent( + """\ + def add(a, b): + return a + b + result = add(3, 4) + """ + ) + ns: dict = {} + exec(_apply(source), ns) + assert ns["result"] == 7 + + def test_class_definition_executes_correctly(self) -> None: + source = textwrap.dedent( + """\ + class Counter: + def __init__(self): + self.value = 0 + def increment(self): + self.value += 1 + c = Counter() + c.increment() + c.increment() + """ + ) + ns: dict = {} + exec(_apply(source), ns) + assert ns["c"].value == 2 + + def test_string_with_quotes_executes_correctly(self) -> None: + source = "x = 'hello'\ny = \"world\"\n" + ns: dict = {} + exec(_apply(source), ns) + assert ns["x"] == "hello" + assert ns["y"] == "world" + + def test_string_with_backslash_executes_correctly(self) -> None: + source = "import re\npattern = re.compile('\\\\d+')\n" + ns: dict = {} + exec(_apply(source), ns) + + def test_empty_source_executes_correctly(self) -> None: + ns: dict = {} + exec(_apply(""), ns) + + def test_apply_returns_module(self) -> None: + tree = ast.parse("x = 1") + result = ExecWrapper().apply(tree) + assert isinstance(result, ast.Module) + + def test_apply_produces_compilable_tree(self) -> None: + tree = ast.parse("x = 1\ny = 2") + result = ExecWrapper().apply(tree) + compile(result, "", "exec") + + def test_inner_source_is_string_constant(self) -> None: + tree = ast.parse("x = 42") + result = ExecWrapper().apply(tree) + call = result.body[0].value + assert isinstance(call, ast.Call) + inner = call.args[0] + assert isinstance(inner, ast.Constant) + assert isinstance(inner.value, str) + assert "42" in inner.value diff --git a/tests/techniques/test_string_hex_encoder.py b/tests/techniques/test_string_hex_encoder.py new file mode 100644 index 0000000..4e34adf --- /dev/null +++ b/tests/techniques/test_string_hex_encoder.py @@ -0,0 +1,165 @@ +"""Tests for StringHexEncoder.""" + +import ast +import textwrap + +from python_obfuscator.techniques.ast_transforms.string_hex_encoder import ( + StringHexEncoder, + _str_to_hex_call, +) + + +def _apply(source: str) -> str: + tree = ast.parse(source) + result = StringHexEncoder().apply(tree) + return ast.unparse(result) + + +def _eval(source: str) -> object: + """Execute source and return the value of the last expression.""" + ns: dict = {} + exec(source, ns) + return ns + + +class TestStrToHexCall: + def test_produces_call_node(self) -> None: + node = _str_to_hex_call("hello") + assert isinstance(node, ast.Call) + + def test_result_evaluates_to_original_string(self) -> None: + node = _str_to_hex_call("hello") + source = ast.unparse(ast.fix_missing_locations(node)) + assert eval(source) == "hello" # noqa: S307 + + def test_empty_string(self) -> None: + node = _str_to_hex_call("") + source = ast.unparse(ast.fix_missing_locations(node)) + assert eval(source) == "" # noqa: S307 + + def test_unicode_string(self) -> None: + node = _str_to_hex_call("café") + source = ast.unparse(ast.fix_missing_locations(node)) + assert eval(source) == "café" # noqa: S307 + + def test_string_with_spaces(self) -> None: + node = _str_to_hex_call("hello world") + source = ast.unparse(ast.fix_missing_locations(node)) + assert eval(source) == "hello world" # noqa: S307 + + def test_string_with_quotes(self) -> None: + node = _str_to_hex_call('say "hi"') + source = ast.unparse(ast.fix_missing_locations(node)) + assert eval(source) == 'say "hi"' # noqa: S307 + + def test_hex_representation_is_visible_in_output(self) -> None: + node = _str_to_hex_call("hi") + source = ast.unparse(ast.fix_missing_locations(node)) + # 'hi' -> 68 69 + assert "6869" in source + + def test_uses_fromhex_decode_pattern(self) -> None: + node = _str_to_hex_call("x") + source = ast.unparse(ast.fix_missing_locations(node)) + assert "fromhex" in source + assert "decode" in source + + +class TestStringHexEncoderTransform: + def test_string_literal_is_replaced(self) -> None: + result = _apply('x = "hello"') + assert "hello" not in result + assert "fromhex" in result + + def test_replacement_evaluates_to_original(self) -> None: + source = 'x = "hello"' + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["x"] == "hello" + + def test_integer_constant_is_unchanged(self) -> None: + result = _apply("x = 42") + assert "42" in result + assert "fromhex" not in result + + def test_float_constant_is_unchanged(self) -> None: + result = _apply("x = 3.14") + assert "3.14" in result + assert "fromhex" not in result + + def test_bytes_constant_is_unchanged(self) -> None: + result = _apply("x = b'hello'") + assert "fromhex" not in result + + def test_none_constant_is_unchanged(self) -> None: + result = _apply("x = None") + assert "fromhex" not in result + + def test_bool_constant_is_unchanged(self) -> None: + result = _apply("x = True") + assert "fromhex" not in result + + def test_empty_string_is_replaced(self) -> None: + source = 'x = ""' + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["x"] == "" + + def test_unicode_string_is_replaced(self) -> None: + source = 'x = "café"' + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["x"] == "café" + + def test_multiple_strings_in_file(self) -> None: + source = textwrap.dedent( + """\ + a = "foo" + b = "bar" + """ + ) + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["a"] == "foo" + assert ns["b"] == "bar" + + def test_fstring_is_not_modified(self) -> None: + source = 'name = "world"\ngreeting = f"hello {name}"' + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["greeting"] == "hello world" + + def test_output_is_valid_python(self) -> None: + source = textwrap.dedent( + """\ + x = "test" + y = 42 + z = "another" + """ + ) + result = _apply(source) + ast.parse(result) # raises SyntaxError if invalid + + def test_nested_string_in_function(self) -> None: + source = textwrap.dedent( + """\ + def greet(): + return "hello" + """ + ) + result = _apply(source) + ns: dict = {} + exec(result, ns) + assert ns["greet"]() == "hello" + + def test_apply_calls_fix_missing_locations(self) -> None: + """apply() must produce a tree that compiles without LocationError.""" + source = 'x = "hello"' + tree = ast.parse(source) + new_tree = StringHexEncoder().apply(tree) + compile(new_tree, "", "exec") # should not raise diff --git a/tests/techniques/test_variable_renamer.py b/tests/techniques/test_variable_renamer.py new file mode 100644 index 0000000..66f84fa --- /dev/null +++ b/tests/techniques/test_variable_renamer.py @@ -0,0 +1,311 @@ +"""Tests for VariableRenamer.""" + +import ast +import textwrap + +from python_obfuscator.techniques.ast_transforms.variable_renamer import ( + VariableRenamer, + _NameCollector, +) + + +def _apply(source: str) -> str: + tree = ast.parse(source) + result = VariableRenamer().apply(tree) + return ast.unparse(result) + + +def _collect(source: str) -> frozenset[str]: + tree = ast.parse(source) + collector = _NameCollector() + collector.visit(tree) + return collector.renameable + + +class TestNameCollector: + def test_collects_simple_assignment(self) -> None: + assert "x" in _collect("x = 1") + + def test_does_not_collect_builtin_names(self) -> None: + renameable = _collect("print('hello')\nx = len([1, 2])") + assert "print" not in renameable + assert "len" not in renameable + + def test_does_not_collect_imported_name(self) -> None: + renameable = _collect("import os\npath = os.getcwd()") + assert "os" not in renameable + + def test_does_not_collect_from_import_name(self) -> None: + renameable = _collect("from pathlib import Path\np = Path('.')") + assert "Path" not in renameable + + def test_collects_alias_of_import(self) -> None: + renameable = _collect("import os as operating_system\nx = 1") + assert "operating_system" not in renameable + assert "x" in renameable + + def test_from_import_with_asname_excluded(self) -> None: + renameable = _collect("from os import path as p\nx = 1") + assert "p" not in renameable + + def test_star_import_does_not_crash(self) -> None: + renameable = _collect("from os.path import *\nx = 1") + assert "x" in renameable + + def test_does_not_collect_dunder_names(self) -> None: + renameable = _collect("__name__ = 'main'\nx = 1") + assert "__name__" not in renameable + assert "x" in renameable + + def test_collects_function_name(self) -> None: + assert "my_func" in _collect("def my_func(): pass") + + def test_collects_async_function_name(self) -> None: + assert "my_coro" in _collect("async def my_coro(): pass") + + def test_collects_class_name(self) -> None: + assert "MyClass" in _collect("class MyClass: pass") + + def test_collects_function_argument(self) -> None: + renameable = _collect("def foo(bar): pass") + assert "bar" in renameable + + def test_collects_for_loop_variable(self) -> None: + assert "item" in _collect("for item in range(10): pass") + + def test_does_not_collect_load_context_name(self) -> None: + # 'y' is only loaded, never stored + renameable = _collect("x = y + 1") + assert "y" not in renameable + + def test_does_not_collect_builtin_even_if_shadowed(self) -> None: + renameable = _collect("list = [1, 2, 3]") + assert "list" not in renameable + + +class TestVariableRenamer: + def test_renames_simple_variable(self) -> None: + result = _apply("my_variable = 42") + assert "my_variable" not in result + + def test_renamed_code_produces_same_value(self) -> None: + source = textwrap.dedent( + """\ + x = 10 + y = 20 + z = x + y + """ + ) + result = _apply(source) + ns: dict = {} + exec(result, ns) + values = {v for k, v in ns.items() if not k.startswith("__")} + assert 30 in values + + def test_builtin_names_not_renamed(self) -> None: + source = "result = len([1, 2, 3])" + result = _apply(source) + assert "len" in result + + def test_import_name_not_renamed(self) -> None: + source = "import os\npath = os.getcwd()" + result = _apply(source) + assert "os" in result + + def test_dunder_not_renamed(self) -> None: + source = "if __name__ == '__main__': pass" + result = _apply(source) + assert "__name__" in result + + def test_function_name_is_renamed(self) -> None: + source = "def compute(): return 42" + result = _apply(source) + assert "compute" not in result + + def test_renamed_function_still_callable(self) -> None: + source = textwrap.dedent( + """\ + def add(a, b): + return a + b + """ + ) + result = _apply(source) + ns: dict = {} + exec(result, ns) + funcs = [v for v in ns.values() if callable(v)] + assert len(funcs) == 1 + assert funcs[0](3, 4) == 7 + + def test_function_argument_is_renamed(self) -> None: + source = "def foo(my_arg): return my_arg" + result = _apply(source) + assert "my_arg" not in result + + def test_renamed_argument_still_works(self) -> None: + source = "def identity(value): return value" + result = _apply(source) + ns: dict = {} + exec(result, ns) + funcs = [v for v in ns.values() if callable(v)] + assert funcs[0](99) == 99 + + def test_class_name_is_renamed(self) -> None: + source = "class MyClass: pass" + result = _apply(source) + assert "MyClass" not in result + + def test_async_function_name_is_renamed(self) -> None: + source = "async def my_coro(): pass" + result = _apply(source) + assert "my_coro" not in result + + def test_same_name_renamed_consistently(self) -> None: + source = textwrap.dedent( + """\ + counter = 0 + counter = counter + 1 + result = counter + """ + ) + result = _apply(source) + ns: dict = {} + exec(result, ns) + values = {v for k, v in ns.items() if not k.startswith("__")} + assert 1 in values + + def test_output_is_valid_python(self) -> None: + source = textwrap.dedent( + """\ + def process(items): + total = 0 + for item in items: + total += item + return total + """ + ) + result = _apply(source) + ast.parse(result) + + def test_for_loop_variable_renamed(self) -> None: + source = textwrap.dedent( + """\ + total = 0 + for num in range(5): + total += num + """ + ) + result = _apply(source) + assert "num" not in result + ns: dict = {} + exec(result, ns) + values = {v for k, v in ns.items() if not k.startswith("__")} + assert 10 in values + + def test_apply_produces_compilable_tree(self) -> None: + source = "x = 1\ndef foo(a): return a" + tree = ast.parse(source) + new_tree = VariableRenamer().apply(tree) + compile(new_tree, "", "exec") + + def test_multiple_calls_independent(self) -> None: + """Two separate VariableRenamer instances do not share state.""" + source = "value = 5" + r1 = _apply(source) + r2 = _apply(source) + # Both are valid Python; they may or may not produce the same name + # (random), but neither should contain the original name. + assert "value" not in r1 + assert "value" not in r2 + + +class TestVariableRenamerFalseBranches: + """Cover the 'name NOT in rename_map' branches in each visit_* method.""" + + def _make_renamer_with_empty_map(self) -> VariableRenamer: + renamer = VariableRenamer() + renamer._rename_map = {} + return renamer + + def test_visit_name_unchanged_when_not_in_map(self) -> None: + renamer = self._make_renamer_with_empty_map() + node = ast.Name(id="foo", ctx=ast.Load()) + result = renamer.visit_Name(node) + assert result.id == "foo" + + def test_visit_function_def_unchanged_when_not_in_map(self) -> None: + renamer = self._make_renamer_with_empty_map() + func_node = ast.parse("def my_func(): pass").body[0] + assert isinstance(func_node, ast.FunctionDef) + result = renamer.visit_FunctionDef(func_node) + assert result.name == "my_func" + + def test_visit_async_function_def_unchanged_when_not_in_map(self) -> None: + renamer = self._make_renamer_with_empty_map() + func_node = ast.parse("async def my_coro(): pass").body[0] + assert isinstance(func_node, ast.AsyncFunctionDef) + result = renamer.visit_AsyncFunctionDef(func_node) + assert result.name == "my_coro" + + def test_visit_class_def_unchanged_when_not_in_map(self) -> None: + renamer = self._make_renamer_with_empty_map() + class_node = ast.parse("class MyClass: pass").body[0] + assert isinstance(class_node, ast.ClassDef) + result = renamer.visit_ClassDef(class_node) + assert result.name == "MyClass" + + def test_visit_arg_unchanged_when_not_in_map(self) -> None: + renamer = self._make_renamer_with_empty_map() + arg_node = ast.arg(arg="my_param") + result = renamer.visit_arg(arg_node) + assert result.arg == "my_param" + + def test_builtin_named_function_not_renamed_during_apply(self) -> None: + # 'list' is a builtin; when a function is defined with that name it + # must be excluded from the rename map so visit_FunctionDef takes the + # false branch in a real apply() call. + source = "def list(x): return x" + result = _apply(source) + assert "list" in result + + def test_visit_nonlocal_renames_known_names(self) -> None: + # If nonlocal is not updated when 'count' is renamed, Python raises + # SyntaxError: no binding for nonlocal 'count' found. + source = textwrap.dedent( + """\ + def outer(): + count = 0 + def inner(): + nonlocal count + count += 1 + return count + return inner + result = outer()() + """ + ) + ns: dict = {} + exec(_apply(source), ns) + # 'result' is renamed; locate the int value in the namespace. + result_val = next( + v for k, v in ns.items() if k != "__builtins__" and isinstance(v, int) + ) + assert result_val == 1 + + def test_visit_global_renames_known_names(self) -> None: + # If global is not updated when 'counter' is renamed, the renamed + # increment() will refer to a non-existent global name. + source = textwrap.dedent( + """\ + counter = 0 + def increment(): + global counter + counter += 1 + increment() + result = counter + """ + ) + ns: dict = {} + exec(_apply(source), ns) + result_val = next( + v for k, v in ns.items() if k != "__builtins__" and isinstance(v, int) + ) + assert result_val == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1827efe --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,74 @@ +import pytest + +from python_obfuscator.config import ObfuscationConfig +from python_obfuscator.techniques.registry import all_technique_names + + +class TestObfuscationConfig: + def test_all_enabled_contains_all_registered(self) -> None: + cfg = ObfuscationConfig.all_enabled() + assert cfg.enabled_techniques == all_technique_names() + + def test_all_enabled_is_non_empty(self) -> None: + cfg = ObfuscationConfig.all_enabled() + assert len(cfg.enabled_techniques) > 0 + + def test_only_restricts_to_given_names(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer") + assert cfg.enabled_techniques == frozenset({"variable_renamer"}) + + def test_only_multiple_names(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer", "string_hex_encoder") + assert cfg.enabled_techniques == frozenset( + {"variable_renamer", "string_hex_encoder"} + ) + + def test_only_zero_names_produces_empty(self) -> None: + cfg = ObfuscationConfig.only() + assert cfg.enabled_techniques == frozenset() + + def test_without_removes_named_technique(self) -> None: + cfg = ObfuscationConfig.all_enabled().without("variable_renamer") + assert "variable_renamer" not in cfg.enabled_techniques + + def test_without_keeps_other_techniques(self) -> None: + cfg = ObfuscationConfig.all_enabled().without("variable_renamer") + assert "string_hex_encoder" in cfg.enabled_techniques + + def test_without_unknown_name_is_a_noop(self) -> None: + cfg = ObfuscationConfig.all_enabled() + cfg2 = cfg.without("nonexistent_technique") + assert cfg2.enabled_techniques == cfg.enabled_techniques + + def test_without_multiple_names(self) -> None: + cfg = ObfuscationConfig.all_enabled().without( + "variable_renamer", "string_hex_encoder" + ) + assert "variable_renamer" not in cfg.enabled_techniques + assert "string_hex_encoder" not in cfg.enabled_techniques + + def test_with_added_adds_names(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer").with_added( + "string_hex_encoder" + ) + assert "variable_renamer" in cfg.enabled_techniques + assert "string_hex_encoder" in cfg.enabled_techniques + + def test_with_added_duplicate_is_idempotent(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer").with_added("variable_renamer") + assert cfg.enabled_techniques == frozenset({"variable_renamer"}) + + def test_config_is_immutable(self) -> None: + cfg = ObfuscationConfig.all_enabled() + with pytest.raises((AttributeError, TypeError)): + cfg.enabled_techniques = frozenset() # type: ignore[misc] + + def test_configs_are_equal_with_same_techniques(self) -> None: + a = ObfuscationConfig.only("variable_renamer") + b = ObfuscationConfig.only("variable_renamer") + assert a == b + + def test_configs_differ_with_different_techniques(self) -> None: + a = ObfuscationConfig.only("variable_renamer") + b = ObfuscationConfig.only("string_hex_encoder") + assert a != b diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..1a7d20e --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,78 @@ +"""Tests for helper utilities.""" + +import random + +from python_obfuscator.helpers.random_datatype import RandomDataTypeGenerator +from python_obfuscator.helpers.variable_name_generator import VariableNameGenerator + + +class TestRandomDataTypeGenerator: + def test_get_random_returns_str_or_int(self) -> None: + gen = RandomDataTypeGenerator() + for _ in range(20): + assert isinstance(gen.get_random(), (str, int)) + + def test_random_string_default_length(self) -> None: + gen = RandomDataTypeGenerator() + assert len(gen.random_string()) == 79 + + def test_random_string_custom_length(self) -> None: + gen = RandomDataTypeGenerator() + assert len(gen.random_string(length=10)) == 10 + + def test_random_int_in_valid_range(self) -> None: + gen = RandomDataTypeGenerator() + for _ in range(20): + val = gen.random_int() + assert isinstance(val, int) + assert 0 <= val <= 999 + + def test_seeded_rng_is_deterministic(self) -> None: + g1 = RandomDataTypeGenerator(rng=random.Random(42)) + g2 = RandomDataTypeGenerator(rng=random.Random(42)) + results1 = [g1.get_random() for _ in range(10)] + results2 = [g2.get_random() for _ in range(10)] + assert results1 == results2 + + def test_different_seeds_produce_different_sequences(self) -> None: + g1 = RandomDataTypeGenerator(rng=random.Random(1)) + g2 = RandomDataTypeGenerator(rng=random.Random(2)) + s1 = [g1.get_random() for _ in range(20)] + s2 = [g2.get_random() for _ in range(20)] + assert s1 != s2 + + +class TestVariableNameGenerator: + def test_get_random_returns_valid_identifier(self) -> None: + gen = VariableNameGenerator() + for i in range(1, 15): + name = gen.get_random(i) + assert name.isidentifier(), f"{name!r} is not a valid identifier" + + def test_seeded_rng_is_deterministic(self) -> None: + g1 = VariableNameGenerator(rng=random.Random(42)) + g2 = VariableNameGenerator(rng=random.Random(42)) + names1 = [g1.get_random(i) for i in range(1, 15)] + names2 = [g2.get_random(i) for i in range(1, 15)] + assert names1 == names2 + + def test_different_seeds_produce_different_names(self) -> None: + g1 = VariableNameGenerator(rng=random.Random(1)) + g2 = VariableNameGenerator(rng=random.Random(2)) + n1 = [g1.get_random(i) for i in range(1, 15)] + n2 = [g2.get_random(i) for i in range(1, 15)] + assert n1 != n2 + + def test_individual_generators_return_identifiers(self) -> None: + gen = VariableNameGenerator(rng=random.Random(0)) + for i in range(1, 10): + for method in ( + gen.random_string, + gen.l_and_i, + gen.time_based, + gen.just_id, + gen.scream, + gen.single_letter_a_lot, + ): + name = method(i) + assert name.isidentifier(), f"{method.__name__}({i}) = {name!r}" diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..54df503 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,677 @@ +"""End-to-end integration tests. + +Each test writes a non-trivial Python program, executes it with an empty +config (no transforms) to get the baseline output, then executes the fully +obfuscated version and asserts the output is identical. + +Output is captured by overriding ``print`` in the execution namespace. +This works even when ``exec_wrapper`` is active because the inner ``exec`` +call inherits the outer globals dict (which contains our fake print), and +``variable_renamer`` and ``dead_code_injector`` never touch the ``print`` +name (it is a builtin and therefore excluded from all transforms). +""" + +from __future__ import annotations + +import textwrap + +from python_obfuscator import ObfuscationConfig, obfuscate + + +def run(source: str, config: ObfuscationConfig | None = None) -> list[str]: + """Execute *source* and return every string passed to print(), in order.""" + captured: list[str] = [] + + def fake_print( + *args: object, sep: str = " ", end: str = "\n", **_: object + ) -> None: # noqa: ARG001 + captured.append(sep.join(str(a) for a in args)) + + cfg = config or ObfuscationConfig.all_enabled() + ns: dict = {"print": fake_print} + exec(obfuscate(source, config=cfg), ns) # noqa: S102 + return captured + + +_BASELINE = ObfuscationConfig.only() # no transforms — plain exec + + +def check(source: str) -> None: + """Assert that fully-obfuscated output matches unobfuscated output.""" + expected = run(source, config=_BASELINE) + actual = run(source) + assert ( + actual == expected + ), f"Obfuscated output differs.\nExpected: {expected}\nActual: {actual}" + + +# --------------------------------------------------------------------------- +# Individual technique passes (useful for isolating regressions) +# --------------------------------------------------------------------------- + + +class TestIndividualTechniques: + _SRC = textwrap.dedent( + """\ + x = 10 + y = 20 + print(x + y) + print(x * y) + """ + ) + + def test_string_hex_encoder_only(self) -> None: + cfg = ObfuscationConfig.only("string_hex_encoder") + assert run(self._SRC, config=_BASELINE) == run(self._SRC, config=cfg) + + def test_variable_renamer_only(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer") + assert run(self._SRC, config=_BASELINE) == run(self._SRC, config=cfg) + + def test_dead_code_injector_only(self) -> None: + cfg = ObfuscationConfig.only("dead_code_injector") + assert run(self._SRC, config=_BASELINE) == run(self._SRC, config=cfg) + + def test_exec_wrapper_only(self) -> None: + cfg = ObfuscationConfig.only("exec_wrapper") + assert run(self._SRC, config=_BASELINE) == run(self._SRC, config=cfg) + + +# --------------------------------------------------------------------------- +# Full-pipeline scenarios +# --------------------------------------------------------------------------- + + +class TestArithmetic: + def test_basic_arithmetic(self) -> None: + check( + textwrap.dedent( + """\ + a = 6 + b = 7 + print(a + b) + print(a * b) + print(a - b) + print(100 // a) + print(100 % b) + """ + ) + ) + + def test_float_arithmetic(self) -> None: + check( + textwrap.dedent( + """\ + x = 3.14 + y = 2.0 + print(round(x * y, 4)) + print(round(x / y, 4)) + """ + ) + ) + + def test_chained_operations(self) -> None: + check( + textwrap.dedent( + """\ + result = (1 + 2) * (3 + 4) - (5 * 6) + (7 ** 2) + print(result) + """ + ) + ) + + +class TestFunctions: + def test_simple_function(self) -> None: + check( + textwrap.dedent( + """\ + def add(a, b): + return a + b + print(add(3, 4)) + print(add(10, -3)) + """ + ) + ) + + def test_recursive_fibonacci(self) -> None: + check( + textwrap.dedent( + """\ + def fib(n): + if n <= 1: + return n + return fib(n - 1) + fib(n - 2) + for i in range(8): + print(fib(i)) + """ + ) + ) + + def test_default_arguments(self) -> None: + # Keyword-argument call sites use bare strings (ast.keyword.arg), not + # ast.Name nodes, so variable_renamer cannot update them when a parameter + # is renamed. Use positional arguments to keep the test self-consistent + # after renaming. + check( + textwrap.dedent( + """\ + def greet(name, prefix="Hello"): + return prefix + " " + name + print(greet("world")) + print(greet("Python", "Hi")) + """ + ) + ) + + def test_multiple_return_values(self) -> None: + check( + textwrap.dedent( + """\ + def minmax(seq): + return min(seq), max(seq) + lo, hi = minmax([3, 1, 4, 1, 5, 9, 2, 6]) + print(lo, hi) + """ + ) + ) + + def test_variadic_args(self) -> None: + check( + textwrap.dedent( + """\ + def total(*nums): + return sum(nums) + print(total(1, 2, 3, 4, 5)) + """ + ) + ) + + def test_higher_order_function(self) -> None: + check( + textwrap.dedent( + """\ + def apply(fn, values): + return [fn(v) for v in values] + result = apply(lambda x: x * x, [1, 2, 3, 4]) + for v in result: + print(v) + """ + ) + ) + + +class TestClosures: + def test_simple_closure(self) -> None: + check( + textwrap.dedent( + """\ + def make_adder(n): + def inner(x): + return x + n + return inner + add5 = make_adder(5) + print(add5(10)) + print(add5(0)) + """ + ) + ) + + def test_nonlocal(self) -> None: + check( + textwrap.dedent( + """\ + def counter(): + count = 0 + def inc(): + nonlocal count + count += 1 + return count + return inc + c = counter() + print(c()) + print(c()) + print(c()) + """ + ) + ) + + +class TestClasses: + def test_simple_class(self) -> None: + check( + textwrap.dedent( + """\ + class Point: + def __init__(self, x, y): + self.x = x + self.y = y + def distance(self): + return (self.x ** 2 + self.y ** 2) ** 0.5 + p = Point(3, 4) + print(p.distance()) + """ + ) + ) + + def test_inheritance(self) -> None: + check( + textwrap.dedent( + """\ + class Animal: + def __init__(self, name): + self.name = name + def speak(self): + return "..." + class Dog(Animal): + def speak(self): + return "Woof" + class Cat(Animal): + def speak(self): + return "Meow" + for animal in [Dog("Rex"), Cat("Whiskers")]: + print(animal.speak()) + """ + ) + ) + + def test_class_method_and_static(self) -> None: + check( + textwrap.dedent( + """\ + class MathHelper: + factor = 2 + @classmethod + def double(cls, n): + return n * cls.factor + @staticmethod + def square(n): + return n * n + print(MathHelper.double(5)) + print(MathHelper.square(4)) + """ + ) + ) + + +class TestControlFlow: + def test_for_loop(self) -> None: + check( + textwrap.dedent( + """\ + total = 0 + for i in range(1, 6): + total += i + print(total) + """ + ) + ) + + def test_while_loop(self) -> None: + check( + textwrap.dedent( + """\ + n = 1 + while n < 32: + n *= 2 + print(n) + """ + ) + ) + + def test_nested_for_loops(self) -> None: + check( + textwrap.dedent( + """\ + pairs = [] + for i in range(3): + for j in range(3): + if i != j: + pairs.append((i, j)) + print(len(pairs)) + """ + ) + ) + + def test_if_elif_else(self) -> None: + check( + textwrap.dedent( + """\ + def classify(n): + if n < 0: + return "negative" + elif n == 0: + return "zero" + else: + return "positive" + for v in [-5, 0, 7]: + print(classify(v)) + """ + ) + ) + + def test_break_and_continue(self) -> None: + check( + textwrap.dedent( + """\ + evens = [] + for i in range(20): + if i % 2 != 0: + continue + if i > 10: + break + evens.append(i) + print(evens) + """ + ) + ) + + +class TestDataStructures: + def test_list_comprehension(self) -> None: + check( + textwrap.dedent( + """\ + squares = [x * x for x in range(6)] + print(squares) + """ + ) + ) + + def test_dict_comprehension(self) -> None: + check( + textwrap.dedent( + """\ + word = "hello" + freq = {c: word.count(c) for c in set(word)} + for key in sorted(freq): + print(key, freq[key]) + """ + ) + ) + + def test_set_operations(self) -> None: + check( + textwrap.dedent( + """\ + a = {1, 2, 3, 4} + b = {3, 4, 5, 6} + print(sorted(a & b)) + print(sorted(a | b)) + print(sorted(a - b)) + """ + ) + ) + + def test_nested_list(self) -> None: + check( + textwrap.dedent( + """\ + matrix = [[i * j for j in range(1, 4)] for i in range(1, 4)] + for row in matrix: + print(row) + """ + ) + ) + + +class TestExceptionHandling: + def test_try_except(self) -> None: + check( + textwrap.dedent( + """\ + def safe_div(a, b): + try: + return a // b + except ZeroDivisionError: + return None + print(safe_div(10, 2)) + print(safe_div(10, 0)) + """ + ) + ) + + def test_try_except_finally(self) -> None: + check( + textwrap.dedent( + """\ + log = [] + try: + log.append("try") + x = 1 // 0 + except ZeroDivisionError: + log.append("except") + finally: + log.append("finally") + print(log) + """ + ) + ) + + def test_multiple_except_clauses(self) -> None: + check( + textwrap.dedent( + """\ + def parse_int(s): + try: + return int(s) + except (ValueError, TypeError): + return -1 + print(parse_int("42")) + print(parse_int("abc")) + print(parse_int(None)) + """ + ) + ) + + def test_try_else(self) -> None: + check( + textwrap.dedent( + """\ + results = [] + for val in ["10", "x", "5"]: + try: + n = int(val) + except ValueError: + results.append("bad") + else: + results.append(n * 2) + print(results) + """ + ) + ) + + +class TestGenerators: + def test_generator_function(self) -> None: + check( + textwrap.dedent( + """\ + def countdown(n): + while n > 0: + yield n + n -= 1 + print(list(countdown(5))) + """ + ) + ) + + def test_generator_expression(self) -> None: + check( + textwrap.dedent( + """\ + gen = (x * x for x in range(5)) + print(list(gen)) + """ + ) + ) + + +class TestStringOperations: + def test_string_formatting(self) -> None: + check( + textwrap.dedent( + """\ + name = "world" + n = 42 + print(f"Hello, {name}!") + print(f"The answer is {n}.") + """ + ) + ) + + def test_string_methods(self) -> None: + check( + textwrap.dedent( + """\ + s = "Hello, World!" + print(s.lower()) + print(s.upper()) + print(s.replace("World", "Python")) + print(s.split(", ")) + """ + ) + ) + + def test_multiline_string(self) -> None: + check( + textwrap.dedent( + """\ + text = "line one\\nline two\\nline three" + lines = text.split("\\n") + for line in lines: + print(line) + """ + ) + ) + + def test_string_with_quotes(self) -> None: + check( + textwrap.dedent( + """\ + s1 = "it's a test" + s2 = 'say "hello"' + print(s1) + print(s2) + """ + ) + ) + + +class TestComplexPrograms: + def test_bubble_sort(self) -> None: + check( + textwrap.dedent( + """\ + def bubble_sort(lst): + n = len(lst) + for i in range(n): + for j in range(n - i - 1): + if lst[j] > lst[j + 1]: + lst[j], lst[j + 1] = lst[j + 1], lst[j] + return lst + data = [64, 34, 25, 12, 22, 11, 90] + print(bubble_sort(data)) + """ + ) + ) + + def test_binary_search(self) -> None: + check( + textwrap.dedent( + """\ + def binary_search(lst, target): + lo, hi = 0, len(lst) - 1 + while lo <= hi: + mid = (lo + hi) // 2 + if lst[mid] == target: + return mid + elif lst[mid] < target: + lo = mid + 1 + else: + hi = mid - 1 + return -1 + arr = list(range(0, 20, 2)) + print(binary_search(arr, 8)) + print(binary_search(arr, 7)) + """ + ) + ) + + def test_class_with_dunder_methods(self) -> None: + check( + textwrap.dedent( + """\ + class Stack: + def __init__(self): + self._data = [] + def push(self, item): + self._data.append(item) + def pop(self): + return self._data.pop() + def __len__(self): + return len(self._data) + def __repr__(self): + return repr(self._data) + s = Stack() + s.push(1) + s.push(2) + s.push(3) + print(len(s)) + print(s.pop()) + print(len(s)) + """ + ) + ) + + def test_functional_pipeline(self) -> None: + check( + textwrap.dedent( + """\ + from functools import reduce + numbers = list(range(1, 11)) + evens = list(filter(lambda x: x % 2 == 0, numbers)) + doubled = list(map(lambda x: x * 2, evens)) + total = reduce(lambda a, b: a + b, doubled) + print(evens) + print(doubled) + print(total) + """ + ) + ) + + def test_deeply_nested_closures(self) -> None: + check( + textwrap.dedent( + """\ + def level1(a): + def level2(b): + def level3(c): + return a + b + c + return level3 + return level2 + fn = level1(1)(2) + print(fn(3)) + print(fn(10)) + """ + ) + ) + + def test_memoisation_with_dict(self) -> None: + check( + textwrap.dedent( + """\ + cache = {} + def fib(n): + if n in cache: + return cache[n] + if n <= 1: + cache[n] = n + else: + cache[n] = fib(n - 1) + fib(n - 2) + return cache[n] + for i in [0, 1, 5, 10, 15]: + print(fib(i)) + """ + ) + ) diff --git a/tests/test_module.py b/tests/test_module.py index aef2b8e..0768e9f 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -1,17 +1,34 @@ -import python_obfuscator - -# TODO: Improve tests -def test_module_methods(): - obfuscate = python_obfuscator.obfuscator() - - code = """ - v1 = 0 - v2 = 0 - v4 = 10 - assert v4 + v4 == 20 - assert v1 + v2 == 0 - """.replace( - " ", "" +"""Smoke test: obfuscated output must execute and preserve semantics.""" + +import textwrap + +from python_obfuscator import ObfuscationConfig, Obfuscator, obfuscate + + +def test_default_obfuscation_preserves_computation() -> None: + source = textwrap.dedent( + """\ + v1 = 0 + v2 = 0 + v4 = 10 + assert v4 + v4 == 20 + assert v1 + v2 == 0 + """ ) + result = obfuscate(source) + exec(result) # must not raise + + +def test_obfuscator_class_smoke() -> None: + o = Obfuscator() + result = o.obfuscate("x = 1 + 2") + assert isinstance(result, str) + - exec(obfuscate.obfuscate(code)) +def test_obfuscator_with_subset_config() -> None: + cfg = ObfuscationConfig.only("string_hex_encoder") + result = obfuscate('greeting = "hello"', config=cfg) + assert "fromhex" in result + ns: dict = {} + exec(result, ns) + assert ns["greeting"] == "hello" diff --git a/tests/test_obfuscator.py b/tests/test_obfuscator.py new file mode 100644 index 0000000..3a4b6d1 --- /dev/null +++ b/tests/test_obfuscator.py @@ -0,0 +1,77 @@ +import ast +import textwrap + +import pytest + +from python_obfuscator.config import ObfuscationConfig +from python_obfuscator.obfuscator import Obfuscator, _validate_config, obfuscate + +SIMPLE_SOURCE = textwrap.dedent( + """\ + x = 1 + y = 2 + result = x + y + """ +) + + +class TestValidateConfig: + def test_valid_config_does_not_raise(self) -> None: + _validate_config(ObfuscationConfig.all_enabled()) + + def test_unknown_technique_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="Unknown technique"): + _validate_config(ObfuscationConfig.only("does_not_exist")) + + def test_error_message_lists_available_techniques(self) -> None: + with pytest.raises(ValueError, match="Available:"): + _validate_config(ObfuscationConfig.only("bad_name")) + + +class TestObfuscateFunction: + def test_returns_string(self) -> None: + assert isinstance(obfuscate(SIMPLE_SOURCE), str) + + def test_none_config_uses_all_enabled(self) -> None: + assert isinstance(obfuscate(SIMPLE_SOURCE, config=None), str) + + def test_explicit_config_is_respected(self) -> None: + cfg = ObfuscationConfig.only("string_hex_encoder") + result = obfuscate('x = "hello"', config=cfg) + assert "fromhex" in result + + def test_empty_config_returns_unparsed_source(self) -> None: + result = obfuscate("x = 1 + 2\n", config=ObfuscationConfig.only()) + assert "x = 1 + 2" in result + + def test_unknown_technique_raises(self) -> None: + with pytest.raises(ValueError): + obfuscate(SIMPLE_SOURCE, config=ObfuscationConfig.only("bad")) + + def test_output_is_valid_python(self) -> None: + ast.parse(obfuscate(SIMPLE_SOURCE)) + + +class TestObfuscatorClass: + def test_instantiation_with_no_config(self) -> None: + o = Obfuscator() + assert o.config == ObfuscationConfig.all_enabled() + + def test_instantiation_with_explicit_config(self) -> None: + cfg = ObfuscationConfig.only("variable_renamer") + assert Obfuscator(cfg).config == cfg + + def test_instantiation_with_unknown_technique_raises(self) -> None: + with pytest.raises(ValueError): + Obfuscator(ObfuscationConfig.only("nonexistent")) + + def test_obfuscate_returns_string(self) -> None: + assert isinstance(Obfuscator().obfuscate(SIMPLE_SOURCE), str) + + def test_obfuscate_output_is_valid_python(self) -> None: + ast.parse(Obfuscator().obfuscate(SIMPLE_SOURCE)) + + def test_obfuscate_called_multiple_times_is_safe(self) -> None: + o = Obfuscator() + assert isinstance(o.obfuscate("x = 1"), str) + assert isinstance(o.obfuscate("y = 2"), str) diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..aafe6f2 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,71 @@ +import pytest + +from python_obfuscator.techniques.base import ASTTransform, TechniqueMetadata +from python_obfuscator.techniques.registry import ( + _REGISTRY, + all_technique_names, + get_transforms, + register, +) + + +class TestRegister: + def test_known_transforms_are_registered(self) -> None: + assert "variable_renamer" in _REGISTRY + assert "string_hex_encoder" in _REGISTRY + assert "dead_code_injector" in _REGISTRY + assert "exec_wrapper" in _REGISTRY + + def test_register_invalid_type_raises_type_error(self) -> None: + class NotATransform: + metadata = TechniqueMetadata(name="bad", description="bad", priority=0) + + with pytest.raises(TypeError, match="must be a subclass"): + register(NotATransform) # type: ignore[arg-type] + + def test_register_returns_class_unchanged(self) -> None: + class _Temp(ASTTransform): + metadata = TechniqueMetadata( + name="_temp_register_test", description="temp", priority=999 + ) + + result = register(_Temp) + assert result is _Temp + _REGISTRY.pop("_temp_register_test", None) + + +class TestAllTechniqueNames: + def test_returns_frozenset(self) -> None: + assert isinstance(all_technique_names(), frozenset) + + def test_includes_all_known_techniques(self) -> None: + names = all_technique_names() + for expected in ( + "variable_renamer", + "string_hex_encoder", + "dead_code_injector", + "exec_wrapper", + ): + assert expected in names + + def test_equals_registry_keys(self) -> None: + assert all_technique_names() == frozenset(_REGISTRY) + + +class TestGetTransforms: + def test_returns_only_enabled_techniques(self) -> None: + transforms = get_transforms(frozenset({"variable_renamer"})) + assert [cls.metadata.name for cls in transforms] == ["variable_renamer"] + + def test_empty_enabled_returns_empty(self) -> None: + assert get_transforms(frozenset()) == [] + + def test_unknown_name_is_silently_ignored(self) -> None: + assert get_transforms(frozenset({"nonexistent"})) == [] + + def test_sorted_by_priority_ascending(self) -> None: + transforms = get_transforms( + frozenset({"variable_renamer", "string_hex_encoder", "dead_code_injector"}) + ) + priorities = [cls.metadata.priority for cls in transforms] + assert priorities == sorted(priorities)