pyprojkit 0.1.0__py3-none-any.whl

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,170 @@
1
+ """
2
+ Configurations for all supported tools.
3
+
4
+ Formatters are tools which additionally run as part of the `format` task.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any, ClassVar
11
+
12
+ from .. import _paths
13
+ from .base import BaseFormatterConfig, BaseToolConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from .project import ProjectConfig
17
+
18
+ __all__ = [
19
+ "AutoflakeConfig",
20
+ "IsortConfig",
21
+ "BlackConfig",
22
+ "DocformatterConfig",
23
+ "TomlSortConfig",
24
+ "PytestConfig",
25
+ "CoverageRunConfig",
26
+ "CoverageReportConfig",
27
+ "DoitConfig",
28
+ "NoxConfig",
29
+ "MypyConfig",
30
+ ]
31
+
32
+
33
+ # --- formatters (run order defined by FormattingConfig) ---
34
+
35
+
36
+ @dataclass(kw_only=True)
37
+ class AutoflakeConfig(BaseFormatterConfig):
38
+ table_path: ClassVar[str] = "tool.autoflake"
39
+
40
+ in_place: bool | None = True
41
+ recursive: bool | None = True
42
+ remove_all_unused_imports: bool | None = field(
43
+ default=True, metadata={"toml": "remove-all-unused-imports"}
44
+ )
45
+ remove_unused_variables: bool | None = field(
46
+ default=True, metadata={"toml": "remove-unused-variables"}
47
+ )
48
+
49
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
50
+ return ["autoflake"] + py_paths
51
+
52
+
53
+ @dataclass(kw_only=True)
54
+ class IsortConfig(BaseFormatterConfig):
55
+ table_path: ClassVar[str] = "tool.isort"
56
+
57
+ profile: str | None = "black"
58
+ quiet: bool | None = True
59
+
60
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
61
+ return ["isort"] + py_paths
62
+
63
+
64
+ @dataclass(kw_only=True)
65
+ class BlackConfig(BaseFormatterConfig):
66
+ table_path: ClassVar[str] = "tool.black"
67
+
68
+ quiet: bool | None = True
69
+
70
+ def extra_toml(self, project: ProjectConfig) -> dict[str, Any]:
71
+ return {"target-version": project.python.black_targets}
72
+
73
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
74
+ return ["black"] + py_paths
75
+
76
+
77
+ @dataclass(kw_only=True)
78
+ class DocformatterConfig(BaseFormatterConfig):
79
+ table_path: ClassVar[str] = "tool.docformatter"
80
+
81
+ # docformatter returns 3 when files were changed
82
+ expect_rc: ClassVar[frozenset[int]] = frozenset({0, 3})
83
+
84
+ black: bool | None = True
85
+ in_place: bool | None = field(default=True, metadata={"toml": "in-place"})
86
+ make_summary_multi_line: bool | None = field(
87
+ default=True, metadata={"toml": "make-summary-multi-line"}
88
+ )
89
+ non_strict: bool | None = field(default=True, metadata={"toml": "non-strict"})
90
+ pre_summary_newline: bool | None = field(
91
+ default=True, metadata={"toml": "pre-summary-newline"}
92
+ )
93
+ recursive: bool | None = True
94
+
95
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
96
+ return ["docformatter"] + py_paths
97
+
98
+
99
+ @dataclass(kw_only=True)
100
+ class TomlSortConfig(BaseFormatterConfig):
101
+ table_path: ClassVar[str] = "tool.tomlsort"
102
+
103
+ sort_first: tuple[str, ...] | None = (
104
+ "project",
105
+ "dependency-groups",
106
+ "build-system",
107
+ )
108
+ sort_table_keys: bool | None = True
109
+
110
+ # explicit since the toml-sort CLI and library defaults disagree; must be
111
+ # pinned so the sync engine's normalization and the format task agree
112
+ spaces_before_inline_comment: int | None = 2
113
+
114
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
115
+ return ["toml-sort", "-i"] + toml_paths
116
+
117
+
118
+ # --- non-formatter tools ---
119
+
120
+
121
+ @dataclass(kw_only=True)
122
+ class PytestConfig(BaseToolConfig):
123
+ table_path: ClassVar[str] = "tool.pytest.ini_options"
124
+
125
+ addopts: str | None = "--import-mode=importlib -s -v -rA"
126
+ cache_dir: str | None = str(_paths.PYTEST_CACHE_PATH)
127
+ testpaths: str | None = "test"
128
+
129
+
130
+ @dataclass(kw_only=True)
131
+ class CoverageRunConfig(BaseToolConfig):
132
+ table_path: ClassVar[str] = "tool.coverage.run"
133
+
134
+ data_file: str | None = str(_paths.COVERAGE_DATA_PATH)
135
+
136
+
137
+ @dataclass(kw_only=True)
138
+ class CoverageReportConfig(BaseToolConfig):
139
+ table_path: ClassVar[str] = "tool.coverage.report"
140
+
141
+ exclude_lines: tuple[str, ...] | None = (
142
+ "if TYPE_CHECKING:",
143
+ "\\.\\.\\.$",
144
+ )
145
+
146
+
147
+ @dataclass(kw_only=True)
148
+ class DoitConfig(BaseToolConfig):
149
+ table_path: ClassVar[str] = "tool.doit"
150
+
151
+ dep_file: str | None = str(_paths.DOIT_DB_PATH)
152
+ verbosity: int | None = 2
153
+
154
+
155
+ @dataclass(kw_only=True)
156
+ class NoxConfig(BaseToolConfig):
157
+ table_path: ClassVar[str] = "tool.nox"
158
+
159
+ default_venv_backend: str | None = "uv"
160
+ envdir: str | None = str(_paths.NOX_ENVDIR_PATH)
161
+
162
+
163
+ @dataclass(kw_only=True)
164
+ class MypyConfig(BaseToolConfig):
165
+ table_path: ClassVar[str] = "tool.mypy"
166
+
167
+ ignore_missing_imports: bool | None = True
168
+
169
+ def extra_toml(self, project: ProjectConfig) -> dict[str, Any]:
170
+ return {"python_version": project.python.mypy_version}
pyprojkit/discovery.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ Discovery of a project's `pyprojconf.py`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib.util
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .config.base import ConfigError
12
+ from .config.project import ProjectConfig
13
+
14
+ __all__ = [
15
+ "load_config",
16
+ ]
17
+
18
+ CONF_FILENAME = "pyprojconf.py"
19
+
20
+
21
+ def load_config(root: Path | str | None = None) -> ProjectConfig:
22
+ """
23
+ Load `pyprojconf.py` from the given project root (defaulting to the current
24
+ directory) and return its `config`.
25
+ """
26
+ root_path = Path(root) if root else Path.cwd()
27
+ conf_path = root_path / CONF_FILENAME
28
+
29
+ if not conf_path.is_file():
30
+ raise ConfigError(f"'{conf_path}' not found")
31
+
32
+ module_name = f"_pyprojconf_{abs(hash(str(conf_path.resolve())))}"
33
+ spec = importlib.util.spec_from_file_location(module_name, conf_path)
34
+ assert spec and spec.loader
35
+
36
+ module = importlib.util.module_from_spec(spec)
37
+ sys.modules[module_name] = module
38
+ try:
39
+ spec.loader.exec_module(module)
40
+ except Exception as exc:
41
+ raise ConfigError(f"Failed to import '{conf_path}': {exc}") from exc
42
+ finally:
43
+ sys.modules.pop(module_name, None)
44
+
45
+ config = getattr(module, "config", None)
46
+ if not isinstance(config, ProjectConfig):
47
+ raise ConfigError(
48
+ f"'{conf_path}' must define a module-level `config: ProjectConfig`"
49
+ )
50
+
51
+ return config
pyprojkit/sessions.py ADDED
@@ -0,0 +1,71 @@
1
+ """
2
+ `nox` session factories.
3
+
4
+ Usage in a project's `noxfile.py`:
5
+
6
+ ```python
7
+ from pyprojkit import NoxFactory
8
+
9
+ test = NoxFactory().create_test_session()
10
+ ```
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ import nox
19
+ from nox import Session
20
+
21
+ from .config.base import ConfigError
22
+ from .config.project import ProjectConfig
23
+ from .discovery import load_config
24
+
25
+ __all__ = [
26
+ "NoxFactory",
27
+ ]
28
+
29
+
30
+ class NoxFactory:
31
+ """
32
+ Factory for `nox` sessions, configured by the project's `pyprojconf.py`.
33
+
34
+ Sessions are registered with nox upon creation, so `create_*_session()` must be
35
+ called at `noxfile.py` import time.
36
+ """
37
+
38
+ _config: ProjectConfig
39
+ _registered: set[str]
40
+
41
+ def __init__(
42
+ self,
43
+ config: ProjectConfig | None = None,
44
+ root: Path | str | None = None,
45
+ ):
46
+ self._config = config or load_config(root)
47
+ self._registered = set()
48
+ nox.options.envdir = self._config.tools.nox.envdir
49
+
50
+ def create_test_session(self, *, group: str = "dev") -> Callable[[Session], None]:
51
+ """
52
+ Create and register `test` session which runs pytest over all supported Python
53
+ versions, installing dependencies from the given dependency group via uv.
54
+ """
55
+ if "test" in self._registered:
56
+ raise ConfigError("Session 'test' already created")
57
+ self._registered.add("test")
58
+
59
+ @nox.session(python=self._config.python.pins, name="test")
60
+ def test(session: Session):
61
+ session.run_install(
62
+ "uv",
63
+ "sync",
64
+ f"--group={group}",
65
+ "--frozen",
66
+ f"--python={session.python}", # explicitly pin the version
67
+ env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
68
+ )
69
+ session.run("pytest", *session.posargs)
70
+
71
+ return test
pyprojkit/sync.py ADDED
@@ -0,0 +1,243 @@
1
+ """
2
+ Sync engine: writes managed parts of `pyproject.toml` from a project's `pyprojconf.py`.
3
+
4
+ Managed content:
5
+
6
+ - `project.requires-python`
7
+ - Python version classifiers (`Programming Language :: Python :: 3[.X]`);
8
+ other classifiers are left untouched
9
+ - One `[tool.X]` table per enabled tool (fully owned — any hand edits or
10
+ comments inside are overwritten)
11
+ - `[tool.pyprojkit].managed`: bookkeeping list of owned tables, enabling safe
12
+ removal of tables for tools dropped from the configuration
13
+
14
+ Everything else (dependencies, build-system, urls, unmanaged tool tables,
15
+ etc.) is preserved. Output is normalized with toml-sort (as a library, using
16
+ the same settings as the managed `[tool.tomlsort]` table), so a subsequent
17
+ `toml-sort` run in the format task is a no-op.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import difflib
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ import tomlkit
28
+ from tomlkit import TOMLDocument
29
+ from tomlkit.items import Table
30
+
31
+ from .config.base import ConfigError
32
+ from .config.project import ProjectConfig
33
+ from .config.tools import TomlSortConfig
34
+
35
+ __all__ = [
36
+ "compute_managed_tables",
37
+ "render",
38
+ "sync",
39
+ ]
40
+
41
+ _CLASSIFIER_RE = re.compile(r"^Programming Language :: Python :: \d+(\.\d+)?$")
42
+
43
+ _MANAGED_COMMENT = "managed by pyprojkit"
44
+
45
+
46
+ def compute_managed_tables(config: ProjectConfig) -> dict[str, dict[str, Any]]:
47
+ """
48
+ Compute contents of all managed tables, keyed by dotted table path.
49
+ """
50
+ tables: dict[str, dict[str, Any]] = {}
51
+ tools = config.tools
52
+
53
+ for formatter in tools.formatting.formatters:
54
+ tables[formatter.table_path] = formatter.to_toml(config)
55
+
56
+ if test := tools.test:
57
+ for tool in (test.pytest, test.coverage_run, test.coverage_report):
58
+ tables[tool.table_path] = tool.to_toml(config)
59
+
60
+ tables[tools.doit.table_path] = tools.doit.to_toml(config)
61
+ tables[tools.nox.table_path] = tools.nox.to_toml(config)
62
+
63
+ if (analysis := tools.analysis) and analysis.mypy:
64
+ tables[analysis.mypy.table_path] = analysis.mypy.to_toml(config)
65
+
66
+ # merge escape-hatch overrides last; unknown paths become managed tables
67
+ for path, overrides in tools.tool_overrides.items():
68
+ tables.setdefault(path, {}).update(overrides)
69
+
70
+ return tables
71
+
72
+
73
+ def render(config: ProjectConfig, text: str) -> str:
74
+ """
75
+ Render the synced `pyproject.toml` contents from existing contents.
76
+ """
77
+ doc = tomlkit.parse(text)
78
+
79
+ project = doc.get("project")
80
+ if project is None:
81
+ raise ConfigError("pyproject.toml has no [project] table")
82
+
83
+ project["requires-python"] = config.python.requires_python
84
+ project["requires-python"].comment(_MANAGED_COMMENT)
85
+ _update_classifiers(project, config)
86
+
87
+ tables = compute_managed_tables(config)
88
+
89
+ prev_managed = _get_managed_list(doc)
90
+ for path in prev_managed:
91
+ if path not in tables:
92
+ _delete_table(doc, path)
93
+
94
+ for path, content in tables.items():
95
+ _set_table(doc, path, content)
96
+
97
+ _set_table(doc, "tool.pyprojkit", {"managed": sorted(tables)})
98
+
99
+ return _normalize(config, tomlkit.dumps(doc))
100
+
101
+
102
+ def sync(
103
+ config: ProjectConfig,
104
+ root: Path | str | None = None,
105
+ *,
106
+ check: bool = False,
107
+ ) -> bool:
108
+ """
109
+ Sync `pyproject.toml` under the given project root (defaulting to the current
110
+ directory).
111
+
112
+ In check mode, nothing is written; prints a diff and returns `False` if out of sync.
113
+ In write mode, returns `True` (having updated the file if needed).
114
+ """
115
+ path = (Path(root) if root else Path.cwd()) / "pyproject.toml"
116
+ if not path.is_file():
117
+ raise ConfigError(f"'{path}' not found")
118
+
119
+ old = path.read_text()
120
+ new = render(config, old)
121
+
122
+ if old == new:
123
+ return True
124
+
125
+ if check:
126
+ diff = difflib.unified_diff(
127
+ old.splitlines(keepends=True),
128
+ new.splitlines(keepends=True),
129
+ fromfile="pyproject.toml (on disk)",
130
+ tofile="pyproject.toml (synced)",
131
+ )
132
+ print("".join(diff), end="")
133
+ return False
134
+
135
+ path.write_text(new)
136
+ return True
137
+
138
+
139
+ def _update_classifiers(project: Table, config: ProjectConfig):
140
+ """
141
+ Replace python version classifiers with those derived from config, preserving all
142
+ others; result is sorted, with managed entries marked by an inline comment.
143
+ """
144
+ existing = cast(list[str], [str(c) for c in project.get("classifiers", [])])
145
+ kept = [c for c in existing if not _CLASSIFIER_RE.match(c)]
146
+ managed = set(config.python.classifiers)
147
+
148
+ array = tomlkit.array()
149
+ for entry in sorted(set(kept) | managed):
150
+ array.add_line(
151
+ entry,
152
+ indent=" ",
153
+ comment=_MANAGED_COMMENT if entry in managed else None,
154
+ )
155
+ array.add_line(indent="")
156
+ project["classifiers"] = array
157
+
158
+
159
+ def _get_managed_list(doc: TOMLDocument) -> list[str]:
160
+ try:
161
+ return list(doc["tool"]["pyprojkit"]["managed"]) # type: ignore[index]
162
+ except (KeyError, TypeError):
163
+ return []
164
+
165
+
166
+ def _set_table(doc: TOMLDocument, path: str, content: dict[str, Any]):
167
+ parts = path.split(".")
168
+ container: Any = doc
169
+ for part in parts[:-1]:
170
+ if part in container:
171
+ container = container[part]
172
+ else:
173
+ table = tomlkit.table(is_super_table=True)
174
+ container[part] = table
175
+ container = table
176
+
177
+ leaf = tomlkit.table()
178
+ leaf.comment(_MANAGED_COMMENT)
179
+ for key, value in content.items():
180
+ leaf[key] = _to_item(value)
181
+ container[parts[-1]] = leaf
182
+
183
+
184
+ def _delete_table(doc: TOMLDocument, path: str):
185
+ parts = path.split(".")
186
+
187
+ # walk to leaf's parent
188
+ containers: list[Any] = [doc]
189
+ for part in parts[:-1]:
190
+ container = containers[-1].get(part)
191
+ if container is None:
192
+ return
193
+ containers.append(container)
194
+
195
+ if parts[-1] not in containers[-1]:
196
+ return
197
+ del containers[-1][parts[-1]]
198
+
199
+ # prune emptied parents (but never the document itself)
200
+ for i in range(len(containers) - 1, 0, -1):
201
+ if len(containers[i]) == 0:
202
+ del containers[i - 1][parts[i - 1]]
203
+
204
+
205
+ def _to_item(value: Any) -> Any:
206
+ if isinstance(value, (list, tuple)):
207
+ array = tomlkit.array()
208
+ array.extend(value)
209
+ if len(array) >= 2:
210
+ array.multiline(True)
211
+ return array
212
+ return value
213
+
214
+
215
+ def _normalize(config: ProjectConfig, text: str) -> str:
216
+ """
217
+ Normalize with toml-sort as a library, driven by the project's `TomlSortConfig`
218
+ (skipped if toml-sort is not among the formatters).
219
+ """
220
+ tomlsort_config = next(
221
+ (
222
+ f
223
+ for f in config.tools.formatting.formatters
224
+ if isinstance(f, TomlSortConfig)
225
+ ),
226
+ None,
227
+ )
228
+ if tomlsort_config is None:
229
+ return text
230
+
231
+ from toml_sort import TomlSort
232
+ from toml_sort.tomlsort import FormattingConfiguration, SortConfiguration
233
+
234
+ sort_config = SortConfiguration(
235
+ table_keys=bool(tomlsort_config.sort_table_keys),
236
+ first=list(tomlsort_config.sort_first or []),
237
+ )
238
+ format_config = FormattingConfiguration(
239
+ spaces_before_inline_comment=tomlsort_config.spaces_before_inline_comment or 1,
240
+ )
241
+ return TomlSort(
242
+ input_toml=text, sort_config=sort_config, format_config=format_config
243
+ ).sorted()