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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -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)