Source code for commonlibs.fileio.paths

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# ----------------------------------------------------------------------
# Copyright 2019-2020 Airinnova AB and the CommonLibs authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------

# Author: Aaron Dettmann

"""
Better file path handling
"""

from collections import defaultdict
from pathlib import Path, PurePath
import os
import shutil
import string


[docs]class ProjectPaths: UID_ROOT = 'root' FORMATTER_COUNTER = 'counter' def __init__(self, root_dir): """ Class providing tools for filepath handling This class automatically converts filepaths into absolute paths, which helps to avoid changing directories during runtime of a script. Paths stored and returned in this class are based on the 'Path()' object from the pathlib standard library, see also: * https://docs.python.org/3/library/pathlib.html Args: :root_dir: Project root directory (as abs. or rel. path) The 'root_dir' is the project root directory. All other other added to this class are assumed to reside inside the 'root_dir'. In other words, absolute file paths are assembled based on the 'root_dir'. Attributes: :_counter: Index to create numbered paths :_abs_paths: Dictionary with absolute paths :groups: Dictionary with grouped file UIDs """ self._counter = 0 self._abs_paths = {} self._groups = defaultdict(list) self._set_root_dir(root_dir) def __call__(self, uid, make_dirs=False, is_dir=False): """ Return a path for given UID Args: :uid: Unique identifier for the path """ path = self._format_path(uid) if make_dirs: parent_dirs = path if is_dir else path.parent parent_dirs.mkdir(parents=True, exist_ok=True) return path def _set_root_dir(self, root_dir): """ Set the project root directory (rel. path will be converted to abs.) Args: :root_dir: Project root directory """ self._abs_paths[self.UID_ROOT] = Path(root_dir).resolve() @property def root(self): """Return the Path() object for the project root directory""" return self.__call__(self.UID_ROOT) @property def counter(self): return self._counter @counter.setter def counter(self, counter): if not isinstance(counter, int): raise ValueError("Counter must be of type 'int'") self._counter = counter
[docs] def add_path(self, uid, path, uid_groups=None, is_absolute=False): """ Add a path Args: :uid: Unique identifier for the path :path: Path string or Path() object :uid_groups: Optional UID(s) to identify files by groups :is_absolute: Flag indicating if given 'path' is absolute """ if uid in self._abs_paths.keys(): raise ValueError(f"UID '{uid}' already used") # ----- Group bookkeeping ----- if uid_groups is not None: if not isinstance(uid_groups, (str, list, tuple)): raise TypeError(f"'uid_groups' must be of type (str, list, tuple)") if isinstance(uid_groups, str): uid_groups = (uid_groups,) for uid_group in uid_groups: self._groups[uid_group].append(uid) path = Path(path) if not is_absolute: path = join_paths(self.root, path) self._abs_paths[uid] = path
[docs] def add_subpath(self, uid_parent, uid, path, uid_groups=None): """ Add a child path to an existing parent path Args: :uid_parent: UID of the parent directory :uid: UID of the new path :path: relative path to add :uid_groups: Optional UID(s) to identify files by groups """ if uid_parent not in self._abs_paths.keys(): raise ValueError(f"Parent UID '{uid_parent}' not found") parent_path = self._abs_paths[uid_parent] assembled_path = join_paths(parent_path, path) self.add_path(uid, assembled_path, uid_groups)
[docs] def remove_path(self, uid): """ Remove a path from the bookkeeping Args: :uid: Unique identifier for the path """ # Dont allow to remove root # - change_root() del self._abs_paths[uid]
[docs] def add_suffix(self, uid, new_suffix): """ Add a suffix to a path Args: :uid: Unique identifier for the path :new_suffix: String with the new suffix """ if not isinstance(new_suffix, str): raise ValueError("Suffix must be a string") if not new_suffix.startswith("."): new_suffix = "." + new_suffix self._abs_paths[uid] = Path(str(self._abs_paths[uid]) + new_suffix)
[docs] def change_suffix(self, uid, new_suffix): """ Modify the suffix of a path Args: :uid: Unique identifier for the path :new_suffix: String with the new suffix """ old_suffix = PurePath(self._abs_paths[uid]).suffix if old_suffix: # Temporarily (!) we can store a string here self._abs_paths[uid] = str(self._abs_paths[uid])[:-len(old_suffix)] self.add_suffix(uid, new_suffix)
def _format_path(self, uid): """ TODO: UPDATE docstring Run a string format() method on a path and return new path Args: :uid: Unique identifier All other standard arguments and keyword arguments are forwarded to the format() method of 'str' """ if uid not in self._abs_paths.keys(): raise ValueError(f"UID '{uid}' not found") formatted_path = str(self._abs_paths[uid]) # formatted_path = str(self._abs_paths[uid]).format(*args, **kwargs) # #### TODO: IMPROVE!!! #### # - Make more general formatters = [f for (_, f, _, _) in string.Formatter().parse(formatted_path)] if self.FORMATTER_COUNTER in formatters: formatted_path = formatted_path.format(counter=self.counter) # ########################## return Path(formatted_path)
[docs] def iter_group_paths(self, uid_groups, make_dirs=False, is_dir=False): """ Return a generator with paths belong to group with given UID Args: :uid_groups: Optional UID(s) to identify files by groups """ if not isinstance(uid_groups, (str, list, tuple)): raise TypeError(f"'uid_groups' must be of type (str, list, tuple)") if isinstance(uid_groups, str): uid_groups = (uid_groups,) for uid_group in uid_groups: for uid in self._groups[uid_group]: yield self.__call__(uid, make_dirs=make_dirs, is_dir=is_dir)
[docs] def make_dirs_for_groups(self, uid_groups, is_dir=True): """ Create directories for all paths belonging to specified group(s) Args: :uid_groups: Optional UID(s) to identify files by groups """ for _ in self.iter_group_paths(uid_groups, make_dirs=True, is_dir=is_dir): pass
[docs] def rm_dirs_for_groups(self, uid_groups): """ TODO Args: :uid_groups: Optional UID(s) to identify files by groups """ for path in self.iter_group_paths(uid_groups): if path.is_dir(): shutil.rmtree(path, ignore_errors=True) else: # ############# TODO : better # - ignore_errors etc... try: os.remove(path) except: pass
[docs]def join_paths(*paths): """ Join two or more paths and return a new 'Path()' object Args: :paths: Paths to join Returns: :joined_path: Joined path Raises: :TypeError: If not enough paths are given """ if len(paths) < 2: raise TypeError("Must provide at leat two paths") # TODO: check paths types (string, or path-like) joined_path = PurePath(paths[0]) for path in paths[1:]: joined_path = joined_path.joinpath(path) return Path(joined_path)