Skip to content

Commit

Permalink
Resolve relative paths in configuration (#668)
Browse files Browse the repository at this point in the history
* Remove temporary robocop.log from git

* Resolve relative path in toml files

* Apply suggestions from code review

Co-authored-by: Mateusz Nojek <matnojek@gmail.com>

* Modify information about argument file option

Co-authored-by: Mateusz Nojek <matnojek@gmail.com>
  • Loading branch information
bhirsz and mnojek committed Jul 31, 2022
1 parent c3fd9c1 commit 87bf1e0
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 106 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,7 @@ venv.bak/
.vscode/*

# Sarif report file
.sarif.json
.sarif.json

# Robocop log produced by some unit tests
robocop.log
118 changes: 81 additions & 37 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,43 +22,87 @@ source code. More in :ref:`including-rules`.

Loading configuration from file
-------------------------------
You can load arguments for Robocop from file with::

--argumentfile jenkins_args.txt

If no arguments are provided to Robocop it will try to find ``.robocop`` file and load it from there.
It will start looking from current directory and go up until it founds it or '.git' file is found. ``.robocop`` file
supports the same syntax as given from cli::

--include rulename
# inline comment
--reports all

If there is no ``.robocop`` file present it will try to load ``pyproject.toml`` file (if there is toml module installed).
Robocop use [tool.robocop] section. Options have the same names as CLI arguments. Example configuration file::

[tool.robocop]
paths = [
"tests\\atest\\rules\\bad-indent",
"tests\\atest\\rules\\duplicated-library"
]
include = ['W0504', '*doc*']
exclude = ["0203"]
reports = [
"rules_by_id",
"scan_timer"
]
ignore = ["ignore_me.robot"]
ext-rules = ["path_to_external\\dir"]
filetypes = [".txt", ".tsv"]
threshold = "E"
format = "{source}:{line}:{col} [{severity}] {rule_id} {desc} (name)"
output = "robocop.log"
configure = [
"line-too-long:line_length:150",
"0201:severity:E"
]
no_recursive = true
.. dropdown:: How to load configuratiom from file

You can load arguments for Robocop from file with ``--argumentfile / -A`` option and path to argument file::

robocop --argumentfile argument_file.txt
robocop -A path/to/file.txt

If no arguments are provided to Robocop it will try to find ``.robocop`` file and load it from there.
It will start looking from current directory and go up until it founds it or '.git' file is found. ``.robocop`` file
supports the same syntax as given from CLI::

--include rulename
# inline comment
--reports all

If there is no ``.robocop`` file present it will try to load ``pyproject.toml`` file (if there is toml module installed).
Robocop use [tool.robocop] section. Options have the same names as CLI arguments.

.. dropdown:: Example pyproject.toml configuration file

::

[tool.robocop]
paths = [
"tests\\atest\\rules\\bad-indent",
"tests\\atest\\rules\\duplicated-library"
]
include = ['W0504', '*doc*']
exclude = ["0203"]
reports = [
"rules_by_id",
"scan_timer"
]
ignore = ["ignore_me.robot"]
ext-rules = ["path_to_external\\dir"]
filetypes = [".txt", ".tsv"]
threshold = "E"
format = "{source}:{line}:{col} [{severity}] {rule_id} {desc} (name)"
output = "robocop.log"
configure = [
"line-too-long:line_length:150",
"0201:severity:E"
]
no_recursive = true

.. dropdown:: Relative paths in the configuration

Configuration files can contain both relative and absolute paths when configuring paths,
external rules or log output path.

Hovewer extra care is needed when using relative paths because the configuration is automatically loaded.

Given following project structure:

root/
::

nested/
external.py
pyproject.toml
.robocop

and following contents:

``pyproject.toml``::

ext-rules = ["external.py"]

``.robocop``::

--ext-rules external.py

If run Robocop from ``/nested`` directory, Robocop will automatically find and load configuration file from the parent directory.
If your configuration file contains relative paths, the resolved paths will be different depending on the configuration type:

- ``pyproject.toml`` will resolve path using configuration file as root. External rules path will point to ``root/external.py``
- ``.robocop`` will resolve path using working directory of Robocop. External rules path will point to ``root/nested/external.py``

This may cause issues in the execution - you can solve it by either using absolute paths or
using ``pyproject.toml`` file instead of ``.robocop``.


Listing available rules
-----------------------
Expand Down
160 changes: 94 additions & 66 deletions robocop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,41 +134,6 @@ def translate_patterns(self):
self.include = self.filter_patterns_from_names(self.include, self.include_patterns)
self.exclude = self.filter_patterns_from_names(self.exclude, self.exclude_patterns)

def preparse(self, args):
args = sys.argv[1:] if args is None else args
parsed_args = []
args = (arg for arg in args)
for arg in args:
if arg in ("-A", "--argumentfile"):
try:
argfile = next(args)
except StopIteration:
raise ArgumentFileNotFoundError("") from None
parsed_args += self.load_args_from_file(argfile)
else:
parsed_args.append(arg)
return parsed_args

def load_args_from_file(self, argfile):
try:
with FileReader(argfile) as arg_f:
args = []
for line in arg_f.readlines():
if line.strip().startswith("#"):
continue
for arg in line.split(" ", 1):
arg = arg.strip()
if not arg:
continue
args.append(arg)
if "-A" in args or "--argumentfile" in args:
raise NestedArgumentFileError(argfile)
if args:
self.config_from = argfile
return args
except FileNotFoundError:
raise ArgumentFileNotFoundError(argfile) from None

def _create_parser(self):
parser = CustomArgParser(
prog="robocop",
Expand Down Expand Up @@ -344,47 +309,108 @@ def _create_parser(self):
def parse_opts(self, args=None, from_cli: bool = True):
if self.root is None:
self.root = find_project_root(self.paths)
default_args = self.load_default_config_file()
if default_args is None:
self.load_pyproject_file()
else:
default_args = self.preparse(default_args)
self.parse_args_to_config(default_args)
self.load_default_config_file()

args = self.preparse(args) if from_cli else None
if args:
args = self.parse_args_to_config(args)
if from_cli:
args = self.parse_args(args)
else:
args = None

self.remove_severity()
self.translate_patterns()

if self.verbose:
if self.config_from:
print(f"Loaded configuration from {self.config_from}")
else:
print("No config file found or configuration is empty. Using default configuration")
self.print_config_source()

return args

def print_config_source(self):
# We can only print after reading all configs, since self.verbose is unknown before we read it from config
# TODO self.config_from can be multiple configs (if it's from argumentfile)
if not self.verbose:
return
if self.config_from:
print(f"Loaded configuration from {self.config_from}")
else:
print("No config file found or configuration is empty. Using default configuration")

def load_default_config_file(self):
if not self.load_robocop_file():
self.load_pyproject_file()

def load_robocop_file(self):
"""Returns True if .robocop exists"""
robocop_path = find_file_in_project_root(".robocop", self.root)
if robocop_path.is_file():
return self.load_args_from_file(robocop_path)
return None
if not robocop_path.is_file():
return False
args = self.load_args_from_file(robocop_path)
self.parse_args(args)
return True

def load_pyproject_file(self):
pyproject_path = find_file_in_project_root("pyproject.toml", self.root)
if not pyproject_path.is_file():
return
config_dir = pyproject_path.parent
try:
with Path(pyproject_path).open("rb") as fp:
config = tomli.load(fp)
except tomli.TOMLDecodeError as err:
raise InvalidArgumentError(f"Failed to decode {str(pyproject_path)}: {err}") from None
config = config.get("tool", {}).get("robocop", {})
if self.parse_toml_to_config(config):
if self.parse_toml_to_config(config, config_dir):
self.config_from = pyproject_path

def parse_args_to_config(self, args):
if args is None:
return None

args = self.parser.parse_args(args)
for key, value in dict(**vars(args)).items():
if key in self.__dict__:
self.__dict__[key] = value

return args

def parse_args(self, args):
args = self.preparse(args)
if args:
args = self.parse_args_to_config(args)
return args

def preparse(self, args):
args = sys.argv[1:] if args is None else args
parsed_args = []
args = (arg for arg in args)
for arg in args:
if arg in ("-A", "--argumentfile"):
try:
argfile = next(args)
except StopIteration:
raise ArgumentFileNotFoundError("") from None
parsed_args += self.load_args_from_file(argfile)
else:
parsed_args.append(arg)
return parsed_args

def load_args_from_file(self, argfile):
try:
with FileReader(argfile) as arg_f:
args = []
for line in arg_f.readlines():
if line.strip().startswith("#"):
continue
for arg in line.split(" ", 1):
arg = arg.strip()
if not arg:
continue
args.append(arg)
if "-A" in args or "--argumentfile" in args:
raise NestedArgumentFileError(argfile)
if args:
self.config_from = argfile
return args
except FileNotFoundError:
raise ArgumentFileNotFoundError(argfile) from None

@staticmethod
def replace_in_set(container: Set, old_key: str, new_key: str):
if old_key not in container:
Expand Down Expand Up @@ -450,14 +476,27 @@ def replace_severity_values(rule_name: str):
rule_name = rule_name.replace(char, "")
return rule_name

def parse_toml_to_config(self, toml_data: Dict):
def resolve_relative(self, orig_path, config_dir: Path):
path = Path(orig_path)
if path.is_absolute():
return orig_path
return str(config_dir / path)

def parse_toml_to_config(self, toml_data: Dict, config_dir: Path):
if not toml_data:
return False
resolve_relative = {"paths", "ext_rules", "output"}
assign_type = {"paths", "format"}
set_type = {"include", "exclude", "ignore", "ext_rules"}
append_type = {"configure", "reports"}
toml_data = {key.replace("-", "_"): value for key, value in toml_data.items()}
for key, value in toml_data.items():
if key in resolve_relative:
if isinstance(value, list):
for index, val in enumerate(value):
value[index] = self.resolve_relative(val, config_dir)
else:
value = self.resolve_relative(value, config_dir)
if key in assign_type:
self.__dict__[key] = value
elif key in set_type:
Expand All @@ -478,14 +517,3 @@ def parse_toml_to_config(self, toml_data: Dict):
else:
raise InvalidArgumentError(f"Option '{key}' is not supported in pyproject.toml configuration file.")
return True

def parse_args_to_config(self, args):
if args is None:
return None

args = self.parser.parse_args(args)
for key, value in dict(**vars(args)).items():
if key in self.__dict__:
self.__dict__[key] = value

return args
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tool.robocop]
ext-rules = ["test.py"]
output = "robocop.log"
Empty file.
2 changes: 2 additions & 0 deletions tests/test_data/relative_path_in_config_robocop/.robocop
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--ext-rules test.py
--output robocop.log
Empty file.
Empty file.

0 comments on commit 87bf1e0

Please sign in to comment.