dash-devtools-plus 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.
- dash_devtools_plus/__init__.py +11 -0
- dash_devtools_plus/assets/dash_devtools_plus.css +2 -0
- dash_devtools_plus/assets/dash_devtools_plus.js +428 -0
- dash_devtools_plus/hook_inventory.py +366 -0
- dash_devtools_plus/plugin.py +632 -0
- dash_devtools_plus/server_metrics.py +79 -0
- dash_devtools_plus-0.1.0.dist-info/METADATA +315 -0
- dash_devtools_plus-0.1.0.dist-info/RECORD +12 -0
- dash_devtools_plus-0.1.0.dist-info/WHEEL +5 -0
- dash_devtools_plus-0.1.0.dist-info/entry_points.txt +2 -0
- dash_devtools_plus-0.1.0.dist-info/licenses/LICENSE +21 -0
- dash_devtools_plus-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Dash hooks registration for Dash Devtools Plus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from functools import lru_cache, wraps
|
|
10
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import Any, Optional, Union
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
from weakref import WeakKeyDictionary, ref
|
|
15
|
+
|
|
16
|
+
from dash import get_app, hooks
|
|
17
|
+
|
|
18
|
+
from .hook_inventory import build_hook_inventory, capture_app_hook_snapshot
|
|
19
|
+
from .server_metrics import collect_server_metrics
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_LOCK = Lock()
|
|
23
|
+
_REGISTERED = False
|
|
24
|
+
_DEBUG_STATE_LOCK = Lock()
|
|
25
|
+
_APP_DEBUG_STATE: WeakKeyDictionary[Any, bool] = WeakKeyDictionary()
|
|
26
|
+
_CLIENTSIDE_SOURCE_LOCK = Lock()
|
|
27
|
+
_APP_CLIENTSIDE_SOURCES: WeakKeyDictionary[Any, dict[str, dict[str, Any]]] = (
|
|
28
|
+
WeakKeyDictionary()
|
|
29
|
+
)
|
|
30
|
+
_CONFIG: dict[str, Any] = {
|
|
31
|
+
"defaultLocale": "en",
|
|
32
|
+
"accentColor": "#119DFF",
|
|
33
|
+
}
|
|
34
|
+
_CALLBACKS_ROUTE = "_dash-devtools-plus/callbacks"
|
|
35
|
+
_COMPONENT_LIBRARIES_ROUTE = "_dash-devtools-plus/component-libraries"
|
|
36
|
+
_HOOK_LIBRARIES_ROUTE = "_dash-devtools-plus/hook-libraries"
|
|
37
|
+
_SERVER_METRICS_ROUTE = "_dash-devtools-plus/server-metrics"
|
|
38
|
+
_COMPONENT_LIBRARY_SIGNATURE = frozenset({"_component", "_dash", "_js_dist"})
|
|
39
|
+
_PROJECT_MARKERS = ("pyproject.toml", "setup.py", "setup.cfg", ".git")
|
|
40
|
+
_EDITOR_ORDER = ("vscode", "cursor", "pycharm")
|
|
41
|
+
_EDITOR_LABELS = {
|
|
42
|
+
"vscode": "VS Code",
|
|
43
|
+
"cursor": "Cursor",
|
|
44
|
+
"pycharm": "PyCharm",
|
|
45
|
+
}
|
|
46
|
+
_EDITOR_COMMANDS = {
|
|
47
|
+
"vscode": ("code",),
|
|
48
|
+
"cursor": ("cursor",),
|
|
49
|
+
"pycharm": ("pycharm", "pycharm-professional", "pycharm-community"),
|
|
50
|
+
}
|
|
51
|
+
_SUPPORTED_EDITORS = frozenset(_EDITOR_LABELS)
|
|
52
|
+
_EDITOR: Optional[str] = "vscode"
|
|
53
|
+
_PROJECT_ROOT: Optional[Path] = None
|
|
54
|
+
_EDITOR_PROJECT_ROOT: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configure_devtools_plus(
|
|
58
|
+
*,
|
|
59
|
+
default_locale: str = "en",
|
|
60
|
+
accent_color: str = "#119DFF",
|
|
61
|
+
editor: Optional[str] = "vscode",
|
|
62
|
+
project_root: Optional[Union[str, Path]] = None,
|
|
63
|
+
editor_project_root: Optional[Union[str, Path]] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Configure Devtools Plus before constructing the :class:`dash.Dash` app.
|
|
66
|
+
|
|
67
|
+
Parameters are deliberately small and serialisable because Dash passes them
|
|
68
|
+
directly to the custom React component.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
global _EDITOR, _PROJECT_ROOT, _EDITOR_PROJECT_ROOT
|
|
72
|
+
|
|
73
|
+
if default_locale not in {"en", "zh-CN"}:
|
|
74
|
+
raise ValueError("default_locale must be 'en' or 'zh-CN'")
|
|
75
|
+
if editor not in {*_SUPPORTED_EDITORS, None}:
|
|
76
|
+
supported = ", ".join(repr(name) for name in _EDITOR_ORDER)
|
|
77
|
+
raise ValueError(f"editor must be one of {supported}, or None")
|
|
78
|
+
|
|
79
|
+
resolved_project_root = None
|
|
80
|
+
if project_root is not None:
|
|
81
|
+
resolved_project_root = Path(project_root).expanduser().resolve()
|
|
82
|
+
if not resolved_project_root.is_dir():
|
|
83
|
+
raise ValueError("project_root must point to an existing directory")
|
|
84
|
+
|
|
85
|
+
client_root = None
|
|
86
|
+
if editor_project_root is not None:
|
|
87
|
+
client_root = str(editor_project_root).strip()
|
|
88
|
+
if not client_root:
|
|
89
|
+
raise ValueError("editor_project_root must not be empty")
|
|
90
|
+
|
|
91
|
+
_EDITOR = editor
|
|
92
|
+
_PROJECT_ROOT = resolved_project_root
|
|
93
|
+
_EDITOR_PROJECT_ROOT = client_root
|
|
94
|
+
_CONFIG.update(
|
|
95
|
+
defaultLocale=default_locale,
|
|
96
|
+
accentColor=accent_color,
|
|
97
|
+
)
|
|
98
|
+
register()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _component_props() -> dict[str, Any]:
|
|
102
|
+
try:
|
|
103
|
+
app = get_app()
|
|
104
|
+
except Exception: # Dash can call props outside an active app context.
|
|
105
|
+
app = None
|
|
106
|
+
|
|
107
|
+
return _component_props_for(app)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _component_props_for(app: Any | None) -> dict[str, Any]:
|
|
111
|
+
"""Build props for one app so multi-app processes cannot share debug state."""
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
**_CONFIG,
|
|
115
|
+
"enabled": bool(app is not None and _devtools_enabled(app)),
|
|
116
|
+
"callbacksEndpoint": _CALLBACKS_ROUTE,
|
|
117
|
+
"componentLibrariesEndpoint": _COMPONENT_LIBRARIES_ROUTE,
|
|
118
|
+
"hookLibrariesEndpoint": _HOOK_LIBRARIES_ROUTE,
|
|
119
|
+
"serverMetricsEndpoint": _SERVER_METRICS_ROUTE,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _set_debug_state(app: Any, enabled: bool) -> None:
|
|
124
|
+
"""Remember the explicit ``debug`` value resolved by Dash for one app."""
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with _DEBUG_STATE_LOCK:
|
|
128
|
+
_APP_DEBUG_STATE[app] = bool(enabled)
|
|
129
|
+
except TypeError:
|
|
130
|
+
# Keep compatibility if a future Dash app becomes non-weakrefable.
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tracked_debug_state(app: Any) -> bool:
|
|
135
|
+
try:
|
|
136
|
+
with _DEBUG_STATE_LOCK:
|
|
137
|
+
return bool(_APP_DEBUG_STATE.get(app, False))
|
|
138
|
+
except TypeError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _backend_debug_state(app: Any) -> bool:
|
|
143
|
+
"""Read the live backend debug flag without assuming Flask internals."""
|
|
144
|
+
|
|
145
|
+
backend_server = getattr(getattr(app, "backend", None), "server", None)
|
|
146
|
+
if backend_server is None:
|
|
147
|
+
backend_server = getattr(app, "server", None)
|
|
148
|
+
return bool(getattr(backend_server, "debug", False))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _devtools_enabled(app: Any) -> bool:
|
|
152
|
+
"""Require both debug mode and the native Dash Dev Tools UI."""
|
|
153
|
+
|
|
154
|
+
dev_tools = getattr(app, "_dev_tools", {}) # pylint: disable=protected-access
|
|
155
|
+
ui_enabled = bool(getattr(dev_tools, "get", lambda *_: False)("ui"))
|
|
156
|
+
debug_enabled = _tracked_debug_state(app) or _backend_debug_state(app)
|
|
157
|
+
return debug_enabled and ui_enabled
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _install_debug_tracker(app: Any) -> None:
|
|
161
|
+
"""Track Dash's resolved debug value without retaining application objects."""
|
|
162
|
+
|
|
163
|
+
original = app.enable_dev_tools
|
|
164
|
+
if getattr(original, "_dash_devtools_plus_debug_tracker", False):
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
@wraps(original)
|
|
168
|
+
def tracked_enable_dev_tools(*args: Any, **kwargs: Any) -> bool:
|
|
169
|
+
debug_enabled = original(*args, **kwargs)
|
|
170
|
+
_set_debug_state(app, bool(debug_enabled))
|
|
171
|
+
return debug_enabled
|
|
172
|
+
|
|
173
|
+
tracked_enable_dev_tools._dash_devtools_plus_debug_tracker = True # type: ignore[attr-defined]
|
|
174
|
+
app.enable_dev_tools = tracked_enable_dev_tools
|
|
175
|
+
_set_debug_state(app, _backend_debug_state(app))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _bind_component_props(app: Any) -> None:
|
|
179
|
+
"""Bind the cloned Devtools Plus registration to its owning Dash app."""
|
|
180
|
+
|
|
181
|
+
app_reference = ref(app)
|
|
182
|
+
|
|
183
|
+
def app_component_props() -> dict[str, Any]:
|
|
184
|
+
return _component_props_for(app_reference())
|
|
185
|
+
|
|
186
|
+
app_component_props.__module__ = __name__
|
|
187
|
+
app_component_props.__name__ = "app_component_props"
|
|
188
|
+
for devtools_hook in app._hooks.get_hooks("dev_tools"): # pylint: disable=protected-access
|
|
189
|
+
if devtools_hook.get("namespace") == "DashDevtoolsPlus":
|
|
190
|
+
devtools_hook["props"] = app_component_props
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _setup_app(app: Any) -> None:
|
|
194
|
+
"""Install the security boundary before capturing hook registrations."""
|
|
195
|
+
|
|
196
|
+
_install_debug_tracker(app)
|
|
197
|
+
_bind_component_props(app)
|
|
198
|
+
_install_clientside_source_tracker(app)
|
|
199
|
+
capture_app_hook_snapshot(app)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _harden_response(response: Any) -> Any:
|
|
203
|
+
"""Prevent sensitive development metadata from being cached or sniffed."""
|
|
204
|
+
|
|
205
|
+
headers = getattr(response, "headers", None)
|
|
206
|
+
if headers is not None:
|
|
207
|
+
headers["Cache-Control"] = "no-store, max-age=0"
|
|
208
|
+
headers["Pragma"] = "no-cache"
|
|
209
|
+
headers["X-Content-Type-Options"] = "nosniff"
|
|
210
|
+
return response
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _debug_only_response(app: Any, payload_factory: Any) -> Any:
|
|
214
|
+
"""Serve a payload only inside an explicitly debug-enabled Dash app."""
|
|
215
|
+
|
|
216
|
+
if not _devtools_enabled(app):
|
|
217
|
+
return _harden_response(app.backend.make_response("Not Found", status=404))
|
|
218
|
+
return _harden_response(app.backend.jsonify(payload_factory()))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
222
|
+
try:
|
|
223
|
+
path.relative_to(parent)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _source_root(source_file: Path) -> Optional[Path]:
|
|
230
|
+
"""Find a project boundary without exposing paths outside the project."""
|
|
231
|
+
|
|
232
|
+
if _PROJECT_ROOT is not None:
|
|
233
|
+
return _PROJECT_ROOT if _is_relative_to(source_file, _PROJECT_ROOT) else None
|
|
234
|
+
|
|
235
|
+
candidates = (Path.cwd().resolve(), *source_file.parents)
|
|
236
|
+
for candidate in candidates:
|
|
237
|
+
if not _is_relative_to(source_file, candidate):
|
|
238
|
+
continue
|
|
239
|
+
if any((candidate / marker).exists() for marker in _PROJECT_MARKERS):
|
|
240
|
+
return candidate
|
|
241
|
+
return source_file.parent
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _editor_uri(
|
|
245
|
+
editor: str, source_file: Path, source_root: Path, line: int
|
|
246
|
+
) -> Optional[str]:
|
|
247
|
+
"""Build an IDE URI for a verified file contained by the project root."""
|
|
248
|
+
|
|
249
|
+
if editor not in _SUPPORTED_EDITORS or not _is_relative_to(source_file, source_root):
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
relative_path = source_file.relative_to(source_root)
|
|
253
|
+
editor_root = _EDITOR_PROJECT_ROOT or str(source_root)
|
|
254
|
+
is_windows_path = (
|
|
255
|
+
bool(re.match(r"^[A-Za-z]:[\\/]", editor_root)) or "\\" in editor_root
|
|
256
|
+
)
|
|
257
|
+
if is_windows_path:
|
|
258
|
+
target_path = PureWindowsPath(editor_root, *relative_path.parts).as_posix()
|
|
259
|
+
else:
|
|
260
|
+
target_path = PurePosixPath(editor_root, *relative_path.parts).as_posix()
|
|
261
|
+
|
|
262
|
+
encoded_path = quote(target_path, safe="/:")
|
|
263
|
+
if editor == "pycharm":
|
|
264
|
+
return f"pycharm://open?file={encoded_path}&line={line}&column=1"
|
|
265
|
+
|
|
266
|
+
uri_path = encoded_path if encoded_path.startswith("/") else f"/{encoded_path}"
|
|
267
|
+
return f"{editor}://file{uri_path}:{line}:1"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _windows_url_protocol_registered(scheme: str) -> bool:
|
|
271
|
+
"""Check the merged Windows URL protocol registry without launching an app."""
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
import winreg # pylint: disable=import-outside-toplevel
|
|
275
|
+
|
|
276
|
+
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, scheme) as protocol_key:
|
|
277
|
+
try:
|
|
278
|
+
winreg.QueryValueEx(protocol_key, "URL Protocol")
|
|
279
|
+
except OSError:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
with winreg.OpenKey(
|
|
283
|
+
winreg.HKEY_CLASSES_ROOT, rf"{scheme}\shell\open\command"
|
|
284
|
+
) as command_key:
|
|
285
|
+
command, _ = winreg.QueryValueEx(command_key, None)
|
|
286
|
+
return bool(str(command).strip())
|
|
287
|
+
except (ImportError, OSError):
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@lru_cache(maxsize=len(_EDITOR_ORDER))
|
|
292
|
+
def _editor_supported(editor: str) -> bool:
|
|
293
|
+
"""Best-effort detection of IDE URL handlers on the Dash server host."""
|
|
294
|
+
|
|
295
|
+
if editor not in _SUPPORTED_EDITORS:
|
|
296
|
+
return False
|
|
297
|
+
if sys.platform == "win32":
|
|
298
|
+
return _windows_url_protocol_registered(editor)
|
|
299
|
+
|
|
300
|
+
if any(shutil.which(command) for command in _EDITOR_COMMANDS[editor]):
|
|
301
|
+
return True
|
|
302
|
+
if sys.platform != "darwin":
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
application_names = {
|
|
306
|
+
"vscode": ("Visual Studio Code.app",),
|
|
307
|
+
"cursor": ("Cursor.app",),
|
|
308
|
+
"pycharm": ("PyCharm.app", "PyCharm Professional.app", "PyCharm CE.app"),
|
|
309
|
+
}
|
|
310
|
+
application_roots = (Path("/Applications"), Path.home() / "Applications")
|
|
311
|
+
return any(
|
|
312
|
+
(root / application_name).is_dir()
|
|
313
|
+
for root in application_roots
|
|
314
|
+
for application_name in application_names[editor]
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _editor_targets(
|
|
319
|
+
source_file: Path, source_root: Path, line: int
|
|
320
|
+
) -> list[dict[str, Any]]:
|
|
321
|
+
"""Return trusted IDE targets, with the configured preference listed first."""
|
|
322
|
+
|
|
323
|
+
if _EDITOR is None:
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
editor_order = (_EDITOR, *(_editor for _editor in _EDITOR_ORDER if _editor != _EDITOR))
|
|
327
|
+
targets = []
|
|
328
|
+
for editor in editor_order:
|
|
329
|
+
uri = _editor_uri(editor, source_file, source_root, line)
|
|
330
|
+
if uri is not None:
|
|
331
|
+
targets.append(
|
|
332
|
+
{
|
|
333
|
+
"id": editor,
|
|
334
|
+
"label": _EDITOR_LABELS[editor],
|
|
335
|
+
"uri": uri,
|
|
336
|
+
"supported": _editor_supported(editor),
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
return targets
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _source_location(
|
|
343
|
+
source_path_value: str,
|
|
344
|
+
line: int,
|
|
345
|
+
*,
|
|
346
|
+
kind: str,
|
|
347
|
+
function: Optional[str],
|
|
348
|
+
docstring: Optional[str] = None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
"""Build safe, project-relative source metadata for the debug endpoint."""
|
|
351
|
+
|
|
352
|
+
source_path = Path(source_path_value).resolve()
|
|
353
|
+
root = _source_root(source_path)
|
|
354
|
+
if root is None:
|
|
355
|
+
raise ValueError("callback source is outside the configured project root")
|
|
356
|
+
relative_path = source_path.relative_to(root).as_posix()
|
|
357
|
+
editor_targets = _editor_targets(source_path, root, line)
|
|
358
|
+
return {
|
|
359
|
+
"kind": kind,
|
|
360
|
+
"function": function,
|
|
361
|
+
"path": relative_path,
|
|
362
|
+
"line": line,
|
|
363
|
+
"editorUri": editor_targets[0]["uri"] if editor_targets else None,
|
|
364
|
+
"editorUris": editor_targets,
|
|
365
|
+
"docstring": docstring,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _callback_docstring(callback: Any) -> Optional[str]:
|
|
370
|
+
"""Return a display-ready Docstring while preserving relative indentation."""
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
original = inspect.unwrap(callback)
|
|
374
|
+
value = getattr(original, "__doc__", None)
|
|
375
|
+
if not isinstance(value, str):
|
|
376
|
+
return None
|
|
377
|
+
cleaned = inspect.cleandoc(value)
|
|
378
|
+
return cleaned or None
|
|
379
|
+
except (AttributeError, TypeError, ValueError):
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _callback_source(callback: Any) -> dict[str, Any]:
|
|
384
|
+
if callback is None:
|
|
385
|
+
return {
|
|
386
|
+
"kind": "clientside",
|
|
387
|
+
"function": None,
|
|
388
|
+
"path": None,
|
|
389
|
+
"line": None,
|
|
390
|
+
"editorUri": None,
|
|
391
|
+
"editorUris": [],
|
|
392
|
+
"docstring": None,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
docstring = _callback_docstring(callback)
|
|
396
|
+
try:
|
|
397
|
+
original = inspect.unwrap(callback)
|
|
398
|
+
source_path_value = inspect.getsourcefile(original) or inspect.getfile(original)
|
|
399
|
+
line = inspect.getsourcelines(original)[1]
|
|
400
|
+
return _source_location(
|
|
401
|
+
source_path_value,
|
|
402
|
+
line,
|
|
403
|
+
kind="python",
|
|
404
|
+
function=getattr(original, "__name__", type(original).__name__),
|
|
405
|
+
docstring=docstring,
|
|
406
|
+
)
|
|
407
|
+
except (OSError, TypeError, ValueError):
|
|
408
|
+
return {
|
|
409
|
+
"kind": "unavailable",
|
|
410
|
+
"function": getattr(callback, "__name__", None),
|
|
411
|
+
"path": None,
|
|
412
|
+
"line": None,
|
|
413
|
+
"editorUri": None,
|
|
414
|
+
"editorUris": [],
|
|
415
|
+
"docstring": docstring,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _clientside_registration_source(caller_frame: Any) -> dict[str, Any]:
|
|
420
|
+
"""Describe the Python statement that registered a clientside callback."""
|
|
421
|
+
|
|
422
|
+
if caller_frame is None:
|
|
423
|
+
return _callback_source(None)
|
|
424
|
+
try:
|
|
425
|
+
return _source_location(
|
|
426
|
+
caller_frame.f_code.co_filename,
|
|
427
|
+
caller_frame.f_lineno,
|
|
428
|
+
kind="clientside-registration",
|
|
429
|
+
function="app.clientside_callback",
|
|
430
|
+
)
|
|
431
|
+
except (AttributeError, OSError, TypeError, ValueError):
|
|
432
|
+
return _callback_source(None)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _install_clientside_source_tracker(app: Any) -> None:
|
|
436
|
+
"""Capture each ``app.clientside_callback`` call site for this Dash app."""
|
|
437
|
+
|
|
438
|
+
original = app.clientside_callback
|
|
439
|
+
if getattr(original, "_dash_devtools_plus_source_tracker", False):
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
@wraps(original)
|
|
443
|
+
def tracked_clientside_callback(*args: Any, **kwargs: Any) -> Any:
|
|
444
|
+
current_frame = inspect.currentframe()
|
|
445
|
+
try:
|
|
446
|
+
source = _clientside_registration_source(
|
|
447
|
+
current_frame.f_back if current_frame is not None else None
|
|
448
|
+
)
|
|
449
|
+
finally:
|
|
450
|
+
del current_frame
|
|
451
|
+
|
|
452
|
+
with _CLIENTSIDE_SOURCE_LOCK:
|
|
453
|
+
dependency_count = len(app._callback_list) # pylint: disable=protected-access
|
|
454
|
+
result = original(*args, **kwargs)
|
|
455
|
+
new_dependencies = app._callback_list[dependency_count:] # pylint: disable=protected-access
|
|
456
|
+
app_sources = _APP_CLIENTSIDE_SOURCES.setdefault(app, {})
|
|
457
|
+
for dependency in new_dependencies:
|
|
458
|
+
callback_id = dependency.get("output")
|
|
459
|
+
if callback_id:
|
|
460
|
+
app_sources[callback_id] = source
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
tracked_clientside_callback._dash_devtools_plus_source_tracker = True # type: ignore[attr-defined]
|
|
464
|
+
app.clientside_callback = tracked_clientside_callback
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _tracked_clientside_source(app: Any, callback_id: Any) -> dict[str, Any]:
|
|
468
|
+
try:
|
|
469
|
+
with _CLIENTSIDE_SOURCE_LOCK:
|
|
470
|
+
source = _APP_CLIENTSIDE_SOURCES.get(app, {}).get(callback_id)
|
|
471
|
+
except TypeError:
|
|
472
|
+
source = None
|
|
473
|
+
return dict(source) if source is not None else _callback_source(None)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _callback_metadata(app: Any) -> list[dict[str, Any]]:
|
|
477
|
+
callbacks = []
|
|
478
|
+
for dependency in app._callback_list: # pylint: disable=protected-access
|
|
479
|
+
item = dict(dependency)
|
|
480
|
+
callback_id = item.get("output")
|
|
481
|
+
callback_entry = app.callback_map.get(callback_id, {})
|
|
482
|
+
if item.get("no_output"):
|
|
483
|
+
# Dash uses an internal hash as the registry key for callbacks with
|
|
484
|
+
# no declared Output. It is an implementation detail, not a role.
|
|
485
|
+
item["output"] = None
|
|
486
|
+
item["mcp_enabled"] = callback_entry.get("mcp_enabled")
|
|
487
|
+
if item.get("clientside_function"):
|
|
488
|
+
item["source"] = _tracked_clientside_source(app, callback_id)
|
|
489
|
+
else:
|
|
490
|
+
item["source"] = _callback_source(callback_entry.get("callback"))
|
|
491
|
+
callbacks.append(item)
|
|
492
|
+
return callbacks
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _serve_callback_metadata():
|
|
496
|
+
app = get_app()
|
|
497
|
+
return _debug_only_response(app, lambda: _callback_metadata(app))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _component_library_metadata() -> list[dict[str, Any]]:
|
|
501
|
+
"""Return loaded Dash component libraries identified by their signature."""
|
|
502
|
+
|
|
503
|
+
libraries: list[tuple[int, dict[str, Any]]] = []
|
|
504
|
+
seen_modules: set[int] = set()
|
|
505
|
+
for module_name, module in tuple(sys.modules.items()):
|
|
506
|
+
if module is None or id(module) in seen_modules:
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
if not _COMPONENT_LIBRARY_SIGNATURE.issubset(dir(module)):
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
seen_modules.add(id(module))
|
|
513
|
+
package = getattr(module, "package", None)
|
|
514
|
+
package_metadata = package if isinstance(package, dict) else {}
|
|
515
|
+
display_name = (
|
|
516
|
+
package_metadata.get("name")
|
|
517
|
+
or getattr(module, "package_name", None)
|
|
518
|
+
or module_name
|
|
519
|
+
)
|
|
520
|
+
version = getattr(module, "__version__", None) or package_metadata.get(
|
|
521
|
+
"version"
|
|
522
|
+
)
|
|
523
|
+
exports = getattr(module, "__all__", ())
|
|
524
|
+
js_dist = getattr(module, "_js_dist", ())
|
|
525
|
+
|
|
526
|
+
libraries.append(
|
|
527
|
+
(
|
|
528
|
+
id(module),
|
|
529
|
+
{
|
|
530
|
+
"module": module_name,
|
|
531
|
+
"name": str(display_name),
|
|
532
|
+
"version": str(version) if version is not None else None,
|
|
533
|
+
"exports": len(exports) if hasattr(exports, "__len__") else None,
|
|
534
|
+
"jsAssets": len(js_dist) if hasattr(js_dist, "__len__") else None,
|
|
535
|
+
"scope": "core" if module_name.startswith("dash.") else "third-party",
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
aliases: dict[int, set[str]] = {module_id: set() for module_id, _ in libraries}
|
|
543
|
+
project_root = Path.cwd().resolve()
|
|
544
|
+
for owner_module in tuple(sys.modules.values()):
|
|
545
|
+
source_file = getattr(owner_module, "__file__", None)
|
|
546
|
+
if not source_file:
|
|
547
|
+
continue
|
|
548
|
+
try:
|
|
549
|
+
if not _is_relative_to(Path(source_file).resolve(), project_root):
|
|
550
|
+
continue
|
|
551
|
+
namespace = tuple(vars(owner_module).items())
|
|
552
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
for alias, value in namespace:
|
|
556
|
+
module_aliases = aliases.get(id(value))
|
|
557
|
+
if (
|
|
558
|
+
module_aliases is not None
|
|
559
|
+
and alias.isidentifier()
|
|
560
|
+
and not alias.startswith("_")
|
|
561
|
+
):
|
|
562
|
+
module_aliases.add(alias)
|
|
563
|
+
|
|
564
|
+
records = []
|
|
565
|
+
for module_id, library in libraries:
|
|
566
|
+
library["aliases"] = sorted(aliases[module_id], key=str.casefold)
|
|
567
|
+
records.append(library)
|
|
568
|
+
|
|
569
|
+
return sorted(
|
|
570
|
+
records,
|
|
571
|
+
key=lambda item: (item["scope"] != "core", item["name"].casefold()),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _serve_component_library_metadata():
|
|
576
|
+
app = get_app()
|
|
577
|
+
return _debug_only_response(app, _component_library_metadata)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _serve_hook_library_metadata():
|
|
581
|
+
app = get_app()
|
|
582
|
+
return _debug_only_response(app, lambda: build_hook_inventory(app))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _serve_server_metrics():
|
|
586
|
+
app = get_app()
|
|
587
|
+
return _debug_only_response(app, collect_server_metrics)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def register() -> None:
|
|
591
|
+
"""Register assets and the toolbar component exactly once."""
|
|
592
|
+
|
|
593
|
+
global _REGISTERED
|
|
594
|
+
with _LOCK:
|
|
595
|
+
if _REGISTERED:
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
hooks.script(
|
|
599
|
+
[
|
|
600
|
+
{
|
|
601
|
+
"dev_package_path": "assets/dash_devtools_plus.js",
|
|
602
|
+
"namespace": "dash_devtools_plus",
|
|
603
|
+
"dev_only": True,
|
|
604
|
+
}
|
|
605
|
+
]
|
|
606
|
+
)
|
|
607
|
+
hooks.stylesheet(
|
|
608
|
+
[
|
|
609
|
+
{
|
|
610
|
+
"dev_package_path": "assets/dash_devtools_plus.css",
|
|
611
|
+
"namespace": "dash_devtools_plus",
|
|
612
|
+
"dev_only": True,
|
|
613
|
+
}
|
|
614
|
+
]
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
hooks.route(name=_CALLBACKS_ROUTE)(_serve_callback_metadata)
|
|
618
|
+
hooks.route(name=_COMPONENT_LIBRARIES_ROUTE)(
|
|
619
|
+
_serve_component_library_metadata
|
|
620
|
+
)
|
|
621
|
+
hooks.route(name=_HOOK_LIBRARIES_ROUTE)(_serve_hook_library_metadata)
|
|
622
|
+
hooks.route(name=_SERVER_METRICS_ROUTE)(_serve_server_metrics)
|
|
623
|
+
hooks.setup(priority=sys.maxsize)(_setup_app)
|
|
624
|
+
|
|
625
|
+
hooks.devtool(
|
|
626
|
+
namespace="DashDevtoolsPlus",
|
|
627
|
+
component_type="DevtoolsPlus",
|
|
628
|
+
props=_component_props,
|
|
629
|
+
position="left",
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
_REGISTERED = True
|