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.
pyprojkit/tasks.py ADDED
@@ -0,0 +1,375 @@
1
+ """
2
+ `doit` task factories.
3
+
4
+ Usage in a project's `dodo.py`:
5
+
6
+ ```python
7
+ from pyprojkit import TaskFactory
8
+
9
+ factory = TaskFactory() # auto-loads ./pyprojconf.py
10
+
11
+ task_sync = factory.create_sync_task()
12
+ task_format = factory.create_format_task()
13
+ task_test = factory.create_test_task()
14
+ task_badges = factory.create_badges_task()
15
+ task_publish = factory.create_publish_task()
16
+ ```
17
+
18
+ or equivalently: `globals().update(factory.create_all_tasks())`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ from pathlib import Path
26
+ from typing import Callable
27
+
28
+ from doit import task_params
29
+ from doit.task import Task
30
+ from doit.tools import create_folder
31
+
32
+ from . import _paths
33
+ from ._run import cleanup_dir, run
34
+ from .config.base import ConfigError
35
+ from .config.project import ProjectConfig
36
+ from .discovery import load_config
37
+ from .sync import sync
38
+
39
+ __all__ = [
40
+ "TaskFactory",
41
+ ]
42
+
43
+ TaskCreator = Callable[..., Task]
44
+
45
+
46
+ class TaskFactory:
47
+ """
48
+ Factory for `doit` tasks, configured by the project's `pyprojconf.py`.
49
+ """
50
+
51
+ _config: ProjectConfig
52
+ _root: Path
53
+
54
+ def __init__(
55
+ self,
56
+ config: ProjectConfig | None = None,
57
+ root: Path | str | None = None,
58
+ ):
59
+ self._root = Path(root) if root else Path.cwd()
60
+ self._config = config or load_config(self._root)
61
+ _paths.CACHE_PATH.mkdir(parents=True, exist_ok=True)
62
+
63
+ def create_sync_task(self) -> TaskCreator:
64
+ """
65
+ Create `sync` task which updates `pyproject.toml` from `pyprojconf.py`.
66
+ """
67
+ config, root = self._config, self._root
68
+
69
+ def task_sync() -> Task:
70
+ return Task(
71
+ "sync",
72
+ actions=[(sync, (config, root))],
73
+ targets=[],
74
+ file_dep=[],
75
+ doc="Sync pyproject.toml from pyprojconf.py",
76
+ )
77
+
78
+ return task_sync
79
+
80
+ def create_format_task(self) -> TaskCreator:
81
+ """
82
+ Create `format` task which runs the configured formatters.
83
+ """
84
+ config, root = self._config, self._root
85
+
86
+ def task_format() -> Task:
87
+ # aggregate files to format
88
+ py_paths = [str(p) for p in sorted(root.glob("*.py"))] + [
89
+ path for path in config.format_paths if (root / path).is_dir()
90
+ ]
91
+ toml_paths = [str(p) for p in sorted(root.glob("*.toml"))]
92
+
93
+ actions = [
94
+ (
95
+ run,
96
+ (formatter.command(py_paths, toml_paths), set(formatter.expect_rc)),
97
+ )
98
+ for formatter in config.tools.formatting.formatters
99
+ ]
100
+
101
+ return Task(
102
+ "format",
103
+ actions=actions,
104
+ targets=[],
105
+ file_dep=[],
106
+ doc="Run formatters",
107
+ )
108
+
109
+ return task_format
110
+
111
+ def create_test_task(self) -> TaskCreator:
112
+ """
113
+ Create `test` task which runs pytest and generates coverage reports.
114
+ """
115
+ self._require(self._config.tools.test, "tools.test")
116
+
117
+ def task_test() -> Task:
118
+ args = [
119
+ "pytest",
120
+ f"--cov={self._config.package}",
121
+ f"--cov-report=html:{_paths.COV_HTML_PATH}",
122
+ f"--cov-report=xml:{_paths.COV_XML_PATH}",
123
+ f"--junitxml={_paths.JUNIT_PATH}",
124
+ ]
125
+
126
+ return Task(
127
+ "test",
128
+ actions=[
129
+ (create_folder, [_paths.COV_PATH]),
130
+ (run, (args,)),
131
+ ],
132
+ targets=[
133
+ f"{_paths.COV_HTML_PATH}/index.html",
134
+ str(_paths.COV_XML_PATH),
135
+ str(_paths.JUNIT_PATH),
136
+ ],
137
+ file_dep=[],
138
+ clean=[(cleanup_dir, [_paths.TESTS_PATH])],
139
+ doc="Run pytest and generate coverage reports",
140
+ )
141
+
142
+ return task_test
143
+
144
+ def create_badges_task(self) -> TaskCreator:
145
+ """
146
+ Create `badges` task which generates badges from test results.
147
+ """
148
+ self._require(self._config.tools.test, "tools.test")
149
+
150
+ def task_badges() -> Task:
151
+ tests_args = [
152
+ "genbadge",
153
+ "tests",
154
+ "-i",
155
+ str(_paths.JUNIT_PATH),
156
+ "-o",
157
+ str(_paths.PYTEST_BADGE_PATH),
158
+ ]
159
+
160
+ cov_args = [
161
+ "genbadge",
162
+ "coverage",
163
+ "-i",
164
+ str(_paths.COV_XML_PATH),
165
+ "-o",
166
+ str(_paths.COV_BADGE_PATH),
167
+ ]
168
+
169
+ return Task(
170
+ "badges",
171
+ actions=[
172
+ (create_folder, [_paths.BADGES_PATH]),
173
+ (run, (tests_args,)),
174
+ (run, (cov_args,)),
175
+ ],
176
+ targets=[
177
+ str(_paths.PYTEST_BADGE_PATH),
178
+ str(_paths.COV_BADGE_PATH),
179
+ ],
180
+ file_dep=[
181
+ str(_paths.JUNIT_PATH),
182
+ str(_paths.COV_XML_PATH),
183
+ ],
184
+ doc="Generate badges from test results",
185
+ )
186
+
187
+ return task_badges
188
+
189
+ def create_publish_task(self) -> TaskCreator:
190
+ """
191
+ Create `publish` task which builds and publishes the package via uv.
192
+ """
193
+ out_dir = Path(self._config.tools.publish.out_dir)
194
+
195
+ def _publish():
196
+ artifacts = [str(p) for p in sorted(out_dir.iterdir())]
197
+ assert artifacts, f"No build artifacts in {out_dir}"
198
+ run(["uv", "publish", *artifacts])
199
+
200
+ def task_publish() -> Task:
201
+ return Task(
202
+ "publish",
203
+ actions=[
204
+ # clean first so stale artifacts are never published
205
+ (cleanup_dir, [out_dir]),
206
+ (run, (["uv", "build", "--out-dir", str(out_dir)],)),
207
+ (_publish,),
208
+ ],
209
+ targets=[],
210
+ file_dep=[],
211
+ doc="Build and publish package via uv",
212
+ )
213
+
214
+ return task_publish
215
+
216
+ def create_init_task(self) -> TaskCreator:
217
+ """
218
+ Create `init` task which generates `__init__.py` files using mkinit.
219
+ """
220
+ mkinit = self._require(self._config.tools.doc.mkinit, "tools.doc.mkinit")
221
+ package = self._config.package
222
+
223
+ def task_init() -> Task:
224
+ return Task(
225
+ "init",
226
+ actions=[
227
+ (run, (["mkinit", f"src/{package}", *mkinit.args],)),
228
+ ],
229
+ targets=[],
230
+ file_dep=[],
231
+ doc="Generate __init__.py files using mkinit",
232
+ )
233
+
234
+ return task_init
235
+
236
+ def create_doc_task(self) -> TaskCreator:
237
+ """
238
+ Create `doc` task which builds documentation using sphinx.
239
+ """
240
+ sphinx = self._require(self._config.tools.doc.sphinx, "tools.doc.sphinx")
241
+
242
+ def _do_copy(copy: bool):
243
+ if not copy:
244
+ return
245
+
246
+ from dotenv import load_dotenv
247
+
248
+ load_dotenv()
249
+
250
+ assert sphinx.copy_env_var, "No copy_env_var configured"
251
+ dest = os.environ.get(sphinx.copy_env_var)
252
+ assert dest, f"Environment variable {sphinx.copy_env_var} not set"
253
+
254
+ dest_path = Path(dest)
255
+ assert dest_path.is_dir()
256
+
257
+ # clean destination
258
+ for path in dest_path.iterdir():
259
+ if path.is_file():
260
+ path.unlink()
261
+ else:
262
+ shutil.rmtree(path)
263
+
264
+ shutil.copytree(_paths.DOC_HTML_PATH, dest, dirs_exist_ok=True)
265
+
266
+ print(f"\nCopied: {_paths.DOC_HTML_PATH} -> {dest}")
267
+
268
+ @task_params(
269
+ [
270
+ {
271
+ "name": "copy",
272
+ "long": "copy",
273
+ "type": bool,
274
+ "default": False,
275
+ "help": "Copy to output folder after build",
276
+ }
277
+ ]
278
+ )
279
+ def task_doc(copy: bool) -> Task:
280
+ args = [
281
+ "sphinx-build",
282
+ "-T", # show full traceback upon error
283
+ sphinx.source_dir,
284
+ str(_paths.DOC_HTML_PATH),
285
+ ]
286
+
287
+ return Task(
288
+ "doc",
289
+ actions=[
290
+ (create_folder, [_paths.DOC_HTML_PATH]),
291
+ (run, (args,)),
292
+ (_do_copy, (copy,)),
293
+ ],
294
+ targets=[
295
+ f"{_paths.DOC_HTML_PATH}/index.html",
296
+ ],
297
+ file_dep=[],
298
+ clean=[(cleanup_dir, [_paths.DOC_HTML_PATH])],
299
+ doc="Generate documentation",
300
+ )
301
+
302
+ return task_doc
303
+
304
+ def create_analysis_task(self) -> TaskCreator:
305
+ """
306
+ Create `analysis` task which runs static analysis tools.
307
+ """
308
+ analysis = self._require(self._config.tools.analysis, "tools.analysis")
309
+ package = self._config.package
310
+
311
+ def task_analysis() -> Task:
312
+ actions = []
313
+
314
+ if analysis.mypy:
315
+ actions.append(
316
+ (
317
+ run,
318
+ (
319
+ [
320
+ "mypy",
321
+ "--html-report",
322
+ str(_paths.MYPY_HTML_PATH),
323
+ "--cobertura-xml-report",
324
+ str(_paths.MYPY_XML_PATH),
325
+ package,
326
+ ],
327
+ ),
328
+ )
329
+ )
330
+
331
+ if analysis.pyright:
332
+ actions.append((run, (["pyright", package],)))
333
+
334
+ return Task(
335
+ "analysis",
336
+ actions=actions,
337
+ targets=[],
338
+ file_dep=[],
339
+ doc="Run static analysis tools",
340
+ )
341
+
342
+ return task_analysis
343
+
344
+ def create_all_tasks(self) -> dict[str, TaskCreator]:
345
+ """
346
+ Create all tasks enabled by the configuration, keyed by `task_<name>` suitable
347
+ for `globals().update(...)` in `dodo.py`.
348
+ """
349
+ tools = self._config.tools
350
+
351
+ creators: dict[str, TaskCreator] = {
352
+ "task_sync": self.create_sync_task(),
353
+ "task_format": self.create_format_task(),
354
+ "task_publish": self.create_publish_task(),
355
+ }
356
+
357
+ if tools.test:
358
+ creators["task_test"] = self.create_test_task()
359
+ creators["task_badges"] = self.create_badges_task()
360
+
361
+ if tools.doc.mkinit:
362
+ creators["task_init"] = self.create_init_task()
363
+
364
+ if tools.doc.sphinx:
365
+ creators["task_doc"] = self.create_doc_task()
366
+
367
+ if tools.analysis:
368
+ creators["task_analysis"] = self.create_analysis_task()
369
+
370
+ return creators
371
+
372
+ def _require[T](self, value: T | None, name: str) -> T:
373
+ if value is None:
374
+ raise ConfigError(f"Configuration does not set `{name}`")
375
+ return value
pyprojkit/versions.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Python version handling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Mapping
9
+
10
+ __all__ = [
11
+ "DEFAULT_PATCH_VERSIONS",
12
+ "PythonVersions",
13
+ ]
14
+
15
+ DEFAULT_PATCH_VERSIONS: dict[tuple[int, int], str] = {
16
+ (3, 12): "3.12.13",
17
+ (3, 13): "3.13.13",
18
+ (3, 14): "3.14.6",
19
+ }
20
+ """
21
+ Mapping of minor version to latest known patch release, used to pin nox test sessions.
22
+
23
+ Updated along with pyprojkit releases; individual projects can override via
24
+ `PythonVersions.patch_overrides`.
25
+ """
26
+
27
+ @dataclass(frozen=True)
28
+ class PythonVersions:
29
+ """
30
+ Python versions supported by a project, given as a major version and an inclusive
31
+ range of minor versions. Only explicitly supported (i.e. tested) versions should be
32
+ specified.
33
+
34
+ Drives `requires-python`, trove classifiers, formatter target versions, and nox
35
+ interpreter pins.
36
+ """
37
+
38
+ major: int
39
+ """
40
+ Major version, e.g. `3`.
41
+ """
42
+
43
+ minor: tuple[int, int]
44
+ """
45
+ Minor version range as (min, max), both inclusive, e.g. `(12, 14)`.
46
+ """
47
+
48
+ patch_overrides: Mapping[tuple[int, int], str] = field(default_factory=dict)
49
+ """
50
+ Overrides for patch releases used to pin nox sessions, keyed by (major, minor).
51
+ """
52
+
53
+ def __post_init__(self):
54
+ if self.minor[0] > self.minor[1]:
55
+ raise ValueError(f"Invalid minor version range: {self.minor}")
56
+
57
+ @property
58
+ def versions(self) -> list[tuple[int, int]]:
59
+ """
60
+ All supported (major, minor) versions.
61
+ """
62
+ return [
63
+ (self.major, minor) for minor in range(self.minor[0], self.minor[1] + 1)
64
+ ]
65
+
66
+ @property
67
+ def requires_python(self) -> str:
68
+ """
69
+ Version specifier for `requires-python`, spanning exactly the supported
70
+ versions.
71
+ """
72
+ return f">={self.major}.{self.minor[0]},<{self.major}.{self.minor[1] + 1}"
73
+
74
+ @property
75
+ def classifiers(self) -> list[str]:
76
+ """
77
+ Trove classifiers for the supported versions.
78
+ """
79
+ prefix = "Programming Language :: Python :: "
80
+ return [f"{prefix}{self.major}"] + [
81
+ f"{prefix}{maj}.{min}" for maj, min in self.versions
82
+ ]
83
+
84
+ @property
85
+ def black_targets(self) -> list[str]:
86
+ """
87
+ `target-version` values for black.
88
+ """
89
+ return [f"py{maj}{min}" for maj, min in self.versions]
90
+
91
+ @property
92
+ def mypy_version(self) -> str:
93
+ """
94
+ `python_version` value for mypy (the minimum supported version).
95
+ """
96
+ return f"{self.major}.{self.minor[0]}"
97
+
98
+ @property
99
+ def pins(self) -> list[str]:
100
+ """
101
+ Exact patch releases for nox interpreter pinning.
102
+ """
103
+ pins: list[str] = []
104
+ for version in self.versions:
105
+ patch = self.patch_overrides.get(
106
+ version, DEFAULT_PATCH_VERSIONS.get(version)
107
+ )
108
+ if patch is None:
109
+ raise ValueError(
110
+ f"No known patch release for Python {version[0]}.{version[1]}; "
111
+ f"pass patch_overrides={{{version}: '<release>'}} or update pyprojkit"
112
+ )
113
+ pins.append(patch)
114
+ return pins