Repository: stfc/PSyclone
Pull request #2833: Martin fileinfo cache 2
Author: login: schreiberx
Labels: name: ready to be merged
Created: 2024-12-19T16:39:26Z | Merged: 2025-03-26T16:27:05Z

This is the final PR for the updates on caching fparser (and later on psyir).

This update avoids using the same file multiple times for individual modules in each file.


Diff:
diff --git a/changelog b/changelog
index 124173842e..61eab79f71 100644
--- a/changelog
+++ b/changelog
@@ -1,3 +1,7 @@
+   5) PR #2833. Extends the fparser caching functionality to allow the
+   location of the cache files to be configured. Also avoids repeated
+   parsing of a single file containing multiple modules.
+
    4) PR #2664 for #1664. Add OpenACC async clause and ACCWait directive.
 
    3) PR #2828 - adds a new ReplaceReferenceByLiteralTrans transformation.   
diff --git a/doc/developer_guide/module_manager.rst b/doc/developer_guide/module_manager.rst
index bcad26a6c3..020a071ee8 100644
--- a/doc/developer_guide/module_manager.rst
+++ b/doc/developer_guide/module_manager.rst
@@ -108,7 +108,7 @@ access to the PSyIR of the ``Container`` representing the module:
 
 .. automethod:: psyclone.parse.ModuleInfo.get_psyir
 
-However, it also provides methods (``get_used_modules``,
+However, it also provides methods (``get_used_module_names``,
 ``get_used_symbols_from_modules``) for interrogating the parse tree which
 can be useful if it is not possible to represent this in PSyIR.
 
@@ -124,7 +124,7 @@ which prints the filenames of all modules used in ``tl_testkern_mod``:
 
     testkern_info = mod_manager.get_module_info("tl_testkern_mod")
 
-    used_mods = testkern_info.get_used_modules()
+    used_mods = testkern_info.get_used_module_names()
     # Sort the modules so we get a reproducible output ordering
     used_mods_list = sorted(list(used_mods))
     for module_name in used_mods_list:
@@ -169,7 +169,7 @@ of `ModuleManager`.
 
 .. testcode ::
 
-    mod_manager = ModuleManager.get(use_caching=True)
+    mod_manager = ModuleManager.get(cache_active=True)
 
 
 Most of the time in the PSyIR generation is currently spent in the
@@ -177,10 +177,40 @@ fparser tree generation. Consequently, this leads to significant
 speed-ups in the process of reading and parsing the source code
 of modules.
 
+
+
+Default cache file locations
+----------------------------
+
+
 The default cache file is named the same way as the source file,
 but replaces the file extension with `.psycache`. E.g., a cache file
 for the source file `foo.f90` will be called `foo.psycache`.
 
+
+
+(Global) cache file folder
+--------------------------
+
+To avoid storing cache files together with source code files,
+a path can be provided to the module manager.
+
+.. testcode ::
+
+    mod_manager = ModuleManager.get(cache_active=True,
+                     cache_path="/tmp/my_cache_path")
+
+A cache file name will then be created based on the hashsum of each
+source code file. The combination of the provided `cache_path` and
+the cache file name will then be used as the storage location.
+
+Note, that the cache path directory must exist.
+
+
+
+Caching algorithm
+-----------------
+
 The caching algorithm to obtain the fparser tree OR PSyIR is briefly described as follows:
 
 - If fparser tree / PSyIR was read before: RETURN fparser tree or PSyIR
diff --git a/psyclone.pdf b/psyclone.pdf
index 856eed88d8..8082c78770 100644
Binary files a/psyclone.pdf and b/psyclone.pdf differ
diff --git a/src/psyclone/domain/lfric/lfric_extract_driver_creator.py b/src/psyclone/domain/lfric/lfric_extract_driver_creator.py
index d3a2824e31..ec921358ce 100644
--- a/src/psyclone/domain/lfric/lfric_extract_driver_creator.py
+++ b/src/psyclone/domain/lfric/lfric_extract_driver_creator.py
@@ -1007,7 +1007,8 @@ def collect_all_required_modules(file_container):
                             if isinstance(symbol, ContainerSymbol))
 
         mod_manager = ModuleManager.get()
-        return mod_manager.get_all_dependencies_recursively(all_mods)
+        return mod_manager.get_all_dependencies_recursively(
+            list(all_mods))
 
     # -------------------------------------------------------------------------
     def get_driver_as_string(self, nodes, read_write_info, prefix, postfix,
diff --git a/src/psyclone/parse/file_info.py b/src/psyclone/parse/file_info.py
index 06b7f25149..ae866550b0 100644
--- a/src/psyclone/parse/file_info.py
+++ b/src/psyclone/parse/file_info.py
@@ -87,15 +87,28 @@ class FileInfo:
         object holds information on. Can also be set to 'None' in case of
         providing fparser / PSyIR node in a different way.
     :param use_caching: Use caching of intermediate representations
+    :param cache_path: Path to directory where to put cache files.
+        If it is provided, the file name for caching will include
+        the hash sum to avoid conflicting file names.
+        This allows using, e.g., `~/.cache/psyclone` as a cache
+        directory for all cached files.
+        See _get_filepath_cache() for more information.
 
     """
-    def __init__(self, filepath: str, use_caching: bool = False):
+    def __init__(self,
+                 filepath: str,
+                 cache_active: bool = False,
+                 cache_path: str = None
+                 ):
 
         # Full path to file
         self._filename: str = filepath
 
         # Use cache features
-        self._use_caching: bool = use_caching
+        self._cache_active: bool = cache_active
+
+        # Cache filepath
+        self._cache_path = cache_path
 
         # Source code:
         self._source_code: str = None
@@ -113,9 +126,8 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # Psyir node
         self._psyir_node: FileContainer = None
 
-        # Single cache file
-        (path, ext) = os.path.splitext(self._filename)
-        self._filepath_cache = path + ".psycache"
+        # Filepath of cache
+        self._cache_filename = None
 
         # This reference to `_CacheFileInfo` is created when loading
         # cached information from a cache file.
@@ -131,6 +143,47 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_cache_filepath(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        if self._cache_filename is not None:
+            return self._cache_filename
+
+        if self._cache_path is None:
+            # If cache path is not specified, we use the source code path
+            # E.g.,
+            # path/to/file.f90 => path/to/file.psycache
+            (filepath_no_ext, _) = os.path.splitext(self._filename)
+
+            self._cache_filename = filepath_no_ext + ".psycache"
+            return self._cache_filename
+
+        # Cache path was specified.
+        # We assume this path is shared amongst different projects
+        # where psyclone is used for. We can't just use the file
+        # name of the source file itself, since the same one
+        # could be used in different projects and lead to
+        # conflicting file names. Hence, we use a hashsum based
+        # on the content of the source code itself.
+        # This also avoids having separate cache files for files
+        # with the same content spread across different projects.
+
+        # Therefore, we associate each cache file to a hashsum.
+        # The hashsum is truncated so that the entire filename
+        # fits in a 64 byte character string.
+        return os.path.join(
+            self._cache_path, self._source_code_hash_sum[:55] + ".psycache"
+        )
+
     @property
     def basename(self):
         '''
@@ -190,21 +243,27 @@ def get_source_code(self, verbose: bool = False) -> str:
                 f"FileInfo: No such file or directory '{self._filename}'."
             ) from err
 
-        if self._use_caching:
-            # Only update if caching is used.
-            # Compute hash sum which will be used to
-            # check cache of fparser tree
+        if verbose:
+            # TODO #11: Use logging for this
+            print(
+                f"- Source file '{self._filename}': "
+                f"Loading source code"
+            )
+
+        if self._cache_active:
+            # Update the hash sum
             self._source_code_hash_sum = hashlib.md5(
-                self._source_code.encode()
-            ).hexdigest()
+                self._source_code.encode()).hexdigest()
 
         return self._source_code
 
     def _cache_load(
         self,
         verbose: bool = False,
+        indent: str = ""
     ) -> _CacheFileInfo:
         """Load fparser parse tree from the cache file if possible.
+
         This also checks for matching checksums after loading the data
         from the cache.
         The checksum is based solely on a hashsum of the source code itself,
@@ -213,7 +272,7 @@ def _cache_load(
         :param verbose: Produce some verbose output
         """
 
-        if not self._use_caching:
+        if not self._cache_active:
             return
 
         # Load the source code in case it's not yet loaded.
@@ -230,18 +289,29 @@ def _cache_load(
         # basically garbage. This will lead either to an Exception from the
         # unpickling or a non-matching checksum which is both caught below.
         try:
-            filehandler = open(self._filepath_cache, "rb")
+            filehandler = open(self._get_cache_filepath(), "rb")
+            if verbose:
+                # TODO #11: Use logging for this
+                print(
+                    f"{indent}- Using cache file "
+                    f"'{self._get_cache_filepath()}'"
+                )
         except FileNotFoundError:
             if verbose:
                 # TODO #11: Use logging for this
-                print(f"  - No cache file '{self._filepath_cache}' found")
+                print(
+                    f"{indent}- No cache file "
+                    f"'{self._get_cache_filepath()}' found"
+                )
             return None
 
         # Unpack cache file
         try:
             cache: _CacheFileInfo = pickle.load(filehandler)
         except Exception as ex:
-            print(f"  - Error while reading cache file - ignoring: {str(ex)}")
+            print(f"{indent}  - Error while reading cache file -"
+                  f" ignoring: {str(ex)}"
+                  )
             return None
 
         # Verify checksums
@@ -270,7 +340,7 @@ def _cache_save(
         :param verbose: Produce some verbose output
         """
 
-        if not self._use_caching:
+        if not self._cache_active:
             return None
 
         if self._source_code_hash_sum is None:
@@ -326,15 +396,15 @@ def _cache_save(
             # This is not a perfect solution, but avoids parallel
             # writing access of the same file.
 
-            # We first remove a potentially existing file
+            # We first remove a potentially existing file.
             try:
-                os.remove(self._filepath_cache)
+                os.remove(self._get_cache_filepath())
             except FileNotFoundError:
                 pass
 
             # Then we open it in exclusive mode.
             # If it already exists, an exception would be raised.
-            fd = os.open(self._filepath_cache,
+            fd = os.open(self._get_cache_filepath(),
                          os.O_CREAT | os.O_WRONLY | os.O_EXCL)
 
             filehandler = os.fdopen(fd, "wb")
@@ -432,10 +502,16 @@ def get_fparser_tree(
 
         return self._fparser_tree
 
-    def get_psyir(self, verbose: bool = False) -> FileContainer:
-        """Returns the PSyIR FileContainer of the file.
+    def get_psyir(
+            self,
+            verbose: bool = False,
+            indent: str = ""
+    ) -> FileContainer:
+        """Returns the psyclone FileContainer of the file.
 
         :param verbose: Produce some verbose output
+        :param indent: String used for indentation of each line
+            for verbose output.
 
         :returns: PSyIR file container node.
 
@@ -444,21 +520,21 @@ def get_psyir(self, verbose: bool = False) -> FileContainer:
             return self._psyir_node
 
         # Check for cache
-        self._cache_load(verbose=verbose)
+        self._cache_load(verbose=verbose, indent=indent)
 
         if self._cache_data_load is not None:
             if self._cache_data_load._psyir_node is not None:
                 # Use cached version
                 if verbose:
                     # TODO #11: Use logging for this
-                    print("  - Using cache of PSyIR")
+                    print(f"{indent}- Using cache of PSyIR")
 
                 self._psyir_node = self._cache_data_load._psyir_node
                 return self._psyir_node
 
         if verbose:
             # TODO #11: Use logging for this
-            print(f"  - Running psyir for '{self._filename}'")
+            print(f"{indent}- Running psyir for '{self._filename}'")
 
         # First, we get the fparser tree
         fparse_tree = self.get_fparser_tree(
diff --git a/src/psyclone/parse/module_info.py b/src/psyclone/parse/module_info.py
index 8db153a146..79249746de 100644
--- a/src/psyclone/parse/module_info.py
+++ b/src/psyclone/parse/module_info.py
@@ -87,7 +87,8 @@ class ModuleInfo:
     def __init__(
         self,
         module_name: str,
-        file_info: FileInfo
+        file_info: FileInfo,
+        psyir_container_node: Container = None
     ):
         if not isinstance(module_name, str):
             raise TypeError("Expected type 'str' for argument 'module_name'")
@@ -102,7 +103,7 @@ def __init__(
         self._file_info: FileInfo = file_info
 
         # The PSyIR representation
-        self._psyir_container_node: Container = None
+        self._psyir_container_node: Container = psyir_container_node
 
         # A cache for the module dependencies: this is just a set
         # of all modules USEd by this module.
@@ -205,7 +206,7 @@ def _extract_import_information(self):
             self._map_module_name_to_used_symbols[mod_name] = all_symbols
 
     # ------------------------------------------------------------------------
-    def get_used_modules(self) -> List[str]:
+    def get_used_module_names(self) -> List[str]:
         '''This function returns a set of all modules `used` in this
         module. Fortran `intrinsic` modules will be ignored. The information
         is based on the fparser parse tree of the module (since fparser can
@@ -213,7 +214,6 @@ def get_used_modules(self) -> List[str]:
         pre-processor directives).
 
         :returns: a set with all imported module names.
-        :rtype: set[str]
 
         '''
         if self._used_module_names is None:
@@ -334,5 +334,5 @@ def view_tree(self, indent=""):
         retstr = ""
         retstr += f"{indent}- name: '{self.name}'\n"
         retstr += (f"{indent}- used_module_names:"
-                   f" {self.get_used_modules()}\n")
+                   f" {self.get_used_module_names()}\n")
         return retstr
diff --git a/src/psyclone/parse/module_manager.py b/src/psyclone/parse/module_manager.py
index 9f9076694f..ca2f90680f 100644
--- a/src/psyclone/parse/module_manager.py
+++ b/src/psyclone/parse/module_manager.py
@@ -37,24 +37,30 @@
 which module is contained in which file (including full location). '''
 
 
-from collections import OrderedDict
 import copy
 from difflib import SequenceMatcher
+from typing import Dict, Iterable, List, Set, Union, OrderedDict
 import os
 import re
 
 from psyclone.errors import InternalError
 from psyclone.parse.file_info import FileInfo
 from psyclone.parse.module_info import ModuleInfo, ModuleInfoError
+from psyclone.psyir.nodes import Container, Node, Routine
 
 
 class ModuleManager:
-    '''This class implements a singleton that manages module
-    dependencies.
+    """
+    This class implements a singleton that manages module
+    dependencies. It should not be created directly.
+    Use `ModuleManager.get()` instead.
 
-    :param use_caching: Whether to use (`True`) or
+    :param cache_active: Whether to use (`True`) or
         disable (`False`) caching
-    '''
+    :param cache_path: Path to the cache directory. If `None`, the
+        cache file will be created in the same directory as the source
+        file with a new file ending `.psycache`.
+    """
 
     # Class variable to store the singleton instance
     _instance = None
@@ -63,9 +69,8 @@ class ModuleManager:
     # of the file.
     _threshold_similarity = 0.7
 
-    # ------------------------------------------------------------------------
     @staticmethod
-    def get(use_caching: bool = None):
+    def get(cache_active: bool = None, cache_path: str = None):
         '''Static function that if necessary creates and returns the singleton
         ModuleManager instance.
 
@@ -76,26 +81,47 @@ def get(use_caching: bool = None):
             can happen, but this shouldn't lead to wrong results. However,
             that's untested so far.
 
+        :param cache_path: If set, the cache file will be stored in the given
+            path (directory) using a hashsum of the source code to create
+            a unique cache file name. If `None`, a cache file will be created
+            in the same directory as the source file with a new
+            file ending `.psycache`.
+
         '''
         if not ModuleManager._instance:
-            ModuleManager._instance = ModuleManager(use_caching)
+            ModuleManager._instance = ModuleManager(cache_active, cache_path)
 
         return ModuleManager._instance
 
     # ------------------------------------------------------------------------
     def __init__(
-                self,
-                use_caching: bool = None
-            ):
+            self,
+            cache_active: bool = None,
+            cache_path: str = None
+    ):
+        """
+        Set up the module manager. Module manager is actually a singleton
+        and should not be created directly. Use `ModuleManager.get()`
+        instead.
+
+        :param cache_active: Whether to use (`True`) or
+            disable (`False`) caching
+        :param cache_path: Path to the cache directory. If `None`, the
+            cache file will be created in the same directory as the source
+            file with a new file ending `.psycache`.
+        """
 
         if ModuleManager._instance is not None:
             raise InternalError("You need to use 'ModuleManager.get()' "
                                 "to get the singleton instance.")
 
         # Disable caching by default
-        self._use_caching = use_caching if use_caching is not None else False
+        self._cache_active = (
+            cache_active if cache_active is not None else False)
+
+        # Path to cache
+        self._cache_path: str = cache_path
 
-        self._modules = {}
         self._visited_files = {}
 
         # The list of all search paths which have not yet all their files
@@ -104,6 +130,18 @@ def __init__(
         self._remaining_search_paths = OrderedDict()
         self._original_search_paths = []
 
+        # Ordered dictionary to lookup file info from file path
+        self._filepath_to_file_info: OrderedDict[str, FileInfo] = OrderedDict()
+
+        # Ordered dictionary to lookup ModuleInfo from a file path
+        # Note that there can be multiple modules per file
+        self._filepath_to_module_info: OrderedDict[str, List[ModuleInfo]] = \
+            OrderedDict()
+
+        # Dictionary of ModuleInfo objects, indexed by module name
+        self._modules: OrderedDict[str, ModuleInfo] = \
+            OrderedDict()
+
         self._ignore_modules = set()
 
         # Setup the regex used to find Fortran modules. Have to be careful not
@@ -166,7 +204,11 @@ def _add_all_files_from_dir(self, directory):
                 if full_path in self._visited_files:
                     continue
                 self._visited_files[full_path] = \
-                    FileInfo(full_path, use_caching=self._use_caching)
+                    FileInfo(
+                            full_path,
+                            cache_active=self._cache_active,
+                            cache_path=self._cache_path
+                        )
                 new_files.append(self._visited_files[full_path])
         return new_files
 
@@ -222,21 +264,172 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, Iterable[str]]) -> None:
+        """Utility to add multiple files to the ModuleManager.
+        Any file already found to be present in the ModuleManager is skipped.
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_files(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def create_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def create_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to create the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(
+            self,
+            error_if_file_already_processed: bool = False,
+            error_if_module_already_processed: bool = False,
+            verbose: bool = False,
+            indent: str = ""
+    ):
+        """Load the module info using psyir nodes for all FileInfo objects
+        in the ModuleManager.
+
+        :param verbose: If `True`, print verbose information
+        :param error_if_file_already_processed: If `True`, raise an error
+                if a file was already processed.
+        :param error_if_module_already_processed: If `True`, raise an error
+                if a module was already processed.
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed if
+            error_if_file_already_processed is `True`
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    if error_if_module_already_processed:
+                        raise KeyError(
+                            f"Module '{container_name}' already processed"
+                        )
+                    else:
+                        print(
+                            indent+f"Module '{container_name}' already"
+                            " processed"
+                        )
+                        continue
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+
+            if filepath in self._filepath_to_module_info.keys():
+                if error_if_file_already_processed:
+                    raise KeyError(f"File '{filepath}' already processed")
+                else:
+                    print(indent+f"File '{filepath}' already processed")
+
+                continue
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    @property
+    def all_module_infos(self) -> List[ModuleInfo]:
+        """
+        :returns: list of all module infos.
+        """
+        return list(self._modules.values())
+
+    @property
+    def all_file_infos(self) -> List[FileInfo]:
+        """
+        :returns: List of all FileInfo objects.
+        """
+        return list(self._filepath_to_file_info.values())
+
+    def get_module_info(self, module_name: str) -> ModuleInfo:
+        """This function returns the ModuleInfo for the specified
         module.
 
-        :param str module_name: name of the module.
+        :param module_name: Name of the module.
 
         :returns: object describing the requested module or None if the
                   manager has been configured to ignore this module.
-        :rtype: :py:class:`psyclone.parse.ModuleInfo` | None
 
         :raises FileNotFoundError: if the module_name is not found in
             either the cached data nor in the search path.
 
-        '''
+        """
         mod_lower = module_name.lower()
 
         if mod_lower in self._ignore_modules:
@@ -278,16 +471,14 @@ def get_module_info(self, module_name):
                                 f"command line option.")
 
     # ------------------------------------------------------------------------
-    def get_modules_in_file(self, finfo: FileInfo):
+    def get_modules_in_file(self, finfo: FileInfo) -> List[str]:
         '''
         Uses a regex search to find all modules defined in the file with the
         supplied name.
 
         :param finfo: object holding information on the file to examine.
-        :type finfo: :py:class:`psyclone.parse.FileInfo`
 
         :returns: the names of any modules present in the supplied file.
-        :rtype: list[str]
 
         '''
         # TODO #2597: perhaps use the fparser FortranReader here as this regex
@@ -300,11 +491,13 @@ def get_modules_in_file(self, finfo: FileInfo):
 
         return [name.lower() for name in mod_names]
 
-    # ------------------------------------------------------------------------
-    def get_all_dependencies_recursively(self, all_mods):
+    def get_all_dependencies_recursively(
+            self,
+            all_mod_names: List[str],
+    ) -> OrderedDict[str, List[str]]:
         '''This function collects recursively all module dependencies
-        for any of the modules in the ``all_mods`` set. I.e. it will
-        add all modules used by any module listed in ``all_mods``,
+        for any of the modules in the ``all_mod_names`` set. I.e. it will
+        add all modules used by any module listed in ``all_mod_names``,
         and any modules used by the just added modules etc. In the end,
         it will return a dictionary that for each module lists which
         modules it depends on. This dictionary will be complete,
@@ -314,27 +507,26 @@ def get_all_dependencies_recursively(self, all_mods):
 
         If a module cannot be found (e.g. its path was not given to the
         ModuleManager, or it might be a system module for which the sources
-        are not available, a message will be printed, and this module will
+        are not available), a message will be printed, and this module will
         be ignored (i.e. not listed in any dependencies).
         # TODO 2120: allow a choice to abort or ignore.
 
-        :param set[str] all_mods: the set of all modules for which to collect
-            module dependencies.
-
-        :returns: a dictionary with all modules that are required (directly
-            or indirectly) for the modules in ``all_mods``.
-        :rtype: dict[str, set[str]]
+        :param all_mod_names: the set of all module names for which
+            to collect module dependencies.
 
+        :returns: an ordered dictionary with all modules that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
         '''
+
         # This contains the mapping from each module name to the
         # list of the dependencies and is returned as result:
-        module_dependencies = {}
+        module_dependencies = OrderedDict()
 
         # Work on a copy to avoid modifying the caller's set:
-        todo = all_mods.copy()
+        todo = all_mod_names.copy()
 
-        # This set contains module that could not be found (to avoid
-        # adding them to the todo list again
+        # This list contains all module names that could not be found
+        # (to avoid adding them to the todo list again)
         not_found = set()
 
         while todo:
@@ -346,9 +538,8 @@ def get_all_dependencies_recursively(self, all_mods):
             if module in self.ignores():
                 continue
             try:
-                mod_deps = self.get_module_info(module).get_used_modules()
-                # Convert to set since we continue with a set
-                mod_deps = set(mod_deps)
+                mod_deps = self.get_module_info(module).get_used_module_names()
+                mod_deps = list(mod_deps)
             except (FileNotFoundError, ModuleInfoError):
                 if module not in not_found:
                     # We don't have any information about this module,
@@ -365,35 +556,39 @@ def get_all_dependencies_recursively(self, all_mods):
                 continue
 
             # Remove all dependencies which we don't know anything about:
-            mod_deps = mod_deps.difference(not_found)
+            mod_deps = [x for x in mod_deps if x not in not_found]
 
             # Add the dependencies of `module` to the result dictionary:
             module_dependencies[module] = mod_deps
 
             # Remove all dependencies from the list of new dependencies
             # of `module` that have already been handled:
-            new_deps = mod_deps.difference(module_dependencies.keys())
+            module_dependencies_keys = module_dependencies.keys()
+            new_deps = [x for x in mod_deps
+                        if x not in module_dependencies_keys]
 
-            # Then add these really new modules to the list of modules
+            # Then append these really new modules to the list of modules
             # that still need to be handled
-            todo |= new_deps
+            for dep in new_deps:
+                if dep not in todo:
+                    todo.append(dep)
 
         return module_dependencies
 
     # -------------------------------------------------------------------------
-    def sort_modules(self, module_dependencies):
+    def sort_modules(
+        self, module_dependencies: Dict[str, Set[str]]
+    ) -> List[str]:
         '''This function sorts the given dependencies so that all
         dependencies of a module are before any module that
         needs it. Input is a dictionary that contains all modules to
         be sorted as keys, and the value for each module is the set
         of dependencies that the module depends on.
 
-        :param module_dependencies: the list of modules required as keys, \
+        :param module_dependencies: the list of modules required as keys,
             with all their dependencies as value.
-        :type module_dependencies: dict[str, set[str]]
 
         :returns: the sorted list of modules.
-        :rtype: list[str]
 
         '''
         result = []
@@ -435,9 +630,9 @@ def sort_modules(self, module_dependencies):
                 # dependencies, the best we can do in this case - and
                 # it's better to provide all modules (even if they cannot)
                 # be sorted, than missing some.
-                all_mods_sorted = sorted((mod for mod in todo.keys()),
-                                         key=lambda x: len(todo[x]))
-                mod = all_mods_sorted[0]
+                all_mod_names_sorted = sorted((mod for mod in todo.keys()),
+                                              key=lambda x: len(todo[x]))
+                mod = all_mod_names_sorted[0]
 
             # Add the current module to the result and remove it from
             # the todo list.
diff --git a/src/psyclone/tests/parse/file_info_test.py b/src/psyclone/tests/parse/file_info_test.py
index 2f65be3eac..2f85b2f0e4 100644
--- a/src/psyclone/tests/parse/file_info_test.py
+++ b/src/psyclone/tests/parse/file_info_test.py
@@ -84,7 +84,7 @@ def test_file_info_cached_source_code(tmpdir):
     content = "module andy\n\nend module"
     with open(fname, "w", encoding="utf-8") as fout:
         fout.write(content)
-    finfo = FileInfo(fname, use_caching=True)
+    finfo = FileInfo(fname, cache_active=True)
     input1 = finfo.get_source_code()
     assert input1 == content
     # Check that the contents have been cached.
@@ -98,7 +98,7 @@ def test_file_info_cached_source_code(tmpdir):
     finfo.get_fparser_tree()
     assert finfo._cache_data_save is not None
 
-    finfo = FileInfo(fname, use_caching=True)
+    finfo = FileInfo(fname, cache_active=True)
     input1 = finfo.get_fparser_tree()
     assert finfo._cache_data_load is not None
     assert finfo._cache_data_save is None
@@ -158,7 +158,7 @@ def test_file_info_source_fparser_psyir(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     source_code = file_info.get_source_code(verbose=True)
     source_code2 = file_info.get_source_code(verbose=True)
@@ -197,7 +197,7 @@ def test_file_info_load_from_cache(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # Call save_cache just for code coverage
     file_info._cache_save(verbose=True)
@@ -216,7 +216,7 @@ def test_file_info_load_from_cache(tmpdir):
     assert file_info._cache_data_save is not None
 
     # Load new file
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # No cache used
     assert file_info._cache_data_load is None
@@ -260,7 +260,7 @@ def test_file_info_load_from_cache_corrupted(tmpdir):
     # Step 1) Load with corrupted cache file
     #
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # No cache exists
     assert file_info._cache_data_load is None
@@ -278,7 +278,7 @@ def test_file_info_load_from_cache_corrupted(tmpdir):
     # Step 2) Cache file should now be restored
     #
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # No cache exists
     assert file_info._cache_data_load is None
@@ -321,7 +321,7 @@ def test_file_info_source_changed(tmpdir):
     # Step 1) Create cache file
     #
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
     psyir_node = file_info.get_psyir(verbose=True)
     assert isinstance(psyir_node, Node)
 
@@ -339,7 +339,7 @@ def test_file_info_source_changed(tmpdir):
     # Step 3) Cache file should not be used
     #
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # Load, but not from cache
     psyir_node = file_info.get_psyir(verbose=True)
@@ -373,26 +373,25 @@ def test_file_info_source_with_bugs(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY + "arbitrary words to trigger error")
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     from psyclone.parse.file_info import FileInfoFParserError
 
     with pytest.raises(FileInfoFParserError) as einfo:
         file_info.get_psyir(verbose=True)
 
-    assert (
-        "FileInfoFParserError: Failed to create fparser tree: at line 5"
-        in (str(einfo.value))
-    )
+    assert ("FileInfoFParserError: Failed to create"
+            " fparser tree: at line 5") in (
+        str(einfo.value))
 
     # Call it a 2nd time for coverage of not attempting to create it a 2nd time
     with pytest.raises(FileInfoFParserError) as einfo:
         file_info.get_psyir(verbose=True)
 
 
-def test_file_info_cachefile_not_accessible(tmpdir):
+def test_file_info_cachefile_not_writable(tmpdir):
     """
-    Check if cachefile is not accessible
+    Test for a cachefile that is not writable (can't be created).
 
     """
     filename = os.path.join(tmpdir, "testfile_e.f90")
@@ -404,19 +403,26 @@ def test_file_info_cachefile_not_accessible(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
-    # Set buggy cache file
-    file_info._filepath_cache = "/I_DONT_EXIST/FILE/cache.psycache"
+    # Overwrite the path of the cache file to one which
+    # can't be simply created (non-existing path)
+    file_info._cache_path = "/I_DONT_EXIST_PATH/FILE/cache.psycache"
 
+    # If only the source code is requested, this won't
+    # raise any errors since no cache is used for this,
+    # but it just returns the source code.
     source_code = file_info.get_source_code(verbose=True)
     assert source_code == SOURCE_DUMMY
 
+    # If the psyir, hence, fparser tree is requested, creating
+    # the cache will fail, but the psyir node itself will
+    # still be returned.
     psyir_node = file_info.get_psyir(verbose=True)
     assert isinstance(psyir_node, Node)
 
 
-def test_file_info_cachefile_pickle_load_exception(tmpdir, monkeypatch):
+def test_file_info_cachefile_pickle_load_exception(tmpdir):
     """
     Check pickle exceptions work
 
@@ -425,7 +431,7 @@ def test_file_info_cachefile_pickle_load_exception(tmpdir, monkeypatch):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     psyir_node = file_info.get_psyir(verbose=True)
     assert isinstance(psyir_node, Node)
@@ -443,7 +449,7 @@ def test_file_info_cachefile_pickle_dump_exception(tmpdir, monkeypatch):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     def fun_exception(a, b):
         raise Exception()
@@ -481,14 +487,14 @@ def test_file_info_source_psyir_test(tmpdir):
         fout.write(SOURCE_DUMMY)
 
     # Create cache
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
     file_info.get_psyir()
 
     # Load again for coverage case
     file_info.get_psyir()
 
     # Load from cache
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     fparser_tree = file_info.get_fparser_tree(verbose=True)
     fparser_tree2 = file_info.get_fparser_tree(verbose=True)
@@ -510,7 +516,6 @@ def test_fparser_error():
 
     # Catch special exception
     from psyclone.parse.file_info import FileInfoFParserError
-
     with pytest.raises(FileInfoFParserError) as einfo:
         file_info.get_fparser_tree()
 
diff --git a/src/psyclone/tests/parse/module_info_test.py b/src/psyclone/tests/parse/module_info_test.py
index c64419f1c7..74318554dc 100644
--- a/src/psyclone/tests/parse/module_info_test.py
+++ b/src/psyclone/tests/parse/module_info_test.py
@@ -182,7 +182,7 @@ def test_mod_info_get_psyir_wrong_file(tmpdir, capsys):
 # -----------------------------------------------------------------------------
 @pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
                          "mod_man_test_setup_directories")
-def test_mod_info_get_used_modules():
+def test_mod_info_get_used_module_names():
     '''Tests that dependencies are reported as expected. We use the standard
     directory and file setup (see mod_man_test_setup_directories).
     tmp/d1/a_mod.f90       : no dependencies
@@ -197,15 +197,15 @@ def test_mod_info_get_used_modules():
     mod_man.add_search_path("d1")
     mod_man.add_search_path("d2")
 
-    assert mod_man.get_module_info("a_mod").get_used_modules() == list()
-    assert mod_man.get_module_info("b_mod").get_used_modules() == list()
+    assert mod_man.get_module_info("a_mod").get_used_module_names() == list()
+    assert mod_man.get_module_info("b_mod").get_used_module_names() == list()
 
     mod_c_info: ModuleInfo = mod_man.get_module_info("c_mod")
     assert mod_c_info.name == "c_mod"
-    dep = mod_c_info.get_used_modules()
+    dep = mod_c_info.get_used_module_names()
     assert dep == ["a_mod", "b_mod"]
 
-    dep_cached = mod_c_info.get_used_modules()
+    dep_cached = mod_c_info.get_used_module_names()
     # Calling the method a second time should return the same
     # (cached) list object
     assert dep_cached is dep
@@ -215,14 +215,14 @@ def test_mod_info_get_used_modules():
     mod_man.add_search_path(dyn_path, recursive=True)
     # This module imports the intrinsic module iso_fortran_env,
     # (which should be ignored):
-    deps = mod_man.get_module_info("field_r64_mod").get_used_modules()
+    deps = mod_man.get_module_info("field_r64_mod").get_used_module_names()
     assert "iso_fortran_env" not in deps
 
     # This module has a 'use' without 'only'. Make sure that
     # the modules are still added to the dependencies, but that no
     # symbols are added:
     mod_info = mod_man.get_module_info("testkern_wtheta_mod")
-    deps = mod_info.get_used_modules()
+    deps = mod_info.get_used_module_names()
     for module in deps:
         assert module in ["constants_mod", "argument_mod",
                           "fs_continuity_mod", "kernel_mod"]
diff --git a/src/psyclone/tests/parse/module_manager_test.py b/src/psyclone/tests/parse/module_manager_test.py
index 72d9fa9c60..51f32c2a13 100644
--- a/src/psyclone/tests/parse/module_manager_test.py
+++ b/src/psyclone/tests/parse/module_manager_test.py
@@ -40,7 +40,7 @@
 import pytest
 
 from psyclone.errors import InternalError
-from psyclone.parse import ModuleInfo, ModuleManager
+from psyclone.parse import FileInfo, ModuleInfo, ModuleManager
 
 
 # ----------------------------------------------------------------------------
@@ -198,7 +198,8 @@ def test_mod_manager_get_module_info():
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
     assert list(mod_man._remaining_search_paths) == ["d2", "d2/d4"]
-    assert set(mod_man._modules.keys()) == set(["a_mod", "b_mod"])
+    assert set(mod_man._modules.keys()) == \
+        set(["a_mod", "b_mod"])
 
     # Then locate the e_mod, which should remove two paths from
     # the search path:
@@ -212,8 +213,8 @@ def test_mod_manager_get_module_info():
                                                       "d2/d4/e_mod.F90",
                                                       "d2/g_mod.F90",
                                                       "d2/error_mod.F90"])
-    assert set(mod_man._modules.keys()) == set(["a_mod", "b_mod",
-                                                "e_mod"])
+    assert set(mod_man._modules.keys()) == \
+        set(["a_mod", "b_mod", "e_mod"])
 
     with pytest.raises(FileNotFoundError) as err:
         mod_man.get_module_info("does_not_exist")
@@ -241,21 +242,21 @@ def test_mod_manager_get_all_dependencies_recursively(capsys):
     mod_man.add_search_path("d1")
     mod_man.add_search_path("d2")
 
-    all_d = mod_man.get_all_dependencies_recursively({"d_mod"})
+    all_d = mod_man.get_all_dependencies_recursively(["d_mod"])
     assert len(all_d.keys()) == 4
-    assert all_d["a_mod"] == set()
-    assert all_d["b_mod"] == set()
-    assert all_d["c_mod"] == set(("a_mod", "b_mod"))
-    assert all_d["d_mod"] == set(("c_mod", ))
+    assert all_d["a_mod"] == []
+    assert all_d["b_mod"] == []
+    assert all_d["c_mod"] == ["a_mod", "b_mod"]
+    assert all_d["d_mod"] == ["c_mod"]
 
     # Test ignoring of unknown modules, in this case NetCDF
-    all_e = mod_man.get_all_dependencies_recursively({"e_mod"})
+    all_e = mod_man.get_all_dependencies_recursively(["e_mod"])
     assert len(all_e.keys()) == 1
-    assert all_e["e_mod"] == set()
+    assert all_e["e_mod"] == []
     out, _ = capsys.readouterr()
     assert "Could not find module 'netcdf'" in out
 
-    all_c = mod_man.get_all_dependencies_recursively({"c_mod"})
+    all_c = mod_man.get_all_dependencies_recursively(["c_mod"])
     assert "a_mod" in all_c
     assert "b_mod" in all_c
     assert "c_mod" in all_c
@@ -263,7 +264,7 @@ def test_mod_manager_get_all_dependencies_recursively(capsys):
     # Instruct the module manager to ignore a_mod, which means
     # it should only have b_mod and c_mod in its dependencies:
     mod_man.add_ignore_module("a_mod")
-    all_c = mod_man.get_all_dependencies_recursively({"c_mod"})
+    all_c = mod_man.get_all_dependencies_recursively(["c_mod"])
     assert "a_mod" not in all_c
     assert "b_mod" in all_c
     assert "c_mod" in all_c
@@ -343,3 +344,131 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
+    for file_info in mod_man._filepath_to_file_info.values():
+        file_info: FileInfo
+        assert file_info._source_code is not None
+        assert file_info._fparser_tree is None
+        assert file_info._psyir_node is None
+
+    mod_man.create_all_fparser_trees()
+
+    for file_info in mod_man._filepath_to_file_info.values():
+        file_info: FileInfo
+        assert file_info._source_code is not None
+        assert file_info._fparser_tree is not None
+        assert file_info._psyir_node is None
+
+    mod_man.create_all_psyir_nodes()
+
+    for file_info in mod_man._filepath_to_file_info.values():
+        file_info: FileInfo
+        assert file_info._source_code is not None
+        assert file_info._fparser_tree is not None
+        assert file_info._psyir_node is not None
+
+    dummy = mod_man.all_file_infos
+    assert dummy is not None
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Only one module loaded
+    assert len(mod_man._modules) == 1
+
+    dummy = mod_man.all_module_infos
+    assert dummy is not None
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(
+                error_if_module_already_processed=True,
+                verbose=True
+            )
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_load_all_module_infos_trigger_error_module_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same module twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Load all module infos
+    mod_man.load_all_module_infos(
+            verbose=True
+        )
+
+    # Doing this a 2nd time should not raise any error
+    mod_man.load_all_module_infos(
+            verbose=True
+        )
+
+    # This should raise an error that a module has been already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(
+                error_if_module_already_processed=True,
+                verbose=True
+            )
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_load_all_module_infos_trigger_error_file_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same file twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    with open("t_mod.f90", "w", encoding="utf-8") as f_out:
+        f_out.write("\n")   # Just an empty file
+
+    mod_man.add_files("t_mod.f90")
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the file was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(
+                error_if_file_already_processed=True,
+                verbose=True
+            )
+
+    assert "File 't_mod.f90' already processed" in str(einfo.value)


Reviews:
user: login: arporter
body: Looks good Martin, this is functionality that will be important to have.
There are a couple of places where you appear to be duplicating functionality that we already have so I've asked for clarification.
I've not looked at the tests yet (but these will require updating anyway when you remove the bare asserts from the code).
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-17T11:39:48Z
user: login: schreiberx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T10:20:51Z
user: login: schreiberx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T10:21:16Z
user: login: schreiberx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T10:21:47Z
user: login: schreiberx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T10:22:02Z
user: login: schreiberx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T10:25:47Z
user: login: arporter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-28T09:02:15Z
user: login: arporter
body: Please see inline comments.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-28T10:01:12Z
user: login: arporter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T17:01:09Z
user: login: arporter
body: Back to you Martin.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-13T09:31:30Z
user: login: arporter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T09:44:51Z
user: login: arporter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T09:45:49Z
user: login: arporter
body: Thanks Martin. Almost done now - there are just two comments that still need a response.
Coverage of the individual files is 100%.
The errors in the link check are not the fault of this PR (in fact, I don't know why they've suddenly appeared).
I've brought the branch up to master so you'll need to pull it.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-18T09:57:59Z
user: login: arporter
body: Thanks Martin.
Just one question remaining...
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-24T16:45:58Z
user: login: arporter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T16:25:49Z
user: login: arporter
body: Changes all good now.
Integration tests are green.
Will proceed to merge.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T16:26:50Z

Review comments:
diff_hunk: @@ -169,18 +169,49 @@ of `ModuleManager`.
 
 .. testcode ::
 
-    mod_manager = ModuleManager.get(use_caching=True)
+    mod_manager = ModuleManager.get(cache_active=True)
 
 
 Most of the time in the PSyIR generation is currently spent in the
 fparser tree generation. Consequently, this leads to significant
 speed-ups in the process of reading and parsing the source code
 of modules.
 
+
+
+Default cache file locations
+----------------------------
+
+
 The default cache file is named the same way as the source file,
 but replaces the file extension with `.psycache`. E.g., a cache file
 for the source file `foo.f90` will be called `foo.psycache`.
 
+
+
+(Global) cache file folder
+--------------------------
+
+To avoid storing cache files together with source code files,
+a path can be provided to the module manager.
+
+.. testcode ::
+
+    mod_manager = ModuleManager.get(cache_active=True,
+                     cache_path="/tmp/my_cache_path")
+
+A cache file name will then be created based on the hashsum of each
+source code file. The combination of the provided `cache_path` and
+the cache file name will then be used as the storage location.
+
+A standard storage path could be, e.g., `$HOME/.cache/psyclone`.
path: doc/developer_guide/module_manager.rst
user: login: arporter
body: Please rm this line - it's up to the user what they specify (at least, as far as this PR is concerned).
created_at: 2025-02-17T10:30:37Z
author_association: MEMBER
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
+
+        assert self._cache_active, (
+            "Cache file path requested, but caching disabled")
+
+        if self._cache_filename is not None:
+            return self._cache_filename
+
+        if self._cache_path is None:
+            # If cache path is not specified, we use the source code path
+            # E.g.,
+            # path/to/file.f90 => path/to/file.psycache
+            (filepath_no_ext, _) = os.path.splitext(self._filename)
+
+            self._cache_filename = filepath_no_ext + ".psycache"
+            return self._cache_filename
+
+        # Cache path was specified.
+        # We assume this path is shared amongst many.
path: src/psyclone/parse/file_info.py
user: login: arporter
body: i.e. we may have (and cache) multiple versions of the same source file?
created_at: 2025-02-17T10:35:11Z
author_association: MEMBER
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
+
+        assert self._cache_active, (
+            "Cache file path requested, but caching disabled")
+
+        if self._cache_filename is not None:
+            return self._cache_filename
+
+        if self._cache_path is None:
+            # If cache path is not specified, we use the source code path
+            # E.g.,
+            # path/to/file.f90 => path/to/file.psycache
+            (filepath_no_ext, _) = os.path.splitext(self._filename)
+
+            self._cache_filename = filepath_no_ext + ".psycache"
+            return self._cache_filename
+
+        # Cache path was specified.
+        # We assume this path is shared amongst many.
+        # Therefore, we associate each cache file to a hashsum.
+        return os.path.join(
+            self._cache_path, self._source_code_hash_sum[:55] + ".psycache"
path: src/psyclone/parse/file_info.py
user: login: arporter
body: What's the magic "55" here? Please add a comment to explain.
created_at: 2025-02-17T10:35:44Z
author_association: MEMBER
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
path: src/psyclone/parse/file_info.py
user: login: arporter
body: I read this as "get the cache of the filepath" whereas we want "get the filepath of the cache" so please rename to `_get_cache_filepath`.
created_at: 2025-02-17T10:36:54Z
author_association: MEMBER
diff_hunk: @@ -113,9 +126,8 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # Psyir node
         self._psyir_node: FileContainer = None
 
-        # Single cache file
-        (path, ext) = os.path.splitext(self._filename)
-        self._filepath_cache = path + ".psycache"
+        # Filepath to cache
path: src/psyclone/parse/file_info.py
user: login: arporter
body: s/to/of/
created_at: 2025-02-17T10:37:35Z
author_association: MEMBER
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
path: src/psyclone/parse/file_info.py
user: login: arporter
body: No bare asserts please. To keep the number of comments down (!) I'll just request this once here and ask that you go through this PR and fix all instances.
created_at: 2025-02-17T10:38:25Z
author_association: MEMBER
diff_hunk: @@ -328,13 +393,13 @@ def _cache_save(
 
             # We first remove a potentially existing file
             try:
-                os.remove(self._filepath_cache)
+                os.remove(self._get_filepath_cache())
path: src/psyclone/parse/file_info.py
user: login: arporter
body: Would this file still exist now that it includes the hash in the filename (i.e. if we need to write the cache file, that's because the hash must have changed)? Or are you just being careful?
created_at: 2025-02-17T11:02:34Z
author_association: MEMBER
diff_hunk: @@ -324,6 +325,22 @@ def get_symbol(self, name: str) -> Union[Symbol, None]:
         except KeyError:
             return None
 
+    def get_routine_by_name(
path: src/psyclone/parse/module_info.py
user: login: arporter
body: This is a duplication of `Container.find_routine_psyir()` and `Container.resolve_routine()`. Can this be removed and that functionality used instead (from wherever you call this method).
created_at: 2025-02-17T11:05:51Z
author_association: MEMBER
diff_hunk: @@ -78,24 +79,28 @@ def get(use_caching: bool = None):
 
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Pls document the `cache_path` argument.
created_at: 2025-02-17T11:06:43Z
author_association: MEMBER
diff_hunk: @@ -78,24 +79,28 @@ def get(use_caching: bool = None):
 
         '''
         if not ModuleManager._instance:
-            ModuleManager._instance = ModuleManager(use_caching)
+            ModuleManager._instance = ModuleManager(cache_active, cache_path)
 
         return ModuleManager._instance
 
     # ------------------------------------------------------------------------
     def __init__(
                 self,
-                use_caching: bool = None
+                cache_active: bool = None,
+                cache_path: str = None
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Pls document this in the class docstring.
created_at: 2025-02-17T11:13:53Z
author_association: MEMBER
diff_hunk: @@ -104,6 +109,18 @@ def __init__(
         self._remaining_search_paths = OrderedDict()
         self._original_search_paths = []
 
+        # Ordered dictionary to lookup file info from file path
+        self._filepath_to_file_info: OrderedDict[str, FileInfo] = OrderedDict()
+
+        # Ordered dictionary to lookup modules of all files
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: "...lookup ModuleInfo from a file path."
created_at: 2025-02-17T11:15:57Z
author_association: MEMBER
diff_hunk: @@ -104,6 +109,18 @@ def __init__(
         self._remaining_search_paths = OrderedDict()
         self._original_search_paths = []
 
+        # Ordered dictionary to lookup file info from file path
+        self._filepath_to_file_info: OrderedDict[str, FileInfo] = OrderedDict()
+
+        # Ordered dictionary to lookup modules of all files
+        # Note that there can be multiple modules per file
+        self._filepath_to_module_info: OrderedDict[str, List[ModuleInfo]] = \
+            OrderedDict()
+
+        # Dictionary of modules to lookup module info
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: "...of ModuleInfo objects, indexed by module name"?
created_at: 2025-02-17T11:16:38Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Can you have `Iterable[str]` rather than specifying List or Set?
created_at: 2025-02-17T11:18:50Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: "Utility to add multiple files to the ModuleManager" perhaps?
Also, "Any file already found to be present in the ModuleManager is skipped."
created_at: 2025-02-17T11:19:27Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: s/codes/files/ perhaps?
created_at: 2025-02-17T11:22:57Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Presumably, this parses source as required, rather than just loading from file? Could therefore be `create_all_fparser_trees`?
created_at: 2025-02-17T11:24:27Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Again, 'create' rather than 'load' (and again in the docstring).
created_at: 2025-02-17T11:25:34Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: "...nodes for all FileInfo objects in the ModuleManager."
created_at: 2025-02-17T11:31:23Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
+        """
+
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: No blank line please.
created_at: 2025-02-17T11:31:54Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: :returns: list of all module infos.
created_at: 2025-02-17T11:32:13Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
+        """
+
+        return list(self._modules.values())
+
+    def get_all_file_infos(self) -> List[FileInfo]:
+        """
+        Return a list of all FileInfo objects
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: :returns: ...
created_at: 2025-02-17T11:32:25Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
+        """
+
+        return list(self._modules.values())
+
+    def get_all_file_infos(self) -> List[FileInfo]:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Am I right in thinking this doesn't do anything other than return information on objects we've already created? If so, I think this should be an `@property` named `all_file_infos`.
created_at: 2025-02-17T11:34:05Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: c.f. my comment on `get_all_file_infos`, could this be a property named `all_module_infos`?
created_at: 2025-02-17T11:34:36Z
author_association: MEMBER
diff_hunk: @@ -300,7 +443,102 @@ def get_modules_in_file(self, finfo: FileInfo):
 
         return [name.lower() for name in mod_names]
 
-    # ------------------------------------------------------------------------
+    def get_all_recursively_used_module_infos(
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Is this a duplication of the functionality already provided by `get_all_dependencies_recursively` and `sort_modules`?
created_at: 2025-02-17T11:37:56Z
author_association: MEMBER
diff_hunk: @@ -169,18 +169,49 @@ of `ModuleManager`.
 
 .. testcode ::
 
-    mod_manager = ModuleManager.get(use_caching=True)
+    mod_manager = ModuleManager.get(cache_active=True)
 
 
 Most of the time in the PSyIR generation is currently spent in the
 fparser tree generation. Consequently, this leads to significant
 speed-ups in the process of reading and parsing the source code
 of modules.
 
+
+
+Default cache file locations
+----------------------------
+
+
 The default cache file is named the same way as the source file,
 but replaces the file extension with `.psycache`. E.g., a cache file
 for the source file `foo.f90` will be called `foo.psycache`.
 
+
+
+(Global) cache file folder
+--------------------------
+
+To avoid storing cache files together with source code files,
+a path can be provided to the module manager.
+
+.. testcode ::
+
+    mod_manager = ModuleManager.get(cache_active=True,
+                     cache_path="/tmp/my_cache_path")
+
+A cache file name will then be created based on the hashsum of each
+source code file. The combination of the provided `cache_path` and
+the cache file name will then be used as the storage location.
+
+A standard storage path could be, e.g., `$HOME/.cache/psyclone`.
path: doc/developer_guide/module_manager.rst
user: login: schreiberx
body: OK.
created_at: 2025-02-20T10:20:50Z
author_association: COLLABORATOR
diff_hunk: @@ -113,9 +126,8 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # Psyir node
         self._psyir_node: FileContainer = None
 
-        # Single cache file
-        (path, ext) = os.path.splitext(self._filename)
-        self._filepath_cache = path + ".psycache"
+        # Filepath to cache
path: src/psyclone/parse/file_info.py
user: login: schreiberx
body: OK.
created_at: 2025-02-20T10:21:15Z
author_association: COLLABORATOR
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
path: src/psyclone/parse/file_info.py
user: login: schreiberx
body: OK.
created_at: 2025-02-20T10:21:46Z
author_association: COLLABORATOR
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
path: src/psyclone/parse/file_info.py
user: login: schreiberx
body: OK
created_at: 2025-02-20T10:22:01Z
author_association: COLLABORATOR
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
+
+        assert self._cache_active, (
+            "Cache file path requested, but caching disabled")
+
+        if self._cache_filename is not None:
+            return self._cache_filename
+
+        if self._cache_path is None:
+            # If cache path is not specified, we use the source code path
+            # E.g.,
+            # path/to/file.f90 => path/to/file.psycache
+            (filepath_no_ext, _) = os.path.splitext(self._filename)
+
+            self._cache_filename = filepath_no_ext + ".psycache"
+            return self._cache_filename
+
+        # Cache path was specified.
+        # We assume this path is shared amongst many.
path: src/psyclone/parse/file_info.py
user: login: schreiberx
body: No, that's not what I meant.
I updated the comment for a better description. Please check.

BTW: That's how it's done with GPU shaders.
created_at: 2025-02-20T10:25:46Z
author_association: COLLABORATOR
diff_hunk: @@ -131,6 +143,42 @@ def __init__(self, filepath: str, use_caching: bool = False):
         # is requested.
         self._cache_data_save: _CacheFileInfo = None
 
+    def _get_filepath_cache(self):
+        """Return the filepath of the cache.
+
+        This also supports having a shared caching directory,
+        e.g., in `$HOME/.cache/psyclone/`.
+
+        This sets up unique cache file names based on the
+        hashcode. Consequently, this can't be done in the
+        constructor since the hashcode of the source code
+        is required first.
+        """
+
+        assert self._source_code_hash_sum is not None
+
+        assert self._cache_active, (
+            "Cache file path requested, but caching disabled")
+
+        if self._cache_filename is not None:
+            return self._cache_filename
+
+        if self._cache_path is None:
+            # If cache path is not specified, we use the source code path
+            # E.g.,
+            # path/to/file.f90 => path/to/file.psycache
+            (filepath_no_ext, _) = os.path.splitext(self._filename)
+
+            self._cache_filename = filepath_no_ext + ".psycache"
+            return self._cache_filename
+
+        # Cache path was specified.
+        # We assume this path is shared amongst many.
path: src/psyclone/parse/file_info.py
user: login: arporter
body: Great, I understand now :-)
created_at: 2025-02-28T09:02:15Z
author_association: MEMBER
diff_hunk: @@ -324,6 +325,22 @@ def get_symbol(self, name: str) -> Union[Symbol, None]:
         except KeyError:
             return None
 
+    def get_routine_by_name(
path: src/psyclone/parse/module_info.py
user: login: arporter
body: This is still outstanding?
created_at: 2025-02-28T09:07:10Z
author_association: MEMBER
diff_hunk: @@ -286,7 +304,7 @@ def load_all_fparser_trees(self, verbose: bool = False) -> None:
             fileinfo: FileInfo
             fileinfo.get_fparser_tree(verbose=verbose)
 
-    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+    def create_all_psyir_nodes(self, verbose: bool = False) -> None:
         """
         Routine to load the psyir nodes of all files added
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: s/load/create/
created_at: 2025-02-28T09:20:20Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: You've missed this one?
created_at: 2025-02-28T09:21:36Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
+        """
+
+        return list(self._modules.values())
+
+    def get_all_file_infos(self) -> List[FileInfo]:
+        """
+        Return a list of all FileInfo objects
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Thanks. There's no need to duplicate when it's this simple - you can just have the `:returns: ...` line instead of the initial "Returns a list of ..." text.
created_at: 2025-02-28T09:22:38Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
+        """
+        Return a list of all module infos
+        """
+
+        return list(self._modules.values())
+
+    def get_all_file_infos(self) -> List[FileInfo]:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Not addressed?
created_at: 2025-02-28T09:43:18Z
author_association: MEMBER
diff_hunk: @@ -222,21 +243,145 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, List[str], Set[str]]) -> None:
+        """Add a file to the list of files
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_codes(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def load_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def load_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to load the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    def get_all_module_infos(self) -> List[ModuleInfo]:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Not addressed?
created_at: 2025-02-28T09:44:05Z
author_association: MEMBER
diff_hunk: @@ -443,17 +465,23 @@ def get_modules_in_file(self, finfo: FileInfo) -> List[str]:
 
         return [name.lower() for name in mod_names]
 
-    def get_all_recursively_used_module_infos(
+    def get_all_recursively_used_module_infos_for_module_info_name(
         self,
         module_info_name: str,
         verbose: bool = False,
         indent: str = "",
     ) -> List[ModuleInfo]:
         """This function collects all modules which are recursively used
-        by the specified module. It returns a list of module infos in the
+        by the specified module bane. It returns a list of module infos in the
         order of dependencies, i.e. a module which is used by another module
         is listed before the module which uses it.
 
+        The differences to `get_all_dependencies_recursively` are as follows:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Thanks for updating. However, I still think that with a little bit of work, this method could mostly use `get_all_dependencies_recursively` - you could pass it a list containing just the module info you're after. Why do you need to preserve the ordering of the list of dependent ModuleInfos?
created_at: 2025-02-28T09:58:00Z
author_association: MEMBER
diff_hunk: @@ -443,17 +465,23 @@ def get_modules_in_file(self, finfo: FileInfo) -> List[str]:
 
         return [name.lower() for name in mod_names]
 
-    def get_all_recursively_used_module_infos(
+    def get_all_recursively_used_module_infos_for_module_info_name(
         self,
         module_info_name: str,
         verbose: bool = False,
         indent: str = "",
     ) -> List[ModuleInfo]:
         """This function collects all modules which are recursively used
-        by the specified module. It returns a list of module infos in the
+        by the specified module bane. It returns a list of module infos in the
         order of dependencies, i.e. a module which is used by another module
         is listed before the module which uses it.
 
+        The differences to `get_all_dependencies_recursively` are as follows:
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: When doing this, please also remove the bare assert.
created_at: 2025-02-28T09:58:24Z
author_association: MEMBER
diff_hunk: @@ -324,6 +325,22 @@ def get_symbol(self, name: str) -> Union[Symbol, None]:
         except KeyError:
             return None
 
+    def get_routine_by_name(
path: src/psyclone/parse/module_info.py
user: login: arporter
body: Still there?
created_at: 2025-03-12T17:01:09Z
author_association: MEMBER
diff_hunk: @@ -1007,7 +1007,8 @@ def collect_all_required_modules(file_container):
                             if isinstance(symbol, ContainerSymbol))
 
         mod_manager = ModuleManager.get()
-        return mod_manager.get_all_dependencies_recursively(all_mods)
+        return mod_manager.get_all_dependencies_recursively_DEPRECATED(
path: src/psyclone/domain/lfric/lfric_extract_driver_creator.py
user: login: arporter
body: This looks wrong?
created_at: 2025-03-12T17:10:47Z
author_association: MEMBER
diff_hunk: @@ -324,6 +325,22 @@ def get_symbol(self, name: str) -> Union[Symbol, None]:
         except KeyError:
             return None
 
+    def get_routine_by_name(
path: src/psyclone/parse/module_info.py
user: login: arporter
body: In fact, a grep reveals that this is only ever called from a test.
created_at: 2025-03-13T09:09:56Z
author_association: MEMBER
diff_hunk: @@ -76,26 +76,47 @@ def get(use_caching: bool = None):
             can happen, but this shouldn't lead to wrong results. However,
             that's untested so far.
 
+        :param cache_path: If set, the cache file will be stored in the given
+            path (directory) using a hashsum of the source code to create
+            a unique cache file name. If `None`, a cache file will be created
+            in the same directory as the source file with a new
+            file ending `.psycache`.
+
         '''
         if not ModuleManager._instance:
-            ModuleManager._instance = ModuleManager(use_caching)
+            ModuleManager._instance = ModuleManager(cache_active, cache_path)
 
         return ModuleManager._instance
 
     # ------------------------------------------------------------------------
     def __init__(
-                self,
-                use_caching: bool = None
-            ):
+            self,
+            cache_active: bool = None,
+            cache_path: str = None
+    ):
+        """
+        Set up the module manager. Module manager is actually a singleton
+        and should not be created directly. Use `ModuleManager.get()`
+        instead.
+
+        :param cache_active: Whether to use (`True`) or
+            disable (`False`) caching
+        :param cache_path: Path to the cache directory. If `None`, the
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Please copy this `cache_path` documentation to the class docstring. In fact, you could remove the documentation of the params from here as that belongs in the class docstring. You could keep the explanation that the user is supposed to use get() though.
created_at: 2025-03-13T09:14:07Z
author_association: MEMBER
diff_hunk: @@ -222,21 +259,148 @@ def ignores(self):
         '''
         return self._ignore_modules
 
-    # ------------------------------------------------------------------------
-    def get_module_info(self, module_name):
-        '''This function returns the ModuleInformation for the specified
+    def add_files(self, filepaths: Union[str, Iterable[str]]) -> None:
+        """Utility to add multiple files to the ModuleManager.
+        Any file already found to be present in the ModuleManager is skipped.
+
+        :param filepaths: Filename or list of filenames
+        """
+
+        if isinstance(filepaths, str):
+            filepaths = [filepaths]
+
+        for filepath in filepaths:
+            if filepath in self._filepath_to_file_info:
+                # Already loaded => skip
+                continue
+
+            self._filepath_to_file_info[filepath] = FileInfo(
+                filepath,
+                cache_active=self._cache_active,
+                cache_path=self._cache_path,
+            )
+
+    def load_all_source_files(self, verbose: bool = False) -> None:
+        """Routine to load the source of all files previously added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_source_code(verbose=verbose)
+
+    def create_all_fparser_trees(self, verbose: bool = False) -> None:
+        """
+        Routine to load the fparser tree of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_fparser_tree(verbose=verbose)
+
+    def create_all_psyir_nodes(self, verbose: bool = False) -> None:
+        """
+        Routine to create the psyir nodes of all files added
+        to the module manager
+
+        :param verbose: If `True`, print verbose information
+        """
+
+        for fileinfo in self._filepath_to_file_info.values():
+            fileinfo: FileInfo
+            fileinfo.get_psyir(verbose=verbose)
+
+    def load_all_module_infos(self, verbose: bool = False, indent: str = ""):
+        """Load the module info using psyir nodes for all FileInfo objects
+        in the ModuleManager.
+
+        :param verbose: If `True`, print verbose information
+        :param indent: Prefix used as indentation for each line of
+            verbose output.
+
+        :raises KeyError: If module was already processed
+        """
+
+        # iterate over all file infos and load psyir
+        for file_info in self._filepath_to_file_info.values():
+            file_info: FileInfo
+
+            if verbose:
+                print(
+                    f"{indent}- Loading module information for "
+                    f"file '{file_info._filename}"
+                )
+
+            psyir_node: Node = file_info.get_psyir(
+                verbose=verbose, indent=indent + "  "
+            )
+
+            # Collect all module infos in this list
+            module_info_in_file: List[ModuleInfo] = []
+
+            # Walk over containers and add respective module information
+            for container_node in psyir_node.walk(
+                Container, stop_type=Routine
+            ):
+                if type(container_node) is not Container:
+                    # Sort out types which are not exactly of
+                    # type 'Container', e.g., 'FileContainer'
+                    continue
+
+                container_node: Container
+
+                container_name: str = container_node.name.lower()
+
+                if container_name in self._modules.keys():
+                    raise KeyError(
+                        f"Module '{container_name}' already processed"
+                    )
+
+                module_info = ModuleInfo(
+                    container_name, file_info, container_node
+                )
+                module_info_in_file.append(module_info)
+
+                self._modules[container_name] = module_info
+
+            filepath = file_info.filename
+            if filepath in self._filepath_to_module_info.keys():
+                raise KeyError(f"File '{filepath}' already processed")
+
+            self._filepath_to_module_info[filepath] = module_info_in_file
+
+    @property
+    def all_module_infos(self) -> List[ModuleInfo]:
+        """
+        :returns: list of all module infos.
+        """
+        return list(self._modules.values())
+
+    @property
+    def all_file_infos(self) -> List[FileInfo]:
+        """
+        :returns: List of all FileInfo objects.
+        """
+        return list(self._filepath_to_file_info.values())
+
+    def get_module_info(self, module_name: str) -> ModuleInfo:
+        """This function returns the ModuleInfo for the specified
         module.
 
-        :param str module_name: name of the module.
+        :param module_name: Name of the module.
 
         :returns: object describing the requested module or None if the
                   manager has been configured to ignore this module.
-        :rtype: :py:class:`psyclone.parse.ModuleInfo` | None
 
         :raises FileNotFoundError: if the module_name is not found in
             either the cached data nor in the search path.
+        """
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: The blank line should be before the """, not after.
created_at: 2025-03-13T09:14:57Z
author_association: MEMBER
diff_hunk: @@ -314,28 +478,27 @@ def get_all_dependencies_recursively(self, all_mods):
 
         If a module cannot be found (e.g. its path was not given to the
         ModuleManager, or it might be a system module for which the sources
-        are not available, a message will be printed, and this module will
+        are not available), a message will be printed, and this module will
         be ignored (i.e. not listed in any dependencies).
         # TODO 2120: allow a choice to abort or ignore.
 
-        :param set[str] all_mods: the set of all modules for which to collect
-            module dependencies.
-
-        :returns: a dictionary with all modules that are required (directly
-            or indirectly) for the modules in ``all_mods``.
-        :rtype: dict[str, set[str]]
+        :param all_mod_names: the set of all module names for which
+            to collect module dependencies.
 
+        :returns: an ordered dictionary with all modules that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
         '''
+
         # This contains the mapping from each module name to the
         # list of the dependencies and is returned as result:
-        module_dependencies = {}
+        module_dependencies = OrderedDict()
 
         # Work on a copy to avoid modifying the caller's set:
-        todo = all_mods.copy()
+        todo = all_mod_names.copy()
 
         # This set contains module that could not be found (to avoid
         # adding them to the todo list again
-        not_found = set()
+        not_found = list()
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Is there a reason you've converted this to a list? It seems to have made life slightly harder/more verbose in the code below.
created_at: 2025-03-13T09:16:58Z
author_association: MEMBER
diff_hunk: @@ -365,35 +527,98 @@ def get_all_dependencies_recursively(self, all_mods):
                 continue
 
             # Remove all dependencies which we don't know anything about:
-            mod_deps = mod_deps.difference(not_found)
+            mod_deps = [x for x in mod_deps if x not in not_found]
 
             # Add the dependencies of `module` to the result dictionary:
             module_dependencies[module] = mod_deps
 
             # Remove all dependencies from the list of new dependencies
             # of `module` that have already been handled:
-            new_deps = mod_deps.difference(module_dependencies.keys())
+            module_dependencies_keys = module_dependencies.keys()
+            new_deps = [x for x in mod_deps
+                        if x not in module_dependencies_keys]
 
-            # Then add these really new modules to the list of modules
+            # Then append these really new modules to the list of modules
             # that still need to be handled
-            todo |= new_deps
+            for dep in new_deps:
+                if dep not in todo:
+                    todo.append(dep)
 
         return module_dependencies
 
+    def get_all_dependencies_recursively_DEPRECATED(
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Presumably this should go?
created_at: 2025-03-13T09:17:37Z
author_association: MEMBER
diff_hunk: @@ -365,35 +527,98 @@ def get_all_dependencies_recursively(self, all_mods):
                 continue
 
             # Remove all dependencies which we don't know anything about:
-            mod_deps = mod_deps.difference(not_found)
+            mod_deps = [x for x in mod_deps if x not in not_found]
 
             # Add the dependencies of `module` to the result dictionary:
             module_dependencies[module] = mod_deps
 
             # Remove all dependencies from the list of new dependencies
             # of `module` that have already been handled:
-            new_deps = mod_deps.difference(module_dependencies.keys())
+            module_dependencies_keys = module_dependencies.keys()
+            new_deps = [x for x in mod_deps
+                        if x not in module_dependencies_keys]
 
-            # Then add these really new modules to the list of modules
+            # Then append these really new modules to the list of modules
             # that still need to be handled
-            todo |= new_deps
+            for dep in new_deps:
+                if dep not in todo:
+                    todo.append(dep)
 
         return module_dependencies
 
+    def get_all_dependencies_recursively_DEPRECATED(
+            self,
+            all_mod_names: Set[str]
+    ) -> Dict[str, Set[str]]:
+        """
+        This function collects recursively all module dependencies
+        for any of the modules in the ``all_mod_names`` set.
+        See ``get_all_dependencies_recursively`` for more information.
+
+        This function exists for legacy reasons, taking sets as input and also
+        using sets as output. But sets don't allow for reproducible runs which
+        is the reason why this is declared to be deprecated.
+
+        :param all_mod_names: the set of all module names for which to
+            collect module dependencies.
+
+        :returns: a dictionary with all module names as keys that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
+        """
+        module_dependencies: OrderedDict[str, List[str]] = \
+            self.get_all_dependencies_recursively(
+                list(all_mod_names)
+            )
+
+        module_dependencies_retval = dict()
+        for module_name, mod_name_list in module_dependencies.items():
+            module_dependencies_retval[module_name] = set(mod_name_list)
+
+        return module_dependencies_retval
+
+    def get_all_dependencies_recursively_for_module_name(
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Again, this is only ever called from tests and could just be `get_all_dependencies_recursively([module_info_name])` so I don't think this method is required.
created_at: 2025-03-13T09:19:12Z
author_association: MEMBER
diff_hunk: @@ -404,10 +403,37 @@ def test_file_info_cachefile_not_accessible(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # Set buggy cache file
path: src/psyclone/tests/parse/file_info_test.py
user: login: arporter
body: Sorry Martin but I need some more explanation here: what should happen if the cache file can't be found?
created_at: 2025-03-13T09:21:00Z
author_association: MEMBER
diff_hunk: @@ -404,10 +403,37 @@ def test_file_info_cachefile_not_accessible(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # Set buggy cache file
-    file_info._filepath_cache = "/I_DONT_EXIST/FILE/cache.psycache"
+    file_info._cache_path = "/I_DONT_EXIST/FILE/cache.psycache"
+
+    source_code = file_info.get_source_code(verbose=True)
+    assert source_code == SOURCE_DUMMY
+
+    psyir_node = file_info.get_psyir(verbose=True)
+    assert isinstance(psyir_node, Node)
+
+
+def test_file_info_cachefile_not_writable(tmpdir):
+    '''
+    Check if cachefile is not writable.
+    This should not raise any errors!
+    '''
+
+    filename = os.path.join(tmpdir, "testfile_e.f90")
+
+    try:
+        os.remove(filename)
+    except FileNotFoundError:
path: src/psyclone/tests/parse/file_info_test.py
user: login: arporter
body: This can be improved by using the `tmp_path` fixture instead of `tmpdir` - that will ensure that the directory this test is given is unique to it so the file can't exist.
created_at: 2025-03-13T09:22:48Z
author_association: MEMBER
diff_hunk: @@ -404,10 +403,37 @@ def test_file_info_cachefile_not_accessible(tmpdir):
     with open(filename, "w", encoding="utf-8") as fout:
         fout.write(SOURCE_DUMMY)
 
-    file_info: FileInfo = FileInfo(filename, use_caching=True)
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
 
     # Set buggy cache file
-    file_info._filepath_cache = "/I_DONT_EXIST/FILE/cache.psycache"
+    file_info._cache_path = "/I_DONT_EXIST/FILE/cache.psycache"
+
+    source_code = file_info.get_source_code(verbose=True)
+    assert source_code == SOURCE_DUMMY
+
+    psyir_node = file_info.get_psyir(verbose=True)
+    assert isinstance(psyir_node, Node)
+
+
+def test_file_info_cachefile_not_writable(tmpdir):
+    '''
+    Check if cachefile is not writable.
+    This should not raise any errors!
+    '''
+
+    filename = os.path.join(tmpdir, "testfile_e.f90")
+
+    try:
+        os.remove(filename)
+    except FileNotFoundError:
+        pass
+    with open(filename, "w", encoding='utf-8') as fout:
+        fout.write(SOURCE_DUMMY)
+
+    file_info: FileInfo = FileInfo(filename, cache_active=True)
+
+    # Set buggy cache file
+    file_info._cache_path = "/I_DONT_EXIST/FILE/cache.psycache"
 
     source_code = file_info.get_source_code(verbose=True)
path: src/psyclone/tests/parse/file_info_test.py
user: login: arporter
body: Again, I need some talking through this (comments) please.
created_at: 2025-03-13T09:23:42Z
author_association: MEMBER
diff_hunk: @@ -491,6 +491,29 @@ def test_minfo_get_fparser_tree_missing_file():
             " not found:" in str(einfo.value))
 
 
+def test_module_info_get_routine_name(tmpdir, monkeypatch):
path: src/psyclone/tests/parse/module_info_test.py
user: login: arporter
body: This can probably be removed.
created_at: 2025-03-13T09:24:33Z
author_association: MEMBER
diff_hunk: @@ -226,7 +227,7 @@ def test_mod_manager_get_module_info():
 # ----------------------------------------------------------------------------
 @pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
                          "mod_man_test_setup_directories")
-def test_mod_manager_get_all_dependencies_recursively(capsys):
+def test_mod_manager_get_all_dependencies_recursively_DEPRECATED(capsys):
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: This should probably be removed?
created_at: 2025-03-13T09:25:19Z
author_association: MEMBER
diff_hunk: @@ -343,3 +344,133 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: Please add checks that these calls have modified the internal state of `mod_man` as you expect.
created_at: 2025-03-13T09:26:08Z
author_association: MEMBER
diff_hunk: @@ -343,3 +344,133 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
+    mod_man.create_all_fparser_trees()
+    mod_man.create_all_psyir_nodes()
+    dummy = mod_man.all_file_infos
+    assert dummy is not None
+    mod_man.load_all_module_infos(verbose=True)
+    dummy = mod_man.all_module_infos
+    assert dummy is not None
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_load_all_module_infos_trigger_error_module_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same module twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the first module to be processed
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: Why does this raise an error - can we not silently/safely ignore this if we've already processed it?
created_at: 2025-03-13T09:27:22Z
author_association: MEMBER
diff_hunk: @@ -343,3 +344,133 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
+    mod_man.create_all_fparser_trees()
+    mod_man.create_all_psyir_nodes()
+    dummy = mod_man.all_file_infos
+    assert dummy is not None
+    mod_man.load_all_module_infos(verbose=True)
+    dummy = mod_man.all_module_infos
+    assert dummy is not None
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_load_all_module_infos_trigger_error_module_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same module twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_load_all_module_infos_trigger_error_file_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same file twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    with open("t_mod.f90", "w", encoding="utf-8") as f_out:
+        f_out.write("\n")   # Just an empty file
+
+    mod_man.add_files("t_mod.f90")
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the file was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "File 't_mod.f90' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_get_all_rec_used_mod_infos_missing_b(capsys):
+    '''
+    Make particular check for
+    get_all_dependencies_recursively_for_module_name():
+    - Create files for modules 'c'
+    - Dependency of modules is c -> b
+    - This will follow some paths in the control flow
+    - It will not complain about a missing 'b'
+    '''
+    mod_man = ModuleManager.get()
+
+    with open("c_b.f90", "w", encoding="utf-8") as f_out:
+        f_out.write("module c\nuse b\nend")   # Just an empty file
+
+    mod_man.add_files("c_b.f90")
+    mod_man.load_all_module_infos(verbose=True)
+
+    # These are soft errors that don't raise Exceptions
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: s/These are soft/The missing dependency is a soft/ if that's right? If not, please elaborate a bit.
created_at: 2025-03-13T09:30:13Z
author_association: MEMBER
diff_hunk: @@ -343,3 +344,133 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
+    mod_man.create_all_fparser_trees()
+    mod_man.create_all_psyir_nodes()
+    dummy = mod_man.all_file_infos
+    assert dummy is not None
+    mod_man.load_all_module_infos(verbose=True)
+    dummy = mod_man.all_module_infos
+    assert dummy is not None
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_load_all_module_infos_trigger_error_module_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same module twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_load_all_module_infos_trigger_error_file_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same file twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    with open("t_mod.f90", "w", encoding="utf-8") as f_out:
+        f_out.write("\n")   # Just an empty file
+
+    mod_man.add_files("t_mod.f90")
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the file was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "File 't_mod.f90' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_get_all_rec_used_mod_infos_missing_b(capsys):
+    '''
+    Make particular check for
+    get_all_dependencies_recursively_for_module_name():
+    - Create files for modules 'c'
+    - Dependency of modules is c -> b
+    - This will follow some paths in the control flow
+    - It will not complain about a missing 'b'
+    '''
+    mod_man = ModuleManager.get()
+
+    with open("c_b.f90", "w", encoding="utf-8") as f_out:
+        f_out.write("module c\nuse b\nend")   # Just an empty file
+
+    mod_man.add_files("c_b.f90")
+    mod_man.load_all_module_infos(verbose=True)
+
+    # These are soft errors that don't raise Exceptions
+    mod_man.get_all_dependencies_recursively_for_module_name("c")
+
+    out, _ = capsys.readouterr()
+    assert "Could not find module 'b'." in out
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance")
+def test_mod_manager_get_all_rec_used_mod_infos_missing_first_module(capsys):
+    '''
+    Make particular check for
+    get_all_dependencies_recursively_for_module_name():
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: s/:/when no modules can be found/ perhaps?
created_at: 2025-03-13T09:31:08Z
author_association: MEMBER
diff_hunk: @@ -343,3 +344,133 @@ def test_mod_manager_add_ignore_modules():
     # Just in case verify that other modules are not affected
     mod_info = mod_man.get_module_info("b_mod")
     assert mod_info.filename == "d1/d3/b_mod.F90"
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_add_files_and_more():
+    '''Fixture will create the following files
+
+    d1/a_mod.f90
+    d1/d3/b_mod.F90
+    d1/d3/c_mod.x90
+    d2/d_mod.X90
+    d2/d4/e_mod.F90
+
+    We will check through some further ModuleManager functions.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    # Add same file again, will be silently ignored
+    mod_man.add_files("d1/a_mod.f90")
+
+    #
+    # Test various other functions
+    #
+    mod_man.load_all_source_files()
+    mod_man.create_all_fparser_trees()
+    mod_man.create_all_psyir_nodes()
+    dummy = mod_man.all_file_infos
+    assert dummy is not None
+    mod_man.load_all_module_infos(verbose=True)
+    dummy = mod_man.all_module_infos
+    assert dummy is not None
+
+    # Should raise an error that the first module to be processed
+    # was already processed
+    with pytest.raises(KeyError) as einfo:
+        mod_man.load_all_module_infos(verbose=True)
+
+    assert "Module 'a_mod' already processed" in str(einfo.value)
+
+
+@pytest.mark.usefixtures("change_into_tmpdir", "clear_module_manager_instance",
+                         "mod_man_test_setup_directories")
+def test_mod_manager_load_all_module_infos_trigger_error_module_read_twice():
+    '''
+    Make particular check for load_all_module_infos():
+    - Reading in the same module twice is triggering an error.
+    '''
+    mod_man = ModuleManager.get()
+
+    #
+    # Test add_files(...)
+    #
+    mod_man.add_files("d1/a_mod.f90")
+
+    mod_man.load_all_module_infos(verbose=True)
+
+    # Should raise an error that the first module to be processed
path: src/psyclone/tests/parse/module_manager_test.py
user: login: arporter
body: Still outstanding?
created_at: 2025-03-18T09:44:51Z
author_association: MEMBER
diff_hunk: @@ -314,28 +478,27 @@ def get_all_dependencies_recursively(self, all_mods):
 
         If a module cannot be found (e.g. its path was not given to the
         ModuleManager, or it might be a system module for which the sources
-        are not available, a message will be printed, and this module will
+        are not available), a message will be printed, and this module will
         be ignored (i.e. not listed in any dependencies).
         # TODO 2120: allow a choice to abort or ignore.
 
-        :param set[str] all_mods: the set of all modules for which to collect
-            module dependencies.
-
-        :returns: a dictionary with all modules that are required (directly
-            or indirectly) for the modules in ``all_mods``.
-        :rtype: dict[str, set[str]]
+        :param all_mod_names: the set of all module names for which
+            to collect module dependencies.
 
+        :returns: an ordered dictionary with all modules that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
         '''
+
         # This contains the mapping from each module name to the
         # list of the dependencies and is returned as result:
-        module_dependencies = {}
+        module_dependencies = OrderedDict()
 
         # Work on a copy to avoid modifying the caller's set:
-        todo = all_mods.copy()
+        todo = all_mod_names.copy()
 
         # This set contains module that could not be found (to avoid
         # adding them to the todo list again
-        not_found = set()
+        not_found = list()
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Still outstanding?
created_at: 2025-03-18T09:45:49Z
author_association: MEMBER
diff_hunk: @@ -314,28 +478,27 @@ def get_all_dependencies_recursively(self, all_mods):
 
         If a module cannot be found (e.g. its path was not given to the
         ModuleManager, or it might be a system module for which the sources
-        are not available, a message will be printed, and this module will
+        are not available), a message will be printed, and this module will
         be ignored (i.e. not listed in any dependencies).
         # TODO 2120: allow a choice to abort or ignore.
 
-        :param set[str] all_mods: the set of all modules for which to collect
-            module dependencies.
-
-        :returns: a dictionary with all modules that are required (directly
-            or indirectly) for the modules in ``all_mods``.
-        :rtype: dict[str, set[str]]
+        :param all_mod_names: the set of all module names for which
+            to collect module dependencies.
 
+        :returns: an ordered dictionary with all modules that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
         '''
+
         # This contains the mapping from each module name to the
         # list of the dependencies and is returned as result:
-        module_dependencies = {}
+        module_dependencies = OrderedDict()
 
         # Work on a copy to avoid modifying the caller's set:
-        todo = all_mods.copy()
+        todo = all_mod_names.copy()
 
         # This set contains module that could not be found (to avoid
         # adding them to the todo list again
-        not_found = set()
+        not_found = list()
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Thanks for updating the comment. I still don't understand why you've changed to a list though?
created_at: 2025-03-24T16:33:52Z
author_association: MEMBER
diff_hunk: @@ -314,28 +478,27 @@ def get_all_dependencies_recursively(self, all_mods):
 
         If a module cannot be found (e.g. its path was not given to the
         ModuleManager, or it might be a system module for which the sources
-        are not available, a message will be printed, and this module will
+        are not available), a message will be printed, and this module will
         be ignored (i.e. not listed in any dependencies).
         # TODO 2120: allow a choice to abort or ignore.
 
-        :param set[str] all_mods: the set of all modules for which to collect
-            module dependencies.
-
-        :returns: a dictionary with all modules that are required (directly
-            or indirectly) for the modules in ``all_mods``.
-        :rtype: dict[str, set[str]]
+        :param all_mod_names: the set of all module names for which
+            to collect module dependencies.
 
+        :returns: an ordered dictionary with all modules that are required
+            (directly or indirectly) for the modules in ``all_mod_names``.
         '''
+
         # This contains the mapping from each module name to the
         # list of the dependencies and is returned as result:
-        module_dependencies = {}
+        module_dependencies = OrderedDict()
 
         # Work on a copy to avoid modifying the caller's set:
-        todo = all_mods.copy()
+        todo = all_mod_names.copy()
 
         # This set contains module that could not be found (to avoid
         # adding them to the todo list again
-        not_found = set()
+        not_found = list()
path: src/psyclone/parse/module_manager.py
user: login: arporter
body: Thanks for sorting this.
created_at: 2025-03-26T16:25:49Z
author_association: MEMBER

PR comments:
user: login: schreiberx
created_at: 2024-12-19T16:40:43Z
body: @arporter That's the final PR for the file info caching stuff.
It's probably best to first get the previous PR in the master.
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2024-12-19T17:13:13Z
body: ## [Codecov](https://app.codecov.io/gh/stfc/PSyclone/pull/2833?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stfc) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.89%. Comparing base [(`7ef4ec2`)](https://app.codecov.io/gh/stfc/PSyclone/commit/7ef4ec211934458116779424b96cf384ed3728ac?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stfc) to head [(`6560887`)](https://app.codecov.io/gh/stfc/PSyclone/commit/6560887ef24ee00d8eeb0aa83e6de10d9b5a06e5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stfc).
> Report is 45 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           master    #2833    +/-   ##
========================================
  Coverage   99.89%   99.89%            
========================================
  Files         362      362            
  Lines       51571    51685   +114     
========================================
+ Hits        51519    51633   +114     
  Misses         52       52            
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/stfc/PSyclone/pull/2833?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stfc).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stfc).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: schreiberx
created_at: 2025-01-10T12:37:55Z
body: @arporter Your turn. Let's try to stay below 50 comments :-)
author_association: COLLABORATOR
user: login: schreiberx
created_at: 2025-02-20T11:56:11Z
body: @arporter Back to you :-)
author_association: COLLABORATOR
user: login: arporter
created_at: 2025-02-28T10:00:47Z
body: Thanks Martin. There are a few things to look at including a few you seem to have missed last time :-) I note that there is still at least one bare assert in the code but I'll do a proper grep next time around. Please could you also bring up-to-date with master.
author_association: MEMBER
user: login: schreiberx
created_at: 2025-03-11T21:28:22Z
body: Back to you @arporter !
author_association: COLLABORATOR
user: login: schreiberx
created_at: 2025-03-13T12:57:47Z
body: @arporter Back to you.
author_association: COLLABORATOR
user: login: schreiberx
created_at: 2025-03-21T08:36:28Z
body: @arporter Back to you
author_association: COLLABORATOR
user: login: schreiberx
created_at: 2025-03-24T20:56:09Z
body: Salut @arporter ,  I'm answering here since answering your comment above doesn't work.
I'm using a list since this list is deterministic under hash sum creation. If creating a hash sum of all .... Ah......... I think I get what you mean.
You mean a list is unnecessary here since `not_found` is never stored. I think you're right. Good to have this review :-)
author_association: COLLABORATOR
user: login: schreiberx
created_at: 2025-03-24T20:59:47Z
body: @arporter Changes are added. It's now a set. Again, thanks for spotting the previous performance bug.
author_association: COLLABORATOR