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.
@@ -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,7 @@
1
+ from ._check_module import CheckModule
2
+ from ._check import ICheck
3
+
4
+ __all__ = [
5
+ "CheckModule",
6
+ "ICheck",
7
+ ]
@@ -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,6 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class CheckSettings(BaseModel):
6
+ checks: Optional[list[str]] = Field(default=None, alias="checks")
@@ -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