Skip to content

Commit

Permalink
Merge pull request #45 from coveo/feature/custom-runner-duplicates
Browse files Browse the repository at this point in the history
add an executable property to the custom runners
  • Loading branch information
jonapich authored Apr 4, 2024
2 parents 671a715 + da5fbf4 commit b5f7986
Show file tree
Hide file tree
Showing 14 changed files with 71 additions and 25 deletions.
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,19 @@ autofix-args = [".", "--profile black"]
[tool.stew.ci.custom-runners.pytest]
check-args = ["--tb=long", "--junitxml=.ci/pytest-results.xml"]
# using `executable`, you can create multiple custom runners with the same executable:
[tool.stew.ci.custom-runners.ruff-check]
executable = "ruff"
working-directory = "project"
check-args = ["check", "."]
autofix-args = [ "check", "--fix", "."]
[tool.stew.ci.custom-runners.ruff-format]
executable = "ruff"
working-directory = "project"
check-args = ["format", "--check", "."]
autofix-args = ["format", "."]
```

When a builtin runner such as pytest is redefined as a custom runner, you must provide all the arguments.
Expand All @@ -338,15 +351,16 @@ In this case, not passing `--junitxml` would mean that we lose the report that u

### Options

The following options are supported for custom runners:

- name: You can specify the module name if it differs from the name of the tool.
- Important: Runners are called through `python -m <name>`, not through the shell!
- executable: You can specify the executable name if it's different from the tool's name.
- Runners are called through `python -m <executable>` first to see if it's installed in the virtual environment, else through the shell.
- Using `executable`, you can create multiple custom runners with the same executable (e.g.: `ruff check` vs `ruff format`)
- check-args: The arguments to invoke the check.
- autofix-args: The arguments to invoke the autofix. Provide the empty string "" in order to run without arguments.
- check-failed-exit-codes: A list of ints denoting the exit codes to consider "failed" (anything else will be "error"). 0 is always a success. default is `[1]`.
- create-generic-report: Whether to create a generic pass/fail JUnit report for this check.
- working-directory: The default is "project" which corresponds to the project's `pyproject.toml` file. You can change it to "repository" in order to run from the root.
- name: You can specify the module name if it differs from the name of the tool.
- Deprecated: name must be unique. This has been replaced by `executable`.

The `args` and `check-args` can be:

Expand Down
10 changes: 8 additions & 2 deletions coveo_stew/ci/any_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(
working_directory: str = "project",
check_args: Optional[Union[str, List[str]]] = None,
autofix_args: Optional[Union[str, List[str]]] = None,
executable: Optional[str] = None,
_pyproject: PythonProject,
) -> None:
if args and check_args:
Expand All @@ -47,6 +48,7 @@ def __init__(

super().__init__(_pyproject=_pyproject)
self._name = name
self._executable = executable
self.check_failed_exit_codes = check_failed_exit_codes
self.outputs_own_report = not create_generic_report
self.check_args = [] if check_args is None else check_args
Expand All @@ -68,7 +70,7 @@ def __init__(

async def _launch(self, environment: PythonEnvironment, *extra_args: str) -> RunnerStatus:
args = [self.check_args] if isinstance(self.check_args, str) else self.check_args
command = environment.build_command(self.name, *args)
command = environment.build_command(self.executable, *args)

working_directory = self._pyproject.project_path
if self.working_directory is WorkingDirectoryKind.Repository:
Expand All @@ -91,9 +93,13 @@ async def _launch(self, environment: PythonEnvironment, *extra_args: str) -> Run
def name(self) -> str:
return self._name

@property
def executable(self) -> str:
return self._executable or self.name

async def _custom_autofix(self, environment: PythonEnvironment) -> None:
args = [self.autofix_args] if isinstance(self.autofix_args, str) else self.autofix_args
command = environment.build_command(self.name, *args)
command = environment.build_command(self.executable, *args)

working_directory = self._pyproject.project_path
if self.working_directory is WorkingDirectoryKind.Repository:
Expand Down
11 changes: 9 additions & 2 deletions coveo_stew/ci/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ def get_runner(self, runner_name: str) -> Optional[ContinuousIntegrationRunner]:
return self._runners.get(runner_name)

def _generate_ci_plans(
self, checks: Optional[List[str]], skips: Optional[List[str]], parallel: bool = True
self,
checks: Optional[List[str]],
skips: Optional[List[str]],
parallel: bool = True,
) -> Generator[CIPlan, None, None]:
"""Generates one test plan per environment."""
checks = [check.lower() for check in checks] if checks else []
Expand Down Expand Up @@ -130,7 +133,11 @@ async def launch_continuous_integration(
generate_github_step_report(ci_plans)

statuses = set(check.status for plan in ci_plans for check in plan.checks)
for status in (RunnerStatus.Error, RunnerStatus.CheckFailed, RunnerStatus.Success):
for status in (
RunnerStatus.Error,
RunnerStatus.CheckFailed,
RunnerStatus.Success,
):
if status in statuses:
return status

Expand Down
2 changes: 1 addition & 1 deletion coveo_stew/ci/poetry_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ class PoetryCheckRunner(ContinuousIntegrationRunner):
async def _launch(self, environment: PythonEnvironment, *extra_args: str) -> RunnerStatus:
await async_check_output(
*environment.build_command(PythonTool.Poetry, "check"),
working_directory=self._pyproject.project_path
working_directory=self._pyproject.project_path,
)
return RunnerStatus.Success
15 changes: 11 additions & 4 deletions coveo_stew/ci/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ def project(self) -> PythonProject:
return self._pyproject

async def launch(
self, environment: PythonEnvironment = None, *extra_args: str, auto_fix: bool = False
self,
environment: PythonEnvironment = None,
*extra_args: str,
auto_fix: bool = False,
) -> "ContinuousIntegrationRunner":
"""
Launch the runner's checks.
Expand Down Expand Up @@ -118,7 +121,8 @@ def _output_generic_report(self, environment: PythonEnvironment) -> None:
test_case = TestCase(self.name, classname=f"ci.{self._pyproject.package.name}")
if self.status is RunnerStatus.Error:
test_case.add_error_info(
"An error occurred, the test was unable to complete.", self.last_output()
"An error occurred, the test was unable to complete.",
self.last_output(),
)
elif self.status is RunnerStatus.CheckFailed:
test_case.add_failure_info("The test completed; errors were found.", self.last_output())
Expand Down Expand Up @@ -197,7 +201,9 @@ class Run:
checks: Sequence[ContinuousIntegrationRunner]

@cached_property
def exceptions(self) -> List[Tuple[ContinuousIntegrationRunner, DetailedCalledProcessError]]:
def exceptions(
self,
) -> List[Tuple[ContinuousIntegrationRunner, DetailedCalledProcessError]]:
"""Exceptions are stored here after the run. Exceptions are cleared when `run_and_report` is called."""
return []

Expand All @@ -224,7 +230,8 @@ async def run_and_report(
else:
for runner in self.checks:
self._report(
await runner.launch(self.environment, auto_fix=auto_fix), feedback=feedback
await runner.launch(self.environment, auto_fix=auto_fix),
feedback=feedback,
)

if self.exceptions:
Expand Down
3 changes: 2 additions & 1 deletion coveo_stew/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
def find_pyproject(project_name: str, path: Path = None, *, verbose: bool = False) -> PythonProject:
"""Find a python project in path using the exact project name"""
project = next(
discover_pyprojects(path, query=project_name, exact_match=True, verbose=verbose), None
discover_pyprojects(path, query=project_name, exact_match=True, verbose=verbose),
None,
)
if not project:
raise PythonProjectNotFound(f"{project_name} cannot be found in {path}")
Expand Down
2 changes: 1 addition & 1 deletion coveo_stew/metadata/poetry_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def all_dependencies(self) -> Mapping[str, Dependency]:


def dependencies_factory(
dependencies: Mapping[str, Union[str, dict]] = None
dependencies: Mapping[str, Union[str, dict]] = None,
) -> Dict[str, Dependency]:
"""Transforms a poetry dependency section (such as tool.poetry.dev-dependencies) into Dependency instances."""
return (
Expand Down
2 changes: 1 addition & 1 deletion coveo_stew/metadata/stew_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def __init__(
build: bool = False,
build_without_hashes: bool = False,
pydev: bool = False,
build_dependencies: Mapping[str, Any] = None
build_dependencies: Mapping[str, Any] = None,
) -> None:
self.build = build # we won't build a project unless this is specified.
# poetry sometimes fail at getting hashes, in which case the export cannot work because pip will complain
Expand Down
5 changes: 3 additions & 2 deletions coveo_stew/offline_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ def _store_dependencies_in_wheelhouse(self, project: Optional[PythonProject] = N
lines: List[str] = []
for requirement in project.export().splitlines():
if match := LOCAL_REQUIREMENT_PATTERN.match(requirement):
dependency_name, dependency_location = match["library_name"].strip(), Path(
match["path"].strip()
dependency_name, dependency_location = (
match["library_name"].strip(),
Path(match["path"].strip()),
)
# this is a local dependency. Since poetry locks all transitive dependencies,
# we're only interested in the setup dependencies and the local dependency.
Expand Down
3 changes: 2 additions & 1 deletion coveo_stew/pydev.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ def _dev_dependencies_of_dependencies(
if dev_dependency.is_local:
value: Any = tomlkit.inline_table()
value.append(
"path", str(dev_dependency.path.relative_to(local_project.project_path))
"path",
str(dev_dependency.path.relative_to(local_project.project_path)),
)
else:
value = dev_dependency.version
Expand Down
9 changes: 6 additions & 3 deletions coveo_stew/stew.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ def activated_environment(self) -> Optional[PythonEnvironment]:
)

def virtual_environments(
self, *, create_default_if_missing: Union[bool, EnvironmentCreationBehavior] = False
self,
*,
create_default_if_missing: Union[bool, EnvironmentCreationBehavior] = False,
) -> Iterator[PythonEnvironment]:
"""The project's virtual environments. These are cached for performance.
Expand Down Expand Up @@ -211,8 +213,9 @@ def _get_virtual_environment_paths(self) -> Iterator[Tuple[Path, bool]]:
if (stripped := str_path.strip()) and (
match := re.fullmatch(ENVIRONMENT_PATH_PATTERN, stripped)
):
yield Path(match.groupdict()["path"].strip()), bool(
match.groupdict().get("activated")
yield (
Path(match.groupdict()["path"].strip()),
bool(match.groupdict().get("activated")),
)

def current_environment_belongs_to_project(self) -> bool:
Expand Down
3 changes: 2 additions & 1 deletion test_coveo_stew/test_backward_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
)
def test_get_verb(poetry_version: str, verb: str, expected_verb: str) -> None:
with mock.patch(
*ref(find_poetry_version, context=get_verb), return_value=Version(poetry_version)
*ref(find_poetry_version, context=get_verb),
return_value=Version(poetry_version),
):
get_verb.cache_clear()
assert get_verb(verb, None) == expected_verb
4 changes: 3 additions & 1 deletion test_coveo_stew/test_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def test_pyproject_mock_initial_state(pyproject_mock: PythonProject) -> None:


@Integration
def test_pyproject_mock_initial_state_integration(pyproject_mock: PythonProject) -> None:
def test_pyproject_mock_initial_state_integration(
pyproject_mock: PythonProject,
) -> None:
assert not pyproject_mock.lock_is_outdated()


Expand Down
5 changes: 4 additions & 1 deletion test_coveo_stew/test_stew_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ def write_code(project: PythonProject, code: str) -> Generator[None, None, None]
@parametrize(
("check", "failure_text"),
[
("mypy", 'error: Argument 1 to "fn" has incompatible type "int"; expected "str"'),
(
"mypy",
'error: Argument 1 to "fn" has incompatible type "int"; expected "str"',
),
("isort", "Imports are incorrectly sorted and/or formatted."),
("black", f"would reformat mock_linter_errors{os.sep}code.py"),
],
Expand Down

0 comments on commit b5f7986

Please sign in to comment.