Skip to content

Commit 0f51a60

Browse files
committed
Add --prefer-single-line option
1 parent a0eae6e commit 0f51a60

File tree

7 files changed

+239
-12
lines changed

7 files changed

+239
-12
lines changed

src/python_minifier/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ def minify(
7272
remove_debug=False,
7373
remove_explicit_return_none=True,
7474
remove_builtin_exception_brackets=True,
75-
constant_folding=True
75+
constant_folding=True,
76+
prefer_single_line=False,
7677
):
7778
"""
7879
Minify a python module
@@ -107,6 +108,7 @@ def minify(
107108
:param bool remove_explicit_return_none: If explicit return None statements should be replaced with a bare return
108109
:param bool remove_builtin_exception_brackets: If brackets should be removed when raising exceptions with no arguments
109110
:param bool constant_folding: If literal expressions should be evaluated
111+
:param bool prefer_single_line: If semi-colons should be preferred over newlines where there is no difference in output size
110112
111113
:rtype: str
112114
@@ -192,7 +194,7 @@ def minify(
192194
if convert_posargs_to_args:
193195
module = remove_posargs(module)
194196

195-
minified = unparse(module)
197+
minified = unparse(module, prefer_single_line=prefer_single_line)
196198

197199
if preserve_shebang is True:
198200
shebang_line = _find_shebang(source)
@@ -219,7 +221,7 @@ def _find_shebang(source):
219221
return None
220222

221223

222-
def unparse(module):
224+
def unparse(module, prefer_single_line=False):
223225
"""
224226
Turn a module AST into python code
225227
@@ -228,13 +230,14 @@ def unparse(module):
228230
229231
:param module: The module to turn into python code
230232
:type: module: :class:`ast.Module`
233+
:param bool prefer_single_line: If semi-colons should be preferred over newlines where there is no difference in output size
231234
:rtype: str
232235
233236
"""
234237

235238
assert isinstance(module, ast.Module)
236239

237-
printer = ModulePrinter()
240+
printer = ModulePrinter(prefer_single_line=prefer_single_line)
238241
printer(module)
239242

240243
try:

src/python_minifier/__init__.pyi

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,15 @@ def minify(
2828
remove_debug: bool = ...,
2929
remove_explicit_return_none: bool = ...,
3030
remove_builtin_exception_brackets: bool = ...,
31-
constant_folding: bool = ...
31+
constant_folding: bool = ...,
32+
prefer_single_line: bool = ...
3233
) -> Text: ...
3334

3435

35-
def unparse(module: ast.Module) -> Text: ...
36+
def unparse(
37+
module: ast.Module,
38+
prefer_single_line: bool = ...
39+
) -> Text: ...
3640

3741

3842
def awslambda(

src/python_minifier/__main__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ def parse_args():
140140
dest='in_place'
141141
)
142142

143+
parser.add_argument(
144+
'--prefer-single-line',
145+
action='store_true',
146+
help='Prefer multiple statements on a single line where there is no difference in output size',
147+
dest='prefer_single_line',
148+
)
149+
143150
# Minification arguments
144151
minification_options = parser.add_argument_group('minification options', 'Options that affect how the source is minified')
145152
minification_options.add_argument(
@@ -373,7 +380,8 @@ def do_minify(source, filename, minification_args):
373380
remove_debug=minification_args.remove_debug,
374381
remove_explicit_return_none=minification_args.remove_explicit_return_none,
375382
remove_builtin_exception_brackets=minification_args.remove_exception_brackets,
376-
constant_folding=minification_args.constant_folding
383+
constant_folding=minification_args.constant_folding,
384+
prefer_single_line=minification_args.prefer_single_line,
377385
)
378386

379387
# Encode minified result to bytes for comparison and output

src/python_minifier/expression_printer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ExpressionPrinter(object):
1111
Builds the smallest possible exact representation of an ast
1212
"""
1313

14-
def __init__(self):
14+
def __init__(self, prefer_single_line=False):
1515

1616
self.precedences = {
1717
'Lambda': 2, # Lambda
@@ -34,7 +34,7 @@ def __init__(self):
3434
'Tuple': 18, 'Set': 18, 'List': 18, 'Dict': 18, 'ListComp': 18, 'SetComp': 18, 'DictComp': 18, 'GeneratorExp': 18, # Container
3535
}
3636

37-
self.printer = TokenPrinter()
37+
self.printer = TokenPrinter(prefer_single_line=prefer_single_line)
3838

3939
def __call__(self, module):
4040
"""

src/python_minifier/module_printer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ class ModulePrinter(ExpressionPrinter):
1111
Builds the smallest possible exact representation of an ast
1212
"""
1313

14-
def __init__(self, indent_char='\t'):
15-
super(ModulePrinter, self).__init__()
14+
def __init__(self, indent_char='\t', prefer_single_line=False):
15+
super(ModulePrinter, self).__init__(prefer_single_line=prefer_single_line)
1616
self.indent_char = indent_char
1717

1818
def __call__(self, module):

src/python_minifier/token_printer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def leave_block(self):
306306
def end_statement(self):
307307
""" End a statement with a newline, or a semi-colon if it saves characters. """
308308

309-
if self.indent == 0:
309+
if self.indent == 0 and not self._prefer_single_line:
310310
self.newline()
311311
else:
312312
if self._code[-1] != ';':

test/test_prefer_single_line.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Tests for the prefer_single_line option."""
2+
import ast
3+
import sys
4+
import os
5+
import tempfile
6+
7+
from python_minifier import minify, unparse
8+
from python_minifier.ast_annotation import add_parent
9+
from python_minifier.rename import add_namespace
10+
11+
from subprocess_compat import run_subprocess, safe_decode
12+
13+
14+
# minify() tests
15+
16+
def test_minify_default_uses_newlines():
17+
"""Default behavior uses newlines between module-level statements."""
18+
source = '''
19+
a = 1
20+
b = 2
21+
c = 3
22+
'''
23+
expected = 'a=1\nb=2\nc=3'
24+
assert minify(source) == expected
25+
26+
27+
def test_minify_prefer_single_line_false_uses_newlines():
28+
"""prefer_single_line=False uses newlines between module-level statements."""
29+
source = '''
30+
a = 1
31+
b = 2
32+
c = 3
33+
'''
34+
expected = 'a=1\nb=2\nc=3'
35+
assert minify(source, prefer_single_line=False) == expected
36+
37+
38+
def test_minify_prefer_single_line_true_uses_semicolons():
39+
"""prefer_single_line=True uses semicolons between module-level statements."""
40+
source = '''
41+
a = 1
42+
b = 2
43+
c = 3
44+
'''
45+
expected = 'a=1;b=2;c=3'
46+
assert minify(source, prefer_single_line=True) == expected
47+
48+
49+
def test_minify_single_statement_no_trailing_separator():
50+
"""Single statement has no trailing separator regardless of option."""
51+
source = 'a = 1'
52+
expected = 'a=1'
53+
assert minify(source, prefer_single_line=False) == expected
54+
assert minify(source, prefer_single_line=True) == expected
55+
56+
57+
def test_minify_empty_module():
58+
"""Empty module produces empty output."""
59+
source = ''
60+
expected = ''
61+
assert minify(source, prefer_single_line=False) == expected
62+
assert minify(source, prefer_single_line=True) == expected
63+
64+
65+
def test_minify_function_body_uses_semicolons():
66+
"""Function body statements use semicolons regardless of option."""
67+
source = '''
68+
def f():
69+
a = 1
70+
b = 2
71+
return a + b
72+
'''
73+
# Both produce identical output since the option only affects module level
74+
expected = 'def f():A=1;B=2;return A+B'
75+
assert minify(source, prefer_single_line=False) == expected
76+
assert minify(source, prefer_single_line=True) == expected
77+
78+
79+
def test_minify_class_body_uses_semicolons():
80+
"""Class body statements use semicolons regardless of option."""
81+
source = '''
82+
class C:
83+
a = 1
84+
b = 2
85+
'''
86+
# Both produce identical output since the option only affects module level
87+
expected = 'class C:a=1;b=2'
88+
assert minify(source, prefer_single_line=False) == expected
89+
assert minify(source, prefer_single_line=True) == expected
90+
91+
92+
def test_minify_mixed_module_and_function():
93+
"""Compound statements like def require newlines regardless of option."""
94+
source = '''
95+
x = 1
96+
def f():
97+
a = 1
98+
b = 2
99+
y = 2
100+
'''
101+
# Both outputs are identical because compound statements require newlines
102+
expected = 'x=1\ndef f():A=1;B=2\ny=2'
103+
assert minify(source, prefer_single_line=False) == expected
104+
assert minify(source, prefer_single_line=True) == expected
105+
106+
107+
def test_minify_imports():
108+
"""Import statements respect prefer_single_line option."""
109+
source = '''
110+
import os
111+
import sys
112+
a = 1
113+
'''
114+
# Imports are combined, module-level separator differs
115+
expected_newlines = 'import os,sys\na=1'
116+
expected_semicolons = 'import os,sys;a=1'
117+
assert minify(source, prefer_single_line=False) == expected_newlines
118+
assert minify(source, prefer_single_line=True) == expected_semicolons
119+
120+
121+
# unparse() tests
122+
123+
def _prepare_module(source):
124+
"""Parse and annotate a module for unparsing."""
125+
module = ast.parse(source)
126+
add_parent(module)
127+
add_namespace(module)
128+
return module
129+
130+
131+
def test_unparse_default_uses_newlines():
132+
"""unparse() default uses newlines."""
133+
source = 'a=1\nb=2'
134+
expected = 'a=1\nb=2'
135+
module = _prepare_module(source)
136+
assert unparse(module) == expected
137+
138+
139+
def test_unparse_prefer_single_line_false():
140+
"""unparse() with prefer_single_line=False uses newlines."""
141+
source = 'a=1\nb=2'
142+
expected = 'a=1\nb=2'
143+
module = _prepare_module(source)
144+
assert unparse(module, prefer_single_line=False) == expected
145+
146+
147+
def test_unparse_prefer_single_line_true():
148+
"""unparse() with prefer_single_line=True uses semicolons."""
149+
source = 'a=1\nb=2'
150+
expected = 'a=1;b=2'
151+
module = _prepare_module(source)
152+
assert unparse(module, prefer_single_line=True) == expected
153+
154+
155+
# CLI tests
156+
157+
def test_cli_default_uses_newlines():
158+
"""CLI without --prefer-single-line uses newlines."""
159+
code = 'a = 1\nb = 2\nc = 3'
160+
expected = 'a=1\nb=2\nc=3'
161+
162+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
163+
f.write(code)
164+
temp_file = f.name
165+
166+
try:
167+
result = run_subprocess([
168+
sys.executable, '-m', 'python_minifier', temp_file
169+
], timeout=30)
170+
171+
assert result.returncode == 0
172+
stdout_text = safe_decode(result.stdout)
173+
assert stdout_text == expected
174+
finally:
175+
os.unlink(temp_file)
176+
177+
178+
def test_cli_prefer_single_line_flag():
179+
"""CLI with --prefer-single-line uses semicolons."""
180+
code = 'a = 1\nb = 2\nc = 3'
181+
expected = 'a=1;b=2;c=3'
182+
183+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
184+
f.write(code)
185+
temp_file = f.name
186+
187+
try:
188+
result = run_subprocess([
189+
sys.executable, '-m', 'python_minifier',
190+
'--prefer-single-line', temp_file
191+
], timeout=30)
192+
193+
assert result.returncode == 0
194+
stdout_text = safe_decode(result.stdout)
195+
assert stdout_text == expected
196+
finally:
197+
os.unlink(temp_file)
198+
199+
200+
def test_cli_stdin_prefer_single_line():
201+
"""CLI --prefer-single-line works with stdin."""
202+
code = 'a = 1\nb = 2\nc = 3'
203+
expected = 'a=1;b=2;c=3'
204+
205+
result = run_subprocess([
206+
sys.executable, '-m', 'python_minifier',
207+
'--prefer-single-line', '-'
208+
], input_data=code, timeout=30)
209+
210+
assert result.returncode == 0
211+
stdout_text = safe_decode(result.stdout)
212+
assert stdout_text == expected

0 commit comments

Comments
 (0)