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.
@@ -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
+ }