├── .gitignore
├── pnrp
├── __main__.py
└── __init__.py
├── .style.yapf
├── tests
├── test_runstate.py
├── test_ast_compare.py
├── test_driver.py
└── test_ast_diff.py
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | run.py
3 |
--------------------------------------------------------------------------------
/pnrp/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import pnrp
3 |
4 | if __name__ == '__main__':
5 | [_, fpath, *args] = sys.argv
6 | pnrp.cli(fpath, args)
7 |
--------------------------------------------------------------------------------
/.style.yapf:
--------------------------------------------------------------------------------
1 | [style]
2 | based_on_style = google
3 |
4 | # max characters per line
5 | COLUMN_LIMIT = 100
6 |
7 | # Put closing brackets on a separate line, dedented, if the bracketed expression can't fit in a single line
8 | DEDENT_CLOSING_BRACKETS = true
9 |
10 | # Place each dictionary entry onto its own line.
11 | EACH_DICT_ENTRY_ON_SEPARATE_LINE = true
12 |
13 | # Join short lines into one line. E.g., single line if statements.
14 | JOIN_MULTIPLE_LINES = true
15 |
16 | # Insert a blank line before a def or class immediately nested within another def or class
17 | BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = true
18 |
19 | # Split before arguments if the argument list is terminated by a comma.
20 | SPLIT_ARGUMENTS_WHEN_COMMA_TERMINATED = true
21 |
22 | # If an argument / parameter list is going to be split, then split before the first argument
23 | SPLIT_BEFORE_FIRST_ARGUMENT = true
24 |
--------------------------------------------------------------------------------
/tests/test_runstate.py:
--------------------------------------------------------------------------------
1 | import pnrp
2 | import inspect
3 |
4 |
5 | def test_runstate_simple():
6 | gvars = {}
7 | states = []
8 | fchanges = [
9 | """
10 | a = 'hello'
11 | """, """
12 | a = 'hello'
13 | print(a)
14 | a *= 2
15 | """
16 | ]
17 |
18 | fchanges = [('run.py', inspect.cleandoc(x)) for x in fchanges]
19 | pnrp.driver(fchanges, gvars=gvars, flush_runstate=lambda xs: states.append('\n'.join(xs)))
20 |
21 | # Expected States represents too loops through
22 | # change, run, complete
23 | # change, run, complete
24 | expected_states = inspect.cleandoc(
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 | ).split('---')
53 | expected_states = [inspect.cleandoc(x) for x in expected_states]
54 |
55 | assert gvars['a'] == 'hellohello'
56 | assert states == expected_states
57 |
--------------------------------------------------------------------------------
/tests/test_ast_compare.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import inspect
3 | import pnrp
4 |
5 |
6 | def str2ast(x):
7 | return ast.parse(inspect.cleandoc(x))
8 |
9 |
10 | def test_ast_compare_same():
11 | a = str2ast("""
12 | a = 1
13 | """)
14 | b = str2ast("""
15 | a = 1
16 | """)
17 |
18 | assert pnrp.compare_ast(a, b) == True
19 |
20 |
21 | def test_ast_compare_same_recursive():
22 | a = str2ast("""
23 | def foo():
24 | a = 1
25 | """)
26 | b = str2ast("""
27 | def foo():
28 | a = 1
29 | """)
30 |
31 | assert pnrp.compare_ast(a, b) == True
32 |
33 |
34 | def test_ast_compare_diff():
35 | a = str2ast("""
36 | a = 1
37 | """)
38 | b = str2ast("""
39 | b = 1
40 | """)
41 |
42 | assert pnrp.compare_ast(a, b) == False
43 |
44 |
45 | def test_ast_compare_diff_top_level():
46 | a = str2ast("""
47 | def foo():
48 | a = 1
49 | """)
50 | b = str2ast("""
51 | def bar():
52 | a = 1
53 | """)
54 |
55 | assert pnrp.compare_ast(a, b) == False
56 |
57 |
58 | def test_ast_compare_diff_top_nested():
59 | a = str2ast("""
60 | def foo():
61 | a = 1
62 | """)
63 | b = str2ast("""
64 | def foo():
65 | b = 1
66 | """)
67 |
68 | assert pnrp.compare_ast(a, b) == False
69 |
--------------------------------------------------------------------------------
/tests/test_driver.py:
--------------------------------------------------------------------------------
1 | import pnrp
2 | import inspect
3 |
4 |
5 | def test_driver_basic():
6 | gvars = {}
7 | fchanges = [
8 | """
9 | a = 'hello'
10 | """, """
11 | a = 'hello'
12 | print(a)
13 | a *= 2
14 | """
15 | ]
16 |
17 | fchanges = [('run.py', inspect.cleandoc(x)) for x in fchanges]
18 | pnrp.driver(fchanges, gvars=gvars)
19 |
20 | assert gvars['a'] == 'hellohello'
21 |
22 |
23 | def test_driver_syntax_error():
24 | """Should continue on after syntax errors"""
25 | gvars = {}
26 | fchanges = [
27 | """
28 | a = 'hello'
29 | """, """
30 | a = 'hello'
31 | b = # Syntax Error
32 | a = 'world'
33 | print(a)
34 | """, """
35 | a = 'hello'
36 | a = 'world'
37 | print(a)
38 | """
39 | ]
40 |
41 | fchanges = [('run.py', inspect.cleandoc(x)) for x in fchanges]
42 | pnrp.driver(fchanges, gvars=gvars)
43 |
44 | assert gvars['a'] == 'world'
45 |
46 |
47 | def test_driver_exception():
48 | """Should continue on after exception"""
49 | gvars = {}
50 | fchanges = [
51 | """
52 | a = 'hello'
53 | """, """
54 | a = 'hello'
55 | raise 'foo'
56 | a = 'world'
57 | """, """
58 | a = 'hello'
59 | a = 'world'
60 | """
61 | ]
62 |
63 | fchanges = [('run.py', inspect.cleandoc(x)) for x in fchanges]
64 | pnrp.driver(fchanges, gvars=gvars)
65 |
66 | assert gvars['a'] != 'hello'
67 | assert gvars['a'] == 'world'
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Pete and Repeat
2 |
3 |
4 | Love the Python REPL?
5 | Hate that you can't write real software in a Jupyter Notebook?
6 | Well do I have the project for you.
7 |
8 |
9 |
10 |
11 |
12 |
13 | Pete and Repeat (pnrp) brings all the goodness of the REPL and Jupyter Notebooks to your regular old Python projects.
14 | It kind of works like [Observable](https://observablehq.com) but for Python from the CLI.
15 |
16 | To get started run,
17 |
18 | ```
19 | $ pip install pnrp
20 | $ python -m pnrp .py
21 | ```
22 |
23 | pnrp will run your script to completion just as the regular Python interpreter.
24 | Instead of exiting though, it'll monitor every line of code executed for changes.
25 | When a line of code changes, it will rerun **ONLY** the statements that have changed, and their dependent statements.
26 | We are able to do this by monitoring the files of any imported module, analyzing the AST of the code to be executed, and tracking dependencies between statements.
27 | This is of course no silver bullet, Python is a highly dynamic language and there are situations when this won't work.
28 | That is why we'll provide you an escape hatch to rerun from specific points in your script onward.
29 |
30 | ## How it works, by example
31 |
32 | Suppose we have a python program
33 |
34 | ```python
35 | # Script at time t
36 |
37 | data = open('some-big-file.txt', 'r').read()
38 | print(len(data))
39 | ```
40 |
41 | We then modify the script,
42 |
43 | ```python
44 | # Script at time t+1
45 | import re
46 |
47 | expr = re.compile('Pete (and)? repeat')
48 |
49 | data = open('some-big-file.txt', 'r').read()
50 | lines = data.split('\n')
51 | print(len([x for x in lines if expr.match(x)]))
52 | ```
53 |
54 | Rerunning the script normally would cause our machine to reread `some-big-file.txt` on each iteration.
55 | With pnrp, we intelligently look at your code to see that line 6 is unaltered so we cache the computation and only run the lines that were changed.
56 |
57 |
58 | ## Feature Comparison
59 |
60 |
61 | | | Live Kernel | Graphics Support | Multi-file Reloading | Bring your own Editor | Normal Execution Order |
62 | |:----------------:|:------------:|:----------------:|:--------------------:|:---------------------:|:----------------------:|
63 | | REPL | X | | | | X |
64 | | Jupyter Notebook | X | X | | | |
65 | | PNRP | X | X | X | X | X |
66 |
67 |
68 | ## TODO
69 |
70 | - [ ] Multi-file / import support
71 | - [X] AST Based statement parsing
72 | - [X] AST Dependency based rerun
73 | - [ ] Explore [`sys.settrace`](https://docs.python.org/3/library/sys.html#sys.settrace) for code dependencies
74 | - [ ] Pretty printing
75 | - [ ] Server / Client model ?? (to better support interactivity, interrupts, etc.)
76 |
--------------------------------------------------------------------------------
/tests/test_ast_diff.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the ast diffing engine to tell us which statements should be executed.
3 | These tests are generally of the form,
4 |
5 | diff(curr_ast, next_ast) == statements to execute
6 |
7 | """
8 |
9 | import ast
10 | import inspect
11 | import pnrp
12 | import pytest
13 |
14 |
15 | def str2ast(x):
16 | return ast.parse(inspect.cleandoc(x)).body
17 |
18 |
19 | def ast2str(x):
20 | return ast.unparse(x)
21 |
22 |
23 | def test_nothing_to_run():
24 | curr_ast = str2ast("""
25 | a = 1
26 | """)
27 | next_ast = str2ast("""
28 | a = 1
29 | """)
30 |
31 | to_run = pnrp.exprs_to_run(curr_ast, next_ast)
32 |
33 | assert to_run == []
34 |
35 |
36 | def test_run_everything_after():
37 | curr_ast = str2ast("""
38 | a = 1
39 | """)
40 | next_ast = str2ast("""
41 | a = 1
42 | b = 2
43 | c = 3
44 | """)
45 |
46 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
47 |
48 | assert ast2str(exprs) == inspect.cleandoc("""
49 | b = 2
50 | c = 3
51 | """)
52 |
53 |
54 | def test_run_everything_after_function():
55 | curr_ast = str2ast("""
56 | a = 1
57 | """)
58 | next_ast = str2ast(
59 | """
60 | a = 1
61 | def foo(x):
62 | return x + 1
63 | foo(a)
64 | """
65 | )
66 |
67 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
68 |
69 | assert ast2str(exprs) == inspect.cleandoc(
70 | """
71 | def foo(x):
72 | return x + 1
73 | foo(a)
74 | """
75 | )
76 |
77 |
78 | def test_rerun_dependent_exprs():
79 | curr_ast = str2ast(
80 | """
81 | a = 1
82 | def foo(x):
83 | return x + 1
84 | foo(a)
85 | """
86 | )
87 | next_ast = str2ast(
88 | """
89 | a = 1
90 | def foo(x):
91 | return x + 2
92 | foo(a)
93 | """
94 | )
95 |
96 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
97 |
98 | assert ast2str(exprs) == inspect.cleandoc(
99 | """
100 | def foo(x):
101 | return x + 2
102 | foo(a)
103 | """
104 | )
105 |
106 |
107 | def test_rerun_dependent_exprs_assign():
108 | curr_ast = str2ast(
109 | """
110 | a = 1
111 | def foo(x):
112 | return x + 1
113 | foo(a)
114 | """
115 | )
116 | next_ast = str2ast(
117 | """
118 | a = 2
119 | def foo(x):
120 | return x + 1
121 | foo(a)
122 | """
123 | )
124 |
125 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
126 |
127 | assert ast2str(exprs) == inspect.cleandoc("""
128 | a = 2
129 | foo(a)
130 | """)
131 |
132 |
133 | def test_rerun_grand_dependent_exprs_assign():
134 | curr_ast = str2ast(
135 | """
136 | a = 1
137 | b = 2
138 | c = a + b
139 | d = c + 1
140 | e = b + 1
141 | """
142 | )
143 | next_ast = str2ast(
144 | """
145 | a = 2
146 | b = 2
147 | c = a + b
148 | d = c + 1
149 | e = b + 1
150 | """
151 | )
152 |
153 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
154 |
155 | assert ast2str(exprs) == inspect.cleandoc(
156 | """
157 | a = 2
158 | c = a + b
159 | d = c + 1
160 | """
161 | )
162 |
163 |
164 | def test_run_independent_new_exprs_only():
165 | curr_ast = str2ast("""
166 | a = 1
167 | """)
168 | next_ast = str2ast("""
169 | import time
170 | a = 1
171 | time.sleep(1)
172 | """)
173 |
174 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
175 |
176 | assert ast2str(exprs) == inspect.cleandoc("""
177 | import time
178 | time.sleep(1)
179 | """)
180 |
181 |
182 | def test_handles_all_assignments():
183 | curr_ast = str2ast("")
184 | next_ast = str2ast("""
185 | a = 1
186 | (a, b) = (2, 3)
187 | """)
188 |
189 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
190 |
191 | assert ast2str(exprs) == inspect.cleandoc("""
192 | a = 1
193 | (a, b) = (2, 3)
194 | """)
195 |
196 |
197 | def test_handles_print_delete_and_readd():
198 | curr_ast = str2ast("""
199 | a = 1
200 | print(a)
201 | """)
202 | next_ast = str2ast("""
203 | a = 2
204 | print(a)
205 | """)
206 |
207 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
208 |
209 | assert ast2str(exprs) == inspect.cleandoc("""
210 | a = 2
211 | print(a)
212 | """)
213 |
214 | curr_ast = next_ast
215 | next_ast = str2ast("""
216 | a = 2
217 | """)
218 |
219 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
220 |
221 | assert ast2str(exprs) == inspect.cleandoc("")
222 |
223 | curr_ast = next_ast
224 | next_ast = str2ast("""
225 | a = 2
226 | print(a)
227 | """)
228 |
229 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
230 |
231 | assert ast2str(exprs) == inspect.cleandoc("""
232 | print(a)
233 | """)
234 |
235 |
236 | def test_handles_aug_assign():
237 | curr_ast = str2ast("""
238 | a = 1
239 | print(a)
240 | """)
241 | next_ast = str2ast("""
242 | a = 1
243 | print(a)
244 | a += 2
245 | """)
246 |
247 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
248 |
249 | assert ast2str(exprs
250 | ) == inspect.cleandoc("""
251 | a = 1
252 | print(a)
253 | a += 2
254 | """)
255 |
256 |
257 | def test_handles_ann_assign():
258 | curr_ast = str2ast("""
259 | a = 1
260 | print(a)
261 | """)
262 | next_ast = str2ast("""
263 | a = 1
264 | print(a)
265 | a: int = 2
266 | """)
267 |
268 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
269 |
270 | assert ast2str(exprs) == inspect.cleandoc(
271 | """
272 | a = 1
273 | print(a)
274 | a: int = 2
275 | """
276 | )
277 |
278 |
279 | # We'll come back to this later, good test though
280 | @pytest.mark.skip
281 | def test_handles_overwriting_variables():
282 | curr_ast = str2ast("""
283 | a = 1
284 | print(a)
285 | """)
286 | next_ast = str2ast(
287 | """
288 | a = 1
289 | print(a)
290 | a = 2
291 | print(a + 1)
292 | """
293 | )
294 |
295 | exprs = pnrp.exprs_to_run(curr_ast, next_ast)
296 |
297 | assert ast2str(exprs) == inspect.cleandoc("""
298 | a = 2
299 | print(a + 1)
300 | """)
301 |
--------------------------------------------------------------------------------
/pnrp/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import os.path
3 | import sys
4 | import time
5 | import importlib
6 | import subprocess
7 | import multiprocessing
8 | import itertools
9 | import ast
10 | import traceback
11 | import difflib
12 |
13 | from typing import List
14 |
15 |
16 | class ANSI:
17 | BLACK = '\u001b[30m'
18 | RED = '\u001b[31m'
19 | GREEN = '\u001b[32m'
20 | YELLOW = '\u001b[33m'
21 | BLUE = '\u001b[34m'
22 | MAGENTA = '\u001b[35m'
23 | CYAN = '\u001b[36m'
24 | WHITE = '\u001b[37m'
25 | DIM = '\u001b[2m'
26 | RESET = '\u001b[0m'
27 |
28 |
29 | prompt = lambda *args: print(ANSI.DIM + '>', *args, ANSI.RESET)
30 | eprint = lambda *args: print(*args, file=sys.stderr)
31 | HOME_DIR = os.path.expanduser('~/.pnrp')
32 |
33 |
34 | def filewatcher(fpath):
35 | fullfpath = os.path.join(os.getcwd(), fpath)
36 | mtime = None
37 | while True:
38 | try:
39 | nmtime = os.stat(fullfpath).st_mtime
40 | except:
41 | continue
42 |
43 | if mtime != nmtime:
44 | mtime = nmtime
45 |
46 | if os.path.exists(fullfpath):
47 | yield fullfpath, open(fullfpath, 'r').read()
48 |
49 | time.sleep(0.5)
50 |
51 |
52 | def compare_ast(node1, node2):
53 | if type(node1) != type(node2):
54 | return False
55 | elif isinstance(node1, ast.AST):
56 | for kind, var in vars(node1).items():
57 | if kind not in ('lineno', 'col_offset', 'ctx'):
58 | var2 = vars(node2).get(kind)
59 | if not compare_ast(var, var2):
60 | return False
61 | return True
62 | elif isinstance(node1, list):
63 | if len(node1) != len(node2):
64 | return False
65 | for i in range(len(node1)):
66 | if not compare_ast(node1[i], node2[i]):
67 | return False
68 | return True
69 | else:
70 | return node1 == node2
71 |
72 |
73 | def exprs_to_run(curr_ast: List['Expression'], next_ast: List['Expression']):
74 | """Return the minimal list of statements to run to get the execution
75 | of curr_ast to match a clean execution of next_ast.
76 |
77 | For each expression that has changed, we see which global variables it changes via
78 | assignment, then recursively apply that logic to every unchanged statment that uses
79 | the changes variables.
80 |
81 | This culminates in a list of expressions that have been directly changed, or are
82 | affected by a changed expression.
83 |
84 | WARNING: This does not account for mutations or side effects. It encourages you
85 | to write simple code. Any side-effectful code should be wrapped in a function to
86 | scope it's side effects. Local mutation is okay!
87 |
88 | We will support an escape hatch to explicitly rerun side effectful code.
89 | """
90 | # Calculate the new or changed statements to be run
91 | changed_exprs = []
92 | curr_code_exprs = [ast.dump(x, include_attributes=False) for x in curr_ast]
93 | next_code_exprs = [ast.dump(x, include_attributes=False) for x in next_ast]
94 | diff = difflib.SequenceMatcher(a=curr_code_exprs, b=next_code_exprs)
95 | for tag, i1, i2, j1, j2 in diff.get_opcodes():
96 | if tag == 'replace':
97 | changed_exprs.append(next_ast[j1:j2])
98 | if tag == 'insert':
99 | changed_exprs.append(next_ast[j1:j2])
100 |
101 | changed_exprs = [x for slst in changed_exprs for x in slst]
102 |
103 | def extract_assignments(expr):
104 | """Given an expression, extract all the top-level variable reassignments
105 | that occur. A list of all assignment syntax can be found at
106 |
107 | https://docs.python.org/3/library/ast.html#abstract-grammar
108 | """
109 | if isinstance(expr, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
110 | return [expr.name]
111 | elif isinstance(expr, (ast.Assign,)):
112 | return [expr for sexpr in expr.targets for expr in extract_assignments(sexpr)]
113 | elif isinstance(expr, (ast.AugAssign, ast.AnnAssign)):
114 | return extract_assignments(expr.target)
115 | elif isinstance(expr, (ast.Name,)):
116 | return expr.id
117 | elif isinstance(expr, (ast.List, ast.Tuple)):
118 | return [expr for sexpr in expr.elts for expr in extract_assignments(sexpr)]
119 |
120 | return []
121 |
122 | # Extract the global variables that will change directly
123 | # as a result of the code changes
124 | changed_names = set()
125 | for expr in changed_exprs:
126 | changed_names |= set(extract_assignments(expr))
127 |
128 | # Select every expression that either directly changed itself
129 | # or, depends on a global variable that was changed, recursively.
130 | exprs = []
131 | for expr in next_ast:
132 | if expr in changed_exprs:
133 | exprs.append(expr)
134 | continue
135 |
136 | wexpr = ast.Module(body=[expr], type_ignores=[])
137 | co = compile(wexpr, '', 'exec')
138 | names = set(co.co_names)
139 | if names & changed_names:
140 | exprs.append(expr)
141 | changed_names |= set(extract_assignments(expr))
142 |
143 | return exprs
144 |
145 |
146 | def flush_runstate(run_state):
147 | """Write out the runstate so code editors can read it and
148 | present the results inline with the code"""
149 | with open(f'{HOME_DIR}/runstate', 'w') as f:
150 | f.write('\n'.join(run_state))
151 |
152 |
153 | def driver(fchanges, gvars=None, flush_runstate=flush_runstate):
154 | curr_ast = []
155 | gvars = {} if gvars is None else gvars
156 |
157 | for fpath, srccode in fchanges:
158 | modname, _ = fpath.rsplit('.', 1)
159 |
160 | # Parse new code, determine which statements to run
161 | try:
162 | next_ast = ast.parse(srccode).body
163 | except SyntaxError:
164 | traceback.print_exc()
165 | continue
166 |
167 | exprs = exprs_to_run(curr_ast, next_ast)
168 |
169 | # Print out the state of what needs to be run
170 | run_state = ['.'] * (srccode.count('\n') + 1)
171 | for expr in exprs:
172 | num_lines = 1 + (expr.end_lineno - expr.lineno)
173 | run_state[expr.lineno - 1:expr.end_lineno] = ['~'] * num_lines # Mark cahnged
174 |
175 | flush_runstate(run_state)
176 |
177 | # Run the changed expressions
178 | if not exprs:
179 | prompt('File saved, but nothing to run')
180 | curr_ast = next_ast
181 | else:
182 | prompt(fpath, 'Changed!')
183 |
184 | for expr in exprs:
185 | num_lines = 1 + (expr.end_lineno - expr.lineno)
186 | try:
187 | wexpr = ast.Module(body=[expr], type_ignores=[])
188 | code = compile(wexpr, modname, 'exec')
189 | run_state[expr.lineno - 1:expr.end_lineno] = ['>>'] * num_lines # Mark running
190 | flush_runstate(run_state)
191 | exec(code, gvars)
192 | run_state[expr.lineno - 1:expr.end_lineno] = ['~'] * num_lines # Mark complete
193 | except Exception:
194 | run_state[expr.lineno - 1:expr.end_lineno] = ['x'] * num_lines # Mark failed
195 | flush_runstate(run_state)
196 | traceback.print_exc()
197 | break
198 | else:
199 | curr_ast = next_ast
200 | run_state = ['.'] * (srccode.count('\n') + 1) # Mark all complete
201 | flush_runstate(run_state)
202 |
203 |
204 | def cli(fpath, args):
205 | del sys.argv[0]
206 | # Ensure we have a homedir setup
207 | os.makedirs(HOME_DIR, exist_ok=True)
208 | driver(filewatcher(fpath))
209 |
--------------------------------------------------------------------------------