pyprojkit 0.3.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,219 @@
1
+ """
2
+ Project-wide configuration, declared in a project's `pyprojconf.py`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Sequence
10
+
11
+ from .. import _paths
12
+ from ..versions import PythonVersions
13
+ from .base import BaseFormatterConfig
14
+ from .tools import (
15
+ CoverageReportConfig,
16
+ CoverageRunConfig,
17
+ DoitConfig,
18
+ MypyConfig,
19
+ NoxConfig,
20
+ PytestConfig,
21
+ )
22
+
23
+ __all__ = [
24
+ "ProjectConfig",
25
+ "ToolsConfig",
26
+ "FormattingConfig",
27
+ "TestConfig",
28
+ "DocConfig",
29
+ "MkinitConfig",
30
+ "SphinxConfig",
31
+ "AnalysisConfig",
32
+ "PublishConfig",
33
+ ]
34
+
35
+
36
+ @dataclass(kw_only=True)
37
+ class FormattingConfig:
38
+ """
39
+ Formatting configuration: which formatters run (in order) and their settings.
40
+ """
41
+
42
+ formatters: tuple[BaseFormatterConfig, ...]
43
+ """
44
+ Formatters in run order.
45
+ """
46
+
47
+ @classmethod
48
+ def default(cls) -> FormattingConfig:
49
+ """
50
+ Get the default formatting profile.
51
+ """
52
+ from .profiles import get_formatting_profile
53
+
54
+ return get_formatting_profile("default")
55
+
56
+
57
+ @dataclass(kw_only=True)
58
+ class TestConfig:
59
+ """
60
+ Testing configuration (pytest + coverage).
61
+ """
62
+
63
+ pytest: PytestConfig = field(default_factory=PytestConfig)
64
+ coverage_run: CoverageRunConfig = field(default_factory=CoverageRunConfig)
65
+ coverage_report: CoverageReportConfig = field(default_factory=CoverageReportConfig)
66
+
67
+
68
+ @dataclass(kw_only=True)
69
+ class MkinitConfig:
70
+ """
71
+ Configuration for generating `__init__.py` files using mkinit.
72
+ """
73
+
74
+ args: tuple[str, ...] = (
75
+ "--recursive",
76
+ "--nomods",
77
+ "--relative",
78
+ "-i",
79
+ "--source-order",
80
+ )
81
+ """
82
+ Arguments passed to mkinit.
83
+ """
84
+
85
+
86
+ @dataclass(kw_only=True)
87
+ class SphinxConfig:
88
+ """
89
+ Configuration for building documentation using sphinx.
90
+ """
91
+
92
+ source_dir: str = "doc"
93
+ """
94
+ Directory containing sphinx sources.
95
+ """
96
+
97
+ copy_env_var: str | None = None
98
+ """
99
+ Name of environment variable (or `.env` entry) giving a directory to which to copy
100
+ built documentation when the doc task is run with `--copy`.
101
+ """
102
+
103
+
104
+ @dataclass(kw_only=True)
105
+ class DocConfig:
106
+ """
107
+ Documentation tool configurations.
108
+ """
109
+
110
+ mkinit: MkinitConfig | None = field(default_factory=MkinitConfig)
111
+ """
112
+ Enables the `init` task; enabled by default.
113
+ """
114
+
115
+ sphinx: SphinxConfig | None = None
116
+ """
117
+ Enables the `doc` task; opt-in.
118
+ """
119
+
120
+
121
+ @dataclass(kw_only=True)
122
+ class AnalysisConfig:
123
+ """
124
+ Static analysis configuration.
125
+ """
126
+
127
+ mypy: MypyConfig | None = field(default_factory=MypyConfig)
128
+ pyright: bool = True
129
+
130
+
131
+ @dataclass(kw_only=True)
132
+ class PublishConfig:
133
+ """
134
+ Configuration for building and publishing via uv.
135
+ """
136
+
137
+ out_dir: str = str(_paths.UV_PATH)
138
+ """
139
+ Directory for build artifacts; cleaned before each build so stale artifacts are
140
+ never published.
141
+ """
142
+
143
+
144
+ @dataclass(kw_only=True)
145
+ class ToolsConfig:
146
+ """
147
+ Development tool configurations, grouped by broad tool category.
148
+
149
+ A "tools profile" is simply a pre-canned `ToolsConfig` factory; see
150
+ `pyprojkit.config.profiles`. The default instance corresponds to the default
151
+ profile.
152
+ """
153
+
154
+ formatting: FormattingConfig = field(default_factory=FormattingConfig.default)
155
+ test: TestConfig | None = field(default_factory=TestConfig)
156
+ doit: DoitConfig = field(default_factory=DoitConfig)
157
+ nox: NoxConfig = field(default_factory=NoxConfig)
158
+ doc: DocConfig = field(default_factory=DocConfig)
159
+ analysis: AnalysisConfig | None = None
160
+ publish: PublishConfig = field(default_factory=PublishConfig)
161
+
162
+ tool_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
163
+ """
164
+ Escape hatch: extra entries merged last into managed tables, keyed by table path,
165
+ e.g. `{"tool.pytest.ini_options": {"addopts": "..."}}`.
166
+
167
+ A key not otherwise managed creates a new managed table.
168
+ """
169
+
170
+ @classmethod
171
+ def default(cls) -> ToolsConfig:
172
+ """
173
+ Get the default tools profile.
174
+ """
175
+ from .profiles import get_tools_profile
176
+
177
+ return get_tools_profile("default")
178
+
179
+
180
+ @dataclass(kw_only=True)
181
+ class ProjectConfig:
182
+ """
183
+ Top-level project configuration.
184
+
185
+ A project's `pyprojconf.py` must define a module-level instance named `config`.
186
+ """
187
+
188
+ package: str
189
+ """
190
+ Import name of the package, e.g. `"trilium_alchemy"`.
191
+ """
192
+
193
+ python: PythonVersions
194
+ """
195
+ Supported Python versions.
196
+ """
197
+
198
+ packages_dir: str = "src"
199
+ """
200
+ Directory containing packages, relative to the project root.
201
+ """
202
+
203
+ format_paths: Sequence[str] = ("src", "test", "doc", "examples")
204
+ """
205
+ Directories to format (those which exist), in addition to `*.py` and `*.toml` files
206
+ at the project root.
207
+ """
208
+
209
+ tools: ToolsConfig = field(default_factory=ToolsConfig.default)
210
+ """
211
+ Tool configurations.
212
+ """
213
+
214
+ @property
215
+ def package_path(self) -> Path:
216
+ """
217
+ Path to the package directory, relative to the project root.
218
+ """
219
+ return Path(self.packages_dir) / self.package
@@ -0,0 +1,168 @@
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
+ "DocfmtConfig",
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 DocfmtConfig(BaseFormatterConfig):
79
+ table_path: ClassVar[str] = "tool.docfmt"
80
+
81
+ # docfmt returns 0 when it writes changes in-place; 1 means "would change"
82
+ # in check mode and 2 is an error, so only 0 is expected here
83
+ expect_rc: ClassVar[frozenset[int]] = frozenset({0})
84
+
85
+ # line length is left unset: docfmt picks it up from `[tool.black]`
86
+ in_place: bool | None = field(default=True, metadata={"toml": "in-place"})
87
+ recursive: bool | None = True
88
+ summary_on_own_line: bool | None = field(
89
+ default=True, metadata={"toml": "summary-on-own-line"}
90
+ )
91
+ force_reflow: bool | None = field(default=True, metadata={"toml": "force-reflow"})
92
+
93
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
94
+ return ["docfmt"] + py_paths
95
+
96
+
97
+ @dataclass(kw_only=True)
98
+ class TomlSortConfig(BaseFormatterConfig):
99
+ table_path: ClassVar[str] = "tool.tomlsort"
100
+
101
+ sort_first: tuple[str, ...] | None = (
102
+ "project",
103
+ "dependency-groups",
104
+ "build-system",
105
+ )
106
+ sort_table_keys: bool | None = True
107
+
108
+ # explicit since the toml-sort CLI and library defaults disagree; must be
109
+ # pinned so the sync engine's normalization and the format task agree
110
+ spaces_before_inline_comment: int | None = 2
111
+
112
+ def command(self, py_paths: list[str], toml_paths: list[str]) -> list[str]:
113
+ return ["toml-sort", "-i"] + toml_paths
114
+
115
+
116
+ # --- non-formatter tools ---
117
+
118
+
119
+ @dataclass(kw_only=True)
120
+ class PytestConfig(BaseToolConfig):
121
+ table_path: ClassVar[str] = "tool.pytest.ini_options"
122
+
123
+ addopts: str | None = "--import-mode=importlib -s -v -rA"
124
+ cache_dir: str | None = str(_paths.PYTEST_CACHE_PATH)
125
+ testpaths: str | None = "test"
126
+
127
+
128
+ @dataclass(kw_only=True)
129
+ class CoverageRunConfig(BaseToolConfig):
130
+ table_path: ClassVar[str] = "tool.coverage.run"
131
+
132
+ data_file: str | None = str(_paths.COVERAGE_DATA_PATH)
133
+
134
+
135
+ @dataclass(kw_only=True)
136
+ class CoverageReportConfig(BaseToolConfig):
137
+ table_path: ClassVar[str] = "tool.coverage.report"
138
+
139
+ exclude_lines: tuple[str, ...] | None = (
140
+ "if TYPE_CHECKING:",
141
+ "\\.\\.\\.$",
142
+ )
143
+
144
+
145
+ @dataclass(kw_only=True)
146
+ class DoitConfig(BaseToolConfig):
147
+ table_path: ClassVar[str] = "tool.doit"
148
+
149
+ dep_file: str | None = str(_paths.DOIT_DB_PATH)
150
+ verbosity: int | None = 2
151
+
152
+
153
+ @dataclass(kw_only=True)
154
+ class NoxConfig(BaseToolConfig):
155
+ table_path: ClassVar[str] = "tool.nox"
156
+
157
+ default_venv_backend: str | None = "uv"
158
+ envdir: str | None = str(_paths.NOX_ENVDIR_PATH)
159
+
160
+
161
+ @dataclass(kw_only=True)
162
+ class MypyConfig(BaseToolConfig):
163
+ table_path: ClassVar[str] = "tool.mypy"
164
+
165
+ ignore_missing_imports: bool | None = True
166
+
167
+ def extra_toml(self, project: ProjectConfig) -> dict[str, Any]:
168
+ 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