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/__init__.py +104 -0
- pyprojkit/_paths.py +43 -0
- pyprojkit/_run.py +36 -0
- pyprojkit/cli.py +58 -0
- pyprojkit/config/__init__.py +79 -0
- pyprojkit/config/base.py +84 -0
- pyprojkit/config/profiles.py +97 -0
- pyprojkit/config/project.py +194 -0
- pyprojkit/config/tools.py +170 -0
- pyprojkit/discovery.py +51 -0
- pyprojkit/sessions.py +71 -0
- pyprojkit/sync.py +243 -0
- pyprojkit/tasks.py +375 -0
- pyprojkit/versions.py +114 -0
- pyprojkit-0.1.0.dist-info/METADATA +310 -0
- pyprojkit-0.1.0.dist-info/RECORD +19 -0
- pyprojkit-0.1.0.dist-info/WHEEL +4 -0
- pyprojkit-0.1.0.dist-info/entry_points.txt +3 -0
- pyprojkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|