ps-plugin-module-check 0.2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ps_plugin_module_check-0.2.9/PKG-INFO +123 -0
- ps_plugin_module_check-0.2.9/README.md +106 -0
- ps_plugin_module_check-0.2.9/pyproject.toml +33 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/__init__.py +7 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/_check.py +17 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/_check_module.py +173 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/_check_settings.py +6 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/__init__.py +17 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_environment_check.py +46 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_imports_check.py +273 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_poetry_check.py +30 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_pylint_check.py +13 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_pyright_check.py +13 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_pytest_check.py +13 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_ruff_check.py +15 -0
- ps_plugin_module_check-0.2.9/src/ps/plugin/module/check/checks/_tool_check.py +66 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ps-plugin-module-check
|
|
3
|
+
Version: 0.2.9
|
|
4
|
+
Summary: Check module for ps-poetry: configurable quality checks including ruff, pyright, pytest, and more
|
|
5
|
+
Author: ztBlackGad
|
|
6
|
+
Requires-Python: >=3.10,<3.14
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Dist: ps-dependency-injection (>=0.2.9,<0.3.0)
|
|
13
|
+
Project-URL: Homepage, https://github.com/BlackGad/ps-poetry
|
|
14
|
+
Project-URL: Repository, https://github.com/BlackGad/ps-poetry
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Overview
|
|
18
|
+
|
|
19
|
+
The `ps-plugin-module-check` module extends Poetry's built-in `check` command with a configurable sequence of quality checks across all projects in a monorepo. It provides seven built-in checkers — `poetry`, `environment`, `imports`, `ruff`, `pylint`, `pytest`, and `pyright` — with support for automatic fixing and per-project configuration.
|
|
20
|
+
|
|
21
|
+
The module is registered as a `ps.module` entry point and activates when included in the host project's `[tool.ps-plugin]` configuration.
|
|
22
|
+
|
|
23
|
+
# Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ps-plugin-module-check
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or with Poetry:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
poetry add ps-plugin-module-check
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Enable it in the plugin configuration:
|
|
36
|
+
|
|
37
|
+
```toml
|
|
38
|
+
[tool.ps-plugin]
|
|
39
|
+
modules = ["ps-check"]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
# Quick Start
|
|
43
|
+
|
|
44
|
+
Run all configured checks across all projects:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
poetry check
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Run checks on specific projects with automatic fixing:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
poetry check my-package --fix
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
# Configuration
|
|
57
|
+
|
|
58
|
+
The module reads its settings from the `[tool.ps-plugin]` section of the host project's `pyproject.toml`. The `checks` field specifies which checkers to run and in what order.
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
[tool.ps-plugin]
|
|
62
|
+
checks = ["poetry", "environment", "ruff", "pytest"]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When `checks` is omitted or empty, no checkers are selected and the command exits immediately. Only checkers listed in `checks` are executed, and they run in the declared order.
|
|
66
|
+
|
|
67
|
+
# Command-Line Usage
|
|
68
|
+
|
|
69
|
+
The module extends Poetry's `check` command with additional arguments and options:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
poetry check [INPUTS...] [--fix] [--continue-on-error]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
* `INPUTS` — Optional list of project names or paths to check. When omitted, all discovered projects are checked. When running from a sub-project that differs from the host project, the sub-project is selected automatically.
|
|
76
|
+
* `--fix` / `-f` — Enable automatic fixing in checkers that support it (`ruff` and `imports`).
|
|
77
|
+
* `--continue-on-error` / `-c` — Continue checking remaining projects and checkers after a failure instead of stopping on the first error.
|
|
78
|
+
|
|
79
|
+
# Available Checks
|
|
80
|
+
|
|
81
|
+
## poetry
|
|
82
|
+
|
|
83
|
+
Runs Poetry's built-in `check` command on each target project independently, validating the `pyproject.toml` structure and metadata.
|
|
84
|
+
|
|
85
|
+
## environment
|
|
86
|
+
|
|
87
|
+
Validates consistency of package sources across all target projects. Detects two types of conflicts:
|
|
88
|
+
|
|
89
|
+
* **URL conflicts** — The same source name (case-insensitive) is declared with different URLs in different projects.
|
|
90
|
+
* **Priority conflicts** — The same source name is declared with different priority levels across projects.
|
|
91
|
+
|
|
92
|
+
This checker is validation-only and does not support automatic fixing.
|
|
93
|
+
|
|
94
|
+
## imports
|
|
95
|
+
|
|
96
|
+
Walks each project's source directories and collects all non-stdlib, non-relative imports using AST analysis. For each import, the checker verifies that the providing distribution is declared as a dependency of the project, either directly or transitively through local path dependencies.
|
|
97
|
+
|
|
98
|
+
Stdlib modules, `__future__` imports, and modules belonging to the project's own package are silently skipped. When a required distribution is not declared, the checker reports the missing dependency together with the file paths and line numbers where the import appears.
|
|
99
|
+
|
|
100
|
+
When the `--fix` flag is provided, the checker adds each missing dependency automatically. Local monorepo packages are preferred over PyPI packages and are added as development dependencies with a path reference. PyPI packages are added as regular dependencies with an unconstrained version specifier. Projects are greedily consolidated so that adding a single dependency covers as many missing imports as possible through its transitive closure.
|
|
101
|
+
|
|
102
|
+
## ruff
|
|
103
|
+
|
|
104
|
+
Runs the `ruff` linter on the collected source paths. When the `--fix` flag is provided, the `--fix` argument is forwarded to ruff to apply automatic corrections. This checker is only available when `ruff` is installed and accessible in PATH.
|
|
105
|
+
|
|
106
|
+
## pylint
|
|
107
|
+
|
|
108
|
+
Runs `pylint` on the collected source paths. This checker is only available when `pylint` is installed and accessible in PATH.
|
|
109
|
+
|
|
110
|
+
## pytest
|
|
111
|
+
|
|
112
|
+
Runs `pytest` on the collected source paths. This checker is only available when `pytest` is installed and accessible in PATH.
|
|
113
|
+
|
|
114
|
+
## pyright
|
|
115
|
+
|
|
116
|
+
Runs `pyright` on the collected source paths for static type checking. This checker is only available when `pyright` is installed and accessible in PATH. Auto-fixing is not supported.
|
|
117
|
+
|
|
118
|
+
# Custom Checks
|
|
119
|
+
|
|
120
|
+
`ICheck` is the abstract base class for implementing custom checkers. Subclasses implement `check(io, projects, fix)` to perform the check and return an optional exception on failure. The `can_check(projects)` method allows a checker to declare itself inapplicable to a given project list. Each subclass must declare a `name: ClassVar[str]` attribute that matches the name used in the `checks` configuration list.
|
|
121
|
+
|
|
122
|
+
Register custom implementations with the DI container using `di.register(ICheck)` inside `poetry_activate` so the check module discovers them automatically via `di.resolve_many(ICheck)`.
|
|
123
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Overview
|
|
2
|
+
|
|
3
|
+
The `ps-plugin-module-check` module extends Poetry's built-in `check` command with a configurable sequence of quality checks across all projects in a monorepo. It provides seven built-in checkers — `poetry`, `environment`, `imports`, `ruff`, `pylint`, `pytest`, and `pyright` — with support for automatic fixing and per-project configuration.
|
|
4
|
+
|
|
5
|
+
The module is registered as a `ps.module` entry point and activates when included in the host project's `[tool.ps-plugin]` configuration.
|
|
6
|
+
|
|
7
|
+
# Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ps-plugin-module-check
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with Poetry:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
poetry add ps-plugin-module-check
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Enable it in the plugin configuration:
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
[tool.ps-plugin]
|
|
23
|
+
modules = ["ps-check"]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
# Quick Start
|
|
27
|
+
|
|
28
|
+
Run all configured checks across all projects:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
poetry check
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Run checks on specific projects with automatic fixing:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
poetry check my-package --fix
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
# Configuration
|
|
41
|
+
|
|
42
|
+
The module reads its settings from the `[tool.ps-plugin]` section of the host project's `pyproject.toml`. The `checks` field specifies which checkers to run and in what order.
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
[tool.ps-plugin]
|
|
46
|
+
checks = ["poetry", "environment", "ruff", "pytest"]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
When `checks` is omitted or empty, no checkers are selected and the command exits immediately. Only checkers listed in `checks` are executed, and they run in the declared order.
|
|
50
|
+
|
|
51
|
+
# Command-Line Usage
|
|
52
|
+
|
|
53
|
+
The module extends Poetry's `check` command with additional arguments and options:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
poetry check [INPUTS...] [--fix] [--continue-on-error]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
* `INPUTS` — Optional list of project names or paths to check. When omitted, all discovered projects are checked. When running from a sub-project that differs from the host project, the sub-project is selected automatically.
|
|
60
|
+
* `--fix` / `-f` — Enable automatic fixing in checkers that support it (`ruff` and `imports`).
|
|
61
|
+
* `--continue-on-error` / `-c` — Continue checking remaining projects and checkers after a failure instead of stopping on the first error.
|
|
62
|
+
|
|
63
|
+
# Available Checks
|
|
64
|
+
|
|
65
|
+
## poetry
|
|
66
|
+
|
|
67
|
+
Runs Poetry's built-in `check` command on each target project independently, validating the `pyproject.toml` structure and metadata.
|
|
68
|
+
|
|
69
|
+
## environment
|
|
70
|
+
|
|
71
|
+
Validates consistency of package sources across all target projects. Detects two types of conflicts:
|
|
72
|
+
|
|
73
|
+
* **URL conflicts** — The same source name (case-insensitive) is declared with different URLs in different projects.
|
|
74
|
+
* **Priority conflicts** — The same source name is declared with different priority levels across projects.
|
|
75
|
+
|
|
76
|
+
This checker is validation-only and does not support automatic fixing.
|
|
77
|
+
|
|
78
|
+
## imports
|
|
79
|
+
|
|
80
|
+
Walks each project's source directories and collects all non-stdlib, non-relative imports using AST analysis. For each import, the checker verifies that the providing distribution is declared as a dependency of the project, either directly or transitively through local path dependencies.
|
|
81
|
+
|
|
82
|
+
Stdlib modules, `__future__` imports, and modules belonging to the project's own package are silently skipped. When a required distribution is not declared, the checker reports the missing dependency together with the file paths and line numbers where the import appears.
|
|
83
|
+
|
|
84
|
+
When the `--fix` flag is provided, the checker adds each missing dependency automatically. Local monorepo packages are preferred over PyPI packages and are added as development dependencies with a path reference. PyPI packages are added as regular dependencies with an unconstrained version specifier. Projects are greedily consolidated so that adding a single dependency covers as many missing imports as possible through its transitive closure.
|
|
85
|
+
|
|
86
|
+
## ruff
|
|
87
|
+
|
|
88
|
+
Runs the `ruff` linter on the collected source paths. When the `--fix` flag is provided, the `--fix` argument is forwarded to ruff to apply automatic corrections. This checker is only available when `ruff` is installed and accessible in PATH.
|
|
89
|
+
|
|
90
|
+
## pylint
|
|
91
|
+
|
|
92
|
+
Runs `pylint` on the collected source paths. This checker is only available when `pylint` is installed and accessible in PATH.
|
|
93
|
+
|
|
94
|
+
## pytest
|
|
95
|
+
|
|
96
|
+
Runs `pytest` on the collected source paths. This checker is only available when `pytest` is installed and accessible in PATH.
|
|
97
|
+
|
|
98
|
+
## pyright
|
|
99
|
+
|
|
100
|
+
Runs `pyright` on the collected source paths for static type checking. This checker is only available when `pyright` is installed and accessible in PATH. Auto-fixing is not supported.
|
|
101
|
+
|
|
102
|
+
# Custom Checks
|
|
103
|
+
|
|
104
|
+
`ICheck` is the abstract base class for implementing custom checkers. Subclasses implement `check(io, projects, fix)` to perform the check and return an optional exception on failure. The `can_check(projects)` method allows a checker to declare itself inapplicable to a given project list. Each subclass must declare a `name: ClassVar[str]` attribute that matches the name used in the `checks` configuration list.
|
|
105
|
+
|
|
106
|
+
Register custom implementations with the DI container using `di.register(ICheck)` inside `poetry_activate` so the check module discovers them automatically via `di.resolve_many(ICheck)`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ps-plugin-module-check"
|
|
3
|
+
description = "Check module for ps-poetry: configurable quality checks including ruff, pyright, pytest, and more"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.10,<3.14"
|
|
6
|
+
version = "0.2.9"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "ztBlackGad" },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/BlackGad/ps-poetry"
|
|
13
|
+
Repository = "https://github.com/BlackGad/ps-poetry"
|
|
14
|
+
|
|
15
|
+
[project.entry-points."ps.module"]
|
|
16
|
+
module_entry = "ps.plugin.module.check"
|
|
17
|
+
|
|
18
|
+
[tool.poetry.group.ps.dependencies]
|
|
19
|
+
ps-plugin-sdk = ">=0.2.9,<0.3.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
[tool.poetry.dependencies]
|
|
23
|
+
ps-dependency-injection = ">=0.2.9,<0.3.0"
|
|
24
|
+
|
|
25
|
+
[tool.poetry]
|
|
26
|
+
packages = [ { include = "ps/plugin/module/check", from = "src" } ]
|
|
27
|
+
|
|
28
|
+
[tool.ps-plugin]
|
|
29
|
+
host-project = "../.."
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["poetry-core>=1.0.0"]
|
|
33
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import ClassVar, Optional
|
|
3
|
+
|
|
4
|
+
from cleo.io.io import IO
|
|
5
|
+
|
|
6
|
+
from ps.plugin.sdk.mixins import NameAwareProtocol
|
|
7
|
+
from ps.plugin.sdk.project import Project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ICheck(NameAwareProtocol, ABC):
|
|
11
|
+
name: ClassVar[str]
|
|
12
|
+
|
|
13
|
+
def can_check(self, projects: list[Project]) -> bool:
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def check(self, io: IO, projects: list[Project], fix: bool) -> Optional[Exception]: ...
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from typing import ClassVar, Optional, Type
|
|
2
|
+
|
|
3
|
+
from cleo.events.console_command_event import ConsoleCommandEvent
|
|
4
|
+
from cleo.events.console_terminate_event import ConsoleTerminateEvent
|
|
5
|
+
from cleo.io.inputs.argument import Argument
|
|
6
|
+
from cleo.io.inputs.input import Input
|
|
7
|
+
from cleo.io.inputs.option import Option
|
|
8
|
+
from cleo.io.io import IO
|
|
9
|
+
from poetry.console.application import Application
|
|
10
|
+
from poetry.console.commands.check import CheckCommand
|
|
11
|
+
|
|
12
|
+
from ps.di import DI
|
|
13
|
+
from ps.plugin.module.check._check import ICheck
|
|
14
|
+
from ps.plugin.sdk.events import ensure_argument, ensure_option
|
|
15
|
+
from ps.plugin.sdk.mixins import NameAwareProtocol
|
|
16
|
+
from ps.plugin.sdk.project import Environment, Project, filter_projects
|
|
17
|
+
|
|
18
|
+
from ._check_settings import CheckSettings
|
|
19
|
+
from .checks import (
|
|
20
|
+
EnvironmentCheck,
|
|
21
|
+
ImportsCheck,
|
|
22
|
+
PoetryCheck,
|
|
23
|
+
PylintCheck,
|
|
24
|
+
PyrightCheck,
|
|
25
|
+
PytestCheck,
|
|
26
|
+
RuffCheck,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _filter_checkers[T: NameAwareProtocol](available_checkers: list[T], check_settings: CheckSettings, io: IO) -> list[T]:
|
|
31
|
+
# Explicitly specified checks in settings
|
|
32
|
+
specified_checks = check_settings.checks if check_settings.checks is not None else []
|
|
33
|
+
|
|
34
|
+
# Get specified checkers in order
|
|
35
|
+
specified_checkers = sorted(
|
|
36
|
+
[checker for checker in available_checkers if checker.name in specified_checks],
|
|
37
|
+
key=lambda c: specified_checks.index(c.name)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Get available but not specified checkers
|
|
41
|
+
available_not_specified = [
|
|
42
|
+
checker for checker in available_checkers if checker.name not in specified_checks
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Print selected checkers only in verbose mode
|
|
46
|
+
if io.is_verbose():
|
|
47
|
+
io.write_line("<fg=magenta>Selected checkers:</> ")
|
|
48
|
+
for idx, checker in enumerate(specified_checkers, start=1):
|
|
49
|
+
io.write_line(f" {idx}. <fg=cyan>{checker.name}</>")
|
|
50
|
+
|
|
51
|
+
# Print available but not selected checkers in verbose mode
|
|
52
|
+
if io.is_verbose() and available_not_specified:
|
|
53
|
+
io.write_line("\n<fg=magenta>Available but not selected:</> ")
|
|
54
|
+
for checker in available_not_specified:
|
|
55
|
+
io.write_line(f" - <fg=dark_gray>{checker.name}</>")
|
|
56
|
+
|
|
57
|
+
return specified_checkers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_inputs(input: Input) -> list[str]:
|
|
61
|
+
return input.arguments.get("inputs", [])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_fix_option(input: Input) -> bool:
|
|
65
|
+
return input.options.get("fix", False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_continue_on_error_option(input: Input) -> bool:
|
|
69
|
+
return input.options.get("continue-on-error", False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _perform_checks(di: DI, projects: list[Project], checkers: list[ICheck], fix: bool, continue_on_error: bool) -> int:
|
|
73
|
+
if not checkers:
|
|
74
|
+
return 0
|
|
75
|
+
io = di.resolve(IO)
|
|
76
|
+
assert io is not None
|
|
77
|
+
result = 0
|
|
78
|
+
for checker in checkers:
|
|
79
|
+
io.write_line(f"<fg=cyan>{checker.name.upper()}</>")
|
|
80
|
+
if not checker.can_check(projects):
|
|
81
|
+
if io.is_debug():
|
|
82
|
+
io.write_line("<fg=yellow>Skipping checker as it cannot check the provided projects in current environment.</>")
|
|
83
|
+
continue
|
|
84
|
+
exception = checker.check(io, projects, fix)
|
|
85
|
+
if exception is not None:
|
|
86
|
+
io.write_line(f"<error>{exception}</error>")
|
|
87
|
+
result = 1
|
|
88
|
+
if not continue_on_error:
|
|
89
|
+
return result
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_builtin_checks: list[Type[ICheck]] = [
|
|
94
|
+
PoetryCheck,
|
|
95
|
+
EnvironmentCheck,
|
|
96
|
+
ImportsCheck,
|
|
97
|
+
PytestCheck,
|
|
98
|
+
PylintCheck,
|
|
99
|
+
RuffCheck,
|
|
100
|
+
PyrightCheck,
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CheckModule:
|
|
105
|
+
name: ClassVar[str] = "ps-check"
|
|
106
|
+
|
|
107
|
+
def __init__(self, di: DI) -> None:
|
|
108
|
+
for check_cls in _builtin_checks:
|
|
109
|
+
di.register(ICheck).implementation(check_cls)
|
|
110
|
+
self._di = di
|
|
111
|
+
self._exit_code: Optional[int] = None
|
|
112
|
+
|
|
113
|
+
def poetry_activate(self, application: Application) -> bool:
|
|
114
|
+
ensure_argument(CheckCommand, Argument(
|
|
115
|
+
name="inputs",
|
|
116
|
+
description="Optional inputs pointers to check. It could be project names or paths. If not provided, all discovered projects will be checked.",
|
|
117
|
+
is_list=True,
|
|
118
|
+
required=False)
|
|
119
|
+
)
|
|
120
|
+
ensure_option(CheckCommand, Option(
|
|
121
|
+
name="fix",
|
|
122
|
+
description="Instruct to perform automatic issues fix where possible.",
|
|
123
|
+
shortcut="f",
|
|
124
|
+
flag=True)
|
|
125
|
+
)
|
|
126
|
+
ensure_option(CheckCommand, Option(
|
|
127
|
+
name="continue-on-error",
|
|
128
|
+
description="Continue checking even after a check failure.",
|
|
129
|
+
shortcut="c",
|
|
130
|
+
flag=True)
|
|
131
|
+
)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def poetry_command(self, event: ConsoleCommandEvent) -> None:
|
|
135
|
+
if not isinstance(event.command, CheckCommand):
|
|
136
|
+
return
|
|
137
|
+
event.disable_command()
|
|
138
|
+
|
|
139
|
+
environment = self._di.resolve(Environment)
|
|
140
|
+
assert environment is not None
|
|
141
|
+
|
|
142
|
+
inputs = _get_inputs(event.io.input)
|
|
143
|
+
if not inputs and environment.host_project != environment.entry_project:
|
|
144
|
+
inputs.append(str(environment.entry_project.path))
|
|
145
|
+
|
|
146
|
+
filtered_projects = filter_projects(inputs, environment.projects)
|
|
147
|
+
if not filtered_projects:
|
|
148
|
+
event.io.write_line("<comment>No projects found to process.</comment>")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
fix = _get_fix_option(event.io.input)
|
|
152
|
+
continue_on_error = _get_continue_on_error_option(event.io.input)
|
|
153
|
+
|
|
154
|
+
plugin_settings = environment.host_project.plugin_settings
|
|
155
|
+
check_settings = CheckSettings.model_validate(plugin_settings.model_dump(), by_alias=True)
|
|
156
|
+
|
|
157
|
+
available_checkers = self._di.resolve_many(ICheck)
|
|
158
|
+
checkers = _filter_checkers(available_checkers, check_settings, event.io)
|
|
159
|
+
|
|
160
|
+
if not checkers:
|
|
161
|
+
event.io.write_line("<comment>No checkers selected to run.</comment>")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if fix:
|
|
165
|
+
event.io.write_line("<fg=green>Automatic fix enabled</>")
|
|
166
|
+
if continue_on_error:
|
|
167
|
+
event.io.write_line("<fg=magenta>Continue on error enabled</>")
|
|
168
|
+
event.io.write_line(f"\n<fg=magenta>Checking <comment>{len(filtered_projects)}</comment> project(s)</>")
|
|
169
|
+
self._exit_code = _perform_checks(self._di, filtered_projects, checkers, fix, continue_on_error)
|
|
170
|
+
|
|
171
|
+
def poetry_terminate(self, event: ConsoleTerminateEvent) -> None:
|
|
172
|
+
if self._exit_code is not None:
|
|
173
|
+
event.set_exit_code(self._exit_code)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from ._environment_check import EnvironmentCheck
|
|
2
|
+
from ._imports_check import ImportsCheck
|
|
3
|
+
from ._poetry_check import PoetryCheck
|
|
4
|
+
from ._pylint_check import PylintCheck
|
|
5
|
+
from ._pyright_check import PyrightCheck
|
|
6
|
+
from ._pytest_check import PytestCheck
|
|
7
|
+
from ._ruff_check import RuffCheck
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"EnvironmentCheck",
|
|
11
|
+
"ImportsCheck",
|
|
12
|
+
"PoetryCheck",
|
|
13
|
+
"PylintCheck",
|
|
14
|
+
"PyrightCheck",
|
|
15
|
+
"PytestCheck",
|
|
16
|
+
"RuffCheck",
|
|
17
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import ClassVar, Optional
|
|
2
|
+
|
|
3
|
+
from cleo.io.io import IO
|
|
4
|
+
|
|
5
|
+
from ps.plugin.module.check._check import ICheck
|
|
6
|
+
from ps.plugin.sdk.project import Project
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvironmentCheck(ICheck):
|
|
10
|
+
name: ClassVar[str] = "environment"
|
|
11
|
+
|
|
12
|
+
def check(self, io: IO, projects: list[Project], fix: bool) -> Optional[Exception]:
|
|
13
|
+
errors = self._check_conflicting_sources(projects)
|
|
14
|
+
|
|
15
|
+
if not errors:
|
|
16
|
+
io.write_line("Environment check passed with no errors")
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
for error in errors:
|
|
20
|
+
io.write_line(f"<error>{error}</error>")
|
|
21
|
+
return Exception(f"Environment check failed with {len(errors)} error(s)")
|
|
22
|
+
|
|
23
|
+
def _check_conflicting_sources(self, projects: list[Project]) -> list[str]:
|
|
24
|
+
urls: dict[str, dict[Optional[str], list[str]]] = {}
|
|
25
|
+
priorities: dict[str, dict[Optional[str], list[str]]] = {}
|
|
26
|
+
names: dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
for project in projects:
|
|
29
|
+
project_name = project.name.value or str(project.path)
|
|
30
|
+
for source in project.sources:
|
|
31
|
+
key = source.name.lower()
|
|
32
|
+
names.setdefault(key, source.name)
|
|
33
|
+
priority = str(source.priority) if source.priority is not None else None
|
|
34
|
+
urls.setdefault(key, {}).setdefault(source.url, []).append(project_name)
|
|
35
|
+
priorities.setdefault(key, {}).setdefault(priority, []).append(project_name)
|
|
36
|
+
|
|
37
|
+
errors = []
|
|
38
|
+
for key in urls.keys() | priorities.keys():
|
|
39
|
+
for field, groups in (("urls", urls.get(key, {})), ("priorities", priorities.get(key, {}))):
|
|
40
|
+
if len(groups) > 1:
|
|
41
|
+
lines = "\n".join(
|
|
42
|
+
f" - '{val}' in {', '.join(f'{p!r}' for p in projs)}"
|
|
43
|
+
for val, projs in groups.items()
|
|
44
|
+
)
|
|
45
|
+
errors.append(f"Source '{names[key]}' has conflicting {field}:\n{lines}")
|
|
46
|
+
return errors
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, packages_distributions
|
|
7
|
+
from importlib.metadata import requires as metadata_requires
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar, Optional
|
|
10
|
+
|
|
11
|
+
from cleo.io.io import IO
|
|
12
|
+
|
|
13
|
+
from ps.plugin.module.check._check import ICheck
|
|
14
|
+
from ps.plugin.sdk.project import Environment, Project, normalize_dist_name, dist_name_variants
|
|
15
|
+
from ps.plugin.sdk.toml import TomlValue
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _package_entries(project: Project) -> list[dict]:
|
|
19
|
+
value = TomlValue.locate(project.document, ["tool.poetry.packages"]).value
|
|
20
|
+
return [entry for entry in value or [] if isinstance(entry, dict)]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _collect_imports(source_dirs: list[Path]) -> dict[str, set[tuple[Path, int]]]:
|
|
24
|
+
imports: dict[str, set[tuple[Path, int]]] = defaultdict(set)
|
|
25
|
+
|
|
26
|
+
for source_dir in source_dirs:
|
|
27
|
+
for py_file in source_dir.rglob("*.py"):
|
|
28
|
+
try:
|
|
29
|
+
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
for node in ast.walk(tree):
|
|
34
|
+
if isinstance(node, ast.Import):
|
|
35
|
+
for alias in node.names:
|
|
36
|
+
imports[alias.name].add((py_file, node.lineno))
|
|
37
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
38
|
+
imports[node.module].add((py_file, node.lineno))
|
|
39
|
+
|
|
40
|
+
return imports
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_local_module_map(projects: list[Project]) -> dict[str, list[str]]:
|
|
44
|
+
result: dict[str, list[str]] = defaultdict(list)
|
|
45
|
+
|
|
46
|
+
for project in projects:
|
|
47
|
+
dist_name = project.name.value
|
|
48
|
+
if not dist_name:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
for entry in _package_entries(project):
|
|
52
|
+
include = entry.get("include")
|
|
53
|
+
if include:
|
|
54
|
+
result[include.replace("/", ".").replace("\\", ".")].append(str(dist_name))
|
|
55
|
+
|
|
56
|
+
return dict(result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_project_lookup(projects: list[Project]) -> dict[Path, Project]:
|
|
60
|
+
lookup: dict[Path, Project] = {}
|
|
61
|
+
for project in projects:
|
|
62
|
+
lookup[project.path] = project
|
|
63
|
+
lookup[project.path.parent] = project
|
|
64
|
+
return lookup
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _find_providers(
|
|
68
|
+
module: str,
|
|
69
|
+
local_map: Mapping[str, list[str]],
|
|
70
|
+
dist_map: Mapping[str, list[str]],
|
|
71
|
+
) -> Optional[list[str]]:
|
|
72
|
+
parts = module.split(".")
|
|
73
|
+
for size in range(len(parts), 0, -1):
|
|
74
|
+
candidate = ".".join(parts[:size])
|
|
75
|
+
if providers := local_map.get(candidate) or dist_map.get(candidate):
|
|
76
|
+
return providers
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _collect_pypi_transitive_deps(
|
|
81
|
+
package_name: str,
|
|
82
|
+
cache: dict[str, set[str]],
|
|
83
|
+
in_progress: set[str],
|
|
84
|
+
) -> set[str]:
|
|
85
|
+
key = normalize_dist_name(package_name)
|
|
86
|
+
if key in cache:
|
|
87
|
+
return cache[key]
|
|
88
|
+
if key in in_progress:
|
|
89
|
+
return set()
|
|
90
|
+
|
|
91
|
+
in_progress.add(key)
|
|
92
|
+
names: set[str] = set()
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
reqs = metadata_requires(package_name) or []
|
|
96
|
+
except PackageNotFoundError:
|
|
97
|
+
cache[key] = names
|
|
98
|
+
in_progress.discard(key)
|
|
99
|
+
return names
|
|
100
|
+
|
|
101
|
+
for req_str in reqs:
|
|
102
|
+
dep_name = re.split(r"[\s\(\[;><=!]", req_str, maxsplit=1)[0].strip()
|
|
103
|
+
if not dep_name:
|
|
104
|
+
continue
|
|
105
|
+
names.update(dist_name_variants(dep_name))
|
|
106
|
+
names.update(_collect_pypi_transitive_deps(dep_name, cache, in_progress))
|
|
107
|
+
|
|
108
|
+
cache[key] = names
|
|
109
|
+
in_progress.discard(key)
|
|
110
|
+
return names
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _collect_all_dep_names(
|
|
114
|
+
root_project: Project,
|
|
115
|
+
project_lookup: Mapping[Path, Project],
|
|
116
|
+
cache: dict[Path, set[str]],
|
|
117
|
+
pypi_cache: dict[str, set[str]],
|
|
118
|
+
in_progress: Optional[set[Path]] = None,
|
|
119
|
+
) -> set[str]:
|
|
120
|
+
if root_project.path in cache:
|
|
121
|
+
return cache[root_project.path]
|
|
122
|
+
|
|
123
|
+
in_progress = in_progress or set()
|
|
124
|
+
if root_project.path in in_progress:
|
|
125
|
+
return set()
|
|
126
|
+
|
|
127
|
+
in_progress.add(root_project.path)
|
|
128
|
+
names: set[str] = set()
|
|
129
|
+
pypi_in_progress: set[str] = set()
|
|
130
|
+
|
|
131
|
+
for dep in root_project.dependencies:
|
|
132
|
+
if dep.name:
|
|
133
|
+
names.update(dist_name_variants(dep.name))
|
|
134
|
+
if not dep.path:
|
|
135
|
+
names.update(_collect_pypi_transitive_deps(dep.name, pypi_cache, pypi_in_progress))
|
|
136
|
+
|
|
137
|
+
resolved = dep.resolved_project_path
|
|
138
|
+
if dep.path and resolved and (transient := project_lookup.get(resolved)):
|
|
139
|
+
names.update(
|
|
140
|
+
_collect_all_dep_names(
|
|
141
|
+
transient,
|
|
142
|
+
project_lookup=project_lookup,
|
|
143
|
+
cache=cache,
|
|
144
|
+
pypi_cache=pypi_cache,
|
|
145
|
+
in_progress=in_progress,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
cache[root_project.path] = names
|
|
150
|
+
in_progress.discard(root_project.path)
|
|
151
|
+
return names
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ImportsCheck(ICheck):
|
|
155
|
+
name: ClassVar[str] = "imports"
|
|
156
|
+
|
|
157
|
+
def __init__(self, environment: Environment) -> None:
|
|
158
|
+
self._environment = environment
|
|
159
|
+
self._dep_cache: dict[Path, set[str]] = {}
|
|
160
|
+
self._pypi_cache: dict[str, set[str]] = {}
|
|
161
|
+
|
|
162
|
+
def check(self, io: IO, projects: list[Project], fix: bool) -> Optional[Exception]:
|
|
163
|
+
dist_map = packages_distributions()
|
|
164
|
+
stdlib_modules = frozenset(sys.stdlib_module_names) # type: ignore[attr-defined]
|
|
165
|
+
all_projects = list(self._environment.projects)
|
|
166
|
+
local_map = _build_local_module_map(all_projects)
|
|
167
|
+
project_lookup = _build_project_lookup(all_projects)
|
|
168
|
+
project_by_dist: dict[str, Project] = {
|
|
169
|
+
normalize_dist_name(str(p.name.value)): p
|
|
170
|
+
for p in all_projects
|
|
171
|
+
if p.name.value
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
total_errors = 0
|
|
175
|
+
|
|
176
|
+
for project in self._environment.sorted_projects(projects):
|
|
177
|
+
imports_map = {
|
|
178
|
+
module: locations
|
|
179
|
+
for module, locations in _collect_imports(project.source_dirs).items()
|
|
180
|
+
if module.split(".")[0] not in stdlib_modules and module.split(".")[0] != "__future__"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
project_own_names = dist_name_variants(str(project.name.value)) | {normalize_dist_name(str(project.name.value))}
|
|
184
|
+
dep_names = _collect_all_dep_names(
|
|
185
|
+
project,
|
|
186
|
+
project_lookup=project_lookup,
|
|
187
|
+
cache=self._dep_cache,
|
|
188
|
+
pypi_cache=self._pypi_cache,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
missing: dict[tuple[str, ...], set[tuple[Path, int]]] = defaultdict(set)
|
|
192
|
+
|
|
193
|
+
for module, locations in sorted(imports_map.items()):
|
|
194
|
+
providers = _find_providers(module, local_map, dist_map)
|
|
195
|
+
if not providers:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
normalized_providers = {normalize_dist_name(name) for name in providers}
|
|
199
|
+
if normalized_providers & project_own_names:
|
|
200
|
+
continue
|
|
201
|
+
if normalized_providers & dep_names:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
missing[tuple(providers)].update(locations)
|
|
205
|
+
|
|
206
|
+
if not missing:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
io.write_line(f" <fg=blue>{project.path}</> <fg=dark_gray>({project.name.value})</>")
|
|
210
|
+
|
|
211
|
+
if fix:
|
|
212
|
+
self._apply_fix(io, project, missing, dep_names, project_lookup, project_by_dist)
|
|
213
|
+
self._dep_cache.clear()
|
|
214
|
+
else:
|
|
215
|
+
total_errors += len(missing)
|
|
216
|
+
for providers_key, locations in sorted(missing.items()):
|
|
217
|
+
label = providers_key[0] if len(providers_key) == 1 else " | ".join(providers_key)
|
|
218
|
+
io.write_line(f" <error>missing: {label!r}</error>")
|
|
219
|
+
for py_file, line in sorted(locations):
|
|
220
|
+
io.write_line(f" <fg=dark_gray>{py_file}:{line}</>")
|
|
221
|
+
|
|
222
|
+
if total_errors == 0:
|
|
223
|
+
io.write_line("Imports check passed with no errors")
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
io.write_line("<comment>Run with --fix to add missing dependencies automatically.</comment>")
|
|
227
|
+
return Exception(f"Imports check failed with {total_errors} missing distribution(s)")
|
|
228
|
+
|
|
229
|
+
def _apply_fix(
|
|
230
|
+
self,
|
|
231
|
+
io: IO,
|
|
232
|
+
project: Project,
|
|
233
|
+
missing: dict[tuple[str, ...], set[tuple[Path, int]]],
|
|
234
|
+
dep_names: set[str],
|
|
235
|
+
project_lookup: dict[Path, Project],
|
|
236
|
+
project_by_dist: dict[str, Project],
|
|
237
|
+
) -> None:
|
|
238
|
+
choices: list[tuple[tuple[str, ...], str, set[str]]] = []
|
|
239
|
+
for providers_key in missing:
|
|
240
|
+
local_provider = next(
|
|
241
|
+
(p for p in providers_key if project_by_dist.get(normalize_dist_name(p))),
|
|
242
|
+
None,
|
|
243
|
+
)
|
|
244
|
+
chosen = local_provider or providers_key[0]
|
|
245
|
+
local_dep = project_by_dist.get(normalize_dist_name(chosen))
|
|
246
|
+
if local_dep:
|
|
247
|
+
coverage = dist_name_variants(chosen) | _collect_all_dep_names(
|
|
248
|
+
local_dep, project_lookup, self._dep_cache, self._pypi_cache
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
coverage = dist_name_variants(chosen) | _collect_pypi_transitive_deps(
|
|
252
|
+
chosen, self._pypi_cache, set()
|
|
253
|
+
)
|
|
254
|
+
choices.append((providers_key, chosen, coverage))
|
|
255
|
+
|
|
256
|
+
choices.sort(key=lambda x: len(x[2]), reverse=True)
|
|
257
|
+
|
|
258
|
+
newly_covered: set[str] = set()
|
|
259
|
+
for providers_key, chosen, coverage in choices:
|
|
260
|
+
norm = {normalize_dist_name(p) for p in providers_key}
|
|
261
|
+
if norm & (dep_names | newly_covered):
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
newly_covered.update(coverage)
|
|
265
|
+
|
|
266
|
+
local_dep = project_by_dist.get(normalize_dist_name(chosen))
|
|
267
|
+
if local_dep:
|
|
268
|
+
project.add_development_dependency(chosen, local_dep.path.parent)
|
|
269
|
+
io.write_line(f" <info>added (local): {chosen!r}</info>")
|
|
270
|
+
else:
|
|
271
|
+
project.add_dependency(chosen, constraint="*")
|
|
272
|
+
io.write_line(f" <info>added: {chosen!r}</info>")
|
|
273
|
+
project.save()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import ClassVar, Optional
|
|
2
|
+
|
|
3
|
+
from cleo.io.buffered_io import BufferedIO
|
|
4
|
+
from cleo.io.io import IO
|
|
5
|
+
from poetry.console.commands.check import CheckCommand
|
|
6
|
+
from poetry.factory import Factory
|
|
7
|
+
|
|
8
|
+
from ps.plugin.module.check._check import ICheck
|
|
9
|
+
from ps.plugin.sdk.project import Project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PoetryCheck(ICheck):
|
|
13
|
+
name: ClassVar[str] = "poetry"
|
|
14
|
+
|
|
15
|
+
def check(self, io: IO, projects: list[Project], fix: bool) -> Optional[Exception]:
|
|
16
|
+
has_errors = False
|
|
17
|
+
for project in projects:
|
|
18
|
+
project_name = project.name.value or project.path.name
|
|
19
|
+
path_suffix = f" [<fg=dark_gray>{project.path}</>]" if io.is_verbose() else ""
|
|
20
|
+
io.write_line(f"Checking <fg=blue>{project_name}</>{path_suffix}")
|
|
21
|
+
try:
|
|
22
|
+
poetry_project = Factory().create_poetry(cwd=project.path, io=io)
|
|
23
|
+
check_command = CheckCommand()
|
|
24
|
+
check_command.set_poetry(poetry_project)
|
|
25
|
+
buffered_io = BufferedIO(decorated=io.is_decorated())
|
|
26
|
+
check_command.execute(buffered_io)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
io.write_line(f" <error>{e}</error>")
|
|
29
|
+
has_errors = True
|
|
30
|
+
return Exception("Poetry check failed") if has_errors else None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ._tool_check import ToolCheck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PylintCheck(ToolCheck):
|
|
8
|
+
name: ClassVar[str] = "pylint"
|
|
9
|
+
|
|
10
|
+
def _build_command(self, source_paths: list[Path], fix: bool) -> list[str]:
|
|
11
|
+
command = ["pylint"]
|
|
12
|
+
command.extend([str(path) for path in source_paths])
|
|
13
|
+
return command
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ._tool_check import ToolCheck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PyrightCheck(ToolCheck):
|
|
8
|
+
name: ClassVar[str] = "pyright"
|
|
9
|
+
|
|
10
|
+
def _build_command(self, source_paths: list[Path], fix: bool) -> list[str]:
|
|
11
|
+
command = ["pyright"]
|
|
12
|
+
command.extend([str(path) for path in source_paths])
|
|
13
|
+
return command
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ._tool_check import ToolCheck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PytestCheck(ToolCheck):
|
|
8
|
+
name: ClassVar[str] = "pytest"
|
|
9
|
+
|
|
10
|
+
def _build_command(self, source_paths: list[Path], fix: bool) -> list[str]:
|
|
11
|
+
command = ["pytest"]
|
|
12
|
+
command.extend([str(path) for path in source_paths])
|
|
13
|
+
return command
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from ._tool_check import ToolCheck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RuffCheck(ToolCheck):
|
|
8
|
+
name: ClassVar[str] = "ruff"
|
|
9
|
+
|
|
10
|
+
def _build_command(self, source_paths: list[Path], fix: bool) -> list[str]:
|
|
11
|
+
command = ["ruff", "check"]
|
|
12
|
+
if fix:
|
|
13
|
+
command.append("--fix")
|
|
14
|
+
command.extend([str(path) for path in source_paths])
|
|
15
|
+
return command
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from cleo.io.io import IO
|
|
8
|
+
|
|
9
|
+
from ps.plugin.module.check._check import ICheck
|
|
10
|
+
from ps.di import DI
|
|
11
|
+
from ps.plugin.sdk.project import Environment, Project
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _collect_source_paths(projects: list[Project]) -> list[Path]:
|
|
15
|
+
all_paths = sorted(
|
|
16
|
+
{project.path.parent for project in projects},
|
|
17
|
+
key=lambda p: len(p.parts),
|
|
18
|
+
)
|
|
19
|
+
source_paths: list[Path] = []
|
|
20
|
+
for path in all_paths:
|
|
21
|
+
if not any(
|
|
22
|
+
path != parent and path.is_relative_to(parent)
|
|
23
|
+
for parent in source_paths
|
|
24
|
+
):
|
|
25
|
+
source_paths.append(path)
|
|
26
|
+
return source_paths
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolCheck(ICheck):
|
|
30
|
+
def __init__(self, di: DI) -> None:
|
|
31
|
+
self._di = di
|
|
32
|
+
|
|
33
|
+
def can_check(self, projects: list[Project]) -> bool:
|
|
34
|
+
return shutil.which(self.name) is not None
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def _build_command(self, source_paths: list[Path], fix: bool) -> list[str]: ...
|
|
38
|
+
|
|
39
|
+
def check(self, io: IO, projects: list[Project], fix: bool) -> Optional[Exception]:
|
|
40
|
+
environment = self._di.resolve(Environment)
|
|
41
|
+
assert environment is not None
|
|
42
|
+
|
|
43
|
+
source_paths = _collect_source_paths(projects)
|
|
44
|
+
if not source_paths:
|
|
45
|
+
io.write_line("No source paths found.")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
command = self._build_command(source_paths, fix)
|
|
49
|
+
io.write_line(f"Running command: {' '.join(command)}")
|
|
50
|
+
|
|
51
|
+
cwd = environment.host_project.path.parent
|
|
52
|
+
process = subprocess.Popen( # noqa: S603
|
|
53
|
+
command,
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.STDOUT,
|
|
56
|
+
text=True,
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
cwd=cwd,
|
|
59
|
+
)
|
|
60
|
+
if process.stdout:
|
|
61
|
+
for line in iter(process.stdout.readline, ""):
|
|
62
|
+
io.write(line)
|
|
63
|
+
process.wait()
|
|
64
|
+
if process.returncode != 0:
|
|
65
|
+
return Exception(f"{self.name.capitalize()} check failed with exit code {process.returncode}")
|
|
66
|
+
return None
|