Skip to content

Add options for specifying target python versions #105

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test_corpus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
matrix:
python: ["2.7", "3.3", "3.4", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
ref: ["${{ inputs.ref }}", "${{ inputs.base-ref }}"]
target_current_python: ["true"]
container:
image: danielflook/python-minifier-build:python${{ matrix.python }}-2024-01-12
volumes:
Expand Down Expand Up @@ -96,7 +97,7 @@ jobs:
export PYTHONHASHSEED=0
fi

python${{matrix.python}} workflow/corpus_test/generate_results.py /corpus /corpus-results $(<sha.txt) ${{ inputs.regenerate-results }}
python${{matrix.python}} workflow/corpus_test/generate_results.py /corpus /corpus-results $(<sha.txt) ${{ inputs.regenerate-results }} ${{ matrix.target_current_python }}

generate_report:
name: Generate Report
Expand Down
12 changes: 11 additions & 1 deletion corpus_test/generate_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,17 @@ def report(results_dir: str, minifier_ref: str, minifier_sha: str, base_ref: str
try:
base_summary = result_summary(results_dir, python_version, base_sha)
except FileNotFoundError:
base_summary = ResultSet(python_version, base_ref)
yield (
f'| {python_version} ' +
f'| {summary.valid_count} ' +
f'| {summary.mean_time:.3f} ' +
f'| {summary.mean_percent_of_original:.3f}% ' +
f'| {len(list(summary.larger_than_original()))} ' +
f'| {len(list(summary.recursion_error()))} ' +
f'| {len(list(summary.unstable_minification()))} ' +
f'| {len(list(summary.exception()))} '
)
continue

mean_time_change = summary.mean_time - base_summary.mean_time

Expand Down
33 changes: 25 additions & 8 deletions corpus_test/generate_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class RE(Exception):
pass


def minify_corpus_entry(corpus_path, corpus_entry):
def minify_corpus_entry(corpus_path, corpus_entry, target_python):
"""
Minify a single entry in the corpus and return the result

Expand All @@ -40,7 +40,7 @@ def minify_corpus_entry(corpus_path, corpus_entry):

start_time = time.time()
try:
minified = python_minifier.minify(source, filename=corpus_entry)
minified = python_minifier.minify(source, filename=corpus_entry, target_python=target_python)
end_time = time.time()
result.time = end_time - start_time

Expand Down Expand Up @@ -71,7 +71,7 @@ def minify_corpus_entry(corpus_path, corpus_entry):
return result


def corpus_test(corpus_path, results_path, sha, regenerate_results):
def corpus_test(corpus_path, results_path, sha, regenerate_results, target_current_python):
"""
Test the minifier on the entire corpus

Expand All @@ -87,15 +87,31 @@ def corpus_test(corpus_path, results_path, sha, regenerate_results):
"""
python_version = '.'.join([str(s) for s in sys.version_info[:2]])

log_path = 'results_' + python_version + '_' + sha + '.log'
target_python = None

if target_current_python:
log_path = 'results_' + python_version + '_' + sha + '.log'
results_file_path = os.path.join(results_path, 'results_' + python_version + '_' + sha + '.csv')

if hasattr(python_minifier, 'TargetPythonOptions'):
target_python = python_minifier.TargetPythonOptions(
minimum=sys.version_info[:2],
maximum=sys.version_info[:2]
)
print(target_python)
else:
print('Old version of python-minifier which always targets current version')
else:
log_path = 'results_' + python_version + '_compatible_target_' + sha + '.log'
results_file_path = os.path.join(results_path, 'results_' + python_version + '_compatible_target_' + sha + '.csv')
print('Targeting compatible Python versions')

print('Logging in GitHub Actions is absolute garbage. Logs are going to ' + log_path)

logging.basicConfig(filename=os.path.join(results_path, log_path), level=logging.DEBUG)

corpus_entries = [entry[:-len('.py.gz')] for entry in os.listdir(corpus_path)]

results_file_path = os.path.join(results_path, 'results_' + python_version + '_' + sha + '.csv')

if os.path.isfile(results_file_path):
logging.info('Results file already exists: %s', results_file_path)
if regenerate_results:
Expand All @@ -117,7 +133,7 @@ def corpus_test(corpus_path, results_path, sha, regenerate_results):

logging.debug(entry)

result = minify_corpus_entry(corpus_path, entry)
result = minify_corpus_entry(corpus_path, entry, target_python=target_python)
result_writer.write(result)
tested_entries += 1

Expand All @@ -144,9 +160,10 @@ def main():
parser.add_argument('results_dir', type=str, help='Path to results directory', default='results')
parser.add_argument('minifier_sha', type=str, help='The python-minifier sha we are testing')
parser.add_argument('regenerate_results', type=bool_parse, help='Regenerate results even if they are present', default='false')
parser.add_argument('target_current_python', type=bool_parse, help='Target the minify process to the current Python version', default='true')
args = parser.parse_args()

corpus_test(args.corpus_dir, args.results_dir, args.minifier_sha, args.regenerate_results)
corpus_test(args.corpus_dir, args.results_dir, args.minifier_sha, args.regenerate_results, args.target_current_python)


if __name__ == '__main__':
Expand Down
2 changes: 1 addition & 1 deletion docs/source/api_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Package Reference
=================

.. automodule:: python_minifier

.. autoclass:: TargetPythonOptions
.. autofunction:: minify
.. autoclass:: RemoveAnnotationsOptions
.. autofunction:: awslambda
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This package transforms python source code into a 'minified' representation of t
:caption: Contents:

installation
python_target_version
command_usage
api_usage
transforms/index
Expand Down
18 changes: 18 additions & 0 deletions docs/source/python_target_version.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Python Target Version
=====================

This package uses the version of Python that it is installed with to parse your source code.
This means that you should install python-minifier using a version of Python that is appropriate for the source code you want to minify.

The output aims to match the Python compatibility of the original source code.

There are options to configure the target versions of Python that the minified code should be compatible with, which will affect the output of the minification process.
You can specify the minimum and maximum target versions of Python that the minified code should be compatible with.

If the input source module uses syntax that is not compatible with the specified target versions, the target version range is automatically adjusted to include the syntax used in the input source module.

.. note::
The target version options will not increase the Python compatibility of the minified code beyond the compatibility of the original source code.

They can only be used to reduce the compatibility of the minified code to a subset of the compatibility of the original source code.

2 changes: 1 addition & 1 deletion docs/source/transforms/remove_object_base.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Remove Object Base
==================

In Python 3 all classes implicitly inherit from ``object``. This transform removes ``object`` from the base class list
of all classes. This transform does nothing on Python 2.
of all classes. This transform is only applied if the target Python version is 3.0 or higher.

This transform is always safe to use and enabled by default.

Expand Down
80 changes: 73 additions & 7 deletions src/python_minifier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
a 'minified' representation of the same source code.

"""
import sys

import python_minifier.ast_compat as ast
import re

from python_minifier import compat
from python_minifier.ast_compare import CompareError, compare_ast
from python_minifier.module_printer import ModulePrinter
from python_minifier.rename import (
Expand Down Expand Up @@ -51,6 +53,46 @@ def __init__(self, exception, source, minified):
def __str__(self):
return 'Minification was unstable! Please create an issue at https://github.com/dflook/python-minifier/issues'

class TargetPythonOptions(object):
"""
Options that can be passed to the minify function to specify the target python version

:param minimum: The minimum python version that the minified code should be compatible with
:type minimum: tuple[int, int] or None
:param maximum: The maximum python version that the minified code should be compatible with
:type maximum: tuple[int, int] or None
"""

def __init__(self, minimum, maximum):
self.minimum = minimum
self.maximum = maximum
self._constrained = False

def apply_constraint(self, minimum, maximum):
"""
Apply a constraint to the target python version

:param minimum: The minimum python version that the minified code should be compatible with
:type minimum: tuple[int, int]
:param maximum: The maximum python version that the minified code should be compatible with
:type maximum: tuple[int, int]
"""
assert maximum >= minimum

if minimum > self.minimum:
self.minimum = minimum

if maximum < self.maximum:
self.maximum = maximum

if self.minimum > self.maximum:
self.maximum = self.minimum

self._constrained = True

def __repr__(self):
return 'TargetPythonOptions(minimum=%r, target_maximum_python=%r)' % (self.minimum, self.maximum)


def minify(
source,
Expand All @@ -71,7 +113,8 @@ def minify(
remove_debug=False,
remove_explicit_return_none=True,
remove_builtin_exception_brackets=True,
constant_folding=True
constant_folding=True,
target_python=None
):
"""
Minify a python module
Expand Down Expand Up @@ -105,6 +148,8 @@ def minify(
:param bool remove_explicit_return_none: If explicit return None statements should be replaced with a bare return
:param bool remove_builtin_exception_brackets: If brackets should be removed when raising exceptions with no arguments
:param bool constant_folding: If literal expressions should be evaluated
:param target_python: Options for the target python version
:type target_python: :class:`TargetPythonOptions`

:rtype: str

Expand All @@ -115,6 +160,11 @@ def minify(
# This will raise if the source file can't be parsed
module = ast.parse(source, filename)

if target_python is None:
target_python = TargetPythonOptions((2, 7), (sys.version_info.major, sys.version_info.minor))
if target_python._constrained is False:
target_python.apply_constraint(*compat.find_syntax_versions(module))

add_namespace(module)

if remove_literal_statements:
Expand All @@ -141,7 +191,7 @@ def minify(
if remove_pass:
module = RemovePass()(module)

if remove_object_base:
if target_python.minimum >= (3, 0) and remove_object_base:
module = RemoveObject()(module)

if remove_asserts:
Expand Down Expand Up @@ -189,7 +239,7 @@ def minify(
if convert_posargs_to_args:
module = remove_posargs(module)

minified = unparse(module)
minified = unparse(module, target_python)

if preserve_shebang is True:
shebang_line = _find_shebang(source)
Expand All @@ -214,7 +264,7 @@ def _find_shebang(source):

return None

def unparse(module):
def unparse(module, target_python=None):
"""
Turn a module AST into python code

Expand All @@ -223,13 +273,22 @@ def unparse(module):

:param module: The module to turn into python code
:type: module: :class:`ast.Module`
:param target_python: Options for the target python version
:type target_python: :class:`TargetPythonOptions`
:rtype: str

"""

assert isinstance(module, ast.Module)

printer = ModulePrinter()
if target_python is None:
target_python = TargetPythonOptions((2, 7), (sys.version_info.major, sys.version_info.minor))
if target_python._constrained is False:
target_python.apply_constraint(*compat.find_syntax_versions(module))

sys.stderr.write('Target Python: %r\n' % target_python)

printer = ModulePrinter(target_python)
printer(module)

try:
Expand All @@ -245,7 +304,7 @@ def unparse(module):
return printer.code


def awslambda(source, filename=None, entrypoint=None):
def awslambda(source, filename=None, entrypoint=None, target_python=None):
"""
Minify a python module for use as an AWS Lambda function

Expand All @@ -256,6 +315,8 @@ def awslambda(source, filename=None, entrypoint=None):
:param str filename: The original source filename if known
:param entrypoint: The lambda entrypoint function
:type entrypoint: str or NoneType
:param target_python: Options for the target python version
:type target_python: :class:`TargetPythonOptions`
:rtype: str

"""
Expand All @@ -265,5 +326,10 @@ def awslambda(source, filename=None, entrypoint=None):
rename_globals = False

return minify(
source, filename, remove_literal_statements=True, rename_globals=rename_globals, preserve_globals=[entrypoint],
source,
filename,
remove_literal_statements=True,
rename_globals=rename_globals,
preserve_globals=[entrypoint],
target_python=target_python
)
18 changes: 14 additions & 4 deletions src/python_minifier/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import ast
from typing import List, Text, AnyStr, Optional, Any, Union
from typing import List, Text, AnyStr, Optional, Any, Union, Tuple

from .transforms.remove_annotations_options import RemoveAnnotationsOptions as RemoveAnnotationsOptions

class UnstableMinification(RuntimeError):
def __init__(self, exception: Any, source: Any, minified: Any): ...

class TargetPythonOptions(object):
def __init__(self, minimum: Optional[Tuple[int, int]], maximum: Optional[Tuple[int, int]]): ...

def apply_constraint(self, minimum: Tuple[int, int], maximum: Tuple[int, int]) -> None: ...

def minify(
source: AnyStr,
filename: Optional[str] = ...,
Expand All @@ -25,13 +30,18 @@ def minify(
remove_debug: bool = ...,
remove_explicit_return_none: bool = ...,
remove_builtin_exception_brackets: bool = ...,
constant_folding: bool = ...
constant_folding: bool = ...,
target_python: Optional[TargetPythonOptions] = ...
) -> Text: ...

def unparse(module: ast.Module) -> Text: ...
def unparse(
module: ast.Module,
target_python: Optional[TargetPythonOptions] = ...
) -> Text: ...

def awslambda(
source: AnyStr,
filename: Optional[Text] = ...,
entrypoint: Optional[Text] = ...
entrypoint: Optional[Text] = ...,
target_python: Optional[TargetPythonOptions] = ...
) -> Text: ...
Loading
Loading