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/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