python-hmr
Advanced tools
| { | ||
| "label": "", | ||
| "message": "python-hmr", | ||
| "logoSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 675.78 675.78\"><defs><style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{clip-path:url(#clip-path-2);}.cls-4{fill:#3c79ab;}.cls-5{fill:#fdd837;}.cls-6{fill:#fff;}</style><clipPath id=\"clip-path\" transform=\"translate(-276.95 -142.32)\"><rect class=\"cls-1\" width=\"1280\" height=\"960.41\"/></clipPath><clipPath id=\"clip-path-2\" transform=\"translate(-276.95 -142.32)\"><rect class=\"cls-1\" width=\"1280\" height=\"960.41\"/></clipPath></defs><title>Asset 1</title><g id=\"Layer_2\" data-name=\"Layer 2\"><g id=\"Layer_1-2\" data-name=\"Layer 1\"><g class=\"cls-2\"><g class=\"cls-3\"><path class=\"cls-4\" d=\"M614.84,142.32c-184,0-333.57,147-337.78,330C281,312.65,402.33,184.56,551.49,184.56,703.11,184.56,826,316.92,826,480.21a63.36,63.36,0,0,0,126.71,0c0-186.62-151.27-337.89-337.89-337.89\" transform=\"translate(-276.95 -142.32)\"/><path class=\"cls-5\" d=\"M614.84,818.09c184,0,333.58-147,337.79-329.94-3.9,159.61-125.27,287.71-274.43,287.71-151.63,0-274.54-132.37-274.54-295.65a63.36,63.36,0,1,0-126.71,0c0,186.61,151.28,337.88,337.89,337.88\" transform=\"translate(-276.95 -142.32)\"/><path class=\"cls-6\" d=\"M917.92,480.21a26.82,26.82,0,1,1-26.82-26.82,26.82,26.82,0,0,1,26.82,26.82\" transform=\"translate(-276.95 -142.32)\"/><path class=\"cls-6\" d=\"M366.05,493.1a26.82,26.82,0,1,1-26.82-26.82,26.83,26.83,0,0,1,26.82,26.82\" transform=\"translate(-276.95 -142.32)\"/></g></g></g></g></svg>", | ||
| "logoWidth": 15, | ||
| "labelColor": "white", | ||
| "color": "#FEC550" | ||
| } |
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 675.78 675.78"><defs><style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{clip-path:url(#clip-path-2);}.cls-4{fill:#3c79ab;}.cls-5{fill:#fdd837;}.cls-6{fill:#fff;}</style><clipPath id="clip-path" transform="translate(-276.95 -142.32)"><rect class="cls-1" width="1280" height="960.41"/></clipPath><clipPath id="clip-path-2" transform="translate(-276.95 -142.32)"><rect class="cls-1" width="1280" height="960.41"/></clipPath></defs><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g class="cls-2"><g class="cls-3"><path class="cls-4" d="M614.84,142.32c-184,0-333.57,147-337.78,330C281,312.65,402.33,184.56,551.49,184.56,703.11,184.56,826,316.92,826,480.21a63.36,63.36,0,0,0,126.71,0c0-186.62-151.27-337.89-337.89-337.89" transform="translate(-276.95 -142.32)"/><path class="cls-5" d="M614.84,818.09c184,0,333.58-147,337.79-329.94-3.9,159.61-125.27,287.71-274.43,287.71-151.63,0-274.54-132.37-274.54-295.65a63.36,63.36,0,1,0-126.71,0c0,186.61,151.28,337.88,337.89,337.88" transform="translate(-276.95 -142.32)"/><path class="cls-6" d="M917.92,480.21a26.82,26.82,0,1,1-26.82-26.82,26.82,26.82,0,0,1,26.82,26.82" transform="translate(-276.95 -142.32)"/><path class="cls-6" d="M366.05,493.1a26.82,26.82,0,1,1-26.82-26.82,26.83,26.83,0,0,1,26.82,26.82" transform="translate(-276.95 -142.32)"/></g></g></g></g></svg> |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
+56
| from __future__ import annotations | ||
| __all__ = ["reload"] | ||
| from types import ModuleType | ||
| from typing import List, Callable, Sequence, Union | ||
| from hmr._reload import ModuleReloader, ObjectReloader | ||
| Reloadable = Union[ModuleType, Callable] | ||
| Reloader = Union[ModuleReloader, ObjectReloader] | ||
| def reload( | ||
| *obj: Reloadable | Sequence[Reloadable], exclude: Sequence[str] = None | ||
| ) -> Reloader | List[Reloader]: | ||
| """The reloader to proxy reloaded object | ||
| Parameters | ||
| ---------- | ||
| obj : The object(s) to be monitored and | ||
| reloaded when file changes on the disk | ||
| exclude : Exclude the module that you don't want to be reloaded | ||
| """ | ||
| reloaders = [] | ||
| if len(obj) == 1: | ||
| if isinstance(obj[0], (list, tuple)): | ||
| obj_list = obj[0] | ||
| else: | ||
| obj_list = obj | ||
| else: | ||
| obj_list = obj | ||
| for obj in obj_list: | ||
| if isinstance(obj, ModuleType): | ||
| reloader = ModuleReloader(obj, exclude) | ||
| elif isinstance(obj, Callable): | ||
| reloader = ObjectReloader(obj, exclude) | ||
| else: | ||
| msg = ( | ||
| f"Operation failed: {obj} is either a constant value or " | ||
| f"an already initialized object and cannot be reloaded. " | ||
| "To resolve this issue: " | ||
| "1. If you're attempting to pass a function or class, " | ||
| "use its name without parentheses (e.g., `func` instead of `func()`). " | ||
| "2. To access a constant, refer to it directly from its module " | ||
| "using dot notation (e.g., `module.var`)." | ||
| ) | ||
| raise TypeError(msg) | ||
| reloaders.append(reloader) | ||
| if len(reloaders) == 1: | ||
| return reloaders[0] | ||
| return reloaders |
+173
| from __future__ import annotations | ||
| __all__ = ["ModuleReloader", "ObjectReloader", "BaseReloader"] | ||
| import sys | ||
| import warnings | ||
| import weakref | ||
| from dataclasses import dataclass | ||
| from importlib import reload, invalidate_caches, import_module | ||
| from importlib.util import module_from_spec, find_spec | ||
| from pathlib import Path | ||
| from types import ModuleType, FunctionType | ||
| from typing import Set | ||
| from ._watcher import Watchers | ||
| def _recursive_reload(module, exclude): | ||
| reload(sys.modules.get(module.__name__)) | ||
| for attr in dir(module): | ||
| attr = getattr(module, attr) | ||
| if isinstance(attr, ModuleType): | ||
| if attr.__name__.startswith(module.__name__): | ||
| if attr.__name__ not in exclude: | ||
| _recursive_reload(attr, exclude) | ||
| def get_module_by_name(name): | ||
| return module_from_spec(find_spec(name)) | ||
| @dataclass | ||
| class ProxyModule: | ||
| name: str | ||
| file: str | ||
| root_name: str | ||
| root_path: str | ||
| object: object | ModuleType | FunctionType | ||
| root_object: ModuleType | ||
| exclude: Set[str] = None | ||
| def is_func(self): | ||
| return isinstance(self.object, FunctionType) | ||
| def reload(self): | ||
| _recursive_reload(self.root_object, self.exclude) | ||
| self.root_object = import_module(self.root_name) | ||
| class BaseReloader(ModuleType): | ||
| __proxy_module__: ProxyModule | ||
| def __init__(self, proxy_obj): | ||
| obj = proxy_obj.object | ||
| super().__init__(obj.__name__, obj.__doc__) | ||
| self.__proxy_module__ = proxy_obj | ||
| Watchers.add_reload(self) | ||
| # Try to reload the module when the object is created | ||
| self.__reload__() | ||
| def __repr__(self): | ||
| return f"<HMR for {self.__proxy_module__.object}>" | ||
| def __getattr__(self, name): | ||
| return getattr(self.__proxy_module__.object, name) | ||
| def __del__(self): | ||
| Watchers.delete_reload(self) | ||
| # For IDE auto-completion | ||
| @property | ||
| def __all__(self) -> list: | ||
| if hasattr(self.__proxy_module__.object, "__all__"): | ||
| return self.__proxy_module__.object.__all__ | ||
| return [] | ||
| # For IDE auto-completion | ||
| def __dir__(self): | ||
| return self.__proxy_module__.object.__dir__() | ||
| def __reload__(self) -> None: | ||
| raise NotImplementedError | ||
| class ModuleReloader(BaseReloader): | ||
| def __init__(self, module, exclude=None): | ||
| # If user import a submodule | ||
| # we still need to monitor the whole module to reload | ||
| if exclude is None: | ||
| exclude = set() | ||
| else: | ||
| exclude = set(exclude) | ||
| root_module = get_module_by_name(module.__name__.split(".")[0]) | ||
| root_path = Path(root_module.__spec__.origin).parent | ||
| proxy = ProxyModule( | ||
| name=module.__name__, | ||
| file=module.__spec__.origin, | ||
| root_name=root_module.__name__, | ||
| root_path=str(root_path), | ||
| object=module, | ||
| root_object=root_module, | ||
| exclude=exclude, | ||
| ) | ||
| super().__init__(proxy) | ||
| def __reload__(self): | ||
| invalidate_caches() | ||
| self.__proxy_module__.reload() | ||
| class ObjectReloader(BaseReloader): | ||
| def __init__(self, obj, exclude=None): | ||
| root_module = get_module_by_name(obj.__module__) | ||
| root_path = Path(root_module.__spec__.origin).parent | ||
| if exclude is None: | ||
| exclude = set() | ||
| else: | ||
| exclude = set(exclude) | ||
| proxy = ProxyModule( | ||
| name=obj.__name__, | ||
| file=root_module.__spec__.origin, | ||
| root_name=root_module.__name__, | ||
| root_path=str(root_path), | ||
| object=obj, | ||
| root_object=get_module_by_name(obj.__module__), | ||
| exclude=exclude, | ||
| ) | ||
| self.__ref_instances = [] # Keep references to all instances | ||
| super().__init__(proxy) | ||
| def __call__(self, *args, **kwargs): | ||
| # When user override the __call__ method in class | ||
| try: | ||
| instance = self.__proxy_module__.object.__call__(*args, **kwargs) | ||
| except TypeError: | ||
| instance = self.__proxy_module__.object(*args, **kwargs) | ||
| if not self.__proxy_module__.is_func(): | ||
| # When the class initiate | ||
| # Register a reference to the instance | ||
| # So we can replace it later | ||
| self.__ref_instances.append(weakref.ref(instance)) | ||
| return instance | ||
| def __reload__(self) -> None: | ||
| """Reload the object""" | ||
| invalidate_caches() | ||
| self.__proxy_module__.reload() | ||
| with open(self.__proxy_module__.file, "r") as f: | ||
| source_code = f.read() | ||
| locals_: dict = {} | ||
| exec(source_code, self.__proxy_module__.root_object.__dict__, locals_) | ||
| updated_object = locals_.get(self.__proxy_module__.name, None) | ||
| if updated_object is None: | ||
| warnings.warn( | ||
| "Can't reload object. If it's a decorated function, " | ||
| "use functools.wraps to " | ||
| "preserve the function signature.", | ||
| UserWarning, | ||
| ) | ||
| else: | ||
| self.__proxy_module__.object = updated_object | ||
| # Replace the old reference of all instances with the new one | ||
| if not self.__proxy_module__.is_func(): | ||
| for ref in self.__ref_instances: | ||
| instance = ref() # We keep weak references to objects | ||
| if instance: | ||
| instance.__class__ = updated_object |
| from __future__ import annotations | ||
| __all__ = ["Watchers"] | ||
| import atexit | ||
| import sys | ||
| import traceback | ||
| from datetime import datetime | ||
| from typing import Dict, TYPE_CHECKING | ||
| if TYPE_CHECKING: | ||
| from ._reload import BaseReloader | ||
| from watchdog.events import FileSystemEventHandler | ||
| from watchdog.observers import Observer | ||
| from watchdog.observers.api import ObservedWatch | ||
| class EventsHandler(FileSystemEventHandler): | ||
| reload_list = [] | ||
| _last_error = None | ||
| def on_any_event(self, event): | ||
| # print(event.src_path, event.event_type, event.is_directory) | ||
| # print("Reload list", self.reload_list) | ||
| for reloader in self.reload_list: | ||
| try: | ||
| reloader.__reload__() | ||
| except Exception: | ||
| _current = datetime.now() | ||
| error_stack = traceback.extract_stack() | ||
| # only fire the same error once | ||
| if self._last_error is None or self._last_error != error_stack: | ||
| self._last_error = error_stack | ||
| traceback.print_exc(file=sys.stderr) | ||
| return | ||
| class WatcherStorage: | ||
| def __init__(self): | ||
| self._observer = None | ||
| self.watchers: Dict[str, (ObservedWatch, EventsHandler)] = {} | ||
| atexit.register(self.__del__) | ||
| @property | ||
| def observer(self) -> Observer: | ||
| if self._observer is None: | ||
| self._observer = Observer() | ||
| self._observer.daemon = True | ||
| self._observer.start() | ||
| return self._observer | ||
| def add_reload(self, reloader: BaseReloader): | ||
| root_path = reloader.__proxy_module__.root_path | ||
| watcher = self.watchers.get(root_path) | ||
| if watcher is None: | ||
| event_handler = EventsHandler() | ||
| event_handler.reload_list.append(reloader) | ||
| watch = self.observer.schedule(event_handler, root_path, recursive=True) | ||
| self.watchers[root_path] = (watch, event_handler) | ||
| else: | ||
| watch, event_handler = watcher | ||
| event_handler.reload_list.append(reloader) | ||
| def delete_reload(self, reloader: BaseReloader): | ||
| root_path = reloader.__proxy_module__.root_path | ||
| watcher = self.watchers.get(root_path) | ||
| if watcher is not None: | ||
| watch, event_handler = watcher | ||
| # This may be emitted multiple times | ||
| # Must wrap in a try-except block | ||
| try: | ||
| event_handler.reload_list.remove(reloader) | ||
| if not event_handler.reload_list: | ||
| self.observer.unschedule(watch) | ||
| del self.watchers[root_path] | ||
| except (ValueError, KeyError): | ||
| pass | ||
| def __del__(self): | ||
| if self._observer is not None: | ||
| self._observer.unschedule_all() | ||
| self._observer.stop() | ||
| self._observer.join() | ||
| Watchers = WatcherStorage() | ||
| # def get_watchers(): | ||
| # return Watchers |
| from .file_module import file_func | ||
| from .sub_module import sub_func | ||
| from .wrap import work_wrap | ||
| def func(): | ||
| return "Hi from func" | ||
| @work_wrap | ||
| @work_wrap | ||
| def decorated_func(): | ||
| return 100 | ||
| class Class: | ||
| v = 1 | ||
| var = 1 | ||
| class Complicate: | ||
| """Complicate class for testing purposes.""" | ||
| def __init__(self): | ||
| self.x = 12 | ||
| # def __repr__(self): | ||
| # return f"Complicate(x={self.x})" | ||
| def __add__(self, other): | ||
| return self.x + other.x | ||
| def __call__(self, *args, **kwargs): | ||
| return self.add(*args) | ||
| def add(self, a, b): | ||
| return a + b + self.x |
| def file_func(): | ||
| return "Hi from file_func" |
| from . import Class | ||
| cls = Class() |
| from tests.my_pkg.wrap import wrap | ||
| def sub_func(): | ||
| return "Hi from sub_func" | ||
| @wrap | ||
| @wrap | ||
| def decorated_sub_func(): | ||
| return 100 | ||
| class SubClass: | ||
| v = 1 |
| import functools | ||
| def wrap(f): | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args | ||
| def work_wrap(f): | ||
| @functools.wraps(f) | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args |
@@ -10,7 +10,7 @@ name: Build | ||
| steps: | ||
| - uses: actions/checkout@v3 | ||
| - uses: actions/checkout@v4 | ||
| - name: Set up Python env | ||
| uses: actions/setup-python@v3 | ||
| uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: 3.8 | ||
| python-version: 3.11 | ||
@@ -27,4 +27,4 @@ - name: Install dependencies | ||
| FLIT_INDEX_URL: https://upload.pypi.org/legacy/ | ||
| FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} | ||
| FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} | ||
| FLIT_USERNAME: __token__ | ||
| FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }} | ||
| run: flit publish |
@@ -18,3 +18,3 @@ name: Test | ||
| - name: Set up Python ${{ matrix.python-version }} | ||
| uses: actions/setup-python@v3 | ||
| uses: actions/setup-python@v4 | ||
| with: | ||
@@ -21,0 +21,0 @@ python-version: ${{ matrix.python-version }} |
+3
-4
| """Hot module reload for python""" | ||
| __all__ = ["reload", "Reloader"] | ||
| __version__ = "0.2.0" | ||
| __all__ = ["reload"] | ||
| __version__ = "0.3.0" | ||
| from .api import Reloader | ||
| reload = Reloader | ||
| from ._api import reload |
+1
-1
| MIT License | ||
| Copyright (c) 2023 Mr-Milk | ||
| Copyright (c) 2024 Mr-Milk | ||
@@ -5,0 +5,0 @@ Permission is hereby granted, free of charge, to any person obtaining a copy |
+69
-42
| Metadata-Version: 2.1 | ||
| Name: python-hmr | ||
| Version: 0.2.0 | ||
| Version: 0.3.0 | ||
| Summary: Hot module reload for python | ||
| Author-email: Mr-Milk <yb97643@um.edu.mo> | ||
| Author-email: Mr-Milk <zym.zym1220@gmail.com> | ||
| Requires-Python: >=3.8 | ||
@@ -10,17 +10,30 @@ Description-Content-Type: text/markdown | ||
| Requires-Dist: watchdog | ||
| Requires-Dist: pytest ; extra == "dev" | ||
| Requires-Dist: ruff ; extra == "dev" | ||
| Project-URL: Home, https://github.com/mr-milk/python-hmr | ||
| Provides-Extra: dev | ||
| <img src="https://raw.githubusercontent.com/Mr-Milk/python-hmr/d642a1054d5502a020f107bebecba41abeb4c7ea/img/logo.svg" alt="python-hmr logo" align="left" height="50" /> | ||
| # Python Hot Module Reload | ||
| <p align="center"> | ||
| <picture align="center"> | ||
| <img src="https://raw.githubusercontent.com/Mr-Milk/python-hmr/main/assets/logo.svg" | ||
| alt="python-hmr logo" height="50"/> | ||
| </picture> | ||
| </p> | ||
| <p align="center"> | ||
| <i>Better debugging experience with HMR</i> | ||
| </p> | ||
|  | ||
|  | ||
|  | ||
|  | ||
| Automatic reload your project when files are modified. | ||
| No need to modify your source code. Works at any environment. | ||
| No need to modify your source code. Works in any environment. | ||
|  | ||
|  | ||
@@ -46,52 +59,54 @@ Supported Syntax: | ||
| Import your dev package as usual. | ||
| > [!CAUTION] | ||
| > From v0.3.0, there is only one API, `hmr.reload`. | ||
| Import your dev packages as usual. And add 2 lines | ||
| for automatically reloading. | ||
| ```python | ||
| import my_pkg | ||
| import dev | ||
| import hmr | ||
| dev = hmr.reload(dev) | ||
| ``` | ||
| Add 2 lines to automatically reload your source code. | ||
| If you have multiple modules to reload, you can do it like this. | ||
| ```python | ||
| import my_pkg | ||
| from dev import run1, run2 | ||
| import hmr | ||
| my_pkg = hmr.reload(my_pkg) | ||
| run1, run2 = hmr.reload(run1, run2) | ||
| ``` | ||
| Now you are ready to go! | ||
| Now you are ready to go! Try to modify the `run1` or `run2` | ||
| and see the magic happen. | ||
| ## Usage Manual | ||
| ### Module/Submodule reload | ||
| ## Detailed Usage | ||
| ```python | ||
| import my_pkg.sub_module as sub | ||
| import hmr | ||
| sub = hmr.reload(sub) | ||
| ``` | ||
| ### Function/Class instance | ||
| ### Function/Class reload | ||
| When you try to add HMR for a function or class, remember to | ||
| pass the name of the function or class instance without parenthesis. | ||
| No difference to reloading module | ||
| ```python | ||
| from my_pkg import func, Class | ||
| from dev import Runner | ||
| import hmr | ||
| func = hmr.reload(func) | ||
| Class = hmr.reload(Class) | ||
| Runner = hmr.reload(Runner) | ||
| a = Runner() | ||
| b = Runner() | ||
| ``` | ||
| If your have multiple class instance, they will all be updated. | ||
| Both `a` and `b` will be updated. | ||
| > [!IMPORTANT] | ||
| > Here, when both `a` and `b` will be updated after reloading. This may be helpful | ||
| > if you have an expansive state store within the class instance. | ||
| > | ||
| > However, it's suggested to reinitialize the class instance after reloading. | ||
| ```python | ||
| a = Class() | ||
| b = Class() | ||
| ``` | ||
| ### @Decorated Function reload | ||
| ### @Decorated Function | ||
@@ -101,6 +116,17 @@ Use [functools.wraps](https://docs.python.org/3/library/functools.html#functools.wraps) to preserve | ||
| ### State handling | ||
| ```python | ||
| import functools | ||
| If your application is not stateless, it's suggested that you | ||
| group all your state variable into the same `.py` file like `state.py` | ||
| def work(f): | ||
| @functools.wraps(f) | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args | ||
| ``` | ||
| ### Stateful application | ||
| If your application is stateful, you can exclude the state from being reloaded. | ||
| For simplicity, you can group all your state variable into the same `.py` file like `state.py` | ||
| and exclude that from being reloaded. | ||
@@ -112,17 +138,17 @@ | ||
| ```python | ||
| import my_pkg | ||
| import dev | ||
| import hmr | ||
| my_pkg = hmr.reload(my_pkg, excluded=["my_pkg.state"]) | ||
| dev = hmr.reload(dev, exclude=["dev.state"]) | ||
| ``` | ||
| The `my_pkg/state.py` will not be reloaded, the state will persist. | ||
| In this way `dev/state.py` will not be reloaded, the state will persist. | ||
| The same apply when reloading a function or class. | ||
| This also apply when reloading a function or class. | ||
| ```python | ||
| from my_pkg import func | ||
| from dev import run | ||
| import hmr | ||
| func = hmr.reload(func, excluded=["my_pkg.state"]) | ||
| run = hmr.reload(run, exclude=["dev.state"]) | ||
| ``` | ||
@@ -137,1 +163,2 @@ | ||
| - [reloadr](https://github.com/hoh/reloadr) | ||
+8
-2
@@ -7,3 +7,3 @@ [build-system] | ||
| name = "python-hmr" | ||
| authors = [{name = "Mr-Milk", email = "yb97643@um.edu.mo"}] | ||
| authors = [{name = "Mr-Milk", email = "zym.zym1220@gmail.com"}] | ||
| license = {file = "LICENSE"} | ||
@@ -18,2 +18,8 @@ readme = "README.md" | ||
| [tool.flit.module] | ||
| name = "hmr" | ||
| name = "hmr" | ||
| [project.optional-dependencies] | ||
| dev = [ | ||
| "pytest", | ||
| "ruff", | ||
| ] |
+64
-41
@@ -1,14 +0,24 @@ | ||
| <img src="https://raw.githubusercontent.com/Mr-Milk/python-hmr/d642a1054d5502a020f107bebecba41abeb4c7ea/img/logo.svg" alt="python-hmr logo" align="left" height="50" /> | ||
| # Python Hot Module Reload | ||
| <p align="center"> | ||
| <picture align="center"> | ||
| <img src="https://raw.githubusercontent.com/Mr-Milk/python-hmr/main/assets/logo.svg" | ||
| alt="python-hmr logo" height="50"/> | ||
| </picture> | ||
| </p> | ||
| <p align="center"> | ||
| <i>Better debugging experience with HMR</i> | ||
| </p> | ||
|  | ||
|  | ||
|  | ||
|  | ||
| Automatic reload your project when files are modified. | ||
| No need to modify your source code. Works at any environment. | ||
| No need to modify your source code. Works in any environment. | ||
|  | ||
|  | ||
@@ -34,52 +44,54 @@ Supported Syntax: | ||
| Import your dev package as usual. | ||
| > [!CAUTION] | ||
| > From v0.3.0, there is only one API, `hmr.reload`. | ||
| Import your dev packages as usual. And add 2 lines | ||
| for automatically reloading. | ||
| ```python | ||
| import my_pkg | ||
| import dev | ||
| import hmr | ||
| dev = hmr.reload(dev) | ||
| ``` | ||
| Add 2 lines to automatically reload your source code. | ||
| If you have multiple modules to reload, you can do it like this. | ||
| ```python | ||
| import my_pkg | ||
| from dev import run1, run2 | ||
| import hmr | ||
| my_pkg = hmr.reload(my_pkg) | ||
| run1, run2 = hmr.reload(run1, run2) | ||
| ``` | ||
| Now you are ready to go! | ||
| Now you are ready to go! Try to modify the `run1` or `run2` | ||
| and see the magic happen. | ||
| ## Usage Manual | ||
| ### Module/Submodule reload | ||
| ## Detailed Usage | ||
| ```python | ||
| import my_pkg.sub_module as sub | ||
| import hmr | ||
| sub = hmr.reload(sub) | ||
| ``` | ||
| ### Function/Class instance | ||
| ### Function/Class reload | ||
| When you try to add HMR for a function or class, remember to | ||
| pass the name of the function or class instance without parenthesis. | ||
| No difference to reloading module | ||
| ```python | ||
| from my_pkg import func, Class | ||
| from dev import Runner | ||
| import hmr | ||
| func = hmr.reload(func) | ||
| Class = hmr.reload(Class) | ||
| Runner = hmr.reload(Runner) | ||
| a = Runner() | ||
| b = Runner() | ||
| ``` | ||
| If your have multiple class instance, they will all be updated. | ||
| Both `a` and `b` will be updated. | ||
| > [!IMPORTANT] | ||
| > Here, when both `a` and `b` will be updated after reloading. This may be helpful | ||
| > if you have an expansive state store within the class instance. | ||
| > | ||
| > However, it's suggested to reinitialize the class instance after reloading. | ||
| ```python | ||
| a = Class() | ||
| b = Class() | ||
| ``` | ||
| ### @Decorated Function reload | ||
| ### @Decorated Function | ||
@@ -89,6 +101,17 @@ Use [functools.wraps](https://docs.python.org/3/library/functools.html#functools.wraps) to preserve | ||
| ### State handling | ||
| ```python | ||
| import functools | ||
| If your application is not stateless, it's suggested that you | ||
| group all your state variable into the same `.py` file like `state.py` | ||
| def work(f): | ||
| @functools.wraps(f) | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args | ||
| ``` | ||
| ### Stateful application | ||
| If your application is stateful, you can exclude the state from being reloaded. | ||
| For simplicity, you can group all your state variable into the same `.py` file like `state.py` | ||
| and exclude that from being reloaded. | ||
@@ -100,17 +123,17 @@ | ||
| ```python | ||
| import my_pkg | ||
| import dev | ||
| import hmr | ||
| my_pkg = hmr.reload(my_pkg, excluded=["my_pkg.state"]) | ||
| dev = hmr.reload(dev, exclude=["dev.state"]) | ||
| ``` | ||
| The `my_pkg/state.py` will not be reloaded, the state will persist. | ||
| In this way `dev/state.py` will not be reloaded, the state will persist. | ||
| The same apply when reloading a function or class. | ||
| This also apply when reloading a function or class. | ||
| ```python | ||
| from my_pkg import func | ||
| from dev import run | ||
| import hmr | ||
| func = hmr.reload(func, excluded=["my_pkg.state"]) | ||
| run = hmr.reload(run, exclude=["dev.state"]) | ||
| ``` | ||
@@ -124,2 +147,2 @@ | ||
| - [auto-reloader](https://github.com/moisutsu/auto-reloader) | ||
| - [reloadr](https://github.com/hoh/reloadr) | ||
| - [reloadr](https://github.com/hoh/reloadr) |
+32
-20
@@ -10,3 +10,3 @@ import os | ||
| TEST_DIR = Path(os.path.dirname(os.path.abspath(__file__))) | ||
| TEST_DIR = Path(__file__).parent | ||
@@ -16,3 +16,7 @@ | ||
| def pytest_addoption(parser): | ||
| parser.addoption("--wait", action="store", default=0.3, ) | ||
| parser.addoption( | ||
| "--wait", | ||
| action="store", | ||
| default=0.3, | ||
| ) | ||
@@ -22,3 +26,3 @@ | ||
| option_value = float(metafunc.config.option.wait) | ||
| if 'wait' in metafunc.fixturenames and option_value is not None: | ||
| if "wait" in metafunc.fixturenames and option_value is not None: | ||
| metafunc.parametrize("wait", [option_value]) | ||
@@ -28,6 +32,6 @@ | ||
| def read_replace_write(file: Union[str, Path], replace: Tuple): | ||
| with open(file, 'r') as f: | ||
| with open(file, "r") as f: | ||
| raw = f.read() | ||
| with open(file, 'w') as f: | ||
| with open(file, "w") as f: | ||
| text = raw.replace(*replace) | ||
@@ -41,7 +45,7 @@ f.write(text) | ||
| def pkg_name(): | ||
| return f'my_pkg_{str(uuid4())}' | ||
| return f"my_pkg_{str(uuid4())}" | ||
| def copy_pkg(pkg_name): | ||
| pkg = TEST_DIR.parent / 'my_pkg' | ||
| pkg = TEST_DIR / "my_pkg" | ||
| dest = TEST_DIR / pkg_name | ||
@@ -53,3 +57,3 @@ if dest.exists(): | ||
| @pytest.fixture(scope='function', autouse=True) | ||
| @pytest.fixture(scope="function", autouse=True) | ||
| def create_package(pkg_name): | ||
@@ -65,3 +69,3 @@ copy_pkg(pkg_name) | ||
| try: | ||
| if platform.system() in ['Linux', 'Darwin']: | ||
| if platform.system() in ["Linux", "Darwin"]: | ||
| os.system(f"rm -rf {pkg_dir.absolute()}") | ||
@@ -74,16 +78,20 @@ else: | ||
| @pytest.fixture(scope='function') | ||
| @pytest.fixture(scope="function") | ||
| def package(pkg_name): | ||
| pkg_dir = TEST_DIR / pkg_name | ||
| pkg_init: Path = pkg_dir / '__init__.py' | ||
| pkg_file_module: Path = pkg_dir / 'file_module.py' | ||
| pkg_sub_module_init: Path = pkg_dir / 'sub_module' / '__init__.py' | ||
| pkg_subsub_module_init: Path = pkg_dir / 'sub_module' / 'subsub_module' / '__init__.py' | ||
| pkg_init: Path = pkg_dir / "__init__.py" | ||
| pkg_file_module: Path = pkg_dir / "file_module.py" | ||
| pkg_sub_module_init: Path = pkg_dir / "sub_module" / "__init__.py" | ||
| pkg_subsub_module_init: Path = ( | ||
| pkg_dir / "sub_module" / "subsub_module" / "__init__.py" | ||
| ) | ||
| class Package: | ||
| pkg_name = pkg_name | ||
| pkg_init: Path = pkg_dir / '__init__.py' | ||
| pkg_file_module: Path = pkg_dir / 'file_module.py' | ||
| pkg_sub_module_init: Path = pkg_dir / 'sub_module' / '__init__.py' | ||
| pkg_subsub_module_init: Path = pkg_dir / 'sub_module' / 'subsub_module' / '__init__.py' | ||
| pkg_init: Path = pkg_dir / "__init__.py" | ||
| pkg_file_module: Path = pkg_dir / "file_module.py" | ||
| pkg_sub_module_init: Path = pkg_dir / "sub_module" / "__init__.py" | ||
| pkg_subsub_module_init: Path = ( | ||
| pkg_dir / "sub_module" / "subsub_module" / "__init__.py" | ||
| ) | ||
@@ -110,7 +118,11 @@ @staticmethod | ||
| def modify_file_module_func(): | ||
| read_replace_write(pkg_file_module, ("Hi from file_func", "Hello from file_func")) | ||
| read_replace_write( | ||
| pkg_file_module, ("Hi from file_func", "Hello from file_func") | ||
| ) | ||
| @staticmethod | ||
| def modify_sub_module_func(): | ||
| read_replace_write(pkg_sub_module_init, ("Hi from sub_func", "Hello from sub_func")) | ||
| read_replace_write( | ||
| pkg_sub_module_init, ("Hi from sub_func", "Hello from sub_func") | ||
| ) | ||
@@ -117,0 +129,0 @@ @staticmethod |
@@ -8,3 +8,3 @@ import importlib | ||
| from hmr import Reloader | ||
| from hmr import reload | ||
@@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).parent.resolve())) | ||
| def test_module(package, pkg_name, wait): | ||
| my_pkg = importlib.import_module(pkg_name) # import pkg_name | ||
| my_pkg = Reloader(my_pkg, excluded=['my_pkg.sub_module']) | ||
| my_pkg = reload(my_pkg, exclude=["my_pkg.sub_module"]) | ||
| assert my_pkg.func() == "Hi from func" | ||
@@ -31,3 +32,3 @@ package.modify_module_func() | ||
| sub = importlib.import_module(f"{pkg_name}.sub_module") | ||
| sub = Reloader(sub) | ||
| sub = reload(sub) | ||
@@ -44,3 +45,3 @@ assert sub.sub_func() == "Hi from sub_func" | ||
| my_pkg = importlib.import_module(pkg_name) # import pkg_name | ||
| my_pkg = Reloader(my_pkg) | ||
| my_pkg = reload(my_pkg) | ||
| # sleep(wait) | ||
@@ -47,0 +48,0 @@ # check.equal(my_pkg.func(), "Hi from func") |
@@ -8,3 +8,3 @@ import importlib | ||
| from hmr import Reloader | ||
| from hmr import reload | ||
@@ -15,3 +15,3 @@ sys.path.insert(0, str(Path(__file__).parent.resolve())) | ||
| def check_func(func, modify_func, before, after, wait=0): | ||
| func = Reloader(func) | ||
| func = reload(func) | ||
| assert func() == before | ||
@@ -25,3 +25,3 @@ | ||
| def check_cls(cls, modify_cls, before, after, wait=0): | ||
| cls = Reloader(cls) | ||
| cls = reload(cls) | ||
| c = cls() | ||
@@ -38,5 +38,7 @@ assert c.v == before | ||
| func = importlib.import_module(pkg_name).__getattribute__( | ||
| "func") # from x import func | ||
| check_func(func, package.modify_module_func, "Hi from func", | ||
| "Hello from func", wait) | ||
| "func" | ||
| ) # from x import func | ||
| check_func( | ||
| func, package.modify_module_func, "Hi from func", "Hello from func", wait | ||
| ) | ||
@@ -46,6 +48,12 @@ | ||
| def test_sub_func(package, pkg_name, wait): | ||
| sub_func = importlib.import_module( | ||
| f"{pkg_name}.sub_module").__getattribute__("sub_func") | ||
| check_func(sub_func, package.modify_sub_module_func, "Hi from sub_func", | ||
| "Hello from sub_func", wait) | ||
| sub_func = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( | ||
| "sub_func" | ||
| ) | ||
| check_func( | ||
| sub_func, | ||
| package.modify_sub_module_func, | ||
| "Hi from sub_func", | ||
| "Hello from sub_func", | ||
| wait, | ||
| ) | ||
@@ -61,4 +69,5 @@ | ||
| def test_sub_class(package, pkg_name, wait): | ||
| SubClass = importlib.import_module( | ||
| f"{pkg_name}.sub_module").__getattribute__("SubClass") | ||
| SubClass = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( | ||
| "SubClass" | ||
| ) | ||
| check_cls(SubClass, package.modify_sub_module_class, 1, 2, wait) | ||
@@ -71,3 +80,3 @@ | ||
| var = importlib.import_module(pkg_name).__getattribute__("var") | ||
| var = Reloader(var) | ||
| var = reload(var) | ||
@@ -78,3 +87,3 @@ | ||
| func = importlib.import_module(pkg_name).__getattribute__("func") | ||
| func = Reloader(func) | ||
| func = reload(func) | ||
| ref_f = func | ||
@@ -93,3 +102,3 @@ | ||
| Class = importlib.import_module(pkg_name).__getattribute__("Class") | ||
| Class = Reloader(Class) | ||
| Class = reload(Class) | ||
| assert Class.v == 1 | ||
@@ -111,5 +120,5 @@ a = Class() | ||
| decorated_func = importlib.import_module(pkg_name).__getattribute__( | ||
| "decorated_func") | ||
| check_func(decorated_func, package.modify_module_decorated_func, 100, 10, | ||
| wait) | ||
| "decorated_func" | ||
| ) | ||
| check_func(decorated_func, package.modify_module_decorated_func, 100, 10, wait) | ||
@@ -120,3 +129,4 @@ | ||
| dsf = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( | ||
| "decorated_sub_func") | ||
| "decorated_sub_func" | ||
| ) | ||
| check_func(dsf, package.modify_sub_module_decorated_func, 100, 10, wait) |
-90
| __all__ = ['Reloader'] | ||
| import sys | ||
| from datetime import datetime | ||
| from types import ModuleType | ||
| from typing import Callable, Any | ||
| from watchdog.events import FileSystemEventHandler | ||
| from watchdog.observers import Observer | ||
| from hmr.reload import ModuleReloader, ObjectReloader | ||
| class EventsHandler(FileSystemEventHandler): | ||
| reloader = None | ||
| _last_error = None | ||
| _updated = None | ||
| def on_any_event(self, event): | ||
| try: | ||
| self.reloader.fire() | ||
| except Exception as e: | ||
| _current = datetime.now() | ||
| # only fire the same error once | ||
| if self._last_error != str(e): | ||
| self._last_error = str(e) | ||
| print(e, file=sys.stderr) | ||
| return | ||
| if (_current - self._updated).total_seconds() > 1: | ||
| print(e, file=sys.stderr) | ||
| return | ||
| class Reloader: | ||
| """The reloader to proxy reloaded object | ||
| Args: | ||
| obj: The object to be monitored and | ||
| reloaded when file changes on the disk | ||
| excluded: Excluded the module that you don't want to be reloaded | ||
| """ | ||
| _observer = None | ||
| def __init__(self, | ||
| obj: Any, | ||
| excluded=None | ||
| ): | ||
| if isinstance(obj, ModuleType): | ||
| self.reloader = ModuleReloader(obj, excluded) | ||
| elif isinstance(obj, Callable): | ||
| self.reloader = ObjectReloader(obj, excluded) | ||
| else: | ||
| msg = "Hot Module Reload supports Module, Function and Class; " \ | ||
| "Do not pass initialize class or function, " \ | ||
| "use `func` not `func()`. " \ | ||
| "For static variable " \ | ||
| "access it from the module like `module.var`" | ||
| raise TypeError(msg) | ||
| path = self.reloader.get_module_path() | ||
| event_handler = EventsHandler() | ||
| event_handler.reloader = self.reloader | ||
| event_handler._updated = datetime.now() | ||
| observer = Observer() | ||
| self._observer = observer | ||
| observer.schedule(event_handler, str(path), | ||
| recursive=True) | ||
| observer.setDaemon(True) | ||
| observer.start() | ||
| def __stop__(self): | ||
| """Shutdown the monitor""" | ||
| if self._observer is not None: | ||
| self._observer.unschedule_all() | ||
| self._observer.stop() | ||
| def __del__(self): | ||
| return self.__stop__() | ||
| def __getattr__(self, name): | ||
| return getattr(self.reloader, name) | ||
| def __call__(self, *args, **kwargs): | ||
| return self.reloader.__call__(*args, **kwargs) |
| import sys | ||
| import warnings | ||
| import weakref | ||
| from importlib import reload, invalidate_caches, import_module | ||
| from importlib.util import module_from_spec, find_spec | ||
| from pathlib import Path | ||
| from types import ModuleType, FunctionType | ||
| def _recursive_reload(module, excluded): | ||
| reload(sys.modules.get(module.__name__)) | ||
| for attr in dir(module): | ||
| attr = getattr(module, attr) | ||
| if isinstance(attr, ModuleType): | ||
| if attr.__name__.startswith(module.__name__): | ||
| if attr.__name__ not in excluded: | ||
| _recursive_reload(attr, excluded) | ||
| def get_module_by_name(name): | ||
| return module_from_spec(find_spec(name)) | ||
| class ModuleReloader: | ||
| def __init__(self, module, excluded=None): | ||
| # If user import a submodule | ||
| # we still need to monitor the whole module for rerun | ||
| entry_module = get_module_by_name(module.__name__.split(".")[0]) | ||
| self.module = module | ||
| self.entry_module = entry_module | ||
| self.excluded = [] if excluded is None else excluded | ||
| def __getattr__(self, name): | ||
| return getattr(self.module, name) | ||
| def fire(self): | ||
| invalidate_caches() | ||
| _recursive_reload(self.entry_module, self.excluded) | ||
| self.module = import_module(self.module.__name__) | ||
| self.entry_module = import_module(self.entry_module.__name__) | ||
| def get_module_path(self): | ||
| return Path(self.entry_module.__spec__.origin).parent | ||
| class ObjectReloader(ModuleReloader): | ||
| def __init__(self, obj, excluded=None): | ||
| self.object = obj | ||
| self.is_func = isinstance(obj, FunctionType) | ||
| self.object_name = obj.__name__ | ||
| self.object_module = get_module_by_name(obj.__module__) | ||
| self.object_file = self.object_module.__spec__.origin | ||
| self.original_object = obj | ||
| self._instances = [] # Keep references to all instances | ||
| super().__init__(self.object_module, excluded=excluded) | ||
| def __call__(self, *args, **kwargs): | ||
| instance = self.object.__call__(*args, **kwargs) | ||
| if not self.is_func: | ||
| # When the class initiate | ||
| # Register a reference to the instance | ||
| # So we can replace it later | ||
| self._instances.append(weakref.ref(instance)) | ||
| return instance | ||
| def __getattr__(self, name): | ||
| return getattr(self.object, name) | ||
| def fire(self) -> None: | ||
| """Reload the object""" | ||
| super().fire() | ||
| with open(self.object_file, 'r') as f: | ||
| source_code = f.read() | ||
| locals_: dict = {} | ||
| exec(source_code, self.module.__dict__, locals_) | ||
| self.object = locals_.get(self.object_name, None) | ||
| if self.object is None: | ||
| self.object = self.original_object | ||
| warnings.warn("Can't reload object. If it's a decorated function, " | ||
| "use functools.wraps to " | ||
| "preserve the function signature.", UserWarning) | ||
| # Replace the old reference of all instances with the new one | ||
| if not self.is_func: | ||
| for ref in self._instances: | ||
| instance = ref() # We keep weak references to objects | ||
| if instance: | ||
| instance.__class__ = self.object |
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 675.78 675.78"><defs><style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{clip-path:url(#clip-path-2);}.cls-4{fill:#3c79ab;}.cls-5{fill:#fdd837;}.cls-6{fill:#fff;}</style><clipPath id="clip-path" transform="translate(-276.95 -142.32)"><rect class="cls-1" width="1280" height="960.41"/></clipPath><clipPath id="clip-path-2" transform="translate(-276.95 -142.32)"><rect class="cls-1" width="1280" height="960.41"/></clipPath></defs><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g class="cls-2"><g class="cls-3"><path class="cls-4" d="M614.84,142.32c-184,0-333.57,147-337.78,330C281,312.65,402.33,184.56,551.49,184.56,703.11,184.56,826,316.92,826,480.21a63.36,63.36,0,0,0,126.71,0c0-186.62-151.27-337.89-337.89-337.89" transform="translate(-276.95 -142.32)"/><path class="cls-5" d="M614.84,818.09c184,0,333.58-147,337.79-329.94-3.9,159.61-125.27,287.71-274.43,287.71-151.63,0-274.54-132.37-274.54-295.65a63.36,63.36,0,1,0-126.71,0c0,186.61,151.28,337.88,337.89,337.88" transform="translate(-276.95 -142.32)"/><path class="cls-6" d="M917.92,480.21a26.82,26.82,0,1,1-26.82-26.82,26.82,26.82,0,0,1,26.82,26.82" transform="translate(-276.95 -142.32)"/><path class="cls-6" d="M366.05,493.1a26.82,26.82,0,1,1-26.82-26.82,26.83,26.83,0,0,1,26.82,26.82" transform="translate(-276.95 -142.32)"/></g></g></g></g></svg> |
Sorry, the diff of this file is not supported yet
| from .file_module import file_func | ||
| from .sub_module import sub_func | ||
| from .wrap import work_wrap | ||
| def func(): | ||
| return "Hi from func" | ||
| @work_wrap | ||
| @work_wrap | ||
| def decorated_func(): | ||
| return 100 | ||
| class Class: | ||
| v = 1 | ||
| var = 1 |
| def file_func(): | ||
| return "Hi from file_func" |
| from . import Class | ||
| cls = Class() |
| from my_pkg.wrap import wrap | ||
| def sub_func(): | ||
| return "Hi from sub_func" | ||
| @wrap | ||
| @wrap | ||
| def decorated_sub_func(): | ||
| return 100 | ||
| class SubClass: | ||
| v = 1 |
| import functools | ||
| def wrap(f): | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args | ||
| def work_wrap(f): | ||
| @functools.wraps(f) | ||
| def args(*arg, **kwargs): | ||
| return f(*arg, **kwargs) | ||
| return args |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
1262242
76.71%28
12%561
40.6%