# Copyright 2008-2015 Nokia Networks
# Copyright 2016- Robot Framework Foundation
#
# 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.
import fnmatch
import os.path
import re
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Iterable, Iterator, Sequence
from robot.errors import DataError
from robot.output import LOGGER
from robot.utils import get_error_message
[docs]class SuiteStructure(ABC):
source: 'Path|None'
init_file: 'Path|None'
children: 'list[SuiteStructure]|None'
def __init__(self, extensions: 'ValidExtensions', source: 'Path|None',
init_file: 'Path|None' = None,
children: 'Sequence[SuiteStructure]|None' = None):
self._extensions = extensions
self.source = source
self.init_file = init_file
self.children = list(children) if children is not None else None
@property
def extension(self) -> 'str|None':
source = self._get_source_file()
return self._extensions.get_extension(source) if source else None
@abstractmethod
def _get_source_file(self) -> 'Path|None':
raise NotImplementedError
[docs] @abstractmethod
def visit(self, visitor: 'SuiteStructureVisitor'):
raise NotImplementedError
[docs]class SuiteFile(SuiteStructure):
source: Path
def __init__(self, extensions: 'ValidExtensions', source: Path):
super().__init__(extensions, source)
def _get_source_file(self) -> Path:
return self.source
[docs] def visit(self, visitor: 'SuiteStructureVisitor'):
visitor.visit_file(self)
[docs]class SuiteDirectory(SuiteStructure):
children: 'list[SuiteStructure]'
def __init__(self, extensions: 'ValidExtensions', source: 'Path|None' = None,
init_file: 'Path|None' = None,
children: Sequence[SuiteStructure] = ()):
super().__init__(extensions, source, init_file, children)
def _get_source_file(self) -> 'Path|None':
return self.init_file
@property
def is_multi_source(self) -> bool:
return self.source is None
[docs] def add(self, child: 'SuiteStructure'):
self.children.append(child)
[docs] def visit(self, visitor: 'SuiteStructureVisitor'):
visitor.visit_directory(self)
[docs]class SuiteStructureVisitor:
[docs] def visit_file(self, structure: SuiteFile):
pass
[docs] def visit_directory(self, structure: SuiteDirectory):
self.start_directory(structure)
for child in structure.children:
child.visit(self)
self.end_directory(structure)
[docs] def start_directory(self, structure: SuiteDirectory):
pass
[docs] def end_directory(self, structure: SuiteDirectory):
pass
[docs]class SuiteStructureBuilder:
ignored_prefixes = ('_', '.')
ignored_dirs = ('CVS',)
def __init__(self, extensions: Sequence[str] = ('.robot', '.rbt', '.robot.rst'),
included_files: Sequence[str] = ()):
self.extensions = ValidExtensions(extensions, included_files)
self.included_files = IncludedFiles(included_files)
[docs] def build(self, *paths: Path) -> SuiteStructure:
if len(paths) == 1:
return self._build(paths[0])
return self._build_multi_source(paths)
def _build(self, path: Path) -> SuiteStructure:
if path.is_file():
return SuiteFile(self.extensions, path)
return self._build_directory(path)
def _build_directory(self, path: Path) -> SuiteStructure:
structure = SuiteDirectory(self.extensions, path)
for item in self._list_dir(path):
if self._is_init_file(item):
if structure.init_file:
# TODO: This error should fail parsing for good.
LOGGER.error(f"Ignoring second test suite init file '{item}'.")
else:
structure.init_file = item
elif self._is_included(item):
structure.add(self._build(item))
else:
LOGGER.info(f"Ignoring file or directory '{item}'.")
return structure
def _list_dir(self, path: Path) -> 'list[Path]':
try:
return sorted(path.iterdir(), key=lambda p: p.name.lower())
except OSError:
raise DataError(f"Reading directory '{path}' failed: {get_error_message()}")
def _is_init_file(self, path: Path) -> bool:
return (path.stem.lower() == '__init__'
and self.extensions.match(path)
and path.is_file())
def _is_included(self, path: Path) -> bool:
if path.name.startswith(self.ignored_prefixes):
return False
if path.is_dir():
return path.name not in self.ignored_dirs
if not path.is_file():
return False
if not self.extensions.match(path):
return False
return self.included_files.match(path)
def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure:
structure = SuiteDirectory(self.extensions)
for path in paths:
if self._is_init_file(path):
if structure.init_file:
raise DataError("Multiple init files not allowed.")
structure.init_file = path
else:
structure.add(self._build(path))
return structure
[docs]class ValidExtensions:
def __init__(self, extensions: Sequence[str],
included_files: Sequence[str] = ()):
self.extensions = {ext.lstrip('.').lower() for ext in extensions}
for pattern in included_files:
ext = os.path.splitext(pattern)[1]
if ext:
self.extensions.add(ext.lstrip('.').lower())
[docs] def match(self, path: Path) -> bool:
for ext in self._extensions_from(path):
if ext in self.extensions:
return True
return False
[docs] def get_extension(self, path: Path) -> str:
for ext in self._extensions_from(path):
if ext in self.extensions:
return ext
return path.suffix.lower()[1:]
def _extensions_from(self, path: Path) -> Iterator[str]:
suffixes = path.suffixes
while suffixes:
yield ''.join(suffixes).lower()[1:]
suffixes.pop(0)
[docs]class IncludedFiles:
def __init__(self, patterns: 'Sequence[str|Path]' = ()):
self.patterns = [self._compile(i) for i in patterns]
def _compile(self, pattern: 'str|Path') -> 're.Pattern':
pattern = self._dir_to_recursive(self._path_to_abs(self._normalize(pattern)))
# Handle recursive glob patterns.
parts = [self._translate(p) for p in pattern.split('**')]
return re.compile('.*'.join(parts), re.IGNORECASE)
def _normalize(self, pattern: 'str|Path') -> str:
if isinstance(pattern, Path):
pattern = str(pattern)
return os.path.normpath(pattern).replace('\\', '/')
def _path_to_abs(self, pattern: str) -> str:
if '/' in pattern or '.' not in pattern or os.path.exists(pattern):
pattern = os.path.abspath(pattern).replace('\\', '/')
return pattern
def _dir_to_recursive(self, pattern: str) -> str:
if '.' not in os.path.basename(pattern) or os.path.isdir(pattern):
pattern += '/**'
return pattern
def _translate(self, glob_pattern: str) -> str:
# `fnmatch.translate` returns pattern in format `(?s:<pattern>)\Z` but we want
# only the `<pattern>` part. This is a bit risky because the format may change
# in future Python versions, but we have tests and ought to notice that.
re_pattern = fnmatch.translate(glob_pattern)[4:-3]
# Unlike `fnmatch`, we want `*` to match only a single path segment.
return re_pattern.replace('.*', '[^/]*')
[docs] def match(self, path: Path) -> bool:
if not self.patterns:
return True
return self._match(path.name) or self._match(str(path))
def _match(self, path: str) -> bool:
path = self._normalize(path)
return any(p.fullmatch(path) for p in self.patterns)