From 73cc6b489bafad14ae8a3c3f9db55066faf4a20d Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Fri, 31 Jul 2026 18:18:52 +0100 Subject: [PATCH 1/9] Update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca822c..62bd96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,13 @@ the minified code may only run with Python 3.12. - New transform to remove `if` statement branches that have no effect because the condition is always `True` or `False`. This is enabled by default and can be disabled with the `--no-remove-dead-branches` option. - Constant folding can now fold boolean (`and`/`or`) and comparison (`==`, `is`, `<`, ...) expressions with literal operands. +### Changed +- The remove pass, remove asserts, remove literal statements and combine imports transforms now also apply within `except` handler and `match` case bodies, where they were previously skipped. + ### Fixed - The remove debug transform no longer removes branches if they affect the program, even though they aren't executed, e.g. they contain `yield`, a `global`/`nonlocal` declaration, or the only binding of a local name. It also now properly inlines the else branch. +- Fixed producing invalid output for a `match` case guard that is a tuple, `yield`, or named expression, e.g. `case x if (1, 2):`. +- Constant folding no longer emits a `DeprecationWarning` when folding `~` on a boolean (e.g. `~True`) on Python 3.12 and later ## [3.2.0] - 2025-12-31 From 66bdd156c67f4b8df4c2867e94b218c97c058e25 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Fri, 31 Jul 2026 18:19:56 +0100 Subject: [PATCH 2/9] Upload corpus test summary as artefact It's not possible to get it out of the Job summary programmatically as far as I can tell. --- .github/workflows/test_corpus.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test_corpus.yaml b/.github/workflows/test_corpus.yaml index 7e95a4a..9ea9d83 100644 --- a/.github/workflows/test_corpus.yaml +++ b/.github/workflows/test_corpus.yaml @@ -155,3 +155,11 @@ jobs: with: config: '.config/corpus_report.markdownlint.yaml' globs: 'report.md' + + - name: Upload Report + if: ${{ always() }} + uses: actions/upload-artifact@v4.4.3 + with: + name: corpus-report + path: report.md + if-no-files-found: warn From 23c8a7f1484fd43aeb26aabcb68c351e792aa670 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Fri, 31 Jul 2026 18:20:51 +0100 Subject: [PATCH 3/9] Add gating jobs for workflows So we can easily require the whole workflow and matrixes are passing in branch protection --- .github/workflows/test.yaml | 12 ++++++++++++ .github/workflows/xtest.yaml | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0cabc6f..398b106 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -214,3 +214,15 @@ jobs: with: name: coverage-html-report path: htmlcov/ + + unit-tests-passed: + # Aggregate gate for branch protection. + name: Unit Tests Passed + runs-on: ubuntu-24.04 + needs: [test, test-windows, lint, lint_dockerfiles, coverage-report] + if: ${{ always() }} + steps: + - name: Verify all jobs succeeded + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: | + exit 1 diff --git a/.github/workflows/xtest.yaml b/.github/workflows/xtest.yaml index 02d50b9..e475857 100644 --- a/.github/workflows/xtest.yaml +++ b/.github/workflows/xtest.yaml @@ -83,3 +83,15 @@ jobs: with: ref: ${{ github.ref }} base-ref: ${{ github.base_ref }} + + regression-tests-passed: + # Aggregate gate for branch protection. + name: Regression Tests Passed + runs-on: ubuntu-24.04 + needs: [test, hypo_test, test-corpus] + if: ${{ always() }} + steps: + - name: Verify all jobs succeeded + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: | + exit 1 From 22b9a6039d08a20e64fd509cad1d856b47d44b57 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Fri, 31 Jul 2026 19:19:45 +0100 Subject: [PATCH 4/9] update pyyaml --- tox/python3.10-windows-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox/python3.10-windows-requirements.txt b/tox/python3.10-windows-requirements.txt index 77f7282..e0b3e6b 100644 --- a/tox/python3.10-windows-requirements.txt +++ b/tox/python3.10-windows-requirements.txt @@ -5,5 +5,5 @@ pluggy==0.13.1 py==1.11.0 pyperf==2.2.0 pytest==6.2.4 -PyYAML==5.4.1 +PyYAML==6.0.2 toml==0.10.2 From e39c943d3cfe98837c31fb27497e3e78f801d99b Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Sat, 1 Aug 2026 17:39:05 +0100 Subject: [PATCH 5/9] Add release test aggregate job for branch protection --- .github/workflows/release_test.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release_test.yaml b/.github/workflows/release_test.yaml index 36c177d..5565a31 100644 --- a/.github/workflows/release_test.yaml +++ b/.github/workflows/release_test.yaml @@ -41,3 +41,17 @@ jobs: contents: write with: release_version: ${{ needs.determine_version.outputs.release_version }} + + release-tests-passed: + # Aggregate gate for branch protection. + name: Release Tests Passed + runs-on: ubuntu-24.04 + needs: [determine_version, create_draft_release] + if: ${{ always() }} + steps: + - name: Verify all jobs succeeded + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: | + echo "A required job did not succeed:" + echo '${{ toJSON(needs) }}' + exit 1 From f30c054dc1caeb4b0e8b2cc91a0bfc9c1a90fd0d Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Sun, 2 Aug 2026 00:26:27 +0100 Subject: [PATCH 6/9] Update actions --- .github/workflows/create_draft_release.yaml | 28 ++++++++--------- .github/workflows/release.yaml | 5 +-- .github/workflows/release_test.yaml | 4 +-- .github/workflows/test.yaml | 35 +++++++++++---------- .github/workflows/test_corpus.yaml | 14 ++++----- .github/workflows/xtest.yaml | 4 +-- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/.github/workflows/create_draft_release.yaml b/.github/workflows/create_draft_release.yaml index f0acb74..a6d8347 100644 --- a/.github/workflows/create_draft_release.yaml +++ b/.github/workflows/create_draft_release.yaml @@ -29,7 +29,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -65,14 +65,14 @@ jobs: echo "wheel=$(find dist -name '*-py3-*.whl' -printf "%f\n")" >> "$GITHUB_OUTPUT" - name: Upload sdist artifact - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v7.0.1 with: name: dist-sdist path: dist/${{ steps.package.outputs.sdist }} if-no-files-found: error - name: Upload Python 3 wheel artifact - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v7.0.1 with: name: dist-py3-wheel path: dist/${{ steps.package.outputs.wheel }} @@ -88,7 +88,7 @@ jobs: image: danielflook/python-minifier-build:python2.7-2025-09-26 steps: - name: Download source distribution artifact - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v7.0.0 with: name: dist-sdist path: dist/ @@ -104,7 +104,7 @@ jobs: echo "wheel=$(find dist -name '*-py2-*.whl' -printf "%f\n")" >> "$GITHUB_OUTPUT" - name: Upload Python 2 wheel artifact - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v7.0.1 with: name: dist-py2-wheel path: dist/${{ steps.package.outputs.wheel }} @@ -119,7 +119,7 @@ jobs: permissions: contents: read steps: - - uses: actions/download-artifact@v4.1.8 + - uses: actions/download-artifact@v7.0.0 with: name: dist-sdist path: dist/ @@ -132,7 +132,7 @@ jobs: pyminify --version - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -144,7 +144,7 @@ jobs: sphinx-build docs/source /tmp/build - name: Upload documentation artifact - uses: actions/upload-pages-artifact@v3.0.1 + uses: actions/upload-pages-artifact@v5.0.0 with: path: /tmp/build @@ -161,14 +161,14 @@ jobs: package_type: [sdist, wheel] steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false persist-credentials: false - name: Download distribution artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v7.0.0 with: pattern: dist-* path: dist/ @@ -220,7 +220,7 @@ jobs: image: danielflook/python-minifier-build:python3.14-2025-09-26 steps: - name: Download distribution artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v7.0.0 with: pattern: dist-* path: dist/ @@ -238,7 +238,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -271,7 +271,7 @@ jobs: release_id: ${{ steps.create_release.outputs.release_id }} steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -307,7 +307,7 @@ jobs: echo "release_id=$untagged_id" >> "$GITHUB_OUTPUT" - name: Download distribution artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v7.0.0 with: pattern: dist-* path: dist/ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b66dc5..9a08f16 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -38,7 +38,7 @@ jobs: url: https://pypi.org/project/python-minifier/${{ inputs.version }} steps: - name: Download distribution artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v7.0.0 with: pattern: dist-* path: dist/ @@ -58,13 +58,14 @@ jobs: permissions: pages: write id-token: write + actions: read environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4.0.5 + uses: actions/deploy-pages@v5.0.0 publish_release: name: Publish Release diff --git a/.github/workflows/release_test.yaml b/.github/workflows/release_test.yaml index 5565a31..b6d2026 100644 --- a/.github/workflows/release_test.yaml +++ b/.github/workflows/release_test.yaml @@ -18,7 +18,7 @@ jobs: image: danielflook/python-minifier-build:python3.14-2025-09-26 steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 show-progress: false @@ -52,6 +52,4 @@ jobs: - name: Verify all jobs succeeded if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} run: | - echo "A required job did not succeed:" - echo '${{ toJSON(needs) }}' exit 1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 398b106..7870778 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,7 +16,7 @@ jobs: python: ["python2.7", "python3.3", "python3.4", "python3.5", "python3.6", "python3.7", "python3.8", "python3.9", "python3.10", "python3.11", "python3.12", "python3.13", "python3.14", "pypy", "pypy3"] steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -36,7 +36,7 @@ jobs: - name: Upload coverage if: ${{ matrix.python != 'python3.3' && matrix.python != 'python3.4' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-${{ matrix.python }} path: .coverage.* @@ -51,7 +51,7 @@ jobs: python-version: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -59,13 +59,13 @@ jobs: - name: Set up Python if: ${{ matrix.python-version != '2.7' && matrix.python-version != '3.14' }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Set up Python 3.14 if: ${{ matrix.python-version == '3.14' }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7.0.0 with: python-version: '3.14.0-rc.3' @@ -97,7 +97,7 @@ jobs: name: Linting steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -109,7 +109,7 @@ jobs: ./actionlint - name: Install the latest version of uv - uses: astral-sh/setup-uv@f94ec6bedd8674c4426838e6b50417d36b6ab231 # v5.3.1 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Actions Security Check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -117,21 +117,22 @@ jobs: uvx zizmor --format plain . - name: Lint CHANGELOG - uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20.0.0 + uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 with: config: '.config/changelog.markdownlint.yaml' globs: 'CHANGELOG.md' - name: Lint Other Markdown - uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20.0.0 + uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 with: config: '.config/.markdownlint.yaml' globs: | **/README.md - name: Lint Python code - uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 # v3.5.1 + uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: + version: 0.16.1 args: --config=.config/ruff.toml check lint_dockerfiles: @@ -152,14 +153,14 @@ jobs: - Dockerfile-fuzz steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false persist-credentials: false - name: Lint ${{ matrix.dockerfile }} - uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 + uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 with: dockerfile: ./docker/${{ matrix.dockerfile }} config: .config/hadolint.yaml @@ -170,20 +171,20 @@ jobs: needs: test steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false persist-credentials: false - name: Download coverage artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7.0.0 with: pattern: coverage-* merge-multiple: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7.0.0 with: python-version: '3.14' @@ -197,7 +198,7 @@ jobs: ls -la .coverage* - name: Upload combined coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-combined path: .coverage @@ -210,7 +211,7 @@ jobs: coverage html --rcfile=.config/.coveragerc - name: Upload HTML report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-html-report path: htmlcov/ diff --git a/.github/workflows/test_corpus.yaml b/.github/workflows/test_corpus.yaml index 9ea9d83..6a69298 100644 --- a/.github/workflows/test_corpus.yaml +++ b/.github/workflows/test_corpus.yaml @@ -51,7 +51,7 @@ jobs: run: rm -rf "${GITHUB_WORKSPACE:?}/*" - name: Checkout workflow ref - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -60,7 +60,7 @@ jobs: - name: Checkout ref id: checkout-ref - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 fetch-tags: true @@ -111,7 +111,7 @@ jobs: run: rm -rf "${GITHUB_WORKSPACE:?}/*" - name: Checkout workflow ref - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: path: workflow fetch-depth: 1 @@ -120,7 +120,7 @@ jobs: - name: Checkout ref id: ref - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: ref: ${{ inputs.ref }} path: python-minifier @@ -131,7 +131,7 @@ jobs: - name: Checkout base ref id: base-ref - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: ref: ${{ inputs.base-ref }} path: python-minifier-base @@ -150,7 +150,7 @@ jobs: python3.14 workflow/corpus_test/generate_report.py /corpus-results ${{ inputs.ref }} ${{ steps.ref.outputs.commit }} ${{ inputs.base-ref }} ${{ steps.base-ref.outputs.commit }} | tee -a $GITHUB_STEP_SUMMARY > report.md - name: Lint Report - uses: DavidAnson/markdownlint-cli2-action@05f32210e84442804257b2a6f20b273450ec8265 # v19 + uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 continue-on-error: true with: config: '.config/corpus_report.markdownlint.yaml' @@ -158,7 +158,7 @@ jobs: - name: Upload Report if: ${{ always() }} - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v7.0.1 with: name: corpus-report path: report.md diff --git a/.github/workflows/xtest.yaml b/.github/workflows/xtest.yaml index e475857..c6dbda7 100644 --- a/.github/workflows/xtest.yaml +++ b/.github/workflows/xtest.yaml @@ -19,7 +19,7 @@ jobs: run: rm -rf "${GITHUB_WORKSPACE:?}/*" - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false @@ -54,7 +54,7 @@ jobs: run: rm -rf "${GITHUB_WORKSPACE:?}/*" - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7.0.1 with: fetch-depth: 1 show-progress: false From ba7d55766faf47e755936753c309fe8fa6951300 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Sun, 2 Aug 2026 14:25:04 +0100 Subject: [PATCH 7/9] Update ruff --- .config/ruff.toml | 35 +++++++++++++++++++---- src/python_minifier/__main__.py | 1 - src/python_minifier/expression_printer.py | 4 +-- test/subprocess_compat.py | 2 +- test/test_is_constant_node.py | 4 +-- test/test_unicode_cli.py | 15 ++++------ 6 files changed, 39 insertions(+), 22 deletions(-) diff --git a/.config/ruff.toml b/.config/ruff.toml index a586089..cf9afa7 100644 --- a/.config/ruff.toml +++ b/.config/ruff.toml @@ -8,16 +8,21 @@ line-length = 200 exclude = ["docs"] +# corpus_test/generate_report.py only runs on the newest Python in CI, +# unlike generate_results.py which runs on every supported version +[per-file-target-version] +"corpus_test/generate_report.py" = "py314" + [lint] -# Enable most rules -select = [ +# The default ruleset is the starting point. +# extend-select adds rule groups that are not (fully) enabled by default. +# F (Pyflakes) and YTT (flake8-2020) were previously selected and are now fully enabled by default. +extend-select = [ "E", # pycodestyle errors "W", # pycodestyle warnings - "F", # Pyflakes "I", # isort "S", # flake8-bandit (security) "ERA", # eradicate (commented-out code) - "YTT", # flake8-2020 (sys.version checks) "FBT", # flake8-boolean-trap (boolean positional arguments) "A", # flake8-builtins (shadowing builtins) "COM", # flake8-commas (trailing commas) @@ -33,16 +38,28 @@ ignore = [ "S307", # Use of eval() - intentional and safe in this codebase (constant folding, validation) "FBT002", # Boolean default positional arguments - API design choice throughout codebase "COM812", # Missing trailing comma - we prohibit trailing commas instead + "UP", # pyupgrade suggests Python 3 only syntax - the codebase must remain Python 2.7 compatible + "BLE001", # Catching Exception is intentional - transforms and candidate generation fall back on any error + "SIM102", # Nested if statements separate node-type guards from attribute conditions - deliberate style + "SIM103", # Guard clause chains deliberately end with a constant return rather than returning the last condition + "SIM114", # Exhaustive branches per AST node type are deliberate, keeping dispatch readable and symmetric ] # Per-file ignores for specific compatibility needs [lint.per-file-ignores] -# AST compatibility module needs star imports and shadows Ellipsis builtin for Python version compatibility -"src/python_minifier/ast_compat.py" = ["F403", "F405", "A001"] +# AST compatibility module needs star imports, shadows the Ellipsis builtin for Python version +# compatibility, and forwards constructor arguments after a keyword argument in compat node classes +"src/python_minifier/ast_compat.py" = ["F403", "F405", "A001", "B026"] # __init__.py files intentionally re-export for public API "*/__init__.py" = ["F401"] +# The documented public API takes an options object as a default argument, evaluated once at import +"src/python_minifier/__init__.py" = ["B008"] + +# A shared sentinel instance as a default argument is intentional +"src/python_minifier/ast_annotation/__init__.py" = ["B008"] + # Type comparisons needed for Python 2.7 compatibility in specific files "src/python_minifier/ast_compare.py" = ["E721"] "src/python_minifier/rename/rename_literals.py" = ["E721"] @@ -51,6 +68,9 @@ ignore = [ # Compatibility imports in utility modules "src/python_minifier/rename/util.py" = ["F401"] +# AssertionError for unreachable AST node types is an internal invariant check, not caller type validation +"src/python_minifier/rename/binding.py" = ["TRY004"] + # Broad exception handling needed for candidate generation (try different quote styles) "src/python_minifier/f_string.py" = ["S112"] "src/python_minifier/t_string.py" = ["S112"] @@ -58,6 +78,9 @@ ignore = [ # random.choice() used for variable name generation, not cryptography "src/python_minifier/rename/name_generator.py" = ["S311"] +# Standalone CI script that deliberately logs through the root logger +"corpus_test/generate_results.py" = ["LOG015"] + # Test files need exec() to validate minified code behavior and subprocess calls for integration tests "test/**/*.py" = ["S102", "S603"] diff --git a/src/python_minifier/__main__.py b/src/python_minifier/__main__.py index 83cd2dd..bd52404 100644 --- a/src/python_minifier/__main__.py +++ b/src/python_minifier/__main__.py @@ -10,7 +10,6 @@ class MinificationNotBeneficialError(Exception): """Raised when minification results in larger output than the original.""" - pass def stdout_write_bytes(data): """Write bytes to stdout with proper Python 2.7/3.x compatibility.""" diff --git a/src/python_minifier/expression_printer.py b/src/python_minifier/expression_printer.py index c2221ff..7f39bc4 100644 --- a/src/python_minifier/expression_printer.py +++ b/src/python_minifier/expression_printer.py @@ -221,9 +221,7 @@ def visit_UnaryOp(self, node): right_precedence = self.precedence(node.operand) op_precedence = self.precedence(node) - if right_precedence != 0 and ( - (op_precedence > right_precedence) - ): + if right_precedence != 0 and op_precedence > right_precedence: self.printer.delimiter('(') self._expression(node.operand) self.printer.delimiter(')') diff --git a/test/subprocess_compat.py b/test/subprocess_compat.py index 1308fa2..162df45 100644 --- a/test/subprocess_compat.py +++ b/test/subprocess_compat.py @@ -9,7 +9,7 @@ def run_subprocess(cmd, timeout=None, input_data=None, env=None): # Python 3.5+ - encode string input to bytes for subprocess input_bytes = input_data.encode('utf-8') if isinstance(input_data, str) else input_data return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - input=input_bytes, timeout=timeout, env=env) + input=input_bytes, timeout=timeout, env=env, check=False) # Python 2.7, 3.3, 3.4 - no subprocess.run, no timeout support popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE if input_data else None, env=env) diff --git a/test/test_is_constant_node.py b/test/test_is_constant_node.py index d92b54c..d3dd6d6 100644 --- a/test/test_is_constant_node.py +++ b/test/test_is_constant_node.py @@ -62,7 +62,7 @@ def test_ast_compat_types_python314(): if sys.version_info < (3, 14): pytest.skip('ast_compat types test only for Python 3.14+') - import python_minifier.ast_compat as ast_compat + from python_minifier import ast_compat # Test that ast_compat provides the removed types assert is_constant_node(ast_compat.Str('a'), ast_compat.Str) @@ -80,7 +80,7 @@ def test_ast_compat_constant_nodes_python314(): if sys.version_info < (3, 14): pytest.skip('ast_compat constant test only for Python 3.14+') - import python_minifier.ast_compat as ast_compat + from python_minifier import ast_compat # Test that Constant nodes work with ast_compat types assert is_constant_node(ast.Constant('a'), ast_compat.Str) diff --git a/test/test_unicode_cli.py b/test/test_unicode_cli.py index 727ee4a..f3d822e 100644 --- a/test/test_unicode_cli.py +++ b/test/test_unicode_cli.py @@ -14,9 +14,8 @@ def test_cli_output_flag_with_unicode(): # Minimal source with all problematic Unicode characters from reported issues source_code = u'print(u"❌ ✓ 🐍 Привет © ∀")' - source_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) - source_file.write(source_code.encode('utf-8')) - source_file.close() + with tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) as source_file: + source_file.write(source_code.encode('utf-8')) output_path = source_file.name + '.min.py' @@ -79,9 +78,8 @@ def test_cli_in_place_with_unicode(): """ source_code = u'print(u"❌ ✓ 🐍 Привет © ∀")' - temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) - temp_file.write(source_code.encode('utf-8')) - temp_file.close() + with tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) as temp_file: + temp_file.write(source_code.encode('utf-8')) try: # Run pyminify with --in-place flag @@ -137,9 +135,8 @@ def test_cli_stdout_with_unicode(): """ source_code = u'print(u"❌ ✓ 🐍 Привет © ∀")' - temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) - temp_file.write(source_code.encode('utf-8')) - temp_file.close() + with tempfile.NamedTemporaryFile(mode='wb', suffix='.py', delete=False) as temp_file: + temp_file.write(source_code.encode('utf-8')) try: # Run without --output or --in-place (should output to stdout) From a45fe5dbdbec6cb84336daee997bd55ee7564dd2 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 3 Aug 2026 21:11:07 +0100 Subject: [PATCH 8/9] Reduce required stack frames It was easier to hit the recursion limit with long chains of if-elif. This makes that less of an issue. --- .../transforms/remove_dead_branches.py | 114 ++++++++---------- test/test_remove_dead_branches.py | 37 ++++++ 2 files changed, 85 insertions(+), 66 deletions(-) diff --git a/src/python_minifier/transforms/remove_dead_branches.py b/src/python_minifier/transforms/remove_dead_branches.py index a3f6f4e..5c5db44 100644 --- a/src/python_minifier/transforms/remove_dead_branches.py +++ b/src/python_minifier/transforms/remove_dead_branches.py @@ -186,44 +186,62 @@ def changes_semantics(self, namespace, candidate_suite): else: return properties != candidate_properties + def constant_test(self, node): + """ + The constant truth value of an if statement's test, or None + + RemoveDebug marks if statements whose __debug__ test it has resolved + with a resolved_test attribute. Otherwise only the True and False + constants are recognised - before python 3.4 they are reassignable + names, so nothing is recognised. + + :param node: The statement to examine + :type node: ast.AST + :rtype: bool or None + """ + if not isinstance(node, ast.If): + return None + + if hasattr(node, 'resolved_test'): + if node.resolved_test is True or node.resolved_test is False: + return node.resolved_test + return None + + if is_constant_node(node.test, ast.NameConstant) and isinstance(node.test.value, bool): + return node.test.value + + return None + def remove_false_branches(self, node_list): suite = [] for node in node_list: - if not isinstance(node, ast.If): - suite.append(self.visit(node)) - continue + condition = self.constant_test(node) - # This is an If statement + # A branch of a constant test can be removed, if that doesn't change + # the semantics of the program - if hasattr(node, 'resolved_test'): - condition = node.resolved_test - elif is_constant_node(node.test, ast.NameConstant) and isinstance(node.test.value, bool): - condition = node.test.value - else: - # The test is not a constant, so we can't remove this branch - suite.append(self.visit(node)) + if condition is True and not self.changes_semantics(node.namespace, node.orelse): + # The else branch is dead, keep only the body + self._removed_suites.append(node.orelse) + suite.extend(self.remove_false_branches(node.body)) continue - # We can possibly remove one of the branches, but we need to check if it changes the semantics of the program - - if condition is True: - if self.changes_semantics(node.namespace, node.orelse): - # Removing the else branch changes the semantics of the program, so we can't remove it - suite.append(self.visit(node)) - else: - # The else branch is dead, keep only the body - self._removed_suites.append(node.orelse) - suite.extend(self.remove_false_branches(node.body)) - - elif condition is False: - if self.changes_semantics(node.namespace, node.body): - # Removing the body branch changes the semantics of the program, so we can't remove it - suite.append(self.visit(node)) - else: - # The body is dead, keep only the else branch - self._removed_suites.append(node.body) - suite.extend(self.remove_false_branches(node.orelse)) + if condition is False and not self.changes_semantics(node.namespace, node.body): + # The body is dead, keep only the else branch + self._removed_suites.append(node.body) + suite.extend(self.remove_false_branches(node.orelse)) + continue + + node = self.visit(node) + + # An else suite that dead branch removal (or another transform) emptied + # or reduced to the 0 padding can be omitted. + orelse = getattr(node, 'orelse', None) + if orelse and self._is_empty_suite(orelse): + node.orelse = [] + + suite.append(node) return suite @@ -253,39 +271,3 @@ def _is_empty_suite(self, suite): if len(suite) == 0: return True return len(suite) == 1 and isinstance(suite[0], ast.Expr) and is_constant_node(suite[0].value, ast.Num) and suite[0].value.n == 0 - - def visit_If(self, node): - node = super(RemoveDeadBranches, self).visit_If(node) - - # Empty orelse suite can be omitted - if self._is_empty_suite(node.orelse): - node.orelse = [] - - return node - - def visit_While(self, node): - node = super(RemoveDeadBranches, self).visit_While(node) - - # Empty orelse suite can be omitted - if self._is_empty_suite(node.orelse): - node.orelse = [] - - return node - - def visit_For(self, node): - node = super(RemoveDeadBranches, self).visit_For(node) - - # Empty orelse suite can be omitted - if self._is_empty_suite(node.orelse): - node.orelse = [] - - return node - - def visit_Try(self, node): - node = super(RemoveDeadBranches, self).visit_Try(node) - - # Empty orelse suite can be omitted - if self._is_empty_suite(node.orelse): - node.orelse = [] - - return node diff --git a/test/test_remove_dead_branches.py b/test/test_remove_dead_branches.py index 6a9bafa..fd39d1d 100644 --- a/test/test_remove_dead_branches.py +++ b/test/test_remove_dead_branches.py @@ -531,6 +531,43 @@ def test_removes_from_match_case(): # endregion +# region deep nesting + +def stack_depth(): + depth = 0 + frame = sys._getframe() + while frame is not None: + depth += 1 + frame = frame.f_back + return depth + + +def test_deep_elif_chain(): + # Machine generated dispatch code can contain elif chains hundreds of levels + # deep. elif chains nest through the orelse suite without any indentation, so + # they are not capped by the tokenizer's 100 level indentation limit and the + # transform must not use more stack frames per level than other transforms. + # The transform gets a fixed stack budget so the test doesn't depend on how + # deep the test runner's own stack already is. + source = 'if x0:\n a()\n' + ''.join('elif x%d:\n a()\n' % i for i in range(1, 150)) + + limit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(stack_depth() + 700) + run_test(source, source) + finally: + sys.setrecursionlimit(limit) + + +def test_deep_constant_elif_chain(): + # A chain of dead elif branches is spliced level by level, which must not + # exhaust the stack either + skip_if_no_nameconstant() + source = 'if False:\n a()\n' + ''.join('elif False:\n a()\n' for _ in range(1, 150)) + run_test(source, '') + +# endregion + # region orelse cleanup def test_drops_emptied_if_else(): From 0928d936f89c5a75baec56bdb2ef90ea4e4dc2b4 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 3 Aug 2026 21:11:52 +0100 Subject: [PATCH 9/9] Fix corpus test report markdown lint findings --- corpus_test/generate_report.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/corpus_test/generate_report.py b/corpus_test/generate_report.py index 89ce84d..dfb48f2 100644 --- a/corpus_test/generate_report.py +++ b/corpus_test/generate_report.py @@ -215,7 +215,7 @@ def report_larger_than_original(results_dir: str, python_versions: list[str], mi ## Larger than original | Corpus Entry | Original Size | Minified Size | -|--------------|--------------:|--------------:|''' +| ------------ | ------------: | ------------: |''' for python_version in python_versions: try: @@ -234,7 +234,7 @@ def report_unstable(results_dir: str, python_versions: list[str], minifier_sha: ## Unstable | Corpus Entry | Python Version | Original Size | -|--------------|----------------|--------------:|''' +| ------------ | -------------- | ------------: |''' for python_version in python_versions: try: @@ -253,7 +253,7 @@ def report_exceptions(results_dir: str, python_versions: list[str], minifier_sha ## Exceptions | Corpus Entry | Python Version | Exception | -|--------------|----------------|-----------|''' +| ------------ | -------------- | --------- |''' exceptions_found = False @@ -278,7 +278,7 @@ def report_larger_than_base(results_dir: str, python_versions: list[str], minifi ## Top 10 Larger than base | Corpus Entry | Python Version | Original Size | Minified Size | -|--------------|----------------|--------------:|--------------:|''' +| ------------ | -------------- | ------------: | ------------: |''' there_are_some_larger_than_base = False @@ -308,7 +308,7 @@ def report_slowest(results_dir: str, python_versions: list[str], minifier_sha: s ## Top 10 Slowest | Corpus Entry | Original Size | Minified Size | Time | -|--------------|--------------:|--------------:|-----:|''' +| ------------ | ------------: | ------------: | ---: |''' for python_version in python_versions: try: @@ -371,7 +371,7 @@ def report(results_dir: str, minifier_ref: str, minifier_sha: str, base_ref: str ## Summary | Python Version | Valid Corpus Entries | Average Time | Minified Size | Larger than original | Recursion Error | Unstable Minification | Exception | -|----------------|---------------------:|-------------:|--------------:|---------------------:|----------------:|----------------------:|----------:|''' +| -------------- | -------------------: | -----------: | ------------: | -------------------: | --------------: | --------------------: | --------: |''' for python_version in ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']: try: