Coverage for procpath/sqliteviz.py: 100%
53 statements
« prev ^ index » next coverage.py v6.5.0, created at 2025-04-05 18:56 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2025-04-05 18:56 +0000
1import hashlib
2import http.server
3import io
4import json
5import logging
6import textwrap
7import zipfile
8from functools import partial
9from pathlib import Path
10from urllib.request import urlopen
12from . import procret
15__all__ = 'get_visualisation_bundle', 'install_sqliteviz', 'serve_dir', 'symlink_database'
17logger = logging.getLogger(__package__)
20def install_sqliteviz(zip_url: str, target_dir: Path):
21 response = urlopen(zip_url) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
22 with zipfile.ZipFile(io.BytesIO(response.read())) as z: (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
23 z.extractall(target_dir) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
25 bundle = json.dumps(get_visualisation_bundle(), sort_keys=True) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
26 (target_dir / 'inquiries.json').write_text(bundle) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
29def _get_line_chart_config(title: str) -> dict:
30 return { (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
31 'data': [{
32 'meta': {'columnNames': {'x': 'ts', 'y': 'value'}},
33 'mode': 'lines',
34 'type': 'scatter',
35 'x': None,
36 'xsrc': 'ts',
37 'y': None,
38 'ysrc': 'value',
39 'transforms': [{
40 'groups': None,
41 'groupssrc': 'pid',
42 'meta': {'columnNames': {'groups': 'pid'}},
43 'styles': [],
44 'type': 'groupby',
45 }],
46 }],
47 'frames': [],
48 'layout': {
49 'autosize': True,
50 'title': {'text': title},
51 'xaxis': {
52 'autorange': True,
53 'range': [],
54 'type': 'date'
55 },
56 'yaxis': {
57 'autorange': True,
58 'range': [],
59 'type': 'linear'
60 },
61 },
62 }
65def _get_sqliteviz_only_charts():
66 return [ (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
67 # Process Timeline PID
68 {
69 'id': 'csfOTEpzlFfYz7OUc2aGI',
70 'createdAt': '2023-09-03T12:00:00Z',
71 'name': 'Process Timeline, PID',
72 'query': textwrap.dedent('''
73 WITH RECURSIVE tree(pid, ppid, pid_comm) AS (
74 SELECT stat_pid, stat_ppid, stat_pid || ' ' || stat_comm
75 FROM record
76 GROUP BY 1
77 UNION
78 SELECT pid, stat_ppid, stat_pid || ' ' || stat_comm
79 FROM record, tree
80 WHERE record.stat_pid = tree.ppid
81 ), lookup AS (
82 SELECT pid, group_concat(pid_comm, ' / ') path_to_root
83 FROM tree
84 GROUP BY 1
85 )
86 SELECT
87 ts * 1000 AS ts,
88 stat_pid,
89 stat_pid || ' ' || stat_comm AS pid_comm,
90 iif(
91 length(cmdline) > 0,
92 substr(cmdline, 0, 75) || iif(length(cmdline) > 75, '...', ''),
93 stat_comm
94 ) || '<br>' || path_to_root AS cmd
95 FROM record
96 JOIN lookup ON stat_pid = pid
97 ''').strip(),
98 'viewType': 'chart',
99 'viewOptions': {
100 'data': [{
101 'type': 'scattergl',
102 'mode': 'markers',
103 'meta': {'columnNames': {'x': 'ts', 'y': 'stat_pid', 'text': 'cmd'}},
104 'transforms': [{
105 'type': 'groupby',
106 'styles': [],
107 'meta': {'columnNames': {'groups': 'pid_comm'}},
108 'groups': None,
109 'groupssrc': 'pid_comm',
110 }],
111 'y': None,
112 'ysrc': 'stat_pid',
113 'x': None,
114 'xsrc': 'ts',
115 'text': None,
116 'textsrc': 'cmd',
117 'marker': {'size': 12, 'maxdisplayed': 0},
118 'line': {'width': 3},
119 'hoverinfo': 'x+text',
120 }],
121 'layout': {
122 'xaxis': {
123 'type': 'date',
124 'range': [],
125 'autorange': True,
126 },
127 'yaxis': {
128 'type': 'category',
129 'range': [],
130 'autorange': True,
131 'showticklabels': False,
132 },
133 'title': {'text': 'Process Timeline, PID'},
134 'hovermode': 'closest',
135 },
136 'frames': [],
137 },
138 },
139 # Process Timeline CPU
140 {
141 'id': '4PBtpi7inEAe-yjtRHCi0',
142 'createdAt': '2023-09-03T12:00:00Z',
143 'name': 'Process Timeline, CPU',
144 'query': textwrap.dedent('''
145 WITH RECURSIVE tree(pid, ppid, pid_comm) AS (
146 SELECT stat_pid, stat_ppid, stat_pid || ' ' || stat_comm
147 FROM record
148 GROUP BY 1
149 UNION
150 SELECT pid, stat_ppid, stat_pid || ' ' || stat_comm
151 FROM record, tree
152 WHERE record.stat_pid = tree.ppid
153 ), path_lookup AS (
154 SELECT pid, group_concat(pid_comm, ' / ') path_to_root
155 FROM tree
156 GROUP BY 1
157 ), cpu_diff AS (
158 SELECT
159 ts,
160 stat_pid,
161 stat_ppid,
162 stat_priority,
163 stat_comm,
164 cmdline,
165 stat_utime + stat_stime - LAG(stat_utime + stat_stime) OVER (
166 PARTITION BY stat_pid
167 ORDER BY record_id
168 ) tick_diff,
169 ts - LAG(ts) OVER (
170 PARTITION BY stat_pid
171 ORDER BY record_id
172 ) ts_diff
173 FROM record
174 ), record_ext AS (
175 SELECT
176 *,
177 100.0 * tick_diff / (
178 SELECT value FROM meta WHERE key = 'clock_ticks'
179 ) / ts_diff cpu_usage
180 FROM cpu_diff
181 WHERE tick_diff IS NOT NULL
182 )
183 SELECT
184 ts * 1000 AS ts,
185 stat_pid,
186 stat_pid || ' ' || stat_comm AS pid_comm,
187 power(1.02, -r.stat_priority) priority_size,
188 cpu_usage,
189 iif(
190 length(cmdline) > 0,
191 substr(cmdline, 0, 75) || iif(length(cmdline) > 75, '...', ''),
192 stat_comm
193 )
194 || '<br>' || path_to_root
195 || '<br>' || 'CPU, %: ' || printf('%.2f', cpu_usage)
196 || '<br>' || 'priority: ' || stat_priority AS cmd
197 FROM record_ext r
198 JOIN path_lookup p ON r.stat_pid = p.pid
199 -- Tune the following CPU usage inequality for a clearer figure
200 WHERE cpu_usage > 0
201 ''').strip(),
202 'viewType': 'chart',
203 'viewOptions': {
204 'data': [{
205 'type': 'scattergl',
206 'mode': 'markers',
207 'meta': {
208 'columnNames': {
209 'text': 'cmd',
210 'x': 'ts',
211 'y': 'stat_pid',
212 'marker': {
213 'color': 'cpu_usage',
214 'size': 'priority_size',
215 },
216 },
217 },
218 'y': None,
219 'ysrc': 'stat_pid',
220 'x': None,
221 'xsrc': 'ts',
222 'text': None,
223 'textsrc': 'cmd',
224 'marker': {
225 'maxdisplayed': 0,
226 'color': None,
227 'colorsrc': 'cpu_usage',
228 'size': None,
229 'sizesrc': 'priority_size',
230 'sizeref': 0.00667,
231 'sizemode': 'area',
232 'showscale': True,
233 'colorbar': {'title': {'text': 'CPU, %'}},
234 'line': {'width': 0},
235 },
236 'line': {'width': 3},
237 'hoverinfo': 'x+text',
238 }],
239 'layout': {
240 'xaxis': {
241 'type': 'date',
242 'range': [],
243 'autorange': True,
244 },
245 'yaxis': {
246 'type': 'category',
247 'range': [],
248 'autorange': True,
249 'showticklabels': False,
250 },
251 'title': {'text': 'Process Timeline, CPU'},
252 'hovermode': 'closest',
253 },
254 'frames': [],
255 },
256 },
257 # Process Tree
258 {
259 'id': '3XXe7a80GvD6Trk9FyXRz',
260 'name': 'Process Tree',
261 'createdAt': '2023-09-03T12:00:00Z',
262 'query': textwrap.dedent('''
263 WITH lookup(pid, num) AS (
264 SELECT stat_pid, ROW_NUMBER() OVER(ORDER BY stat_pid) - 1
265 FROM record
266 GROUP BY 1
267 ), nodes AS (
268 SELECT
269 stat_pid,
270 -- Opt-in for special bare column processing to prefer the
271 -- first values (the minimum value is not used per se)
272 MIN(ts),
273 stat_ppid,
274 stat_pid || ' ' || stat_comm AS pid_comm,
275 iif(
276 length(cmdline) > 0,
277 substr(cmdline, 0, 75) || iif(length(cmdline) > 75, '...', ''),
278 stat_comm
279 ) cmd
280 FROM record
281 GROUP BY 1
282 )
283 SELECT p.num p_num, pp.num pp_num, pid_comm, cmd, 1 value
284 FROM nodes
285 JOIN lookup p ON stat_pid = p.pid
286 LEFT JOIN lookup pp ON stat_ppid = pp.pid
287 ORDER BY p.num
288 ''').strip(),
289 'viewType': 'chart',
290 'viewOptions': {
291 'data': [
292 {
293 'type': 'sankey',
294 'mode': 'markers',
295 'node': {'labelsrc': 'pid_comm'},
296 'link': {
297 'valuesrc': 'value',
298 'targetsrc': 'p_num',
299 'sourcesrc': 'pp_num',
300 'labelsrc': 'cmd'
301 },
302 'meta': {
303 'columnNames': {
304 'node': {'label': 'pid_comm'},
305 'link': {
306 'source': 'pp_num',
307 'target': 'p_num',
308 'value': 'value',
309 'label': 'cmd'
310 }
311 }
312 },
313 'orientation': 'h',
314 'hoverinfo': 'name',
315 'arrangement': 'freeform'
316 }
317 ],
318 'layout': {
319 'xaxis': {'range': [], 'autorange': True},
320 'yaxis': {'range': [], 'autorange': True},
321 'autosize': True,
322 'title': {'text': 'Process Tree'}
323 },
324 'frames': []
325 }
326 },
327 # Total Memory Consumption
328 {
329 'id': 'boSs15w7Endl5V9bABjXv',
330 'createdAt': '2023-09-03T12:00:00Z',
331 'name': 'Total Resident Set Size, MiB',
332 'query': textwrap.dedent('''
333 WITH downsampled_record AS (
334 SELECT
335 stat_pid,
336 -- Adjust downsampling factor
337 CAST(ts / 10 as INT) * 10 ts,
338 stat_comm,
339 cmdline,
340 MAX(stat_rss) stat_rss
341 FROM record
342 GROUP BY 1, 2
343 ), proc_group AS (
344 SELECT
345 -- Comment "stat_comm" group and uncomment this to have coarser grouping
346 -- CASE
347 -- WHEN cmdline LIKE '%firefox%' THEN '1. firefox'
348 -- WHEN cmdline LIKE '%chromium%' THEN '2. chromium'
349 -- ELSE '3. other'
350 -- END pgroup,
351 stat_comm pgroup,
352 ts,
353 SUM(stat_rss)
354 / 1024.0 / 1024 * (SELECT value FROM meta WHERE key = 'page_size') value
355 FROM downsampled_record
356 GROUP BY ts, 1
357 ORDER BY ts
358 ), proc_group_avg AS (
359 SELECT
360 ts,
361 pgroup,
362 AVG(value) OVER (
363 PARTITION BY pgroup
364 ORDER BY ts
365 -- Adjust centred moving average window
366 RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING
367 ) value
368 FROM proc_group
369 ), total_lookup(ts, total) AS (
370 SELECT ts, SUM(value)
371 FROM proc_group_avg
372 GROUP BY 1
373 )
374 SELECT
375 proc_group_avg.ts * 1000 ts,
376 pgroup,
377 value,
378 'total: ' || round(total, 1) || ' MiB' total
379 FROM proc_group_avg
380 JOIN total_lookup ON proc_group_avg.ts = total_lookup.ts
381 ORDER BY ts
382 ''').strip(),
383 'viewType': 'chart',
384 'viewOptions': {
385 'data': [{
386 'type': 'scatter',
387 'mode': 'lines',
388 'meta': {'columnNames': {'x': 'ts', 'y': 'value'}},
389 'transforms': [{
390 'type': 'groupby',
391 'groupssrc': 'pgroup',
392 'groups': None,
393 'styles': [],
394 'meta': {'columnNames': {'groups': 'pgroup'}},
395 }],
396 'stackgroup': 1,
397 'x': None,
398 'xsrc': 'ts',
399 'y': None,
400 'ysrc': 'value',
401 'text': None,
402 'textsrc': 'total',
403 'hoverinfo': 'x+text+name',
404 }],
405 'layout': {
406 'xaxis': {
407 'type': 'date',
408 'range': [],
409 'autorange': True,
410 },
411 'yaxis': {
412 'type': 'linear',
413 'range': [],
414 'autorange': True,
415 'separatethousands': True,
416 },
417 'title': {'text': 'Total Resident Set Size, MiB'},
418 'hovermode': 'closest',
419 },
420 'frames': []
421 },
422 },
423 # Total CPU Usage
424 {
425 'id': 'kd17-XGI85L2Oogj74Uyb',
426 'createdAt': '2023-09-03T12:00:00Z',
427 'name': 'Total CPU Usage, %',
428 'query': textwrap.dedent('''
429 WITH downsampled_record AS (
430 SELECT
431 stat_pid,
432 -- Adjust downsampling factor
433 CAST(ts / 10 as INT) * 10 ts,
434 stat_comm,
435 cmdline,
436 MAX(stat_utime) stat_utime,
437 MAX(stat_stime) stat_stime
438 FROM record
439 GROUP BY 1, 2
440 ), proc_cpu_diff AS (
441 SELECT
442 stat_pid,
443 ts,
444 stat_comm,
445 cmdline,
446 stat_utime + stat_stime - LAG(stat_utime + stat_stime) OVER (
447 PARTITION BY stat_pid
448 ORDER BY ts
449 ) tick_diff,
450 ts - LAG(ts) OVER (
451 PARTITION BY stat_pid
452 ORDER BY ts
453 ) ts_diff
454 FROM downsampled_record
455 ), proc_group AS (
456 SELECT
457 -- Comment "stat_comm" group and uncomment this to have coarser grouping
458 -- CASE
459 -- WHEN cmdline LIKE '%firefox%' THEN '1. firefox'
460 -- WHEN cmdline LIKE '%chromium%' THEN '2. chromium'
461 -- ELSE '3. other'
462 -- END pgroup,
463 stat_comm pgroup,
464 ts,
465 SUM(tick_diff) tick_diff,
466 AVG(ts_diff) ts_diff
467 FROM proc_cpu_diff
468 WHERE tick_diff IS NOT NULL
469 GROUP BY ts, 1
470 ORDER BY ts
471 ), proc_group_avg AS (
472 SELECT
473 ts,
474 pgroup,
475 AVG(
476 100.0
477 * tick_diff
478 / ts_diff
479 / (SELECT value FROM meta WHERE key = 'clock_ticks')
480 ) OVER (
481 PARTITION BY pgroup
482 ORDER BY ts
483 -- Adjust centred moving average window
484 RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING
485 ) value
486 FROM proc_group
487 ), total_lookup(ts, total) AS (
488 SELECT ts, SUM(value)
489 FROM proc_group_avg
490 GROUP BY 1
491 )
492 SELECT
493 proc_group_avg.ts * 1000 ts,
494 pgroup,
495 value,
496 'total: ' || round(total, 1) || ' %' total
497 FROM proc_group_avg
498 JOIN total_lookup ON proc_group_avg.ts = total_lookup.ts
499 ORDER BY ts
500 ''').strip(),
501 'viewType': 'chart',
502 'viewOptions': {
503 'data': [{
504 'type': 'scatter',
505 'mode': 'lines',
506 'meta': {'columnNames': {'x': 'ts', 'y': 'value'}},
507 'transforms': [{
508 'type': 'groupby',
509 'groupssrc': 'pgroup',
510 'groups': None,
511 'styles': [],
512 'meta': {'columnNames': {'groups': 'pgroup'}},
513 }],
514 'stackgroup': 1,
515 'x': None,
516 'xsrc': 'ts',
517 'y': None,
518 'ysrc': 'value',
519 'text': None,
520 'textsrc': 'total',
521 'hoverinfo': 'x+text+name',
522 }],
523 'layout': {
524 'xaxis': {
525 'type': 'date',
526 'range': [],
527 'autorange': True,
528 },
529 'yaxis': {
530 'type': 'linear',
531 'range': [],
532 'autorange': True,
533 'separatethousands': True,
534 },
535 'title': {'text': 'Total CPU Usage, %'},
536 'hovermode': 'closest',
537 },
538 'frames': []
539 },
540 },
541 ]
544def get_visualisation_bundle() -> dict:
545 """Get Sqliteviz import-able visualisation bundle."""
547 inquiries = [] (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
548 result = {'version': 2, 'inquiries': inquiries} (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
550 for query in procret.registry.values(): (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
551 query_text = query.get_short_query(ts_as_milliseconds=True) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
552 inquiries.append({ (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
553 'id': hashlib.md5(query_text.encode()).hexdigest()[:21],
554 'createdAt': '2023-09-03T12:00:00Z',
555 'name': query.title,
556 'query': textwrap.dedent(query_text).strip(),
557 'viewType': 'chart',
558 'viewOptions': _get_line_chart_config(query.title),
559 })
561 inquiries.extend(_get_sqliteviz_only_charts()) (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
563 return result (empty)procpath.test.cmd.TestExploreCommand.test_exploreprocpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.unit.TestSqlitevizQuery.test_total_cpu_usageprocpath.test.unit.TestSqlitevizQuery.test_total_memory_consumption
566class HttpRequestHandler(http.server.SimpleHTTPRequestHandler):
567 def send_head(self):
568 # Disable cache validation based on modified timestamp of the
569 # file because it's a symlink pointing to different files, and
570 # next one can easily be older than current one
571 if self.path == '/db.sqlite':
572 del self.headers['If-Modified-Since']
574 return super().send_head()
576 def end_headers(self):
577 if self.path == '/db.sqlite':
578 # The "no-store" response directive indicates that caches
579 # should not store this response. No point to try to cache
580 # big database files
581 self.send_header('Cache-Control', 'no-store')
582 else:
583 # The "no-cache" response directive indicates that the
584 # response can be stored in caches, but the response must
585 # be validated with the origin server before each reuse
586 self.send_header('Cache-Control', 'no-cache')
588 super().end_headers()
591def serve_dir(
592 bind: str, port: int, directory: str, *, server_cls=http.server.ThreadingHTTPServer
593):
594 handler_cls = partial(HttpRequestHandler, directory=directory)
595 with server_cls((bind, port), handler_cls) as httpd:
596 httpd.serve_forever()
599def symlink_database(database_file: str, sqliteviz_dir: Path) -> Path:
600 db_path = Path(database_file).absolute() procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database
601 if not db_path.exists(): procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_preload_database_missingprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database
602 raise FileNotFoundError procpath.test.cmd.TestExploreCommand.test_explore_preload_database_missing
604 sym_path = sqliteviz_dir / 'db.sqlite' procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database
605 sym_path.unlink(missing_ok=True) procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database
606 sym_path.symlink_to(db_path) procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database
607 return sym_path procpath.test.cmd.TestExploreCommand.test_explore_preload_databaseprocpath.test.cmd.TestExploreCommand.test_explore_serveprocpath.test.cmd.TestExploreCommand.test_symlink_database