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.
- 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 +219 -0
- pyprojkit/config/tools.py +168 -0
- pyprojkit/discovery.py +51 -0
- pyprojkit/sessions.py +71 -0
- pyprojkit/sync.py +243 -0
- pyprojkit/tasks.py +393 -0
- pyprojkit/versions.py +115 -0
- pyprojkit-0.3.0.dist-info/METADATA +311 -0
- pyprojkit-0.3.0.dist-info/RECORD +19 -0
- pyprojkit-0.3.0.dist-info/WHEEL +4 -0
- pyprojkit-0.3.0.dist-info/entry_points.txt +3 -0
- pyprojkit-0.3.0.dist-info/licenses/LICENSE +21 -0
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()
|
pyprojkit/tasks.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
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
|
+
import xml.etree.ElementTree as ET
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Callable
|
|
28
|
+
|
|
29
|
+
from doit import task_params
|
|
30
|
+
from doit.task import Task
|
|
31
|
+
from doit.tools import create_folder
|
|
32
|
+
|
|
33
|
+
from . import _paths
|
|
34
|
+
from ._run import cleanup_dir, run
|
|
35
|
+
from .config.base import ConfigError
|
|
36
|
+
from .config.project import ProjectConfig
|
|
37
|
+
from .discovery import load_config
|
|
38
|
+
from .sync import sync
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"TaskFactory",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
type TaskCreator = Callable[..., Task]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalize_svg(path: Path | str):
|
|
48
|
+
"""
|
|
49
|
+
Rewrite SVG with attributes sorted, as genbadge doesn't order them
|
|
50
|
+
deterministically.
|
|
51
|
+
"""
|
|
52
|
+
ET.register_namespace("", "http://www.w3.org/2000/svg")
|
|
53
|
+
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
|
|
54
|
+
tree = ET.parse(path)
|
|
55
|
+
for elem in tree.iter():
|
|
56
|
+
attrib = sorted(elem.attrib.items())
|
|
57
|
+
elem.attrib.clear()
|
|
58
|
+
elem.attrib.update(attrib)
|
|
59
|
+
tree.write(path, encoding="unicode")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TaskFactory:
|
|
63
|
+
"""
|
|
64
|
+
Factory for `doit` tasks, configured by the project's `pyprojconf.py`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
_config: ProjectConfig
|
|
68
|
+
_root: Path
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
config: ProjectConfig | None = None,
|
|
73
|
+
root: Path | str | None = None,
|
|
74
|
+
):
|
|
75
|
+
self._root = Path(root) if root else Path.cwd()
|
|
76
|
+
self._config = config or load_config(self._root)
|
|
77
|
+
_paths.CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
def create_sync_task(self) -> TaskCreator:
|
|
80
|
+
"""
|
|
81
|
+
Create `sync` task which updates `pyproject.toml` from `pyprojconf.py`.
|
|
82
|
+
"""
|
|
83
|
+
config, root = self._config, self._root
|
|
84
|
+
|
|
85
|
+
def task_sync() -> Task:
|
|
86
|
+
return Task(
|
|
87
|
+
"sync",
|
|
88
|
+
actions=[(sync, (config, root))],
|
|
89
|
+
targets=[],
|
|
90
|
+
file_dep=[],
|
|
91
|
+
doc="Sync pyproject.toml from pyprojconf.py",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return task_sync
|
|
95
|
+
|
|
96
|
+
def create_format_task(self) -> TaskCreator:
|
|
97
|
+
"""
|
|
98
|
+
Create `format` task which runs the configured formatters.
|
|
99
|
+
"""
|
|
100
|
+
config, root = self._config, self._root
|
|
101
|
+
|
|
102
|
+
def task_format() -> Task:
|
|
103
|
+
# aggregate files to format
|
|
104
|
+
py_paths = [str(p) for p in sorted(root.glob("*.py"))] + [
|
|
105
|
+
path for path in config.format_paths if (root / path).is_dir()
|
|
106
|
+
]
|
|
107
|
+
toml_paths = [str(p) for p in sorted(root.glob("*.toml"))]
|
|
108
|
+
|
|
109
|
+
actions = [
|
|
110
|
+
(
|
|
111
|
+
run,
|
|
112
|
+
(formatter.command(py_paths, toml_paths), set(formatter.expect_rc)),
|
|
113
|
+
)
|
|
114
|
+
for formatter in config.tools.formatting.formatters
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
return Task(
|
|
118
|
+
"format",
|
|
119
|
+
actions=actions,
|
|
120
|
+
targets=[],
|
|
121
|
+
file_dep=[],
|
|
122
|
+
doc="Run formatters",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return task_format
|
|
126
|
+
|
|
127
|
+
def create_test_task(self) -> TaskCreator:
|
|
128
|
+
"""
|
|
129
|
+
Create `test` task which runs pytest and generates coverage reports.
|
|
130
|
+
"""
|
|
131
|
+
self._require(self._config.tools.test, "tools.test")
|
|
132
|
+
|
|
133
|
+
def task_test() -> Task:
|
|
134
|
+
args = [
|
|
135
|
+
"pytest",
|
|
136
|
+
f"--cov={self._config.package}",
|
|
137
|
+
f"--cov-report=html:{_paths.COV_HTML_PATH}",
|
|
138
|
+
f"--cov-report=xml:{_paths.COV_XML_PATH}",
|
|
139
|
+
f"--junitxml={_paths.JUNIT_PATH}",
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
return Task(
|
|
143
|
+
"test",
|
|
144
|
+
actions=[
|
|
145
|
+
(create_folder, [_paths.COV_PATH]),
|
|
146
|
+
(run, (args,)),
|
|
147
|
+
],
|
|
148
|
+
targets=[
|
|
149
|
+
f"{_paths.COV_HTML_PATH}/index.html",
|
|
150
|
+
str(_paths.COV_XML_PATH),
|
|
151
|
+
str(_paths.JUNIT_PATH),
|
|
152
|
+
],
|
|
153
|
+
file_dep=[],
|
|
154
|
+
clean=[(cleanup_dir, [_paths.TESTS_PATH])],
|
|
155
|
+
doc="Run pytest and generate coverage reports",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return task_test
|
|
159
|
+
|
|
160
|
+
def create_badges_task(self) -> TaskCreator:
|
|
161
|
+
"""
|
|
162
|
+
Create `badges` task which generates badges from test results.
|
|
163
|
+
"""
|
|
164
|
+
self._require(self._config.tools.test, "tools.test")
|
|
165
|
+
|
|
166
|
+
def task_badges() -> Task:
|
|
167
|
+
tests_args = [
|
|
168
|
+
"genbadge",
|
|
169
|
+
"tests",
|
|
170
|
+
"-i",
|
|
171
|
+
str(_paths.JUNIT_PATH),
|
|
172
|
+
"-o",
|
|
173
|
+
str(_paths.PYTEST_BADGE_PATH),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
cov_args = [
|
|
177
|
+
"genbadge",
|
|
178
|
+
"coverage",
|
|
179
|
+
"-i",
|
|
180
|
+
str(_paths.COV_XML_PATH),
|
|
181
|
+
"-o",
|
|
182
|
+
str(_paths.COV_BADGE_PATH),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
return Task(
|
|
186
|
+
"badges",
|
|
187
|
+
actions=[
|
|
188
|
+
(create_folder, [_paths.BADGES_PATH]),
|
|
189
|
+
(run, (tests_args,)),
|
|
190
|
+
(run, (cov_args,)),
|
|
191
|
+
(_normalize_svg, [_paths.PYTEST_BADGE_PATH]),
|
|
192
|
+
(_normalize_svg, [_paths.COV_BADGE_PATH]),
|
|
193
|
+
],
|
|
194
|
+
targets=[
|
|
195
|
+
str(_paths.PYTEST_BADGE_PATH),
|
|
196
|
+
str(_paths.COV_BADGE_PATH),
|
|
197
|
+
],
|
|
198
|
+
file_dep=[
|
|
199
|
+
str(_paths.JUNIT_PATH),
|
|
200
|
+
str(_paths.COV_XML_PATH),
|
|
201
|
+
],
|
|
202
|
+
doc="Generate badges from test results",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return task_badges
|
|
206
|
+
|
|
207
|
+
def create_publish_task(self) -> TaskCreator:
|
|
208
|
+
"""
|
|
209
|
+
Create `publish` task which builds and publishes the package via uv.
|
|
210
|
+
"""
|
|
211
|
+
out_dir = Path(self._config.tools.publish.out_dir)
|
|
212
|
+
|
|
213
|
+
def _publish():
|
|
214
|
+
artifacts = [str(p) for p in sorted(out_dir.iterdir())]
|
|
215
|
+
assert artifacts, f"No build artifacts in {out_dir}"
|
|
216
|
+
run(["uv", "publish", *artifacts])
|
|
217
|
+
|
|
218
|
+
def task_publish() -> Task:
|
|
219
|
+
return Task(
|
|
220
|
+
"publish",
|
|
221
|
+
actions=[
|
|
222
|
+
# clean first so stale artifacts are never published
|
|
223
|
+
(cleanup_dir, [out_dir]),
|
|
224
|
+
(run, (["uv", "build", "--out-dir", str(out_dir)],)),
|
|
225
|
+
(_publish,),
|
|
226
|
+
],
|
|
227
|
+
targets=[],
|
|
228
|
+
file_dep=[],
|
|
229
|
+
doc="Build and publish package via uv",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return task_publish
|
|
233
|
+
|
|
234
|
+
def create_init_task(self) -> TaskCreator:
|
|
235
|
+
"""
|
|
236
|
+
Create `init` task which generates `__init__.py` files using mkinit.
|
|
237
|
+
"""
|
|
238
|
+
mkinit = self._require(self._config.tools.doc.mkinit, "tools.doc.mkinit")
|
|
239
|
+
package_path = self._config.package_path
|
|
240
|
+
|
|
241
|
+
def task_init() -> Task:
|
|
242
|
+
return Task(
|
|
243
|
+
"init",
|
|
244
|
+
actions=[
|
|
245
|
+
(run, (["mkinit", str(package_path), *mkinit.args],)),
|
|
246
|
+
],
|
|
247
|
+
targets=[],
|
|
248
|
+
file_dep=[],
|
|
249
|
+
doc="Generate __init__.py files using mkinit",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return task_init
|
|
253
|
+
|
|
254
|
+
def create_doc_task(self) -> TaskCreator:
|
|
255
|
+
"""
|
|
256
|
+
Create `doc` task which builds documentation using sphinx.
|
|
257
|
+
"""
|
|
258
|
+
sphinx = self._require(self._config.tools.doc.sphinx, "tools.doc.sphinx")
|
|
259
|
+
|
|
260
|
+
def _do_copy(copy: bool):
|
|
261
|
+
if not copy:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
from dotenv import load_dotenv
|
|
265
|
+
|
|
266
|
+
load_dotenv()
|
|
267
|
+
|
|
268
|
+
assert sphinx.copy_env_var, "No copy_env_var configured"
|
|
269
|
+
dest = os.environ.get(sphinx.copy_env_var)
|
|
270
|
+
assert dest, f"Environment variable {sphinx.copy_env_var} not set"
|
|
271
|
+
|
|
272
|
+
dest_path = Path(dest)
|
|
273
|
+
assert dest_path.is_dir()
|
|
274
|
+
|
|
275
|
+
# clean destination
|
|
276
|
+
for path in dest_path.iterdir():
|
|
277
|
+
if path.is_file():
|
|
278
|
+
path.unlink()
|
|
279
|
+
else:
|
|
280
|
+
shutil.rmtree(path)
|
|
281
|
+
|
|
282
|
+
shutil.copytree(_paths.DOC_HTML_PATH, dest, dirs_exist_ok=True)
|
|
283
|
+
|
|
284
|
+
print(f"\nCopied: {_paths.DOC_HTML_PATH} -> {dest}")
|
|
285
|
+
|
|
286
|
+
@task_params(
|
|
287
|
+
[
|
|
288
|
+
{
|
|
289
|
+
"name": "copy",
|
|
290
|
+
"long": "copy",
|
|
291
|
+
"type": bool,
|
|
292
|
+
"default": False,
|
|
293
|
+
"help": "Copy to output folder after build",
|
|
294
|
+
}
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
def task_doc(copy: bool) -> Task:
|
|
298
|
+
args = [
|
|
299
|
+
"sphinx-build",
|
|
300
|
+
"-T", # show full traceback upon error
|
|
301
|
+
sphinx.source_dir,
|
|
302
|
+
str(_paths.DOC_HTML_PATH),
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
return Task(
|
|
306
|
+
"doc",
|
|
307
|
+
actions=[
|
|
308
|
+
(create_folder, [_paths.DOC_HTML_PATH]),
|
|
309
|
+
(run, (args,)),
|
|
310
|
+
(_do_copy, (copy,)),
|
|
311
|
+
],
|
|
312
|
+
targets=[
|
|
313
|
+
f"{_paths.DOC_HTML_PATH}/index.html",
|
|
314
|
+
],
|
|
315
|
+
file_dep=[],
|
|
316
|
+
clean=[(cleanup_dir, [_paths.DOC_HTML_PATH])],
|
|
317
|
+
doc="Generate documentation",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return task_doc
|
|
321
|
+
|
|
322
|
+
def create_analysis_task(self) -> TaskCreator:
|
|
323
|
+
"""
|
|
324
|
+
Create `analysis` task which runs static analysis tools.
|
|
325
|
+
"""
|
|
326
|
+
analysis = self._require(self._config.tools.analysis, "tools.analysis")
|
|
327
|
+
package_path = self._config.package_path
|
|
328
|
+
|
|
329
|
+
def task_analysis() -> Task:
|
|
330
|
+
actions = []
|
|
331
|
+
|
|
332
|
+
if analysis.mypy:
|
|
333
|
+
actions.append(
|
|
334
|
+
(
|
|
335
|
+
run,
|
|
336
|
+
(
|
|
337
|
+
[
|
|
338
|
+
"mypy",
|
|
339
|
+
"--html-report",
|
|
340
|
+
str(_paths.MYPY_HTML_PATH),
|
|
341
|
+
"--cobertura-xml-report",
|
|
342
|
+
str(_paths.MYPY_XML_PATH),
|
|
343
|
+
str(package_path),
|
|
344
|
+
],
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if analysis.pyright:
|
|
350
|
+
actions.append((run, (["pyright", str(package_path)],)))
|
|
351
|
+
|
|
352
|
+
return Task(
|
|
353
|
+
"analysis",
|
|
354
|
+
actions=actions,
|
|
355
|
+
targets=[],
|
|
356
|
+
file_dep=[],
|
|
357
|
+
doc="Run static analysis tools",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return task_analysis
|
|
361
|
+
|
|
362
|
+
def create_all_tasks(self) -> dict[str, TaskCreator]:
|
|
363
|
+
"""
|
|
364
|
+
Create all tasks enabled by the configuration, keyed by `task_<name>` suitable
|
|
365
|
+
for `globals().update(...)` in `dodo.py`.
|
|
366
|
+
"""
|
|
367
|
+
tools = self._config.tools
|
|
368
|
+
|
|
369
|
+
creators: dict[str, TaskCreator] = {
|
|
370
|
+
"task_sync": self.create_sync_task(),
|
|
371
|
+
"task_format": self.create_format_task(),
|
|
372
|
+
"task_publish": self.create_publish_task(),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if tools.test:
|
|
376
|
+
creators["task_test"] = self.create_test_task()
|
|
377
|
+
creators["task_badges"] = self.create_badges_task()
|
|
378
|
+
|
|
379
|
+
if tools.doc.mkinit:
|
|
380
|
+
creators["task_init"] = self.create_init_task()
|
|
381
|
+
|
|
382
|
+
if tools.doc.sphinx:
|
|
383
|
+
creators["task_doc"] = self.create_doc_task()
|
|
384
|
+
|
|
385
|
+
if tools.analysis:
|
|
386
|
+
creators["task_analysis"] = self.create_analysis_task()
|
|
387
|
+
|
|
388
|
+
return creators
|
|
389
|
+
|
|
390
|
+
def _require[T](self, value: T | None, name: str) -> T:
|
|
391
|
+
if value is None:
|
|
392
|
+
raise ConfigError(f"Configuration does not set `{name}`")
|
|
393
|
+
return value
|