composeui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
composeui/core/reload.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""``--reload`` for a desktop app: a supervisor runs the program in a child process.
|
|
2
|
+
|
|
3
|
+
On a change, the supervisor sends the app the files changed. The app saves its views'
|
|
4
|
+
state and its model's data (a study), runs the program's changed code again in place
|
|
5
|
+
(`rerun_program`), and opens the study in the new app, so its window stays as it was left.
|
|
6
|
+
When the app can't, e.g. ComposeUI itself changed, it exits instead, and the supervisor
|
|
7
|
+
runs the program again in a new child process, which opens the study.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import importlib
|
|
14
|
+
import importlib.abc
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import types
|
|
26
|
+
from contextvars import ContextVar
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from composeui.core.model import InvalidStateError
|
|
31
|
+
from composeui.core.study import open_study, save_study
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Generator, Iterator, Sequence
|
|
35
|
+
from types import FrameType
|
|
36
|
+
|
|
37
|
+
from composeui.core.model import Model
|
|
38
|
+
from composeui.core.view import MainView
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"acknowledge_changes",
|
|
42
|
+
"can_reload_in_place",
|
|
43
|
+
"has_change_events",
|
|
44
|
+
"is_reloaded",
|
|
45
|
+
"keep_rerun",
|
|
46
|
+
"load_reloaded_state",
|
|
47
|
+
"program_command",
|
|
48
|
+
"rerun_program",
|
|
49
|
+
"run_reloading",
|
|
50
|
+
"save_reloaded_state",
|
|
51
|
+
"take_changes",
|
|
52
|
+
"watch_changes",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
LOGGER = logging.getLogger("COMPOSEUI")
|
|
56
|
+
|
|
57
|
+
#: environment variable set in the child process, so it runs the app, not another
|
|
58
|
+
#: supervisor: the directory where the runs leave their state to the next
|
|
59
|
+
RELOADED_VARIABLE = "COMPOSEUI_RELOADED"
|
|
60
|
+
#: the files of that directory: the last study saved; what the study doesn't hold, its
|
|
61
|
+
#: file's path and the language shown (JSON); the files changed, one per line, which the
|
|
62
|
+
#: app renames once it takes them, and removes once it runs their code
|
|
63
|
+
_STUDY_NAME = "study"
|
|
64
|
+
_SESSION_NAME = "session.json"
|
|
65
|
+
_CHANGES_NAME = "changes"
|
|
66
|
+
_TAKEN_NAME = "taken"
|
|
67
|
+
#: seconds a running app has to take the files changed, or to exit, before it is
|
|
68
|
+
#: terminated; once taken, running their code may take as long as it does
|
|
69
|
+
_RELOAD_TIMEOUT = 5.0
|
|
70
|
+
#: seconds a terminated app has to exit before it is killed
|
|
71
|
+
_STOP_TIMEOUT = 5.0
|
|
72
|
+
#: ComposeUI's own package: its code is never imported again in a running app
|
|
73
|
+
_COMPOSEUI_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
74
|
+
#: set while `rerun_program` runs the program: the apps it ran, unrun
|
|
75
|
+
_RERUNS: ContextVar[list[Any] | None] = ContextVar("composeui_reruns", default=None)
|
|
76
|
+
#: seconds between two looks at the files, and at whether the app has exited
|
|
77
|
+
_STEP = 0.5
|
|
78
|
+
#: directories never watched: tools' caches and environments, not the app's code
|
|
79
|
+
_IGNORED_DIRECTORIES = frozenset({"__pycache__", "node_modules", "site-packages"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_reloaded() -> bool:
|
|
83
|
+
"""Tell whether this process is the app a ``--reload`` supervisor runs."""
|
|
84
|
+
return RELOADED_VARIABLE in os.environ
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _state_directory() -> Path:
|
|
88
|
+
return Path(os.environ[RELOADED_VARIABLE])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def take_changes() -> list[Path] | None:
|
|
92
|
+
"""Take the files changed, if the supervisor asks the app to run their code.
|
|
93
|
+
|
|
94
|
+
Taken, they tell the supervisor to wait for the app, until `acknowledge_changes`.
|
|
95
|
+
"""
|
|
96
|
+
directory = _state_directory()
|
|
97
|
+
try:
|
|
98
|
+
(directory / _CHANGES_NAME).replace(directory / _TAKEN_NAME)
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return None
|
|
101
|
+
text = (directory / _TAKEN_NAME).read_text()
|
|
102
|
+
return [Path(line) for line in text.splitlines()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def acknowledge_changes() -> None:
|
|
106
|
+
"""Tell the supervisor the app runs the changed code, so it keeps the app running."""
|
|
107
|
+
(_state_directory() / _TAKEN_NAME).unlink(missing_ok=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def can_reload_in_place(changed: Sequence[Path]) -> bool:
|
|
111
|
+
"""Tell whether the app can run the code of the files `changed` without a new process.
|
|
112
|
+
|
|
113
|
+
Not when ComposeUI's own code changed: the running app's objects keep its old classes.
|
|
114
|
+
"""
|
|
115
|
+
return not any(_COMPOSEUI_DIRECTORY in path.resolve().parents for path in changed)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _AppKept(BaseException): # not an error: the program's end
|
|
119
|
+
"""Ends the program `rerun_program` runs, at the app's ``run``, which it keeps."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def keep_rerun(run: object) -> None:
|
|
123
|
+
"""Keep `run`, an app ready to run, if `rerun_program` runs the program, and end it.
|
|
124
|
+
|
|
125
|
+
An app's ``run`` calls it before it runs. The program ends there, as when the app
|
|
126
|
+
runs, so the code after ``run`` doesn't run on a reload either.
|
|
127
|
+
"""
|
|
128
|
+
reruns = _RERUNS.get()
|
|
129
|
+
if reruns is not None:
|
|
130
|
+
reruns.append(run)
|
|
131
|
+
# not an `Exception`, which the program might catch
|
|
132
|
+
raise _AppKept
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_program_module(module: object) -> bool:
|
|
136
|
+
"""Tell whether `module` is the program's own code: under the directory, not ComposeUI's."""
|
|
137
|
+
file = getattr(module, "__file__", None)
|
|
138
|
+
if file is None:
|
|
139
|
+
return False
|
|
140
|
+
path = Path(file).resolve()
|
|
141
|
+
return _is_program_file(path, Path.cwd().resolve()) and _COMPOSEUI_DIRECTORY not in path.parents
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rerun_program() -> Any:
|
|
145
|
+
"""Run the program again in this process, its code imported afresh; return its app.
|
|
146
|
+
|
|
147
|
+
The modules of its code (`_is_program_module`) are imported again. The app the
|
|
148
|
+
program runs is kept, unrun (`keep_rerun`). On an error, e.g. a syntax error, or
|
|
149
|
+
when no app is kept, the program's previous modules come back and the error is raised.
|
|
150
|
+
"""
|
|
151
|
+
previous = {
|
|
152
|
+
name: module
|
|
153
|
+
for name, module in sys.modules.items()
|
|
154
|
+
if name == "__main__" or _is_program_module(module)
|
|
155
|
+
}
|
|
156
|
+
main = previous["__main__"]
|
|
157
|
+
for name in previous:
|
|
158
|
+
del sys.modules[name]
|
|
159
|
+
reruns: list[Any] = []
|
|
160
|
+
token = _RERUNS.set(reruns)
|
|
161
|
+
# an empty cache of compiled code: Python checks a module's by its source's size and
|
|
162
|
+
# modification time in seconds, so an edit keeping the size within that second would
|
|
163
|
+
# run the previous code
|
|
164
|
+
pycache_prefix = sys.pycache_prefix
|
|
165
|
+
importlib.invalidate_caches() # the directories' listings, e.g. for a new module
|
|
166
|
+
try:
|
|
167
|
+
with tempfile.TemporaryDirectory(prefix="composeui-pycache-") as cache:
|
|
168
|
+
sys.pycache_prefix = cache
|
|
169
|
+
_run_as_main(main)
|
|
170
|
+
except _AppKept:
|
|
171
|
+
pass
|
|
172
|
+
except SystemExit as error:
|
|
173
|
+
sys.modules.update(previous)
|
|
174
|
+
msg = "the program exited instead of running an app"
|
|
175
|
+
raise RuntimeError(msg) from error
|
|
176
|
+
except BaseException:
|
|
177
|
+
sys.modules.update(previous)
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
sys.pycache_prefix = pycache_prefix
|
|
181
|
+
_RERUNS.reset(token)
|
|
182
|
+
if len(reruns) == 0:
|
|
183
|
+
sys.modules.update(previous)
|
|
184
|
+
msg = "the program didn't run an app"
|
|
185
|
+
raise RuntimeError(msg)
|
|
186
|
+
return reruns[0]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_as_main(previous_main: types.ModuleType) -> None:
|
|
190
|
+
"""Run the program that `previous_main` ran, its code read afresh, as a new ``__main__``."""
|
|
191
|
+
spec = getattr(previous_main, "__spec__", None)
|
|
192
|
+
if spec is not None: # `python -m package.module`: found again, its package imported anew
|
|
193
|
+
spec = importlib.util.find_spec(spec.name)
|
|
194
|
+
elif getattr(previous_main, "__file__", None) is not None: # `python script.py`
|
|
195
|
+
spec = importlib.util.spec_from_file_location("__main__", previous_main.__file__)
|
|
196
|
+
if spec is None or not isinstance(spec.loader, importlib.abc.InspectLoader):
|
|
197
|
+
msg = "can't find the program to run it again"
|
|
198
|
+
raise RuntimeError(msg)
|
|
199
|
+
code = spec.loader.get_code(spec.name)
|
|
200
|
+
# none only for a compiled extension, which Python can't start as a program
|
|
201
|
+
assert code is not None
|
|
202
|
+
# as `__main__` for good, as the program's classes resolve their string annotations
|
|
203
|
+
# through `sys.modules[cls.__module__]`
|
|
204
|
+
program = types.ModuleType("__main__")
|
|
205
|
+
program.__spec__ = spec
|
|
206
|
+
program.__file__ = spec.origin
|
|
207
|
+
program.__loader__ = spec.loader
|
|
208
|
+
program.__package__ = spec.parent
|
|
209
|
+
sys.modules["__main__"] = program
|
|
210
|
+
exec(code, program.__dict__) # noqa: S102
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def save_reloaded_state(
|
|
214
|
+
main_view: MainView, model: Model | None, directory: Path | None = None
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Save the views' state and the model's data, for the next run to open.
|
|
217
|
+
|
|
218
|
+
Into `directory`, an existing one, else the one the supervisor shares with the app.
|
|
219
|
+
"""
|
|
220
|
+
directory = _state_directory() if directory is None else directory
|
|
221
|
+
state_filepath = main_view.state_filepath
|
|
222
|
+
save_study(main_view, model, directory / _STUDY_NAME)
|
|
223
|
+
# the user's study is still the file they opened, not this scratch one
|
|
224
|
+
main_view.state_filepath = state_filepath
|
|
225
|
+
session = {
|
|
226
|
+
"state_filepath": None if state_filepath is None else str(state_filepath),
|
|
227
|
+
"language": main_view.language,
|
|
228
|
+
}
|
|
229
|
+
(directory / _SESSION_NAME).write_text(json.dumps(session))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_reloaded_state(
|
|
233
|
+
main_view: MainView, model: Model | None, directory: Path | None = None
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Open the state the previous run saved, if any and if it still fits the app.
|
|
236
|
+
|
|
237
|
+
From `directory`, else the one the supervisor shares with the app.
|
|
238
|
+
"""
|
|
239
|
+
directory = _state_directory() if directory is None else directory
|
|
240
|
+
if not (directory / _STUDY_NAME).exists():
|
|
241
|
+
return
|
|
242
|
+
session = _read_session(directory / _SESSION_NAME)
|
|
243
|
+
language = main_view.language
|
|
244
|
+
# first: the study's text was saved in it
|
|
245
|
+
saved_language = session.get("language")
|
|
246
|
+
if isinstance(saved_language, str) and saved_language != language:
|
|
247
|
+
main_view.set_language(saved_language)
|
|
248
|
+
try:
|
|
249
|
+
open_study(main_view, model, directory / _STUDY_NAME)
|
|
250
|
+
except InvalidStateError as error:
|
|
251
|
+
LOGGER.warning("The state of the previous run is dropped: %s", error)
|
|
252
|
+
if main_view.language != language:
|
|
253
|
+
main_view.set_language(language)
|
|
254
|
+
return
|
|
255
|
+
state_filepath = session.get("state_filepath")
|
|
256
|
+
main_view.state_filepath = Path(state_filepath) if isinstance(state_filepath, str) else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _read_session(path: Path) -> dict[str, Any]:
|
|
260
|
+
"""Read what the study doesn't hold; nothing if it's missing or damaged, e.g. by a crash."""
|
|
261
|
+
try:
|
|
262
|
+
session = json.loads(path.read_text())
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
return {}
|
|
265
|
+
return session if isinstance(session, dict) else {}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def program_command() -> list[str]:
|
|
269
|
+
"""Build the command running this program again: the script, or the ``-m`` module."""
|
|
270
|
+
main = sys.modules["__main__"]
|
|
271
|
+
spec = getattr(main, "__spec__", None)
|
|
272
|
+
if spec is not None: # `python -m package.module`
|
|
273
|
+
return [sys.executable, "-m", spec.name, *sys.argv[1:]]
|
|
274
|
+
path = getattr(main, "__file__", None)
|
|
275
|
+
if path is not None: # `python script.py`
|
|
276
|
+
return [sys.executable, str(Path(path).resolve()), *sys.argv[1:]]
|
|
277
|
+
msg = "--reload runs the program again: start it as a script or with -m"
|
|
278
|
+
raise ValueError(msg)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _environments(directory: Path) -> set[Path]:
|
|
282
|
+
"""Get the running Python's directories under `directory`, e.g. a conda environment's.
|
|
283
|
+
|
|
284
|
+
Its standard library may be there, outside a ``site-packages``.
|
|
285
|
+
"""
|
|
286
|
+
prefixes = {sys.prefix, sys.base_prefix, sys.exec_prefix, sys.base_exec_prefix}
|
|
287
|
+
resolved = {Path(prefix).resolve() for prefix in prefixes}
|
|
288
|
+
return {prefix for prefix in resolved if directory in prefix.parents}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_ignored_directory(name: str) -> bool:
|
|
292
|
+
return name.startswith(".") or name in _IGNORED_DIRECTORIES
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _is_program_file(path: Path, directory: Path) -> bool:
|
|
296
|
+
"""Tell whether `path` is a Python file of the program's code, both paths resolved.
|
|
297
|
+
|
|
298
|
+
Under `directory`, not in a hidden or ignored directory, nor in a Python environment.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
relative = path.relative_to(directory)
|
|
302
|
+
except ValueError:
|
|
303
|
+
return False
|
|
304
|
+
return (
|
|
305
|
+
path.suffix == ".py"
|
|
306
|
+
and not any(_is_ignored_directory(part) for part in relative.parts[:-1])
|
|
307
|
+
and not any(environment in path.parents for environment in _environments(directory))
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _modification_times(directory: Path) -> dict[Path, int]:
|
|
312
|
+
times: dict[Path, int] = {}
|
|
313
|
+
environments = _environments(directory)
|
|
314
|
+
for root, directories, files in os.walk(directory):
|
|
315
|
+
# pruned, not walked and filtered: a virtual environment holds thousands of files
|
|
316
|
+
directories[:] = [
|
|
317
|
+
name
|
|
318
|
+
for name in directories
|
|
319
|
+
if not _is_ignored_directory(name) and Path(root, name) not in environments
|
|
320
|
+
]
|
|
321
|
+
for name in files:
|
|
322
|
+
if name.endswith(".py"):
|
|
323
|
+
path = Path(root, name)
|
|
324
|
+
with contextlib.suppress(OSError): # removed while walking
|
|
325
|
+
times[path] = path.stat().st_mtime_ns
|
|
326
|
+
return times
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def has_change_events(directory: Path) -> bool:
|
|
330
|
+
"""Tell whether the system reports the changes under `directory`.
|
|
331
|
+
|
|
332
|
+
On WSL, it doesn't for a Windows drive's files, under ``/mnt``: they're polled.
|
|
333
|
+
"""
|
|
334
|
+
uname = platform.uname()
|
|
335
|
+
is_wsl = uname.system == "Linux" and "microsoft" in uname.release.lower()
|
|
336
|
+
return not (is_wsl and Path("/mnt") in directory.resolve().parents)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _poll_changes(directory: Path, before: dict[Path, int]) -> Generator[set[Path], None, None]:
|
|
340
|
+
"""Yield the Python files changed under `directory` every step, often none."""
|
|
341
|
+
while True:
|
|
342
|
+
time.sleep(_STEP)
|
|
343
|
+
after = _modification_times(directory)
|
|
344
|
+
yield {path for path in before.keys() | after.keys() if before.get(path) != after.get(path)}
|
|
345
|
+
before = after
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _filtered_changes(
|
|
349
|
+
directory: Path, batches: Generator[set[tuple[Any, str]], None, None]
|
|
350
|
+
) -> Generator[set[Path], None, None]:
|
|
351
|
+
"""Yield the Python files changed under `directory` in each batch of watchfiles'."""
|
|
352
|
+
try:
|
|
353
|
+
for batch in batches:
|
|
354
|
+
changed = {Path(path) for _, path in batch}
|
|
355
|
+
yield {path for path in changed if _is_program_file(path, directory)}
|
|
356
|
+
finally:
|
|
357
|
+
batches.close()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def watch_changes(directory: Path) -> Generator[set[Path], None, None]:
|
|
361
|
+
"""Watch the Python files under `directory` from now on, and yield those changed.
|
|
362
|
+
|
|
363
|
+
The generator yields every step, often none, the files' paths resolved. With
|
|
364
|
+
watchfiles installed, the system tells about changes; else, or when it can't, the files
|
|
365
|
+
are polled. Close it to stop watching.
|
|
366
|
+
"""
|
|
367
|
+
# the paths reported are the real ones, e.g. under a symbolic link's target
|
|
368
|
+
directory = directory.resolve()
|
|
369
|
+
try:
|
|
370
|
+
from watchfiles import PythonFilter, watch # noqa: PLC0415
|
|
371
|
+
except ImportError:
|
|
372
|
+
return _poll_changes(directory, _modification_times(directory))
|
|
373
|
+
if not has_change_events(directory):
|
|
374
|
+
return _poll_changes(directory, _modification_times(directory))
|
|
375
|
+
batches = watch(
|
|
376
|
+
directory,
|
|
377
|
+
watch_filter=PythonFilter(),
|
|
378
|
+
rust_timeout=int(_STEP * 1000),
|
|
379
|
+
yield_on_timeout=True,
|
|
380
|
+
# watchfiles polls anywhere on WSL by default, and misses changes so
|
|
381
|
+
force_polling=False,
|
|
382
|
+
)
|
|
383
|
+
next(batches) # set up: the changes from now on are kept until asked for
|
|
384
|
+
return _filtered_changes(directory, batches)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def run_reloading(
|
|
388
|
+
command: Sequence[str] | None = None, changes: Iterator[set[Path]] | None = None
|
|
389
|
+
) -> int:
|
|
390
|
+
"""Run `command`, the program by default, and run it again whenever a Python file changes.
|
|
391
|
+
|
|
392
|
+
It returns once the app exits by itself without an error, e.g. its window is closed.
|
|
393
|
+
An app that fails, e.g. on a syntax error, runs again only on the next change. On a
|
|
394
|
+
change, the running app is asked to run the changed code (`take_changes`); if it exits
|
|
395
|
+
instead, or doesn't take the files changed, it runs again in a new process.
|
|
396
|
+
"""
|
|
397
|
+
command = program_command() if command is None else list(command)
|
|
398
|
+
directory = Path.cwd().resolve()
|
|
399
|
+
changes = watch_changes(directory) if changes is None else changes
|
|
400
|
+
LOGGER.info("Watching the Python files under %s", directory)
|
|
401
|
+
# a supervisor told to stop, e.g. by `kill`, stops its app too, which would else go on
|
|
402
|
+
previous_handler = signal.signal(signal.SIGTERM, _exit)
|
|
403
|
+
child: subprocess.Popen[bytes] | None = None
|
|
404
|
+
try:
|
|
405
|
+
with tempfile.TemporaryDirectory(prefix="composeui-reload-") as state_directory:
|
|
406
|
+
env = {**os.environ, RELOADED_VARIABLE: state_directory}
|
|
407
|
+
child = subprocess.Popen(command, env=env) # noqa: S603
|
|
408
|
+
for changed in changes:
|
|
409
|
+
if len(changed) > 0:
|
|
410
|
+
in_place = _reload_in_place(child, changed, Path(state_directory))
|
|
411
|
+
if not in_place:
|
|
412
|
+
_stop(child)
|
|
413
|
+
# once the app exited, if it did: a terminal app draws over the terminal
|
|
414
|
+
names = ", ".join(sorted(os.path.relpath(p, directory) for p in changed))
|
|
415
|
+
LOGGER.info("Reloading: %s changed", names)
|
|
416
|
+
if not in_place:
|
|
417
|
+
child = subprocess.Popen(command, env=env) # noqa: S603
|
|
418
|
+
else:
|
|
419
|
+
code = None if child is None else child.poll()
|
|
420
|
+
if code == 0:
|
|
421
|
+
return 0
|
|
422
|
+
if code is not None:
|
|
423
|
+
LOGGER.info("The app exited with code %s: waiting for a change", code)
|
|
424
|
+
child = None
|
|
425
|
+
except KeyboardInterrupt:
|
|
426
|
+
pass
|
|
427
|
+
finally:
|
|
428
|
+
_stop(child)
|
|
429
|
+
if isinstance(changes, types.GeneratorType):
|
|
430
|
+
changes.close()
|
|
431
|
+
signal.signal(signal.SIGTERM, previous_handler)
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _exit(signum: int, _frame: FrameType | None) -> None:
|
|
436
|
+
raise SystemExit(128 + signum) # posix only: Windows' terminate sends no signal
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _reload_in_place(
|
|
440
|
+
child: subprocess.Popen[bytes] | None, changed: set[Path], state_directory: Path
|
|
441
|
+
) -> bool:
|
|
442
|
+
"""Ask `child` to run the code of the files `changed`; tell whether it did, still running."""
|
|
443
|
+
if child is None or child.poll() is not None:
|
|
444
|
+
return False
|
|
445
|
+
request = state_directory / _CHANGES_NAME
|
|
446
|
+
taken = state_directory / _TAKEN_NAME
|
|
447
|
+
# written whole, then renamed: the app never reads half the list
|
|
448
|
+
partial = state_directory / f".{_CHANGES_NAME}.partial"
|
|
449
|
+
partial.write_text("".join(f"{path}\n" for path in sorted(changed)))
|
|
450
|
+
partial.replace(request)
|
|
451
|
+
deadline = time.monotonic() + _RELOAD_TIMEOUT
|
|
452
|
+
try:
|
|
453
|
+
while True:
|
|
454
|
+
if child.poll() is not None: # it saved its state and exited for a new process
|
|
455
|
+
return False
|
|
456
|
+
if not request.exists() and not taken.exists():
|
|
457
|
+
return True
|
|
458
|
+
if request.exists() and time.monotonic() > deadline:
|
|
459
|
+
LOGGER.warning("The app didn't answer: it runs again, its state since then lost")
|
|
460
|
+
return False
|
|
461
|
+
time.sleep(0.05)
|
|
462
|
+
finally:
|
|
463
|
+
# none left for the next app, whichever runs
|
|
464
|
+
request.unlink(missing_ok=True)
|
|
465
|
+
taken.unlink(missing_ok=True)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _stop(child: subprocess.Popen[bytes] | None) -> None:
|
|
469
|
+
if child is None or child.poll() is not None:
|
|
470
|
+
return
|
|
471
|
+
child.terminate()
|
|
472
|
+
try:
|
|
473
|
+
child.wait(timeout=_STOP_TIMEOUT)
|
|
474
|
+
except subprocess.TimeoutExpired: # posix only: Windows' terminate can't be ignored
|
|
475
|
+
child.kill()
|
|
476
|
+
child.wait()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""The base classes of the renderers, which show a view with a widget of a toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from typing_extensions import Protocol
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable, Generator, Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MainRenderer",
|
|
17
|
+
"MissingRefreshAttributeFunctionError",
|
|
18
|
+
"NoRenderedObjectError",
|
|
19
|
+
"Renderer",
|
|
20
|
+
"RendererContainer",
|
|
21
|
+
"ViewLike",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ViewLike(Protocol):
|
|
26
|
+
"""What a renderer needs of every view: whether it is visible and enabled."""
|
|
27
|
+
|
|
28
|
+
is_visible: bool
|
|
29
|
+
is_enabled: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TView = TypeVar("TView", bound=ViewLike)
|
|
33
|
+
TObject = TypeVar("TObject")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NoRenderedObjectError(Exception):
|
|
37
|
+
"""A renderer used before `Renderer.render` built its widget."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__(
|
|
41
|
+
"Cannot access rendered object because nothing has been rendered yet. "
|
|
42
|
+
"Call `render()` first."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MissingRefreshAttributeFunctionError(Exception):
|
|
47
|
+
"""A changed field of a view that its renderer has no handler for."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, renderer: Renderer[Any, Any], attr_name: str) -> None:
|
|
50
|
+
super().__init__(
|
|
51
|
+
f"The attribute {attr_name} don't have a corresponding "
|
|
52
|
+
f"refresh function in {type(renderer)}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Renderer(ABC, Generic[TView, TObject]):
|
|
57
|
+
"""The base of a backend's renderers: one shows a view with a widget of its toolkit.
|
|
58
|
+
|
|
59
|
+
`render` builds the widget. `refresh_attribute` shows a new value of a field of the view,
|
|
60
|
+
with the handler of `_refresh_dispatcher` for that field. The renderer sets the view's
|
|
61
|
+
fields from the widget's signals, inside `stop_refresh`, so the view doesn't show them
|
|
62
|
+
back.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, view: TView) -> None:
|
|
66
|
+
self._view = view
|
|
67
|
+
self._rendered_object: TObject | None = None
|
|
68
|
+
self._refresh_dispatcher: dict[str, Callable[[TView, TObject, Any], None]] = {}
|
|
69
|
+
self._is_updating_state = False
|
|
70
|
+
|
|
71
|
+
def has_attr(self, attr_name: str) -> bool:
|
|
72
|
+
"""Check if the attribute has an associated refresh function."""
|
|
73
|
+
return attr_name in self._refresh_dispatcher
|
|
74
|
+
|
|
75
|
+
def is_updating_state(self) -> bool:
|
|
76
|
+
return self._is_updating_state
|
|
77
|
+
|
|
78
|
+
def call_on_main_thread(self, callback: Callable[[], None]) -> None:
|
|
79
|
+
"""Invoke `callback` on the thread that owns the GUI event loop.
|
|
80
|
+
|
|
81
|
+
Default: no real thread affinity to worry about outside a GUI toolkit, so
|
|
82
|
+
just call it directly. Toolkit-bound renderers (e.g. Qt, whose widgets can
|
|
83
|
+
only be touched from the thread that created them) must override this to
|
|
84
|
+
marshal the call onto their main thread instead.
|
|
85
|
+
"""
|
|
86
|
+
callback()
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def render(self) -> None:
|
|
90
|
+
"""Build the widget that shows the view."""
|
|
91
|
+
|
|
92
|
+
def refresh(self) -> None:
|
|
93
|
+
"""Show every field of the view that has a handler in `_refresh_dispatcher`."""
|
|
94
|
+
if self._rendered_object is None:
|
|
95
|
+
raise NoRenderedObjectError
|
|
96
|
+
for attr_name in self._refresh_dispatcher:
|
|
97
|
+
if not hasattr(self._view, attr_name):
|
|
98
|
+
msg = f"{attr_name} is not an attribute of the view {type(self._view).__name__}"
|
|
99
|
+
raise AttributeError(msg)
|
|
100
|
+
refresh = self._refresh_dispatcher[attr_name]
|
|
101
|
+
value = getattr(self._view, attr_name)
|
|
102
|
+
refresh(self._view, self._rendered_object, value)
|
|
103
|
+
|
|
104
|
+
def refresh_attribute(self, attr_name: str, value: Any) -> None:
|
|
105
|
+
"""Show `value`, the new value of the view's field `attr_name`.
|
|
106
|
+
|
|
107
|
+
Raises `MissingRefreshAttributeFunctionError` when no handler shows the field.
|
|
108
|
+
"""
|
|
109
|
+
if self._rendered_object is None:
|
|
110
|
+
raise NoRenderedObjectError
|
|
111
|
+
if hasattr(self._view, attr_name):
|
|
112
|
+
if attr_name in self._refresh_dispatcher:
|
|
113
|
+
refresh = self._refresh_dispatcher[attr_name]
|
|
114
|
+
refresh(self._view, self._rendered_object, value)
|
|
115
|
+
else:
|
|
116
|
+
raise MissingRefreshAttributeFunctionError(self, attr_name)
|
|
117
|
+
else:
|
|
118
|
+
msg = f"{attr_name} is not an attribute of the view {type(self._view)}"
|
|
119
|
+
raise AttributeError(msg)
|
|
120
|
+
|
|
121
|
+
@contextlib.contextmanager
|
|
122
|
+
def stop_refresh(self) -> Generator[None]:
|
|
123
|
+
self._is_updating_state = True
|
|
124
|
+
try:
|
|
125
|
+
yield
|
|
126
|
+
finally:
|
|
127
|
+
self._is_updating_state = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MainRenderer(Renderer[TView, TObject]):
|
|
131
|
+
"""A root-window renderer: the app-level operations a `MainView` relies on.
|
|
132
|
+
|
|
133
|
+
Lets `MainView` talk to whichever backend rendered it through `isinstance`
|
|
134
|
+
checks against this class alone, without importing any backend package.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
#: whether the app's window is drawn on the main thread, which long work there
|
|
138
|
+
#: freezes (Qt, Textual): a view then runs it in a job. The web's requests have
|
|
139
|
+
#: threads of their own
|
|
140
|
+
draws_on_main_thread: ClassVar[bool] = True
|
|
141
|
+
|
|
142
|
+
@abstractmethod
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
"""Close the application."""
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
def open_file(self) -> Path | None:
|
|
148
|
+
"""Ask the user for an existing file path, blocking until answered."""
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def save_file(self) -> Path | None:
|
|
152
|
+
"""Ask the user for a file path to save to, blocking until answered."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class RendererContainer(Generic[TView]):
|
|
156
|
+
"""The renderers of a view shown by several widgets, e.g. a form row's label and field."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, view: TView, renderers: Mapping[str, Renderer[TView, Any]]) -> None:
|
|
159
|
+
self._view = view
|
|
160
|
+
self._renderers = dict(renderers)
|
|
161
|
+
|
|
162
|
+
def is_updating_state(self) -> bool:
|
|
163
|
+
return any(r.is_updating_state() for r in self._renderers.values())
|
|
164
|
+
|
|
165
|
+
def call_on_main_thread(self, callback: Callable[[], None]) -> None:
|
|
166
|
+
"""Invoke `callback` on the thread that owns the GUI event loop.
|
|
167
|
+
|
|
168
|
+
Delegates to one of its renderers: they all share the same toolkit and thread.
|
|
169
|
+
"""
|
|
170
|
+
next(iter(self._renderers.values())).call_on_main_thread(callback)
|
|
171
|
+
|
|
172
|
+
def get_renderer(self, name: str) -> Renderer[TView, Any]:
|
|
173
|
+
return self._renderers[name]
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def renderers(self) -> list[Renderer[TView, Any]]:
|
|
177
|
+
return list(self._renderers.values())
|
|
178
|
+
|
|
179
|
+
def render(self) -> None:
|
|
180
|
+
"""Build the widgets of every renderer."""
|
|
181
|
+
for renderer in self._renderers.values():
|
|
182
|
+
renderer.render()
|
|
183
|
+
|
|
184
|
+
def refresh(self) -> None:
|
|
185
|
+
"""Show every field of the view that a renderer has a handler for."""
|
|
186
|
+
for renderer in self._renderers.values():
|
|
187
|
+
renderer.refresh()
|
|
188
|
+
|
|
189
|
+
def refresh_attribute(self, attr_name: str, value: Any) -> None:
|
|
190
|
+
"""Show `value` with each renderer that has a handler for the field `attr_name`."""
|
|
191
|
+
for renderer in self._renderers.values():
|
|
192
|
+
if renderer.has_attr(attr_name):
|
|
193
|
+
renderer.refresh_attribute(attr_name, value)
|