From 66a94bc993a27d8fe5cec13813e524b1099509c6 Mon Sep 17 00:00:00 2001 From: Daniel Biehl Date: Tue, 26 Nov 2024 20:06:20 +0100 Subject: [PATCH] fix(config): corrected handling of relative items in python path and other variables if current folder is different then root folder of the project fixes #359 --- .../src/robotcode/core/utils/contextlib.py | 44 ++++++ .../plugin/src/robotcode/plugin/__init__.py | 17 +++ .../src/robotcode/robot/config/loader.py | 3 +- .../robotcode/runner/cli/discover/discover.py | 135 +++++++++--------- .../runner/src/robotcode/runner/cli/libdoc.py | 59 ++++---- .../runner/src/robotcode/runner/cli/rebot.py | 85 ++++++----- .../runner/src/robotcode/runner/cli/robot.py | 67 +++++---- .../src/robotcode/runner/cli/testdoc.py | 58 ++++---- 8 files changed, 260 insertions(+), 208 deletions(-) create mode 100644 packages/core/src/robotcode/core/utils/contextlib.py diff --git a/packages/core/src/robotcode/core/utils/contextlib.py b/packages/core/src/robotcode/core/utils/contextlib.py new file mode 100644 index 00000000..15d83f29 --- /dev/null +++ b/packages/core/src/robotcode/core/utils/contextlib.py @@ -0,0 +1,44 @@ +import os +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Any, Callable, List, Literal, Optional, Union + +from .path import same_file + + +class ChDir(AbstractContextManager[Any]): + def __init__( + self, path: "Union[str, os.PathLike[str], None]", verbose_callback: Optional[Callable[[str], None]] = None + ) -> None: + self.path = path + self._old_cwd: List[Optional[Path]] = [] + self._verbose_callback = verbose_callback + + def __enter__(self) -> Optional[Path]: + result = Path.cwd() + + if self.path is None or (self._old_cwd and same_file(self.path, Path.cwd())): + self._old_cwd.append(None) + else: + self._old_cwd.append(result) + + if self.path: + if self._verbose_callback: + self._verbose_callback(f"Changing directory to {self.path}") + + os.chdir(self.path) + + return result + + def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> Literal[False]: + old_path = self._old_cwd.pop() + if old_path is not None: + if self._verbose_callback: + self._verbose_callback(f"Changing directory back to {old_path}") + + os.chdir(old_path) + + return False + + +chdir = ChDir diff --git a/packages/plugin/src/robotcode/plugin/__init__.py b/packages/plugin/src/robotcode/plugin/__init__.py index ca0dcd02..ad398603 100644 --- a/packages/plugin/src/robotcode/plugin/__init__.py +++ b/packages/plugin/src/robotcode/plugin/__init__.py @@ -1,5 +1,6 @@ import dataclasses import sys +from contextlib import contextmanager from dataclasses import dataclass from enum import Enum, unique from pathlib import Path @@ -9,6 +10,8 @@ AnyStr, Callable, Iterable, + Iterator, + Literal, Optional, Sequence, TypeVar, @@ -20,6 +23,7 @@ import pluggy import tomli_w +from robotcode.core.utils.contextlib import chdir from robotcode.core.utils.dataclasses import as_dict, as_json __all__ = [ @@ -292,5 +296,18 @@ def exit(self, code: int = 0) -> None: self.verbose(f"Exit with code {code}") sys.exit(code) + @contextmanager + def chdir(self, path: Union[str, Path, None]) -> Iterator[Optional[Path]]: + with chdir(path, self.verbose) as result: + yield result + + @contextmanager + def save_syspath(self) -> Iterator[Literal[None]]: + self._syspath = sys.path[:] + try: + yield None + finally: + sys.path = self._syspath + pass_application = click.make_pass_decorator(Application, ensure=True) diff --git a/packages/robot/src/robotcode/robot/config/loader.py b/packages/robot/src/robotcode/robot/config/loader.py index 830438d6..a81bda93 100644 --- a/packages/robot/src/robotcode/robot/config/loader.py +++ b/packages/robot/src/robotcode/robot/config/loader.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar, Union from robotcode.core.utils.dataclasses import from_dict +from robotcode.core.utils.path import normalized_path if sys.version_info >= (3, 11): import tomllib @@ -224,7 +225,7 @@ def find_project_root( if not sources: sources = (str(Path.cwd().absolute()),) - path_srcs = [Path(Path.cwd(), src).absolute() for src in sources] + path_srcs = [normalized_path(Path(Path.cwd(), src).absolute()) for src in sources] src_parents = [list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs] diff --git a/packages/runner/src/robotcode/runner/cli/discover/discover.py b/packages/runner/src/robotcode/runner/cli/discover/discover.py index b63adad6..9a40135b 100644 --- a/packages/runner/src/robotcode/runner/cli/discover/discover.py +++ b/packages/runner/src/robotcode/runner/cli/discover/discover.py @@ -476,81 +476,84 @@ def handle_options( ) -> Tuple[TestSuite, Collector, Optional[Dict[str, List[Diagnostic]]]]: root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) - diagnostics_logger = DiagnosticsLogger() - try: - _patch() + with app.chdir(root_folder) as orig_folder: - options, arguments = RobotFrameworkEx( - app, - ( - [*(app.config.default_paths if app.config.default_paths else ())] - if profile.paths is None - else profile.paths if isinstance(profile.paths, list) else [profile.paths] - ), - app.config.dry, - root_folder, - by_longname, - exclude_by_longname, - ).parse_arguments((*cmd_options, "--runemptysuite", *robot_options_and_args)) + diagnostics_logger = DiagnosticsLogger() + try: + _patch() - settings = RobotSettings(options) + options, arguments = RobotFrameworkEx( + app, + ( + [*(app.config.default_paths if app.config.default_paths else ())] + if profile.paths is None + else profile.paths if isinstance(profile.paths, list) else [profile.paths] + ), + app.config.dry, + root_folder, + orig_folder, + by_longname, + exclude_by_longname, + ).parse_arguments((*cmd_options, "--runemptysuite", *robot_options_and_args)) - if app.show_diagnostics: - LOGGER.register_console_logger(**settings.console_output_config) - else: - LOGGER.unregister_console_logger() - - LOGGER.register_logger(diagnostics_logger) - - if get_robot_version() >= (5, 0): - if settings.pythonpath: - sys.path = settings.pythonpath + sys.path - - if get_robot_version() > (6, 1): - builder = TestSuiteBuilder( - included_extensions=settings.extension, - included_files=settings.parse_include, - custom_parsers=settings.parsers, - rpa=settings.rpa, - lang=settings.languages, - allow_empty_suite=settings.run_empty_suite, - ) - elif get_robot_version() >= (6, 0): - builder = TestSuiteBuilder( - settings["SuiteNames"], - included_extensions=settings.extension, - rpa=settings.rpa, - lang=settings.languages, - allow_empty_suite=settings.run_empty_suite, - ) - else: - builder = TestSuiteBuilder( - settings["SuiteNames"], - included_extensions=settings.extension, - rpa=settings.rpa, - allow_empty_suite=settings.run_empty_suite, - ) + settings = RobotSettings(options) + + if app.show_diagnostics: + LOGGER.register_console_logger(**settings.console_output_config) + else: + LOGGER.unregister_console_logger() + + LOGGER.register_logger(diagnostics_logger) + + if get_robot_version() >= (5, 0): + if settings.pythonpath: + sys.path = settings.pythonpath + sys.path + + if get_robot_version() > (6, 1): + builder = TestSuiteBuilder( + included_extensions=settings.extension, + included_files=settings.parse_include, + custom_parsers=settings.parsers, + rpa=settings.rpa, + lang=settings.languages, + allow_empty_suite=settings.run_empty_suite, + ) + elif get_robot_version() >= (6, 0): + builder = TestSuiteBuilder( + settings["SuiteNames"], + included_extensions=settings.extension, + rpa=settings.rpa, + lang=settings.languages, + allow_empty_suite=settings.run_empty_suite, + ) + else: + builder = TestSuiteBuilder( + settings["SuiteNames"], + included_extensions=settings.extension, + rpa=settings.rpa, + allow_empty_suite=settings.run_empty_suite, + ) - suite = builder.build(*arguments) - settings.rpa = suite.rpa - if settings.pre_run_modifiers: - suite.visit(ModelModifier(settings.pre_run_modifiers, settings.run_empty_suite, LOGGER)) - suite.configure(**settings.suite_config) + suite = builder.build(*arguments) + settings.rpa = suite.rpa + if settings.pre_run_modifiers: + suite.visit(ModelModifier(settings.pre_run_modifiers, settings.run_empty_suite, LOGGER)) + suite.configure(**settings.suite_config) - collector = Collector() + collector = Collector() - suite.visit(collector) + suite.visit(collector) - return suite, collector, build_diagnostics(diagnostics_logger.messages) + return suite, collector, build_diagnostics(diagnostics_logger.messages) - except Information as err: - app.echo(str(err)) - app.exit(INFO_PRINTED) - except DataError as err: - app.error(str(err)) - app.exit(DATA_ERROR) + except Information as err: + app.echo(str(err)) + app.exit(INFO_PRINTED) + except DataError as err: + app.error(str(err)) + app.exit(DATA_ERROR) - raise UnknownError("Unexpected error happened.") + raise UnknownError("Unexpected error happened.") def print_statistics(app: Application, suite: TestSuite, collector: Collector) -> None: diff --git a/packages/runner/src/robotcode/runner/cli/libdoc.py b/packages/runner/src/robotcode/runner/cli/libdoc.py index cfc4e01c..f2a718de 100644 --- a/packages/runner/src/robotcode/runner/cli/libdoc.py +++ b/packages/runner/src/robotcode/runner/cli/libdoc.py @@ -1,6 +1,4 @@ -import os -from pathlib import Path -from typing import Any, Optional, Tuple, cast +from typing import Any, Tuple, cast import click from robot.errors import DataError, Information @@ -16,10 +14,9 @@ class LibDocEx(LibDoc): - def __init__(self, dry: bool, root_folder: Optional[Path]) -> None: + def __init__(self, dry: bool) -> None: super().__init__() self.dry = dry - self.root_folder = root_folder def parse_arguments(self, cli_args: Any) -> Any: options, arguments = super().parse_arguments(cli_args) @@ -35,9 +32,6 @@ def parse_arguments(self, cli_args: Any) -> Any: return options, arguments def main(self, arguments: Any, **options: Any) -> Any: - if self.root_folder is not None: - os.chdir(self.root_folder) - return super().main(arguments, **options) @@ -62,7 +56,8 @@ def libdoc(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: robot_arguments = None try: - _, robot_arguments = LibDoc().parse_arguments(robot_options_and_args) + with app.save_syspath(): + _, robot_arguments = LibDoc().parse_arguments(robot_options_and_args) except (DataError, Information): pass @@ -73,32 +68,34 @@ def libdoc(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: no_vcs=app.config.no_vcs, verbose_callback=app.verbose, ) - try: - profile = ( - load_robot_config_from_path(*config_files, verbose_callback=app.verbose) - .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) - .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) - ) - except (TypeError, ValueError) as e: - raise click.ClickException(str(e)) from e + with app.chdir(root_folder): + try: + profile = ( + load_robot_config_from_path(*config_files, verbose_callback=app.verbose) + .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) + .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) + ) - libdoc_options = profile.libdoc - if libdoc_options is None: - libdoc_options = LibDocProfile() + except (TypeError, ValueError) as e: + raise click.ClickException(str(e)) from e - libdoc_options.add_options(profile) + libdoc_options = profile.libdoc + if libdoc_options is None: + libdoc_options = LibDocProfile() - options = libdoc_options.build_command_line() + libdoc_options.add_options(profile) - app.verbose( - lambda: "Executing libdoc robot with the following options:\n " - + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) - ) + options = libdoc_options.build_command_line() - app.exit( - cast( - int, - LibDocEx(app.config.dry, root_folder).execute_cli((*options, *robot_options_and_args), exit=False), + app.verbose( + lambda: "Executing libdoc robot with the following options:\n " + + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) + ) + + app.exit( + cast( + int, + LibDocEx(app.config.dry).execute_cli((*options, *robot_options_and_args), exit=False), + ) ) - ) diff --git a/packages/runner/src/robotcode/runner/cli/rebot.py b/packages/runner/src/robotcode/runner/cli/rebot.py index dda20f68..fd79c9d5 100644 --- a/packages/runner/src/robotcode/runner/cli/rebot.py +++ b/packages/runner/src/robotcode/runner/cli/rebot.py @@ -1,6 +1,5 @@ import os -from pathlib import Path -from typing import Any, Optional, Tuple, cast +from typing import Any, Tuple, cast import click from robot.errors import DataError, Information @@ -17,10 +16,9 @@ class RebotEx(Rebot): - def __init__(self, dry: bool, root_folder: Optional[Path]) -> None: + def __init__(self, dry: bool) -> None: super().__init__() self.dry = dry - self.root_folder = root_folder def parse_arguments(self, cli_args: Any) -> Any: options, arguments = super().parse_arguments(cli_args) @@ -36,9 +34,6 @@ def parse_arguments(self, cli_args: Any) -> Any: return options, arguments def main(self, datasources: Any, **options: Any) -> Any: - if self.root_folder is not None: - os.chdir(self.root_folder) - return super().main(datasources, **options) @@ -63,7 +58,8 @@ def rebot(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: robot_arguments = None try: - _, robot_arguments = Rebot().parse_arguments(robot_options_and_args) + with app.save_syspath(): + _, robot_arguments = Rebot().parse_arguments(robot_options_and_args) except (DataError, Information): pass @@ -75,46 +71,47 @@ def rebot(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: verbose_callback=app.verbose, ) - try: - profile = ( - load_robot_config_from_path(*config_files, verbose_callback=app.verbose) - .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) - .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) - ) + with app.chdir(root_folder): + try: + profile = ( + load_robot_config_from_path(*config_files, verbose_callback=app.verbose) + .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) + .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) + ) - except (TypeError, ValueError) as e: - raise click.ClickException(str(e)) from e + except (TypeError, ValueError) as e: + raise click.ClickException(str(e)) from e - rebot_options = profile.rebot - if rebot_options is None: - rebot_options = RebotProfile() + rebot_options = profile.rebot + if rebot_options is None: + rebot_options = RebotProfile() - rebot_options.add_options(profile) + rebot_options.add_options(profile) - try: - options = rebot_options.build_command_line() - except (TypeError, ValueError) as e: - raise click.ClickException(str(e)) from e + try: + options = rebot_options.build_command_line() + except (TypeError, ValueError) as e: + raise click.ClickException(str(e)) from e - app.verbose( - lambda: "Executing rebot with the following options:\n " - + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) - ) + app.verbose( + lambda: "Executing rebot with the following options:\n " + + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) + ) - console_links_args = [] - if get_robot_version() >= (7, 1) and os.getenv("ROBOTCODE_DISABLE_ANSI_LINKS", "").lower() in [ - "on", - "1", - "yes", - "true", - ]: - console_links_args = ["--consolelinks", "off"] - - app.exit( - cast( - int, - RebotEx(app.config.dry, root_folder).execute_cli( - (*options, *console_links_args, *robot_options_and_args), exit=False - ), + console_links_args = [] + if get_robot_version() >= (7, 1) and os.getenv("ROBOTCODE_DISABLE_ANSI_LINKS", "").lower() in [ + "on", + "1", + "yes", + "true", + ]: + console_links_args = ["--consolelinks", "off"] + + app.exit( + cast( + int, + RebotEx(app.config.dry).execute_cli( + (*options, *console_links_args, *robot_options_and_args), exit=False + ), + ) ) - ) diff --git a/packages/runner/src/robotcode/runner/cli/robot.py b/packages/runner/src/robotcode/runner/cli/robot.py index affad3cd..29309a2e 100644 --- a/packages/runner/src/robotcode/runner/cli/robot.py +++ b/packages/runner/src/robotcode/runner/cli/robot.py @@ -1,5 +1,4 @@ import os -import sys import weakref from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast @@ -156,6 +155,7 @@ def __init__( paths: List[str], dry: bool, root_folder: Optional[Path], + orig_folder: Optional[Path], by_longname: Tuple[str, ...] = (), exclude_by_longname: Tuple[str, ...] = (), ) -> None: @@ -164,15 +164,11 @@ def __init__( self.paths = paths self.dry = dry self.root_folder = root_folder - self._orig_cwd = Path.cwd() + self._orig_cwd = Path.cwd() if orig_folder is None else orig_folder self.by_longname = by_longname self.exclude_by_longname = exclude_by_longname def parse_arguments(self, cli_args: Any) -> Any: - if self.root_folder is not None and Path.cwd() != self.root_folder: - self.app.verbose(f"Changing working directory from {self._orig_cwd} to {self.root_folder}") - os.chdir(self.root_folder) - try: options, arguments = super().parse_arguments(cli_args) if self.root_folder is not None: @@ -253,13 +249,12 @@ def handle_robot_options( _patch() robot_arguments: Optional[List[Union[str, Path]]] = None - old_sys_path = sys.path.copy() + try: - _, robot_arguments = RobotFramework().parse_arguments(robot_options_and_args) + with app.save_syspath(): + _, robot_arguments = RobotFramework().parse_arguments(robot_options_and_args) except (DataError, Information): pass - finally: - sys.path = old_sys_path config_files, root_folder, _ = get_config_files( robot_arguments, @@ -322,29 +317,31 @@ def robot( root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) - console_links_args = [] - if get_robot_version() >= (7, 1) and os.getenv("ROBOTCODE_DISABLE_ANSI_LINKS", "").lower() in [ - "on", - "1", - "yes", - "true", - ]: - console_links_args = ["--consolelinks", "off"] - - app.exit( - cast( - int, - RobotFrameworkEx( - app, - ( - [*(app.config.default_paths if app.config.default_paths else ())] - if profile.paths is None - else profile.paths if isinstance(profile.paths, list) else [profile.paths] - ), - app.config.dry, - root_folder, - by_longname, - exclude_by_longname, - ).execute_cli((*cmd_options, *console_links_args, *robot_options_and_args), exit=False), + with app.chdir(root_folder) as orig_folder: + console_links_args = [] + if get_robot_version() >= (7, 1) and os.getenv("ROBOTCODE_DISABLE_ANSI_LINKS", "").lower() in [ + "on", + "1", + "yes", + "true", + ]: + console_links_args = ["--consolelinks", "off"] + + app.exit( + cast( + int, + RobotFrameworkEx( + app, + ( + [*(app.config.default_paths if app.config.default_paths else ())] + if profile.paths is None + else profile.paths if isinstance(profile.paths, list) else [profile.paths] + ), + app.config.dry, + root_folder, + orig_folder, + by_longname, + exclude_by_longname, + ).execute_cli((*cmd_options, *console_links_args, *robot_options_and_args), exit=False), + ) ) - ) diff --git a/packages/runner/src/robotcode/runner/cli/testdoc.py b/packages/runner/src/robotcode/runner/cli/testdoc.py index 5a1aad0d..1f4c8e7f 100644 --- a/packages/runner/src/robotcode/runner/cli/testdoc.py +++ b/packages/runner/src/robotcode/runner/cli/testdoc.py @@ -1,6 +1,4 @@ -import os -from pathlib import Path -from typing import Any, Optional, Tuple, cast +from typing import Any, Tuple, cast import click from robot.errors import DataError, Information @@ -16,10 +14,9 @@ class TestDocEx(TestDoc): - def __init__(self, dry: bool, root_folder: Optional[Path]) -> None: + def __init__(self, dry: bool) -> None: super().__init__() self.dry = dry - self.root_folder = root_folder def parse_arguments(self, cli_args: Any) -> Any: options, arguments = super().parse_arguments(cli_args) @@ -35,9 +32,6 @@ def parse_arguments(self, cli_args: Any) -> Any: return options, arguments def main(self, arguments: Any, **options: Any) -> Any: - if self.root_folder is not None: - os.chdir(self.root_folder) - return super().main(arguments, **options) @@ -62,7 +56,8 @@ def testdoc(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: robot_arguments = None try: - _, robot_arguments = TestDoc().parse_arguments(robot_options_and_args) + with app.save_syspath(): + _, robot_arguments = TestDoc().parse_arguments(robot_options_and_args) except (DataError, Information): pass @@ -74,32 +69,33 @@ def testdoc(app: Application, robot_options_and_args: Tuple[str, ...]) -> None: verbose_callback=app.verbose, ) - try: - profile = ( - load_robot_config_from_path(*config_files, verbose_callback=app.verbose) - .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) - .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) - ) + with app.chdir(root_folder): + try: + profile = ( + load_robot_config_from_path(*config_files, verbose_callback=app.verbose) + .combine_profiles(*(app.config.profiles or []), verbose_callback=app.verbose, error_callback=app.error) + .evaluated_with_env(verbose_callback=app.verbose, error_callback=app.error) + ) - except (TypeError, ValueError) as e: - raise click.ClickException(str(e)) from e + except (TypeError, ValueError) as e: + raise click.ClickException(str(e)) from e - testdoc_options = profile.testdoc - if testdoc_options is None: - testdoc_options = TestDocProfile() + testdoc_options = profile.testdoc + if testdoc_options is None: + testdoc_options = TestDocProfile() - testdoc_options.add_options(profile) + testdoc_options.add_options(profile) - options = testdoc_options.build_command_line() + options = testdoc_options.build_command_line() - app.verbose( - lambda: "Executing testdoc with the following options:\n " - + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) - ) + app.verbose( + lambda: "Executing testdoc with the following options:\n " + + " ".join(f'"{o}"' for o in (options + list(robot_options_and_args))) + ) - app.exit( - cast( - int, - TestDocEx(app.config.dry, root_folder).execute_cli((*options, *robot_options_and_args), exit=False), + app.exit( + cast( + int, + TestDocEx(app.config.dry).execute_cli((*options, *robot_options_and_args), exit=False), + ) ) - )