# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2015-2021 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Definitions and helpers to handle filesets."""
import os
from glob import iglob
from typing import List, Set, Tuple
from craft_parts import errors
from craft_parts.utils import path_utils
[docs]class Fileset:
"""Helper class to process string lists."""
def __init__(self, entries: List[str], *, name: str = ""):
self._name = name
self._list = entries
def __repr__(self) -> str:
return f"Fileset({self._list!r}, name={self._name!r})"
@property
def name(self) -> str:
"""Return the fileset name."""
return self._name
@property
def entries(self) -> List[str]:
"""Return the list of entries in this fileset."""
return self._list.copy()
@property
def includes(self) -> List[str]:
"""Return the list of files to be included."""
return [path_utils.get_partitioned_path(x) for x in self._list if x[0] != "-"]
@property
def excludes(self) -> List[str]:
"""Return the list of files to be excluded."""
return [
path_utils.get_partitioned_path(x[1:]) for x in self._list if x[0] == "-"
]
[docs] def remove(self, item: str) -> None:
"""Remove this entry from the list of files.
:param item: The item to remove.
"""
self._list.remove(item)
[docs] def combine(self, other: "Fileset") -> None:
"""Combine the entries in this fileset with entries from another fileset.
:param other: The fileset to combine with.
"""
to_combine = False
# combine if the fileset has a wildcard
if "*" in self.entries:
to_combine = True
self.remove("*")
other_excludes = set(other.excludes)
my_includes = set(self.includes)
contradicting_set = set.intersection(other_excludes, my_includes)
if contradicting_set:
raise errors.FilesetConflict(contradicting_set)
# combine if the fileset is only excludes
if {x[0] for x in self.entries} == set("-"):
to_combine = True
if to_combine:
self._list = list(set(self._list + other.entries))
[docs]def migratable_filesets(fileset: Fileset, srcdir: str) -> Tuple[Set[str], Set[str]]:
"""Return the files and directories that can be migrated.
:param fileset: The fileset to migrate.
:return: A tuple containing the set of files and the set of directories
that can be migrated.
"""
includes, excludes = _get_file_list(fileset)
include_files = _generate_include_set(srcdir, includes)
exclude_files, exclude_dirs = _generate_exclude_set(srcdir, excludes)
files = include_files - exclude_files
for exclude_dir in exclude_dirs:
files = {x for x in files if not x.startswith(exclude_dir + "/")}
# Separate dirs from files.
dirs = {
x
for x in files
if os.path.isdir(os.path.join(srcdir, x))
and not os.path.islink(os.path.join(srcdir, x))
}
# Remove dirs from files.
files = files - dirs
# Include (resolved) parent directories for each selected file.
for filename in files:
filename = _get_resolved_relative_path(filename, srcdir)
dirname = os.path.dirname(filename)
while dirname:
dirs.add(dirname)
dirname = os.path.dirname(dirname)
# Resolve parent paths for dirs and files.
resolved_dirs = set()
for dirname in dirs:
resolved_dirs.add(_get_resolved_relative_path(dirname, srcdir))
resolved_files = set()
for filename in files:
resolved_files.add(_get_resolved_relative_path(filename, srcdir))
return resolved_files, resolved_dirs
def _get_file_list(fileset: Fileset) -> Tuple[List[str], List[str]]:
"""Split a fileset to obtain include and exclude file filters.
:param fileset: The fileset to split.
:return: A tuple containing the include and exclude lists.
"""
includes: List[str] = []
excludes: List[str] = []
for item in fileset.entries:
if item.startswith("-"):
excludes.append(item[1:])
elif item.startswith("\\"):
includes.append(item[1:])
else:
includes.append(item)
# paths must be relative
for entry in includes + excludes:
if os.path.isabs(entry):
raise errors.FilesetError(
name=fileset.name, message=f"path {entry!r} must be relative."
)
includes = includes or ["*"]
processed_includes: List[str] = []
processed_excludes: List[str] = []
for file in includes:
processed_includes.append(path_utils.get_partitioned_path(file))
for file in excludes:
processed_excludes.append(path_utils.get_partitioned_path(file))
return processed_includes, processed_excludes
def _generate_include_set(directory: str, includes: List[str]) -> Set[str]:
"""Obtain the list of files to include based on include file filter.
:param directory: The path to the tree containing the files to filter.
:return: The set of files to include.
"""
include_files = set()
for include in includes:
if "*" in include:
pattern = os.path.join(directory, include)
matches = iglob(pattern, recursive=True)
include_files |= set(matches)
else:
include_files |= set([os.path.join(directory, include)])
include_dirs = [
x for x in include_files if os.path.isdir(x) and not os.path.islink(x)
]
include_files = {os.path.relpath(x, directory) for x in include_files}
# Expand includeFiles, so that an exclude like '*/*.so' will still match
# files from an include like 'lib'
for include_dir in include_dirs:
for root, dirs, files in os.walk(include_dir):
include_files |= {
os.path.relpath(os.path.join(root, d), directory) for d in dirs
}
include_files |= {
os.path.relpath(os.path.join(root, f), directory) for f in files
}
return include_files
def _generate_exclude_set(
directory: str, excludes: List[str]
) -> Tuple[Set[str], Set[str]]:
"""Obtain the list of files to exclude based on exclude file filter.
:param directory: The path to the tree containing the files to filter.
:return: The set of files to exclude.
"""
exclude_files = set()
for exclude in excludes:
pattern = os.path.join(directory, exclude)
matches = iglob(pattern, recursive=True)
exclude_files |= set(matches)
exclude_dirs = {
os.path.relpath(x, directory) for x in exclude_files if os.path.isdir(x)
}
exclude_files = {os.path.relpath(x, directory) for x in exclude_files}
return exclude_files, exclude_dirs
def _get_resolved_relative_path(relative_path: str, base_directory: str) -> str:
"""Resolve path components against target base_directory.
If the resulting target path is a symlink, it will not be followed.
Only the path's parents are fully resolved against base_directory,
and the relative path is returned.
:param relative_path: Path of target, relative to base_directory.
:param base_directory: Base path of target.
:return: Resolved path, relative to base_directory.
"""
parent_relpath, filename = os.path.split(relative_path)
parent_abspath = os.path.realpath(os.path.join(base_directory, parent_relpath))
filename_abspath = os.path.join(parent_abspath, filename)
filename_relpath = os.path.relpath(filename_abspath, base_directory)
return filename_relpath