-
Notifications
You must be signed in to change notification settings - Fork 3
/
hotload.py
executable file
·298 lines (223 loc) · 7.74 KB
/
hotload.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#!/usr/bin/env python3
from __future__ import print_function
import datetime
import importlib
import itertools
import os
import re
import sys
import time
import traceback
from abc import abstractmethod
from pprint import pprint
from types import FunctionType, ModuleType
# Turn on to see how long each reload takes.
TIME_RELOADS = False
################################################################################
# UTILITY FUNCTIONS
def _reload_module(module):
"""Reload a module from Python 2 or 3"""
try:
importlib.reload(module)
except AttributeError:
# Fallback for Python 2
reload(module)
def _file_changed(f):
"""Read os metadata for last modification of file at `f`
Tested on Unix and Windows. More details in the standard library[1].
[1]: https://docs.python.org/3/library/stat.html#stat.ST_CTIME
"""
for attempt in itertools.count(start=1):
try:
return os.stat(f).st_mtime
except FileNotFoundError:
if attempt > 3:
raise
time.sleep(0.01 * attempt)
def _all_file_changes(filepaths):
return {path: _file_changed(path) for path in filepaths}
def _changed_modules(new_changed, last_changed):
changed_modules = []
if last_changed is None:
return changed_modules
modules = {}
for module in sys.modules.copy().values():
file = getattr(module, '__file__', None)
if file is not None:
modules[file] = module
for path in new_changed:
if new_changed[path] != last_changed[path]:
module = modules.get(path, None)
if module is not None:
changed_modules.append(module)
return changed_modules
def _feature_flag(envvar, cli_arg):
if os.getenv(envvar) == envvar:
return True
if cli_arg in sys.argv:
return True
return False
def _reload_recursive():
"""Feature flag - is recursive reloading enabled?"""
return _feature_flag("HOTLOAD_RECURSIVE", "--recursive")
def __no_clear():
return _feature_flag("HOTLOAD_NO_CLEAR", "--no-clear")
def listfiles(folder, ext=""):
fs = list()
for root, dirs, files in os.walk(folder):
fs.extend(os.path.join(root, f) for f in files if f.endswith(ext))
return fs
################################################################################
# RUNNABLE
#
# Runnables can be run. That's it! A few runnables are included for running
# some compile-time Python, running a system command, running a dynamically
# reloaded Python module.
#
# Consideration: should I ensure that I can pass state between reloads? Or
# should I handle that internally? Perhaps simplest just to handle locally.
class Runnable(object):
@abstractmethod
def run(self, **kwargs):
pass
pass
class PythonHandle(Runnable):
def __init__(self, code):
self.code = code
def run(self, **kwargs):
self.code()
pass
class Command(Runnable):
def __init__(self, command):
self.command = command
def run(self, **kwargs):
os.system(self.command)
pass
class ReloadedPythonModule(Runnable):
def __init__(self, module):
assert isinstance(module, ModuleType), (
"ReloadedPythonModule requires a module handle as input."
"Use ReloadedPythonModule.from_module_name to construct a PythonModule from"
" a module name (string)"
)
self.module = module
@classmethod
def from_module_name(cls, module_name):
module = importlib.import_module(module_name)
return cls(module)
def pre_reload_hook(self, _):
pass
def post_reload_hook(self, _):
pass
def run(self, **kwargs):
self.pre_reload_hook(self.module)
_reload_module(self.module)
self.post_reload_hook(self.module)
print(
"Successfully reloaded {} @ {}".format(
self.module.__name__, datetime.datetime.now()
)
)
def function(self, function_name):
function = self.module.__dict__[function_name]
assert isinstance(function, FunctionType)
return function
pass
class ClearTerminal(Runnable):
def run(self, **kwargs):
os.system("cls" if os.name == "nt" else "clear")
class ReloadModules(Runnable):
def __init__(self, module):
self.main_module = module
def run(self, changed_modules, **kwargs):
for module in changed_modules:
if module != self.main_module:
_reload_module(module)
def hotload(watch, steps, waittime_ms=1.0 / 144):
"""Hotload that code!"""
# Avoid duplicates in the recurring check
watchfiles = set()
for targets in watch:
for path in targets:
watchfiles.add(path)
# Take note of when files were last changed before we start reloading
last_changed = None
is_module_reloader = lambda step: type(step).__name__ == "ReloadModules"
do_reload_modules = bool(list(filter(is_module_reloader, steps)))
# Begin the loop! Each Runner is responsible for handling its own exceptions.
while True:
new_changed = _all_file_changes(watchfiles)
if last_changed == new_changed:
time.sleep(waittime_ms)
else:
reload_begin_ms = time.time() * 1000
changed_modules = _changed_modules(new_changed, last_changed) if do_reload_modules else []
last_changed = new_changed
try:
for step in steps:
try:
step.run(changed_modules=changed_modules)
except KeyboardInterrupt:
raise
except:
print("Error running {}".format(step))
traceback.print_exc()
except KeyboardInterrupt:
print("Interrupt received, stopping hotload")
return
reload_done_ms = time.time() * 1000
if TIME_RELOADS:
print("Reloaded in {} ms".format(reload_done_ms - reload_begin_ms))
pass
pass
def main():
USAGE = """Usage: hotload SCRIPT
Hotload python script when files on standard input change
Example usage:
find . -name '*.py' | hotload init.py
.py extension for script may be omitted.
"""
print("Running hotload ...")
sys.path.append(".")
if len(sys.argv) < 2:
print(USAGE)
sys.exit(1)
entrypoint = None
for i, cliarg in enumerate(sys.argv):
# Look for CLI options
if i < 2:
# Must be "hotload" and "script"
continue
if cliarg == "--entrypoint":
try:
entrypoint = sys.argv[i + 1]
except IndexError:
print("LOL")
print("Nothing happening? Remember to pass watch files on stdin.")
print("Example: ls *py | hotload hello.py")
print()
print(" ls *py | hotload hello.py")
watchfiles = [os.path.normpath(os.path.join(os.getcwd(), f.strip())) for f in sys.stdin.readlines()]
if not watchfiles:
print("Error: no watch files specified.")
print(USAGE)
sys.exit(1)
init_module = re.sub(r"\.py", "", sys.argv[1]).replace("/", ".")
os.environ["HOTLOAD_RUNNING"] = "HOTLOAD_RUNNING"
reloaded_module = ReloadedPythonModule.from_module_name(init_module)
if entrypoint:
reloaded_module.post_reload_hook = lambda _: reloaded_module.function(entrypoint)()
steps = []
if not __no_clear():
steps.append(ClearTerminal())
if _reload_recursive():
steps.append(ReloadModules(reloaded_module.module))
steps.append(reloaded_module)
conf = {
"watch": [watchfiles],
"steps": steps
}
hotload(**conf)
pass
if __name__ == "__main__":
main()