Skip to content

Commit

Permalink
Support more Python versions by avoiding ast.unparse
Browse files Browse the repository at this point in the history
Improve performance.
  • Loading branch information
eskildsf committed Dec 19, 2024
1 parent abc9ccd commit afc10e5
Show file tree
Hide file tree
Showing 4 changed files with 63 additions and 44 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/python-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9.20", "3.10.15", "3.11.11", "3.12.8", "3.13.1"]
python-version: ["3.7.17", "3.8.18", "3.9.20", "3.10.15", "3.11.11", "3.12.8", "3.13.1"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -20,8 +20,8 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: python -m pip install --upgrade pip
- name: Install reloading (as editable)
run: pip install -e ".[development]"
- name: Install reloading
run: pip install ".[development]"
- name: Lint with flake8. Stop if syntax errors or undefined variables.
run: |
cd reloading
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi"
[project]
name = "reloading"
dependencies = []
requires-python = ">=3.9"
requires-python = ">=3.6"
authors = [{name = "Julian Vossen", email = "[email protected]"}]
maintainers = [{name = "Eskild Schroll-Fleischer", email = "[email protected]"}]
version="1.2.0"
Expand All @@ -19,6 +19,9 @@ classifiers=[
"Topic :: Utilities",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand Down
91 changes: 53 additions & 38 deletions reloading/reloading.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
overload)
from itertools import chain
from functools import partial, update_wrapper
from copy import deepcopy
import logging

log = logging.getLogger("reloading")
Expand Down Expand Up @@ -167,14 +166,26 @@ def parse_file_until_successful(filepath: str) -> ast.Module:
source = load_file(filepath)


break_ast = ast.parse('raise Exception("break")').body
continue_ast = ast.parse('raise Exception("continue")').body


class ReplaceBreakContineWithExceptions(ast.NodeTransformer):
def visit_Break(self, node):
return break_ast

def visit_Continue(self, node):
return continue_ast


def replace_break_continue(ast_module: ast.Module):
# Replace "break" and "continue" with custom exceptions.
# Otherwise SyntaxError is raised because these instructions
# are called outside a loop.
code = ast.unparse(ast_module)
code = code.replace("break", "raise Exception('break')")
code = code.replace("continue", "raise Exception('continue')")
return compile(code, filename="", mode="exec")
transformer = ReplaceBreakContineWithExceptions()
transformed_ast_module = transformer.visit(ast_module)
ast.fix_missing_locations(transformed_ast_module)
return compile(transformed_ast_module, filename="", mode="exec")


class WhileLoop:
Expand All @@ -186,6 +197,13 @@ def __init__(self, ast_module: ast.Module, test: ast.Call, id: str):
self.test: ast.Call = test
self.id: str = id
self.compiled_body = replace_break_continue(self.ast)
# If no argument was supplied, then loop forever
ast_condition = ast.Expression(body=ast.Constant(True))
if len(test.args) > 0:
# Create expression to evaluate condition
ast_condition = ast.Expression(body=test.args[0])
ast.fix_missing_locations(ast_condition)
self.condition = compile(ast_condition, filename="", mode="eval")


class ForLoop:
Expand Down Expand Up @@ -242,9 +260,7 @@ def get_loop_object(loop_frame_info: inspect.FrameInfo,
def sorting_function(candidate):
return abs(candidate.lineno - loop_frame_info.lineno)
candidate = min(candidates, key=sorting_function)
# Use reloaded_file_ast as template.
loop_node_ast = deepcopy(reloaded_file_ast)
loop_node_ast.body = candidate.body
loop_node_ast = ast.Module(candidate.body, type_ignores=[])
if isinstance(candidate, ast.For):
assert isinstance(candidate.target, (ast.Name, ast.Tuple, ast.List))
return ForLoop(loop_node_ast, candidate.target, get_loop_id(candidate))
Expand Down Expand Up @@ -311,30 +327,40 @@ def execute_for_loop(seq: Iterable, loop_frame_info: inspect.FrameInfo):
caller_globals: Dict[str, Any] = loop_frame_info.frame.f_globals
caller_locals: Dict[str, Any] = loop_frame_info.frame.f_locals

file_stat: int = os.stat(filepath).st_mtime_ns
# Initialize variables
file_stat: int = 0
vacant_variable_name: str = ""
assign_compiled = compile('', filename='', mode='exec')
for_loop = get_loop_code(
loop_frame_info, loop_id=None
)

for i, iteration_variable_values in enumerate(seq):
# Reload code if possibly modified
if file_stat != os.stat(filepath).st_mtime_ns:
log.info(f'For loop at line {loop_frame_info.lineno} of file '
f'"{filepath}" has been reloaded.')
if i > 0:
log.info(f'For loop at line {loop_frame_info.lineno} of file '
f'"{filepath}" has been reloaded.')
for_loop = get_loop_code(
loop_frame_info, loop_id=for_loop.id
)
assert isinstance(for_loop, ForLoop)
file_stat = os.stat(filepath).st_mtime_ns
assert isinstance(for_loop, ForLoop)
# Make up a name for a variable which is not already present in
# the global or local namespace.
vacant_variable_name: str = unique_name(chain(caller_locals.keys(),
caller_globals.keys()))
# Make up a name for a variable which is not already present in
# the global or local namespace.
vacant_variable_name = unique_name(
chain(caller_locals.keys(), caller_globals.keys())
)
# Reassign variable values from vacant variable in local scope
assign = ast.Module([
ast.Assign(targets=[for_loop.iteration_variables],
value=ast.Name(vacant_variable_name, ast.Load()))],
type_ignores=[])
ast.fix_missing_locations(assign)
assign_compiled = compile(assign, filename='', mode='exec')
# Store iteration variable values in vacant variable in local scope
caller_locals[vacant_variable_name] = iteration_variable_values
# Reassign variable values from vacant variable in local scope
exec(for_loop.iteration_variables_str + " = " + vacant_variable_name,
caller_globals, caller_locals)
exec(assign_compiled, caller_globals, caller_locals)
# Clean up namespace
del caller_locals[vacant_variable_name]
try:
Expand Down Expand Up @@ -362,15 +388,7 @@ def execute_while_loop(loop_frame_info: inspect.FrameInfo):
)

def condition(while_loop):
test = ast.unparse(while_loop.test).replace("reloading", "")
# Make up a name for a variable which is not already present in
# the global or local namespace.
vacant_variable_name: str = unique_name(chain(caller_locals.keys(),
caller_globals.keys()))
exec(vacant_variable_name+" = "+test, caller_globals, caller_locals)
result = deepcopy(caller_locals[vacant_variable_name])
del caller_locals[vacant_variable_name]
return result
return eval(while_loop.condition, caller_globals, caller_locals)

i = 0
while condition(while_loop):
Expand Down Expand Up @@ -453,16 +471,14 @@ def strip_reloading_decorator(function_with_decorator: ast.FunctionDef):
Remove the 'reloading' decorator and all decorators before it.
"""
# Create shorthand for readability
fwd = function_with_decorator
# Find decorators
fwod = function_with_decorator
# Find decorator names
decorator_names = [get_decorator_name_or_none(decorator)
for decorator
in fwd.decorator_list]
# Find index of "reloading" decorator
reloading_index = decorator_names.index("reloading")
# Use function with decorator as template
fwod = deepcopy(fwd)
fwod.decorator_list = fwd.decorator_list[reloading_index + 1:]
in fwod.decorator_list]
fwod.decorator_list = [decorator for decorator, name
in zip(fwod.decorator_list, decorator_names)
if name != "reloading"]
function_without_decorator = fwod
return function_without_decorator

Expand Down Expand Up @@ -502,8 +518,7 @@ def sorting_function(candidate):
return abs(candidate.lineno - function_frame_info.lineno)
candidate = min(candidates, key=sorting_function)
function_node = strip_reloading_decorator(candidate)
function_node_ast = deepcopy(reloaded_file_ast)
function_node_ast.body = [function_node]
function_node_ast = ast.Module([function_node], type_ignores=[])
return function_node_ast
else:
raise ReloadingException(
Expand Down
5 changes: 3 additions & 2 deletions reloading/test_reloading.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,13 @@ def test_empty_iterator(self):
class TestReloadingWhileLoopWithoutChanges(unittest.TestCase):
def test_no_argument(self):
i = 0
# reloading() returns an empty sequence
while reloading():
i += 1
if i == 10:
break

if sys.version_info.major >= 3 and sys.version_info.minor >= 13:
self.assertEqual(i, 0)
self.assertEqual(i, 10)

def test_false(self):
i = 0
Expand Down

0 comments on commit afc10e5

Please sign in to comment.