Coverage for gws-app/gws/plugin/admin_action/inspector/__init__.py: 0%
189 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Object inspector: an html page to inspect the object tree.
3The page lists all tree nodes, shows the properties of the selected object and
4lets the user follow references to other objects. It is served by the
5``adminInspector`` command of the ``admin`` action.
7An object is addressed by a path of ``/``-separated, url-quoted segments.
8The first segment is the uid of a tree node, or empty for the root.
9Further segments are attribute names, dict keys or list indexes.
11Values are shown as primitives, as links to tree nodes and other objects,
12or, for dicts, lists and ``gws.Data``, expanded inline up to a fixed depth.
14The search walks the whole object graph from the root, breadth first, and lists
15objects with a primitive property containing the search text (``text``),
16or a specific property containing it (``prop=text``). The search is case-insensitive
17and the number of results is limited.
19Example::
21 /_/adminInspector?path=
22 /_/adminInspector?path=<node uid>/config/layers/0
23 /_/adminInspector?search=title=roads
24"""
26import collections
27import json
28import os
29import re
30import types
31import urllib.parse
33import gws
34import gws.lib.mime
36_DIR = os.path.dirname(__file__)
37_ASSETS = {
38 'page.js': (f'{_DIR}/page.js', gws.lib.mime.JS),
39 'page.css': (f'{_DIR}/page.css', gws.lib.mime.CSS),
40}
41_URL = f'{gws.c.SERVER_ENDPOINT}/adminInspector?path='
42_MAX_COLLECTION_DEPTH = 3
43_MAX_REPR_LENGTH = 500
44_MAX_SEARCH_RESULTS = 500
46_OPAQUE_TYPES = (
47 type,
48 types.ModuleType,
49 types.FunctionType,
50 types.BuiltinFunctionType,
51 types.MethodType,
52 types.MethodWrapperType,
53)
56def get_content(root: gws.Root, path: str, search: str) -> gws.ContentResponse:
57 """Render the inspector page for an object path, or return a page asset.
59 Args:
60 root: The configuration root.
61 path: Object path, or the name of an asset (``page.js``, ``page.css``).
62 search: Optional search text, ``text`` or ``prop=text``.
64 Returns:
65 The rendered page or the asset.
67 Raises:
68 gws.NotFoundError: If the path cannot be resolved.
69 """
70 path = path or ''
71 search = (search or '').strip()
73 if path in _ASSETS:
74 p, mime_type = _ASSETS[path]
75 return gws.ContentResponse(contentPath=p, mimeType=mime_type)
77 obj = _resolve(root, _split(path))
79 config = {
80 'url': _URL,
81 'path': path,
82 'selectedUid': _split(path)[0],
83 'crumbs': _crumbs(root, path),
84 'nodes': [_node_entry(node) for node in root.nodes],
85 'search': search,
86 'results': _search(root, search) if search else None,
87 'maxResults': _MAX_SEARCH_RESULTS,
88 'label': _label(obj),
89 'props': [_value(root, v, _join(path, k), 0) | {'key': str(k)} for k, v in _entries(obj)],
90 }
92 tpl = root.app.templateMgr.template_from_path(f'{_DIR}/page.cx.html')
93 args = {
94 'url': _URL,
95 'version': max(int(os.path.getmtime(p)) for p, _ in _ASSETS.values()),
96 'configJson': json.dumps(config).replace('<', '\\u003c'),
97 }
98 return tpl.render(gws.TemplateRenderInput(args=args))
101##
104def _split(path: str) -> list[str]:
105 return [urllib.parse.unquote(s) for s in path.split('/')]
108def _join(path: str, key) -> str:
109 return path + '/' + urllib.parse.quote(str(key), safe='')
112def _node_path(node: gws.Node) -> str:
113 return urllib.parse.quote(node.uid, safe='')
116def _resolve(root: gws.Root, segs: list[str]):
117 """Return the object addressed by path segments."""
118 if segs[0]:
119 obj = root.uidMap.get(segs[0])
120 if obj is None:
121 raise gws.NotFoundError(f'object {segs[0]!r} not found')
122 else:
123 obj = root
125 for s in segs[1:]:
126 obj = _get(obj, _key(obj, s))
128 return obj
131def _key(obj, seg: str):
132 """Convert a path segment into a key of an object (dict key, list index or attribute name)."""
133 if isinstance(obj, dict):
134 for k in obj:
135 if str(k) == seg:
136 return k
137 elif _is_list(obj):
138 if seg.isdigit() and int(seg) < len(obj):
139 return int(seg)
140 elif _is_object(obj):
141 if seg in vars(obj):
142 return seg
143 raise gws.NotFoundError(f'{seg!r} not found in {_label(obj)}')
146def _get(obj, key):
147 """Return a child of an object by key."""
148 if isinstance(obj, (set, frozenset)):
149 return list(obj)[key]
150 if isinstance(obj, (dict, list, tuple)):
151 return obj[key]
152 return vars(obj)[key]
155def _entries(obj) -> list[tuple]:
156 """Return the (key, value) pairs of a dict, list or object."""
157 if isinstance(obj, dict):
158 return list(obj.items())
159 if _is_list(obj):
160 return list(enumerate(obj))
161 if _is_object(obj):
162 return sorted(vars(obj).items())
163 return []
166##
169def _value(root: gws.Root, val, path: str, depth: int) -> dict:
170 """Describe a value for the page, expanding collections up to a fixed depth."""
171 if _is_primitive(val):
172 return {'kind': 'primitive', 'baseType': _base_type(val), 'value': _primitive_str(val)}
174 if val is root:
175 return {'kind': 'object', 'label': _format(val), 'path': ''}
177 if _is_tree_node(root, val):
178 return {'kind': 'object', 'label': _format(val), 'path': _node_path(val)}
180 if (isinstance(val, (dict, gws.Data)) or _is_list(val)) and depth < _MAX_COLLECTION_DEPTH:
181 return {
182 'kind': 'collection',
183 'label': _label(val),
184 'path': path,
185 'items': [_value(root, v, _join(path, k), depth + 1) | {'key': str(k)} for k, v in _entries(val)],
186 }
188 if _is_object(val) or _is_list(val):
189 return {'kind': 'object', 'label': _label(val), 'path': path}
191 return {'kind': 'other', 'value': _repr(val)}
194def _search(root: gws.Root, query: str) -> list[dict]:
195 """Walk the object graph breadth first and return the objects matching the query."""
196 m = re.match(r'^([^=\s]+)\s*=(.*)$', query)
197 prop, text = (m.group(1), m.group(2).strip()) if m else (None, query)
198 text = text.lower()
200 results = []
201 found = set()
202 seen = set()
203 queue = collections.deque([(root, '', root, '')])
205 while queue and len(results) < _MAX_SEARCH_RESULTS:
206 obj, path, owner, owner_path = queue.popleft()
207 if id(obj) in seen:
208 continue
209 seen.add(id(obj))
211 if obj is root or (_is_object(obj) and not isinstance(obj, (dict, gws.Data))):
212 owner, owner_path = obj, path
214 try:
215 entries = _entries(obj)
216 except Exception:
217 continue
219 for k, v in entries:
220 if owner_path not in found and (prop is None or str(k) == prop) and not _is_list(obj) and _match(v, text) is not None:
221 found.add(owner_path)
222 results.append({'path': owner_path, 'label': _format(owner)})
223 if _is_primitive(v) or v is root:
224 continue
225 if _is_tree_node(root, v):
226 queue.append((v, _node_path(v), owner, owner_path))
227 elif _is_object(v) or _is_list(v):
228 queue.append((v, _join(path, k), owner, owner_path))
230 return results
233def _match(val, text: str):
234 """Return the string form of a primitive value, or of a list item, that contains the text, or ``None``."""
235 if _is_primitive(val):
236 s = _primitive_str(val)
237 return s if text in s.lower() else None
238 if _is_list(val):
239 for v in val:
240 if _is_primitive(v):
241 s = _primitive_str(v)
242 if text in s.lower():
243 return s
246def _node_entry(node: gws.Node) -> dict:
247 return {
248 'uid': node.uid,
249 'path': _node_path(node),
250 'label': _format(node),
251 }
254def _crumbs(root: gws.Root, path: str) -> list[dict]:
255 """Return breadcrumbs for a path: the root, the parent chain of the node and the path segments."""
256 segs = _split(path)
257 crumbs = []
259 if segs[0]:
260 node = root.uidMap.get(segs[0])
261 while _is_tree_node(root, node):
262 crumbs.insert(0, {'label': _format(node), 'path': _node_path(node)})
263 node = vars(node).get('parent')
265 crumbs.insert(0, {'label': _format(root), 'path': ''})
267 p = _node_path(root.uidMap[segs[0]]) if segs[0] else ''
268 for s in segs[1:]:
269 p = _join(p, s)
270 crumbs.append({'label': s, 'path': p})
272 return crumbs
275def _label(val) -> str:
276 """Return a short label for a value, with the size for collections."""
277 if isinstance(val, dict):
278 return f'dict[{len(val)}]'
279 if _is_list(val):
280 return f'{type(val).__name__}[{len(val)}]'
281 if isinstance(val, gws.Data):
282 return f'{type(val).__name__}[{len(vars(val))}]'
283 return _format(val)
286def _format(val) -> str:
287 """Return ``<class uid>`` for an object, or ``<class id>`` if it has no uid."""
288 if isinstance(val, gws.Root):
289 return '<root>'
290 if isinstance(val, gws.Application):
291 return '<app>'
292 uid = vars(val).get('uid') if _is_object(val) else None
293 ident = uid if isinstance(uid, str) and uid else hex(id(val))
294 return f'<{_class_name(val)} {ident}>'
297def _class_name(val) -> str:
298 cls = type(val)
299 return cls.__module__ + '.' + cls.__qualname__
302def _base_type(val) -> str:
303 for t in (bool, int, float, str):
304 if isinstance(val, t):
305 return t.__name__
306 return 'NoneType'
309def _primitive_str(val) -> str:
310 if val is None:
311 return 'None'
312 if isinstance(val, str):
313 return str.__str__(val)
314 if isinstance(val, bool):
315 return 'True' if val else 'False'
316 if isinstance(val, int):
317 return int.__repr__(val)
318 return float.__repr__(val)
321def _repr(val) -> str:
322 try:
323 s = repr(val)
324 except Exception as exc:
325 s = f'<repr error: {exc}>'
326 if len(s) > _MAX_REPR_LENGTH:
327 s = s[:_MAX_REPR_LENGTH] + '...'
328 return s
331def _is_primitive(val) -> bool:
332 return val is None or isinstance(val, (bool, int, float, str))
335def _is_list(val) -> bool:
336 return isinstance(val, (list, tuple, set, frozenset))
339def _is_object(val) -> bool:
340 """Check if a value is a dict or an object with attributes that can be inspected."""
341 if isinstance(val, dict):
342 return True
343 if isinstance(val, _OPAQUE_TYPES):
344 return False
345 return hasattr(val, '__dict__')
348def _is_tree_node(root: gws.Root, val) -> bool:
349 """Check if a value is a node registered in the root by its uid."""
350 return isinstance(val, gws.Node) and root.uidMap.get(vars(val).get('uid')) is val