blob: f35075201e85d35bbd1e471fcfc96d28668e4d23 [file] [log] [blame]
# Copyright 2013 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
from __future__ import annotations
import collections
import itertools
from typing import Any
import abc
import os
def ResetGlobalVariableAssignments():
RecipeConfigType._TOSTRING_MAP.clear() # pylint: disable=W0212
NamedBasePath._API = None
class RecipeConfigType:
"""Base class for custom Recipe config types, intended to be subclassed.
RecipeConfigTypes are meant to be PURE data. There should be no dependency on
any external systems (i.e. no importing sys, os, etc.).
The subclasses should override default_tostring_fn. This method should
produce a string representation of the object. This string representation
should contain all of the data members of the subclass. This representation
will be used during the execution of the recipe_config_tests.
External entities (usually recipe modules), can override the default
tostring_fn method by calling <RecipeConfigType
subclass>.set_tostring_fn(<new method>). This new method will receive an
instance of the RecipeConfigType subclass as its single argument, and is
expected to return a string. There is no restriction on the data that the
override tostring_fn may use. For example, the Path class in this module has
its tostring_fn overridden by the 'path' recipe_module. This new tostring_fn
uses data from the current recipe run, like the host os, to return platform
specific strings using the data in the Path object.
"""
_TOSTRING_MAP = {}
@property
def tostring_fn(self):
cls = self.__class__
return self._TOSTRING_MAP.get(cls.__name__, cls.default_tostring_fn)
@classmethod
def set_tostring_fn(cls, new_tostring_fn):
assert cls.__name__ not in cls._TOSTRING_MAP, (
'tostring_fn already installed for %s' % cls)
cls._TOSTRING_MAP[cls.__name__] = new_tostring_fn
def default_tostring_fn(self):
raise NotImplementedError()
def __str__(self):
return self.tostring_fn(self) # pylint: disable=not-callable
class BasePath(metaclass=abc.ABCMeta):
@abc.abstractmethod
def resolve(self, test_enabled: bool) -> str:
"""Returns a string representation of the path base.
Args:
test_enabled: True iff this is only for recipe expectations.
Raises:
NotImplementedError: If this method isn't overridden by a subclass.
"""
raise NotImplementedError()
class NamedBasePath(BasePath, collections.namedtuple('NamedBasePath', 'name')):
_API = None
@classmethod
def set_path_api(cls, api):
cls._API = api
def resolve(self, test_enabled: bool) -> str:
if self.name == self._API.CheckoutPathName:
checkout_dir = self._API.checkout_dir
if checkout_dir is None:
raise ValueError(
f'Cannot resolve NamedBasePath({self.name!r}) - api.path.checkout_dir is unset.')
return str(checkout_dir)
if self.name in self._API.c.base_paths:
if test_enabled:
return repr(self)
return self._API.join(
*self._API.c.base_paths[self.name]) # pragma: no cover
raise KeyError(
'Failed to resolve NamedBasePath: %s' % self.name) # pragma: no cover
def __repr__(self):
return '[%s]' % self.name.upper()
class ModuleBasePath(BasePath, collections.namedtuple('ModuleBasePath',
'module')):
def resolve(self, test_enabled):
if test_enabled:
return repr(self)
return self.module.path # pragma: no cover
def __repr__(self):
# We change python's module delimiter . to ::, since . is already used
# by expect tests.
return f'RECIPE_MODULE[{self.module.repo.name}::{self.module.name}]'
class RecipeScriptBasePath(BasePath,
collections.namedtuple('RecipeScriptBasePath',
'recipe_name script_path')):
def resolve(self, test_enabled):
if test_enabled:
return repr(self)
return os.path.splitext(
self.script_path)[0] + '.resources' # pragma: no cover
def __repr__(self):
return 'RECIPE[%s].resources' % self.recipe_name
class RepoBasePath(BasePath,
collections.namedtuple('RepoBasePath',
'repo_name repo_root_path')):
def resolve(self, test_enabled):
if test_enabled:
return repr(self)
return self.repo_root_path # pragma: no cover
def __repr__(self):
return 'RECIPE_REPO[%s]' % self.repo_name
class Path(RecipeConfigType):
"""Represents a path which is relative to a semantically-named base.
Because there's a lot of platform (separator style) and runtime-specific
context (working directory) which goes into assembling a final OS-specific
absolute path, we only store three context-free attributes in this Path
object.
"""
def __init__(self,
base: BasePath,
*pieces: str):
"""Creates a Path.
Args:
base: The 'name' of a base path, to be filled in at recipe runtime
by the 'path' recipe module.
*pieces: The components of the path relative to base. These pieces must
be non-relative (i.e. no '..' or '.', etc. as a piece).
"""
super().__init__()
assert isinstance(base, BasePath), base
assert all(isinstance(x, str) for x in pieces), pieces
assert not any(x in ('..', '/', '\\') for x in pieces)
self._base = base
self._pieces = tuple(p for p in pieces if p != '.')
@property
def base(self) -> BasePath:
return self._base
@property
def pieces(self) -> tuple[str, ...]:
return self._pieces
def __eq__(self, other: Path) -> bool:
return (self.base == other.base and
self.pieces == other.pieces)
def __hash__(self) -> int:
return hash((
self.base,
self.pieces,
))
def __ne__(self, other: Any) -> bool:
return not self == other
def __lt__(self, other: Path) -> bool:
if self.base != other.base:
# NOTE: bases all happen to extend namedtuple, which makes this comparison
# work.
return self.base < other.base
return self.pieces < other.pieces
def __truediv__(self, piece: str) -> Path:
"""Adds the shorthand '/'-operator for .join(), returning a new path."""
return self.join(piece)
def join(self, *pieces: str) -> Path:
"""Appends *pieces to this Path, returning a new Path.
Empty values ('', None) in pieces will be omitted.
Args:
pieces: The components of the path relative to base. These pieces must be
non-relative (i.e. no '..' as a piece).
Returns:
The new Path.
"""
if not pieces:
return self
return Path(
self.base,
*[p for p in itertools.chain(self.pieces, pieces) if p])
def is_parent_of(self, child: Path) -> bool:
"""True if |child| is in a subdirectory of this path."""
# Assumes base paths are not nested.
# TODO(vadimsh): We should not rely on this assumption.
if self.base != child.base:
return False
# A path is not a parent to itself.
if len(self.pieces) >= len(child.pieces):
return False
return child.pieces[:len(self.pieces)] == self.pieces
def separate(self, separator: str) -> None:
"""Breaks apart any pieces of self.pieces containing the separator.
Example: If self.pieces is ('foo', 'bar/baz') and separator='/', then
self.pieces will be transformed into ('foo', 'bar', 'baz'). This allows for
more accurate comparisons, like equality or parenthood.
Args:
separator: The file separator character for this platform: '/' for POSIX,
'\\' for Windows. Usually fetched via api.path.sep.
"""
self._pieces = sum((tuple(piece.split(separator)) for piece in self.pieces),
start=())
def __repr__(self) -> str:
s = 'Path(%r' % (self.base,)
if self.pieces:
s += ', %s' % ', '.join(repr(x) for x in self.pieces)
return s + ')'