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

1"""Object inspector: an html page to inspect the object tree. 

2 

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. 

6 

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. 

10 

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. 

13 

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. 

18 

19Example:: 

20 

21 /_/adminInspector?path= 

22 /_/adminInspector?path=<node uid>/config/layers/0 

23 /_/adminInspector?search=title=roads 

24""" 

25 

26import collections 

27import json 

28import os 

29import re 

30import types 

31import urllib.parse 

32 

33import gws 

34import gws.lib.mime 

35 

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 

45 

46_OPAQUE_TYPES = ( 

47 type, 

48 types.ModuleType, 

49 types.FunctionType, 

50 types.BuiltinFunctionType, 

51 types.MethodType, 

52 types.MethodWrapperType, 

53) 

54 

55 

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. 

58 

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``. 

63 

64 Returns: 

65 The rendered page or the asset. 

66 

67 Raises: 

68 gws.NotFoundError: If the path cannot be resolved. 

69 """ 

70 path = path or '' 

71 search = (search or '').strip() 

72 

73 if path in _ASSETS: 

74 p, mime_type = _ASSETS[path] 

75 return gws.ContentResponse(contentPath=p, mimeType=mime_type) 

76 

77 obj = _resolve(root, _split(path)) 

78 

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 } 

91 

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)) 

99 

100 

101## 

102 

103 

104def _split(path: str) -> list[str]: 

105 return [urllib.parse.unquote(s) for s in path.split('/')] 

106 

107 

108def _join(path: str, key) -> str: 

109 return path + '/' + urllib.parse.quote(str(key), safe='') 

110 

111 

112def _node_path(node: gws.Node) -> str: 

113 return urllib.parse.quote(node.uid, safe='') 

114 

115 

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 

124 

125 for s in segs[1:]: 

126 obj = _get(obj, _key(obj, s)) 

127 

128 return obj 

129 

130 

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)}') 

144 

145 

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] 

153 

154 

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 [] 

164 

165 

166## 

167 

168 

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)} 

173 

174 if val is root: 

175 return {'kind': 'object', 'label': _format(val), 'path': ''} 

176 

177 if _is_tree_node(root, val): 

178 return {'kind': 'object', 'label': _format(val), 'path': _node_path(val)} 

179 

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 } 

187 

188 if _is_object(val) or _is_list(val): 

189 return {'kind': 'object', 'label': _label(val), 'path': path} 

190 

191 return {'kind': 'other', 'value': _repr(val)} 

192 

193 

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() 

199 

200 results = [] 

201 found = set() 

202 seen = set() 

203 queue = collections.deque([(root, '', root, '')]) 

204 

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)) 

210 

211 if obj is root or (_is_object(obj) and not isinstance(obj, (dict, gws.Data))): 

212 owner, owner_path = obj, path 

213 

214 try: 

215 entries = _entries(obj) 

216 except Exception: 

217 continue 

218 

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)) 

229 

230 return results 

231 

232 

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 

244 

245 

246def _node_entry(node: gws.Node) -> dict: 

247 return { 

248 'uid': node.uid, 

249 'path': _node_path(node), 

250 'label': _format(node), 

251 } 

252 

253 

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 = [] 

258 

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') 

264 

265 crumbs.insert(0, {'label': _format(root), 'path': ''}) 

266 

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}) 

271 

272 return crumbs 

273 

274 

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) 

284 

285 

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}>' 

295 

296 

297def _class_name(val) -> str: 

298 cls = type(val) 

299 return cls.__module__ + '.' + cls.__qualname__ 

300 

301 

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' 

307 

308 

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) 

319 

320 

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 

329 

330 

331def _is_primitive(val) -> bool: 

332 return val is None or isinstance(val, (bool, int, float, str)) 

333 

334 

335def _is_list(val) -> bool: 

336 return isinstance(val, (list, tuple, set, frozenset)) 

337 

338 

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__') 

346 

347 

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