diff --git a/.config/ruff.toml b/.config/ruff.toml index a5860890..cf9afa76 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/.github/workflows/create_draft_release.yaml b/.github/workflows/create_draft_release.yaml index f0acb74f..a6d83472 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 7b66dc5a..9a08f169 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 36c177dd..b6d20268 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 @@ -41,3 +41,15 @@ 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: | + exit 1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0cabc6fc..7870778f 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,19 @@ 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/ + + 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/test_corpus.yaml b/.github/workflows/test_corpus.yaml index 7e95a4ae..6a692985 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,8 +150,16 @@ 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' globs: 'report.md' + + - name: Upload Report + if: ${{ always() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: corpus-report + path: report.md + if-no-files-found: warn diff --git a/.github/workflows/xtest.yaml b/.github/workflows/xtest.yaml index 02d50b9b..c6dbda76 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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eca822c2..62bd96ee 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 diff --git a/corpus_test/generate_report.py b/corpus_test/generate_report.py index 89ce84da..dfb48f26 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: diff --git a/src/python_minifier/__main__.py b/src/python_minifier/__main__.py index 83cd2dd7..bd524047 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 c2221ffa..7f39bc44 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/src/python_minifier/transforms/remove_dead_branches.py b/src/python_minifier/transforms/remove_dead_branches.py index a3f6f4e0..5c5db444 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/subprocess_compat.py b/test/subprocess_compat.py index 1308fa2b..162df450 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 d92b54c8..d3dd6d60 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_remove_dead_branches.py b/test/test_remove_dead_branches.py index 6a9bafa5..fd39d1d5 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(): diff --git a/test/test_unicode_cli.py b/test/test_unicode_cli.py index 727ee4a2..f3d822eb 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) diff --git a/tox/python3.10-windows-requirements.txt b/tox/python3.10-windows-requirements.txt index 77f7282f..e0b3e6b1 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