├── .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 |
7 |
8 |
9 |
10 | Better debugging experience with HMR
11 |
12 |
13 | 
14 | 
15 | 
16 | 
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 | 
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": "",
5 | "logoWidth": 15,
6 | "labelColor": "white",
7 | "color": "#FEC550"
8 | }
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------