2Code dependency tree analysis and visualization.
4Provides the Tree class for building and navigating compilation and execution
5dependency graphs across multiple FORTRAN source files.
9- Compilation dependency tracking (USE statements)
10- Execution dependency tracking (CALL statements)
11- Source directory scanning
12- Tree serialization to/from JSON
13- Dependency graph visualization (DOT format)
14- Scope reachability analysis
18Tree : Manages cross-file dependency analysis
22>>> tree = Tree(['/path/to/src'], descTreeFile='tree.json')
23>>> deps = tree.needsFile('file.F90')
24>>> tree.plotExecTreeFromScope('module:MOD/sub:SUB', 'deps.png', 2, 2)
25>>> tree.toJson('tree.json') # Save for later use
34from functools
import wraps
41def updateTree(method='file'):
43 Decorator factory to update the tree after PYFTscope method execution.
47 method : str, optional
49 - 'file' (default): Analyze current file.
50 - 'scan': Analyze new files, remove info for deleted files.
51 - 'signal': Analyze files signaled via tree.signal().
53 assert method
in (
'file',
'scan',
'signal')
57 def wrapper(self, *args, **kwargs):
58 result = func(self, *args, **kwargs)
61 self.tree.update(self)
62 elif method ==
'scan':
63 current = set(self.tree.getFiles())
64 old = set(self.tree.knownFiles())
65 self.tree.update(current.symmetric_difference(old))
66 elif method ==
'signal':
67 self.tree.update(self.tree.popSignaled())
75 Build and navigate the code dependency tree.
77 Analyzes FORTRAN source files to build compilation and execution
78 dependency graphs for cross-file analysis.
82 >>> tree = Tree(['/path/to/src'], descTreeFile='tree.json')
83 >>> deps = tree.needsFile('file.F90')
84 >>> tree.plotExecTreeFromScope('module:MOD/sub:SUB', 'deps.png', 2, 2)
86 def __init__(self, tree=None, descTreeFile=None,
87 parserOptions=None, wrapH=False,
90 :param tree: list of directories composing the tree or None
91 :param descTreeFile: filename where the description of the tree will be stored
92 :param parserOptions, wrapH: see the PYFT class
93 :param verbosity: if not None, sets the verbosity level
96 self.
_tree = []
if tree
is None else tree
106 self.
_cwd = os.getcwd()
116 if descTreeFile
is not None and os.path.exists(descTreeFile):
118 elif tree
is not None:
120 if descTreeFile
is not None:
125 :return: a dict containing the full content of the instance
127 return {
'tree': self.
_tree,
145 :param content: Fill the current instance with this dict
147 self.
_tree = content[
'tree']
150 self.
_wrapH = content[
'wrapH']
152 self.
_cwd = content[
'cwd']
153 self.
_scopes = content[
'scopes']
165 Sets self to be a copy of other
171 Sets other to be a copy of self
177 Method used for signaling a modified file which needs to be analized
178 :param filename: file name or PYFTscope object
184 :return: the list of file signaled for update and empties the list
192 :return: the list of analysez file names
194 return list(self.
_scopes.keys())
198 """Is the Tree object valid"""
203 """List of directories"""
209 :param tree: list of directories composing the tree or None
210 :return: list of directories and subdirectories
213 if self.
tree is not None:
214 for tDir
in self.
tree:
215 result += glob.glob(tDir +
'/**/', recursive=
True)
221 :param tree: list of directories composing the tree or None
222 :return: list of directories and subdirectories
225 for tDir
in self.
tree:
226 for filename
in glob.glob(tDir +
'/**/*', recursive=
True):
227 if os.path.splitext(filename)[1]
not in (
'',
'.json',
'.fypp',
230 '.sh',
'.py',
'.pyc'):
232 filenames.append(filename)
238 Builds the self._* variable
247 Updates the object when a file has changed
248 :param file: name of the file (or list of names) with updated content
252 if not isinstance(file, (list, set)):
260 """Empties cached values"""
267 """Fill and return the self._cacheIncInScope cached value"""
275 def _compilationTree(self):
276 """Fill and return the self._cacheCompilationTree cached value"""
281 for filename, incScopePaths
in self.
_includeList.items():
283 for scopePath, incList
in incScopePaths.items():
293 if os.path.normpath(inc) == os.path.normpath(file):
296 elif ((
not os.path.isabs(file))
and
297 os.path.realpath(inc) == os.path.realpath(os.path.join(
298 os.path.dirname(inc), file))):
302 elif os.path.basename(inc) == os.path.basename(file):
304 basename.append(file)
306 same = subdir = basename = []
308 subdir = basename = []
309 if len(basename) > 1:
313 incFilename = same[0]
314 elif len(subdir) > 0:
315 incFilename = subdir[0]
316 elif len(basename) > 0:
317 incFilename = basename[0]
327 for filename, uList
in self.
_useList.items():
329 for modName, _
in [use
for li
in uList.values()
for use
in li]:
330 moduleScopePath =
'module:' + modName
333 for file, scopes
in self.
_scopes.items():
334 if moduleScopePath
in scopes:
339 logging.info(
'Several or none file containing the scope path ' +
340 '%s have been found for file %s',
341 moduleScopePath, filename)
351 def _executionTree(self):
352 """Fill and return the self._cacheCompilationTree cached value"""
356 allScopes = [scopePath
for _, l
in self.
_scopes.items()
for scopePath
in l]
359 for filename, callScopes
in progList.items():
361 for scopePath, cList
in callScopes.items():
363 for call
in set(cList):
368 foundInSameScope = []
371 for kind
in (canonicKind,
'interface'):
373 uList = [self.
_useList[filename][sc]
375 if (sc == scopePath
or scopePath.startswith(sc +
'/'))]
376 for modName, only
in [use
for li
in uList
for use
in li]:
377 moduleScope =
'module:' + modName
378 callScope = moduleScope +
'/' + kind +
':' + call
381 if call
in only
and callScope
in allScopes:
382 foundInUse.append(callScope)
385 for _, scopes
in self.
_scopes.items():
386 if callScope
in scopes:
387 foundInUse.append(callScope)
390 callScope = kind +
':' + call
391 for _, scopes
in self.
_scopes.items():
392 if callScope
in scopes:
393 foundElsewhere.append(callScope)
396 callScope = kind +
':' + call
398 if callScope
in self.
_scopes[incFile]:
399 foundInInclude.append(callScope)
402 callScope = scopePath +
'/' + kind +
':' + call
403 if callScope
in self.
_scopes[filename]:
404 foundInContains.append(callScope)
408 callScope = scopePath.rsplit(
'/', 1)[0] +
'/' + kind + \
411 callScope = kind +
':' + call
412 if callScope
in self.
_scopes[filename]:
413 foundInSameScope.append(callScope)
416 foundInUse = list(set(foundInUse))
417 if len(foundInUse + foundInInclude +
418 foundInContains + foundInSameScope) > 1:
419 logging.error(
'Several definition of the program unit found for '
420 '%s called in %s:', call, scopePath)
421 logging.error(
' found %i time(s) in USE statements',
423 logging.error(
' found %i time(s) in include files',
425 logging.error(
' found %i time(s) in CONTAINS block',
426 len(foundInContains))
427 logging.error(
' found %i time(s) in the same scope',
428 len(foundInSameScope))
430 elif len(foundInUse + foundInInclude +
431 foundInContains + foundInSameScope) == 1:
432 rr = (foundInUse + foundInInclude +
433 foundInContains + foundInSameScope)[0]
434 if canonicKind !=
'func' or rr
in allScopes:
436 elif len(foundElsewhere) > 1:
437 logging.info(
'Several definition of the program unit found for '
438 '%s called in %s', call, scopePath)
439 elif len(foundElsewhere) == 1:
442 if canonicKind !=
'func':
443 logging.info(
'No definition of the program unit found for '
444 '%s called in %s', call, scopePath)
450 for item
in list(execList):
451 itemSplt = item.split(
'/')[-1].split(
':')
452 if itemSplt[0] ==
'interface' and itemSplt[1] !=
'--UNKNOWN--':
454 filenames = [k
for (k, v)
in self.
_scopes.items()
if item
in v]
455 if len(filenames) == 1:
457 execList.remove(item)
458 for sub
in [sub
for sub
in self.
_scopes[filenames[0]]
459 if sub.startswith(item +
'/')]:
460 subscopeIn = sub.rsplit(
'/', 2)[0] +
'/' + sub.split(
'/')[-1]
461 if subscopeIn
in self.
_scopes[filenames[0]]:
463 execList.append(subscopeIn)
465 execList.append(sub.split(
'/')[-1])
476 :param file: Name of the file to explore, or PYFTscope object
477 :return: dict of use, include, call, function and scope list
479 def extractString(text):
481 if text[0]
in (
'"',
"'"):
482 assert text[-1] == text[0]
490 filename = pft.getFileName()
497 filename = filename[2:]
if filename.startswith(
'./')
else filename
505 scopes = pft.getScopes()
508 self.
_scopes[filename].append(scope.path)
510 if scope.path.split(
'/')[-1].split(
':')[0] ==
'interface':
511 for name
in [n2name(nodeN).upper()
512 for moduleproc
in scope.findall(
'./{*}procedure-stmt')
513 for nodeN
in moduleproc.findall(
'./{*}module-procedure-N-LT/' +
516 if re.search(scope.path.rsplit(
'/', 1)[0] +
'/[a-zA-Z]*:' + name,
518 self.
_scopes[filename].append(scope.path +
'/' +
519 sc.path.split(
'/')[-1])
526 [file.text
for file
in scope.findall(
'.//{*}include/{*}filename')]
528 [extractString(file.text)
529 for file
in scope.findall(
'.//{*}include/{*}filename/{*}S')])
533 self.
_useList[filename][scope.path] = []
534 for use
in scope.findall(
'.//{*}use-stmt'):
535 modName = n2name(use.find(
'./{*}module-N/{*}N')).upper()
536 only = [n2name(n).upper()
for n
in use.findall(
'.//{*}use-N//{*}N')]
537 self.
_useList[filename][scope.path].append((modName, only))
542 list(set(n2name(call.find(
'./{*}procedure-designator/{*}named-E/{*}N')).upper()
543 for call
in scope.findall(
'.//{*}call-stmt')))
545 self.
_funcList[filename][scope.path] = set()
546 for name
in [n2name(call.find(
'./{*}N')).upper()
547 for call
in scope.findall(
'.//{*}named-E/{*}R-LT/{*}parens-R/../..')]:
549 var = scope.varList.findVar(name)
550 if var
is None or var[
'as']
is None:
551 self.
_funcList[filename][scope.path].add(name)
564 with open(filename,
'r', encoding=
'utf-8')
as file:
565 descTree = json.load(file)
566 self.
_cwd = descTree[
'cwd']
567 self.
_scopes = descTree[
'scopes']
576 descTree = {
'cwd': self.
_cwd,
584 descTree[
'scopes'] = {k: sorted(descTree[
'scopes'][k])
for k
in sorted(descTree[
'scopes'])}
585 for cat
in (
'useList',
'includeList',
'callList',
'funcList'):
586 descTree[cat] = {file: {scope: sorted(descTree[cat][file][scope])
587 for scope
in sorted(descTree[cat][file])}
588 for file
in sorted(descTree[cat])}
590 with open(filename,
'w', encoding=
'utf-8')
as file:
591 json.dump(descTree, file, indent=2)
596 Return the name of the file defining the scope
597 :param scopePath: scope path to search for
598 :return: list file names in which scope is defined
600 return [filename
for filename, scopes
in self.
_scopes.items()
if scopePath
in scopes]
605 Return the scopes contained in the file
606 :param filename: name of the file tn inspect
607 :return: list of scopes defined in the file
614 :param node: initial node
615 :param descTreePart: 'compilation_tree' or 'execution_tree' part of a descTree object
616 :param level: number of levels (0 to get only the initial node, None to get all nodes)
617 :param down: True to get the nodes lower in the tree, False to get the upper ones
618 :return: list of nodes lower or upper tahn initial node (recursively)
620 def recur(nnn, level, currentList):
622 result = descTreePart.get(nnn, [])
624 result = [item
for (item, l)
in descTreePart.items()
if nnn
in l]
625 if level
is None or level > 1:
626 for res
in list(result):
627 if res
not in currentList:
628 result.extend(recur(res,
None if level
is None else level - 1, result))
630 return recur(node, level, [])
635 :param filename: initial file name
636 :param level: number of levels (0 to get only the initial file, None to get all files)
637 :return: list of file names needed by the initial file (recursively)
644 :param filename: initial file name
645 :param level: number of levels (0 to get only the initial file, None to get all files)
646 :return: list of file names that needs the initial file (recursively)
653 :param scopePath: initial scope path
654 :param level: number of levels (0 to get only the initial scope path,
655 None to get all scopes)
656 :return: list of scopes called by the initial scope path (recursively)
663 :param scopePath: initial scope path
664 :param level: number of levels (0 to get only the initial scope path,
665 None to get all scopes)
666 :return: list of scopes that calls the initial scope path (recursively)
672 includeInterfaces=False, includeStopScopes=False):
674 :param scopePath: scope path to test
675 :param stopScopes: list of scopes
676 :param includeInterfaces: if True, interfaces of positive scopes are also positive
677 :param includeInterfaces: if True, scopes that are in stopScopes return True
678 :return: True if the scope path is called directly or indirectly by one of the scope
679 paths listed in stopScopes
681 scopeSplt = scopePath.split(
'/')
682 if includeInterfaces
and len(scopeSplt) >= 2
and scopeSplt[-2].split(
':')[0] ==
'interface':
685 scopeI = scopeSplt[-1]
689 includeStopScopes=includeStopScopes)
693 return (any(scp
in upperScopes
for scp
in stopScopes)
or
694 (includeStopScopes
and scopePath
in stopScopes))
697 def plotTree(self, centralNodeList, output, plotMaxUpper, plotMaxLower, kind, frame=False):
699 Compute a dependency graph
700 :param centralNodeList: file, scope path, list of files or list of scope paths
701 :param output: output file name (.dot or .png extension)
702 :param plotMaxUpper: Maximum number of elements to plot, upper than the central element
703 :param plotMaxLower: Maximum number of elements to plot, lower than the central element
704 :param kind: must be 'compilation_tree' or 'execution_tree'
705 :param frame: True to plot a frame grouping the central nodes
707 assert kind
in (
'compilation_tree',
'execution_tree')
713 if objHash
not in hashValues:
715 hashValues[objHash] = hashValues[0]
716 return str(hashValues[objHash])
718 def createNode(node, label=None):
720 if label
is not None:
721 result +=
"subgraph cluster_" + myHash(node) +
" {\n"
722 result += f
'label="{label}"\n'
723 if kind ==
'execution_tree':
724 color =
'blue' if node.split(
'/')[-1].split(
':')[0] ==
'func' else 'green'
727 result += myHash(node) + f
' [label="{node}" color="{color}"]\n'
728 if label
is not None:
732 def createLink(file1, file2):
733 return myHash(file1) +
' -> ' + myHash(file2) +
'\n'
735 def createCluster(nodes, label=None):
736 result =
"subgraph cluster_R {\n"
737 result +=
"{rank=same " + (
' '.join([myHash(node)
for node
in nodes])) +
"}\n"
738 if label
is not None:
739 result += f
'label="{label}"\n'
747 def filename(scopePath):
748 if kind ==
'compilation_tree':
750 return [f
for f, l
in self.
_scopes.items()
if scopePath
in l][0]
752 def recur(node, level, down, var):
753 if level
is None or level > 0:
755 result = var.get(node, [])
757 result = [f
for f, l
in var.items()
if node
in l]
759 add(createNode(res, filename(res)))
760 add(createLink(node, res)
if down
else createLink(res, node))
761 if level
is None or level > 1:
762 recur(res,
None if level
is None else level - 1, down, var)
766 if kind ==
'execution_tree':
767 centralScopeFilenames = []
768 for scopePath
in centralNodeList:
769 centralScopeFilenames.append(filename(scopePath))
770 centralScopeFilenames = list(set(centralScopeFilenames))
771 if len(centralScopeFilenames) == 1:
779 var = {k: sorted(var[k])
for k
in sorted(var)}
781 dot = [
"digraph D {\n"]
782 if not isinstance(centralNodeList, list):
783 centralNodeList = [centralNodeList]
784 for centralNode
in centralNodeList:
785 add(createNode(centralNode,
None if printInFrame
else filename(centralNode)))
786 recur(centralNode, plotMaxLower,
True, var)
787 recur(centralNode, plotMaxUpper,
False, var)
789 if kind ==
'compilation_tree':
792 frameText = centralScopeFilenames[0]
if printInFrame
else None
793 add(createCluster(centralNodeList, frameText))
796 fmt = os.path.splitext(output)[1].lower()[1:]
798 with open(output,
'w', encoding=
'utf-8')
as file:
801 dotCommand = [
'dot',
'-T' + fmt,
'-o', output]
802 logging.info(
'Dot command: %s',
' '.join(dotCommand))
803 subprocess.run(dotCommand, input=dot.encode(
'utf8'), check=
True)
808 Compute the compilation dependency graph
809 :param filename: central file
810 :param output: output file name (.dot or .png extension)
811 :param plotMaxUpper: Maximum number of elements to plot, upper than the central element
812 :param plotMaxLower: Maximum number of elements to plot, lower than the central element
814 return self.
plotTree(filename, output, plotMaxUpper, plotMaxLower,
'compilation_tree',
True)
819 Compute the execution dependency graph
820 :param scopePath: central scope path
821 :param output: output file name (.dot or .png extension)
822 :param plotMaxUpper: Maximum number of elements to plot, upper than the central element
823 :param plotMaxLower: Maximum number of elements to plot, lower than the central element
825 return self.
plotTree(scopePath, output, plotMaxUpper, plotMaxLower,
'execution_tree')
830 Compute the compilation dependency graph
831 :param scopePath: central scope path
832 :param output: output file name (.dot or .png extension)
833 :param plotMaxUpper: Maximum number of elements to plot, upper than the central element
834 :param plotMaxLower: Maximum number of elements to plot, lower than the central element
842 Compute the execution dependency graph
843 :param filename: central filename
844 :param output: output file name (.dot or .png extension)
845 :param plotMaxUpper: Maximum number of elements to plot, upper than the central element
846 :param plotMaxLower: Maximum number of elements to plot, lower than the central element
849 'execution_tree',
True)
854 Return the file name containing an interface for the scope path
855 :param scopePath: scope path for which an interface is searched
856 :return: (file name, interface scope) or (None, None) if not found
858 for filename, scopes
in self.
_scopes.items():
859 for scopeInterface
in scopes:
860 if re.search(
r'interface:[a-zA-Z0-9_-]*/' + scopePath +
r'$', scopeInterface):
861 return filename, scopeInterface