diff --git a/README.md b/README.md index 8faf4e4..2903baf 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ function = reloading(function) ## Additional Options +### Iterate Forever in For Loop To iterate forever in a `for` loop you can omit the argument: ```python from reloading import reloading @@ -81,6 +82,31 @@ for _ in reloading(): pass ``` +### Code Changes Logged +On Python 3.9 and newer, a diff is logged when the source code is updated. +Consider the following code as an example. +```python +from reloading import reloading +from time import sleep +import logging + +log = logging.getLogger("reloading") +log.setLevel(logging.DEBUG) + +for i in reloading(range(100)): + print(i) + sleep(1.0) +``` +After some time the code is edited. `i = 2*i` is added before `print(i)`, +resulting in the following log output: +```console +INFO:reloading:For loop at line 10 of file "../example.py" has been reloaded. +DEBUG:reloading:Code changes: ++i = i * 2 + print(i) + sleep(1.0) +``` + ## Known Issus On Python version [less than 3.13](https://docs.python.org/3/reference/datamodel.html#frame.f_locals) it is not possible to properly export the local variables from a loop to parent locals. The following example demonstrates this: @@ -95,14 +121,19 @@ def function(): function() # Prints 0. Not 10 as expected. Fixed in Python 3.13. ``` -A warning is emitted when the issue arises: `WARNING:reloading:Variable(s) "i" in reloaded loop were not exported to the scope which called the reloaded loop at line...`. +A warning is emitted when the issue arises: +```console +WARNING:reloading:Variable(s) "i" in reloaded loop were not exported to the scope which called the reloaded loop at line... +``` ## Lint, Type Check and Testing Run: ```console $ pip install -e ".[development]" +$ ruff check . $ flake8 $ pyright +$ mypy . $ python -m unittest ``` diff --git a/pyproject.toml b/pyproject.toml index ce55b9d..8cc7f45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,5 +47,4 @@ development = [ include = ["reloading"] exclude = [ "**/__pycache__", - "examples", ] diff --git a/reloading/reloading.py b/reloading/reloading.py index 4b532c8..bdf5cc9 100644 --- a/reloading/reloading.py +++ b/reloading/reloading.py @@ -13,11 +13,29 @@ overload) from itertools import chain import logging +import difflib log = logging.getLogger("reloading") logging.basicConfig(level=logging.INFO) +def get_diff_text(ast_before: ast.Module, ast_after: ast.Module): + """ + Calculate difference between two versions of reloaded code. + """ + # Unparse was introduced in Python 3.9. + if sys.version_info.major >= 3 and sys.version_info.minor >= 9: + code_before = ast.unparse(ast_before) + code_after = ast.unparse(ast_after) + diff = difflib.unified_diff(code_before.splitlines(), + code_after.splitlines(), + lineterm="") + # Omit first three lines because they contain superfluous information. + return "\n".join(["Code changes:"]+list(diff)[3:]) + else: + return "Cannot compute code changes. Requires Python > 3.9." + + class ReloadingException(Exception): pass @@ -344,10 +362,13 @@ def execute_for_loop(seq: Iterable, if i > 0: log.info(f'For loop at line {loop_frame_info.lineno} of file ' f'"{filename}" has been reloaded.') + ast_before = for_loop.ast for_loop = get_loop_code( loop_frame_info, loop_id=for_loop.id, filename=filename ) assert isinstance(for_loop, ForLoop) + ast_after = for_loop.ast + log.debug(get_diff_text(ast_before, ast_after)) file_stat = file_stat_ # Make up a name for a variable which is not already present in # the global or local namespace. @@ -400,9 +421,12 @@ def condition(while_loop): if file_stat != file_stat_: log.info(f'While loop at line {loop_frame_info.lineno} of file ' f'"{filename}" has been reloaded.') + ast_before = while_loop.ast while_loop = get_loop_code( loop_frame_info, loop_id=while_loop.id, filename=filename ) + ast_after = while_loop.ast + log.debug(get_diff_text(ast_before, ast_after)) file_stat = file_stat_ try: exec(while_loop.compiled_body, caller_globals, caller_locals) @@ -534,7 +558,7 @@ def __init__(self, function_frame_info: inspect.FrameInfo, ast_module: ast.Module, id: str): - self.ast_module = ast_module + self.ast = ast_module self.id = id self.name = function_name caller_locals = function_frame_info.frame.f_locals @@ -660,10 +684,13 @@ def wrapped(*args, **kwargs): log.info(f'Function "{function.__name__}" initially defined at ' f'line {function_frame_info.lineno} ' f'of file "{filename}" has been reloaded.') + ast_before = function_object.ast function_object = get_reloaded_function(function_frame_info, function, filename, function_object.id) + ast_after = function_object.ast + log.debug(get_diff_text(ast_before, ast_after)) file_stat = file_stat_ i += 1 while True: