Skip to content

Commit

Permalink
Add target python version options
Browse files Browse the repository at this point in the history
  • Loading branch information
dflook committed Sep 12, 2024
1 parent 6fc008c commit e35f880
Show file tree
Hide file tree
Showing 14 changed files with 393 additions and 62 deletions.
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
)
16 changes: 12 additions & 4 deletions src/python_minifier/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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 minify(
source: AnyStr,
filename: Optional[str] = ...,
Expand All @@ -25,13 +28,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: ...
66 changes: 64 additions & 2 deletions src/python_minifier/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
import sys

from python_minifier import minify
from python_minifier import minify, TargetPythonOptions
from python_minifier.transforms.remove_annotations_options import RemoveAnnotationsOptions

if sys.version_info >= (3, 8):
Expand Down Expand Up @@ -75,6 +75,10 @@ def main():
else:
sys.stdout.write(minified)

def split_version(version):
if version is None:
return None
return tuple(map(int, version.split('.')))

def parse_args():
parser = argparse.ArgumentParser(prog='pyminify', description='Minify Python source code', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=main.__doc__)
Expand Down Expand Up @@ -236,6 +240,36 @@ def parse_args():
dest='remove_class_attribute_annotations',
)

available_python_versions = ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
target_python_options = parser.add_argument_group('target python options', 'Options that affect which python versions the minified code should target')
target_python_options.add_argument(
'--target-python',
type=str,
choices=available_python_versions,
action='store',
help='The version of Python that the minified code should target.',
dest='target_python',
metavar='VERSION'
)
target_python_options.add_argument(
'--target-minimum-python',
type=str,
choices=available_python_versions,
action='store',
help='The minimum version of Python that the minified code should target. The default is the minimum version required by the source code.',
dest='target_minimum_python',
metavar='VERSION'
)
target_python_options.add_argument(
'--target-maximum-python',
type=str,
choices=available_python_versions,
action='store',
help='The maximum version of Python that the minified code should target. The default is the version pyminify is currently using. (Currently %s)' % '.'.join(str(v) for v in sys.version_info[:2]),
dest='target_maximum_python',
metavar='VERSION'
)

parser.add_argument('--version', '-v', action='version', version=version)

args = parser.parse_args()
Expand All @@ -258,6 +292,22 @@ def parse_args():
sys.stderr.write('error: --remove-class-attribute-annotations would do nothing when used with --no-remove-annotations\n')
sys.exit(1)

if args.target_python and (args.target_minimum_python or args.target_maximum_python):
sys.stderr.write('error: --target-python cannot be used with --target-minimum-python or --target-maximum-python\n')
sys.exit(1)

if args.target_python and split_version(args.target_python) > sys.version_info[:2]:
sys.stderr.write('error: --target-python cannot be greater than the version of Python running pyminify\n')
sys.exit(1)

if args.target_maximum_python and split_version(args.target_maximum_python) > sys.version_info[:2]:
sys.stderr.write('error: --target-maximum-python cannot be greater than the version of Python running pyminify\n')
sys.exit(1)

if args.target_maximum_python and args.target_minimum_python and split_version(args.target_maximum_python) < split_version(args.target_minimum_python):
sys.stderr.write('error: --target-maximum-python cannot be less than --target-minimum-python\n')
sys.exit(1)

return args


Expand Down Expand Up @@ -305,6 +355,17 @@ def do_minify(source, filename, minification_args):
remove_class_attribute_annotations=minification_args.remove_class_attribute_annotations,
)

if minification_args.target_python:
target_python = TargetPythonOptions(
minimum=split_version(minification_args.target_python),
maximum=split_version(minification_args.target_python)
)
else:
target_python = TargetPythonOptions(
minimum=split_version(minification_args.target_minimum_python) if minification_args.target_minimum_python else (2, 7),
maximum=split_version(minification_args.target_maximum_python) if minification_args.target_maximum_python else sys.version_info[:2]
)

return minify(
source,
filename=filename,
Expand All @@ -324,7 +385,8 @@ def do_minify(source, filename, minification_args):
remove_debug=minification_args.remove_debug,
remove_explicit_return_none=minification_args.remove_explicit_return_none,
remove_builtin_exception_brackets=minification_args.remove_exception_brackets,
constant_folding=minification_args.constant_folding
constant_folding=minification_args.constant_folding,
target_python=target_python
)


Expand Down
Loading

0 comments on commit e35f880

Please sign in to comment.