From e86b3a680048b4ad9a26b4b073a3dec045742e9b Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 15 Dec 2025 10:10:36 +0000 Subject: [PATCH 1/5] Move tox requirements into separate files --- tox/pypy-requirements.txt | 24 ++++++++++++++++++++++++ tox/pypy3-requirements.txt | 18 ++++++++++++++++++ tox/python27-requirements.txt | 21 +++++++++++++++++++++ tox/python310-requirements.txt | 12 ++++++++++++ tox/python311-requirements.txt | 12 ++++++++++++ tox/python312-requirements.txt | 11 +++++++++++ tox/python313-requirements.txt | 11 +++++++++++ tox/python314-requirements.txt | 10 ++++++++++ tox/python33-requirements.txt | 6 ++++++ tox/python34-requirements.txt | 17 +++++++++++++++++ tox/python35-requirements.txt | 15 +++++++++++++++ tox/python36-requirements.txt | 15 +++++++++++++++ tox/python37-requirements.txt | 15 +++++++++++++++ tox/python38-requirements.txt | 12 ++++++++++++ tox/python39-requirements.txt | 10 ++++++++++ 15 files changed, 209 insertions(+) create mode 100644 tox/pypy-requirements.txt create mode 100644 tox/pypy3-requirements.txt create mode 100644 tox/python27-requirements.txt create mode 100644 tox/python310-requirements.txt create mode 100644 tox/python311-requirements.txt create mode 100644 tox/python312-requirements.txt create mode 100644 tox/python313-requirements.txt create mode 100644 tox/python314-requirements.txt create mode 100644 tox/python33-requirements.txt create mode 100644 tox/python34-requirements.txt create mode 100644 tox/python35-requirements.txt create mode 100644 tox/python36-requirements.txt create mode 100644 tox/python37-requirements.txt create mode 100644 tox/python38-requirements.txt create mode 100644 tox/python39-requirements.txt diff --git a/tox/pypy-requirements.txt b/tox/pypy-requirements.txt new file mode 100644 index 00000000..ae530c47 --- /dev/null +++ b/tox/pypy-requirements.txt @@ -0,0 +1,24 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +backports.functools-lru-cache==1.6.6 +cffi==1.12.0 +configparser==4.0.2 +contextlib2==0.6.0.post1 +coverage==5.5 +funcsigs==1.0.2 +greenlet==0.4.13 +importlib-metadata==2.1.3 +more-itertools==5.0.0 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +readline==6.2.4.1 +scandir==1.10.0 +sh==1.12.14 +six==1.16.0 +typing==3.10.0.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/pypy3-requirements.txt b/tox/pypy3-requirements.txt new file mode 100644 index 00000000..ec412718 --- /dev/null +++ b/tox/pypy3-requirements.txt @@ -0,0 +1,18 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +cffi==1.12.0 +coverage==5.5 +greenlet==0.4.13 +importlib-metadata==2.1.3 +more-itertools==8.14.0 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +readline==6.2.4.1 +sh==1.12.14 +six==1.16.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/python27-requirements.txt b/tox/python27-requirements.txt new file mode 100644 index 00000000..d7271862 --- /dev/null +++ b/tox/python27-requirements.txt @@ -0,0 +1,21 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +backports.functools-lru-cache==1.6.6 +configparser==4.0.2 +contextlib2==0.6.0.post1 +coverage==5.5 +funcsigs==1.0.2 +importlib-metadata==2.1.3 +more-itertools==5.0.0 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +scandir==1.10.0 +sh==1.12.14 +six==1.16.0 +typing==3.10.0.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/python310-requirements.txt b/tox/python310-requirements.txt new file mode 100644 index 00000000..5fd6e08e --- /dev/null +++ b/tox/python310-requirements.txt @@ -0,0 +1,12 @@ +attrs==24.2.0 +coverage==5.5 +iniconfig==2.0.0 +packaging==24.1 +pluggy==0.13.1 +py==1.11.0 +pyperf==2.2.0 +pytest==6.2.4 +pytest-cov==2.5.1 +PyYAML==5.4.1 +sh==1.14.2 +toml==0.10.2 diff --git a/tox/python311-requirements.txt b/tox/python311-requirements.txt new file mode 100644 index 00000000..6fff5d3c --- /dev/null +++ b/tox/python311-requirements.txt @@ -0,0 +1,12 @@ +attrs==24.2.0 +coverage==7.6.10 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +py==1.11.0 +pyperf==2.4.1 +pytest==7.1.2 +pytest-cov==5.0.0 +PyYAML==6.0 +sh==1.14.3 +tomli==2.0.1 diff --git a/tox/python312-requirements.txt b/tox/python312-requirements.txt new file mode 100644 index 00000000..506ed9a7 --- /dev/null +++ b/tox/python312-requirements.txt @@ -0,0 +1,11 @@ +coverage==7.6.10 +iniconfig==2.0.0 +packaging==24.1 +pip==24.2 +pluggy==1.5.0 +psutil==6.0.0 +pyperf==2.6.1 +pytest==7.4.2 +pytest-cov==5.0.0 +PyYAML==6.0.1 +sh==2.0.6 diff --git a/tox/python313-requirements.txt b/tox/python313-requirements.txt new file mode 100644 index 00000000..e6da316b --- /dev/null +++ b/tox/python313-requirements.txt @@ -0,0 +1,11 @@ +coverage==7.6.10 +iniconfig==2.0.0 +packaging==24.1 +pip==24.2 +pluggy==1.5.0 +psutil==6.0.0 +pyperf==2.7.0 +pytest==8.3.3 +pytest-cov==5.0.0 +PyYAML==6.0.2 +sh==2.0.7 diff --git a/tox/python314-requirements.txt b/tox/python314-requirements.txt new file mode 100644 index 00000000..c348dabc --- /dev/null +++ b/tox/python314-requirements.txt @@ -0,0 +1,10 @@ +coverage==7.6.10 +iniconfig==2.1.0 +packaging==25.0 +pip==25.2 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.1 +pytest-cov==5.0.0 +PyYAML==6.0.2 +sh==2.2.2 diff --git a/tox/python33-requirements.txt b/tox/python33-requirements.txt new file mode 100644 index 00000000..125be90b --- /dev/null +++ b/tox/python33-requirements.txt @@ -0,0 +1,6 @@ +coverage==4.5.4 +py==1.4.34 +pytest==3.2.5 +pytest-cov==2.5.1 +PyYAML==3.13 +sh==1.12.14 diff --git a/tox/python34-requirements.txt b/tox/python34-requirements.txt new file mode 100644 index 00000000..e58a927a --- /dev/null +++ b/tox/python34-requirements.txt @@ -0,0 +1,17 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +coverage==4.5.4 +importlib-metadata==1.1.3 +more-itertools==7.2.0 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.10.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +scandir==1.10.0 +sh==1.12.14 +six==1.16.0 +typing==3.10.0.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/python35-requirements.txt b/tox/python35-requirements.txt new file mode 100644 index 00000000..185f37c7 --- /dev/null +++ b/tox/python35-requirements.txt @@ -0,0 +1,15 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +coverage==5.5 +importlib-metadata==2.1.3 +more-itertools==8.14.0 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +sh==1.12.14 +six==1.16.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/python36-requirements.txt b/tox/python36-requirements.txt new file mode 100644 index 00000000..70a8d96f --- /dev/null +++ b/tox/python36-requirements.txt @@ -0,0 +1,15 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +coverage==5.5 +importlib-metadata==4.8.3 +more-itertools==8.14.0 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +sh==1.12.14 +six==1.16.0 +typing-extensions==4.1.1 +wcwidth==0.2.13 +zipp==3.6.0 diff --git a/tox/python37-requirements.txt b/tox/python37-requirements.txt new file mode 100644 index 00000000..96b04c93 --- /dev/null +++ b/tox/python37-requirements.txt @@ -0,0 +1,15 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +coverage==5.5 +importlib-metadata==6.7.0 +more-itertools==9.1.0 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +sh==1.12.14 +six==1.16.0 +typing-extensions==4.7.1 +wcwidth==0.2.13 +zipp==3.15.0 diff --git a/tox/python38-requirements.txt b/tox/python38-requirements.txt new file mode 100644 index 00000000..f14b38ab --- /dev/null +++ b/tox/python38-requirements.txt @@ -0,0 +1,12 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +coverage==5.5 +more-itertools==10.5.0 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +pytest-cov==2.5.1 +PyYAML==5.1 +sh==1.12.14 +six==1.16.0 +wcwidth==0.2.13 diff --git a/tox/python39-requirements.txt b/tox/python39-requirements.txt new file mode 100644 index 00000000..b6130800 --- /dev/null +++ b/tox/python39-requirements.txt @@ -0,0 +1,10 @@ +coverage==5.5 +exceptiongroup==1.2.2 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +pytest==8.3.3 +pytest-cov==2.5.1 +PyYAML==6.0.2 +sh==2.0.7 +tomli==2.0.1 From f73f8e38b97d140f99299e2a52e982c9f7d04cac Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 15 Dec 2025 10:56:47 +0000 Subject: [PATCH 2/5] Collect unit test coverage --- .config/.coveragerc | 16 ++ .config/.coveragerc-legacy | 14 ++ .github/workflows/test.yaml | 59 +++++++ .gitignore | 1 + tox-windows.ini | 119 +------------ tox.ini | 212 ++---------------------- tox/python2.7-windows-requirements.txt | 21 +++ tox/python3.10-windows-requirements.txt | 9 + tox/python3.11-windows-requirements.txt | 9 + tox/python3.12-windows-requirements.txt | 7 + tox/python3.13-windows-requirements.txt | 7 + tox/python3.14-windows-requirements.txt | 8 + tox/python3.6-windows-requirements.txt | 14 ++ tox/python3.7-windows-requirements.txt | 14 ++ tox/python3.8-windows-requirements.txt | 10 ++ tox/python3.9-windows-requirements.txt | 7 + 16 files changed, 215 insertions(+), 312 deletions(-) create mode 100644 .config/.coveragerc create mode 100644 .config/.coveragerc-legacy create mode 100644 tox/python2.7-windows-requirements.txt create mode 100644 tox/python3.10-windows-requirements.txt create mode 100644 tox/python3.11-windows-requirements.txt create mode 100644 tox/python3.12-windows-requirements.txt create mode 100644 tox/python3.13-windows-requirements.txt create mode 100644 tox/python3.14-windows-requirements.txt create mode 100644 tox/python3.6-windows-requirements.txt create mode 100644 tox/python3.7-windows-requirements.txt create mode 100644 tox/python3.8-windows-requirements.txt create mode 100644 tox/python3.9-windows-requirements.txt diff --git a/.config/.coveragerc b/.config/.coveragerc new file mode 100644 index 00000000..862346a6 --- /dev/null +++ b/.config/.coveragerc @@ -0,0 +1,16 @@ +[run] +branch = true +parallel = true +relative_files = true +context = ${COVERAGE_CONTEXT} + +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError + if TYPE_CHECKING: + +[paths] +source = + src/python_minifier + */site-packages/python_minifier diff --git a/.config/.coveragerc-legacy b/.config/.coveragerc-legacy new file mode 100644 index 00000000..751feae5 --- /dev/null +++ b/.config/.coveragerc-legacy @@ -0,0 +1,14 @@ +[run] +branch = true +parallel = true + +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError + if TYPE_CHECKING: + +[paths] +source = + src/python_minifier + */site-packages/python_minifier diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4e7c549a..19b6b598 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,14 @@ jobs: run: | tox -r -e $(echo "${{ matrix.python }}" | tr -d .) + - name: Upload coverage + if: ${{ matrix.python != 'python3.3' && matrix.python != 'python3.4' }} + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python }} + path: .coverage.* + include-hidden-files: true + test-windows: name: Test Windows runs-on: windows-2025 @@ -155,3 +163,54 @@ jobs: with: dockerfile: ./docker/${{ matrix.dockerfile }} config: .config/hadolint.yaml + + coverage-report: + name: Coverage Report + runs-on: ubuntu-24.04 + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 1 + show-progress: false + persist-credentials: false + + - name: Download coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install coverage + run: pip install coverage + + - name: Combine coverage + run: | + ls -la .coverage* + coverage combine --rcfile=.config/.coveragerc + ls -la .coverage* + + - name: Upload combined coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-combined + path: .coverage + include-hidden-files: true + + - name: Generate report + run: | + coverage report --rcfile=.config/.coveragerc --format=markdown 2>&1 | tee coverage-report.md + cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" + coverage html --rcfile=.config/.coveragerc + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: coverage-html-report + path: htmlcov/ diff --git a/.gitignore b/.gitignore index 4a7e4622..4bece855 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ docs/source/transforms/*.min.py .circleci-config.yml .coverage .mypy_cache/ +.tox/ diff --git a/tox-windows.ini b/tox-windows.ini index 8db14a1d..d88bc927 100644 --- a/tox-windows.ini +++ b/tox-windows.ini @@ -1,154 +1,39 @@ [tox] -envlist = 3.8,3.9,3.10,3.11,3.12,3.13,3.14 +envlist = 2.7,3.6,3.7,3.8,3.9,3.10,3.11,3.12,3.13,3.14 [testenv] +deps = -r{toxinidir}/tox/python{envname}-windows-requirements.txt commands = pytest {posargs:test} --junitxml=junit-python{envname}.xml --verbose [testenv:2.7] basepython = python2.7 -deps = - atomicwrites==1.4.1 - attrs==21.4.0 - backports.functools-lru-cache==1.6.6 - colorama==0.4.6 - configparser==4.0.2 - contextlib2==0.6.0.post1 - funcsigs==1.0.2 - importlib-metadata==2.1.3 - more-itertools==5.0.0 - packaging==20.9 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.11.0 - pyparsing==2.4.7 - pytest==4.6.11 - PyYAML==5.4.1 - scandir==1.10.0 - six==1.17.0 - typing==3.10.0.0 - wcwidth==0.2.13 - zipp==1.2.0 [testenv:3.6] basepython = python3.6 -deps = - atomicwrites==1.4.1 - attrs==22.2.0 - colorama==0.4.5 - importlib-metadata==4.8.3 - iniconfig==1.1.1 - packaging==21.3 - pluggy==1.0.0 - py==1.11.0 - pyparsing==3.1.4 - pytest==7.0.1 - PyYAML==6.0.1 - tomli==1.2.3 - typing_extensions==4.1.1 - zipp==3.6.0 [testenv:3.7] basepython = python3.7 -deps = - colorama==0.4.6 - exceptiongroup==1.3.0 - importlib-metadata==6.7.0 - iniconfig==2.0.0 - packaging==24.0 - pip==24.0 - pluggy==1.2.0 - pytest==7.4.4 - PyYAML==6.0.1 - setuptools==68.0.0 - tomli==2.0.1 - typing_extensions==4.7.1 - wheel==0.42.0 - zipp==3.15.0 [testenv:3.8] basepython = python3.8 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - more-itertools==10.5.0 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - setuptools==45.3.0 - six==1.16.0 - wcwidth==0.2.13 [testenv:3.9] basepython = python3.9 -deps = - exceptiongroup==1.2.2 - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - pytest==8.3.3 - PyYAML==6.0.2 - tomli==2.0.1 [testenv:3.10] basepython = python3.10 setenv = PIP_CONSTRAINT={toxinidir}/tox/pyyaml-5.4.1-constraints.txt -deps = - attrs==24.2.0 - iniconfig==2.0.0 - packaging==24.1 - pluggy==0.13.1 - py==1.11.0 - pyperf==2.2.0 - pytest==6.2.4 - PyYAML==5.4.1 - toml==0.10.2 [testenv:3.11] basepython = python3.11 -deps = - attrs==24.2.0 - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - py==1.11.0 - pyperf==2.4.1 - pytest==7.1.2 - PyYAML==6.0 - tomli==2.0.1 [testenv:3.12] basepython = python3.12 -deps = - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - psutil==6.0.0 - pyperf==2.6.1 - pytest==7.4.2 - PyYAML==6.0.1 [testenv:3.13] basepython = python3.13 -deps = - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - psutil==6.0.0 - pyperf==2.7.0 - pytest==8.3.3 - PyYAML==6.0.2 [testenv:3.14] basepython = python3.14 -deps = - colorama==0.4.6 - iniconfig==2.1.0 - packaging==25.0 - pip==25.2 - pluggy==1.6.0 - Pygments==2.19.2 - pytest==8.4.1 - PyYAML==6.0.2 diff --git a/tox.ini b/tox.ini index 25ef8ea0..7c5ae9bd 100644 --- a/tox.ini +++ b/tox.ini @@ -4,246 +4,68 @@ envlist = python27,python33,python34,python35,python36,python37,python38,python3 [testenv] setenv = PYTHONHASHSEED = 0 + COVERAGE_FILE = {toxinidir}/.coverage.{envname} + COVERAGE_CONTEXT = {envname} +deps = -r{toxinidir}/tox/{envname}-requirements.txt commands = - pytest {posargs:test} --junitxml=junit-{envname}.xml --verbose + pytest {posargs:test --cov=python_minifier --cov-config={toxinidir}/.config/.coveragerc} --junitxml=junit-{envname}.xml --verbose [testenv:python27] basepython = /usr/bin/python2.7 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - backports.functools-lru-cache==1.6.6 - configparser==4.0.2 - contextlib2==0.6.0.post1 - funcsigs==1.0.2 - importlib-metadata==2.1.3 - more-itertools==5.0.0 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - scandir==1.10.0 - sh==1.12.14 - six==1.16.0 - typing==3.10.0.0 - wcwidth==0.2.13 - zipp==1.2.0 [testenv:python33] basepython = /usr/bin/python3.3 -deps = - py==1.4.34 - pytest==3.2.5 - PyYAML==3.13 - sh==1.12.14 +setenv = + PYTHONHASHSEED = 0 + COVERAGE_FILE = {toxinidir}/.coverage.{envname} +commands = + pytest {posargs:test --cov=python_minifier --cov-config={toxinidir}/.config/.coveragerc-legacy} --junitxml=junit-{envname}.xml --verbose [testenv:python34] basepython = /usr/bin/python3.4 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - importlib-metadata==1.1.3 - more-itertools==7.2.0 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.10.0 - pytest==4.5.0 - PyYAML==5.1 - scandir==1.10.0 - sh==1.12.14 - six==1.16.0 - typing==3.10.0.0 - wcwidth==0.2.13 - zipp==1.2.0 +setenv = + PYTHONHASHSEED = 0 + COVERAGE_FILE = {toxinidir}/.coverage.{envname} +commands = + pytest {posargs:test --cov=python_minifier --cov-config={toxinidir}/.config/.coveragerc-legacy} --junitxml=junit-{envname}.xml --verbose [testenv:python35] basepython = /usr/bin/python3.5 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - importlib-metadata==2.1.3 - more-itertools==8.14.0 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - sh==1.12.14 - six==1.16.0 - wcwidth==0.2.13 - zipp==1.2.0 [testenv:python36] basepython = /usr/bin/python3.6 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - importlib-metadata==4.8.3 - more-itertools==8.14.0 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - sh==1.12.14 - six==1.16.0 - typing-extensions==4.1.1 - wcwidth==0.2.13 - zipp==3.6.0 [testenv:python37] basepython = /usr/bin/python3.7 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - importlib-metadata==6.7.0 - more-itertools==9.1.0 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - sh==1.12.14 - six==1.16.0 - typing-extensions==4.7.1 - wcwidth==0.2.13 - zipp==3.15.0 [testenv:python38] basepython = /usr/bin/python3.8 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - more-itertools==10.5.0 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - sh==1.12.14 - six==1.16.0 - wcwidth==0.2.13 [testenv:python39] basepython = /usr/local/bin/python3.9 -deps = - exceptiongroup==1.2.2 - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - pytest==8.3.3 - PyYAML==6.0.2 - sh==2.0.7 - tomli==2.0.1 [testenv:python310] basepython = /usr/local/bin/python3.10 setenv = + PYTHONHASHSEED = 0 + COVERAGE_FILE = {toxinidir}/.coverage.{envname} + COVERAGE_CONTEXT = {envname} PIP_CONSTRAINT={toxinidir}/tox/pyyaml-5.4.1-constraints.txt -deps = - attrs==24.2.0 - iniconfig==2.0.0 - packaging==24.1 - pluggy==0.13.1 - py==1.11.0 - pyperf==2.2.0 - pytest==6.2.4 - PyYAML==5.4.1 - sh==1.14.2 - toml==0.10.2 [testenv:python311] basepython = /usr/local/bin/python3.11 -deps = - attrs==24.2.0 - iniconfig==2.0.0 - packaging==24.1 - pluggy==1.5.0 - py==1.11.0 - pyperf==2.4.1 - pytest==7.1.2 - PyYAML==6.0 - sh==1.14.3 - tomli==2.0.1 [testenv:python312] basepython = /usr/local/bin/python3.12 -deps = - iniconfig==2.0.0 - packaging==24.1 - pip==24.2 - pluggy==1.5.0 - psutil==6.0.0 - pyperf==2.6.1 - pytest==7.4.2 - PyYAML==6.0.1 - sh==2.0.6 [testenv:python313] basepython = /usr/local/bin/python3.13 -deps = - iniconfig==2.0.0 - packaging==24.1 - pip==24.2 - pluggy==1.5.0 - psutil==6.0.0 - pyperf==2.7.0 - pytest==8.3.3 - PyYAML==6.0.2 - sh==2.0.7 [testenv:python314] basepython = /usr/local/bin/python3.14 -deps = - iniconfig==2.1.0 - packaging==25.0 - pip==25.2 - pluggy==1.6.0 - Pygments==2.19.2 - pytest==8.4.1 - PyYAML==6.0.2 - sh==2.2.2 [testenv:pypy] basepython = /usr/bin/pypy -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - backports.functools-lru-cache==1.6.6 - cffi==1.12.0 - configparser==4.0.2 - contextlib2==0.6.0.post1 - funcsigs==1.0.2 - greenlet==0.4.13 - importlib-metadata==2.1.3 - more-itertools==5.0.0 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - readline==6.2.4.1 - scandir==1.10.0 - sh==1.12.14 - six==1.16.0 - typing==3.10.0.0 - wcwidth==0.2.13 - zipp==1.2.0 [testenv:pypy3] basepython = /usr/bin/pypy3 -deps = - atomicwrites==1.4.1 - attrs==20.3.0 - cffi==1.12.0 - greenlet==0.4.13 - importlib-metadata==2.1.3 - more-itertools==8.14.0 - pathlib2==2.3.7.post1 - pluggy==0.13.1 - py==1.11.0 - pytest==4.5.0 - PyYAML==5.1 - readline==6.2.4.1 - sh==1.12.14 - six==1.16.0 - wcwidth==0.2.13 - zipp==1.2.0 diff --git a/tox/python2.7-windows-requirements.txt b/tox/python2.7-windows-requirements.txt new file mode 100644 index 00000000..1ae2fbaf --- /dev/null +++ b/tox/python2.7-windows-requirements.txt @@ -0,0 +1,21 @@ +atomicwrites==1.4.1 +attrs==21.4.0 +backports.functools-lru-cache==1.6.6 +colorama==0.4.6 +configparser==4.0.2 +contextlib2==0.6.0.post1 +funcsigs==1.0.2 +importlib-metadata==2.1.3 +more-itertools==5.0.0 +packaging==20.9 +pathlib2==2.3.7.post1 +pluggy==0.13.1 +py==1.11.0 +pyparsing==2.4.7 +pytest==4.6.11 +PyYAML==5.4.1 +scandir==1.10.0 +six==1.17.0 +typing==3.10.0.0 +wcwidth==0.2.13 +zipp==1.2.0 diff --git a/tox/python3.10-windows-requirements.txt b/tox/python3.10-windows-requirements.txt new file mode 100644 index 00000000..77f7282f --- /dev/null +++ b/tox/python3.10-windows-requirements.txt @@ -0,0 +1,9 @@ +attrs==24.2.0 +iniconfig==2.0.0 +packaging==24.1 +pluggy==0.13.1 +py==1.11.0 +pyperf==2.2.0 +pytest==6.2.4 +PyYAML==5.4.1 +toml==0.10.2 diff --git a/tox/python3.11-windows-requirements.txt b/tox/python3.11-windows-requirements.txt new file mode 100644 index 00000000..51914fca --- /dev/null +++ b/tox/python3.11-windows-requirements.txt @@ -0,0 +1,9 @@ +attrs==24.2.0 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +py==1.11.0 +pyperf==2.4.1 +pytest==7.1.2 +PyYAML==6.0 +tomli==2.0.1 diff --git a/tox/python3.12-windows-requirements.txt b/tox/python3.12-windows-requirements.txt new file mode 100644 index 00000000..6b7603ab --- /dev/null +++ b/tox/python3.12-windows-requirements.txt @@ -0,0 +1,7 @@ +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +psutil==6.0.0 +pyperf==2.6.1 +pytest==7.4.2 +PyYAML==6.0.1 diff --git a/tox/python3.13-windows-requirements.txt b/tox/python3.13-windows-requirements.txt new file mode 100644 index 00000000..3d040b3a --- /dev/null +++ b/tox/python3.13-windows-requirements.txt @@ -0,0 +1,7 @@ +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +psutil==6.0.0 +pyperf==2.7.0 +pytest==8.3.3 +PyYAML==6.0.2 diff --git a/tox/python3.14-windows-requirements.txt b/tox/python3.14-windows-requirements.txt new file mode 100644 index 00000000..86ce01ad --- /dev/null +++ b/tox/python3.14-windows-requirements.txt @@ -0,0 +1,8 @@ +colorama==0.4.6 +iniconfig==2.1.0 +packaging==25.0 +pip==25.2 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.1 +PyYAML==6.0.2 diff --git a/tox/python3.6-windows-requirements.txt b/tox/python3.6-windows-requirements.txt new file mode 100644 index 00000000..4fc3230f --- /dev/null +++ b/tox/python3.6-windows-requirements.txt @@ -0,0 +1,14 @@ +atomicwrites==1.4.1 +attrs==22.2.0 +colorama==0.4.5 +importlib-metadata==4.8.3 +iniconfig==1.1.1 +packaging==21.3 +pluggy==1.0.0 +py==1.11.0 +pyparsing==3.1.4 +pytest==7.0.1 +PyYAML==6.0.1 +tomli==1.2.3 +typing_extensions==4.1.1 +zipp==3.6.0 diff --git a/tox/python3.7-windows-requirements.txt b/tox/python3.7-windows-requirements.txt new file mode 100644 index 00000000..389e1754 --- /dev/null +++ b/tox/python3.7-windows-requirements.txt @@ -0,0 +1,14 @@ +colorama==0.4.6 +exceptiongroup==1.3.0 +importlib-metadata==6.7.0 +iniconfig==2.0.0 +packaging==24.0 +pip==24.0 +pluggy==1.2.0 +pytest==7.4.4 +PyYAML==6.0.1 +setuptools==68.0.0 +tomli==2.0.1 +typing_extensions==4.7.1 +wheel==0.42.0 +zipp==3.15.0 diff --git a/tox/python3.8-windows-requirements.txt b/tox/python3.8-windows-requirements.txt new file mode 100644 index 00000000..e993dc50 --- /dev/null +++ b/tox/python3.8-windows-requirements.txt @@ -0,0 +1,10 @@ +atomicwrites==1.4.1 +attrs==20.3.0 +more-itertools==10.5.0 +pluggy==0.13.1 +py==1.11.0 +pytest==4.5.0 +PyYAML==5.1 +setuptools==45.3.0 +six==1.16.0 +wcwidth==0.2.13 diff --git a/tox/python3.9-windows-requirements.txt b/tox/python3.9-windows-requirements.txt new file mode 100644 index 00000000..86a24669 --- /dev/null +++ b/tox/python3.9-windows-requirements.txt @@ -0,0 +1,7 @@ +exceptiongroup==1.2.2 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +pytest==8.3.3 +PyYAML==6.0.2 +tomli==2.0.1 From 32d585aa88197b1ba4c216509ac5e7f64d3e4061 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Sun, 21 Dec 2025 17:06:46 +0000 Subject: [PATCH 3/5] Correctly fold UnaryOp nodes --- .../transforms/constant_folding.py | 81 +++-- test/test_folding.py | 304 +++++++++++++++++- 2 files changed, 360 insertions(+), 25 deletions(-) diff --git a/src/python_minifier/transforms/constant_folding.py b/src/python_minifier/transforms/constant_folding.py index ec041eb2..99ae678a 100644 --- a/src/python_minifier/transforms/constant_folding.py +++ b/src/python_minifier/transforms/constant_folding.py @@ -10,36 +10,35 @@ from python_minifier.util import is_constant_node -class FoldConstants(SuiteTransformer): - """ - Fold Constants if it would reduce the size of the source +def is_foldable_constant(node): """ + Check if a node is a constant expression that can participate in folding. - def __init__(self): - super(FoldConstants, self).__init__() + We can asume that children have already been folded, so foldable constants are either: + - Simple literals (Num, NameConstant) + - UnaryOp(USub/Invert) on a Num - these don't fold to shorter forms, + so they remain after child visiting. UAdd and Not would have been + folded away since they always produce shorter results. + """ + if is_constant_node(node, (ast.Num, ast.NameConstant)): + return True - def visit_BinOp(self, node): + if isinstance(node, ast.UnaryOp): + if isinstance(node.op, (ast.USub, ast.Invert)): + return is_constant_node(node.operand, ast.Num) - node.left = self.visit(node.left) - node.right = self.visit(node.right) + return False - # Check this is a constant expression that could be folded - # We don't try to fold strings or bytes, since they have probably been arranged this way to make the source shorter and we are unlikely to beat that - if not is_constant_node(node.left, (ast.Num, ast.NameConstant)): - return node - if not is_constant_node(node.right, (ast.Num, ast.NameConstant)): - return node - if isinstance(node.op, ast.Div): - # Folding div is subtle, since it can have different results in Python 2 and Python 3 - # Do this once target version options have been implemented - return node +class FoldConstants(SuiteTransformer): + """ + Fold Constants if it would reduce the size of the source + """ - if isinstance(node.op, ast.Pow): - # This can be folded, but it is unlikely to reduce the size of the source - # It can also be slow to evaluate - return node + def __init__(self): + super(FoldConstants, self).__init__() + def fold(self, node): # Evaluate the expression try: original_expression = unparse_expression(node) @@ -96,6 +95,44 @@ def visit_BinOp(self, node): # New representation is shorter and has the same value, so use it return self.add_child(new_node, get_parent(node), node.namespace) + def visit_BinOp(self, node): + + node.left = self.visit(node.left) + node.right = self.visit(node.right) + + # Check this is a constant expression that could be folded + # We don't try to fold strings or bytes, since they have probably been arranged this way to make the source shorter and we are unlikely to beat that + if not is_foldable_constant(node.left): + return node + if not is_foldable_constant(node.right): + return node + + if isinstance(node.op, ast.Div): + # Folding div is subtle, since it can have different results in Python 2 and Python 3 + # Do this once target version options have been implemented + return node + + if isinstance(node.op, ast.Pow): + # This can be folded, but it is unlikely to reduce the size of the source + # It can also be slow to evaluate + return node + + return self.fold(node) + + def visit_UnaryOp(self, node): + + node.operand = self.visit(node.operand) + + # Only fold if the operand is a foldable constant + if not is_foldable_constant(node.operand): + return node + + # Only fold these unary operators + if not isinstance(node.op, (ast.USub, ast.UAdd, ast.Invert, ast.Not)): + return node + + return self.fold(node) + def equal_value_and_type(a, b): if type(a) != type(b): diff --git a/test/test_folding.py b/test/test_folding.py index 7a8af44c..ccedfc30 100644 --- a/test/test_folding.py +++ b/test/test_folding.py @@ -3,11 +3,11 @@ import pytest +from python_minifier import minify from python_minifier.ast_annotation import add_parent from python_minifier.ast_compare import compare_ast from python_minifier.rename import add_namespace -from python_minifier.transforms.constant_folding import FoldConstants - +from python_minifier.transforms.constant_folding import FoldConstants, equal_value_and_type def fold_constants(source): module = ast.parse(source) @@ -106,7 +106,9 @@ def test_bool(source, expected): ('0xf0|0x0f', '0xff'), ('10%2', '0'), ('10%3', '1'), - ('10-100', '-90') + ('10-100', '-90'), + ('1+1', '2'), + ('2+2', '4'), ] ) def test_int(source, expected): @@ -149,3 +151,299 @@ def test_not_eval(source, expected): """ run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + # Folding results in infinity, which can be represented as 1e999 + ('1e308 + 1e308', '1e999'), + ('1e308 * 2', '1e999'), + ] +) +def test_fold_infinity(source, expected): + """ + Test that expressions resulting in infinity are folded to 1e999. + + Infinity can be represented as 1e999, which is shorter than + the original expression. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + # Folding would result in NaN, which cannot be represented as a literal + ('1e999 - 1e999', '1e999 - 1e999'), + ('0.0 * 1e999', '0.0 * 1e999'), + ] +) +def test_no_fold_nan(source, expected): + """ + Test that expressions resulting in NaN are not folded. + + NaN is not a valid Python literal, so we cannot fold expressions + that would produce it. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('100.0+100.0', '200.0'), + ('1000.0+1000.0', '2000.0'), + ] +) +def test_fold_float(source, expected): + """ + Test that float expressions are folded when the result is shorter. + """ + run_test(source, expected) + + +def test_equal_value_and_type(): + """ + Test the equal_value_and_type helper function. + """ + + # Same type and value + assert equal_value_and_type(1, 1) is True + assert equal_value_and_type(1.0, 1.0) is True + assert equal_value_and_type(True, True) is True # noqa: FBT003 + assert equal_value_and_type('hello', 'hello') is True + + # Different types + assert equal_value_and_type(1, 1.0) is False + assert equal_value_and_type(1, True) is False # noqa: FBT003 + assert equal_value_and_type(True, 1) is False # noqa: FBT003 + + # Different values + assert equal_value_and_type(1, 2) is False + assert equal_value_and_type(1.0, 2.0) is False + + +def test_equal_value_and_type_nan(): + """ + Test the equal_value_and_type helper function with NaN values. + """ + + nan = float('nan') + + # NaN is not equal to itself in Python (nan != nan is True) + # But if both are NaN, equal_value_and_type returns True via a == b + # Since nan == nan is False, we need to check the actual behavior + result = equal_value_and_type(nan, nan) + # Python's nan == nan is False, so this should be False + assert result is False + + # NaN compared to non-NaN should be False + assert equal_value_and_type(nan, 1.0) is False + assert equal_value_and_type(1.0, nan) is False + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('5 - 10', '-5'), + ('0 - 100', '-100'), + ('1.0 - 2.0', '-1.0'), + ('0.0 - 100.0', '-100.0'), + ] +) +def test_negative_results(source, expected): + """ + Test BinOp expressions that produce negative results. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('5 * -2', '-10'), + ('-5 * 2', '-10'), + ('-5 + 10', '5'), + ('-90 + 10', '-80'), + ('10 - 20 + 5', '-5'), + ('(5 - 10) * 2', '-10'), + ('2 * (0 - 5)', '-10'), + ('(1 - 10) + (2 - 20)', '-27'), + ] +) +def test_negative_operands_folded(source, expected): + """ + Test that expressions with negative operands are folded. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('-(-5)', '5'), + ('--5', '5'), + ('-(-100)', '100'), + ('-(-(5 + 5))', '10'), + ('~(~0)', '0'), + ('~~5', '5'), + ('~~100', '100'), + ('+(+5)', '5'), + ('+(-5)', '-5'), + ] +) +def test_unary_folded(source, expected): + """ + Test that unary operations on constant expressions are folded. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('not not True', 'True'), + ('not not False', 'False'), + ('not True', 'False'), + ('not False', 'True'), + ] +) +def test_unary_not_folded(source, expected): + """ + Test that 'not' operations on constant expressions are folded. + """ + if sys.version_info < (3, 4): + pytest.skip('NameConstant not in python < 3.4') + + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('-5', '-5'), + ('~5', '~5'), + ] +) +def test_unary_simple_not_folded(source, expected): + """ + Test that simple unary operations on literals are not folded + when the result would not be shorter. + """ + run_test(source, expected) + + +def test_unary_plus_folded(): + """ + Test that unary plus on a literal is folded to remove the plus. + """ + run_test('+5', '5') + + +def test_not_false_in_conditional(): + """ + Test that 'not False' is folded to 'True' in a conditional. + """ + if sys.version_info < (3, 4): + pytest.skip('NameConstant not in python < 3.4') + + run_test('if not False:pass', 'if True:pass') + + +def test_not_not_true_in_assignment(): + """ + Test that 'not not True' is folded to 'True' in an assignment. + """ + if sys.version_info < (3, 4): + pytest.skip('NameConstant not in python < 3.4') + + run_test('x=not not True', 'x=True') + + +def test_bool_not_folded_before_34(): + """ + Test that boolean 'not' expressions are not folded in Python < 3.4. + + NameConstant was introduced in Python 3.4, so we cannot fold boolean + constants in earlier versions. + """ + if sys.version_info >= (3, 4): + pytest.skip('Only applies to python < 3.4') + + run_test('if not False:pass', 'if not False:pass') + run_test('x=not not True', 'x=not not True') + + +def test_constant_folding_enabled_by_default(): + """Verify constant folding is enabled by default.""" + source = 'x = 10 + 10' + result = minify(source) + assert '20' in result + assert '10+10' not in result and '10 + 10' not in result # noqa: PT018 + + +def test_constant_folding_disabled(): + """Verify expressions are not folded when constant_folding=False.""" + source = 'x = 10 + 10' + result = minify(source, constant_folding=False) + assert '10+10' in result or '10 + 10' in result + assert result.strip() != 'x=20' + + +def test_constant_folding_disabled_complex_expression(): + """Verify complex expressions are preserved when disabled.""" + source = 'SECONDS_IN_A_DAY = 60 * 60 * 24' + result = minify(source, constant_folding=False) + assert '60*60*24' in result or '60 * 60 * 24' in result + + +def test_constant_folding_enabled_complex_expression(): + """Verify complex expressions are folded when enabled.""" + source = 'SECONDS_IN_A_DAY = 60 * 60 * 24' + result = minify(source, constant_folding=True) + assert '86400' in result + + +@pytest.mark.parametrize( + ('source', 'should_contain_when_disabled'), [ + ('x = 5 - 10', '5-10'), + ('x = True | False', 'True|False'), + ('x = 0xff ^ 0x0f', '255^15'), + ] +) +def test_constant_folding_disabled_various_ops(source, should_contain_when_disabled): + """Verify various operations are not folded when disabled.""" + if sys.version_info < (3, 4) and 'True' in source: + pytest.skip('NameConstant not in python < 3.4') + + result = minify(source, constant_folding=False) + assert should_contain_when_disabled in result.replace(' ', '') + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('1j + 2j', '3j'), + ('3j * 2', '6j'), + ('2 * 3j', '6j'), + ('10j - 5j', '5j'), + ] +) +def test_complex_folded(source, expected): + """ + Test complex number operations that are folded. + + Complex operations are folded when the result is shorter than the original. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('1j - 2j', '1j-2j'), + ('1j * 1j', '1j*1j'), + ('0j + 5', '0j+5'), + ('2j / 1j', '2j/1j'), + ('1j ** 2', '1j**2'), + ] +) +def test_complex_not_folded(source, expected): + """ + Test complex number operations that are not folded. + """ + if sys.version_info < (3, 0) and source == '1j - 2j': + pytest.skip('Complex subtraction representation differs in Python 2') + + run_test(source, expected) From d01f184ba316608ff45accadb568d2d79d12f416 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 22 Dec 2025 11:16:46 +0000 Subject: [PATCH 4/5] Add more unit tests --- test/test_folding.py | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/test/test_folding.py b/test/test_folding.py index ccedfc30..5eaebf18 100644 --- a/test/test_folding.py +++ b/test/test_folding.py @@ -447,3 +447,114 @@ def test_complex_not_folded(source, expected): pytest.skip('Complex subtraction representation differs in Python 2') run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + # These fold because the result is 0j or the folded form is shorter + ('-3j + 3j', '0j'), + ('1j + -1j', '0j'), + ] +) +def test_negative_complex_in_binop_folded(source, expected): + """ + Test that negative complex numbers (UnaryOp USub on complex) participate in BinOp folding. + """ + if sys.version_info < (3, 0): + pytest.skip('Complex number representation differs in Python 2') + + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('-3j + 1j', '-3j+1j'), + ('-5j * 2', '-5j*2'), + ('2 * -5j', '2*-5j'), + ('-10j + 5j', '-10j+5j'), + ] +) +def test_negative_complex_in_binop_not_folded(source, expected): + """ + Test that some negative complex operations don't fold due to representation issues. + + When negating a pure imaginary number like -2j, Python represents -(-2j) as (-0+2j), + which makes the folded form longer than the original expression. + """ + if sys.version_info < (3, 0): + pytest.skip('Complex number representation differs in Python 2') + + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('~0 + 1', '0'), + ('~5 & 0xff', '250'), + ('~0 | 5', '-1'), # -1 in binary is all 1s, so -1 | x = -1 + ('1 + ~0', '0'), + ('~1 + 2', '0'), + ('~0xff & 0xff', '0'), + ] +) +def test_invert_in_binop(source, expected): + """ + Test that bitwise invert (UnaryOp Invert) participates in BinOp folding. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('~0', '~0'), + ('~1', '~1'), + ('~5', '~5'), + ('~255', '~255'), + ] +) +def test_invert_not_folded(source, expected): + """ + Test that simple bitwise invert on literals is not folded when the result is not shorter. + + ~0 = -1, ~1 = -2, ~5 = -6, etc. These are the same length or longer. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + ('~~0', '0'), + ('~~5', '5'), + ('~~~0', '~0'), + ('~~~~5', '5'), + ] +) +def test_double_invert_folded(source, expected): + """ + Test that double bitwise invert is folded. + + ~~x = x, so double invert should fold away. + """ + run_test(source, expected) + + +@pytest.mark.parametrize( + ('source', 'expected'), [ + # In Python, True == 1 and False == 0 for arithmetic + ('-5 + True', '-4'), + ('10 * False', '0'), + ('True + True', '2'), + ('~True', '-2'), # ~1 = -2, shorter than ~True + ('~False', '-1'), # ~0 = -1, shorter than ~False + ] +) +def test_mixed_numeric_bool_folded(source, expected): + """ + Test folding of expressions mixing numeric and boolean operands. + + Python treats True as 1 and False as 0 in numeric contexts. + """ + if sys.version_info < (3, 4): + pytest.skip('NameConstant not in python < 3.4') + + run_test(source, expected) From a14a779b5f2e62064e7400db6d55c9c72aa1f4d3 Mon Sep 17 00:00:00 2001 From: Daniel Flook Date: Mon, 22 Dec 2025 12:47:21 +0000 Subject: [PATCH 5/5] Add UnaryOp to folding property tests --- hypo_test/folding.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/hypo_test/folding.py b/hypo_test/folding.py index 94603546..ca68a0e2 100644 --- a/hypo_test/folding.py +++ b/hypo_test/folding.py @@ -37,11 +37,29 @@ def BinOp(draw, expression) -> ast.BinOp: return ast.BinOp(le[0], op, le[1]) +@composite +def UnaryOp(draw, expression) -> ast.UnaryOp: + op = draw( + sampled_from( + [ + ast.USub(), # Unary minus: -x + ast.UAdd(), # Unary plus: +x + ast.Invert(), # Bitwise not: ~x + ast.Not(), # Logical not: not x + ] + ) + ) + + operand = draw(expression) + + return ast.UnaryOp(op, operand) + + def expression() -> SearchStrategy: return recursive( leaves, lambda expression: - BinOp(expression), + one_of(BinOp(expression), UnaryOp(expression)), max_leaves=150 )