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,366 @@
|
|
|
1
|
+
"""Read-only introspection for Dash hook plugins.
|
|
2
|
+
|
|
3
|
+
Entry-point discovery uses Python's public metadata API. Runtime contribution
|
|
4
|
+
details are deliberately isolated here because Dash currently exposes those
|
|
5
|
+
through private registries whose shape may change between releases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from typing import Any
|
|
17
|
+
from weakref import WeakKeyDictionary
|
|
18
|
+
|
|
19
|
+
import dash
|
|
20
|
+
from dash import hooks
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_SNAPSHOT_LOCK = Lock()
|
|
24
|
+
_APP_SNAPSHOTS: WeakKeyDictionary[Any, frozenset[int]] = WeakKeyDictionary()
|
|
25
|
+
_NORMALIZE_NAME = re.compile(r"[-_.]+")
|
|
26
|
+
_ORDERED_TYPES = (
|
|
27
|
+
"setup",
|
|
28
|
+
"layout",
|
|
29
|
+
"routes",
|
|
30
|
+
"error",
|
|
31
|
+
"callback",
|
|
32
|
+
"index",
|
|
33
|
+
"custom_data",
|
|
34
|
+
"websocket_connect",
|
|
35
|
+
"websocket_message",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalise_distribution(name: str) -> str:
|
|
40
|
+
return _NORMALIZE_NAME.sub("-", name).casefold()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _module_from_value(value: str) -> str:
|
|
44
|
+
return value.partition(":")[0].strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _callable_owner(value: Any) -> tuple[str | None, str | None]:
|
|
48
|
+
if not callable(value):
|
|
49
|
+
return None, None
|
|
50
|
+
module = getattr(value, "__module__", None)
|
|
51
|
+
name = getattr(value, "__qualname__", None) or getattr(value, "__name__", None)
|
|
52
|
+
return module, name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _runtime_contributions() -> tuple[list[dict[str, Any]], str]:
|
|
56
|
+
"""Return a stable JSON-friendly view over the current Dash registry."""
|
|
57
|
+
|
|
58
|
+
namespace = getattr(hooks, "_ns", None)
|
|
59
|
+
if not isinstance(namespace, dict):
|
|
60
|
+
return [], "unavailable"
|
|
61
|
+
|
|
62
|
+
finals = getattr(hooks, "_finals", {})
|
|
63
|
+
final_ids = (
|
|
64
|
+
{id(item) for item in finals.values()}
|
|
65
|
+
if isinstance(finals, dict)
|
|
66
|
+
else set()
|
|
67
|
+
)
|
|
68
|
+
contributions: list[dict[str, Any]] = []
|
|
69
|
+
|
|
70
|
+
for hook_type in _ORDERED_TYPES:
|
|
71
|
+
items = list(namespace.get(hook_type, ()))
|
|
72
|
+
final = finals.get(hook_type) if isinstance(finals, dict) else None
|
|
73
|
+
if final is not None and all(id(item) != id(final) for item in items):
|
|
74
|
+
items.append(final)
|
|
75
|
+
for position, item in enumerate(items, start=1):
|
|
76
|
+
owner_module, callable_name = _callable_owner(getattr(item, "func", None))
|
|
77
|
+
contributions.append(
|
|
78
|
+
{
|
|
79
|
+
"runtimeId": id(item),
|
|
80
|
+
"type": hook_type,
|
|
81
|
+
"position": position,
|
|
82
|
+
"priority": getattr(item, "priority", None),
|
|
83
|
+
"final": id(item) in final_ids,
|
|
84
|
+
"module": owner_module,
|
|
85
|
+
"callable": callable_name,
|
|
86
|
+
"ordered": True,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for position, item in enumerate(namespace.get("dev_tools", ()), start=1):
|
|
91
|
+
props = item.get("props") if isinstance(item, dict) else None
|
|
92
|
+
owner_module, callable_name = _callable_owner(props)
|
|
93
|
+
contributions.append(
|
|
94
|
+
{
|
|
95
|
+
"runtimeId": id(item),
|
|
96
|
+
"type": "devtool",
|
|
97
|
+
"position": position,
|
|
98
|
+
"priority": None,
|
|
99
|
+
"final": False,
|
|
100
|
+
"module": owner_module,
|
|
101
|
+
"callable": callable_name,
|
|
102
|
+
"namespace": item.get("namespace") if isinstance(item, dict) else None,
|
|
103
|
+
"ordered": False,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for hook_type, attribute in (
|
|
108
|
+
("script", "_js_dist"),
|
|
109
|
+
("stylesheet", "_css_dist"),
|
|
110
|
+
):
|
|
111
|
+
for position, item in enumerate(getattr(hooks, attribute, ()), start=1):
|
|
112
|
+
contributions.append(
|
|
113
|
+
{
|
|
114
|
+
"runtimeId": id(item),
|
|
115
|
+
"type": hook_type,
|
|
116
|
+
"position": position,
|
|
117
|
+
"priority": None,
|
|
118
|
+
"final": False,
|
|
119
|
+
"module": None,
|
|
120
|
+
"callable": None,
|
|
121
|
+
"namespace": item.get("namespace") if isinstance(item, dict) else None,
|
|
122
|
+
"ordered": False,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
for position, item in enumerate(
|
|
127
|
+
getattr(hooks, "_clientside_callbacks", ()), start=1
|
|
128
|
+
):
|
|
129
|
+
function = item[0] if isinstance(item, tuple) and item else None
|
|
130
|
+
owner_module, callable_name = _callable_owner(function)
|
|
131
|
+
contributions.append(
|
|
132
|
+
{
|
|
133
|
+
"runtimeId": id(item),
|
|
134
|
+
"type": "clientside_callback",
|
|
135
|
+
"position": position,
|
|
136
|
+
"priority": None,
|
|
137
|
+
"final": False,
|
|
138
|
+
"module": owner_module,
|
|
139
|
+
"callable": callable_name,
|
|
140
|
+
"ordered": False,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return contributions, "complete"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def capture_app_hook_snapshot(app: Any) -> None:
|
|
148
|
+
"""Capture the registry visible when Dash runs this plugin's setup hook."""
|
|
149
|
+
|
|
150
|
+
contributions, _ = _runtime_contributions()
|
|
151
|
+
snapshot = frozenset(item["runtimeId"] for item in contributions)
|
|
152
|
+
try:
|
|
153
|
+
with _SNAPSHOT_LOCK:
|
|
154
|
+
_APP_SNAPSHOTS[app] = snapshot
|
|
155
|
+
except TypeError:
|
|
156
|
+
# A future Dash implementation could make app objects non-weakrefable.
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _snapshot_for(app: Any) -> frozenset[int] | None:
|
|
161
|
+
try:
|
|
162
|
+
with _SNAPSHOT_LOCK:
|
|
163
|
+
return _APP_SNAPSHOTS.get(app)
|
|
164
|
+
except TypeError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _entry_point_libraries() -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
|
|
169
|
+
libraries: dict[str, dict[str, Any]] = {}
|
|
170
|
+
module_owners: dict[str, str] = {}
|
|
171
|
+
seen_entries: set[tuple[str, str, str, str]] = set()
|
|
172
|
+
|
|
173
|
+
for distribution in metadata.distributions():
|
|
174
|
+
distribution_name = distribution.metadata.get("Name") or "unknown"
|
|
175
|
+
version = distribution.version
|
|
176
|
+
library_id = _normalise_distribution(distribution_name)
|
|
177
|
+
for entry_point in distribution.entry_points:
|
|
178
|
+
if entry_point.group != "dash_hooks":
|
|
179
|
+
continue
|
|
180
|
+
module = getattr(entry_point, "module", None) or _module_from_value(
|
|
181
|
+
entry_point.value
|
|
182
|
+
)
|
|
183
|
+
dedupe_key = (
|
|
184
|
+
library_id,
|
|
185
|
+
str(version),
|
|
186
|
+
entry_point.name,
|
|
187
|
+
entry_point.value,
|
|
188
|
+
)
|
|
189
|
+
if dedupe_key in seen_entries:
|
|
190
|
+
continue
|
|
191
|
+
seen_entries.add(dedupe_key)
|
|
192
|
+
|
|
193
|
+
library = libraries.setdefault(
|
|
194
|
+
library_id,
|
|
195
|
+
{
|
|
196
|
+
"id": library_id,
|
|
197
|
+
"name": distribution_name,
|
|
198
|
+
"version": str(version),
|
|
199
|
+
"source": "entry-point",
|
|
200
|
+
"entryPoints": [],
|
|
201
|
+
"modules": [],
|
|
202
|
+
"loaded": False,
|
|
203
|
+
"contributions": [],
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
library["entryPoints"].append(
|
|
207
|
+
{"name": entry_point.name, "value": entry_point.value, "module": module}
|
|
208
|
+
)
|
|
209
|
+
if module not in library["modules"]:
|
|
210
|
+
library["modules"].append(module)
|
|
211
|
+
library["loaded"] = library["loaded"] or module in sys.modules
|
|
212
|
+
module_owners[module] = library_id
|
|
213
|
+
|
|
214
|
+
return libraries, module_owners
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _owner_for_contribution(
|
|
218
|
+
contribution: dict[str, Any], module_owners: dict[str, str]
|
|
219
|
+
) -> tuple[str | None, str]:
|
|
220
|
+
owner_module = contribution.get("module")
|
|
221
|
+
if owner_module:
|
|
222
|
+
candidates = sorted(module_owners, key=len, reverse=True)
|
|
223
|
+
for module in candidates:
|
|
224
|
+
if owner_module == module or owner_module.startswith(f"{module}."):
|
|
225
|
+
return module_owners[module], "module"
|
|
226
|
+
|
|
227
|
+
namespace = contribution.get("namespace")
|
|
228
|
+
if namespace:
|
|
229
|
+
normalised_namespace = namespace.replace("-", "_").casefold()
|
|
230
|
+
for module, owner in module_owners.items():
|
|
231
|
+
module_key = module.replace(".", "_").casefold()
|
|
232
|
+
if normalised_namespace == module_key or normalised_namespace.startswith(
|
|
233
|
+
f"{module_key}_"
|
|
234
|
+
):
|
|
235
|
+
return owner, "namespace"
|
|
236
|
+
return None, "unknown"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _manual_library(module: str) -> dict[str, Any]:
|
|
240
|
+
top_level = module.partition(".")[0]
|
|
241
|
+
loaded_module = sys.modules.get(module)
|
|
242
|
+
source_file = getattr(loaded_module, "__file__", None)
|
|
243
|
+
is_project_module = False
|
|
244
|
+
if source_file:
|
|
245
|
+
try:
|
|
246
|
+
Path(source_file).resolve().relative_to(Path.cwd().resolve())
|
|
247
|
+
is_project_module = True
|
|
248
|
+
except (OSError, ValueError):
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
if is_project_module:
|
|
252
|
+
distribution_name = module
|
|
253
|
+
version = None
|
|
254
|
+
else:
|
|
255
|
+
distribution_names = metadata.packages_distributions().get(top_level, ())
|
|
256
|
+
distribution_name = distribution_names[0] if distribution_names else top_level
|
|
257
|
+
try:
|
|
258
|
+
version = metadata.version(distribution_name)
|
|
259
|
+
except metadata.PackageNotFoundError:
|
|
260
|
+
version = None
|
|
261
|
+
library_id = f"manual:{_normalise_distribution(distribution_name)}"
|
|
262
|
+
return {
|
|
263
|
+
"id": library_id,
|
|
264
|
+
"name": distribution_name,
|
|
265
|
+
"version": str(version) if version is not None else None,
|
|
266
|
+
"source": "manual",
|
|
267
|
+
"entryPoints": [],
|
|
268
|
+
"modules": [module],
|
|
269
|
+
"loaded": module in sys.modules,
|
|
270
|
+
"contributions": [],
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def build_hook_inventory(app: Any) -> dict[str, Any]:
|
|
275
|
+
"""Build the hook library inventory for the current process and app."""
|
|
276
|
+
|
|
277
|
+
libraries, module_owners = _entry_point_libraries()
|
|
278
|
+
contributions, runtime_state = _runtime_contributions()
|
|
279
|
+
snapshot = _snapshot_for(app)
|
|
280
|
+
unassigned: list[dict[str, Any]] = []
|
|
281
|
+
|
|
282
|
+
for contribution in contributions:
|
|
283
|
+
contribution["phase"] = (
|
|
284
|
+
"snapshot"
|
|
285
|
+
if snapshot is None or contribution["runtimeId"] in snapshot
|
|
286
|
+
else "late"
|
|
287
|
+
)
|
|
288
|
+
owner, confidence = _owner_for_contribution(contribution, module_owners)
|
|
289
|
+
contribution["attribution"] = confidence
|
|
290
|
+
if owner is None and contribution.get("module"):
|
|
291
|
+
manual = _manual_library(contribution["module"])
|
|
292
|
+
owner = manual["id"]
|
|
293
|
+
libraries.setdefault(owner, manual)
|
|
294
|
+
if owner is None:
|
|
295
|
+
unassigned.append(contribution)
|
|
296
|
+
continue
|
|
297
|
+
libraries[owner]["contributions"].append(contribution)
|
|
298
|
+
|
|
299
|
+
records = []
|
|
300
|
+
all_type_counts: Counter[str] = Counter()
|
|
301
|
+
late_count = 0
|
|
302
|
+
for library in libraries.values():
|
|
303
|
+
library_contributions = library.pop("contributions")
|
|
304
|
+
type_counts = Counter(item["type"] for item in library_contributions)
|
|
305
|
+
snapshot_count = sum(item["phase"] == "snapshot" for item in library_contributions)
|
|
306
|
+
library_late_count = len(library_contributions) - snapshot_count
|
|
307
|
+
late_count += library_late_count
|
|
308
|
+
all_type_counts.update(type_counts)
|
|
309
|
+
library.update(
|
|
310
|
+
{
|
|
311
|
+
"status": (
|
|
312
|
+
"registered"
|
|
313
|
+
if snapshot_count
|
|
314
|
+
else "late"
|
|
315
|
+
if library_late_count
|
|
316
|
+
else "loaded"
|
|
317
|
+
if library["loaded"]
|
|
318
|
+
else "discovered"
|
|
319
|
+
),
|
|
320
|
+
"registrationCount": len(library_contributions),
|
|
321
|
+
"snapshotCount": snapshot_count,
|
|
322
|
+
"lateCount": library_late_count,
|
|
323
|
+
"hookTypes": [
|
|
324
|
+
{"type": hook_type, "count": count}
|
|
325
|
+
for hook_type, count in sorted(type_counts.items())
|
|
326
|
+
],
|
|
327
|
+
"contributions": library_contributions,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
records.append(library)
|
|
331
|
+
|
|
332
|
+
unassigned_types = Counter(item["type"] for item in unassigned)
|
|
333
|
+
all_type_counts.update(unassigned_types)
|
|
334
|
+
records.sort(
|
|
335
|
+
key=lambda item: (
|
|
336
|
+
item["status"] not in {"registered", "late"},
|
|
337
|
+
item["source"] != "entry-point",
|
|
338
|
+
item["name"].casefold(),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
order_warnings = [
|
|
342
|
+
hook_type
|
|
343
|
+
for hook_type, count in sorted(all_type_counts.items())
|
|
344
|
+
if count > 1 and hook_type not in {"script", "stylesheet", "devtool"}
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"schemaVersion": 1,
|
|
349
|
+
"dashVersion": dash.__version__,
|
|
350
|
+
"processScoped": True,
|
|
351
|
+
"completeness": {
|
|
352
|
+
"entryPoints": "complete",
|
|
353
|
+
"runtimeRegistry": runtime_state,
|
|
354
|
+
"appSnapshot": "complete" if snapshot is not None else "unavailable",
|
|
355
|
+
},
|
|
356
|
+
"summary": {
|
|
357
|
+
"libraries": len(records),
|
|
358
|
+
"registrations": len(contributions),
|
|
359
|
+
"hookTypes": len(all_type_counts),
|
|
360
|
+
"unassigned": len(unassigned),
|
|
361
|
+
"late": late_count,
|
|
362
|
+
},
|
|
363
|
+
"orderWarnings": order_warnings,
|
|
364
|
+
"libraries": records,
|
|
365
|
+
"unassigned": unassigned,
|
|
366
|
+
}
|