# ***** BEGIN GPL LICENSE BLOCK ***** # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # # ***** END GPL LICENCE BLOCK ***** """Scan a directory tree for blend files, to feed a folder-wide BAT pack. This module deliberately has no Blender dependencies, so that it can be used both from the commandline (``bat pack-folder``) and from the add-on. """ import fnmatch import logging import os import pathlib import re import typing from blender_asset_tracer.blendfile import magic_compression log = logging.getLogger(__name__) BLENDFILE_SUFFIX = ".blend" # Matches filenames like scene_v02.blend (single integer version only). # Does NOT match: _v02.1.blend, _v2-1.blend, or files without _vNN suffix. VERSION_RE = re.compile(r"_v(\d+)\.blend$", re.IGNORECASE) # Splits a versioned filename into the parts that identify its version series, # so that scene_v02.blend, scene_v02_light.blend and scene_v02_work.blend are # each versioned independently. The greedy prefix makes the *last* '_vNNN' of # the name the version. VERSION_SERIES_RE = re.compile( r"^(?P.*)_v(?P\d+)(?P.*)\.blend$", re.IGNORECASE ) # Directory name globs that are skipped by default while scanning. DEFAULT_IGNORE_DIRS = (".*", "__pycache__") class ScanResult(typing.NamedTuple): """The outcome of scanning a directory tree for blend files.""" blendfiles: typing.List[pathlib.Path] """Blend files to pack, in deterministic (sorted) order.""" skipped: typing.List[typing.Tuple[pathlib.Path, str]] """Files that were found but not packed, as (path, reason) tuples.""" unreadable_dirs: typing.List[typing.Tuple[pathlib.Path, str]] """Directories that could not be scanned, as (path, error) tuples.""" def find_blend_files( folder: pathlib.Path, *, follow_symlinks: bool = False, ignore_dirs: typing.Sequence[str] = DEFAULT_IGNORE_DIRS, latest_only: bool = False, validate: bool = True, ) -> ScanResult: """Recursively find the blend files in ``folder``. Backup files (``.blend1``, ``.blend2``, …) are never returned, as only the ``.blend`` suffix is matched. :param folder: Directory to scan. :param follow_symlinks: Whether to descend into symlinked directories. Already-visited directories are skipped, so symlink loops are safe. :param ignore_dirs: Globs of directory names to skip entirely. :param latest_only: Only keep the highest ``_vNNN`` file of each series. :param validate: Check that each file actually starts with a blend file magic (optionally compressed). Files that don't are reported as skipped, instead of aborting the entire pack later on. """ blendfiles = [] # type: typing.List[pathlib.Path] skipped = [] # type: typing.List[typing.Tuple[pathlib.Path, str]] unreadable_dirs = [] # type: typing.List[typing.Tuple[pathlib.Path, str]] def on_error(ex: OSError) -> None: path = pathlib.Path(ex.filename) if ex.filename else folder reason = ex.strerror or str(ex) log.warning("Unable to scan %s: %s", path, reason) unreadable_dirs.append((path, reason)) visited_dirs = set() # type: typing.Set[typing.Tuple[int, int]] if follow_symlinks: _first_visit(folder, visited_dirs) for dirpath_str, dirnames, filenames in os.walk( str(folder), onerror=on_error, followlinks=follow_symlinks ): dirpath = pathlib.Path(dirpath_str) # Prune ignored directories, and keep the walk order deterministic. subdirs = sorted( dirname for dirname in dirnames if not _matches_any(dirname, ignore_dirs) ) if follow_symlinks: subdirs = [ dirname for dirname in subdirs if _first_visit(dirpath / dirname, visited_dirs) ] dirnames[:] = subdirs for filename in sorted(filenames): path = dirpath / filename if path.suffix.lower() != BLENDFILE_SUFFIX: continue if validate: reason = why_not_a_blendfile(path) if reason is not None: log.warning("Skipping %s: %s", path, reason) skipped.append((path, reason)) continue blendfiles.append(path) if latest_only: blendfiles, superseded = keep_latest_versions(blendfiles) for path in superseded: log.debug("Skipping %s: superseded by a newer version", path) skipped.extend((path, "superseded by a newer version") for path in superseded) return ScanResult( blendfiles=blendfiles, skipped=skipped, unreadable_dirs=unreadable_dirs ) def why_not_a_blendfile(path: pathlib.Path) -> typing.Optional[str]: """Return None when the file looks like a blend file, else why it doesn't.""" try: with path.open("rb") as fileobj: compression = magic_compression.find_compression_type(fileobj) except OSError as ex: return "unable to read: %s" % (ex.strerror or ex) if compression == magic_compression.Compression.UNRECOGNISED: return "not a blend file" return None def keep_latest_versions( blendfiles: typing.Iterable[pathlib.Path], ) -> typing.Tuple[typing.List[pathlib.Path], typing.List[pathlib.Path]]: """Reduce each ``name_vNNN.blend`` series to its highest version. Files whose name contains no ``_vNNN`` are always kept. Versions are compared per directory and per name, so identically-named files in different directories don't shadow each other, and variants such as ``shot_v003_light.blend`` form their own series. :return: (files to keep, superseded files), both sorted. """ # Maps (directory, name without version) to the best (version, path) so far. best = ( {} ) # type: typing.Dict[typing.Tuple[pathlib.Path, str, str], typing.Tuple[int, pathlib.Path]] kept = [] # type: typing.List[pathlib.Path] superseded = [] # type: typing.List[pathlib.Path] for path in blendfiles: match = VERSION_SERIES_RE.match(path.name) if match is None: kept.append(path) continue key = ( path.parent, match.group("prefix").lower(), match.group("suffix").lower(), ) version = int(match.group("version")) previous = best.get(key) if previous is None: best[key] = (version, path) elif version > previous[0]: best[key] = (version, path) superseded.append(previous[1]) else: superseded.append(path) kept.extend(path for _, path in best.values()) kept.sort() superseded.sort() return kept, superseded def _matches_any(name: str, patterns: typing.Sequence[str]) -> bool: return any(fnmatch.fnmatch(name, pattern) for pattern in patterns) def _first_visit( dirpath: pathlib.Path, visited: typing.Set[typing.Tuple[int, int]] ) -> bool: """Return True when this directory wasn't walked before. Used to keep ``follow_symlinks`` from looping forever. """ try: stat = dirpath.stat() except OSError as ex: log.warning("Unable to inspect %s: %s", dirpath, ex.strerror or ex) return False key = (stat.st_dev, stat.st_ino) if key in visited: log.warning("Not descending into %s again (symlink loop?)", dirpath) return False visited.add(key) return True