├── .github ├── FUNDING.yml ├── actions │ └── run-in-container │ │ └── action.yml ├── github_sucks └── workflows │ ├── release.yaml │ ├── release_test.yaml │ ├── test.yaml │ ├── test_corpus.yaml │ ├── verify_release.yaml │ └── xtest.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── corpus_test ├── generate_report.py ├── generate_results.py └── result.py ├── docker ├── Dockerfile-fedora28 ├── Dockerfile-fedora30 ├── Dockerfile-fedora32 ├── Dockerfile-fedora34 ├── Dockerfile-fedora36 ├── Dockerfile-fedora38 ├── Dockerfile-fedora40 ├── Dockerfile-fuzz ├── build-images.sh └── fuzz.sh ├── docs └── source │ ├── api_usage.rst │ ├── command_usage.rst │ ├── conf.py │ ├── index.rst │ ├── installation.rst │ └── transforms │ ├── combine_imports.py │ ├── combine_imports.rst │ ├── constant_folding.py │ ├── constant_folding.rst │ ├── convert_posargs_to_args.py │ ├── convert_posargs_to_args.rst │ ├── hoist_literals.py │ ├── hoist_literals.rst │ ├── index.rst │ ├── preserve_shebang.py │ ├── preserve_shebang.rst │ ├── remove_annotations.py │ ├── remove_annotations.rst │ ├── remove_asserts.py │ ├── remove_asserts.rst │ ├── remove_builtin_exception_brackets.py │ ├── remove_builtin_exception_brackets.rst │ ├── remove_debug.py │ ├── remove_debug.rst │ ├── remove_explicit_return_none.py │ ├── remove_explicit_return_none.rst │ ├── remove_literal_statements.py │ ├── remove_literal_statements.rst │ ├── remove_object_base.py │ ├── remove_object_base.rst │ ├── remove_pass.py │ ├── remove_pass.rst │ ├── rename_globals.py │ ├── rename_globals.rst │ ├── rename_locals.py │ └── rename_locals.rst ├── hypo_test ├── README.md ├── __init__.py ├── expressions.py ├── folding.py ├── module.py ├── patterns.py └── test_it.py ├── requirements-dev.txt ├── setup.py ├── src └── python_minifier │ ├── __init__.py │ ├── __init__.pyi │ ├── __main__.py │ ├── ast_annotation │ └── __init__.py │ ├── ast_compare.py │ ├── ast_compat.py │ ├── ast_printer.py │ ├── expression_printer.py │ ├── f_string.py │ ├── ministring.py │ ├── module_printer.py │ ├── py.typed │ ├── rename │ ├── README.md │ ├── __init__.py │ ├── bind_names.py │ ├── binding.py │ ├── mapper.py │ ├── name_generator.py │ ├── rename_literals.py │ ├── renamer.py │ ├── resolve_names.py │ └── util.py │ ├── token_printer.py │ ├── transforms │ ├── __init__.py │ ├── combine_imports.py │ ├── constant_folding.py │ ├── remove_annotations.py │ ├── remove_annotations_options.py │ ├── remove_annotations_options.pyi │ ├── remove_asserts.py │ ├── remove_debug.py │ ├── remove_exception_brackets.py │ ├── remove_explicit_return_none.py │ ├── remove_literal_statements.py │ ├── remove_object_base.py │ ├── remove_pass.py │ ├── remove_posargs.py │ └── suite_transformer.py │ └── util.py ├── test ├── ast_annotation │ └── test_add_parent.py ├── helpers.py ├── test_assignment_expressions.py ├── test_await_fstring.py ├── test_bind_names.py ├── test_bind_names_namedexpr.py ├── test_bind_names_python2.py ├── test_bind_names_python312.py ├── test_bind_names_python313.py ├── test_bind_names_python33.py ├── test_combine_imports.py ├── test_comprehension_rename.py ├── test_decorator_expressions.py ├── test_dict_expansion.py ├── test_folding.py ├── test_fstring.py ├── test_generic_types.py ├── test_hoist_literals.py ├── test_import.py ├── test_is_constant_node.py ├── test_iterable_unpacking.py ├── test_match.py ├── test_match_rename.py ├── test_name_generator.py ├── test_nonlocal.py ├── test_posargs.py ├── test_preserve_shebang.py ├── test_remove_annotations.py ├── test_remove_assert.py ├── test_remove_debug.py ├── test_remove_exception_brackets.py ├── test_remove_explicit_return_none.py ├── test_remove_literal_statements.py ├── test_remove_object.py ├── test_remove_pass.py ├── test_rename_builtins.py ├── test_rename_locals.py ├── test_slice.py └── test_type_param_defaults.py ├── tox.ini ├── tox └── pyyaml-5.4.1-constraints.txt ├── typing_test ├── stubtest-allowlist.txt ├── test_badtyping.py └── test_typing.py └── xtest ├── README.md ├── manifests ├── pypy3_test_manifest.yaml ├── python2.7_test_manifest.yaml ├── python3.10_test_manifest.yaml ├── python3.11_test_manifest.yaml ├── python3.12_test_manifest.yaml ├── python3.13_test_manifest.yaml ├── python3.3_test_manifest.yaml ├── python3.4_test_manifest.yaml ├── python3.5_test_manifest.yaml ├── python3.6_test_manifest.yaml ├── python3.7_test_manifest.yaml ├── python3.8_test_manifest.yaml └── python3.9_test_manifest.yaml ├── test_regrtest.py └── test_unparse_env.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [dflook] 2 | -------------------------------------------------------------------------------- /.github/actions/run-in-container/action.yml: -------------------------------------------------------------------------------- 1 | name: Run in container 2 | description: Run a command in a container 3 | author: Daniel Flook 4 | 5 | inputs: 6 | image: 7 | description: The image to run the command in 8 | required: true 9 | run: 10 | description: The command to run 11 | required: true 12 | volumes: 13 | description: Volumes to mount, one per line. Each line of the form 'source:target' 14 | required: false 15 | default: "" 16 | 17 | runs: 18 | using: composite 19 | steps: 20 | - name: Run command 21 | env: 22 | INPUT_VOLUMES: ${{ inputs.volumes }} 23 | run: | 24 | 25 | docker pull --quiet ${{ inputs.image }} 26 | 27 | function run() { 28 | docker run --rm \ 29 | --workdir /github/workspace \ 30 | -e "GITHUB_WORKSPACE=/github/workspace" \ 31 | -v $GITHUB_WORKSPACE:/github/workspace \ 32 | -e "HOME=/github/home" \ 33 | -v "$RUNNER_TEMP/_github_home":"/github/home" \ 34 | -v "/var/run/docker.sock":"/var/run/docker.sock" \ 35 | --mount type=bind,source="$RUNNER_TEMP/run.sh",target=/run.sh,readonly \ 36 | -e GITHUB_EVENT_PATH \ 37 | --mount type=bind,source="$GITHUB_EVENT_PATH",target="$GITHUB_EVENT_PATH,readonly" \ 38 | -e GITHUB_PATH \ 39 | --mount type=bind,source="$GITHUB_PATH",target="$GITHUB_PATH" \ 40 | -e GITHUB_ENV \ 41 | --mount type=bind,source="$GITHUB_ENV",target="$GITHUB_ENV" \ 42 | -e GITHUB_STEP_SUMMARY \ 43 | --mount type=bind,source="$GITHUB_STEP_SUMMARY",target="$GITHUB_STEP_SUMMARY" \ 44 | -e GITHUB_TOKEN \ 45 | -e GITHUB_JOB \ 46 | -e GITHUB_REF \ 47 | -e GITHUB_SHA \ 48 | -e GITHUB_REPOSITORY \ 49 | -e GITHUB_REPOSITORY_OWNER \ 50 | -e GITHUB_RUN_ID \ 51 | -e GITHUB_RUN_NUMBER \ 52 | -e GITHUB_RETENTION_DAYS \ 53 | -e GITHUB_RUN_ATTEMPT \ 54 | -e GITHUB_ACTOR \ 55 | -e GITHUB_WORKFLOW \ 56 | -e GITHUB_HEAD_REF \ 57 | -e GITHUB_BASE_REF \ 58 | -e GITHUB_EVENT_NAME \ 59 | -e GITHUB_SERVER_URL \ 60 | -e GITHUB_API_URL \ 61 | -e GITHUB_GRAPHQL_URL \ 62 | -e GITHUB_ACTION \ 63 | -e GITHUB_ACTION_REPOSITORY \ 64 | -e GITHUB_ACTION_REF \ 65 | -e RUNNER_DEBUG \ 66 | -e RUNNER_OS \ 67 | -e RUNNER_NAME \ 68 | -e RUNNER_TOOL_CACHE \ 69 | -e ACTIONS_RUNTIME_URL \ 70 | -e ACTIONS_RUNTIME_TOKEN \ 71 | -e ACTIONS_CACHE_URL \ 72 | -e GITHUB_ACTIONS \ 73 | -e CI \ 74 | -e GITHUB_ACTOR_ID \ 75 | -e GITHUB_OUTPUT \ 76 | -e GITHUB_REF_NAME \ 77 | -e GITHUB_REF_PROTECTED \ 78 | -e GITHUB_REF_TYPE \ 79 | -e GITHUB_REPOSITORY_ID \ 80 | -e GITHUB_REPOSITORY_OWNER_ID \ 81 | -e GITHUB_TRIGGERING_ACTOR \ 82 | -e GITHUB_WORKFLOW_REF \ 83 | -e GITHUB_WORKFLOW_SHA \ 84 | $VOLUMES_ARGS \ 85 | --entrypoint /bin/bash \ 86 | ${{ inputs.image }} \ 87 | --noprofile --norc -eo pipefail /run.sh 88 | 89 | } 90 | 91 | function fix_owners() { 92 | cat <<"EOF" >"$RUNNER_TEMP/run.sh" 93 | chown -R --reference "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/" || true 94 | chown -R --reference "/github/home" "/github/home/" || true 95 | chown --reference "$GITHUB_WORKSPACE" $GITHUB_PATH || true 96 | chown --reference "$GITHUB_WORKSPACE" $GITHUB_ENV || true 97 | chown --reference "$GITHUB_WORKSPACE" $GITHUB_STEP_SUMMARY || true 98 | EOF 99 | VOLUMES_ARGS="" 100 | run 101 | rm -f "$RUNNER_TEMP/run.sh" 102 | } 103 | 104 | trap fix_owners EXIT 105 | 106 | VOLUMES_ARGS="" 107 | if [[ -n "$INPUT_VOLUMES" ]]; then 108 | for mount in $(echo "$INPUT_VOLUMES" | tr ',' '\n'); do 109 | VOLUMES_ARGS="$VOLUMES_ARGS -v $mount" 110 | done 111 | fi 112 | 113 | cat <<"EOF" >"$RUNNER_TEMP/run.sh" 114 | ${{ inputs.run }} 115 | EOF 116 | 117 | set -x 118 | run 119 | set +x 120 | 121 | shell: bash 122 | 123 | branding: 124 | icon: globe 125 | color: purple 126 | -------------------------------------------------------------------------------- /.github/github_sucks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dflook/python-minifier/b5e9446320eff34539b0226af636088411d18a90/.github/github_sucks -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | concurrency: 8 | group: release 9 | cancel-in-progress: false 10 | 11 | jobs: 12 | 13 | package_python3: 14 | name: Create sdist and Python 3 Wheel 15 | runs-on: ubuntu-latest 16 | outputs: 17 | sdist: ${{ steps.package.outputs.sdist }} 18 | wheel: ${{ steps.package.outputs.wheel }} 19 | container: 20 | image: danielflook/python-minifier-build:python3.13-2024-09-15 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4.2.2 24 | with: 25 | fetch-depth: 1 26 | show-progress: false 27 | persist-credentials: false 28 | 29 | - name: Set version statically 30 | env: 31 | VERSION: ${{ github.event.release.tag_name }} 32 | run: | 33 | sed -i "s/setup_requires=.*/version='$VERSION',/; s/use_scm_version=.*//" setup.py 34 | echo "Version: $VERSION" 35 | 36 | - name: Build distribution packages 37 | id: package 38 | run: | 39 | pip3 install --upgrade build 40 | python3 -m build 41 | 42 | echo "sdist=$(find dist -name '*.tar.gz' -printf "%f\n")" >> "$GITHUB_OUTPUT" 43 | echo "wheel=$(find dist -name '*-py3-*.whl' -printf "%f\n")" >> "$GITHUB_OUTPUT" 44 | 45 | - name: Upload sdist artifact 46 | uses: actions/upload-artifact@v4.4.3 47 | with: 48 | name: dist-sdist 49 | path: dist/${{ steps.package.outputs.sdist }} 50 | if-no-files-found: error 51 | 52 | - name: Upload Python 3 wheel artifact 53 | uses: actions/upload-artifact@v4.4.3 54 | with: 55 | name: dist-py3-wheel 56 | path: dist/${{ steps.package.outputs.wheel }} 57 | if-no-files-found: error 58 | 59 | package_python2: 60 | name: Create Python 2 Wheel 61 | runs-on: ubuntu-latest 62 | needs: [package_python3] 63 | outputs: 64 | wheel: ${{ steps.package.outputs.wheel }} 65 | container: 66 | image: danielflook/python-minifier-build:python2.7-2024-09-15 67 | steps: 68 | - name: Download source distribution artifact 69 | uses: actions/download-artifact@v4.1.8 70 | with: 71 | name: dist-sdist 72 | path: dist/ 73 | 74 | - name: Build Python 2 wheel 75 | id: package 76 | env: 77 | PYTHON3_SDIST: ${{ needs.package_python3.outputs.sdist }} 78 | run: | 79 | dnf install -y findutils 80 | pip install --upgrade wheel 81 | pip wheel dist/"$PYTHON3_SDIST" -w dist 82 | echo "wheel=$(find dist -name '*-py2-*.whl' -printf "%f\n")" >> "$GITHUB_OUTPUT" 83 | 84 | - name: Upload Python 2 wheel artifact 85 | uses: actions/upload-artifact@v4.4.3 86 | with: 87 | name: dist-py2-wheel 88 | path: dist/${{ steps.package.outputs.wheel }} 89 | if-no-files-found: error 90 | 91 | documentation: 92 | name: Test Documentation 93 | runs-on: ubuntu-latest 94 | needs: [package_python3] 95 | container: 96 | image: danielflook/python-minifier-build:python3.13-2024-09-15 97 | steps: 98 | - uses: actions/download-artifact@v4.1.8 99 | with: 100 | name: dist-sdist 101 | path: dist/ 102 | 103 | - name: Install package 104 | env: 105 | PYTHON3_SDIST: ${{ needs.package_python3.outputs.sdist }} 106 | run: | 107 | pip3 install dist/"$PYTHON3_SDIST" 108 | pyminify --version 109 | 110 | - name: Checkout 111 | uses: actions/checkout@v4.2.2 112 | with: 113 | fetch-depth: 1 114 | show-progress: false 115 | persist-credentials: false 116 | 117 | - name: Build documentation 118 | run: | 119 | pip3 install sphinx sphinxcontrib-programoutput sphinx_rtd_theme 120 | sphinx-build docs/source /tmp/build 121 | 122 | - name: Upload documentation artifact 123 | uses: actions/upload-pages-artifact@v3.0.1 124 | with: 125 | path: /tmp/build 126 | 127 | publish-to-pypi: 128 | name: Publish to PyPI 129 | needs: 130 | - package_python3 131 | - package_python2 132 | runs-on: ubuntu-latest 133 | permissions: 134 | id-token: write 135 | environment: 136 | name: pypi 137 | url: https://pypi.org/project/python-minifier/${{ github.event.release.tag_name }} 138 | steps: 139 | - name: Download distribution artifacts 140 | uses: actions/download-artifact@v4.1.8 141 | with: 142 | pattern: dist-* 143 | path: dist/ 144 | merge-multiple: true 145 | 146 | - name: Publish package distributions to PyPI 147 | uses: pypa/gh-action-pypi-publish@v1.12.3 148 | with: 149 | print-hash: true 150 | verbose: true 151 | 152 | publish-docs: 153 | name: Publish Documentation 154 | needs: 155 | - documentation 156 | runs-on: ubuntu-latest 157 | permissions: 158 | pages: write 159 | id-token: write 160 | environment: 161 | name: github-pages 162 | url: ${{ steps.deployment.outputs.page_url }} 163 | steps: 164 | - name: Deploy to GitHub Pages 165 | id: deployment 166 | uses: actions/deploy-pages@v4.0.5 167 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | 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", "pypy", "pypy3"] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4.2.2 17 | with: 18 | fetch-depth: 1 19 | show-progress: false 20 | persist-credentials: false 21 | 22 | - name: Set version statically 23 | run: | 24 | VERSION=0.0.0 25 | sed -i "s/setup_requires=.*/version='$VERSION',/; s/use_scm_version=.*//" setup.py 26 | 27 | - name: Run tests 28 | uses: ./.github/actions/run-in-container 29 | with: 30 | image: danielflook/python-minifier-build:${{ matrix.python }}-2024-09-15 31 | run: | 32 | tox -r -e $(echo "${{ matrix.python }}" | tr -d .) 33 | -------------------------------------------------------------------------------- /.github/workflows/test_corpus.yaml: -------------------------------------------------------------------------------- 1 | name: Minify Corpus 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | description: 'Git ref to test' 8 | required: true 9 | default: 'main' 10 | type: string 11 | base-ref: 12 | type: string 13 | description: 'Git ref to compare against' 14 | required: true 15 | default: '2.10.0' 16 | regenerate-results: 17 | type: boolean 18 | description: 'Regenerate results' 19 | required: true 20 | default: false 21 | workflow_call: 22 | inputs: 23 | ref: 24 | description: 'Git ref to test' 25 | required: true 26 | type: string 27 | base-ref: 28 | type: string 29 | description: 'Git ref to compare against' 30 | required: true 31 | regenerate-results: 32 | type: boolean 33 | description: 'Regenerate results' 34 | required: false 35 | default: false 36 | 37 | jobs: 38 | generate_results: 39 | name: Minify Corpus 40 | runs-on: self-hosted 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python: ["2.7", "3.3", "3.4", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 45 | ref: ["${{ inputs.ref }}", "${{ inputs.base-ref }}"] 46 | steps: 47 | - name: Clear workspace 48 | run: rm -rf "$GITHUB_WORKSPACE/*" 49 | 50 | - name: Checkout workflow ref 51 | uses: actions/checkout@v4.2.2 52 | with: 53 | fetch-depth: 1 54 | show-progress: false 55 | path: workflow 56 | persist-credentials: false 57 | 58 | - name: Checkout ref 59 | id: checkout-ref 60 | uses: actions/checkout@v4.2.2 61 | with: 62 | fetch-depth: 1 63 | fetch-tags: true 64 | show-progress: false 65 | ref: ${{ matrix.ref }} 66 | path: python-minifier 67 | persist-credentials: false 68 | 69 | - name: Run tests 70 | uses: dflook/run-in-container@main 71 | with: 72 | image: danielflook/python-minifier-build:python${{ matrix.python }}-2024-09-15 73 | volumes: | 74 | /corpus:/corpus 75 | /corpus-results:/corpus-results 76 | run: | 77 | set -ex 78 | VERSION=0.0.0 79 | sed -i "s/setup_requires=.*/version='$VERSION',/; s/use_scm_version=.*//" python-minifier/setup.py 80 | 81 | if ! pip${{ matrix.python }} install python-minifier/ 2>stderr.log; then 82 | echo stderr.log 83 | 84 | if grep -q -E 'require a different python version|requires a different Python' stderr.log; then 85 | echo "${{ matrix.ref }} doesn't support Python ${{ matrix.python }}. Skipping." 86 | exit 0 87 | else 88 | exit 1 89 | fi 90 | fi 91 | 92 | if [[ "${{ matrix.python }}" == "3.3" || "${{ matrix.python }}" == "3.4" || "${{ matrix.python }}" == "3.5" ]]; then 93 | # These versions randomise the hash seed, but don't preserve dict order 94 | # This affects how names are assigned. Disable the hash randomisation for 95 | # deterministic results. 96 | export PYTHONHASHSEED=0 97 | fi 98 | 99 | python${{matrix.python}} workflow/corpus_test/generate_results.py /corpus /corpus-results ${{ steps.checkout-ref.outputs.commit }} ${{ inputs.regenerate-results }} 100 | 101 | generate_report: 102 | name: Generate Report 103 | needs: generate_results 104 | runs-on: self-hosted 105 | if: ${{ always() }} 106 | steps: 107 | - name: Clear workspace 108 | run: rm -rf "$GITHUB_WORKSPACE/*" 109 | 110 | - name: Checkout workflow ref 111 | uses: actions/checkout@v4.2.2 112 | with: 113 | path: workflow 114 | fetch-depth: 1 115 | show-progress: false 116 | persist-credentials: false 117 | 118 | - name: Checkout ref 119 | id: ref 120 | uses: actions/checkout@v4.2.2 121 | with: 122 | ref: ${{ inputs.ref }} 123 | path: python-minifier 124 | fetch-depth: 1 125 | fetch-tags: true 126 | show-progress: false 127 | persist-credentials: false 128 | 129 | - name: Checkout base ref 130 | id: base-ref 131 | uses: actions/checkout@v4.2.2 132 | with: 133 | ref: ${{ inputs.base-ref }} 134 | path: python-minifier-base 135 | fetch-depth: 1 136 | fetch-tags: true 137 | show-progress: false 138 | persist-credentials: false 139 | 140 | - name: Generate Report 141 | uses: dflook/run-in-container@main 142 | with: 143 | image: danielflook/python-minifier-build:python3.13-2024-09-15 144 | volumes: | 145 | /corpus-results:/corpus-results 146 | run: | 147 | python3.13 workflow/corpus_test/generate_report.py /corpus-results ${{ inputs.ref }} ${{ steps.ref.outputs.commit }} ${{ inputs.base-ref }} ${{ steps.base-ref.outputs.commit }} >> $GITHUB_STEP_SUMMARY 148 | -------------------------------------------------------------------------------- /.github/workflows/verify_release.yaml: -------------------------------------------------------------------------------- 1 | name: Verify Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'The release version to test' 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | test_package: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python: ["2.7", "3.3", "3.4", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 17 | container: 18 | image: danielflook/python-minifier-build:python${{ matrix.python }}-2024-09-15 19 | steps: 20 | - name: Test 21 | env: 22 | VERSION: ${{ inputs.version }} 23 | run: | 24 | pip${{ matrix.python }} install "python-minifier==$VERSION" 25 | pyminify --version 26 | -------------------------------------------------------------------------------- /.github/workflows/xtest.yaml: -------------------------------------------------------------------------------- 1 | name: Regression Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | 7 | test: 8 | name: Regression Test 9 | runs-on: self-hosted 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | 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", "pypy3"] 14 | steps: 15 | - name: Clear workspace 16 | run: rm -rf "$GITHUB_WORKSPACE/*" 17 | 18 | - name: Checkout 19 | uses: actions/checkout@v4.2.2 20 | with: 21 | fetch-depth: 1 22 | show-progress: false 23 | persist-credentials: false 24 | 25 | - name: Set version statically 26 | run: | 27 | VERSION=0.0.0 28 | sed -i "s/setup_requires=.*/version='$VERSION',/; s/use_scm_version=.*//" setup.py 29 | 30 | - name: Run tests 31 | uses: ./.github/actions/run-in-container 32 | with: 33 | image: danielflook/python-minifier-build:${{ matrix.python }}-2024-09-15 34 | run: | 35 | 36 | if [[ "${{ matrix.python }}" == "python3.4" ]]; then 37 | (cd /usr/lib64/python3.4/test && python3.4 make_ssl_certs.py) 38 | elif [[ "${{ matrix.python }}" == "python3.5" ]]; then 39 | (cd /usr/lib64/python3.5/test && python3.5 make_ssl_certs.py) 40 | elif [[ "${{ matrix.python }}" == "pypy3" ]]; then 41 | (cd /usr/lib64/pypy3-7.0/lib-python/3/test && pypy3 make_ssl_certs.py) 42 | fi 43 | 44 | tox -r -e $(echo "${{ matrix.python }}" | tr -d .) -- xtest 45 | 46 | test-corpus: 47 | needs: test 48 | name: Minify Corpus 49 | uses: ./.github/workflows/test_corpus.yaml 50 | with: 51 | ref: ${{ github.ref }} 52 | base-ref: ${{ github.base_ref }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox/ 2 | .idea/ 3 | __pycache__/ 4 | *.py[cod] 5 | dist/ 6 | downloads/ 7 | eggs/ 8 | .eggs/ 9 | wheels/ 10 | *.egg-info/ 11 | *.egg 12 | venv/ 13 | venv*/ 14 | docs/build/ 15 | docs/source/transforms/*.min.py 16 | .cache/ 17 | .pytest_cache/ 18 | .circleci-config.yml 19 | .coverage 20 | .mypy_cache/ 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Flook 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Minifier 2 | 3 | Transforms Python source code into its most compact representation. 4 | 5 | [Try it out!](https://python-minifier.com) 6 | 7 | python-minifier currently supports Python 2.7 and Python 3.3 to 3.13. Previous releases supported Python 2.6. 8 | 9 | * [PyPI](https://pypi.org/project/python-minifier/) 10 | * [Documentation](https://dflook.github.io/python-minifier/) 11 | * [Issues](https://github.com/dflook/python-minifier/issues) 12 | 13 | As an example, the following python source: 14 | 15 | ```python 16 | def handler(event, context): 17 | l.info(event) 18 | try: 19 | i_token = hashlib.new('md5', (event['RequestId'] + event['StackId']).encode()).hexdigest() 20 | props = event['ResourceProperties'] 21 | 22 | if event['RequestType'] == 'Create': 23 | event['PhysicalResourceId'] = 'None' 24 | event['PhysicalResourceId'] = create_cert(props, i_token) 25 | add_tags(event['PhysicalResourceId'], props) 26 | validate(event['PhysicalResourceId'], props) 27 | 28 | if wait_for_issuance(event['PhysicalResourceId'], context): 29 | event['Status'] = 'SUCCESS' 30 | return send(event) 31 | else: 32 | return reinvoke(event, context) 33 | 34 | elif event['RequestType'] == 'Delete': 35 | if event['PhysicalResourceId'] != 'None': 36 | acm.delete_certificate(CertificateArn=event['PhysicalResourceId']) 37 | event['Status'] = 'SUCCESS' 38 | return send(event) 39 | 40 | elif event['RequestType'] == 'Update': 41 | 42 | if replace_cert(event): 43 | event['PhysicalResourceId'] = create_cert(props, i_token) 44 | add_tags(event['PhysicalResourceId'], props) 45 | validate(event['PhysicalResourceId'], props) 46 | 47 | if not wait_for_issuance(event['PhysicalResourceId'], context): 48 | return reinvoke(event, context) 49 | else: 50 | if 'Tags' in event['OldResourceProperties']: 51 | acm.remove_tags_from_certificate(CertificateArn=event['PhysicalResourceId'], 52 | Tags=event['OldResourceProperties']['Tags']) 53 | 54 | add_tags(event['PhysicalResourceId'], props) 55 | 56 | event['Status'] = 'SUCCESS' 57 | return send(event) 58 | else: 59 | raise RuntimeError('Unknown RequestType') 60 | 61 | except Exception as ex: 62 | l.exception('') 63 | event['Status'] = 'FAILED' 64 | event['Reason'] = str(ex) 65 | return send(event) 66 | ``` 67 | 68 | Becomes: 69 | 70 | ```python 71 | def handler(event,context): 72 | L='OldResourceProperties';K='Tags';J='None';H='SUCCESS';G='RequestType';E='Status';D=context;B='PhysicalResourceId';A=event;l.info(A) 73 | try: 74 | F=hashlib.new('md5',(A['RequestId']+A['StackId']).encode()).hexdigest();C=A['ResourceProperties'] 75 | if A[G]=='Create': 76 | A[B]=J;A[B]=create_cert(C,F);add_tags(A[B],C);validate(A[B],C) 77 | if wait_for_issuance(A[B],D):A[E]=H;return send(A) 78 | else:return reinvoke(A,D) 79 | elif A[G]=='Delete': 80 | if A[B]!=J:acm.delete_certificate(CertificateArn=A[B]) 81 | A[E]=H;return send(A) 82 | elif A[G]=='Update': 83 | if replace_cert(A): 84 | A[B]=create_cert(C,F);add_tags(A[B],C);validate(A[B],C) 85 | if not wait_for_issuance(A[B],D):return reinvoke(A,D) 86 | else: 87 | if K in A[L]:acm.remove_tags_from_certificate(CertificateArn=A[B],Tags=A[L][K]) 88 | add_tags(A[B],C) 89 | A[E]=H;return send(A) 90 | else:raise RuntimeError('Unknown RequestType') 91 | except Exception as I:l.exception('');A[E]='FAILED';A['Reason']=str(I);return send(A) 92 | ``` 93 | 94 | ## Why? 95 | 96 | AWS Cloudformation templates may have AWS lambda function source code embedded in them, but only if the function is less 97 | than 4KiB. I wrote this package so I could write python normally and still embed the module in a template. 98 | 99 | ## Installation 100 | 101 | To install python-minifier use pip: 102 | 103 | ```bash 104 | $ pip install python-minifier 105 | ``` 106 | 107 | Note that python-minifier depends on the python interpreter for parsing source code, 108 | and outputs source code compatible with the version of the interpreter it is run with. 109 | 110 | This means that if you minify code written for Python 3.11 using python-minifier running with Python 3.12, 111 | the minified code may only run with Python 3.12. 112 | 113 | python-minifier runs with and can minify code written for Python 2.7 and Python 3.3 to 3.13. 114 | 115 | ## Usage 116 | 117 | To minify a source file, and write the minified module to stdout: 118 | 119 | ```bash 120 | $ pyminify hello.py 121 | ``` 122 | 123 | There is also an API. The same example would look like: 124 | 125 | ```python 126 | import python_minifier 127 | 128 | with open('hello.py') as f: 129 | print(python_minifier.minify(f.read())) 130 | ``` 131 | 132 | Documentation is available at [dflook.github.io/python-minifier/](https://dflook.github.io/python-minifier/) 133 | 134 | ## License 135 | 136 | Available under the MIT License. Full text is in the [LICENSE](LICENSE) file. 137 | 138 | Copyright (c) 2024 Daniel Flook 139 | -------------------------------------------------------------------------------- /corpus_test/generate_results.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | import gzip 4 | import logging 5 | import os 6 | import sys 7 | import time 8 | 9 | import python_minifier 10 | 11 | from result import Result, ResultWriter 12 | 13 | try: 14 | RError = RecursionError 15 | except NameError: 16 | # Python 2 17 | class RError(Exception): 18 | pass 19 | 20 | 21 | def minify_corpus_entry(corpus_path, corpus_entry): 22 | """ 23 | Minify a single entry in the corpus and return the result 24 | 25 | :param str corpus_path: Path to the corpus 26 | :param str corpus_entry: A file in the corpus 27 | :rtype: Result 28 | """ 29 | 30 | if os.path.isfile(os.path.join(corpus_path, corpus_entry + '.py.gz')): 31 | with gzip.open(os.path.join(corpus_path, corpus_entry + '.py.gz'), 'rb') as f: 32 | source = f.read() 33 | else: 34 | with open(os.path.join(corpus_path, corpus_entry), 'rb') as f: 35 | source = f.read() 36 | 37 | result = Result(corpus_entry, len(source), 0, 0, '') 38 | 39 | start_time = time.time() 40 | try: 41 | minified = python_minifier.minify(source, filename=corpus_entry) 42 | end_time = time.time() 43 | result.time = end_time - start_time 44 | 45 | result.minified_size = len(minified) 46 | 47 | result.outcome = 'Minified' 48 | 49 | except RError: 50 | # Source is too deep 51 | result.outcome = 'RecursionError' 52 | 53 | except SyntaxError: 54 | # Source not valid for this version of Python 55 | result.outcome = 'SyntaxError' 56 | 57 | except python_minifier.UnstableMinification: 58 | # Minification does not equal original source 59 | end_time = time.time() 60 | result.time = end_time - start_time 61 | result.outcome = 'UnstableMinification' 62 | 63 | except AssertionError: 64 | result.outcome = 'Exception: AssertionError' 65 | 66 | except Exception as exception: 67 | result.outcome = 'Exception: ' + str(exception) 68 | 69 | return result 70 | 71 | 72 | def corpus_test(corpus_path, results_path, sha, regenerate_results): 73 | """ 74 | Test the minifier on the entire corpus 75 | 76 | The results are written to a csv file in the results directory. 77 | The name of the file is results__.csv 78 | 79 | If the file already exists and regenerate_results is False, the test is skipped. 80 | 81 | :param str corpus_path: Path to the corpus 82 | :param str results_path: Path to the results directory 83 | :param str sha: The python-minifier sha we are testing 84 | :param bool regenerate_results: Regenerate results even if they are present 85 | """ 86 | python_version = '.'.join([str(s) for s in sys.version_info[:2]]) 87 | 88 | log_path = 'results_' + python_version + '_' + sha + '.log' 89 | print('Logging in GitHub Actions is absolute garbage. Logs are going to ' + log_path) 90 | 91 | logging.basicConfig(filename=os.path.join(results_path, log_path), level=logging.DEBUG) 92 | 93 | corpus_entries = [entry[:-len('.py.gz')] for entry in os.listdir(corpus_path)] 94 | 95 | results_file_path = os.path.join(results_path, 'results_' + python_version + '_' + sha + '.csv') 96 | 97 | if os.path.isfile(results_file_path): 98 | print('Results file already exists: %s' % results_file_path) 99 | if regenerate_results: 100 | os.remove(results_file_path) 101 | 102 | total_entries = len(corpus_entries) 103 | print('Testing python-minifier on %d entries' % total_entries) 104 | tested_entries = 0 105 | 106 | start_time = time.time() 107 | next_checkpoint = time.time() + 60 108 | 109 | with ResultWriter(results_file_path) as result_writer: 110 | print('%d results already present' % len(result_writer)) 111 | 112 | for entry in corpus_entries: 113 | if entry in result_writer: 114 | continue 115 | 116 | logging.debug('Corpus entry [%s]', entry) 117 | 118 | result = minify_corpus_entry(corpus_path, entry) 119 | result_writer.write(result) 120 | tested_entries += 1 121 | 122 | sys.stdout.flush() 123 | 124 | if time.time() > next_checkpoint: 125 | percent = len(result_writer) / float(total_entries) * 100 126 | time_per_entry = (time.time() - start_time) / tested_entries 127 | entries_remaining = len(corpus_entries) - len(result_writer) 128 | time_remaining = datetime.timedelta(seconds=int(entries_remaining * time_per_entry)) 129 | print('Tested %d/%d entries (%d%%) estimated %s remaining' % (len(result_writer), total_entries, percent, time_remaining)) 130 | sys.stdout.flush() 131 | next_checkpoint = time.time() + 60 132 | 133 | print('Finished') 134 | 135 | 136 | def bool_parse(value): 137 | return value == 'true' 138 | 139 | 140 | def main(): 141 | parser = argparse.ArgumentParser(description='Test python-minifier on a corpus of Python files.') 142 | parser.add_argument('corpus_dir', type=str, help='Path to corpus directory', default='corpus') 143 | parser.add_argument('results_dir', type=str, help='Path to results directory', default='results') 144 | parser.add_argument('minifier_sha', type=str, help='The python-minifier sha we are testing') 145 | parser.add_argument('regenerate_results', type=bool_parse, help='Regenerate results even if they are present', default='false') 146 | args = parser.parse_args() 147 | 148 | corpus_test(args.corpus_dir, args.results_dir, args.minifier_sha, args.regenerate_results) 149 | 150 | 151 | if __name__ == '__main__': 152 | main() 153 | -------------------------------------------------------------------------------- /corpus_test/result.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Result(object): 5 | 6 | def __init__(self, corpus_entry, original_size, minified_size, time, outcome): 7 | """ 8 | :param str corpus_entry: The name of the file in the corpus 9 | :param int original_size: The size of the original file 10 | :param int minified_size: The size of the minified file 11 | :param float time: The time taken to minify the file 12 | :param str outcome: The result of the minification 13 | """ 14 | self.corpus_entry = corpus_entry 15 | self.original_size = original_size 16 | self.minified_size = minified_size 17 | self.time = time 18 | self.outcome = outcome 19 | 20 | 21 | class ResultWriter: 22 | def __init__(self, results_path): 23 | """ 24 | :param str results_path: The path to the results file 25 | """ 26 | self._results_path = results_path 27 | self._size = 0 28 | self._existing_result_set = set() 29 | 30 | if not os.path.isfile(self._results_path): 31 | return 32 | 33 | with open(self._results_path, 'r') as f: 34 | for line in f: 35 | if line != 'corpus_entry,original_size,minified_size,time,result\n': 36 | self._existing_result_set.add(line.split(',')[0]) 37 | 38 | self._size += len(self._existing_result_set) 39 | 40 | def __enter__(self): 41 | self.results = open(self._results_path, 'a') 42 | self.results.write('corpus_entry,original_size,minified_size,time,result\n') 43 | return self 44 | 45 | def __exit__(self, exc_type, exc_val, exc_tb): 46 | self.results.close() 47 | 48 | def __contains__(self, item): 49 | """ 50 | :param str item: The name of the entry in the corpus 51 | :return bool: True if the entry already exists in the results file 52 | """ 53 | return item in self._existing_result_set 54 | 55 | def __len__(self): 56 | return self._size 57 | 58 | def write(self, result): 59 | """ 60 | :param Result result: The result to write to the file 61 | """ 62 | self.results.write( 63 | result.corpus_entry + ',' + 64 | str(result.original_size) + ',' + 65 | str(result.minified_size) + ',' + 66 | str(result.time) + ',' + result.outcome + '\n' 67 | ) 68 | self.results.flush() 69 | self._size += 1 70 | 71 | 72 | class ResultReader: 73 | def __init__(self, results_path): 74 | """ 75 | :param str results_path: The path to the results file 76 | """ 77 | self._results_path = results_path 78 | 79 | def __enter__(self): 80 | self.results = open(self._results_path, 'r') 81 | header = self.results.readline() 82 | assert header in ['corpus_entry,original_size,minified_size,time,result\n', ''] 83 | return self 84 | 85 | def __exit__(self, exc_type, exc_val, exc_tb): 86 | self.results.close() 87 | 88 | def __iter__(self): 89 | return self 90 | 91 | def __next__(self): 92 | """ 93 | :return Result: The next result in the file 94 | """ 95 | 96 | line = self.results.readline() 97 | while line == 'corpus_entry,original_size,minified_size,time,result\n': 98 | line = self.results.readline() 99 | 100 | if line == '': 101 | raise StopIteration 102 | 103 | result_line = line.split(',') 104 | return Result( 105 | result_line[0], 106 | int(result_line[1]), 107 | int(result_line[2]), 108 | float(result_line[3]), 109 | result_line[4].rstrip() 110 | ) 111 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora28: -------------------------------------------------------------------------------- 1 | FROM fedora:28 AS python3.3 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | openssh \ 6 | tar \ 7 | gzip \ 8 | gpg \ 9 | ca-certificates \ 10 | && dnf clean all && rm -rf /var/cache/dnf/* 11 | 12 | # Install git 13 | RUN dnf install -y \ 14 | @development-tools \ 15 | dh-autoreconf \ 16 | curl-devel \ 17 | expat-devel \ 18 | gettext-devel \ 19 | openssl-devel \ 20 | perl-devel \ 21 | zlib-devel \ 22 | && dnf clean all && rm -rf /var/cache/dnf/* \ 23 | && curl -f -L https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.18.5.tar.gz --output git-2.18.5.tar.gz \ 24 | && tar -zxf git-2.18.5.tar.gz \ 25 | && cd git-2.18.5 \ 26 | && make configure \ 27 | && ./configure --prefix=/usr/local \ 28 | && make all \ 29 | && make install \ 30 | && cd .. \ 31 | && rm -rf git-2.18.5 \ 32 | && dnf autoremove -y \ 33 | @development-tools \ 34 | dh-autoreconf \ 35 | curl-devel \ 36 | expat-devel \ 37 | gettext-devel \ 38 | openssl-devel \ 39 | perl-devel \ 40 | zlib-devel \ 41 | && dnf clean all && rm -rf /var/cache/dnf/* 42 | 43 | # Python versions 44 | RUN dnf install -y \ 45 | python33 \ 46 | && dnf clean all && rm -rf /var/cache/dnf/* \ 47 | && curl https://bootstrap.pypa.io/pip/3.3/get-pip.py | python3.3 48 | 49 | # Other packages required for tests 50 | RUN dnf install -y \ 51 | bzip2 \ 52 | && dnf clean all && rm -rf /var/cache/dnf/* 53 | 54 | RUN pip3 install 'tox<3' 'virtualenv<16' 'setuptools_scm<7' 55 | 56 | WORKDIR /tmp/work 57 | ENTRYPOINT ["/bin/bash"] 58 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora30: -------------------------------------------------------------------------------- 1 | FROM fedora:30 AS base 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | openssh \ 7 | tar \ 8 | gzip \ 9 | gpg \ 10 | ca-certificates \ 11 | && dnf clean all && rm -rf /var/cache/dnf/* 12 | 13 | # Other packages required for tests 14 | RUN dnf install -y \ 15 | bzip2 \ 16 | && dnf clean all && rm -rf /var/cache/dnf/* 17 | 18 | RUN pip3 install tox==3.11.1 virtualenv==16.6.0 19 | 20 | WORKDIR /tmp/work 21 | ENTRYPOINT ["/bin/bash"] 22 | 23 | ## 24 | FROM base AS python2.7 25 | 26 | RUN dnf install -y \ 27 | python27 \ 28 | python2-test \ 29 | python2-pip \ 30 | findutils \ 31 | && dnf clean all && rm -rf /var/cache/dnf/* 32 | 33 | ## 34 | FROM base AS python3.4 35 | 36 | RUN dnf install -y \ 37 | python34 \ 38 | && dnf clean all && rm -rf /var/cache/dnf/* \ 39 | && curl https://bootstrap.pypa.io/pip/3.4/get-pip.py | python3.4 40 | 41 | ## 42 | FROM base AS python3.5 43 | 44 | RUN dnf install -y \ 45 | python35 \ 46 | && dnf clean all && rm -rf /var/cache/dnf/* \ 47 | && curl https://bootstrap.pypa.io/pip/3.5/get-pip.py | python3.5 48 | 49 | ## 50 | FROM base AS python3.6 51 | 52 | RUN dnf install -y \ 53 | python36 \ 54 | && dnf clean all && rm -rf /var/cache/dnf/* \ 55 | && curl https://bootstrap.pypa.io/pip/3.6/get-pip.py | python3.6 56 | 57 | ## 58 | FROM base AS python3.7 59 | 60 | RUN dnf install -y \ 61 | python37 \ 62 | python3-test \ 63 | python3-pip \ 64 | && dnf clean all && rm -rf /var/cache/dnf/* 65 | 66 | ## 67 | FROM base AS python3.8 68 | 69 | RUN dnf install -y \ 70 | python38 \ 71 | && dnf clean all && rm -rf /var/cache/dnf/* \ 72 | && curl https://bootstrap.pypa.io/get-pip.py | python3.8 73 | 74 | ## 75 | FROM base AS pypy 76 | 77 | RUN dnf install -y \ 78 | pypy \ 79 | && dnf clean all && rm -rf /var/cache/dnf/* 80 | 81 | ## 82 | FROM base AS pypy3 83 | 84 | RUN dnf install -y \ 85 | pypy3 \ 86 | && dnf clean all && rm -rf /var/cache/dnf/* 87 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora32: -------------------------------------------------------------------------------- 1 | FROM fedora:32 AS python3.9 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | openssh \ 7 | tar \ 8 | gzip \ 9 | gpg \ 10 | ca-certificates \ 11 | && dnf clean all && rm -rf /var/cache/dnf/* 12 | 13 | # Development tools 14 | RUN dnf install -y \ 15 | @development-tools \ 16 | findutils \ 17 | zlib-devel \ 18 | bzip2-devel \ 19 | ncurses-devel \ 20 | gdbm-devel \ 21 | openssl-devel \ 22 | sqlite-devel \ 23 | tk-devel \ 24 | libuuid-devel \ 25 | readline-devel \ 26 | libnsl2-devel \ 27 | xz-devel \ 28 | libffi-devel \ 29 | wget \ 30 | && git clone https://github.com/python/cpython.git \ 31 | && cd cpython \ 32 | && git checkout v3.9.0 \ 33 | && ./configure \ 34 | && make \ 35 | && make install \ 36 | && cd .. \ 37 | && rm -rf cpython \ 38 | && dnf install -y \ 39 | @development-tools \ 40 | findutils \ 41 | zlib-devel \ 42 | bzip2-devel \ 43 | ncurses-devel \ 44 | gdbm-devel \ 45 | openssl-devel \ 46 | sqlite-devel \ 47 | tk-devel \ 48 | libuuid-devel \ 49 | readline-devel \ 50 | libnsl2-devel \ 51 | xz-devel \ 52 | libffi-devel \ 53 | wget \ 54 | && dnf clean all && rm -rf /var/cache/dnf/* 55 | 56 | # Other packages required for tests 57 | RUN dnf install -y \ 58 | bzip2 \ 59 | && dnf clean all && rm -rf /var/cache/dnf/* 60 | 61 | RUN pip3 install tox==3.20 62 | 63 | WORKDIR /tmp/work 64 | ENTRYPOINT ["/bin/bash"] 65 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora34: -------------------------------------------------------------------------------- 1 | FROM fedora:34 AS python3.10 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | openssh \ 7 | tar \ 8 | gzip \ 9 | gpg \ 10 | ca-certificates \ 11 | && dnf clean all && rm -rf /var/cache/dnf/* 12 | 13 | # Development tools 14 | RUN dnf install -y \ 15 | @development-tools \ 16 | findutils \ 17 | zlib-devel \ 18 | bzip2-devel \ 19 | ncurses-devel \ 20 | gdbm-devel \ 21 | openssl-devel \ 22 | sqlite-devel \ 23 | tk-devel \ 24 | libuuid-devel \ 25 | readline-devel \ 26 | libnsl2-devel \ 27 | xz-devel \ 28 | libffi-devel \ 29 | wget \ 30 | && git clone https://github.com/python/cpython.git \ 31 | && cd cpython \ 32 | && git checkout v3.10.0 \ 33 | && ./configure \ 34 | && make \ 35 | && make install \ 36 | && cd .. \ 37 | && rm -rf cpython \ 38 | && dnf clean all && rm -rf /var/cache/dnf/* 39 | 40 | # Other packages required for tests 41 | RUN dnf install -y \ 42 | bzip2 \ 43 | && dnf clean all && rm -rf /var/cache/dnf/* 44 | 45 | RUN pip3 install tox==3.24.1 46 | 47 | WORKDIR /tmp/work 48 | ENTRYPOINT ["/bin/bash"] 49 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora36: -------------------------------------------------------------------------------- 1 | FROM fedora:36 AS python3.11 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | openssh \ 7 | tar \ 8 | gzip \ 9 | gpg \ 10 | ca-certificates \ 11 | && dnf clean all && rm -rf /var/cache/dnf/* 12 | 13 | # Development tools 14 | RUN dnf install -y \ 15 | @development-tools \ 16 | findutils \ 17 | zlib-devel \ 18 | bzip2-devel \ 19 | ncurses-devel \ 20 | gdbm-devel \ 21 | openssl-devel \ 22 | sqlite-devel \ 23 | tk-devel \ 24 | libuuid-devel \ 25 | readline-devel \ 26 | libnsl2-devel \ 27 | xz-devel \ 28 | libffi-devel \ 29 | wget \ 30 | && git clone https://github.com/python/cpython.git \ 31 | && cd cpython \ 32 | && git checkout v3.11.0 \ 33 | && ./configure \ 34 | && make \ 35 | && make install \ 36 | && cd .. \ 37 | && rm -rf cpython \ 38 | && dnf clean all && rm -rf /var/cache/dnf/* 39 | 40 | # Other packages required for tests 41 | RUN dnf install -y \ 42 | bzip2 \ 43 | && dnf clean all && rm -rf /var/cache/dnf/* 44 | 45 | RUN pip3 install tox==3.25.1 46 | 47 | WORKDIR /tmp/work 48 | ENTRYPOINT ["/bin/bash"] 49 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora38: -------------------------------------------------------------------------------- 1 | FROM fedora:38 AS python3.12 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | tar \ 7 | gzip \ 8 | gpg \ 9 | ca-certificates \ 10 | && dnf clean all && rm -rf /var/cache/dnf/* 11 | 12 | # Development tools 13 | RUN dnf install -y \ 14 | @development-tools \ 15 | findutils \ 16 | zlib-devel \ 17 | bzip2-devel \ 18 | ncurses-devel \ 19 | gdbm-devel \ 20 | openssl-devel \ 21 | sqlite-devel \ 22 | tk-devel \ 23 | libuuid-devel \ 24 | readline-devel \ 25 | libnsl2-devel \ 26 | xz-devel \ 27 | libffi-devel \ 28 | wget \ 29 | && git clone https://github.com/python/cpython.git \ 30 | && cd cpython \ 31 | && git checkout v3.12.1 \ 32 | && ./configure \ 33 | && make \ 34 | && make install \ 35 | && cd .. \ 36 | && rm -rf cpython \ 37 | && dnf clean all && rm -rf /var/cache/dnf/* 38 | 39 | # Other packages required for tests 40 | RUN dnf install -y \ 41 | bzip2 \ 42 | && dnf clean all && rm -rf /var/cache/dnf/* 43 | 44 | RUN pip3 install tox==4.11.3 45 | 46 | WORKDIR /tmp/work 47 | ENTRYPOINT ["/bin/bash"] 48 | -------------------------------------------------------------------------------- /docker/Dockerfile-fedora40: -------------------------------------------------------------------------------- 1 | FROM fedora:40 AS python3.13 2 | 3 | # CircleCI required tools 4 | RUN dnf install -y \ 5 | git \ 6 | tar \ 7 | gzip \ 8 | gpg \ 9 | ca-certificates \ 10 | && dnf clean all && rm -rf /var/cache/dnf/* 11 | 12 | # Development tools 13 | RUN dnf install -y \ 14 | @development-tools \ 15 | findutils \ 16 | zlib-devel \ 17 | bzip2-devel \ 18 | ncurses-devel \ 19 | gdbm-devel \ 20 | openssl-devel \ 21 | sqlite-devel \ 22 | tk-devel \ 23 | libuuid-devel \ 24 | readline-devel \ 25 | libnsl2-devel \ 26 | xz-devel \ 27 | libffi-devel \ 28 | wget \ 29 | && git clone https://github.com/python/cpython.git \ 30 | && cd cpython \ 31 | && git checkout v3.13.0rc2 \ 32 | && ./configure \ 33 | && make \ 34 | && make install \ 35 | && cd .. \ 36 | && rm -rf cpython \ 37 | && dnf clean all && rm -rf /var/cache/dnf/* 38 | 39 | # Other packages required for tests 40 | RUN dnf install -y \ 41 | bzip2 \ 42 | && dnf clean all && rm -rf /var/cache/dnf/* 43 | 44 | RUN pip3 install tox==4.18.1 45 | 46 | WORKDIR /tmp/work 47 | ENTRYPOINT ["/bin/bash"] 48 | -------------------------------------------------------------------------------- /docker/Dockerfile-fuzz: -------------------------------------------------------------------------------- 1 | FROM fedora:40 AS fuzz 2 | 3 | RUN dnf install -y \ 4 | python3 \ 5 | python3-pip \ 6 | && dnf clean all && rm -rf /var/cache/dnf/* 7 | 8 | RUN pip install hypothesis[cli] hypofuzz 9 | 10 | COPY fuzz.sh /fuzz.sh 11 | 12 | WORKDIR /tmp/work 13 | ENTRYPOINT ["/fuzz.sh"] 14 | 15 | EXPOSE 9999/tcp 16 | VOLUME /tmp/work 17 | -------------------------------------------------------------------------------- /docker/build-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DATE=$(date -I) 6 | 7 | docker pull fedora:28 8 | docker build --tag danielflook/python-minifier-build:python3.3-$DATE -f Dockerfile-fedora28 --target python3.3 . 9 | 10 | docker pull fedora:30 11 | docker build --tag danielflook/python-minifier-build:python2.7-$DATE -f Dockerfile-fedora30 --target python2.7 . 12 | docker build --tag danielflook/python-minifier-build:python3.4-$DATE -f Dockerfile-fedora30 --target python3.4 . 13 | docker build --tag danielflook/python-minifier-build:python3.5-$DATE -f Dockerfile-fedora30 --target python3.5 . 14 | docker build --tag danielflook/python-minifier-build:python3.6-$DATE -f Dockerfile-fedora30 --target python3.6 . 15 | docker build --tag danielflook/python-minifier-build:python3.7-$DATE -f Dockerfile-fedora30 --target python3.7 . 16 | docker build --tag danielflook/python-minifier-build:python3.8-$DATE -f Dockerfile-fedora30 --target python3.8 . 17 | docker build --tag danielflook/python-minifier-build:pypy-$DATE -f Dockerfile-fedora30 --target pypy . 18 | docker build --tag danielflook/python-minifier-build:pypy3-$DATE -f Dockerfile-fedora30 --target pypy3 . 19 | 20 | docker pull fedora:32 21 | docker build --tag danielflook/python-minifier-build:python3.9-$DATE -f Dockerfile-fedora32 --target python3.9 . 22 | 23 | docker pull fedora:34 24 | docker build --tag danielflook/python-minifier-build:python3.10-$DATE -f Dockerfile-fedora34 --target python3.10 . 25 | 26 | docker pull fedora:36 27 | docker build --tag danielflook/python-minifier-build:python3.11-$DATE -f Dockerfile-fedora36 --target python3.11 . 28 | 29 | docker pull fedora:38 30 | docker build --tag danielflook/python-minifier-build:python3.12-$DATE -f Dockerfile-fedora38 --target python3.12 . 31 | 32 | docker pull fedora:40 33 | docker build --tag danielflook/python-minifier-build:python3.13-$DATE -f Dockerfile-fedora40 --target python3.13 . 34 | docker build --tag danielflook/python-minifier-build:fuzz-$DATE -f Dockerfile-fuzz --target fuzz . 35 | 36 | docker push danielflook/python-minifier-build:python3.3-$DATE 37 | docker push danielflook/python-minifier-build:python2.7-$DATE 38 | docker push danielflook/python-minifier-build:python3.4-$DATE 39 | docker push danielflook/python-minifier-build:python3.5-$DATE 40 | docker push danielflook/python-minifier-build:python3.6-$DATE 41 | docker push danielflook/python-minifier-build:python3.7-$DATE 42 | docker push danielflook/python-minifier-build:python3.8-$DATE 43 | docker push danielflook/python-minifier-build:python3.9-$DATE 44 | docker push danielflook/python-minifier-build:python3.10-$DATE 45 | docker push danielflook/python-minifier-build:python3.11-$DATE 46 | docker push danielflook/python-minifier-build:python3.12-$DATE 47 | docker push danielflook/python-minifier-build:python3.13-$DATE 48 | docker push danielflook/python-minifier-build:pypy-$DATE 49 | docker push danielflook/python-minifier-build:pypy3-$DATE 50 | docker push danielflook/python-minifier-build:fuzz-$DATE 51 | -------------------------------------------------------------------------------- /docker/fuzz.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pip install . 4 | 5 | exec hypothesis fuzz hypo_test -------------------------------------------------------------------------------- /docs/source/api_usage.rst: -------------------------------------------------------------------------------- 1 | Package Reference 2 | ================= 3 | 4 | .. automodule:: python_minifier 5 | 6 | .. autofunction:: minify 7 | .. autoclass:: RemoveAnnotationsOptions 8 | .. autofunction:: awslambda 9 | .. autofunction:: unparse 10 | .. autoclass:: UnstableMinification -------------------------------------------------------------------------------- /docs/source/command_usage.rst: -------------------------------------------------------------------------------- 1 | Command Usage 2 | ============= 3 | 4 | The pyminify command is installed with this package. 5 | It can be used to minify python files, and outputs the result to stdout. 6 | 7 | .. program-output:: pyminify --help -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Python-Minifier's documentation! 2 | =========================================== 3 | 4 | This package transforms python source code into a 'minified' representation of the same source code. 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | installation 11 | command_usage 12 | api_usage 13 | transforms/index 14 | 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | To install python-minifier use pip: 5 | 6 | .. code-block:: bash 7 | 8 | $ pip install python-minifier 9 | 10 | Note that python-minifier depends on the python interpreter for parsing source code, 11 | and outputs source code compatible with the version of the interpreter it is run with. 12 | 13 | This means that if you minify code written for Python 3.6 using python-minifier running with Python 3.12, 14 | the minified code may only run with Python 3.12. 15 | 16 | python-minifier runs with and can minify code written for Python 2.7 and Python 3.3 to 3.13. 17 | -------------------------------------------------------------------------------- /docs/source/transforms/combine_imports.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import collections 3 | from typing import Dict 4 | from typing import List, Optional 5 | import sys 6 | import os 7 | -------------------------------------------------------------------------------- /docs/source/transforms/combine_imports.rst: -------------------------------------------------------------------------------- 1 | Combine Imports 2 | =============== 3 | 4 | This transform combines adjacent import statements into a single statement. 5 | The order of the imports will not be changed. 6 | This transform is always safe to use and enabled by default. 7 | 8 | Disable this source transformation by passing the ``combine_imports=False`` argument to the :func:`python_minifier.minify` function, 9 | or passing ``--no-combine-imports`` to the pyminify command. 10 | 11 | Example 12 | ------- 13 | 14 | Input 15 | ~~~~~ 16 | 17 | .. literalinclude:: combine_imports.py 18 | 19 | Output 20 | ~~~~~~ 21 | 22 | .. literalinclude:: combine_imports.min.py 23 | :language: python 24 | -------------------------------------------------------------------------------- /docs/source/transforms/constant_folding.py: -------------------------------------------------------------------------------- 1 | SECONDS_IN_A_DAY = 60 * 60 * 24 2 | SECONDS_IN_A_WEEK = SECONDS_IN_A_DAY * 7 -------------------------------------------------------------------------------- /docs/source/transforms/constant_folding.rst: -------------------------------------------------------------------------------- 1 | Constant Folding 2 | ================ 3 | 4 | This transform evaluates constant expressions with literal operands when minifying and replaces the expression with the resulting value, if the value is shorter than the expression. 5 | 6 | There are some limitations, notably the division and power operators are not evaluated. 7 | 8 | This will be most effective with numeric literals. 9 | 10 | This transform is always safe and enabled by default. Disable by passing the ``constant_folding=False`` argument to the :func:`python_minifier.minify` function, 11 | or passing ``--no-constant-folding`` to the pyminify command. 12 | 13 | Example 14 | ------- 15 | 16 | Input 17 | ~~~~~ 18 | 19 | .. literalinclude:: constant_folding.py 20 | 21 | Output 22 | ~~~~~~ 23 | 24 | .. literalinclude:: constant_folding.min.py 25 | :language: python 26 | -------------------------------------------------------------------------------- /docs/source/transforms/convert_posargs_to_args.py: -------------------------------------------------------------------------------- 1 | def name(p1, p2, /, p_or_kw, *, kw): pass 2 | def name(p1, p2=None, /, p_or_kw=None, *, kw): pass 3 | def name(p1, p2=None, /, *, kw): pass 4 | def name(p1, p2=None, /): pass 5 | def name(p1, p2, /, p_or_kw): pass 6 | def name(p1, p2, /): pass -------------------------------------------------------------------------------- /docs/source/transforms/convert_posargs_to_args.rst: -------------------------------------------------------------------------------- 1 | Convert Positional-Only Arguments to Arguments 2 | ============================================== 3 | 4 | This transform converts positional-only arguments into normal arguments by removing the '/' separator in the 5 | argument list. 6 | 7 | This transform is almost always safe to use and enabled by default. 8 | 9 | Disable this source transformation by passing the ``convert_posargs_to_args=False`` argument to the :func:`python_minifier.minify` function, 10 | or passing ``--no-convert-posargs-to-args`` to the pyminify command. 11 | 12 | Example 13 | ------- 14 | 15 | Input 16 | ~~~~~ 17 | 18 | .. literalinclude:: convert_posargs_to_args.py 19 | 20 | Output 21 | ~~~~~~ 22 | 23 | .. literalinclude:: convert_posargs_to_args.min.py 24 | :language: python 25 | -------------------------------------------------------------------------------- /docs/source/transforms/hoist_literals.py: -------------------------------------------------------------------------------- 1 | def validate(arn, props): 2 | if 'ValidationMethod' in props and props['ValidationMethod'] == 'DNS': 3 | 4 | all_records_created = False 5 | while not all_records_created: 6 | all_records_created = True 7 | 8 | certificate = acm.describe_certificate(CertificateArn=arn)['Certificate'] 9 | 10 | if certificate['Status'] != 'PENDING_VALIDATION': 11 | return 12 | 13 | for v in certificate['DomainValidationOptions']: 14 | 15 | if 'ValidationStatus' not in v or 'ResourceRecord' not in v: 16 | all_records_created = False 17 | continue 18 | 19 | records = [] 20 | if v['ValidationStatus'] == 'PENDING_VALIDATION': 21 | records.append({ 22 | 'Action': 'UPSERT', 23 | 'ResourceRecordSet': { 24 | 'Name': v['ResourceRecord']['Name'], 25 | 'Type': v['ResourceRecord']['Type'], 26 | 'TTL': 60, 27 | 'ResourceRecords': [{ 28 | 'Value': v['ResourceRecord']['Value'] 29 | }] 30 | } 31 | }) 32 | 33 | if records: 34 | response = boto3.client('route53').change_resource_record_sets( 35 | HostedZoneId=get_zone_for(v['DomainName'], props), 36 | ChangeBatch={ 37 | 'Comment': 'Domain validation for %s' % arn, 38 | 'Changes': records 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /docs/source/transforms/hoist_literals.rst: -------------------------------------------------------------------------------- 1 | Hoist Literals 2 | ============== 3 | 4 | This transform replaces string and bytes literals with references to variables. 5 | It may also introduce new names for some builtin constants (True, False, None). 6 | This will only be done if multiple literals can be replaced with a single variable referenced in 7 | multiple locations (and the resulting code is smaller). 8 | 9 | If the rename_globals transform is disabled, the newly introduced global names have an underscore prefix. 10 | 11 | This transform is always safe to use and enabled by default. 12 | Disable this source transformation by passing the ``hoist_literals=False`` argument to the :func:`python_minifier.minify` function, 13 | or passing ``--no-hoist-literals`` to the pyminify command. 14 | 15 | Example 16 | ------- 17 | 18 | Input 19 | ~~~~~ 20 | 21 | .. literalinclude:: hoist_literals.py 22 | 23 | Output 24 | ~~~~~~ 25 | 26 | .. literalinclude:: hoist_literals.min.py 27 | :language: python 28 | -------------------------------------------------------------------------------- /docs/source/transforms/index.rst: -------------------------------------------------------------------------------- 1 | Minification Options 2 | ==================== 3 | 4 | These transforms can be optionally enabled when minifying. Some are enabled by default as they are always or almost 5 | always safe. 6 | 7 | They can be enabled or disabled through the minify function, or passing options to the pyminify command. 8 | 9 | .. toctree:: 10 | :caption: Enabled by default 11 | :maxdepth: 1 12 | 13 | combine_imports 14 | remove_pass 15 | hoist_literals 16 | remove_annotations 17 | rename_locals 18 | remove_object_base 19 | convert_posargs_to_args 20 | preserve_shebang 21 | remove_explicit_return_none 22 | remove_builtin_exception_brackets 23 | constant_folding 24 | 25 | .. toctree:: 26 | :caption: Disabled by default 27 | :maxdepth: 1 28 | 29 | remove_literal_statements 30 | rename_globals 31 | remove_asserts 32 | remove_debug 33 | -------------------------------------------------------------------------------- /docs/source/transforms/preserve_shebang.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | print(sys.executable) 5 | -------------------------------------------------------------------------------- /docs/source/transforms/preserve_shebang.rst: -------------------------------------------------------------------------------- 1 | Preserve Shebang 2 | ================ 3 | 4 | The shebang line indicates what interpreter should be used by the operating system when loading a python file as an executable. 5 | It does not have any meaning to python itself, but may be needed if python files should be directly executable. 6 | 7 | When this option is enabled, any shebang line is preserved in the minified output. The option is enabled by default. 8 | 9 | Disable this option by passing ``preserve_shebang=False`` to the :func:`python_minifier.minify` function, 10 | or passing ``--no-preserve-shebang`` to the pyminify command. 11 | 12 | Example 13 | ------- 14 | 15 | Input 16 | ~~~~~ 17 | 18 | .. literalinclude:: preserve_shebang.py 19 | 20 | Output 21 | ~~~~~~ 22 | 23 | .. literalinclude:: preserve_shebang.min.py 24 | :language: python 25 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_annotations.py: -------------------------------------------------------------------------------- 1 | class A: 2 | b: int 3 | c: int=2 4 | def a(self, val: str) -> None: 5 | b: int 6 | c: int=2 7 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_annotations.rst: -------------------------------------------------------------------------------- 1 | Remove Annotations 2 | ================== 3 | 4 | This transform removes annotations. Although the annotations have no meaning to the python language, 5 | they are made available at runtime. Some python library features require annotations to be kept. 6 | 7 | Annotations can be removed from: 8 | 9 | - Function arguments 10 | - Function return 11 | - Variables 12 | - Class attributes 13 | 14 | By default annotations are removed from variables, function arguments and function return, but not from class attributes. 15 | 16 | This transform is generally safe to use with the default options. If you know the module requires the annotations to be kept, disable this transform. 17 | Class attribute annotations can often be used by other modules, so it is recommended to keep them unless you know they are not used. 18 | 19 | When removing class attribute annotations is enabled, annotations are kept for classes that are derived from: 20 | 21 | - dataclasses.dataclass 22 | - typing.NamedTuple 23 | - typing.TypedDict 24 | 25 | If a variable annotation without assignment is used the annotation is changed to a literal zero instead of being removed. 26 | 27 | Options 28 | ------- 29 | 30 | These arguments can be used with the pyminify command: 31 | 32 | ``--no-remove-variable-annotations`` disables removing variable annotations 33 | 34 | ``--no-remove-return-annotations`` disables removing function return annotations 35 | 36 | ``--no-remove-argument-annotations`` disables removing function argument annotations 37 | 38 | ``--remove-class-attribute-annotations`` enables removing class attribute annotations 39 | 40 | ``--no-remove-annotations`` disables removing all annotations, this transform will not do anything. 41 | 42 | When using the :func:`python_minifier.minify` function you can use the ``remove_annotations`` argument to control this transform. 43 | You can pass a boolean ``True`` to remove all annotations or a boolean ``False`` to keep all annotations. 44 | You can also pass a :class:`python_minifier.RemoveAnnotationsOptions` instance to specify which annotations to remove. 45 | 46 | Example 47 | ------- 48 | 49 | Input 50 | ~~~~~ 51 | 52 | .. literalinclude:: remove_annotations.py 53 | 54 | Output 55 | ~~~~~~ 56 | 57 | .. literalinclude:: remove_annotations.min.py 58 | :language: python 59 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_asserts.py: -------------------------------------------------------------------------------- 1 | word = 'hello' 2 | assert word is 'goodbye' 3 | print(word) 4 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_asserts.rst: -------------------------------------------------------------------------------- 1 | Remove Asserts 2 | ============== 3 | 4 | This transform removes assert statements. 5 | 6 | Assert statements are evaluated by Python when it is not started with the `-O` option. 7 | This transform is only safe to use if the minified output will by run with the `-O` option, or 8 | you are certain that the assert statements are not needed. 9 | 10 | If a statement is required, the assert statement will be replaced by a zero expression statement. 11 | 12 | The transform is disabled by default. Enable it by passing the ``remove_asserts=True`` argument to the :func:`python_minifier.minify` function, 13 | or passing ``--remove-asserts`` to the pyminify command. 14 | 15 | Example 16 | ------- 17 | 18 | Input 19 | ~~~~~ 20 | 21 | .. literalinclude:: remove_asserts.py 22 | 23 | Output 24 | ~~~~~~ 25 | 26 | .. literalinclude:: remove_asserts.min.py 27 | :language: python 28 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_builtin_exception_brackets.py: -------------------------------------------------------------------------------- 1 | class MyBaseClass: 2 | def override_me(self): 3 | raise NotImplementedError() 4 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_builtin_exception_brackets.rst: -------------------------------------------------------------------------------- 1 | Remove Builtin Exception Brackets 2 | ================================= 3 | 4 | This transform removes parentheses when raising builtin exceptions with no arguments. 5 | 6 | The raise statement automatically instantiates exceptions with no arguments, so the parentheses are unnecessary. 7 | This transform does nothing on Python 2. 8 | 9 | If the exception is not a builtin exception, or has arguments, the parentheses are not removed. 10 | 11 | This transform is enabled by default. Disable by passing the ``remove_builtin_exception_brackets=False`` argument to the :func:`python_minifier.minify` function, 12 | or passing ``--no-remove-builtin-exception-brackets`` to the pyminify command. 13 | 14 | Example 15 | ------- 16 | 17 | Input 18 | ~~~~~ 19 | 20 | .. literalinclude:: remove_builtin_exception_brackets.py 21 | 22 | Output 23 | ~~~~~~ 24 | 25 | .. literalinclude:: remove_builtin_exception_brackets.min.py 26 | :language: python 27 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_debug.py: -------------------------------------------------------------------------------- 1 | value = 10 2 | 3 | # Truthy 4 | if __debug__: 5 | value += 1 6 | 7 | if __debug__ is True: 8 | value += 1 9 | 10 | if __debug__ is not False: 11 | value += 1 12 | 13 | if __debug__ == True: 14 | value += 1 15 | 16 | 17 | # Falsy 18 | if not __debug__: 19 | value += 1 20 | 21 | if __debug__ is False: 22 | value += 1 23 | 24 | if __debug__ is not True: 25 | value += 1 26 | 27 | if __debug__ == False: 28 | value += 1 29 | 30 | print(value) -------------------------------------------------------------------------------- /docs/source/transforms/remove_debug.rst: -------------------------------------------------------------------------------- 1 | Remove Debug 2 | ============ 3 | 4 | This transform removes ``if`` statements that test ``__debug__`` is ``True``. 5 | 6 | The builtin ``__debug__`` constant is True if Python is not started with the ``-O`` option. 7 | This transform is only safe to use if the minified output will by run with the ``-O`` option, or 8 | you are certain that any ``if`` statement that tests ``__debug__`` can be removed. 9 | 10 | The condition is not evaluated. The statement is only removed if the condition exactly matches one of the forms in the example below. 11 | 12 | If a statement is required, the ``if`` statement will be replaced by a zero expression statement. 13 | 14 | The transform is disabled by default. Enable it by passing the ``remove_debug=True`` argument to the :func:`python_minifier.minify` function, 15 | or passing ``--remove-debug`` to the pyminify command. 16 | 17 | Example 18 | ------- 19 | 20 | Input 21 | ~~~~~ 22 | 23 | .. literalinclude:: remove_debug.py 24 | 25 | Output 26 | ~~~~~~ 27 | 28 | .. literalinclude:: remove_debug.min.py 29 | :language: python 30 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_explicit_return_none.py: -------------------------------------------------------------------------------- 1 | def important(a): 2 | if a > 3: 3 | return a 4 | if a < 2: 5 | return None 6 | a.adjust(1) 7 | return None 8 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_explicit_return_none.rst: -------------------------------------------------------------------------------- 1 | Remove Explicit Return None 2 | =========================== 3 | 4 | This transforms any ``return None`` statement into a ``return`` statement, as return statement with no value is equivalent to ``return None``. 5 | Also removes any ``return None`` or ``return`` statements that are the last statement in a function. 6 | 7 | The transform is always safe to use and enabled by default. Disable by passing the ``remove_explicit_return_none=False`` argument to the :func:`python_minifier.minify` function, 8 | or passing ``--no-remove-explicit-remove-none`` to the pyminify command. 9 | 10 | Example 11 | ------- 12 | 13 | Input 14 | ~~~~~ 15 | 16 | .. literalinclude:: remove_explicit_return_none.py 17 | 18 | Output 19 | ~~~~~~ 20 | 21 | .. literalinclude:: remove_explicit_return_none.min.py 22 | :language: python 23 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_literal_statements.py: -------------------------------------------------------------------------------- 1 | """This is my module docstring""" 2 | 3 | 'This is another string that has no runtime effect' 4 | b'Bytes literal' 5 | 0 6 | 1000 7 | 8 | def test(): 9 | 'Function docstring' 10 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_literal_statements.rst: -------------------------------------------------------------------------------- 1 | Remove Literal Statements 2 | ========================= 3 | 4 | This transform removes statements that consist entirely of a literal value. This includes docstrings. 5 | If a statement is required, it is replaced by a literal zero expression statement. 6 | 7 | This transform will strip docstrings from the source. If the module uses the ``__doc__`` name the module docstring will 8 | be retained. 9 | 10 | This transform is disabled by default. Enable by passing the ``remove_literal_statements=True`` argument to the :func:`python_minifier.minify` function, 11 | or passing ``--remove-literal-statements`` to the pyminify command. 12 | 13 | Example 14 | ------- 15 | 16 | Input 17 | ~~~~~ 18 | 19 | .. literalinclude:: remove_literal_statements.py 20 | 21 | Output 22 | ~~~~~~ 23 | 24 | .. literalinclude:: remove_literal_statements.min.py 25 | :language: python 26 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_object_base.py: -------------------------------------------------------------------------------- 1 | class MyClass(object): 2 | pass -------------------------------------------------------------------------------- /docs/source/transforms/remove_object_base.rst: -------------------------------------------------------------------------------- 1 | Remove Object Base 2 | ================== 3 | 4 | In Python 3 all classes implicitly inherit from ``object``. This transform removes ``object`` from the base class list 5 | of all classes. This transform does nothing on Python 2. 6 | 7 | This transform is always safe to use and enabled by default. 8 | 9 | Disable this source transformation by passing the ``remove_object_base=False`` argument to the :func:`python_minifier.minify` function, 10 | or passing ``--no-remove-object-base`` to the pyminify command. 11 | 12 | Example 13 | ------- 14 | 15 | Input 16 | ~~~~~ 17 | 18 | .. literalinclude:: remove_object_base.py 19 | 20 | Output 21 | ~~~~~~ 22 | 23 | .. literalinclude:: remove_object_base.min.py 24 | :language: python 25 | -------------------------------------------------------------------------------- /docs/source/transforms/remove_pass.py: -------------------------------------------------------------------------------- 1 | pass 2 | def test(): 3 | pass 4 | pass 5 | pass -------------------------------------------------------------------------------- /docs/source/transforms/remove_pass.rst: -------------------------------------------------------------------------------- 1 | Remove Pass 2 | =========== 3 | 4 | This transform removes pass statements. If a statement is required, 5 | it is replaced by a literal zero expression statement. 6 | 7 | This transform is always safe to use and enabled by default. 8 | 9 | Disable this source transformation by passing the ``remove_pass=False`` argument to the :func:`python_minifier.minify` function, 10 | or passing ``--no-remove-pass`` to the pyminify command. 11 | 12 | Example 13 | ------- 14 | 15 | Input 16 | ~~~~~ 17 | 18 | .. literalinclude:: remove_pass.py 19 | 20 | Output 21 | ~~~~~~ 22 | 23 | .. literalinclude:: remove_pass.min.py 24 | :language: python 25 | -------------------------------------------------------------------------------- /docs/source/transforms/rename_globals.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | my_counter = collections.Counter([True, True, True, False, False]) 4 | 5 | print('Contents:') 6 | print(list(my_counter)) 7 | -------------------------------------------------------------------------------- /docs/source/transforms/rename_globals.rst: -------------------------------------------------------------------------------- 1 | Rename Globals 2 | ============== 3 | 4 | This transform shortens names in the module scope. This includes introducing short names for builtins. 5 | 6 | This could break any program that imports the minified module. For this reason the transform is disabled by 7 | default. 8 | 9 | When enabled, all global names may be renamed if it is space efficient. This includes: 10 | 11 | - Global variables 12 | - Global import aliases 13 | - Global function names 14 | - Global class names 15 | - Builtin names may be bound to a new name in the module scope 16 | 17 | Renaming is prevented by: 18 | 19 | - If ``eval()``, ``exec()``, ``locals()``, ``globals()``, ``vars()`` are used, renaming is disabled 20 | - If ``from import *`` is used in the module, renaming is disabled 21 | - If a name is included as a literal string in ``__all__``, renaming of that name is disabled 22 | - Any name listed in the ``preserve_globals`` argument 23 | 24 | Enable this source transformation by passing the ``rename_globals=True`` argument to the :func:`python_minifier.minify` 25 | function. The ``preserve_globals`` argument is a list of names to disable renaming for. 26 | 27 | When using the pyminify command enable this transformation with ``--rename-globals``. The ``--preserve_globals`` option 28 | may be a comma separated list of names to prevent renaming. 29 | 30 | Example 31 | ------- 32 | 33 | Input 34 | ~~~~~ 35 | 36 | .. literalinclude:: rename_globals.py 37 | 38 | Output 39 | ~~~~~~ 40 | 41 | .. literalinclude:: rename_globals.min.py 42 | :language: python 43 | -------------------------------------------------------------------------------- /docs/source/transforms/rename_locals.py: -------------------------------------------------------------------------------- 1 | def rename_locals_example(module, another_argument=False, third_argument=None): 2 | 3 | if third_argument is None: 4 | third_argument = [] 5 | 6 | third_argument.extend(module) 7 | 8 | for thing in module.things: 9 | if another_argument is False or thing.name in third_argument: 10 | thing.my_method() -------------------------------------------------------------------------------- /docs/source/transforms/rename_locals.rst: -------------------------------------------------------------------------------- 1 | Rename Locals 2 | ============= 3 | 4 | This transform shortens any non-global names. 5 | 6 | This transform is almost always safe to use and enabled by default. 7 | 8 | When enabled all non-global names may be renamed if it is space efficient and safe to do so. This includes: 9 | 10 | - Local variables 11 | - Functions in function scope 12 | - Classes in function scope 13 | - Local imports 14 | - Comprehension target names 15 | - Function arguments that are not typically referenced by the caller (`self`, `cls`, `args`, `kwargs`) 16 | - Positional only function arguments 17 | - Possible keyword function arguments may be bound with a new name in the function body, without changing the function signature 18 | - Exception handler target names 19 | 20 | This will not change: 21 | 22 | - Global names 23 | - Names in class scope 24 | - Lambda function arguments (except args/kwargs and positional only args) 25 | 26 | New names are assigned according to the smallest minified result. To conserve the pool of available shortest names 27 | they are reused in sibling namespaces and shadowed in child namespaces. 28 | 29 | Disable this source transformation by passing the ``rename_locals=False`` argument to the :func:`python_minifier.minify` 30 | function. The ``preserve_locals`` argument is a list of names to disable renaming for. 31 | 32 | When using the pyminify command disable this transformation with ``--no-rename-locals``. The ``--preserve_locals`` option 33 | may be a comma separated list of names to prevent renaming. 34 | 35 | Use of some python builtins (``vars()``, ``exec()``, ``locals()``, ``globals()``, ``eval()``) in the minified module 36 | will disable this transform, as it usually indicates usage of names that this transform can't recognise. 37 | 38 | Example 39 | ------- 40 | 41 | Input 42 | ~~~~~ 43 | 44 | .. literalinclude:: rename_locals.py 45 | 46 | Output 47 | ~~~~~~ 48 | 49 | .. literalinclude:: rename_locals.min.py 50 | :language: python 51 | -------------------------------------------------------------------------------- /hypo_test/README.md: -------------------------------------------------------------------------------- 1 | # Hypothesis tests 2 | 3 | The hypothesis strategies in this directory generate an AST that python can parse. 4 | It does not take care to generate semantically valid programs. 5 | Failure cases should shrink into valid programs, though. 6 | 7 | TODO: 8 | Assignment targets: (in comprehensions too) 9 | 10 | - Tuples, sets?? 11 | Starred 12 | Call arguments 13 | Delete targets 14 | ImportFrom levels 15 | functiondef args 16 | Await 17 | f-strings 18 | -------------------------------------------------------------------------------- /hypo_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dflook/python-minifier/b5e9446320eff34539b0226af636088411d18a90/hypo_test/__init__.py -------------------------------------------------------------------------------- /hypo_test/folding.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from hypothesis.strategies import SearchStrategy, composite, lists, recursive, sampled_from 4 | 5 | from .expressions import NameConstant, Num 6 | 7 | leaves = NameConstant() | Num() 8 | 9 | 10 | @composite 11 | def BinOp(draw, expression) -> ast.BinOp: 12 | op = draw( 13 | sampled_from( 14 | [ 15 | ast.Add(), 16 | ast.Sub(), 17 | ast.Mult(), 18 | ast.Div(), 19 | ast.FloorDiv(), 20 | ast.Mod(), 21 | ast.Pow(), 22 | ast.LShift(), 23 | ast.RShift(), 24 | ast.BitOr(), 25 | ast.BitXor(), 26 | ast.BitAnd(), 27 | ast.MatMult() 28 | ] 29 | ) 30 | ) 31 | 32 | le = draw(lists(expression, min_size=2, max_size=2)) 33 | 34 | return ast.BinOp(le[0], op, le[1]) 35 | 36 | 37 | def expression() -> SearchStrategy: 38 | return recursive( 39 | leaves, 40 | lambda expression: 41 | BinOp(expression), 42 | max_leaves=150 43 | ) 44 | 45 | 46 | @composite 47 | def FoldableExpression(draw) -> ast.Expression: 48 | """ An eval expression """ 49 | e = draw(expression()) 50 | return ast.Expression(e) 51 | -------------------------------------------------------------------------------- /hypo_test/patterns.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import keyword 3 | import string 4 | 5 | from hypothesis import assume 6 | from hypothesis.strategies import booleans, composite, integers, lists, none, one_of, recursive, sampled_from, text 7 | 8 | 9 | @composite 10 | def name(draw): 11 | n = draw(text(alphabet=string.ascii_letters, min_size=1, max_size=3)) 12 | 13 | assume(n not in keyword.kwlist) 14 | 15 | return n 16 | 17 | 18 | @composite 19 | def MatchValue(draw) -> ast.MatchValue: 20 | return ast.MatchValue(ast.Constant(0)) 21 | 22 | 23 | @composite 24 | def MatchSingleton(draw) -> ast.MatchSingleton: 25 | return ast.MatchSingleton(draw(sampled_from([None, True, False]))) 26 | 27 | 28 | @composite 29 | def MatchStar(draw) -> ast.MatchStar: 30 | return ast.MatchStar(name=draw(sampled_from([None, 'rest']))) 31 | 32 | 33 | @composite 34 | def MatchSequence(draw, pattern) -> ast.MatchSequence: 35 | l = draw(lists(pattern, min_size=1, max_size=3)) 36 | 37 | has_star = draw(booleans()) 38 | 39 | if has_star: 40 | star_pos = draw(integers(min_value=0, max_value=len(l))) 41 | l.insert(star_pos, draw(MatchStar())) 42 | 43 | return ast.MatchSequence(patterns=l) 44 | 45 | 46 | @composite 47 | def MatchMapping(draw, pattern) -> ast.MatchMapping: 48 | l = draw(lists(pattern, min_size=1, max_size=3)) 49 | 50 | match_mapping = ast.MatchMapping(keys=[ast.Num(0) for i in range(len(l))], patterns=l) 51 | 52 | has_star = draw(booleans()) 53 | if has_star: 54 | match_mapping.rest = 'rest' 55 | 56 | return match_mapping 57 | 58 | 59 | @composite 60 | def MatchClass(draw, pattern) -> ast.MatchClass: 61 | patterns = draw(lists(pattern, min_size=0, max_size=3)) 62 | 63 | kwd_patterns = draw(lists(pattern, min_size=0, max_size=3)) 64 | kwd = ['a' for i in range(len(kwd_patterns))] 65 | 66 | return ast.MatchClass( 67 | cls=ast.Name(draw(name()), ctx=ast.Load()), 68 | patterns=patterns, 69 | kwd_attrs=kwd, 70 | kwd_patterns=kwd_patterns 71 | ) 72 | 73 | 74 | @composite 75 | def MatchAs(draw, pattern) -> ast.MatchAs: 76 | n = draw(none() | name()) 77 | 78 | if n is None: 79 | p = None 80 | else: 81 | p = draw(pattern) 82 | 83 | return ast.MatchAs(pattern=p, name=n) 84 | 85 | 86 | @composite 87 | def MatchOr(draw, pattern) -> ast.MatchOr: 88 | l = draw(lists(pattern, min_size=2, max_size=3)) 89 | return ast.MatchOr(patterns=l) 90 | 91 | 92 | leaves = MatchValue() | MatchSingleton() 93 | 94 | 95 | def pattern(): 96 | return recursive( 97 | leaves, 98 | lambda pattern: 99 | one_of( 100 | MatchSequence(pattern), 101 | MatchMapping(pattern), 102 | MatchClass(pattern), 103 | MatchAs(pattern), 104 | MatchOr(pattern) 105 | ), 106 | max_leaves=150 107 | ) 108 | 109 | 110 | @composite 111 | def Pattern(draw): 112 | """ A Match case pattern """ 113 | return draw(pattern()) 114 | -------------------------------------------------------------------------------- /hypo_test/test_it.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from datetime import timedelta 4 | 5 | from hypothesis import HealthCheck, Verbosity, given, note, settings 6 | 7 | from python_minifier.ast_compare import compare_ast 8 | from python_minifier.ast_printer import print_ast 9 | from python_minifier.expression_printer import ExpressionPrinter 10 | from python_minifier.module_printer import ModulePrinter 11 | from python_minifier.rename.mapper import add_parent 12 | from python_minifier.transforms.constant_folding import FoldConstants 13 | 14 | from .expressions import Expression 15 | from .folding import FoldableExpression 16 | from .module import Module, TypeAlias 17 | from .patterns import Pattern 18 | 19 | 20 | @given(node=Expression()) 21 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=1), max_examples=100, suppress_health_check=[HealthCheck.too_slow]) # verbosity=Verbosity.verbose 22 | def test_expression(node): 23 | assert isinstance(node, ast.AST) 24 | 25 | note(ast.dump(node)) 26 | printer = ExpressionPrinter() 27 | code = printer(node) 28 | note(code) 29 | compare_ast(node, ast.parse(code, 'test_expression', 'eval')) 30 | 31 | 32 | @given(node=Module()) 33 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=1), max_examples=100, suppress_health_check=[HealthCheck.too_slow], verbosity=Verbosity.verbose) 34 | def test_module(node): 35 | assert isinstance(node, ast.Module) 36 | 37 | note(ast.dump(node)) 38 | printer = ModulePrinter() 39 | code = printer(node) 40 | note(code) 41 | compare_ast(node, ast.parse(code, 'test_module')) 42 | 43 | 44 | @given(node=Pattern()) 45 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=2), max_examples=100, verbosity=Verbosity.verbose) 46 | def test_pattern(node): 47 | 48 | module = ast.Module( 49 | body=[ast.Match( 50 | subject=ast.Constant(value=None), 51 | cases=[ 52 | ast.match_case( 53 | pattern=node, 54 | guard=None, 55 | body=[ast.Pass()] 56 | ) 57 | ] 58 | )], 59 | type_ignores=[] 60 | ) 61 | 62 | printer = ModulePrinter() 63 | code = printer(module) 64 | note(code) 65 | compare_ast(module, ast.parse(code, 'test_pattern')) 66 | 67 | 68 | @given(node=FoldableExpression()) 69 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=1), max_examples=1000, suppress_health_check=[HealthCheck.too_slow]) # verbosity=Verbosity.verbose 70 | def test_folding(node): 71 | assert isinstance(node, ast.AST) 72 | note(print_ast(node)) 73 | 74 | add_parent(node) 75 | 76 | constant_folder = FoldConstants() 77 | 78 | # The constant folder asserts the value is correct 79 | constant_folder(node) 80 | 81 | 82 | @given(node=TypeAlias()) 83 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=2), max_examples=100, verbosity=Verbosity.verbose) 84 | def test_type_alias(node): 85 | 86 | module = ast.Module( 87 | body=[node], 88 | type_ignores=[] 89 | ) 90 | 91 | printer = ModulePrinter() 92 | code = printer(module) 93 | note(code) 94 | compare_ast(module, ast.parse(code, 'test_type_alias')) 95 | 96 | 97 | @given(node=TypeAlias()) 98 | @settings(report_multiple_bugs=False, deadline=timedelta(seconds=2), max_examples=100, verbosity=Verbosity.verbose) 99 | def test_function_type_param(node): 100 | 101 | module = ast.Module( 102 | body=[ast.FunctionDef( 103 | name='test', 104 | args=ast.arguments( 105 | posonlyargs=[], 106 | args=[], 107 | vararg=None, 108 | kwonlyargs=[], 109 | kw_defaults=[], 110 | kwarg=None, 111 | defaults=[], 112 | ), 113 | body=[ast.Pass()], 114 | type_params=node.type_params, 115 | decorator_list=[], 116 | returns=None 117 | )], 118 | type_ignores=[] 119 | ) 120 | 121 | printer = ModulePrinter() 122 | code = printer(module) 123 | note(code) 124 | compare_ast(module, ast.parse(code, 'test_function_type_param')) 125 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | virtualenv 3 | pytest 4 | sphinx 5 | sphinxcontrib-programoutput 6 | sphinx_rtd_theme 7 | pyyaml 8 | sh 9 | hypothesis 10 | hypofuzz 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import setup, find_packages 4 | 5 | readme_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'README.md') 6 | with open(readme_path) as f: 7 | long_desc = f.read() 8 | 9 | setup( 10 | name='python_minifier', 11 | description='Transform Python source code into it\'s most compact representation', 12 | author='Daniel Flook', 13 | author_email='daniel@flook.org', 14 | url='https://github.com/dflook/python-minifier', 15 | license='MIT', 16 | project_urls={ 17 | 'Issues': 'https://github.com/dflook/python-minifier/issues', 18 | 'Documentation': 'https://dflook.github.io/python-minifier/', 19 | 'Changelog': 'https://github.com/dflook/python-minifier/blob/main/CHANGELOG.md' 20 | }, 21 | keywords='minify minifier', 22 | 23 | use_scm_version=True, 24 | package_dir={'': 'src'}, 25 | packages=find_packages('src'), 26 | package_data={"python_minifier": ["py.typed", "*.pyi", "rename/*.pyi", "transforms/*.pyi"]}, 27 | long_description=long_desc, 28 | long_description_content_type='text/markdown', 29 | 30 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <3.14', 31 | setup_requires=['setuptools_scm'], 32 | 33 | classifiers=[ 34 | 'Development Status :: 5 - Production/Stable', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.3', 39 | 'Programming Language :: Python :: 3.4', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Programming Language :: Python :: 3.7', 43 | 'Programming Language :: Python :: 3.8', 44 | 'Programming Language :: Python :: 3.9', 45 | 'Programming Language :: Python :: 3.10', 46 | 'Programming Language :: Python :: 3.11', 47 | 'Programming Language :: Python :: 3.12', 48 | 'Programming Language :: Python :: 3.13', 49 | 'Programming Language :: Python :: 2', 50 | 'Programming Language :: Python :: 2.7', 51 | 'Programming Language :: Python :: Implementation :: CPython', 52 | 'Programming Language :: Python :: Implementation :: PyPy', 53 | 'Intended Audience :: Developers', 54 | 'Topic :: Software Development' 55 | ], 56 | 57 | entry_points={ 58 | 'console_scripts': ['pyminify=python_minifier.__main__:main'] 59 | }, 60 | 61 | zip_safe=True 62 | ) 63 | -------------------------------------------------------------------------------- /src/python_minifier/__init__.pyi: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from typing import Any, AnyStr, List, Optional, Text, Union 4 | 5 | from .transforms.remove_annotations_options import RemoveAnnotationsOptions as RemoveAnnotationsOptions 6 | 7 | 8 | class UnstableMinification(RuntimeError): 9 | def __init__(self, exception: Any, source: Any, minified: Any): ... 10 | 11 | 12 | def minify( 13 | source: AnyStr, 14 | filename: Optional[str] = ..., 15 | remove_annotations: Union[bool, RemoveAnnotationsOptions] = ..., 16 | remove_pass: bool = ..., 17 | remove_literal_statements: bool = ..., 18 | combine_imports: bool = ..., 19 | hoist_literals: bool = ..., 20 | rename_locals: bool = ..., 21 | preserve_locals: Optional[List[Text]] = ..., 22 | rename_globals: bool = ..., 23 | preserve_globals: Optional[List[Text]] = ..., 24 | remove_object_base: bool = ..., 25 | convert_posargs_to_args: bool = ..., 26 | preserve_shebang: bool = ..., 27 | remove_asserts: bool = ..., 28 | remove_debug: bool = ..., 29 | remove_explicit_return_none: bool = ..., 30 | remove_builtin_exception_brackets: bool = ..., 31 | constant_folding: bool = ... 32 | ) -> Text: ... 33 | 34 | 35 | def unparse(module: ast.Module) -> Text: ... 36 | 37 | 38 | def awslambda( 39 | source: AnyStr, 40 | filename: Optional[Text] = ..., 41 | entrypoint: Optional[Text] = ... 42 | ) -> Text: ... 43 | -------------------------------------------------------------------------------- /src/python_minifier/ast_annotation/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides utilities for annotating Abstract Syntax Tree (AST) nodes with parent references. 3 | """ 4 | 5 | import ast 6 | 7 | class _NoParent(ast.AST): 8 | """A placeholder class used to indicate that a node has no parent.""" 9 | 10 | def __repr__(self): 11 | # type: () -> str 12 | return 'NoParent()' 13 | 14 | def add_parent(node, parent=_NoParent()): 15 | # type: (ast.AST, ast.AST) -> None 16 | """ 17 | Recursively adds a parent reference to each node in the AST. 18 | 19 | >>> tree = ast.parse('a = 1') 20 | >>> add_parent(tree) 21 | >>> get_parent(tree.body[0]) == tree 22 | True 23 | 24 | :param node: The current AST node. 25 | :param parent: The parent :class:`ast.AST` node. 26 | """ 27 | 28 | node._parent = parent # type: ignore[attr-defined] 29 | for child in ast.iter_child_nodes(node): 30 | add_parent(child, node) 31 | 32 | def get_parent(node): 33 | # type: (ast.AST) -> ast.AST 34 | """ 35 | Retrieves the parent of the given AST node. 36 | 37 | >>> tree = ast.parse('a = 1') 38 | >>> add_parent(tree) 39 | >>> get_parent(tree.body[0]) == tree 40 | True 41 | 42 | :param node: The AST node whose parent is to be retrieved. 43 | :return: The parent AST node. 44 | :raises ValueError: If the node has no parent. 45 | """ 46 | 47 | if not hasattr(node, '_parent') or isinstance(node._parent, _NoParent): # type: ignore[attr-defined] 48 | raise ValueError('Node has no parent') 49 | 50 | return node._parent # type: ignore[attr-defined] 51 | 52 | def set_parent(node, parent): 53 | # type: (ast.AST, ast.AST) -> None 54 | """ 55 | Replace the parent of the given AST node. 56 | 57 | Create a simple AST: 58 | >>> tree = ast.parse('a = func()') 59 | >>> add_parent(tree) 60 | >>> isinstance(tree.body[0], ast.Assign) and isinstance(tree.body[0].value, ast.Call) 61 | True 62 | >>> assign = tree.body[0] 63 | >>> call = tree.body[0].value 64 | >>> get_parent(call) == assign 65 | True 66 | 67 | Replace the parent of the call node: 68 | >>> tree.body[0] = call 69 | >>> set_parent(call, tree) 70 | >>> get_parent(call) == tree 71 | True 72 | >>> from python_minifier.ast_printer import print_ast 73 | >>> print(print_ast(tree)) 74 | Module(body=[ 75 | Call(Name('func')) 76 | ]) 77 | 78 | :param node: The AST node whose parent is to be set. 79 | :param parent: The parent AST node. 80 | """ 81 | 82 | node._parent = parent # type: ignore[attr-defined] 83 | -------------------------------------------------------------------------------- /src/python_minifier/ast_compare.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | 4 | class CompareError(RuntimeError): 5 | """ 6 | Raised when an AST compares unequal. 7 | """ 8 | 9 | def __init__(self, lnode, rnode, msg=None): 10 | self.lnode = lnode 11 | self.rnode = rnode 12 | self.msg = msg 13 | 14 | def __repr__(self): 15 | return 'NodeError(%r, %r)' % (self.lnode, self.rnode) 16 | 17 | def namespace(self, node): 18 | if hasattr(node, 'namespace'): 19 | if isinstance(node.namespace, (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef)): 20 | return self.namespace(node.namespace) + '.' + node.namespace.name 21 | elif isinstance(node.namespace, ast.Module): 22 | return '' 23 | else: 24 | return repr(node.namespace.__class__) 25 | 26 | return None 27 | 28 | def __str__(self): 29 | error = '' 30 | 31 | if self.msg: 32 | error += self.msg 33 | 34 | if self.namespace(self.lnode): 35 | error += ' in namespace ' + self.namespace(self.lnode) 36 | 37 | if self.lnode and hasattr(self.lnode, 'lineno'): 38 | error += ' at source %i:%i' % (self.lnode.lineno, self.lnode.col_offset) 39 | 40 | return error 41 | 42 | 43 | def compare_ast(l_ast, r_ast): 44 | """ 45 | Compare Python Abstract Syntax Trees 46 | 47 | >>> compare_ast(l_ast, r_ast) 48 | 49 | If the AST's are not identical, an exception will be raised. 50 | 51 | """ 52 | 53 | def counter(): 54 | i = 0 55 | while True: 56 | yield i 57 | i += 1 58 | 59 | if type(l_ast) != type(r_ast): 60 | raise CompareError(l_ast, r_ast, msg='Nodes do not match! %r != %r' % (l_ast, r_ast)) 61 | 62 | for field in set(l_ast._fields + r_ast._fields): 63 | 64 | if field == 'kind' and isinstance(l_ast, ast.Constant): 65 | continue 66 | 67 | if isinstance(getattr(l_ast, field, None), list): 68 | 69 | l_list = getattr(l_ast, field, None) 70 | r_list = getattr(r_ast, field, None) 71 | 72 | if len(l_list) != len(r_list): 73 | raise CompareError( 74 | l_list, 75 | r_list, 76 | 'List does not have the same number of elements! len(%s.%s)=%r, len(%s.%s)=%r' 77 | % (type(l_ast), field, len(l_list), type(r_ast), field, len(r_list)), 78 | ) 79 | 80 | for i, l, r in zip(counter(), l_list, r_list): 81 | if isinstance(l, ast.AST) or isinstance(r, ast.AST): 82 | compare_ast(l, r) 83 | elif l != r: 84 | raise CompareError( 85 | l_ast, 86 | r_ast, 87 | 'Fields do not match! %s.%s[%i]=%r, %s.%s[%i]=%r' 88 | % (type(l_ast), field, i, l, type(r_ast), field, i, r), 89 | ) 90 | 91 | else: 92 | l = getattr(l_ast, field, None) 93 | r = getattr(r_ast, field, None) 94 | 95 | if isinstance(l, ast.AST) or isinstance(r, ast.AST): 96 | compare_ast(l, r) 97 | elif l != r: 98 | raise CompareError( 99 | l_ast, 100 | r_ast, 101 | 'Fields do not match! %s.%s=%r, %s.%s=%r' % (type(l_ast), field, l, type(r_ast), field, r), 102 | ) 103 | -------------------------------------------------------------------------------- /src/python_minifier/ast_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | The is a backwards compatible shim for the ast module. 3 | 4 | This is the best way to make the ast module work the same in both python 2 and 3. 5 | This is essentially what the ast module was doing until 3.12, when it started throwing 6 | deprecation warnings. 7 | """ 8 | 9 | from ast import * 10 | 11 | 12 | # Ideally we don't import anything else 13 | 14 | if 'TypeAlias' in globals(): 15 | 16 | # Add n and s properties to Constant so it can stand in for Num, Str and Bytes 17 | Constant.n = property(lambda self: self.value, lambda self, value: setattr(self, 'value', value)) # type: ignore[assignment] 18 | Constant.s = property(lambda self: self.value, lambda self, value: setattr(self, 'value', value)) # type: ignore[assignment] 19 | 20 | 21 | # These classes are redefined from the ones in ast that complain about deprecation 22 | # They will continue to work once they are removed from ast 23 | 24 | class Str(Constant): # type: ignore[no-redef] 25 | def __new__(cls, s, *args, **kwargs): 26 | return Constant(value=s, *args, **kwargs) 27 | 28 | 29 | class Bytes(Constant): # type: ignore[no-redef] 30 | def __new__(cls, s, *args, **kwargs): 31 | return Constant(value=s, *args, **kwargs) 32 | 33 | 34 | class Num(Constant): # type: ignore[no-redef] 35 | def __new__(cls, n, *args, **kwargs): 36 | return Constant(value=n, *args, **kwargs) 37 | 38 | 39 | class NameConstant(Constant): # type: ignore[no-redef] 40 | def __new__(cls, *args, **kwargs): 41 | return Constant(*args, **kwargs) 42 | 43 | 44 | class Ellipsis(Constant): # type: ignore[no-redef] 45 | def __new__(cls, *args, **kwargs): 46 | return Constant(value=literal_eval('...'), *args, **kwargs) 47 | 48 | 49 | # Create a dummy class for missing AST nodes 50 | for _node_type in [ 51 | 'AnnAssign', 52 | 'AsyncFor', 53 | 'AsyncFunctionDef', 54 | 'AsyncFunctionDef', 55 | 'AsyncWith', 56 | 'Bytes', 57 | 'Constant', 58 | 'DictComp', 59 | 'Exec', 60 | 'ListComp', 61 | 'MatchAs', 62 | 'MatchMapping', 63 | 'MatchStar', 64 | 'NameConstant', 65 | 'NamedExpr', 66 | 'Nonlocal', 67 | 'ParamSpec', 68 | 'SetComp', 69 | 'Starred', 70 | 'TryStar', 71 | 'TypeVar', 72 | 'TypeVarTuple', 73 | 'YieldFrom', 74 | 'arg', 75 | 'withitem', 76 | ]: 77 | if _node_type not in globals(): 78 | globals()[_node_type] = type(_node_type, (AST,), {}) -------------------------------------------------------------------------------- /src/python_minifier/ast_printer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Print a representation of an AST 3 | 4 | This prints a human readable representation of the nodes in the AST. 5 | The goal is to make it easy to see what the AST looks like, and to 6 | make it easy to compare two ASTs. 7 | 8 | This is not intended to be a complete representation of the AST, some 9 | fields or field names may be omitted for clarity. It should still be precise and unambiguous. 10 | 11 | """ 12 | 13 | import python_minifier.ast_compat as ast 14 | 15 | from python_minifier.util import is_constant_node 16 | 17 | 18 | INDENT = ' ' 19 | 20 | # The field name that can be omitted for each node 21 | # Either it's the only field or would otherwise be obvious 22 | default_fields = { 23 | 'Constant': 'value', 24 | 'Num': 'n', 25 | 'Str': 's', 26 | 'Bytes': 's', 27 | 'NameConstant': 'value', 28 | 'FormattedValue': 'value', 29 | 'JoinedStr': 'values', 30 | 'List': 'elts', 31 | 'Tuple': 'elts', 32 | 'Set': 'elts', 33 | 'Name': 'id', 34 | 'Expr': 'value', 35 | 'UnaryOp': 'op', 36 | 'BinOp': 'op', 37 | 'BoolOp': 'op', 38 | 'Call': 'func', 39 | 'Index': 'value', 40 | 'ExtSlice': 'dims', 41 | 'Assert': 'test', 42 | 'Delete': 'targets', 43 | 'Import': 'names', 44 | 'If': 'test', 45 | 'While': 'test', 46 | 'Try': 'handlers', 47 | 'TryExcept': 'handlers', 48 | 'With': 'items', 49 | 'withitem': 'context_expr', 50 | 'FunctionDef': 'name', 51 | 'arg': 'arg', 52 | 'Return': 'value', 53 | 'Yield': 'value', 54 | 'YieldFrom': 'value', 55 | 'Global': 'names', 56 | 'Nonlocal': 'names', 57 | 'ClassDef': 'name', 58 | 'AsyncFunctionDef': 'name', 59 | 'Await': 'value', 60 | 'AsyncWith': 'items', 61 | 'Raise': 'exc', 62 | 'Subscript': 'value', 63 | 'Attribute': 'value', 64 | 'AugAssign': 'op', 65 | } 66 | 67 | 68 | def is_literal(node, field): 69 | if hasattr(ast, 'Constant') and isinstance(node, ast.Constant) and field == 'value': 70 | return True 71 | 72 | if is_constant_node(node, ast.Num) and field == 'n': 73 | return True 74 | 75 | if is_constant_node(node, ast.Str) and field == 's': 76 | return True 77 | 78 | if is_constant_node(node, ast.Bytes) and field == 's': 79 | return True 80 | 81 | if is_constant_node(node, ast.NameConstant) and field == 'value': 82 | return True 83 | 84 | return False 85 | 86 | 87 | def print_ast(node): 88 | if not isinstance(node, ast.AST): 89 | return repr(node) 90 | 91 | s = '' 92 | 93 | node_name = node.__class__.__name__ 94 | s += node_name 95 | s += '(' 96 | 97 | first = True 98 | for field, value in ast.iter_fields(node): 99 | if not value and not is_literal(node, field): 100 | # Don't bother printing fields that are empty, except for literals 101 | continue 102 | 103 | if field == 'ctx': 104 | # Don't print the ctx, it's always apparent from context 105 | continue 106 | 107 | if first: 108 | first = False 109 | else: 110 | s += ', ' 111 | 112 | if default_fields.get(node_name) != field: 113 | s += field + '=' 114 | 115 | if isinstance(value, ast.AST): 116 | s += print_ast(value) 117 | elif isinstance(value, list): 118 | s += '[' 119 | first_list = True 120 | for item in value: 121 | if first_list: 122 | first_list = False 123 | else: 124 | s += ',' 125 | 126 | for line in print_ast(item).splitlines(): 127 | s += '\n' + INDENT + line 128 | s += '\n]' 129 | else: 130 | s += repr(value) 131 | 132 | s += ')' 133 | return s 134 | -------------------------------------------------------------------------------- /src/python_minifier/ministring.py: -------------------------------------------------------------------------------- 1 | BACKSLASH = '\\' 2 | 3 | 4 | class MiniString(object): 5 | """ 6 | Create a representation of a string object 7 | 8 | :param str string: The string to minify 9 | 10 | """ 11 | 12 | def __init__(self, string, quote="'"): 13 | self._s = string 14 | self.safe_mode = False 15 | self.quote = quote 16 | 17 | def __str__(self): 18 | """ 19 | The smallest python literal representation of a string 20 | 21 | :rtype: str 22 | 23 | """ 24 | 25 | if self._s == '': 26 | return '' 27 | 28 | if len(self.quote) == 1: 29 | s = self.to_short() 30 | else: 31 | s = self.to_long() 32 | 33 | try: 34 | eval(self.quote + s + self.quote) 35 | except (UnicodeDecodeError, UnicodeEncodeError): 36 | if self.safe_mode: 37 | raise 38 | 39 | self.safe_mode = True 40 | if len(self.quote) == 1: 41 | s = self.to_short() 42 | else: 43 | s = self.to_long() 44 | 45 | assert eval(self.quote + s + self.quote) == self._s 46 | 47 | return s 48 | 49 | def to_short(self): 50 | s = '' 51 | 52 | escaped = { 53 | '\n': BACKSLASH + 'n', 54 | '\\': BACKSLASH + BACKSLASH, 55 | '\a': BACKSLASH + 'a', 56 | '\b': BACKSLASH + 'b', 57 | '\f': BACKSLASH + 'f', 58 | '\r': BACKSLASH + 'r', 59 | '\t': BACKSLASH + 't', 60 | '\v': BACKSLASH + 'v', 61 | '\0': BACKSLASH + 'x00', 62 | self.quote: BACKSLASH + self.quote, 63 | } 64 | 65 | for c in self._s: 66 | if c in escaped: 67 | s += escaped[c] 68 | else: 69 | if self.safe_mode: 70 | unicode_value = ord(c) 71 | if unicode_value <= 0x7F: 72 | s += c 73 | elif unicode_value <= 0xFFFF: 74 | s += BACKSLASH + 'u' + format(unicode_value, '04x') 75 | else: 76 | s += BACKSLASH + 'U' + format(unicode_value, '08x') 77 | else: 78 | s += c 79 | 80 | return s 81 | 82 | def to_long(self): 83 | s = '' 84 | 85 | escaped = { 86 | '\\': BACKSLASH + BACKSLASH, 87 | '\a': BACKSLASH + 'a', 88 | '\b': BACKSLASH + 'b', 89 | '\f': BACKSLASH + 'f', 90 | '\r': BACKSLASH + 'r', 91 | '\t': BACKSLASH + 't', 92 | '\v': BACKSLASH + 'v', 93 | '\0': BACKSLASH + 'x00', 94 | self.quote[0]: BACKSLASH + self.quote[0], 95 | } 96 | 97 | for c in self._s: 98 | if c in escaped: 99 | s += escaped[c] 100 | else: 101 | if self.safe_mode: 102 | unicode_value = ord(c) 103 | if unicode_value <= 0x7F: 104 | s += c 105 | elif unicode_value <= 0xFFFF: 106 | s += BACKSLASH + 'u' + format(unicode_value, '04x') 107 | else: 108 | s += BACKSLASH + 'U' + format(unicode_value, '08x') 109 | else: 110 | s += c 111 | 112 | return s 113 | 114 | 115 | class MiniBytes(object): 116 | """ 117 | Create a representation of a bytes object 118 | 119 | :param bytes string: The string to minify 120 | 121 | """ 122 | 123 | def __init__(self, string, quote="'"): 124 | self._b = string 125 | self.quote = quote 126 | 127 | def __str__(self): 128 | """ 129 | The smallest python literal representation of a string 130 | 131 | :rtype: str 132 | 133 | """ 134 | 135 | if self._b == b'': 136 | return '' 137 | 138 | if len(self.quote) == 1: 139 | s = self.to_short() 140 | else: 141 | s = self.to_long() 142 | 143 | assert eval('b' + self.quote + s + self.quote) == self._b 144 | 145 | return s 146 | 147 | def to_short(self): 148 | b = '' 149 | 150 | for c in self._b: 151 | if c == b'\\': 152 | b += BACKSLASH 153 | elif c == b'\n': 154 | b += BACKSLASH + 'n' 155 | elif c == self.quote: 156 | b += BACKSLASH + self.quote 157 | else: 158 | if c >= 128: 159 | b += BACKSLASH + chr(c) 160 | else: 161 | b += chr(c) 162 | 163 | return b 164 | 165 | def to_long(self): 166 | b = '' 167 | 168 | for c in self._b: 169 | if c == b'\\': 170 | b += BACKSLASH 171 | elif c == self.quote: 172 | b += BACKSLASH + self.quote 173 | else: 174 | if c >= 128: 175 | b += BACKSLASH + chr(c) 176 | else: 177 | b += chr(c) 178 | 179 | return b 180 | -------------------------------------------------------------------------------- /src/python_minifier/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dflook/python-minifier/b5e9446320eff34539b0226af636088411d18a90/src/python_minifier/py.typed -------------------------------------------------------------------------------- /src/python_minifier/rename/README.md: -------------------------------------------------------------------------------- 1 | # Renaming 2 | 3 | We can save bytes by shortening the names used in a python program. 4 | 5 | One simple way to do this is to replace each unique name in a module with a shorter one. 6 | This will probably exhaust the available single character names, so is not as efficient as it could be. 7 | Also, not all names can be safely changed this way. 8 | 9 | By determining the scope of each name, we can assign the same short name to multiple non-overlapping scopes. 10 | This means sibling namespaces may have the same names, and names will be shadowed in inner namespaces where possible. 11 | 12 | This file is a guide for how the python_minifier package shortens names. 13 | There are multiple steps to the renaming process. 14 | 15 | ## Binding Names 16 | 17 | Names are bound to the local namespace it is defined in. 18 | 19 | ### Create namespace nodes 20 | 21 | Namespaces in python are introduced by Modules, Functions, Comprehensions, Generators and Classes. 22 | The AST node that introduces a new namespace is called a 'namespace node'. 23 | 24 | These attributes are added to namespace nodes: 25 | - Bindings - A list of Bindings local to this namespace, populated by the Bind names step 26 | - Globals - A list of global names in this namespace 27 | - Nonlocals - A list of nonlocal names in this namespace 28 | 29 | ### Determine parent node 30 | 31 | Add a parent attribute to each node with the value of the node of which this is a child. 32 | 33 | ### Determine namespace 34 | 35 | Add a namespace attribute to each node with the value of the namespace node that will be used for name binding and resolution. 36 | This is usually the closest parent namespace node. The exceptions are: 37 | 38 | - Function argument default values are in the same namespace as their function. 39 | - Function decorators are in the same namespace as their function. 40 | - Function annotations are in the same namespace as their function. 41 | - Class decorator are in the same namespace as their class. 42 | - Class bases, keywords, starargs and kwargs are in the same namespace as their class. 43 | - The first iteration expression of a comprehension is in the same namespace as it's parent ListComp/SetComp/DictComp or GeneratorExp. 44 | 45 | ### Bind names 46 | 47 | Every node that binds a name creates a NameBinding for that name in its namespace. 48 | The node is added to the NameBinding as a reference. 49 | 50 | If the name is nonlocal in its namespace it does not create a binding. 51 | 52 | Nodes that create a binding: 53 | - FunctionDef nodes bind their name 54 | - ClassDef nodes bind their name 55 | - arg nodes bind their arg 56 | - Name nodes in Store or Del context bind their id 57 | - MatchAs nodes bind their name 58 | - MatchStar nodes bind their name 59 | - MatchMapping nodes bind their rest 60 | 61 | ### Resolve names 62 | 63 | For the remaining unbound name nodes and nodes that normally create a binding but are for a nonlocal name, we find their binding. 64 | 65 | Bindings for name references are found by searching their namespace, then parent namespaces. 66 | If a name is global in a searched namespace, skip straight to the module node. 67 | If a name is nonlocal in a searched namespace, skip to the next parent namespace. 68 | When traversing parent namespaces, Class namespaces are skipped. 69 | 70 | If a NameBinding is found, add the node as a reference. 71 | If no NameBinding is found, check if the name would resolve to a builtin. 72 | If so, create a BuiltinBinding in the module namespace and add this node as a reference. 73 | 74 | Otherwise we failed to find a binding for this name - Create a NameBinding in the module namespace and add this node 75 | as a reference. 76 | 77 | ## Hoist Literals 78 | 79 | At this point we do the HoistLiterals transform, which adds new HoistedLiteral bindings to the namespaces where it wants 80 | to introduce new names. 81 | 82 | ## Name Assignment 83 | 84 | Collect all bindings in the module and sort by estimated byte savings 85 | 86 | For each binding: 87 | - Determine it's 'reservation scope', which is the set of namespaces that name is referenced in (and all namespaces between them) 88 | - Get the next available name that is unassigned and unreserved in all namespaces in the reservation scope. 89 | - Check if we should proceed with the rename - is it space efficient to do this rename, or has the original name been assigned somewhere else? 90 | - Rename the binding, rename all referenced nodes to the new name, and record this name as assigned in every namespace of the reservation scope. 91 | -------------------------------------------------------------------------------- /src/python_minifier/rename/__init__.py: -------------------------------------------------------------------------------- 1 | from python_minifier.rename.bind_names import bind_names 2 | from python_minifier.rename.mapper import add_namespace 3 | from python_minifier.rename.rename_literals import rename_literals 4 | from python_minifier.rename.renamer import rename 5 | from python_minifier.rename.resolve_names import resolve_names 6 | from python_minifier.rename.util import allow_rename_globals, allow_rename_locals 7 | -------------------------------------------------------------------------------- /src/python_minifier/rename/name_generator.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import keyword 3 | import random 4 | import string 5 | 6 | from python_minifier.rename.util import builtins 7 | 8 | 9 | def random_generator(length=40): 10 | valid_first = string.ascii_uppercase + string.ascii_lowercase 11 | valid_rest = string.digits + valid_first + '_' 12 | 13 | while True: 14 | first = [random.choice(valid_first)] 15 | rest = [random.choice(valid_rest) for i in range(length - 1)] 16 | yield ''.join(first + rest) 17 | 18 | 19 | def name_generator(): 20 | valid_first = string.ascii_uppercase + string.ascii_lowercase 21 | valid_rest = string.digits + valid_first + '_' 22 | 23 | for c in valid_first: 24 | yield c 25 | 26 | for length in itertools.count(1): 27 | for first in valid_first: 28 | for rest in itertools.product(valid_rest, repeat=length): 29 | name = first 30 | name += ''.join(rest) 31 | yield name 32 | 33 | 34 | def name_filter(): 35 | """ 36 | Yield all valid python identifiers 37 | 38 | Name are returned sorted by length, then string sort order. 39 | 40 | Names that already have meaning in python (keywords and builtins) 41 | will not be included in the output. 42 | 43 | :rtype: Iterable[str] 44 | 45 | """ 46 | 47 | reserved = keyword.kwlist + dir(builtins) 48 | 49 | for name in name_generator(): 50 | if name not in reserved: 51 | yield name 52 | -------------------------------------------------------------------------------- /src/python_minifier/rename/resolve_names.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | from python_minifier.rename.binding import BuiltinBinding, NameBinding 4 | from python_minifier.rename.util import builtins, get_global_namespace, get_nonlocal_namespace 5 | 6 | 7 | def get_binding(name, namespace): 8 | if name in namespace.global_names and not isinstance(namespace, ast.Module): 9 | return get_binding(name, get_global_namespace(namespace)) 10 | elif name in namespace.nonlocal_names and not isinstance(namespace, ast.Module): 11 | return get_binding(name, get_nonlocal_namespace(namespace)) 12 | 13 | for binding in namespace.bindings: 14 | if binding.name == name: 15 | return binding 16 | 17 | if not isinstance(namespace, ast.Module): 18 | return get_binding(name, get_nonlocal_namespace(namespace)) 19 | 20 | else: 21 | # This is unresolved at global scope - is it a builtin? 22 | if name in dir(builtins): 23 | if name in ['exec', 'eval', 'locals', 'globals', 'vars']: 24 | namespace.tainted = True 25 | 26 | binding = BuiltinBinding(name, namespace) 27 | namespace.bindings.append(binding) 28 | return binding 29 | 30 | else: 31 | binding = NameBinding(name) 32 | binding.disallow_rename() 33 | namespace.bindings.append(binding) 34 | return binding 35 | 36 | 37 | def get_binding_disallow_class_namespace_rename(name, namespace): 38 | binding = get_binding(name, namespace) 39 | 40 | if isinstance(namespace, ast.ClassDef): 41 | # This name will become an attribute of a class, so it can't be renamed 42 | binding.disallow_rename() 43 | 44 | return binding 45 | 46 | 47 | def resolve_names(node): 48 | """ 49 | Resolve unbound names to a NameBinding 50 | 51 | :param node: The module to resolve names in 52 | :type node: :class:`ast.Module` 53 | 54 | """ 55 | 56 | if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): 57 | get_binding(node.id, node.namespace).add_reference(node) 58 | elif isinstance(node, ast.Name) and node.id in node.namespace.nonlocal_names: 59 | binding = get_binding(node.id, node.namespace) 60 | binding.add_reference(node) 61 | 62 | if isinstance(node.ctx, ast.Store) and isinstance(node.namespace, ast.ClassDef): 63 | binding.disallow_rename() 64 | 65 | elif isinstance(node, ast.ClassDef) and node.name in node.namespace.nonlocal_names: 66 | binding = get_binding_disallow_class_namespace_rename(node.name, node.namespace) 67 | binding.add_reference(node) 68 | 69 | elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in node.namespace.nonlocal_names: 70 | binding = get_binding_disallow_class_namespace_rename(node.name, node.namespace) 71 | binding.add_reference(node) 72 | 73 | elif isinstance(node, ast.alias): 74 | 75 | if node.asname is not None: 76 | if node.asname in node.namespace.nonlocal_names: 77 | binding = get_binding_disallow_class_namespace_rename(node.asname, node.namespace) 78 | binding.add_reference(node) 79 | 80 | else: 81 | # This binds the root module only for a dotted import 82 | root_module = node.name.split('.')[0] 83 | 84 | if root_module in node.namespace.nonlocal_names: 85 | binding = get_binding_disallow_class_namespace_rename(root_module, node.namespace) 86 | binding.add_reference(node) 87 | 88 | if '.' in node.name: 89 | binding.disallow_rename() 90 | 91 | elif isinstance(node, ast.ExceptHandler) and node.name is not None: 92 | if isinstance(node.name, str) and node.name in node.namespace.nonlocal_names: 93 | get_binding_disallow_class_namespace_rename(node.name, node.namespace).add_reference(node) 94 | 95 | elif isinstance(node, ast.Nonlocal): 96 | for name in node.names: 97 | get_binding_disallow_class_namespace_rename(name, node.namespace).add_reference(node) 98 | elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name in node.namespace.nonlocal_names: 99 | get_binding_disallow_class_namespace_rename(node.name, node.namespace).add_reference(node) 100 | elif isinstance(node, ast.MatchMapping) and node.rest in node.namespace.nonlocal_names: 101 | get_binding_disallow_class_namespace_rename(node.rest, node.namespace).add_reference(node) 102 | 103 | elif isinstance(node, ast.Exec): 104 | get_global_namespace(node).tainted = True 105 | 106 | for child in ast.iter_child_nodes(node): 107 | resolve_names(child) 108 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dflook/python-minifier/b5e9446320eff34539b0226af636088411d18a90/src/python_minifier/transforms/__init__.py -------------------------------------------------------------------------------- /src/python_minifier/transforms/combine_imports.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | from python_minifier.transforms.suite_transformer import SuiteTransformer 4 | 5 | 6 | class CombineImports(SuiteTransformer): 7 | """ 8 | Combine multiple import statements where possible 9 | 10 | This doesn't change the order of imports 11 | 12 | """ 13 | 14 | def _combine_import(self, node_list, parent): 15 | 16 | alias = [] 17 | namespace = None 18 | 19 | for statement in node_list: 20 | namespace = statement.namespace 21 | if isinstance(statement, ast.Import): 22 | alias += statement.names 23 | else: 24 | if alias: 25 | yield self.add_child(ast.Import(names=alias), parent=parent, namespace=namespace) 26 | alias = [] 27 | 28 | yield statement 29 | 30 | if alias: 31 | yield self.add_child(ast.Import(names=alias), parent=parent, namespace=namespace) 32 | 33 | def _combine_import_from(self, node_list, parent): 34 | 35 | prev_import = None 36 | alias = [] 37 | 38 | def combine(statement): 39 | if not isinstance(statement, ast.ImportFrom): 40 | return False 41 | 42 | if len(statement.names) == 1 and statement.names[0].name == '*': 43 | return False 44 | 45 | if prev_import is None: 46 | return True 47 | 48 | if statement.module == prev_import.module and statement.level == prev_import.level: 49 | return True 50 | 51 | return False 52 | 53 | for statement in node_list: 54 | if combine(statement): 55 | prev_import = statement 56 | alias += statement.names 57 | else: 58 | if alias: 59 | yield self.add_child( 60 | ast.ImportFrom(module=prev_import.module, names=alias, level=prev_import.level), parent=parent, namespace=prev_import.namespace 61 | ) 62 | alias = [] 63 | 64 | yield statement 65 | 66 | if alias: 67 | yield self.add_child( 68 | ast.ImportFrom(module=prev_import.module, names=alias, level=prev_import.level), parent=parent, namespace=prev_import.namespace 69 | ) 70 | 71 | def suite(self, node_list, parent): 72 | a = list(self._combine_import(node_list, parent)) 73 | b = list(self._combine_import_from(a, parent)) 74 | 75 | return [self.visit(n) for n in b] 76 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/constant_folding.py: -------------------------------------------------------------------------------- 1 | import math 2 | import sys 3 | 4 | import python_minifier.ast_compat as ast 5 | from python_minifier.ast_annotation import get_parent 6 | 7 | from python_minifier.ast_compare import compare_ast 8 | from python_minifier.expression_printer import ExpressionPrinter 9 | from python_minifier.transforms.suite_transformer import SuiteTransformer 10 | from python_minifier.util import is_constant_node 11 | 12 | 13 | class FoldConstants(SuiteTransformer): 14 | """ 15 | Fold Constants if it would reduce the size of the source 16 | """ 17 | 18 | def __init__(self): 19 | super(FoldConstants, self).__init__() 20 | 21 | def visit_BinOp(self, node): 22 | 23 | node.left = self.visit(node.left) 24 | node.right = self.visit(node.right) 25 | 26 | # Check this is a constant expression that could be folded 27 | # 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 28 | if not is_constant_node(node.left, (ast.Num, ast.NameConstant)): 29 | return node 30 | if not is_constant_node(node.right, (ast.Num, ast.NameConstant)): 31 | return node 32 | 33 | if isinstance(node.op, ast.Div): 34 | # Folding div is subtle, since it can have different results in Python 2 and Python 3 35 | # Do this once target version options have been implemented 36 | return node 37 | 38 | if isinstance(node.op, ast.Pow): 39 | # This can be folded, but it is unlikely to reduce the size of the source 40 | # It can also be slow to evaluate 41 | return node 42 | 43 | # Evaluate the expression 44 | try: 45 | original_expression = unparse_expression(node) 46 | original_value = safe_eval(original_expression) 47 | except Exception: 48 | return node 49 | 50 | # Choose the best representation of the value 51 | if isinstance(original_value, float) and math.isnan(original_value): 52 | # There is no nan literal. 53 | # we could use float('nan'), but that complicates folding as it's not a Constant 54 | return node 55 | elif isinstance(original_value, bool): 56 | new_node = ast.NameConstant(value=original_value) 57 | elif isinstance(original_value, (int, float, complex)): 58 | try: 59 | if repr(original_value).startswith('-') and not sys.version_info < (3, 0): 60 | # Represent negative numbers as a USub UnaryOp, so that the ast roundtrip is correct 61 | new_node = ast.UnaryOp(op=ast.USub(), operand=ast.Num(n=-original_value)) 62 | else: 63 | new_node = ast.Num(n=original_value) 64 | except Exception: 65 | # repr(value) failed, most likely due to some limit 66 | return node 67 | else: 68 | return node 69 | 70 | # Evaluate the new value representation 71 | try: 72 | folded_expression = unparse_expression(new_node) 73 | folded_value = safe_eval(folded_expression) 74 | except Exception: 75 | # This can happen if the value is too large to be represented as a literal 76 | # or if the value is unparsed as nan, inf or -inf - which are not valid python literals 77 | return node 78 | 79 | if len(folded_expression) >= len(original_expression): 80 | # Result is not shorter than original expression 81 | return node 82 | 83 | # Check the folded expression parses back to the same AST 84 | try: 85 | folded_ast = ast.parse(folded_expression, 'folded expression', mode='eval') 86 | compare_ast(new_node, folded_ast.body) 87 | except Exception: 88 | # This can happen if the printed value doesn't parse back to the same AST 89 | # e.g. complex numbers can be parsed as BinOp 90 | return node 91 | 92 | # Check the folded value is the same as the original value 93 | if not equal_value_and_type(folded_value, original_value): 94 | return node 95 | 96 | # New representation is shorter and has the same value, so use it 97 | return self.add_child(new_node, get_parent(node), node.namespace) 98 | 99 | 100 | def equal_value_and_type(a, b): 101 | if type(a) != type(b): 102 | return False 103 | 104 | if isinstance(a, float) and math.isnan(a) and not math.isnan(b): 105 | return False 106 | 107 | return a == b 108 | 109 | 110 | def safe_eval(expression): 111 | empty_globals = {} 112 | empty_locals = {} 113 | 114 | # This will return the value, or could raise an exception 115 | return eval(expression, empty_globals, empty_locals) 116 | 117 | 118 | def unparse_expression(node): 119 | expression_printer = ExpressionPrinter() 120 | return expression_printer(node) 121 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_annotations.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import python_minifier.ast_compat as ast 4 | from python_minifier.ast_annotation import get_parent 5 | 6 | from python_minifier.transforms.remove_annotations_options import RemoveAnnotationsOptions 7 | from python_minifier.transforms.suite_transformer import SuiteTransformer 8 | 9 | 10 | class RemoveAnnotations(SuiteTransformer): 11 | """ 12 | Remove type annotations from source 13 | """ 14 | 15 | def __init__(self, options): 16 | assert isinstance(options, RemoveAnnotationsOptions) 17 | self._options = options 18 | super(RemoveAnnotations, self).__init__() 19 | 20 | def __call__(self, node): 21 | if sys.version_info < (3, 0): 22 | return node 23 | return self.visit(node) 24 | 25 | def visit_FunctionDef(self, node): 26 | node.args = self.visit_arguments(node.args) 27 | node.body = self.suite(node.body, parent=node) 28 | node.decorator_list = [self.visit(d) for d in node.decorator_list] 29 | 30 | if hasattr(node, 'type_params') and node.type_params is not None: 31 | node.type_params = [self.visit(t) for t in node.type_params] 32 | 33 | if hasattr(node, 'returns') and self._options.remove_return_annotations: 34 | node.returns = None 35 | 36 | return node 37 | 38 | def visit_arguments(self, node): 39 | assert isinstance(node, ast.arguments) 40 | 41 | if hasattr(node, 'posonlyargs') and node.posonlyargs: 42 | node.posonlyargs = [self.visit_arg(a) for a in node.posonlyargs] 43 | 44 | if node.args: 45 | node.args = [self.visit_arg(a) for a in node.args] 46 | 47 | if hasattr(node, 'kwonlyargs') and node.kwonlyargs: 48 | node.kwonlyargs = [self.visit_arg(a) for a in node.kwonlyargs] 49 | 50 | if hasattr(node, 'varargannotation'): 51 | if self._options.remove_argument_annotations: 52 | node.varargannotation = None 53 | else: 54 | if node.vararg: 55 | node.vararg = self.visit_arg(node.vararg) 56 | 57 | if hasattr(node, 'kwargannotation'): 58 | if self._options.remove_argument_annotations: 59 | node.kwargannotation = None 60 | else: 61 | if node.kwarg: 62 | node.kwarg = self.visit_arg(node.kwarg) 63 | 64 | return node 65 | 66 | def visit_arg(self, node): 67 | if self._options.remove_argument_annotations: 68 | node.annotation = None 69 | return node 70 | 71 | def visit_AnnAssign(self, node): 72 | def is_dataclass_field(node): 73 | if sys.version_info < (3, 7): 74 | return False 75 | 76 | if not isinstance(get_parent(node), ast.ClassDef): 77 | return False 78 | 79 | if len(get_parent(node).decorator_list) == 0: 80 | return False 81 | 82 | for decorator_node in get_parent(node).decorator_list: 83 | if isinstance(decorator_node, ast.Name) and decorator_node.id == 'dataclass': 84 | return True 85 | elif isinstance(decorator_node, ast.Attribute) and decorator_node.attr == 'dataclass': 86 | return True 87 | elif isinstance(decorator_node, ast.Call) and isinstance(decorator_node.func, ast.Name) and decorator_node.func.id == 'dataclass': 88 | return True 89 | elif isinstance(decorator_node, ast.Call) and isinstance(decorator_node.func, ast.Attribute) and decorator_node.func.attr == 'dataclass': 90 | return True 91 | 92 | return False 93 | 94 | def is_typing_sensitive(node): 95 | if sys.version_info < (3, 5): 96 | return False 97 | 98 | if not isinstance(get_parent(node), ast.ClassDef): 99 | return False 100 | 101 | if len(get_parent(node).bases) == 0: 102 | return False 103 | 104 | tricky_types = ['NamedTuple', 'TypedDict'] 105 | 106 | for base_node in get_parent(node).bases: 107 | if isinstance(base_node, ast.Name) and base_node.id in tricky_types: 108 | return True 109 | elif isinstance(base_node, ast.Attribute) and base_node.attr in tricky_types: 110 | return True 111 | 112 | return False 113 | 114 | # is this a class attribute or a variable? 115 | if isinstance(get_parent(node), ast.ClassDef): 116 | if not self._options.remove_class_attribute_annotations: 117 | return node 118 | else: 119 | if not self._options.remove_variable_annotations: 120 | return node 121 | 122 | if is_dataclass_field(node) or is_typing_sensitive(node): 123 | return node 124 | elif node.value: 125 | return self.add_child(ast.Assign([node.target], node.value), parent=get_parent(node), namespace=node.namespace) 126 | else: 127 | # Valueless annotations cause the interpreter to treat the variable as a local. 128 | # I don't know of another way to do that without assigning to it, so 129 | # keep it as an AnnAssign, but replace the annotation with '0' 130 | 131 | node.annotation = self.add_child(ast.Num(0), parent=get_parent(node), namespace=node.namespace) 132 | return node 133 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_annotations_options.py: -------------------------------------------------------------------------------- 1 | class RemoveAnnotationsOptions(object): 2 | """ 3 | Options for the RemoveAnnotations transform 4 | 5 | This can be passed to the minify function as the remove_annotations argument 6 | 7 | :param remove_variable_annotations: Remove variable annotations 8 | :type remove_variable_annotations: bool 9 | :param remove_return_annotations: Remove return annotations 10 | :type remove_return_annotations: bool 11 | :param remove_argument_annotations: Remove argument annotations 12 | :type remove_argument_annotations: bool 13 | :param remove_class_attribute_annotations: Remove class attribute annotations 14 | :type remove_class_attribute_annotations: bool 15 | """ 16 | 17 | remove_variable_annotations = True 18 | remove_return_annotations = True 19 | remove_argument_annotations = True 20 | remove_class_attribute_annotations = False 21 | 22 | def __init__(self, remove_variable_annotations=True, remove_return_annotations=True, remove_argument_annotations=True, remove_class_attribute_annotations=False): 23 | self.remove_variable_annotations = remove_variable_annotations 24 | self.remove_return_annotations = remove_return_annotations 25 | self.remove_argument_annotations = remove_argument_annotations 26 | self.remove_class_attribute_annotations = remove_class_attribute_annotations 27 | 28 | def __repr__(self): 29 | return 'RemoveAnnotationsOptions(remove_variable_annotations=%r, remove_return_annotations=%r, remove_argument_annotations=%r, remove_class_attribute_annotations=%r)' % ( 30 | self.remove_variable_annotations, self.remove_return_annotations, self.remove_argument_annotations, self.remove_class_attribute_annotations 31 | ) 32 | 33 | def __nonzero__(self): 34 | return any((self.remove_variable_annotations, self.remove_return_annotations, self.remove_argument_annotations, self.remove_class_attribute_annotations)) 35 | 36 | def __bool__(self): 37 | return self.__nonzero__() 38 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_annotations_options.pyi: -------------------------------------------------------------------------------- 1 | class RemoveAnnotationsOptions: 2 | remove_variable_annotations: bool 3 | remove_return_annotations: bool 4 | remove_argument_annotations: bool 5 | remove_class_attribute_annotations: bool 6 | 7 | def __init__( 8 | self, 9 | remove_variable_annotations: bool = ..., 10 | remove_return_annotations: bool = ..., 11 | remove_argument_annotations: bool = ..., 12 | remove_class_attribute_annotations: bool = ... 13 | ): 14 | ... 15 | 16 | def __nonzero__(self) -> bool: ... 17 | 18 | def __bool__(self) -> bool: ... 19 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_asserts.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | from python_minifier.transforms.suite_transformer import SuiteTransformer 4 | 5 | 6 | class RemoveAsserts(SuiteTransformer): 7 | """ 8 | Remove assert statements 9 | 10 | If a statement is syntactically necessary, use an empty expression instead 11 | """ 12 | 13 | def __call__(self, node): 14 | return self.visit(node) 15 | 16 | def suite(self, node_list, parent): 17 | without_assert = [self.visit(a) for a in filter(lambda n: not isinstance(n, ast.Assert), node_list)] 18 | 19 | if len(without_assert) == 0: 20 | if isinstance(parent, ast.Module): 21 | return [] 22 | else: 23 | return [self.add_child(ast.Expr(value=ast.Num(0)), parent=parent)] 24 | 25 | return without_assert 26 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_debug.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import python_minifier.ast_compat as ast 4 | 5 | from python_minifier.transforms.suite_transformer import SuiteTransformer 6 | from python_minifier.util import is_constant_node 7 | 8 | 9 | class RemoveDebug(SuiteTransformer): 10 | """ 11 | Remove if statements where the condition tests __debug__ is True 12 | 13 | If a statement is syntactically necessary, use an empty expression instead 14 | """ 15 | 16 | def __call__(self, node): 17 | return self.visit(node) 18 | 19 | def constant_value(self, node): 20 | if sys.version_info < (3, 4): 21 | return node.id == 'True' 22 | elif is_constant_node(node, ast.NameConstant): 23 | return node.value 24 | return None 25 | 26 | def can_remove(self, node): 27 | if not isinstance(node, ast.If): 28 | return False 29 | 30 | if isinstance(node.test, ast.Name) and node.test.id == '__debug__': 31 | return True 32 | 33 | if isinstance(node.test, ast.Compare) and len(node.test.ops) == 1 and isinstance(node.test.ops[0], ast.Is) and self.constant_value(node.test.comparators[0]) is True: 34 | return True 35 | 36 | if isinstance(node.test, ast.Compare) and len(node.test.ops) == 1 and isinstance(node.test.ops[0], ast.IsNot) and self.constant_value(node.test.comparators[0]) is False: 37 | return True 38 | 39 | if isinstance(node.test, ast.Compare) and len(node.test.ops) == 1 and isinstance(node.test.ops[0], ast.Eq) and self.constant_value(node.test.comparators[0]) is True: 40 | return True 41 | 42 | return False 43 | 44 | def suite(self, node_list, parent): 45 | 46 | without_debug = [self.visit(a) for a in filter(lambda n: not self.can_remove(n), node_list)] 47 | 48 | if len(without_debug) == 0: 49 | if isinstance(parent, ast.Module): 50 | return [] 51 | else: 52 | return [self.add_child(ast.Expr(value=ast.Num(0)), parent=parent)] 53 | 54 | return without_debug 55 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_exception_brackets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Remove Call nodes that are only used to raise exceptions with no arguments 3 | 4 | If a Raise statement is used on a Name and the name refers to an exception, it is automatically instantiated with no arguments 5 | We can remove any Call nodes that are only used to raise exceptions with no arguments and let the Raise statement do the instantiation. 6 | When printed, this essentially removes the brackets from the exception name. 7 | 8 | We can't generally know if a name refers to an exception, so we only do this for builtin exceptions 9 | """ 10 | 11 | import sys 12 | 13 | import python_minifier.ast_compat as ast 14 | from python_minifier.ast_annotation import get_parent, set_parent 15 | 16 | from python_minifier.rename.binding import BuiltinBinding 17 | 18 | 19 | # These are always exceptions, in every version of python 20 | builtin_exceptions = [ 21 | 'SyntaxError', 'Exception', 'ValueError', 'BaseException', 'MemoryError', 'RuntimeError', 'DeprecationWarning', 'UnicodeEncodeError', 'KeyError', 'LookupError', 'TypeError', 'BufferError', 22 | 'ImportError', 'OSError', 'StopIteration', 'ArithmeticError', 'UserWarning', 'PendingDeprecationWarning', 'RuntimeWarning', 'IndentationError', 'UnicodeTranslateError', 'UnboundLocalError', 23 | 'AttributeError', 'EOFError', 'UnicodeWarning', 'BytesWarning', 'NameError', 'IndexError', 'TabError', 'SystemError', 'OverflowError', 'FutureWarning', 'SystemExit', 'Warning', 24 | 'FloatingPointError', 'ReferenceError', 'UnicodeError', 'AssertionError', 'SyntaxWarning', 'UnicodeDecodeError', 'GeneratorExit', 'ImportWarning', 'KeyboardInterrupt', 'ZeroDivisionError', 25 | 'NotImplementedError' 26 | ] 27 | 28 | # These are exceptions only in python 2.7 29 | builtin_exceptions_2_7 = [ 30 | 'IOError', 31 | 'StandardError', 32 | 'EnvironmentError', 33 | 'VMSError', 34 | 'WindowsError' 35 | ] 36 | 37 | # These are exceptions in 3.3+ 38 | builtin_exceptions_3_3 = [ 39 | 'ChildProcessError', 40 | 'ConnectionError', 41 | 'BrokenPipeError', 42 | 'ConnectionAbortedError', 43 | 'ConnectionRefusedError', 44 | 'ConnectionResetError', 45 | 'FileExistsError', 46 | 'FileNotFoundError', 47 | 'InterruptedError', 48 | 'IsADirectoryError', 49 | 'NotADirectoryError', 50 | 'PermissionError', 51 | 'ProcessLookupError', 52 | 'TimeoutError', 53 | 'ResourceWarning', 54 | ] 55 | 56 | # These are exceptions in 3.5+ 57 | builtin_exceptions_3_5 = [ 58 | 'StopAsyncIteration', 59 | 'RecursionError', 60 | ] 61 | 62 | # These are exceptions in 3.6+ 63 | builtin_exceptions_3_6 = [ 64 | 'ModuleNotFoundError' 65 | ] 66 | 67 | # These are exceptions in 3.10+ 68 | builtin_exceptions_3_10 = [ 69 | 'EncodingWarning' 70 | ] 71 | 72 | # These are exceptions in 3.11+ 73 | builtin_exceptions_3_11 = [ 74 | 'BaseExceptionGroup', 75 | 'ExceptionGroup', 76 | 'BaseExceptionGroup', 77 | ] 78 | 79 | 80 | def _remove_empty_call(binding): 81 | assert isinstance(binding, BuiltinBinding) 82 | 83 | for name_node in binding.references: 84 | # For this to be a builtin, all references must be name nodes as it is not defined anywhere 85 | assert isinstance(name_node, ast.Name) 86 | assert isinstance(name_node.ctx, ast.Load) 87 | 88 | if not isinstance(get_parent(name_node), ast.Call): 89 | # This is not a call 90 | continue 91 | call_node = get_parent(name_node) 92 | 93 | if not isinstance(get_parent(call_node), ast.Raise): 94 | # This is not a raise statement 95 | continue 96 | raise_node = get_parent(call_node) 97 | 98 | if len(call_node.args) > 0 or len(call_node.keywords) > 0: 99 | # This is a call with arguments 100 | continue 101 | 102 | # This is an instance of the exception being called with no arguments 103 | # let's replace it with just the name, cutting out the Call node 104 | 105 | if raise_node.exc is call_node: 106 | raise_node.exc = name_node 107 | elif raise_node.cause is call_node: 108 | raise_node.cause = name_node 109 | set_parent(name_node, raise_node) 110 | 111 | 112 | def remove_no_arg_exception_call(module): 113 | assert isinstance(module, ast.Module) 114 | 115 | if sys.version_info < (3, 0): 116 | return module 117 | 118 | for binding in module.bindings: 119 | if not isinstance(binding, BuiltinBinding): 120 | continue 121 | 122 | if binding.is_redefined(): 123 | continue 124 | 125 | if binding.name in builtin_exceptions: 126 | # We can remove any calls to builtin exceptions 127 | _remove_empty_call(binding) 128 | 129 | return module 130 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_explicit_return_none.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import python_minifier.ast_compat as ast 4 | 5 | from python_minifier.transforms.suite_transformer import SuiteTransformer 6 | from python_minifier.util import is_constant_node 7 | 8 | 9 | class RemoveExplicitReturnNone(SuiteTransformer): 10 | def __call__(self, node): 11 | return self.visit(node) 12 | 13 | def visit_Return(self, node): 14 | assert isinstance(node, ast.Return) 15 | 16 | # Transform `return None` -> `return` 17 | 18 | if sys.version_info < (3, 4) and isinstance(node.value, ast.Name) and node.value.id == 'None': 19 | node.value = None 20 | 21 | elif sys.version_info >= (3, 4) and is_constant_node(node.value, ast.NameConstant) and node.value.value is None: 22 | node.value = None 23 | 24 | return node 25 | 26 | def visit_FunctionDef(self, node): 27 | assert isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 28 | 29 | node.body = [self.visit(a) for a in node.body] 30 | 31 | # Remove an explicit valueless `return` from the end of a function 32 | if len(node.body) > 0 and isinstance(node.body[-1], ast.Return) and node.body[-1].value is None: 33 | node.body.pop() 34 | 35 | # Replace empty suites with `0` expression statements 36 | if len(node.body) == 0: 37 | node.body = [self.add_child(ast.Expr(value=ast.Num(0)), parent=node)] 38 | 39 | return node 40 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_literal_statements.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | from python_minifier.transforms.suite_transformer import SuiteTransformer 4 | from python_minifier.util import is_constant_node 5 | 6 | 7 | def find_doc(node): 8 | 9 | if isinstance(node, ast.Attribute) and node.attr == '__doc__': 10 | raise ValueError('__doc__ found!') 11 | 12 | for child in ast.iter_child_nodes(node): 13 | find_doc(child) 14 | 15 | 16 | def _doc_in_module(module): 17 | try: 18 | find_doc(module) 19 | return False 20 | except Exception: 21 | return True 22 | 23 | 24 | class RemoveLiteralStatements(SuiteTransformer): 25 | """ 26 | Remove literal expressions from the code 27 | 28 | This includes docstrings 29 | """ 30 | 31 | def __call__(self, node): 32 | if _doc_in_module(node): 33 | return node 34 | return self.visit(node) 35 | 36 | def visit_Module(self, node): 37 | for binding in node.bindings: 38 | if binding.name == '__doc__': 39 | node.body = [self.visit(a) for a in node.body] 40 | return node 41 | 42 | node.body = self.suite(node.body, parent=node) 43 | return node 44 | 45 | def is_literal_statement(self, node): 46 | if not isinstance(node, ast.Expr): 47 | return False 48 | 49 | return is_constant_node(node.value, (ast.Num, ast.Str, ast.NameConstant, ast.Bytes)) 50 | 51 | def suite(self, node_list, parent): 52 | without_literals = [self.visit(n) for n in node_list if not self.is_literal_statement(n)] 53 | 54 | if len(without_literals) == 0: 55 | if isinstance(parent, ast.Module): 56 | return [] 57 | else: 58 | return [self.add_child(ast.Expr(value=ast.Num(0)), parent=parent)] 59 | 60 | return without_literals 61 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_object_base.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import python_minifier.ast_compat as ast 4 | 5 | from python_minifier.transforms.suite_transformer import SuiteTransformer 6 | 7 | 8 | class RemoveObject(SuiteTransformer): 9 | def __call__(self, node): 10 | if sys.version_info < (3, 0): 11 | return node 12 | 13 | return self.visit(node) 14 | 15 | def visit_ClassDef(self, node): 16 | node.bases = [ 17 | b for b in node.bases if not isinstance(b, ast.Name) or (isinstance(b, ast.Name) and b.id != 'object') 18 | ] 19 | 20 | if hasattr(node, 'type_params') and node.type_params is not None: 21 | node.type_params = [self.visit(t) for t in node.type_params] 22 | 23 | node.body = [self.visit(n) for n in node.body] 24 | 25 | return node 26 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_pass.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | from python_minifier.transforms.suite_transformer import SuiteTransformer 4 | 5 | 6 | class RemovePass(SuiteTransformer): 7 | """ 8 | Remove Pass keywords from source 9 | 10 | If a statement is syntactically necessary, use an empty expression instead 11 | """ 12 | 13 | def __call__(self, node): 14 | return self.visit(node) 15 | 16 | def suite(self, node_list, parent): 17 | without_pass = [self.visit(a) for a in filter(lambda n: not isinstance(n, ast.Pass), node_list)] 18 | 19 | if len(without_pass) == 0: 20 | if isinstance(parent, ast.Module): 21 | return [] 22 | else: 23 | return [self.add_child(ast.Expr(value=ast.Num(0)), parent=parent)] 24 | 25 | return without_pass 26 | -------------------------------------------------------------------------------- /src/python_minifier/transforms/remove_posargs.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | 4 | def remove_posargs(node): 5 | if isinstance(node, ast.arguments) and hasattr(node, 'posonlyargs'): 6 | node.args = node.posonlyargs + node.args 7 | node.posonlyargs = [] 8 | 9 | for child in ast.iter_child_nodes(node): 10 | remove_posargs(child) 11 | 12 | return node 13 | -------------------------------------------------------------------------------- /src/python_minifier/util.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | 3 | 4 | def is_constant_node(node, types): 5 | """ 6 | Is a node one of the specified node types 7 | 8 | A node type may be an actual ast class or a tuple of many. 9 | 10 | If types includes a specific Constant type (Str, Bytes, Num etc), 11 | returns true for Constant nodes of the correct type. 12 | 13 | :type node: ast.AST 14 | :param types: 15 | :rtype: bool 16 | 17 | """ 18 | 19 | if not isinstance(types, tuple): 20 | types = (types,) 21 | 22 | for node_type in types: 23 | assert not isinstance(node_type, str) 24 | 25 | if isinstance(node, types): 26 | return True 27 | 28 | if isinstance(node, ast.Constant): 29 | if type(node.value) in [type(None), type(True), type(False)]: 30 | return ast.NameConstant in types 31 | elif isinstance(node.value, (int, float, complex)): 32 | return ast.Num in types 33 | elif isinstance(node.value, str): 34 | return ast.Str in types 35 | elif isinstance(node.value, bytes): 36 | return ast.Bytes in types 37 | elif node.value == Ellipsis: 38 | return ast.Ellipsis in types 39 | else: 40 | raise RuntimeError('Unknown Constant value %r' % type(node.value)) 41 | 42 | return False 43 | -------------------------------------------------------------------------------- /test/ast_annotation/test_add_parent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import ast 3 | from python_minifier.ast_annotation import add_parent, get_parent, set_parent 4 | 5 | 6 | def test_add_parent(): 7 | 8 | source = ''' 9 | class A: 10 | def b(self): 11 | pass 12 | ''' 13 | 14 | tree = ast.parse(source) 15 | 16 | add_parent(tree) 17 | 18 | assert isinstance(tree, ast.Module) 19 | 20 | assert isinstance(tree.body[0], ast.ClassDef) 21 | assert get_parent(tree.body[0]) is tree 22 | 23 | assert isinstance(tree.body[0].body[0], ast.FunctionDef) 24 | assert get_parent(tree.body[0].body[0]) is tree.body[0] 25 | 26 | assert isinstance(tree.body[0].body[0].body[0], ast.Pass) 27 | assert get_parent(tree.body[0].body[0].body[0]) is tree.body[0].body[0] 28 | 29 | 30 | def test_no_parent_for_root_node(): 31 | tree = ast.parse('a = 1') 32 | add_parent(tree) 33 | with pytest.raises(ValueError): 34 | get_parent(tree) 35 | 36 | 37 | def test_no_parent_for_unannotated_node(): 38 | tree = ast.parse('a = 1') 39 | with pytest.raises(ValueError): 40 | get_parent(tree.body[0]) 41 | 42 | 43 | def test_replaces_parent_of_given_node(): 44 | tree = ast.parse('a = func()') 45 | add_parent(tree) 46 | call = tree.body[0].value 47 | tree.body[0] = call 48 | set_parent(call, tree) 49 | assert get_parent(call) == tree 50 | -------------------------------------------------------------------------------- /test/helpers.py: -------------------------------------------------------------------------------- 1 | import python_minifier.ast_compat as ast 2 | from python_minifier.ast_annotation import add_parent 3 | 4 | from python_minifier.rename import add_namespace, resolve_names 5 | from python_minifier.rename.bind_names import bind_names 6 | from python_minifier.rename.util import iter_child_namespaces 7 | from python_minifier.util import is_constant_node 8 | 9 | 10 | def assert_namespace_tree(source, expected_tree): 11 | tree = ast.parse(source) 12 | 13 | add_parent(tree) 14 | add_namespace(tree) 15 | bind_names(tree) 16 | resolve_names(tree) 17 | 18 | actual = print_namespace(tree) 19 | 20 | print(actual) 21 | assert actual.strip() == expected_tree.strip() 22 | 23 | 24 | def print_namespace(namespace, indent=''): 25 | s = '' 26 | 27 | if not indent: 28 | s += '\n' 29 | 30 | def namespace_name(node): 31 | if is_constant_node(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 32 | return 'Function ' + node.name 33 | elif isinstance(node, ast.ClassDef): 34 | return 'Class ' + node.name 35 | else: 36 | return namespace.__class__.__name__ 37 | 38 | s += indent + '+ ' + namespace_name(namespace) + '\n' 39 | 40 | for name in sorted(namespace.global_names): 41 | s += indent + ' - global ' + name + '\n' 42 | 43 | for name in sorted(namespace.nonlocal_names): 44 | s += indent + ' - nonlocal ' + name + '\n' 45 | 46 | for binding in sorted(namespace.bindings, key=lambda b: b.name or str(b.value)): 47 | s += indent + ' - ' + repr(binding) + '\n' 48 | 49 | for child in iter_child_namespaces(namespace): 50 | s += print_namespace(child, indent=indent + ' ') 51 | 52 | return s 53 | -------------------------------------------------------------------------------- /test/test_assignment_expressions.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_pep(): 11 | if sys.version_info < (3, 8): 12 | pytest.skip('No Assignment expressions in python < 3.8') 13 | 14 | source = ''' 15 | if a := True: 16 | print(a) 17 | if self._is_special and (ans := self._check_nans(context=context)): 18 | return ans 19 | results = [(x, y, x/y) for x in input_data if (y := f(x)) > 0] 20 | stuff = [[y := f(x), x/y] for x in range(5)] 21 | ''' 22 | 23 | expected_ast = ast.parse(source) 24 | actual_ast = unparse(expected_ast) 25 | compare_ast(expected_ast, ast.parse(actual_ast)) 26 | -------------------------------------------------------------------------------- /test/test_await_fstring.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_await_fstring(): 11 | if sys.version_info < (3, 7): 12 | pytest.skip('Await in f-string expressions not allowed in python < 3.7') 13 | 14 | source = ''' 15 | async def a(): return 'hello' 16 | async def b(): return f'{await b()}' 17 | ''' 18 | 19 | expected_ast = ast.parse(source) 20 | actual_ast = unparse(expected_ast) 21 | compare_ast(expected_ast, ast.parse(actual_ast)) 22 | -------------------------------------------------------------------------------- /test/test_bind_names_python312.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from helpers import assert_namespace_tree 6 | 7 | 8 | def test_class_typevar_default(): 9 | if sys.version_info < (3, 12): 10 | pytest.skip('Test is for > python3.12 only') 11 | 12 | source = ''' 13 | class Foo[T]: ... 14 | ''' 15 | 16 | expected_namespaces = ''' 17 | + Module 18 | - NameBinding(name='Foo', allow_rename=True) 19 | - NameBinding(name='T', allow_rename=True) 20 | + Class Foo 21 | ''' 22 | 23 | assert_namespace_tree(source, expected_namespaces) 24 | 25 | 26 | def test_function_typevar_default(): 27 | if sys.version_info < (3, 12): 28 | pytest.skip('Test is for > python3.12 only') 29 | 30 | source = ''' 31 | def foo[T](): ... 32 | ''' 33 | 34 | expected_namespaces = ''' 35 | + Module 36 | - NameBinding(name='T', allow_rename=True) 37 | - NameBinding(name='foo', allow_rename=True) 38 | + Function foo 39 | ''' 40 | 41 | assert_namespace_tree(source, expected_namespaces) 42 | 43 | 44 | def test_alias_typevar_default(): 45 | if sys.version_info < (3, 12): 46 | pytest.skip('Test is for > python3.12 only') 47 | 48 | source = ''' 49 | type Alias[DefaultT] = Blah 50 | ''' 51 | 52 | expected_namespaces = ''' 53 | + Module 54 | - NameBinding(name='Alias', allow_rename=True) 55 | - NameBinding(name='Blah', allow_rename=False) 56 | - NameBinding(name='DefaultT', allow_rename=True) 57 | ''' 58 | 59 | assert_namespace_tree(source, expected_namespaces) 60 | 61 | 62 | def test_class_typevartuple_default(): 63 | if sys.version_info < (3, 12): 64 | pytest.skip('Test is for > python3.12 only') 65 | 66 | source = ''' 67 | class Foo[*T]: ... 68 | ''' 69 | 70 | expected_namespaces = ''' 71 | + Module 72 | - NameBinding(name='Foo', allow_rename=True) 73 | - NameBinding(name='T', allow_rename=True) 74 | + Class Foo 75 | ''' 76 | 77 | assert_namespace_tree(source, expected_namespaces) 78 | 79 | 80 | def test_function_typevartuple_default(): 81 | if sys.version_info < (3, 12): 82 | pytest.skip('Test is for > python3.12 only') 83 | 84 | source = ''' 85 | def foo[*T](): ... 86 | ''' 87 | 88 | expected_namespaces = ''' 89 | + Module 90 | - NameBinding(name='T', allow_rename=True) 91 | - NameBinding(name='foo', allow_rename=True) 92 | + Function foo 93 | ''' 94 | 95 | assert_namespace_tree(source, expected_namespaces) 96 | 97 | 98 | def test_alias_typevartuple_default(): 99 | if sys.version_info < (3, 12): 100 | pytest.skip('Test is for > python3.12 only') 101 | 102 | source = ''' 103 | type Alias[*DefaultT] = Blah 104 | ''' 105 | 106 | expected_namespaces = ''' 107 | + Module 108 | - NameBinding(name='Alias', allow_rename=True) 109 | - NameBinding(name='Blah', allow_rename=False) 110 | - NameBinding(name='DefaultT', allow_rename=True) 111 | ''' 112 | 113 | assert_namespace_tree(source, expected_namespaces) 114 | 115 | 116 | def test_class_paramspec_default(): 117 | if sys.version_info < (3, 12): 118 | pytest.skip('Test is for > python3.12 only') 119 | 120 | source = ''' 121 | class Foo[**T]: ... 122 | ''' 123 | 124 | expected_namespaces = ''' 125 | + Module 126 | - NameBinding(name='Foo', allow_rename=True) 127 | - NameBinding(name='T', allow_rename=True) 128 | + Class Foo 129 | ''' 130 | 131 | assert_namespace_tree(source, expected_namespaces) 132 | 133 | 134 | def test_function_paramspec_default(): 135 | if sys.version_info < (3, 12): 136 | pytest.skip('Test is for > python3.12 only') 137 | 138 | source = ''' 139 | def foo[**T](): ... 140 | ''' 141 | 142 | expected_namespaces = ''' 143 | + Module 144 | - NameBinding(name='T', allow_rename=True) 145 | - NameBinding(name='foo', allow_rename=True) 146 | + Function foo 147 | ''' 148 | 149 | assert_namespace_tree(source, expected_namespaces) 150 | 151 | 152 | def test_alias_paramspec_default(): 153 | if sys.version_info < (3, 12): 154 | pytest.skip('Test is for > python3.12 only') 155 | 156 | source = ''' 157 | type Alias[**DefaultT] = Blah 158 | ''' 159 | 160 | expected_namespaces = ''' 161 | + Module 162 | - NameBinding(name='Alias', allow_rename=True) 163 | - NameBinding(name='Blah', allow_rename=False) 164 | - NameBinding(name='DefaultT', allow_rename=True) 165 | ''' 166 | 167 | assert_namespace_tree(source, expected_namespaces) 168 | -------------------------------------------------------------------------------- /test/test_combine_imports.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from helpers import print_namespace 4 | from python_minifier.ast_annotation import add_parent 5 | 6 | from python_minifier.ast_compare import compare_ast 7 | from python_minifier.rename import add_namespace, bind_names, resolve_names 8 | from python_minifier.transforms.combine_imports import CombineImports 9 | 10 | 11 | def combine_imports(module): 12 | add_parent(module) 13 | add_namespace(module) 14 | CombineImports()(module) 15 | return module 16 | 17 | 18 | def assert_namespace_tree(source, expected_tree): 19 | tree = ast.parse(source) 20 | 21 | add_parent(tree) 22 | add_namespace(tree) 23 | CombineImports()(tree) 24 | bind_names(tree) 25 | resolve_names(tree) 26 | 27 | actual = print_namespace(tree) 28 | 29 | print(actual) 30 | assert actual.strip() == expected_tree.strip() 31 | 32 | 33 | def test_import(): 34 | source = '''import builtins 35 | import collections''' 36 | expected = 'import builtins, collections' 37 | 38 | expected_ast = ast.parse(expected) 39 | actual_ast = combine_imports(ast.parse(source)) 40 | compare_ast(expected_ast, actual_ast) 41 | 42 | 43 | def test_import_as(): 44 | source = '''import builtins 45 | import collections as c 46 | import functools as f 47 | import datetime 48 | 49 | pass''' 50 | expected = '''import builtins, collections as c, functools as f, datetime 51 | pass''' 52 | 53 | expected_ast = ast.parse(expected) 54 | actual_ast = combine_imports(ast.parse(source)) 55 | compare_ast(expected_ast, actual_ast) 56 | 57 | 58 | def test_import_from(): 59 | source = '''from builtins import dir 60 | from builtins import help 61 | import collections 62 | from collections import abc''' 63 | expected = '''from builtins import dir, help 64 | import collections 65 | from collections import abc''' 66 | 67 | expected_ast = ast.parse(expected) 68 | actual_ast = combine_imports(ast.parse(source)) 69 | compare_ast(expected_ast, actual_ast) 70 | 71 | 72 | def test_import_in_function(): 73 | source = '''def test(): 74 | import collection as c 75 | import builtins 76 | 77 | return None 78 | ''' 79 | expected = '''def test(): 80 | import collection as c, builtins 81 | return None 82 | ''' 83 | 84 | expected_ast = ast.parse(expected) 85 | actual_ast = combine_imports(ast.parse(source)) 86 | compare_ast(expected_ast, actual_ast) 87 | 88 | 89 | def test_import_star(): 90 | source = ''' 91 | from breakfast import hashbrown 92 | from breakfast import * 93 | from breakfast import sausage 94 | from breakfast import bacon 95 | ''' 96 | expected = ''' 97 | from breakfast import hashbrown 98 | from breakfast import * 99 | from breakfast import sausage, bacon 100 | ''' 101 | 102 | expected_ast = ast.parse(expected) 103 | actual_ast = combine_imports(ast.parse(source)) 104 | compare_ast(expected_ast, actual_ast) 105 | 106 | 107 | def test_import_as_class_namespace(): 108 | 109 | source = ''' 110 | class MyClass: 111 | import os as Hello 112 | ''' 113 | 114 | expected_namespaces = ''' 115 | + Module 116 | - NameBinding(name='MyClass', allow_rename=True) 117 | + Class MyClass 118 | - NameBinding(name='Hello', allow_rename=False) 119 | ''' 120 | 121 | assert_namespace_tree(source, expected_namespaces) 122 | 123 | 124 | def test_import_class_namespace(): 125 | 126 | source = ''' 127 | class MyClass: 128 | import Hello 129 | ''' 130 | 131 | expected_namespaces = ''' 132 | + Module 133 | - NameBinding(name='MyClass', allow_rename=True) 134 | + Class MyClass 135 | - NameBinding(name='Hello', allow_rename=False) 136 | ''' 137 | 138 | assert_namespace_tree(source, expected_namespaces) 139 | 140 | 141 | def test_from_import_class_namespace(): 142 | 143 | source = ''' 144 | class MyClass: 145 | from hello import Hello 146 | ''' 147 | 148 | expected_namespaces = ''' 149 | + Module 150 | - NameBinding(name='MyClass', allow_rename=True) 151 | + Class MyClass 152 | - NameBinding(name='Hello', allow_rename=False) 153 | ''' 154 | 155 | assert_namespace_tree(source, expected_namespaces) 156 | 157 | 158 | def test_from_import_as_class_namespace(): 159 | 160 | source = ''' 161 | class MyClass: 162 | from hello import babadook as Hello 163 | ''' 164 | 165 | expected_namespaces = ''' 166 | + Module 167 | - NameBinding(name='MyClass', allow_rename=True) 168 | + Class MyClass 169 | - NameBinding(name='Hello', allow_rename=False) 170 | ''' 171 | 172 | assert_namespace_tree(source, expected_namespaces) 173 | -------------------------------------------------------------------------------- /test/test_comprehension_rename.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import minify 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_listcomp_regression_2_7(): 11 | if sys.version_info >= (3, 0): 12 | pytest.skip("ListComp doesn't create a new namespace in python < 3.0") 13 | 14 | source = ''' 15 | def f(pa): 16 | return [pa.b for pa in pa.c] 17 | ''' 18 | expected = source 19 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 20 | 21 | 22 | def test_listcomp_regression(): 23 | if sys.version_info < (3, 0): 24 | pytest.skip('ListComp creates a new namespace in python > 3.0') 25 | 26 | source = ''' 27 | def f(parentObject): 28 | return [parentObject.b for parentObject in parentObject.c] 29 | ''' 30 | expected = ''' 31 | def f(parentObject): 32 | return[A.b for A in parentObject.c] 33 | ''' 34 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 35 | 36 | 37 | def test_expression(): 38 | source = '''def test(): 39 | [x*y for x in range(10) for y in range(x, x+10)] 40 | ''' 41 | 42 | expected = '''def test(): 43 | [A*B for A in range(10) for B in range(A,A+10)] 44 | ''' 45 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 46 | 47 | 48 | def test_generator_expression(): 49 | source = ''' 50 | x=1 51 | def func(): 52 | return (x for x in x) 53 | ''' 54 | 55 | expected = ''' 56 | x=1 57 | def func(): 58 | return (A for A in x) 59 | ''' 60 | 61 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 62 | 63 | 64 | def test_generator_expression_multiple_for(): 65 | source = ''' 66 | def func(): 67 | return (x for x in x for x in x) 68 | 69 | def func(long_name, another_long_name): 70 | return (long_name for long_name, another_long_name in long_name for long_name in (long_name, another_long_name)) 71 | ''' 72 | 73 | expected = ''' 74 | def func(): 75 | return (A for A in x for A in A) 76 | 77 | def func(long_name, another_long_name): 78 | return(A for A, B in long_name for A in (A,B)) 79 | ''' 80 | 81 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 82 | 83 | 84 | def test_generator_expression_nested_for(): 85 | source = ''' 86 | def func(): 87 | return (a for a in (b for b in x) for c in c) 88 | 89 | def func(long_name): 90 | return (a for a in (b for b in long_name) for c in c) 91 | ''' 92 | 93 | expected = ''' 94 | def func(): 95 | return(A for A in (A for A in x) for B in B) 96 | 97 | def func(long_name): 98 | return(A for A in (A for A in long_name) for B in B) 99 | ''' 100 | 101 | compare_ast(ast.parse(minify(source, rename_locals=True)), ast.parse(expected)) 102 | -------------------------------------------------------------------------------- /test/test_decorator_expressions.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_pep(): 11 | if sys.version_info < (3, 9): 12 | pytest.skip('Decorator expression not allowed in python <3.9') 13 | 14 | source = ''' 15 | buttons = [QPushButton(f'Button {i}') for i in range(10)] 16 | 17 | # Do stuff with the list of buttons... 18 | 19 | @buttons[0].clicked.connect 20 | def spam(): 21 | ... 22 | 23 | @buttons[1].clicked.connect 24 | def eggs(): 25 | ... 26 | 27 | # Do stuff with the list of buttons... 28 | @(f, g) 29 | def a(): pass 30 | 31 | @(f, g) 32 | class A:pass 33 | 34 | @lambda func: (lambda *p: func(*p).u()) 35 | def g(n): pass 36 | 37 | @s := lambda func: (lambda *p: func(*p).u()) 38 | def g(name): pass 39 | 40 | @s 41 | def r(n, t): 42 | pass 43 | 44 | @lambda f: lambda *p: f or f(*p).u() 45 | def g(name): pass 46 | 47 | @lambda f: lambda *p: \ 48 | [_ for _ in [ \ 49 | f(*p), 50 | ] if _][0] 51 | def c(): pass 52 | 53 | @lambda f: lambda *p: \ 54 | list(filter(lambda _: _,[ 55 | (a := t()) and False, 56 | f(*p), 57 | (b := t()) and False, 58 | ]))[0] 59 | def c(): pass 60 | 61 | ''' 62 | 63 | expected_ast = ast.parse(source) 64 | actual_ast = unparse(expected_ast) 65 | compare_ast(expected_ast, ast.parse(actual_ast)) 66 | -------------------------------------------------------------------------------- /test/test_dict_expansion.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_dict_expanson(): 11 | if sys.version_info < (3, 5): 12 | pytest.skip('dict expansion not allowed in python < 3.5') 13 | 14 | source = [ 15 | r'{**a>>9}', 16 | r'{**(a or b)}', 17 | r'{**(a and b)}', 18 | r'{**a+b}', 19 | r'{**(lambda a:a)}', 20 | r'{**(a>2', '2'), 103 | ('8<<2', '32'), 104 | ('0xff^0x0f', '0xf0'), 105 | ('0xf0&0xff', '0xf0'), 106 | ('0xf0|0x0f', '0xff'), 107 | ('10%2', '0'), 108 | ('10%3', '1'), 109 | ('10-100', '-90') 110 | ] 111 | ) 112 | def test_int(source, expected): 113 | """ 114 | Test BinOp with integer operands we can fold 115 | """ 116 | 117 | run_test(source, expected) 118 | 119 | 120 | @pytest.mark.parametrize( 121 | ('source', 'expected'), [ 122 | ('10/10', '10/10'), 123 | ('5+5/10', '5+5/10'), 124 | ('2*5/10', '10/10'), 125 | ('2/5*10', '2/5*10'), 126 | ('2**5', '2**5'), 127 | ('5@6', '5@6'), 128 | ] 129 | ) 130 | def test_int_not_eval(source, expected): 131 | """ 132 | Test BinOp with operations we don't want to fold 133 | """ 134 | 135 | run_test(source, expected) 136 | 137 | 138 | @pytest.mark.parametrize( 139 | ('source', 'expected'), [ 140 | ('"Hello" + "World"', '"Hello" + "World"'), 141 | ('"Hello" * 5', '"Hello" * 5'), 142 | ('b"Hello" + b"World"', 'b"Hello" + b"World"'), 143 | ('b"Hello" * 5', 'b"Hello" * 5'), 144 | ] 145 | ) 146 | def test_not_eval(source, expected): 147 | """ 148 | Test BinOps we don't want to fold 149 | """ 150 | 151 | run_test(source, expected) 152 | -------------------------------------------------------------------------------- /test/test_fstring.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'statement', [ 12 | 'f"{1=!r:.4}"', 13 | 'f"{1=:.4}"', 14 | 'f"{1=!s:.4}"', 15 | 'f"{1}"', 16 | 'f"{1=}"', 17 | 'f"{1=!s}"', 18 | 'f"{1=!a}"' 19 | ] 20 | ) 21 | def test_fstring_statement(statement): 22 | if sys.version_info < (3, 8): 23 | pytest.skip('f-string debug specifier added in python 3.8') 24 | 25 | assert unparse(ast.parse(statement)) == statement 26 | 27 | 28 | def test_pep0701(): 29 | if sys.version_info < (3, 12): 30 | pytest.skip('f-string syntax is bonkers before python 3.12') 31 | 32 | statement = 'f"{f"{f"{f"{"hello"}"}"}"}"' 33 | assert unparse(ast.parse(statement)) == statement 34 | 35 | statement = 'f"This is the playlist: {", ".join([])}"' 36 | assert unparse(ast.parse(statement)) == statement 37 | 38 | statement = 'f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"' 39 | assert unparse(ast.parse(statement)) == statement 40 | 41 | statement = ''' 42 | f"This is the playlist: {", ".join([ 43 | 'Take me back to Eden', # My, my, those eyes like fire 44 | 'Alkaline', # Not acid nor alkaline 45 | 'Ascensionism' # Take to the broken skies at last 46 | ])}" 47 | ''' 48 | assert unparse(ast.parse(statement)) == 'f"This is the playlist: {", ".join(["Take me back to Eden","Alkaline","Ascensionism"])}"' 49 | 50 | # statement = '''print(f"This is the playlist: {"\N{BLACK HEART SUIT}".join(songs)}")''' 51 | # assert unparse(ast.parse(statement)) == statement 52 | 53 | statement = '''f"Magic wand: {bag["wand"]}"''' 54 | assert unparse(ast.parse(statement)) == statement 55 | 56 | statement = """ 57 | f'''A complex trick: { 58 | bag['bag'] # recursive bags! 59 | }''' 60 | """ 61 | assert unparse(ast.parse(statement)) == 'f"A complex trick: {bag["bag"]}"' 62 | 63 | statement = '''f"These are the things: {", ".join(things)}"''' 64 | assert unparse(ast.parse(statement)) == statement 65 | 66 | statement = '''f"{source.removesuffix(".py")}.c: $(srcdir)/{source}"''' 67 | assert unparse(ast.parse(statement)) == statement 68 | 69 | statement = '''f"{f"{f"infinite"}"}"+' '+f"{f"nesting!!!"}"''' 70 | assert unparse(ast.parse(statement)) == statement 71 | 72 | statement = '''f"{"\\n".join(a)}"''' 73 | assert unparse(ast.parse(statement)) == statement 74 | 75 | statement = '''f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"''' 76 | assert unparse(ast.parse(statement)) == statement 77 | 78 | statement = '''f"{"":*^{1:{1}}}"''' 79 | assert unparse(ast.parse(statement)) == statement 80 | 81 | # statement = '''f"{"":*^{1:{1:{1}}}}"''' 82 | # assert unparse(ast.parse(statement)) == statement 83 | # SyntaxError: f-string: expressions nested too deeply 84 | 85 | statement = '''f"___{ 86 | x 87 | }___"''' 88 | assert unparse(ast.parse(statement)) == '''f"___{x}___"''' 89 | 90 | statement = '''f"Useless use of lambdas: {(lambda x:x*2)}"''' 91 | assert unparse(ast.parse(statement)) == statement 92 | 93 | 94 | def test_fstring_empty_str(): 95 | if sys.version_info < (3, 6): 96 | pytest.skip('f-string expressions not allowed in python < 3.6') 97 | 98 | source = r''' 99 | f"""\ 100 | {fg_br}""" 101 | ''' 102 | 103 | print(source) 104 | expected_ast = ast.parse(source) 105 | actual_ast = unparse(expected_ast) 106 | compare_ast(expected_ast, ast.parse(actual_ast)) 107 | -------------------------------------------------------------------------------- /test/test_import.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | import pytest 4 | 5 | from python_minifier import unparse 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'statement', [ 10 | 'import a', 11 | 'import a,b', 12 | 'import a as b', 13 | 'import a as b,c as d', 14 | 'import a.b', 15 | 'import a.b.c', 16 | 'import a.b.c as d', 17 | 'import a.b.c as d,e as f', 18 | 'from a import A', 19 | 'from.import A', 20 | 'from.import*', 21 | 'from..import A,B', 22 | 'from.a import A', 23 | 'from...a import A', 24 | 'from...a import*', 25 | 'from a import A as B', 26 | 'from a import A as B,C as D', 27 | 'from.a.b import A', 28 | 'from....a.b.c import A', 29 | ] 30 | ) 31 | def test_import_statement(statement): 32 | assert unparse(ast.parse(statement)) == statement 33 | -------------------------------------------------------------------------------- /test/test_is_constant_node.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import ast 6 | 7 | from python_minifier.util import is_constant_node 8 | 9 | 10 | def test_type_nodes(): 11 | assert is_constant_node(ast.Str('a'), ast.Str) 12 | 13 | if hasattr(ast, 'Bytes'): 14 | assert is_constant_node(ast.Bytes(b'a'), ast.Bytes) 15 | 16 | assert is_constant_node(ast.Num(1), ast.Num) 17 | assert is_constant_node(ast.Num(0), ast.Num) 18 | 19 | if hasattr(ast, 'NameConstant'): 20 | assert is_constant_node(ast.NameConstant(True), ast.NameConstant) 21 | assert is_constant_node(ast.NameConstant(False), ast.NameConstant) 22 | assert is_constant_node(ast.NameConstant(None), ast.NameConstant) 23 | else: 24 | assert is_constant_node(ast.Name(id='True', ctx=ast.Load()), ast.Name) 25 | assert is_constant_node(ast.Name(id='False', ctx=ast.Load()), ast.Name) 26 | assert is_constant_node(ast.Name(id='None', ctx=ast.Load()), ast.Name) 27 | 28 | assert is_constant_node(ast.Ellipsis(), ast.Ellipsis) 29 | 30 | 31 | def test_constant_nodes(): 32 | # only test on python 3.8+ 33 | if sys.version_info < (3, 8): 34 | pytest.skip('Constant not available') 35 | 36 | assert is_constant_node(ast.Constant('a'), ast.Str) 37 | assert is_constant_node(ast.Constant(b'a'), ast.Bytes) 38 | assert is_constant_node(ast.Constant(1), ast.Num) 39 | assert is_constant_node(ast.Constant(0), ast.Num) 40 | assert is_constant_node(ast.Constant(True), ast.NameConstant) 41 | assert is_constant_node(ast.Constant(False), ast.NameConstant) 42 | assert is_constant_node(ast.Constant(None), ast.NameConstant) 43 | assert is_constant_node(ast.Constant(ast.literal_eval('...')), ast.Ellipsis) 44 | -------------------------------------------------------------------------------- /test/test_iterable_unpacking.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | def test_return(): 11 | if sys.version_info < (3, 0): 12 | pytest.skip('Iterable unpacking in return not allowed in python < 3.0') 13 | 14 | elif sys.version_info < (3, 8): 15 | # Parenthesis are required 16 | source = 'def a():return(True,*[False])' 17 | 18 | else: 19 | # Parenthesis not required 20 | source = 'def a():return True,*[False]' 21 | 22 | expected_ast = ast.parse(source) 23 | minified = unparse(expected_ast) 24 | compare_ast(expected_ast, ast.parse(minified)) 25 | assert source == minified 26 | -------------------------------------------------------------------------------- /test/test_name_generator.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from python_minifier.rename.name_generator import name_filter 4 | 5 | 6 | def test_name_generator(): 7 | 8 | ng = name_filter() 9 | 10 | names = set() 11 | for name in itertools.islice(ng, 10000): 12 | assert len(name) <= 3 13 | names.add(name) 14 | 15 | assert len(names) == 10000 16 | 17 | # Check no keywords returned 18 | assert 'or' not in names 19 | 20 | # Check no builtins returned 21 | assert 'id' not in names 22 | assert 'abs' not in names 23 | -------------------------------------------------------------------------------- /test/test_nonlocal.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import python_minifier 6 | 7 | 8 | def test_nonlocal_name(): 9 | if sys.version_info < (3, 0): 10 | pytest.skip('No nonlocal in python < 3.0') 11 | 12 | test_code = ''' 13 | def test(): 14 | def outer(): 15 | def inner(): 16 | nonlocal rename_me 17 | rename_me = 'inner' 18 | 19 | inner() 20 | rename_me = False 21 | outer() 22 | return rename_me 23 | 24 | result = test() 25 | ''' 26 | 27 | # First check we understand the behavior 28 | unminified_locals = {} 29 | exec(test_code, {}, unminified_locals) 30 | assert unminified_locals['result'] == 'inner' 31 | 32 | minified = python_minifier.minify(test_code, rename_locals=True) 33 | print(minified) 34 | minified_locals = {} 35 | exec(minified, {}, minified_locals) 36 | assert minified_locals['result'] == 'inner' 37 | 38 | 39 | def test_nonlocal_def(): 40 | if sys.version_info < (3, 0): 41 | pytest.skip('No nonlocal in python < 3.0') 42 | 43 | test_code = ''' 44 | def test(): 45 | def patch(): 46 | nonlocal f 47 | f = lambda: 2 48 | 49 | def f(): 50 | return 1 51 | 52 | patch() 53 | return f() 54 | 55 | result = test() 56 | ''' 57 | 58 | # First check we understand the behavior 59 | unminified_locals = {} 60 | exec(test_code, {}, unminified_locals) 61 | assert unminified_locals['result'] == 2 62 | 63 | minified = python_minifier.minify(test_code, rename_locals=True) 64 | print(minified) 65 | minified_locals = {} 66 | exec(minified, {}, minified_locals) 67 | assert minified_locals['result'] == 2 68 | 69 | 70 | def test_nonlocal_import(): 71 | if sys.version_info < (3, 0): 72 | pytest.skip('No nonlocal in python < 3.0') 73 | 74 | test_code = ''' 75 | def test(): 76 | 77 | def patch(): 78 | nonlocal hashlib 79 | import hashlib 80 | 81 | hashlib = None 82 | patch() 83 | return 'sha256' in hashlib.algorithms_available 84 | 85 | result = test() 86 | ''' 87 | 88 | # First check we understand the behavior 89 | unminified_locals = {} 90 | exec(test_code, {}, unminified_locals) 91 | assert unminified_locals['result'] is True 92 | 93 | minified = python_minifier.minify(test_code, rename_locals=True) 94 | print(minified) 95 | minified_locals = {} 96 | exec(minified, {}, minified_locals) 97 | assert minified_locals['result'] is True 98 | 99 | 100 | def test_nonlocal_import_alias(): 101 | if sys.version_info < (3, 0): 102 | pytest.skip('No nonlocal in python < 3.0') 103 | 104 | test_code = ''' 105 | def test(): 106 | 107 | def patch(): 108 | nonlocal a 109 | import hashlib as a 110 | 111 | a = None 112 | patch() 113 | return 'sha256' in a.algorithms_available 114 | 115 | result = test() 116 | ''' 117 | 118 | # First check we understand the behavior 119 | unminified_locals = {} 120 | exec(test_code, {}, unminified_locals) 121 | assert unminified_locals['result'] is True 122 | 123 | minified = python_minifier.minify(test_code, rename_locals=True) 124 | print(minified) 125 | minified_locals = {} 126 | exec(minified, {}, minified_locals) 127 | assert minified_locals['result'] is True 128 | -------------------------------------------------------------------------------- /test/test_posargs.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | from python_minifier.transforms.remove_posargs import remove_posargs 9 | 10 | 11 | def test_pep(): 12 | if sys.version_info < (3, 8): 13 | pytest.skip('No Assignment expressions in python < 3.8') 14 | 15 | source = ''' 16 | def name(p1, p2, /, p_or_kw, *, kw): pass 17 | def name(p1, p2=None, /, p_or_kw=None, *, kw): pass 18 | def name(p1, p2=None, /, *, kw): pass 19 | def name(p1, p2=None, /): pass 20 | def name(p1, p2, /, p_or_kw): pass 21 | def name(p1, p2, /): pass 22 | def name(p_or_kw, *, kw): pass 23 | def name(*, kw): pass 24 | 25 | def standard_arg(arg): 26 | print(arg) 27 | def pos_only_arg(arg, /): 28 | print(arg) 29 | def kwd_only_arg(*, arg): 30 | print(arg) 31 | def combined_example(pos_only, /, standard, *, kwd_only): 32 | print(pos_only, standard, kwd_only) 33 | ''' 34 | 35 | expected_ast = ast.parse(source) 36 | actual_ast = unparse(expected_ast) 37 | compare_ast(expected_ast, ast.parse(actual_ast)) 38 | 39 | 40 | def test_convert(): 41 | if sys.version_info < (3, 8): 42 | pytest.skip('No Assignment expressions in python < 3.8') 43 | 44 | source = ''' 45 | def name(p1, p2, /, p_or_kw, *, kw): pass 46 | def name(p1, p2=None, /, p_or_kw=None, *, kw): pass 47 | def name(p1, p2=None, /, *, kw): pass 48 | def name(p1, p2=None, /): pass 49 | def name(p1, p2, /, p_or_kw): pass 50 | def name(p1, p2, /): pass 51 | def name(p_or_kw, *, kw): pass 52 | def name(*, kw): pass 53 | 54 | def standard_arg(arg): 55 | print(arg) 56 | def pos_only_arg(arg, /): 57 | print(arg) 58 | def kwd_only_arg(*, arg): 59 | print(arg) 60 | def combined_example(pos_only, /, standard, *, kwd_only): 61 | print(pos_only, standard, kwd_only) 62 | ''' 63 | 64 | expected = ''' 65 | def name(p1, p2, p_or_kw, *, kw): pass 66 | def name(p1, p2=None, p_or_kw=None, *, kw): pass 67 | def name(p1, p2=None, *, kw): pass 68 | def name(p1, p2=None): pass 69 | def name(p1, p2, p_or_kw): pass 70 | def name(p1, p2): pass 71 | def name(p_or_kw, *, kw): pass 72 | def name(*, kw): pass 73 | 74 | def standard_arg(arg): 75 | print(arg) 76 | def pos_only_arg(arg): 77 | print(arg) 78 | def kwd_only_arg(*, arg): 79 | print(arg) 80 | def combined_example(pos_only, standard, *, kwd_only): 81 | print(pos_only, standard, kwd_only) 82 | ''' 83 | 84 | expected_ast = ast.parse(expected) 85 | actual_ast = unparse(remove_posargs(ast.parse(source))) 86 | compare_ast(expected_ast, ast.parse(actual_ast)) 87 | -------------------------------------------------------------------------------- /test/test_preserve_shebang.py: -------------------------------------------------------------------------------- 1 | from python_minifier import minify 2 | 3 | 4 | def test_no_preserve_shebang(): 5 | source = '''#! hello this is my shebang 6 | a=0''' 7 | 8 | minified = '''a=0''' 9 | 10 | actual = minify(source, preserve_shebang=False) 11 | assert actual == minified 12 | 13 | 14 | def test_no_preserve_shebang_bytes(): 15 | source = b'''#! hello this is my shebang 16 | a=0''' 17 | 18 | minified = '''a=0''' 19 | 20 | actual = minify(source, preserve_shebang=False) 21 | assert actual == minified 22 | 23 | 24 | def test_preserve_shebang(): 25 | source = '''#! hello this is my shebang 26 | a=0''' 27 | 28 | actual = minify(source, preserve_shebang=True) 29 | assert actual == source 30 | 31 | 32 | def test_preserve_shebang_bytes(): 33 | source = b'''#! hello this is my shebang 34 | a=0''' 35 | 36 | actual = minify(source, preserve_shebang=True) 37 | assert actual == source.decode() 38 | 39 | 40 | def test_no_shebang(): 41 | source = '''a=0''' 42 | 43 | actual = minify(source, preserve_shebang=True) 44 | assert actual == source 45 | 46 | 47 | def test_no_shebang_bytes(): 48 | source = b'''a=0''' 49 | 50 | actual = minify(source, preserve_shebang=True) 51 | assert actual == source.decode() 52 | -------------------------------------------------------------------------------- /test/test_remove_assert.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from python_minifier.ast_annotation import add_parent 4 | from python_minifier.ast_compare import compare_ast 5 | from python_minifier.rename import add_namespace, bind_names, resolve_names 6 | from python_minifier.transforms.remove_asserts import RemoveAsserts 7 | 8 | 9 | def remove_asserts(source): 10 | module = ast.parse(source, 'remove_asserts') 11 | 12 | add_parent(module) 13 | add_namespace(module) 14 | bind_names(module) 15 | resolve_names(module) 16 | return RemoveAsserts()(module) 17 | 18 | 19 | def test_remove_assert_empty_module(): 20 | source = 'assert False' 21 | expected = '' 22 | 23 | expected_ast = ast.parse(expected) 24 | actual_ast = remove_asserts(source) 25 | compare_ast(expected_ast, actual_ast) 26 | 27 | 28 | def test_remove_assert_module(): 29 | source = '''import collections 30 | assert False 31 | a = 1 32 | assert False''' 33 | expected = '''import collections 34 | a=1''' 35 | 36 | expected_ast = ast.parse(expected) 37 | actual_ast = remove_asserts(source) 38 | compare_ast(expected_ast, actual_ast) 39 | 40 | 41 | def test_remove_if_empty(): 42 | source = '''if True: 43 | assert False''' 44 | expected = '''if True: 45 | 0''' 46 | 47 | expected_ast = ast.parse(expected) 48 | actual_ast = remove_asserts(source) 49 | compare_ast(expected_ast, actual_ast) 50 | 51 | 52 | def test_remove_if_line(): 53 | source = '''if True: assert False''' 54 | expected = '''if True: 0''' 55 | 56 | expected_ast = ast.parse(expected) 57 | actual_ast = remove_asserts(source) 58 | compare_ast(expected_ast, actual_ast) 59 | 60 | 61 | def test_remove_suite(): 62 | source = '''if True: 63 | assert False 64 | a=1 65 | assert False 66 | return None''' 67 | expected = '''if True: 68 | a=1 69 | return None''' 70 | 71 | expected_ast = ast.parse(expected) 72 | actual_ast = remove_asserts(source) 73 | compare_ast(expected_ast, actual_ast) 74 | 75 | 76 | def test_remove_from_class(): 77 | source = '''class A: 78 | assert False 79 | a = 1 80 | assert False 81 | def b(): 82 | assert False 83 | return 1 84 | assert False 85 | ''' 86 | expected = '''class A: 87 | a=1 88 | def b(): 89 | return 1 90 | ''' 91 | 92 | expected_ast = ast.parse(expected) 93 | actual_ast = remove_asserts(source) 94 | compare_ast(expected_ast, actual_ast) 95 | 96 | 97 | def test_remove_from_class_empty(): 98 | source = '''class A: 99 | assert False 100 | ''' 101 | expected = 'class A:0' 102 | 103 | expected_ast = ast.parse(expected) 104 | actual_ast = remove_asserts(source) 105 | compare_ast(expected_ast, actual_ast) 106 | 107 | 108 | def test_remove_from_class_func_empty(): 109 | source = '''class A: 110 | def b(): 111 | assert False 112 | ''' 113 | expected = '''class A: 114 | def b(): 0''' 115 | 116 | expected_ast = ast.parse(expected) 117 | actual_ast = remove_asserts(source) 118 | compare_ast(expected_ast, actual_ast) 119 | -------------------------------------------------------------------------------- /test/test_remove_debug.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | import pytest 4 | 5 | from python_minifier.ast_annotation import add_parent 6 | from python_minifier.ast_compare import compare_ast 7 | from python_minifier.rename import add_namespace, bind_names, resolve_names 8 | from python_minifier.transforms.remove_debug import RemoveDebug 9 | 10 | 11 | def remove_debug(source): 12 | module = ast.parse(source, 'remove_debug') 13 | 14 | add_parent(module) 15 | add_namespace(module) 16 | bind_names(module) 17 | resolve_names(module) 18 | return RemoveDebug()(module) 19 | 20 | 21 | def test_remove_debug_empty_module(): 22 | source = 'if __debug__: pass' 23 | expected = '' 24 | 25 | expected_ast = ast.parse(expected) 26 | actual_ast = remove_debug(source) 27 | compare_ast(expected_ast, actual_ast) 28 | 29 | 30 | def test_remove_debug_module(): 31 | source = '''import collections 32 | if __debug__: pass 33 | a = 1 34 | if __debug__: pass''' 35 | expected = '''import collections 36 | a=1''' 37 | 38 | expected_ast = ast.parse(expected) 39 | actual_ast = remove_debug(source) 40 | compare_ast(expected_ast, actual_ast) 41 | 42 | 43 | def test_remove_if_empty(): 44 | source = '''if True: 45 | if __debug__: pass''' 46 | expected = '''if True: 0''' 47 | 48 | expected_ast = ast.parse(expected) 49 | actual_ast = remove_debug(source) 50 | compare_ast(expected_ast, actual_ast) 51 | 52 | 53 | def test_remove_suite(): 54 | source = '''if True: 55 | if __debug__: pass 56 | a=1 57 | if __debug__: pass 58 | return None''' 59 | expected = '''if True: 60 | a=1 61 | return None''' 62 | 63 | expected_ast = ast.parse(expected) 64 | actual_ast = remove_debug(source) 65 | compare_ast(expected_ast, actual_ast) 66 | 67 | 68 | def test_remove_from_class(): 69 | source = '''class A: 70 | if __debug__: pass 71 | a = 1 72 | if __debug__: pass 73 | def b(): 74 | if __debug__: pass 75 | return 1 76 | if __debug__: pass 77 | ''' 78 | expected = '''class A: 79 | a=1 80 | def b(): 81 | return 1 82 | ''' 83 | 84 | expected_ast = ast.parse(expected) 85 | actual_ast = remove_debug(source) 86 | compare_ast(expected_ast, actual_ast) 87 | 88 | 89 | def test_remove_from_class_empty(): 90 | source = '''class A: 91 | if __debug__: pass 92 | ''' 93 | expected = 'class A:0' 94 | 95 | expected_ast = ast.parse(expected) 96 | actual_ast = remove_debug(source) 97 | compare_ast(expected_ast, actual_ast) 98 | 99 | 100 | def test_remove_from_class_func_empty(): 101 | source = '''class A: 102 | def b(): 103 | if __debug__: pass 104 | ''' 105 | expected = '''class A: 106 | def b(): 0''' 107 | 108 | expected_ast = ast.parse(expected) 109 | actual_ast = remove_debug(source) 110 | compare_ast(expected_ast, actual_ast) 111 | 112 | 113 | @pytest.mark.parametrize( 114 | 'condition', [ 115 | '__debug__', 116 | '__debug__ is True', 117 | '__debug__ is not False', 118 | '__debug__ == True' 119 | ] 120 | ) 121 | def test_remove_truthy_debug(condition): 122 | source = ''' 123 | value = 10 124 | 125 | # Truthy 126 | if ''' + condition + ''': 127 | value += 1 128 | 129 | print(value) 130 | ''' 131 | 132 | expected = ''' 133 | value = 10 134 | 135 | print(value) 136 | ''' 137 | 138 | expected_ast = ast.parse(expected) 139 | actual_ast = remove_debug(source) 140 | compare_ast(expected_ast, actual_ast) 141 | 142 | 143 | @pytest.mark.parametrize( 144 | 'condition', [ 145 | 'not __debug__', 146 | '__debug__ is False', 147 | '__debug__ is not True', 148 | '__debug__ == False', 149 | 'not __debug__ is True', 150 | 'not __debug__ is not False', 151 | 'not __debug__ == True' 152 | ] 153 | ) 154 | def test_no_remove_falsy_debug(condition): 155 | source = ''' 156 | value = 10 157 | 158 | # Truthy 159 | if ''' + condition + ''': 160 | value += 1 161 | 162 | print(value) 163 | ''' 164 | 165 | expected = source 166 | 167 | expected_ast = ast.parse(expected) 168 | actual_ast = remove_debug(source) 169 | compare_ast(expected_ast, actual_ast) 170 | -------------------------------------------------------------------------------- /test/test_remove_literal_statements.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from python_minifier.ast_annotation import add_parent 4 | from python_minifier.ast_compare import compare_ast 5 | from python_minifier.rename import add_namespace, bind_names, resolve_names 6 | from python_minifier.transforms.remove_literal_statements import RemoveLiteralStatements 7 | 8 | 9 | def remove_literals(source): 10 | module = ast.parse(source, 'test_remove_literal_statements') 11 | 12 | add_parent(module) 13 | add_namespace(module) 14 | bind_names(module) 15 | resolve_names(module) 16 | return RemoveLiteralStatements()(module) 17 | 18 | 19 | def test_remove_literal_num(): 20 | source = '213' 21 | expected = '' 22 | 23 | expected_ast = ast.parse(expected) 24 | actual_ast = remove_literals(source) 25 | compare_ast(expected_ast, actual_ast) 26 | 27 | 28 | def test_remove_literal_str(): 29 | source = '"hello"' 30 | expected = '' 31 | 32 | expected_ast = ast.parse(expected) 33 | actual_ast = remove_literals(source) 34 | compare_ast(expected_ast, actual_ast) 35 | 36 | 37 | def test_complex(): 38 | source = ''' 39 | "module docstring" 40 | a = 'hello' 41 | 42 | def t(): 43 | "function docstring" 44 | a = 2 45 | 0 46 | 2 47 | 'sadfsaf' 48 | def g(): 49 | "just a docstring" 50 | 51 | ''' 52 | expected = ''' 53 | a = 'hello' 54 | def t(): 55 | a=2 56 | def g(): 57 | 0 58 | ''' 59 | 60 | expected_ast = ast.parse(expected) 61 | actual_ast = remove_literals(source) 62 | compare_ast(expected_ast, actual_ast) 63 | -------------------------------------------------------------------------------- /test/test_remove_object.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier.ast_compare import compare_ast 7 | from python_minifier.transforms.remove_object_base import RemoveObject 8 | 9 | 10 | def test_remove_object_py3(): 11 | if sys.version_info < (3, 0): 12 | pytest.skip('This test is python3 only') 13 | 14 | source = ''' 15 | class Test(object): 16 | pass 17 | ''' 18 | expected = ''' 19 | class Test: 20 | pass 21 | ''' 22 | 23 | expected_ast = ast.parse(expected) 24 | actual_ast = RemoveObject()(ast.parse(source)) 25 | compare_ast(expected_ast, actual_ast) 26 | 27 | source = ''' 28 | class Test(another_base, object, third_base): 29 | pass 30 | ''' 31 | expected = ''' 32 | class Test(another_base, third_base): 33 | pass 34 | ''' 35 | 36 | expected_ast = ast.parse(expected) 37 | actual_ast = RemoveObject()(ast.parse(source)) 38 | compare_ast(expected_ast, actual_ast) 39 | 40 | expected_ast = ast.parse(expected) 41 | actual_ast = RemoveObject()(ast.parse(source)) 42 | compare_ast(expected_ast, actual_ast) 43 | 44 | source = ''' 45 | class Test(other_base): 46 | pass 47 | ''' 48 | expected = source 49 | 50 | expected_ast = ast.parse(expected) 51 | actual_ast = RemoveObject()(ast.parse(source)) 52 | compare_ast(expected_ast, actual_ast) 53 | 54 | 55 | def test_no_remove_object_py2(): 56 | if sys.version_info >= (3, 0): 57 | pytest.skip('This test is python2 only') 58 | 59 | source = ''' 60 | class Test(object): 61 | pass 62 | ''' 63 | expected = ''' 64 | class Test(object): 65 | pass 66 | ''' 67 | 68 | expected_ast = ast.parse(expected) 69 | actual_ast = RemoveObject()(ast.parse(source)) 70 | compare_ast(expected_ast, actual_ast) 71 | -------------------------------------------------------------------------------- /test/test_remove_pass.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from python_minifier.ast_annotation import add_parent 4 | from python_minifier.ast_compare import compare_ast 5 | from python_minifier.rename import add_namespace, bind_names, resolve_names 6 | from python_minifier.transforms.remove_pass import RemovePass 7 | 8 | 9 | def remove_literals(source): 10 | module = ast.parse(source, 'remove_literals') 11 | 12 | add_parent(module) 13 | add_namespace(module) 14 | bind_names(module) 15 | resolve_names(module) 16 | return RemovePass()(module) 17 | 18 | 19 | def test_remove_pass_empty_module(): 20 | source = 'pass' 21 | expected = '' 22 | 23 | expected_ast = ast.parse(expected) 24 | actual_ast = remove_literals(source) 25 | compare_ast(expected_ast, actual_ast) 26 | 27 | 28 | def test_remove_pass_module(): 29 | source = '''import collections 30 | pass 31 | a = 1 32 | pass''' 33 | expected = '''import collections 34 | a=1''' 35 | 36 | expected_ast = ast.parse(expected) 37 | actual_ast = remove_literals(source) 38 | compare_ast(expected_ast, actual_ast) 39 | 40 | 41 | def test_remove_if_empty(): 42 | source = '''if True: 43 | pass''' 44 | expected = '''if True: 45 | 0''' 46 | 47 | expected_ast = ast.parse(expected) 48 | actual_ast = remove_literals(source) 49 | compare_ast(expected_ast, actual_ast) 50 | 51 | 52 | def test_remove_if_line(): 53 | source = '''if True: pass''' 54 | expected = '''if True: 0''' 55 | 56 | expected_ast = ast.parse(expected) 57 | actual_ast = remove_literals(source) 58 | compare_ast(expected_ast, actual_ast) 59 | 60 | 61 | def test_remove_suite(): 62 | source = '''if True: 63 | pass 64 | a=1 65 | pass 66 | return None''' 67 | expected = '''if True: 68 | a=1 69 | return None''' 70 | 71 | expected_ast = ast.parse(expected) 72 | actual_ast = remove_literals(source) 73 | compare_ast(expected_ast, actual_ast) 74 | 75 | 76 | def test_remove_from_class(): 77 | source = '''class A: 78 | pass 79 | a = 1 80 | pass 81 | def b(): 82 | pass 83 | return 1 84 | pass 85 | ''' 86 | expected = '''class A: 87 | a=1 88 | def b(): 89 | return 1 90 | ''' 91 | 92 | expected_ast = ast.parse(expected) 93 | actual_ast = remove_literals(source) 94 | compare_ast(expected_ast, actual_ast) 95 | 96 | 97 | def test_remove_from_class_empty(): 98 | source = '''class A: 99 | pass 100 | ''' 101 | expected = 'class A:0' 102 | 103 | expected_ast = ast.parse(expected) 104 | actual_ast = remove_literals(source) 105 | compare_ast(expected_ast, actual_ast) 106 | 107 | 108 | def test_remove_from_class_func_empty(): 109 | source = '''class A: 110 | def b(): 111 | pass 112 | ''' 113 | expected = '''class A: 114 | def b(): 0''' 115 | 116 | expected_ast = ast.parse(expected) 117 | actual_ast = remove_literals(source) 118 | compare_ast(expected_ast, actual_ast) 119 | -------------------------------------------------------------------------------- /test/test_rename_builtins.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test for renaming of builtins 3 | 4 | This assumes the standard NameAssigner and name_generator 5 | """ 6 | 7 | import ast 8 | 9 | from python_minifier import unparse 10 | from python_minifier.ast_annotation import add_parent 11 | from python_minifier.ast_compare import CompareError, compare_ast 12 | from python_minifier.rename import add_namespace, allow_rename_globals, allow_rename_locals, bind_names, rename, resolve_names 13 | 14 | 15 | def do_rename(source): 16 | # This will raise if the source file can't be parsed 17 | module = ast.parse(source, 'test_rename_bultins') 18 | add_parent(module) 19 | add_namespace(module) 20 | bind_names(module) 21 | resolve_names(module) 22 | 23 | allow_rename_locals(module, rename_locals=True) 24 | allow_rename_globals(module, rename_globals=True) 25 | 26 | rename(module) 27 | 28 | return module 29 | 30 | 31 | def assert_code(expected_ast, actual_ast): 32 | try: 33 | compare_ast(expected_ast, actual_ast) 34 | except CompareError as e: 35 | print(e) 36 | print(unparse(actual_ast)) 37 | raise 38 | 39 | 40 | def test_rename_builtins(): 41 | source = ''' 42 | sorted() 43 | sorted() 44 | sorted() 45 | sorted() 46 | sorted() 47 | ''' 48 | expected = ''' 49 | A=sorted 50 | A() 51 | A() 52 | A() 53 | A() 54 | A() 55 | ''' 56 | 57 | expected_ast = ast.parse(expected) 58 | actual_ast = do_rename(source) 59 | assert_code(expected_ast, actual_ast) 60 | 61 | 62 | def test_no_rename_assigned_builtin(): 63 | source = ''' 64 | if random.choice([True, False]): 65 | sorted=str 66 | sorted() 67 | sorted() 68 | sorted() 69 | sorted() 70 | sorted() 71 | ''' 72 | expected = source 73 | 74 | expected_ast = ast.parse(expected) 75 | actual_ast = do_rename(source) 76 | assert_code(expected_ast, actual_ast) 77 | 78 | 79 | def test_rename_local_builtin(): 80 | source = ''' 81 | def t(): 82 | sorted() 83 | sorted() 84 | sorted() 85 | sorted() 86 | sorted() 87 | ''' 88 | expected = ''' 89 | A=sorted 90 | def B(): 91 | A() 92 | A() 93 | A() 94 | A() 95 | A() 96 | ''' 97 | 98 | expected_ast = ast.parse(expected) 99 | actual_ast = do_rename(source) 100 | assert_code(expected_ast, actual_ast) 101 | 102 | 103 | def test_no_rename_local_assigned_builtin(): 104 | source = ''' 105 | def a(): 106 | if random.choice([True, False]): 107 | sorted=str 108 | sorted() 109 | sorted() 110 | sorted() 111 | sorted() 112 | sorted() 113 | ''' 114 | 115 | expected = ''' 116 | def A(): 117 | if random.choice([True, False]): 118 | sorted=str 119 | sorted() 120 | sorted() 121 | sorted() 122 | sorted() 123 | sorted() 124 | ''' 125 | 126 | expected_ast = ast.parse(expected) 127 | actual_ast = do_rename(source) 128 | assert_code(expected_ast, actual_ast) 129 | -------------------------------------------------------------------------------- /test/test_slice.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from python_minifier import unparse 4 | from python_minifier.ast_compare import compare_ast 5 | 6 | 7 | def test_slice(): 8 | """AST for slices was changed in 3.9""" 9 | 10 | source = ''' 11 | x[name] 12 | x[1:2] 13 | x[1:2, 3] 14 | x[()] 15 | x[1:2, 2:2] 16 | x[a, ..., b:c] 17 | x[a, ..., b] 18 | x[(a, b)] 19 | x[a:b,] 20 | ''' 21 | 22 | expected_ast = ast.parse(source) 23 | actual_ast = unparse(expected_ast) 24 | compare_ast(expected_ast, ast.parse(actual_ast)) 25 | -------------------------------------------------------------------------------- /test/test_type_param_defaults.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | 4 | import pytest 5 | 6 | from python_minifier import unparse 7 | from python_minifier.ast_compare import compare_ast 8 | 9 | 10 | # There are bizarrely few examples of this, some in the PEP are even syntax errors 11 | 12 | def test_pep696(): 13 | if sys.version_info < (3, 13): 14 | pytest.skip('Defaults for type parameters are not supported in python < 3.13') 15 | 16 | source = ''' 17 | type Alias[DefaultT = int, T] = tuple[DefaultT, T] # SyntaxError: non-default TypeVars cannot follow ones with defaults 18 | 19 | def generic_func[DefaultT = int, T](x: DefaultT, y: T) -> None: ... # SyntaxError: non-default TypeVars cannot follow ones with defaults 20 | 21 | class GenericClass[DefaultT = int, T]: ... # SyntaxError: non-default TypeVars cannot follow ones with defaults 22 | 23 | ''' 24 | 25 | expected_ast = ast.parse(source) 26 | actual_ast = unparse(expected_ast) 27 | compare_ast(expected_ast, ast.parse(actual_ast)) 28 | 29 | 30 | def test_pep696_2(): 31 | if sys.version_info < (3, 13): 32 | pytest.skip('Defaults for type parameters are not supported in python < 3.13') 33 | 34 | source = ''' 35 | # TypeVars 36 | class Foo[T = str]: ... 37 | 38 | # ParamSpecs 39 | class Baz[**P = [int, str]]: ... 40 | 41 | # TypeVarTuples 42 | class Qux[*Ts = *tuple[int, bool]]: ... 43 | 44 | # TypeAliases 45 | type Qux[*Ts = *tuple[str]] = Ham[*Ts] 46 | type Rab[U, T = str] = Bar[T, U] 47 | ''' 48 | 49 | expected_ast = ast.parse(source) 50 | actual_ast = unparse(expected_ast) 51 | compare_ast(expected_ast, ast.parse(actual_ast)) 52 | 53 | 54 | def test_pep696_3(): 55 | if sys.version_info < (3, 13): 56 | pytest.skip('Defaults for type parameters are not supported in python < 3.13') 57 | 58 | source = ''' 59 | class Foo[T = int]: 60 | def meth(self) -> Self: 61 | return self 62 | 63 | reveal_type(Foo.meth) # type is (self: Foo[int]) -> Foo[int] 64 | 65 | ''' 66 | 67 | expected_ast = ast.parse(source) 68 | actual_ast = unparse(expected_ast) 69 | compare_ast(expected_ast, ast.parse(actual_ast)) 70 | 71 | 72 | def test_example(): 73 | if sys.version_info < (3, 13): 74 | pytest.skip('Defaults for type parameters are not supported in python < 3.13') 75 | 76 | source = ''' 77 | def overly_generic[ 78 | SimpleTypeVar, 79 | TypeVarWithDefault = int, 80 | TypeVarWithBound: int, 81 | TypeVarWithConstraints: (str, bytes), 82 | *SimpleTypeVarTuple = (int, float), 83 | **SimpleParamSpec = (str, bytearray), 84 | ]( 85 | a: SimpleTypeVar, 86 | b: TypeVarWithDefault, 87 | c: TypeVarWithBound, 88 | d: Callable[SimpleParamSpec, TypeVarWithConstraints], 89 | *e: SimpleTypeVarTuple, 90 | ): ... 91 | ''' 92 | 93 | expected_ast = ast.parse(source) 94 | actual_ast = unparse(expected_ast) 95 | compare_ast(expected_ast, ast.parse(actual_ast)) 96 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = python27,python33,python34,python35,python36,python37,python38,python39,pypy,pypy3 3 | 4 | [testenv] 5 | commands = 6 | pytest {posargs:test} --junitxml=junit-{envname}.xml --maxfail=1 --verbose 7 | 8 | [testenv:python27] 9 | basepython = /usr/bin/python2.7 10 | deps = 11 | atomicwrites==1.4.1 12 | attrs==20.3.0 13 | backports.functools-lru-cache==1.6.6 14 | configparser==4.0.2 15 | contextlib2==0.6.0.post1 16 | funcsigs==1.0.2 17 | importlib-metadata==2.1.3 18 | more-itertools==5.0.0 19 | pathlib2==2.3.7.post1 20 | pluggy==0.13.1 21 | py==1.11.0 22 | pytest==4.5.0 23 | PyYAML==5.1 24 | scandir==1.10.0 25 | sh==1.12.14 26 | six==1.16.0 27 | typing==3.10.0.0 28 | wcwidth==0.2.13 29 | zipp==1.2.0 30 | 31 | [testenv:python33] 32 | basepython = /usr/bin/python3.3 33 | deps = 34 | py==1.4.34 35 | pytest==3.2.5 36 | PyYAML==3.13 37 | sh==1.12.14 38 | 39 | [testenv:python34] 40 | basepython = /usr/bin/python3.4 41 | deps = 42 | atomicwrites==1.4.1 43 | attrs==20.3.0 44 | importlib-metadata==1.1.3 45 | more-itertools==7.2.0 46 | pathlib2==2.3.7.post1 47 | pluggy==0.13.1 48 | py==1.10.0 49 | pytest==4.5.0 50 | PyYAML==5.1 51 | scandir==1.10.0 52 | sh==1.12.14 53 | six==1.16.0 54 | typing==3.10.0.0 55 | wcwidth==0.2.13 56 | zipp==1.2.0 57 | 58 | [testenv:python35] 59 | basepython = /usr/bin/python3.5 60 | deps = 61 | atomicwrites==1.4.1 62 | attrs==20.3.0 63 | importlib-metadata==2.1.3 64 | more-itertools==8.14.0 65 | pathlib2==2.3.7.post1 66 | pluggy==0.13.1 67 | py==1.11.0 68 | pytest==4.5.0 69 | PyYAML==5.1 70 | sh==1.12.14 71 | six==1.16.0 72 | wcwidth==0.2.13 73 | zipp==1.2.0 74 | 75 | [testenv:python36] 76 | basepython = /usr/bin/python3.6 77 | deps = 78 | atomicwrites==1.4.1 79 | attrs==20.3.0 80 | importlib-metadata==4.8.3 81 | more-itertools==8.14.0 82 | pluggy==0.13.1 83 | py==1.11.0 84 | pytest==4.5.0 85 | PyYAML==5.1 86 | sh==1.12.14 87 | six==1.16.0 88 | typing-extensions==4.1.1 89 | wcwidth==0.2.13 90 | zipp==3.6.0 91 | 92 | [testenv:python37] 93 | basepython = /usr/bin/python3.7 94 | deps = 95 | atomicwrites==1.4.1 96 | attrs==20.3.0 97 | importlib-metadata==6.7.0 98 | more-itertools==9.1.0 99 | pluggy==0.13.1 100 | py==1.11.0 101 | pytest==4.5.0 102 | PyYAML==5.1 103 | sh==1.12.14 104 | six==1.16.0 105 | typing-extensions==4.7.1 106 | wcwidth==0.2.13 107 | zipp==3.15.0 108 | 109 | [testenv:python38] 110 | basepython = /usr/bin/python3.8 111 | deps = 112 | atomicwrites==1.4.1 113 | attrs==20.3.0 114 | more-itertools==10.5.0 115 | pluggy==0.13.1 116 | py==1.11.0 117 | pytest==4.5.0 118 | PyYAML==5.1 119 | sh==1.12.14 120 | six==1.16.0 121 | wcwidth==0.2.13 122 | 123 | [testenv:python39] 124 | basepython = /usr/local/bin/python3.9 125 | deps = 126 | exceptiongroup==1.2.2 127 | iniconfig==2.0.0 128 | packaging==24.1 129 | pluggy==1.5.0 130 | pytest==8.3.3 131 | PyYAML==6.0.2 132 | sh==2.0.7 133 | tomli==2.0.1 134 | 135 | [testenv:python310] 136 | basepython = /usr/local/bin/python3.10 137 | setenv = 138 | PIP_CONSTRAINT={toxinidir}/tox/pyyaml-5.4.1-constraints.txt 139 | deps = 140 | attrs==24.2.0 141 | iniconfig==2.0.0 142 | packaging==24.1 143 | pluggy==0.13.1 144 | py==1.11.0 145 | pyperf==2.2.0 146 | pytest==6.2.4 147 | PyYAML==5.4.1 148 | sh==1.14.2 149 | toml==0.10.2 150 | 151 | [testenv:python311] 152 | basepython = /usr/local/bin/python3.11 153 | deps = 154 | attrs==24.2.0 155 | iniconfig==2.0.0 156 | packaging==24.1 157 | pluggy==1.5.0 158 | py==1.11.0 159 | pyperf==2.4.1 160 | pytest==7.1.2 161 | PyYAML==6.0 162 | sh==1.14.3 163 | tomli==2.0.1 164 | 165 | [testenv:python312] 166 | basepython = /usr/local/bin/python3.12 167 | deps = 168 | iniconfig==2.0.0 169 | packaging==24.1 170 | pip==24.2 171 | pluggy==1.5.0 172 | psutil==6.0.0 173 | pyperf==2.6.1 174 | pytest==7.4.2 175 | PyYAML==6.0.1 176 | sh==2.0.6 177 | 178 | [testenv:python313] 179 | basepython = /usr/local/bin/python3.13 180 | deps = 181 | iniconfig==2.0.0 182 | packaging==24.1 183 | pip==24.2 184 | pluggy==1.5.0 185 | psutil==6.0.0 186 | pyperf==2.7.0 187 | pytest==8.3.3 188 | PyYAML==6.0.2 189 | sh==2.0.7 190 | 191 | [testenv:pypy] 192 | basepython = /usr/bin/pypy 193 | deps = 194 | atomicwrites==1.4.1 195 | attrs==20.3.0 196 | backports.functools-lru-cache==1.6.6 197 | cffi==1.12.0 198 | configparser==4.0.2 199 | contextlib2==0.6.0.post1 200 | funcsigs==1.0.2 201 | greenlet==0.4.13 202 | importlib-metadata==2.1.3 203 | more-itertools==5.0.0 204 | pathlib2==2.3.7.post1 205 | pluggy==0.13.1 206 | py==1.11.0 207 | pytest==4.5.0 208 | PyYAML==5.1 209 | readline==6.2.4.1 210 | scandir==1.10.0 211 | sh==1.12.14 212 | six==1.16.0 213 | typing==3.10.0.0 214 | wcwidth==0.2.13 215 | zipp==1.2.0 216 | 217 | [testenv:pypy3] 218 | basepython = /usr/bin/pypy3 219 | deps = 220 | atomicwrites==1.4.1 221 | attrs==20.3.0 222 | cffi==1.12.0 223 | greenlet==0.4.13 224 | importlib-metadata==2.1.3 225 | more-itertools==8.14.0 226 | pathlib2==2.3.7.post1 227 | pluggy==0.13.1 228 | py==1.11.0 229 | pytest==4.5.0 230 | PyYAML==5.1 231 | readline==6.2.4.1 232 | sh==1.12.14 233 | six==1.16.0 234 | wcwidth==0.2.13 235 | zipp==1.2.0 236 | -------------------------------------------------------------------------------- /tox/pyyaml-5.4.1-constraints.txt: -------------------------------------------------------------------------------- 1 | Cython < 3.0 2 | -------------------------------------------------------------------------------- /typing_test/stubtest-allowlist.txt: -------------------------------------------------------------------------------- 1 | python_minifier.ast_compat.* 2 | -------------------------------------------------------------------------------- /typing_test/test_badtyping.py: -------------------------------------------------------------------------------- 1 | """ 2 | This should fail type checking 3 | """ 4 | 5 | from python_minifier import minify 6 | 7 | 8 | def test_typing() -> None: 9 | 10 | minify( 11 | 456, 12 | remove_pass='yes please' 13 | ) 14 | -------------------------------------------------------------------------------- /typing_test/test_typing.py: -------------------------------------------------------------------------------- 1 | """ 2 | This should pass typechecking 3 | """ 4 | 5 | import ast 6 | 7 | from python_minifier import RemoveAnnotationsOptions, awslambda, minify, unparse 8 | 9 | 10 | def test_typing() -> None: 11 | """ This should have good types """ 12 | 13 | unparse(ast.parse('pass')) 14 | minify('pass') 15 | minify(b'pass') 16 | minify( 17 | 'pass', 18 | filename='filename', 19 | remove_annotations=True, 20 | remove_pass=True, 21 | remove_literal_statements=False, 22 | combine_imports=True, 23 | hoist_literals=True, 24 | rename_locals=True, 25 | preserve_locals=None, 26 | rename_globals=False, 27 | preserve_globals=None, 28 | remove_object_base=True, 29 | convert_posargs_to_args=True, 30 | preserve_shebang=True, 31 | remove_asserts=True, 32 | remove_debug=True 33 | ) 34 | awslambda('pass') 35 | awslambda( 36 | 'pass', 37 | filename='filename', 38 | entrypoint='myentrypoint' 39 | ) 40 | 41 | annotation_options = RemoveAnnotationsOptions( 42 | remove_variable_annotations=True, 43 | remove_return_annotations=True, 44 | remove_argument_annotations=True, 45 | remove_class_attribute_annotations=False 46 | ) 47 | minify('pass', remove_annotations=annotation_options) 48 | -------------------------------------------------------------------------------- /xtest/README.md: -------------------------------------------------------------------------------- 1 | # Extended Tests 2 | 3 | The tests in this directory take a very long time and require a specific environment to run. 4 | 5 | ## test_unparse_env.py 6 | 7 | Tests module unparsing of every file in the active python `sys.path`. If a minified unparsed module doesn't 8 | parse back into the original module, that test fails. 9 | 10 | ## test_regrtest.py 11 | 12 | Minifies and executes files listed in a test manifest. Multiple combinations of options are tested, with additional 13 | options specified where necessary. A non zero exit code from a minified execution is a test failure. 14 | 15 | The `manifests` directory contains manifests for testing the cpython/pypy regression tests on supported python versions. 16 | -------------------------------------------------------------------------------- /xtest/test_regrtest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shutil 4 | import sys 5 | 6 | import pytest 7 | 8 | from python_minifier import minify 9 | 10 | try: 11 | import yaml 12 | except ImportError: 13 | pass 14 | 15 | 16 | class Manifest(object): 17 | """ 18 | The test manifest for a python interpreter 19 | 20 | :param str interpreter: The python interpreter to use 21 | """ 22 | 23 | def __init__(self, interpreter): 24 | self._interpreter = interpreter 25 | 26 | self._manifest_path = 'xtest/manifests/' + interpreter + '_test_manifest.yaml' 27 | 28 | self._files = {} 29 | self.load() 30 | 31 | if interpreter == 'pypy': 32 | self._base_path = '/usr/lib64/pypy-7.0/lib-python/2.7/test/' 33 | elif interpreter == 'pypy3': 34 | self._base_path = '/usr/lib64/pypy3-7.0/lib-python/3/test/' 35 | else: 36 | self._base_path = os.path.join('/usr/lib64', interpreter, 'test') 37 | 38 | def load(self): 39 | with open(self._manifest_path) as f: 40 | self._files = yaml.safe_load(f) or {} 41 | 42 | def __len__(self): 43 | return sum([len(test_cases) for test_cases in self._files.values()]) 44 | 45 | def __iter__(self): 46 | for path in sorted(self._files.keys()): 47 | for test_case in self._files[path]: 48 | yield Case(path, **test_case['options']) 49 | 50 | def verify(self): 51 | """ 52 | Verify all test cases in the manifest pass 53 | """ 54 | print('1..%i' % len(self)) 55 | 56 | failed = 0 57 | 58 | for i, test_case in enumerate(self): 59 | try: 60 | test_case.run_test() 61 | print('ok %i - %s' % (i, test_case)) 62 | except Exception as e: 63 | failed += 1 64 | print('not ok %i - %s' % (i, test_case)) 65 | print(e) 66 | 67 | return failed 68 | 69 | 70 | class Case(object): 71 | def __init__(self, test_path, **options): 72 | self.test_path = test_path 73 | self.options = options 74 | 75 | def __repr__(self): 76 | return 'Case(%r, **%r)' % (self.test_path, self.options) 77 | 78 | def __str__(self): 79 | return '%s with options %r' % (self.test_path, self.options) 80 | 81 | def run_test(self): 82 | from sh import Command, ErrorReturnCode 83 | 84 | ErrorReturnCode.truncate_cap = 1000 85 | 86 | def execute(python, path): 87 | python = Command(python) 88 | python(path) 89 | 90 | try: 91 | with open(self.test_path, 'rb') as f: 92 | source = f.read() 93 | 94 | shutil.copy(self.test_path, self.test_path + '.bak') 95 | 96 | with open(self.test_path, 'wb') as f: 97 | f.write(minify(source, self.test_path, **self.options).encode()) 98 | 99 | execute(sys.executable, self.test_path) 100 | except ErrorReturnCode as e: 101 | print(self.test_path) 102 | print(e.stderr) 103 | raise 104 | finally: 105 | shutil.copy(self.test_path + '.bak', self.test_path) 106 | 107 | 108 | def get_active_manifest(): 109 | """ 110 | The TestManifest for the current interpreter 111 | """ 112 | 113 | if platform.python_implementation() == 'CPython': 114 | return Manifest('python%i.%i' % (sys.version_info[0], sys.version_info[1])) 115 | else: 116 | if sys.version_info[0] == 2: 117 | return Manifest('pypy') 118 | else: 119 | return Manifest('pypy3') 120 | 121 | 122 | manifest = get_active_manifest() 123 | 124 | 125 | @pytest.mark.parametrize('test_case', list(manifest), ids=lambda test_case: repr(test_case)) 126 | def test_regrtest(test_case): 127 | test_case.run_test() 128 | 129 | 130 | if __name__ == '__main__': 131 | sys.exit(manifest.verify()) 132 | -------------------------------------------------------------------------------- /xtest/test_unparse_env.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import sys 4 | import warnings 5 | 6 | import pytest 7 | 8 | from python_minifier import minify, unparse 9 | 10 | warnings.filterwarnings('ignore') 11 | 12 | 13 | def gather_files(): 14 | print('Interpreter version: ', sys.version_info) 15 | print('sys.path: ', sys.path) 16 | for sys_path in sys.path: 17 | for subdir, _dirs, files in os.walk(sys_path): 18 | for file in filter(lambda f: f.endswith('.py'), [os.path.join(subdir, file) for file in files]): 19 | yield file 20 | 21 | 22 | @pytest.mark.parametrize('path', gather_files()) 23 | def test_unparse(path): 24 | 25 | try: 26 | with open(path, 'rb') as f: 27 | source = f.read() 28 | except IOError: 29 | pytest.skip('IOError opening file') 30 | 31 | try: 32 | original_ast = ast.parse(source, path) 33 | except SyntaxError: 34 | pytest.skip('Invalid syntax in file') 35 | 36 | # Test unparsing 37 | unparse(original_ast) 38 | 39 | # Test transforms 40 | minify(source, filename=path) 41 | --------------------------------------------------------------------------------