Module QFlow.builders.tree

Classes

class Node (path: str, root: str = None, normalRoot: str = None)
Expand source code
class Node(dict):
    """
    Node of the folder/file tree.

    The LAST index of the tuple is the one resolved: it always returns the
    ABSOLUTE path corresponding to that node (folder or file).

    Special keys, valid only as the FIRST element of the lookup tuple:
      - 'unf' (unfrozenSpecialKey): rebase the resolved path from the frozen
        root (`root`) onto the normal, non-frozen root (`normalRoot`) --
        i.e. the folder that actually contains the script/executable,
        instead of a PyInstaller onefile temp extraction folder.
      - 'f' (frozenSpecialKey): explicitly resolve against the frozen root
        (`root`), with no rebasing. This is the default behavior when no
        special key is given at all -- 'f' exists only to let callers be
        explicit about which resolution they want, symmetric with 'unf'.

    Everything after the special key (if any) is resolved exactly as usual.
    """
    def __init__(self, path: str, root: str = None, normalRoot: str = None):
        super().__init__()
        self.path = path
        self.root = root if root is not None else path
        self.normalRoot = normalRoot if normalRoot is not None else path

        self.unfrozenSpecialKey = 'unf'
        self.frozenSpecialKey = 'f'

    def __getitem__(self, key):
        keys = key if isinstance(key, tuple) else (key,)

        useNormal = False
        if keys and keys[0] in (self.unfrozenSpecialKey, self.frozenSpecialKey):
            useNormal = keys[0] == self.unfrozenSpecialKey
            keys = keys[1:]

        node = self
        for k in keys:
            node = dict.__getitem__(node, k)

        path = node.path if isinstance(node, Node) else node

        if useNormal:
            relative = os.path.relpath(path, self.root)
            path = os.path.normpath(os.path.join(self.normalRoot, relative))

        return path

    def __repr__(self):
        return f'Node(path={self.path!r}, children={list(self.keys())})'

Node of the folder/file tree.

The LAST index of the tuple is the one resolved: it always returns the ABSOLUTE path corresponding to that node (folder or file).

Special keys, valid only as the FIRST element of the lookup tuple: - 'unf' (unfrozenSpecialKey): rebase the resolved path from the frozen root (root) onto the normal, non-frozen root (normalRoot) – i.e. the folder that actually contains the script/executable, instead of a PyInstaller onefile temp extraction folder. - 'f' (frozenSpecialKey): explicitly resolve against the frozen root (root), with no rebasing. This is the default behavior when no special key is given at all – 'f' exists only to let callers be explicit about which resolution they want, symmetric with 'unf'.

Everything after the special key (if any) is resolved exactly as usual.

Ancestors

  • builtins.dict
class Tree (configFile: str = 'config.json')
Expand source code
class Tree:
    """
    Tree that scans the folders/patterns defined in a JSON config file and
    exposes their absolute paths through:

        tree.f['folder', 'subfolder', ...]

    Prefix the key with 'unf' to resolve the SAME relative path against the
    normal (non-frozen) base path instead of the frozen one:

        tree.f['unf', 'folder', 'subfolder', ...]

    The config JSON must have the form:
        {
            "folders": ["app", "app/**/*.html"]
        }

    Patterns are resolved with glob (supports recursive '**') relative to
    the project's base path (see `basePath`), which changes automatically
    if the program is compiled/frozen.
    """

    def __init__(self, configFile: str = 'config.json'):
        self.root = Tree.basePath()
        self.normalRoot = Tree.normalPath()
        cfgPath = (
            configFile if os.path.isabs(configFile)
            else os.path.join(self.root, configFile)
        )
        self.config = self.loadConfig(cfgPath)
        self.f = self.buildTree(getattr(self.config, 'folders', []))

    def loadConfig(self, path: str) -> SimpleNamespace:
        if not os.path.isfile(path):
            raise FileNotFoundError(f"Config file not found: '{path}'.")
        
        with open(path, 'r', encoding='utf-8') as fh:
            data = json.load(fh)

        return Object(data).obj

    def buildTree(self, patterns) -> Node:
        root = Node(self.root, root=self.root, normalRoot=self.normalRoot)
        for pattern in patterns:
            patternPath = os.path.join(self.root, pattern)
            matches = glob.glob(patternPath, recursive=True)
            if not matches:
                matches = [patternPath]
            for absolutePath in matches:
                relative = os.path.relpath(absolutePath, self.root)
                parts = relative.split(os.sep)
                self.insertNode(root, parts, os.path.abspath(absolutePath))
        return root

    def insertNode(self, node: Node, parts: list, absolutePath: str):
        current = node
        accumulated = self.root
        for part in parts:
            accumulated = os.path.join(accumulated, part)
            if part not in current:
                dict.__setitem__(current, part, Node(os.path.abspath(accumulated)))
            current = dict.__getitem__(current, part)
        current.path = absolutePath

    @staticmethod
    def basePath() -> str:
        """
        Absolute base path of the project.

        - If the executable is frozen (PyInstaller or another bundler):
            * sys._MEIPASS -> --onefile mode (resources extracted to a temp folder)
            * dirname(sys.executable) -> --onedir mode (resources next to the binary)
        - If running as a normal script, uses the directory of the script that
        started the process (sys.argv[0]).
        """
        if FROZENLIB:
            return os.path.abspath(sys._MEIPASS)
        return os.path.dirname(os.path.abspath(sys.argv[0]))

    @staticmethod
    def normalPath() -> str:
        """
        Absolute path of the folder that actually contains the running
        script/executable, regardless of freezing mode.

        Unlike `basePath`, this NEVER returns a --onefile temp extraction
        folder (sys._MEIPASS): it always points to where the .exe/script
        physically lives, which is what you want for files that must be
        read/written alongside the binary (logs, user config, output, etc.)
        rather than looked up inside the bundled resources.
        """
        if FROZENLIB:
            return os.path.dirname(os.path.abspath(sys.executable))
        return os.path.dirname(os.path.abspath(sys.argv[0]))

Tree that scans the folders/patterns defined in a JSON config file and exposes their absolute paths through:

tree.f['folder', 'subfolder', ...]

Prefix the key with 'unf' to resolve the SAME relative path against the normal (non-frozen) base path instead of the frozen one:

tree.f['unf', 'folder', 'subfolder', ...]

The config JSON must have the form: { "folders": ["app", "app/*/.html"] }

Patterns are resolved with glob (supports recursive '**') relative to the project's base path (see basePath), which changes automatically if the program is compiled/frozen.

Static methods

def basePath() ‑> str
Expand source code
@staticmethod
def basePath() -> str:
    """
    Absolute base path of the project.

    - If the executable is frozen (PyInstaller or another bundler):
        * sys._MEIPASS -> --onefile mode (resources extracted to a temp folder)
        * dirname(sys.executable) -> --onedir mode (resources next to the binary)
    - If running as a normal script, uses the directory of the script that
    started the process (sys.argv[0]).
    """
    if FROZENLIB:
        return os.path.abspath(sys._MEIPASS)
    return os.path.dirname(os.path.abspath(sys.argv[0]))

Absolute base path of the project.

  • If the executable is frozen (PyInstaller or another bundler):
    • sys._MEIPASS -> –onefile mode (resources extracted to a temp folder)
    • dirname(sys.executable) -> –onedir mode (resources next to the binary)
  • If running as a normal script, uses the directory of the script that started the process (sys.argv[0]).
def normalPath() ‑> str
Expand source code
@staticmethod
def normalPath() -> str:
    """
    Absolute path of the folder that actually contains the running
    script/executable, regardless of freezing mode.

    Unlike `basePath`, this NEVER returns a --onefile temp extraction
    folder (sys._MEIPASS): it always points to where the .exe/script
    physically lives, which is what you want for files that must be
    read/written alongside the binary (logs, user config, output, etc.)
    rather than looked up inside the bundled resources.
    """
    if FROZENLIB:
        return os.path.dirname(os.path.abspath(sys.executable))
    return os.path.dirname(os.path.abspath(sys.argv[0]))

Absolute path of the folder that actually contains the running script/executable, regardless of freezing mode.

Unlike basePath, this NEVER returns a –onefile temp extraction folder (sys._MEIPASS): it always points to where the .exe/script physically lives, which is what you want for files that must be read/written alongside the binary (logs, user config, output, etc.) rather than looked up inside the bundled resources.

Methods

def buildTree(self, patterns) ‑> Node
Expand source code
def buildTree(self, patterns) -> Node:
    root = Node(self.root, root=self.root, normalRoot=self.normalRoot)
    for pattern in patterns:
        patternPath = os.path.join(self.root, pattern)
        matches = glob.glob(patternPath, recursive=True)
        if not matches:
            matches = [patternPath]
        for absolutePath in matches:
            relative = os.path.relpath(absolutePath, self.root)
            parts = relative.split(os.sep)
            self.insertNode(root, parts, os.path.abspath(absolutePath))
    return root
def insertNode(self,
node: Node,
parts: list,
absolutePath: str)
Expand source code
def insertNode(self, node: Node, parts: list, absolutePath: str):
    current = node
    accumulated = self.root
    for part in parts:
        accumulated = os.path.join(accumulated, part)
        if part not in current:
            dict.__setitem__(current, part, Node(os.path.abspath(accumulated)))
        current = dict.__getitem__(current, part)
    current.path = absolutePath
def loadConfig(self, path: str) ‑> types.SimpleNamespace
Expand source code
def loadConfig(self, path: str) -> SimpleNamespace:
    if not os.path.isfile(path):
        raise FileNotFoundError(f"Config file not found: '{path}'.")
    
    with open(path, 'r', encoding='utf-8') as fh:
        data = json.load(fh)

    return Object(data).obj