├── .github └── workflows │ ├── build.yaml │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── badge │ └── logo.json ├── logo.svg └── showcase │ ├── reload_demo.gif │ └── reload_func.gif ├── hmr ├── __init__.py ├── _api.py ├── _reload.py └── _watcher.py ├── pyproject.toml ├── renovate.json ├── requirements.dev.txt ├── setup.py └── tests ├── conftest.py ├── my_pkg ├── __init__.py ├── file_module.py ├── state.py ├── sub_module │ ├── __init__.py │ └── subsub_module │ │ └── __init__.py └── wrap.py ├── pytest.ini ├── test_module_reload.py └── test_object_reload.py /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | Build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Python env 12 | uses: actions/setup-python@v5 13 | with: 14 | python-version: 3.11 15 | 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install flit 20 | pip install . 21 | 22 | - name: Publish 23 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') 24 | env: 25 | FLIT_INDEX_URL: https://upload.pypi.org/legacy/ 26 | FLIT_USERNAME: __token__ 27 | FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }} 28 | run: flit publish -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | Test: 7 | runs-on: ${{ matrix.os }} 8 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: [ubuntu-latest] 13 | python-version: [3.8] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dev package 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -e . 26 | 27 | - name: Test with pytest 28 | run: | 29 | pip install -r requirements.dev.txt 30 | 31 | - name: Publish to test.ipynb pypi 32 | env: 33 | FLIT_INDEX_URL: https://test.ipynb.pypi.org/legacy/ 34 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 35 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 36 | run: flit publish || exit 0 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | *.ipynb 4 | *.DS_Store 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mr-Milk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Hot Module Reload 2 | 3 |

4 | 5 | python-hmr logo 7 | 8 |

9 |

10 | Better debugging experience with HMR 11 |

12 | 13 | ![Test status](https://img.shields.io/github/actions/workflow/status/Mr-Milk/python-hmr/test.yaml?label=Test&logo=github&style=flat-square) 14 | ![pypi](https://img.shields.io/pypi/v/python-hmr?logoColor=white&style=flat-square) 15 | ![license-mit](https://img.shields.io/github/license/Mr-Milk/python-hmr?color=blue&style=flat-square) 16 | ![Endpoint Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Mr-Milk/python-hmr/main/assets/badge/logo.json&style=social) 17 | 18 | 19 | Automatic reload your project when files are modified. 20 | 21 | No need to modify your source code. Works in any environment. 22 | 23 | ![reload](https://github.com/Mr-Milk/python-hmr/blob/main/assets/showcase/reload_demo.gif?raw=true) 24 | 25 | Supported Syntax: 26 | 27 | - ✅ ```import X``` 28 | - ✅ ```from X import Y``` 29 | 30 | Supported Types: 31 | 32 | - ✅ `Module` 33 | - ✅ `Function` 34 | - ✅ `Class` 35 | 36 | ## Installation 37 | 38 | ```shell 39 | pip install python-hmr 40 | ``` 41 | 42 | ## Quick Start 43 | 44 | > [!CAUTION] 45 | > From v0.3.0, there is only one API, `hmr.reload`. 46 | 47 | Import your dev packages as usual. And add 2 lines 48 | for automatically reloading. 49 | 50 | ```python 51 | import dev 52 | 53 | import hmr 54 | dev = hmr.reload(dev) 55 | ``` 56 | 57 | If you have multiple modules to reload, you can do it like this. 58 | 59 | ```python 60 | from dev import run1, run2 61 | 62 | import hmr 63 | run1, run2 = hmr.reload(run1, run2) 64 | ``` 65 | 66 | Now you are ready to go! Try to modify the `run1` or `run2` 67 | and see the magic happen. 68 | 69 | 70 | ## Detailed Usage 71 | 72 | 73 | ### Function/Class instance 74 | 75 | When you try to add HMR for a function or class, remember to 76 | pass the name of the function or class instance without parenthesis. 77 | 78 | ```python 79 | from dev import Runner 80 | 81 | import hmr 82 | Runner = hmr.reload(Runner) 83 | 84 | a = Runner() 85 | b = Runner() 86 | ``` 87 | 88 | > [!IMPORTANT] 89 | > Here, when both `a` and `b` will be updated after reloading. This may be helpful 90 | > if you have an expansive state store within the class instance. 91 | > 92 | > However, it's suggested to reinitialize the class instance after reloading. 93 | 94 | 95 | ### @Decorated Function 96 | 97 | Use [functools.wraps](https://docs.python.org/3/library/functools.html#functools.wraps) to preserve 98 | signature of your function, or the function information will be replaced by the decorator itself. 99 | 100 | ```python 101 | import functools 102 | 103 | def work(f): 104 | @functools.wraps(f) 105 | def args(*arg, **kwargs): 106 | return f(*arg, **kwargs) 107 | 108 | return args 109 | ``` 110 | 111 | ### Stateful application 112 | 113 | If your application is stateful, you can exclude the state from being reloaded. 114 | For simplicity, you can group all your state variable into the same `.py` file like `state.py` 115 | and exclude that from being reloaded. 116 | 117 | > Make sure you know what you are doing. 118 | > This could lead to unexpected behavior and unreproducible bugs. 119 | 120 | ```python 121 | import dev 122 | 123 | import hmr 124 | dev = hmr.reload(dev, exclude=["dev.state"]) 125 | ``` 126 | 127 | In this way `dev/state.py` will not be reloaded, the state will persist. 128 | 129 | This also apply when reloading a function or class. 130 | 131 | ```python 132 | from dev import run 133 | 134 | import hmr 135 | run = hmr.reload(run, exclude=["dev.state"]) 136 | ``` 137 | 138 | 139 | ## Acknowledgement 140 | 141 | Inspired from the following package. 142 | 143 | - [auto-reloader](https://github.com/moisutsu/auto-reloader) 144 | - [reloadr](https://github.com/hoh/reloadr) 145 | -------------------------------------------------------------------------------- /assets/badge/logo.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "", 3 | "message": "python-hmr", 4 | "logoSvg": "Asset 1", 5 | "logoWidth": 15, 6 | "labelColor": "white", 7 | "color": "#FEC550" 8 | } -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /assets/showcase/reload_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-Milk/python-hmr/5b87c098c59bb3fc48a1500b5618f8c9b0c67aaf/assets/showcase/reload_demo.gif -------------------------------------------------------------------------------- /assets/showcase/reload_func.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-Milk/python-hmr/5b87c098c59bb3fc48a1500b5618f8c9b0c67aaf/assets/showcase/reload_func.gif -------------------------------------------------------------------------------- /hmr/__init__.py: -------------------------------------------------------------------------------- 1 | """Hot module reload for python""" 2 | 3 | __all__ = ["reload"] 4 | __version__ = "0.3.0" 5 | 6 | from ._api import reload 7 | -------------------------------------------------------------------------------- /hmr/_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["reload"] 4 | 5 | from types import ModuleType 6 | from typing import List, Callable, Sequence, Union 7 | 8 | from hmr._reload import ModuleReloader, ObjectReloader 9 | 10 | Reloadable = Union[ModuleType, Callable] 11 | Reloader = Union[ModuleReloader, ObjectReloader] 12 | 13 | 14 | def reload( 15 | *obj: Reloadable | Sequence[Reloadable], exclude: Sequence[str] = None 16 | ) -> Reloader | List[Reloader]: 17 | """The reloader to proxy reloaded object 18 | 19 | Parameters 20 | ---------- 21 | obj : The object(s) to be monitored and 22 | reloaded when file changes on the disk 23 | exclude : Exclude the module that you don't want to be reloaded 24 | 25 | """ 26 | reloaders = [] 27 | 28 | if len(obj) == 1: 29 | if isinstance(obj[0], (list, tuple)): 30 | obj_list = obj[0] 31 | else: 32 | obj_list = obj 33 | else: 34 | obj_list = obj 35 | 36 | for obj in obj_list: 37 | if isinstance(obj, ModuleType): 38 | reloader = ModuleReloader(obj, exclude) 39 | elif isinstance(obj, Callable): 40 | reloader = ObjectReloader(obj, exclude) 41 | else: 42 | msg = ( 43 | f"Operation failed: {obj} is either a constant value or " 44 | f"an already initialized object and cannot be reloaded. " 45 | "To resolve this issue: " 46 | "1. If you're attempting to pass a function or class, " 47 | "use its name without parentheses (e.g., `func` instead of `func()`). " 48 | "2. To access a constant, refer to it directly from its module " 49 | "using dot notation (e.g., `module.var`)." 50 | ) 51 | 52 | raise TypeError(msg) 53 | reloaders.append(reloader) 54 | if len(reloaders) == 1: 55 | return reloaders[0] 56 | return reloaders 57 | -------------------------------------------------------------------------------- /hmr/_reload.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["ModuleReloader", "ObjectReloader", "BaseReloader"] 4 | 5 | import sys 6 | import warnings 7 | import weakref 8 | from dataclasses import dataclass 9 | from importlib import reload, invalidate_caches, import_module 10 | from importlib.util import module_from_spec, find_spec 11 | from pathlib import Path 12 | from types import ModuleType, FunctionType 13 | from typing import Set 14 | 15 | from ._watcher import Watchers 16 | 17 | 18 | def _recursive_reload(module, exclude): 19 | reload(sys.modules.get(module.__name__)) 20 | for attr in dir(module): 21 | attr = getattr(module, attr) 22 | if isinstance(attr, ModuleType): 23 | if attr.__name__.startswith(module.__name__): 24 | if attr.__name__ not in exclude: 25 | _recursive_reload(attr, exclude) 26 | 27 | 28 | def get_module_by_name(name): 29 | return module_from_spec(find_spec(name)) 30 | 31 | 32 | @dataclass 33 | class ProxyModule: 34 | name: str 35 | file: str 36 | root_name: str 37 | root_path: str 38 | object: object | ModuleType | FunctionType 39 | root_object: ModuleType 40 | exclude: Set[str] = None 41 | 42 | def is_func(self): 43 | return isinstance(self.object, FunctionType) 44 | 45 | def reload(self): 46 | _recursive_reload(self.root_object, self.exclude) 47 | self.root_object = import_module(self.root_name) 48 | 49 | 50 | class BaseReloader(ModuleType): 51 | __proxy_module__: ProxyModule 52 | 53 | def __init__(self, proxy_obj): 54 | obj = proxy_obj.object 55 | super().__init__(obj.__name__, obj.__doc__) 56 | self.__proxy_module__ = proxy_obj 57 | Watchers.add_reload(self) 58 | # Try to reload the module when the object is created 59 | self.__reload__() 60 | 61 | def __repr__(self): 62 | return f"" 63 | 64 | def __getattr__(self, name): 65 | return getattr(self.__proxy_module__.object, name) 66 | 67 | def __del__(self): 68 | Watchers.delete_reload(self) 69 | 70 | # For IDE auto-completion 71 | @property 72 | def __all__(self) -> list: 73 | if hasattr(self.__proxy_module__.object, "__all__"): 74 | return self.__proxy_module__.object.__all__ 75 | return [] 76 | 77 | # For IDE auto-completion 78 | def __dir__(self): 79 | return self.__proxy_module__.object.__dir__() 80 | 81 | def __reload__(self) -> None: 82 | raise NotImplementedError 83 | 84 | 85 | class ModuleReloader(BaseReloader): 86 | def __init__(self, module, exclude=None): 87 | # If user import a submodule 88 | # we still need to monitor the whole module to reload 89 | if exclude is None: 90 | exclude = set() 91 | else: 92 | exclude = set(exclude) 93 | 94 | root_module = get_module_by_name(module.__name__.split(".")[0]) 95 | root_path = Path(root_module.__spec__.origin).parent 96 | proxy = ProxyModule( 97 | name=module.__name__, 98 | file=module.__spec__.origin, 99 | root_name=root_module.__name__, 100 | root_path=str(root_path), 101 | object=module, 102 | root_object=root_module, 103 | exclude=exclude, 104 | ) 105 | 106 | super().__init__(proxy) 107 | 108 | def __reload__(self): 109 | invalidate_caches() 110 | self.__proxy_module__.reload() 111 | 112 | 113 | class ObjectReloader(BaseReloader): 114 | def __init__(self, obj, exclude=None): 115 | root_module = get_module_by_name(obj.__module__) 116 | root_path = Path(root_module.__spec__.origin).parent 117 | 118 | if exclude is None: 119 | exclude = set() 120 | else: 121 | exclude = set(exclude) 122 | 123 | proxy = ProxyModule( 124 | name=obj.__name__, 125 | file=root_module.__spec__.origin, 126 | root_name=root_module.__name__, 127 | root_path=str(root_path), 128 | object=obj, 129 | root_object=get_module_by_name(obj.__module__), 130 | exclude=exclude, 131 | ) 132 | 133 | self.__ref_instances = [] # Keep references to all instances 134 | super().__init__(proxy) 135 | 136 | def __call__(self, *args, **kwargs): 137 | # When user override the __call__ method in class 138 | try: 139 | instance = self.__proxy_module__.object.__call__(*args, **kwargs) 140 | except TypeError: 141 | instance = self.__proxy_module__.object(*args, **kwargs) 142 | if not self.__proxy_module__.is_func(): 143 | # When the class initiate 144 | # Register a reference to the instance 145 | # So we can replace it later 146 | self.__ref_instances.append(weakref.ref(instance)) 147 | return instance 148 | 149 | def __reload__(self) -> None: 150 | """Reload the object""" 151 | invalidate_caches() 152 | self.__proxy_module__.reload() 153 | with open(self.__proxy_module__.file, "r") as f: 154 | source_code = f.read() 155 | locals_: dict = {} 156 | exec(source_code, self.__proxy_module__.root_object.__dict__, locals_) 157 | updated_object = locals_.get(self.__proxy_module__.name, None) 158 | if updated_object is None: 159 | warnings.warn( 160 | "Can't reload object. If it's a decorated function, " 161 | "use functools.wraps to " 162 | "preserve the function signature.", 163 | UserWarning, 164 | ) 165 | else: 166 | self.__proxy_module__.object = updated_object 167 | 168 | # Replace the old reference of all instances with the new one 169 | if not self.__proxy_module__.is_func(): 170 | for ref in self.__ref_instances: 171 | instance = ref() # We keep weak references to objects 172 | if instance: 173 | instance.__class__ = updated_object 174 | -------------------------------------------------------------------------------- /hmr/_watcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["Watchers"] 4 | 5 | import atexit 6 | import sys 7 | import traceback 8 | from datetime import datetime 9 | from typing import Dict, TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from ._reload import BaseReloader 13 | 14 | from watchdog.events import FileSystemEventHandler 15 | from watchdog.observers import Observer 16 | from watchdog.observers.api import ObservedWatch 17 | 18 | 19 | class EventsHandler(FileSystemEventHandler): 20 | reload_list = [] 21 | _last_error = None 22 | 23 | def on_any_event(self, event): 24 | # print(event.src_path, event.event_type, event.is_directory) 25 | # print("Reload list", self.reload_list) 26 | for reloader in self.reload_list: 27 | try: 28 | reloader.__reload__() 29 | except Exception: 30 | _current = datetime.now() 31 | error_stack = traceback.extract_stack() 32 | # only fire the same error once 33 | if self._last_error is None or self._last_error != error_stack: 34 | self._last_error = error_stack 35 | traceback.print_exc(file=sys.stderr) 36 | return 37 | 38 | 39 | class WatcherStorage: 40 | def __init__(self): 41 | self._observer = None 42 | self.watchers: Dict[str, (ObservedWatch, EventsHandler)] = {} 43 | 44 | atexit.register(self.__del__) 45 | 46 | @property 47 | def observer(self) -> Observer: 48 | if self._observer is None: 49 | self._observer = Observer() 50 | self._observer.daemon = True 51 | self._observer.start() 52 | return self._observer 53 | 54 | def add_reload(self, reloader: BaseReloader): 55 | root_path = reloader.__proxy_module__.root_path 56 | watcher = self.watchers.get(root_path) 57 | if watcher is None: 58 | event_handler = EventsHandler() 59 | event_handler.reload_list.append(reloader) 60 | watch = self.observer.schedule(event_handler, root_path, recursive=True) 61 | self.watchers[root_path] = (watch, event_handler) 62 | else: 63 | watch, event_handler = watcher 64 | event_handler.reload_list.append(reloader) 65 | 66 | def delete_reload(self, reloader: BaseReloader): 67 | root_path = reloader.__proxy_module__.root_path 68 | watcher = self.watchers.get(root_path) 69 | if watcher is not None: 70 | watch, event_handler = watcher 71 | # This may be emitted multiple times 72 | # Must wrap in a try-except block 73 | try: 74 | event_handler.reload_list.remove(reloader) 75 | if not event_handler.reload_list: 76 | self.observer.unschedule(watch) 77 | del self.watchers[root_path] 78 | except (ValueError, KeyError): 79 | pass 80 | 81 | def __del__(self): 82 | if self._observer is not None: 83 | self._observer.unschedule_all() 84 | self._observer.stop() 85 | self._observer.join() 86 | 87 | 88 | Watchers = WatcherStorage() 89 | 90 | 91 | # def get_watchers(): 92 | # return Watchers 93 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "python-hmr" 7 | authors = [{name = "Mr-Milk", email = "zym.zym1220@gmail.com"}] 8 | license = {file = "LICENSE"} 9 | readme = "README.md" 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | requires-python = ">=3.8" 13 | urls = {Home="https://github.com/mr-milk/python-hmr"} 14 | dependencies = ["watchdog"] 15 | 16 | [tool.flit.module] 17 | name = "hmr" 18 | 19 | [project.optional-dependencies] 20 | dev = [ 21 | "pytest", 22 | "ruff", 23 | ] 24 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # package 2 | watchdog 3 | 4 | # test 5 | pytest 6 | pytest-cov 7 | pytest-check 8 | pytest-xdist 9 | setuptools -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import setup 4 | 5 | README = Path("README.md").read_text() 6 | 7 | setup(name="python-hmr") 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shutil 4 | from pathlib import Path 5 | from typing import Tuple, Union 6 | from uuid import uuid4 7 | 8 | import pytest 9 | 10 | TEST_DIR = Path(__file__).parent 11 | 12 | 13 | # every function need to sleep for a while to wait for the reload to complete 14 | def pytest_addoption(parser): 15 | parser.addoption( 16 | "--wait", 17 | action="store", 18 | default=0.3, 19 | ) 20 | 21 | 22 | def pytest_generate_tests(metafunc): 23 | option_value = float(metafunc.config.option.wait) 24 | if "wait" in metafunc.fixturenames and option_value is not None: 25 | metafunc.parametrize("wait", [option_value]) 26 | 27 | 28 | def read_replace_write(file: Union[str, Path], replace: Tuple): 29 | with open(file, "r") as f: 30 | raw = f.read() 31 | 32 | with open(file, "w") as f: 33 | text = raw.replace(*replace) 34 | f.write(text) 35 | 36 | 37 | # create a unique directory for each test 38 | # we can't reload the same pkg name 39 | @pytest.fixture 40 | def pkg_name(): 41 | return f"my_pkg_{str(uuid4())}" 42 | 43 | 44 | def copy_pkg(pkg_name): 45 | pkg = TEST_DIR / "my_pkg" 46 | dest = TEST_DIR / pkg_name 47 | if dest.exists(): 48 | shutil.rmtree(dest) 49 | shutil.copytree(pkg, dest) 50 | 51 | 52 | @pytest.fixture(scope="function", autouse=True) 53 | def create_package(pkg_name): 54 | copy_pkg(pkg_name) 55 | pkg_dir = TEST_DIR / pkg_name 56 | yield 57 | try: 58 | shutil.rmtree(pkg_dir) 59 | # When test in NSF file system this is a workaround 60 | except Exception as e: 61 | pass 62 | try: 63 | if platform.system() in ["Linux", "Darwin"]: 64 | os.system(f"rm -rf {pkg_dir.absolute()}") 65 | else: 66 | os.system(f"rmdir \\Q \\S {pkg_dir.absolute()}") 67 | except Exception as e: 68 | pass 69 | 70 | 71 | @pytest.fixture(scope="function") 72 | def package(pkg_name): 73 | pkg_dir = TEST_DIR / pkg_name 74 | pkg_init: Path = pkg_dir / "__init__.py" 75 | pkg_file_module: Path = pkg_dir / "file_module.py" 76 | pkg_sub_module_init: Path = pkg_dir / "sub_module" / "__init__.py" 77 | pkg_subsub_module_init: Path = ( 78 | pkg_dir / "sub_module" / "subsub_module" / "__init__.py" 79 | ) 80 | 81 | class Package: 82 | pkg_name = pkg_name 83 | pkg_init: Path = pkg_dir / "__init__.py" 84 | pkg_file_module: Path = pkg_dir / "file_module.py" 85 | pkg_sub_module_init: Path = pkg_dir / "sub_module" / "__init__.py" 86 | pkg_subsub_module_init: Path = ( 87 | pkg_dir / "sub_module" / "subsub_module" / "__init__.py" 88 | ) 89 | 90 | @staticmethod 91 | def raise_syntax_error(): 92 | read_replace_write(pkg_init, ("return", "return_")) 93 | 94 | @staticmethod 95 | def modify_module_func(): 96 | read_replace_write(pkg_init, ("Hi from func", "Hello from func")) 97 | # with open(pkg_init, 'r') as f: 98 | # print(f.read()) 99 | 100 | @staticmethod 101 | def modify_module_decorated_func(): 102 | read_replace_write(pkg_init, ("return 100", "return 10")) 103 | 104 | @staticmethod 105 | def modify_module_class(): 106 | read_replace_write(pkg_init, ("v = 1", "v = 2")) 107 | 108 | @staticmethod 109 | def modify_file_module_func(): 110 | read_replace_write( 111 | pkg_file_module, ("Hi from file_func", "Hello from file_func") 112 | ) 113 | 114 | @staticmethod 115 | def modify_sub_module_func(): 116 | read_replace_write( 117 | pkg_sub_module_init, ("Hi from sub_func", "Hello from sub_func") 118 | ) 119 | 120 | @staticmethod 121 | def modify_sub_module_decorated_func(): 122 | read_replace_write(pkg_sub_module_init, ("return 100", "return 10")) 123 | 124 | @staticmethod 125 | def modify_sub_module_class(): 126 | read_replace_write(pkg_sub_module_init, ("v = 1", "v = 2")) 127 | 128 | @staticmethod 129 | def modify_subsubmodule(): 130 | read_replace_write(pkg_subsub_module_init, ("x = 1", "x = 2")) 131 | 132 | return Package() 133 | -------------------------------------------------------------------------------- /tests/my_pkg/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_module import file_func 2 | from .sub_module import sub_func 3 | from .wrap import work_wrap 4 | 5 | 6 | def func(): 7 | return "Hi from func" 8 | 9 | 10 | @work_wrap 11 | @work_wrap 12 | def decorated_func(): 13 | return 100 14 | 15 | 16 | class Class: 17 | v = 1 18 | 19 | 20 | var = 1 21 | 22 | 23 | class Complicate: 24 | """Complicate class for testing purposes.""" 25 | 26 | def __init__(self): 27 | self.x = 12 28 | 29 | # def __repr__(self): 30 | # return f"Complicate(x={self.x})" 31 | 32 | def __add__(self, other): 33 | return self.x + other.x 34 | 35 | def __call__(self, *args, **kwargs): 36 | return self.add(*args) 37 | 38 | def add(self, a, b): 39 | return a + b + self.x 40 | -------------------------------------------------------------------------------- /tests/my_pkg/file_module.py: -------------------------------------------------------------------------------- 1 | def file_func(): 2 | return "Hi from file_func" 3 | -------------------------------------------------------------------------------- /tests/my_pkg/state.py: -------------------------------------------------------------------------------- 1 | from . import Class 2 | 3 | cls = Class() 4 | -------------------------------------------------------------------------------- /tests/my_pkg/sub_module/__init__.py: -------------------------------------------------------------------------------- 1 | from tests.my_pkg.wrap import wrap 2 | 3 | 4 | def sub_func(): 5 | return "Hi from sub_func" 6 | 7 | 8 | @wrap 9 | @wrap 10 | def decorated_sub_func(): 11 | return 100 12 | 13 | 14 | class SubClass: 15 | v = 1 16 | -------------------------------------------------------------------------------- /tests/my_pkg/sub_module/subsub_module/__init__.py: -------------------------------------------------------------------------------- 1 | x = 3 2 | -------------------------------------------------------------------------------- /tests/my_pkg/wrap.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | 4 | def wrap(f): 5 | def args(*arg, **kwargs): 6 | return f(*arg, **kwargs) 7 | 8 | return args 9 | 10 | 11 | def work_wrap(f): 12 | @functools.wraps(f) 13 | def args(*arg, **kwargs): 14 | return f(*arg, **kwargs) 15 | 16 | return args 17 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-Milk/python-hmr/5b87c098c59bb3fc48a1500b5618f8c9b0c67aaf/tests/pytest.ini -------------------------------------------------------------------------------- /tests/test_module_reload.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | from pathlib import Path 4 | from time import sleep 5 | 6 | import pytest 7 | 8 | from hmr import reload 9 | 10 | sys.path.insert(0, str(Path(__file__).parent.resolve())) 11 | 12 | 13 | # import X 14 | 15 | 16 | def test_module(package, pkg_name, wait): 17 | my_pkg = importlib.import_module(pkg_name) # import pkg_name 18 | 19 | my_pkg = reload(my_pkg, exclude=["my_pkg.sub_module"]) 20 | assert my_pkg.func() == "Hi from func" 21 | package.modify_module_func() 22 | sleep(wait) 23 | # check.equal(my_pkg.func(), "Hello from func") 24 | assert my_pkg.func() == "Hello from func" 25 | 26 | 27 | # import X.Y as A 28 | def test_submodule(package, pkg_name, wait): 29 | sub = importlib.import_module(f"{pkg_name}.sub_module") 30 | sub = reload(sub) 31 | 32 | assert sub.sub_func() == "Hi from sub_func" 33 | 34 | package.modify_sub_module_func() 35 | sleep(wait) 36 | assert sub.sub_func() == "Hello from sub_func" 37 | 38 | 39 | @pytest.mark.xfail 40 | def test_syntax_error(package, pkg_name, wait): 41 | my_pkg = importlib.import_module(pkg_name) # import pkg_name 42 | my_pkg = reload(my_pkg) 43 | # sleep(wait) 44 | # check.equal(my_pkg.func(), "Hi from func") 45 | assert my_pkg.func() == "Hi from func" 46 | 47 | package.raise_syntax_error() 48 | sleep(wait) 49 | # check.equal(my_pkg.func(), "Hello from func") 50 | assert my_pkg.func() == "Hello from func" 51 | -------------------------------------------------------------------------------- /tests/test_object_reload.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | from pathlib import Path 4 | from time import sleep 5 | 6 | import pytest 7 | 8 | from hmr import reload 9 | 10 | sys.path.insert(0, str(Path(__file__).parent.resolve())) 11 | 12 | 13 | def check_func(func, modify_func, before, after, wait=0): 14 | func = reload(func) 15 | assert func() == before 16 | 17 | modify_func() 18 | sleep(wait) 19 | assert func.__call__() == after 20 | 21 | 22 | def check_cls(cls, modify_cls, before, after, wait=0): 23 | cls = reload(cls) 24 | c = cls() 25 | assert c.v == before 26 | 27 | modify_cls() 28 | sleep(wait) 29 | assert c.v == after 30 | 31 | 32 | # from X import func 33 | def test_func(package, pkg_name, wait): 34 | func = importlib.import_module(pkg_name).__getattribute__( 35 | "func" 36 | ) # from x import func 37 | check_func( 38 | func, package.modify_module_func, "Hi from func", "Hello from func", wait 39 | ) 40 | 41 | 42 | # from X.Y import func 43 | def test_sub_func(package, pkg_name, wait): 44 | sub_func = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( 45 | "sub_func" 46 | ) 47 | check_func( 48 | sub_func, 49 | package.modify_sub_module_func, 50 | "Hi from sub_func", 51 | "Hello from sub_func", 52 | wait, 53 | ) 54 | 55 | 56 | # from X import class 57 | def test_class(package, pkg_name, wait): 58 | Class = importlib.import_module(pkg_name).__getattribute__("Class") 59 | check_cls(Class, package.modify_module_class, 1, 2, wait) 60 | 61 | 62 | # from X.Y import class 63 | def test_sub_class(package, pkg_name, wait): 64 | SubClass = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( 65 | "SubClass" 66 | ) 67 | check_cls(SubClass, package.modify_sub_module_class, 1, 2, wait) 68 | 69 | 70 | # from X import var 71 | @pytest.mark.xfail 72 | def test_var(pkg_name): 73 | var = importlib.import_module(pkg_name).__getattribute__("var") 74 | var = reload(var) 75 | 76 | 77 | # test ref object reload 78 | def test_func_ref_reload(package, pkg_name, wait): 79 | func = importlib.import_module(pkg_name).__getattribute__("func") 80 | func = reload(func) 81 | ref_f = func 82 | 83 | assert func() == "Hi from func" 84 | assert ref_f() == "Hi from func" 85 | 86 | package.modify_module_func() 87 | sleep(wait) 88 | assert func() == "Hello from func" 89 | assert ref_f() == "Hello from func" 90 | 91 | 92 | def test_class_ref_reload(package, pkg_name, wait): 93 | Class = importlib.import_module(pkg_name).__getattribute__("Class") 94 | Class = reload(Class) 95 | assert Class.v == 1 96 | a = Class() 97 | b = Class() 98 | 99 | assert a.v == 1 100 | assert b.v == 1 101 | 102 | package.modify_module_class() 103 | sleep(wait) 104 | assert a.v == 2 105 | assert b.v == 2 106 | 107 | 108 | # test decorated function 109 | def test_decoreated_function_with_signature(package, pkg_name, wait): 110 | decorated_func = importlib.import_module(pkg_name).__getattribute__( 111 | "decorated_func" 112 | ) 113 | check_func(decorated_func, package.modify_module_decorated_func, 100, 10, wait) 114 | 115 | 116 | @pytest.mark.xfail 117 | def test_decoreated_function_no_signature(package, pkg_name, wait): 118 | dsf = importlib.import_module(f"{pkg_name}.sub_module").__getattribute__( 119 | "decorated_sub_func" 120 | ) 121 | check_func(dsf, package.modify_sub_module_decorated_func, 100, 10, wait) 122 | --------------------------------------------------------------------------------