blob: 30ecfa32cbbe0f6aba95fc387a33db1a8678b4ca [file] [log] [blame]
#
# Copyright (C) 2020 Codethink Limited
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library 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 library. If not, see <http://www.gnu.org/licenses/>.
#
# Authors:
# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
import os
from contextlib import contextmanager
from typing import TYPE_CHECKING, Optional, List, Tuple
from .plugin import Plugin
from .types import CoreWarnings, OverlapAction
from .utils import FileListResult
if TYPE_CHECKING:
from typing import Dict
# pylint: disable=cyclic-import
from .element import Element
# pylint: enable=cyclic-import
# OverlapCollector()
#
# Collects results of Element.stage_artifact() and saves
# them in order to raise a proper overlap error at the end
# of staging.
#
# Args:
# element (Element): The element for which we are staging artifacts
#
class OverlapCollector:
def __init__(self, element: "Element"):
# The Element we are staging for, on which we'll issue warnings
self._element = element # type: Element
# The list of sessions
self._sessions = [] # type: List[OverlapCollectorSession]
# The active session, if any
self._session = None # type: Optional[OverlapCollectorSession]
# session()
#
# Create a session for collecting overlaps, calls to OverlapCollector.collect_stage_result()
# are expected to always occur within the context of a session (this context manager).
#
# Upon exiting this context, warnings and/or errors will be issued for any overlaps
# which occurred either as a result of overlapping files within this session, or
# as a result of files staged during this session, overlapping with files staged in
# previous sessions in this OverlapCollector.
#
# Args:
# action (OverlapAction): The action to take for this overall session's overlaps with other sessions
# location (str): The Sandbox relative location this session was created for
#
@contextmanager
def session(self, action: str, location: Optional[str]):
assert self._session is None, "Stage session already started"
if location is None:
location = "/"
self._session = OverlapCollectorSession(self._element, action, location)
# Run code body where staging results can be collected.
yield
# Issue warnings for the current session, passing along previously completed sessions
self._session.warnings(self._sessions)
# Store the newly ended session and end the session
self._sessions.append(self._session)
self._session = None
# collect_stage_result()
#
# Collect and accumulate results of Element.stage_artifact()
#
# Args:
# element (Element): The name of the element staged
# result (FileListResult): The result of Element.stage_artifact()
#
def collect_stage_result(self, element: "Element", result: FileListResult):
assert self._session is not None, "Staging files outside of staging session"
self._session.collect_stage_result(element, result)
# OverlapCollectorSession()
#
# Collect the results of a single session
#
# Args:
# element (Element): The element for which we are staging artifacts
# action (OverlapAction): The action to take for this overall session's overlaps with other sessions
# location (str): The Sandbox relative location this session was created for
#
class OverlapCollectorSession:
def __init__(self, element: "Element", action: str, location: str):
# The Element we are staging for, on which we'll issue warnings
self._element = element # type: Element
# The OverlapAction for this session
self._action = action # type: str
# The Sandbox relative directory this session was created for
self._location = location # type: str
# Dictionary of files which were ignored (See FileListResult()), keyed by element unique ID
self._ignored = {} # type: Dict[int, List[str]]
# Dictionary of files which were staged, keyed by element unique ID
self._files_written = {} # type: Dict[int, List[str]]
# Dictionary of element IDs which overlapped, keyed by the file they overlap on
self._overlaps = {} # type: Dict[str, List[int]]
# collect_stage_result()
#
# Collect and accumulate results of Element.stage_artifact()
#
# Args:
# element (Element): The name of the element staged
# result (FileListResult): The result of Element.stage_artifact()
#
def collect_stage_result(self, element: "Element", result: FileListResult):
for overwritten_file in result.overwritten:
overlap_list = None
try:
overlap_list = self._overlaps[overwritten_file]
except KeyError:
# Create a fresh list
#
self._overlaps[overwritten_file] = overlap_list = []
# Search files which were staged in this session, start the
# list off with the bottom most element
#
for element_id, staged_files in self._files_written.items():
if overwritten_file in staged_files:
overlap_list.append(element_id)
break
# Add the currently staged element to the overlap list, it might be
# the only element in the list if it overlaps with a file staged
# from a previous session.
#
overlap_list.append(element._unique_id)
# Record written files and ignored files.
#
self._files_written[element._unique_id] = result.files_written
if result.ignored:
self._ignored[element._unique_id] = result.ignored
# warnings()
#
# Issue any warnings as a batch as a result of staging artifacts,
# based on the results collected with collect_stage_result().
#
# Args:
# sessions (list): List of previously completed sessions
#
def warnings(self, sessions: List["OverlapCollectorSession"]):
# Collect a table of filenames which overlapped something from outside of this session.
#
external_overlaps = {} # type: Dict[str, int]
#
# First issue the warnings for this session
#
if self._overlaps:
overlap_warning = False
detail = "Staged files overwrite existing files in staging area: {}\n".format(self._location)
for filename, element_ids in self._overlaps.items():
# If there is only one element in the overlap list, it means it has
# overlapped a file from a previous session.
#
# Ignore it and handle the warning below
#
if len(element_ids) == 1:
external_overlaps[filename] = element_ids[0]
continue
# Filter whitelisted elements out of the list of overlapping elements
#
# Ignore the bottom-most element as it does not overlap anything.
#
overlapping_element_ids = element_ids[1:]
warning_elements = self._filter_whitelisted(filename, overlapping_element_ids)
if warning_elements:
overlap_warning = True
detail += self._overlap_detail(filename, warning_elements, element_ids)
if overlap_warning:
self._element.warn(
"Non-whitelisted overlaps detected", detail=detail, warning_token=CoreWarnings.OVERLAPS
)
if self._ignored:
detail = "Not staging files which would replace non-empty directories in staging area: {}\n".format(
self._location
)
for element_id, ignored_filenames in self._ignored.items():
element = Plugin._lookup(element_id)
detail += "\nFrom {}:\n".format(element._get_full_name())
detail += " " + " ".join(
["{}\n".format(os.path.join(self._location, filename)) for filename in ignored_filenames]
)
self._element.warn(
"Not staging files which would have replaced non-empty directories",
detail=detail,
warning_token=CoreWarnings.UNSTAGED_FILES,
)
if external_overlaps and self._action != OverlapAction.IGNORE:
detail = "Detected file overlaps while staging elements into: {}\n".format(self._location)
# Find the session responsible for the overlap
#
for filename, element_id in external_overlaps.items():
absolute_filename = os.path.join(self._location, filename)
overlapped_id, location = self._search_stage_element(absolute_filename, sessions)
element = Plugin._lookup(element_id)
overlapped = Plugin._lookup(overlapped_id)
detail += "{}: {} overlaps files previously staged by {} in: {}\n".format(
absolute_filename, element._get_full_name(), overlapped._get_full_name(), location
)
if self._action == OverlapAction.WARNING:
self._element.warn("Overlaps detected", detail=detail, warning_token=CoreWarnings.OVERLAPS)
else:
from .element import ElementError
raise ElementError("Overlaps detected", detail=detail, reason="overlaps")
# _search_stage_element()
#
# Search the sessions list for the element responsible for staging the given file
#
# Args:
# filename (str): The sandbox relative file which was overwritten
# sessions (List[OverlapCollectorSession])
#
# Returns:
# element_id (int): The unique ID of the element responsible
# location (str): The sandbox relative staging location where element_id was staged
#
def _search_stage_element(self, filename: str, sessions: List["OverlapCollectorSession"]) -> Tuple[int, str]:
for session in reversed(sessions):
for element_id, staged_files in session._files_written.items():
if any(
staged_file
for staged_file in staged_files
if os.path.join(session._location, staged_file) == filename
):
return element_id, session._location
assert False, "Could not find element responsible for staging: {}".format(filename)
# Silence the linter with an unreachable return statement
return None, None
# _filter_whitelisted()
#
# Args:
# filename (str): The staging session relative filename
# element_ids (List[int]): Ordered list of elements
#
# Returns:
# (List[Element]): The list of element objects which are not whitelisted
#
def _filter_whitelisted(self, filename: str, element_ids: List[int]):
overlap_elements = []
for element_id in element_ids:
element = Plugin._lookup(element_id)
if not element._file_is_whitelisted(filename):
overlap_elements.append(element)
return overlap_elements
# _overlap_detail()
#
# Get a string to describe overlaps on a filename
#
# Args:
# filename (str): The filename being overlapped
# overlap_elements (List[Element]): A list of Elements overlapping
# element_ids (List[int]): The ordered ID list of elements which staged this file
#
def _overlap_detail(self, filename, overlap_elements, element_ids):
filename = os.path.join(self._location, filename)
if overlap_elements:
overlap_element_names = [element._get_full_name() for element in overlap_elements]
overlap_order_elements = [Plugin._lookup(element_id) for element_id in element_ids]
overlap_order_names = [element._get_full_name() for element in overlap_order_elements]
return "{}: {} {} not permitted to overlap other elements, order {} \n".format(
filename,
" and ".join(overlap_element_names),
"is" if len(overlap_element_names) == 1 else "are",
" above ".join(reversed(overlap_order_names)),
)
else:
return ""