dycw-actions 0.2.2__py3-none-any.whl → 0.6.4__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.
Files changed (63) hide show
  1. actions/__init__.py +1 -1
  2. actions/action_dicts/constants.py +8 -0
  3. actions/action_dicts/lib.py +186 -0
  4. actions/clean_dir/cli.py +33 -0
  5. actions/clean_dir/lib.py +59 -0
  6. actions/clean_dir/settings.py +18 -0
  7. actions/cli.py +44 -6
  8. actions/conformalize_repo/cli.py +76 -0
  9. actions/conformalize_repo/configs/gitignore +244 -0
  10. actions/conformalize_repo/constants.py +72 -0
  11. actions/conformalize_repo/lib.py +1522 -0
  12. actions/conformalize_repo/settings.py +119 -0
  13. actions/constants.py +10 -0
  14. actions/format_requirements/__init__.py +1 -0
  15. actions/format_requirements/cli.py +37 -0
  16. actions/format_requirements/lib.py +121 -0
  17. actions/publish_package/__init__.py +1 -0
  18. actions/publish_package/cli.py +39 -0
  19. actions/publish_package/doc.py +6 -0
  20. actions/{publish → publish_package}/lib.py +17 -16
  21. actions/publish_package/settings.py +31 -0
  22. actions/random_sleep/__init__.py +1 -0
  23. actions/random_sleep/cli.py +35 -0
  24. actions/random_sleep/doc.py +6 -0
  25. actions/{sleep → random_sleep}/lib.py +14 -13
  26. actions/{sleep → random_sleep}/settings.py +4 -4
  27. actions/replace_sequence_strs/__init__.py +1 -0
  28. actions/replace_sequence_strs/cli.py +37 -0
  29. actions/replace_sequence_strs/lib.py +79 -0
  30. actions/run_hooks/__init__.py +1 -0
  31. actions/run_hooks/cli.py +33 -0
  32. actions/run_hooks/doc.py +6 -0
  33. actions/run_hooks/lib.py +97 -0
  34. actions/run_hooks/settings.py +24 -0
  35. actions/setup_cronjob/__init__.py +1 -0
  36. actions/setup_cronjob/cli.py +43 -0
  37. actions/setup_cronjob/configs/cron.tmpl +3 -0
  38. actions/setup_cronjob/configs/logrotate.tmpl +10 -0
  39. actions/setup_cronjob/constants.py +8 -0
  40. actions/setup_cronjob/lib.py +120 -0
  41. actions/setup_cronjob/settings.py +27 -0
  42. actions/tag_commit/__init__.py +1 -0
  43. actions/tag_commit/cli.py +39 -0
  44. actions/tag_commit/doc.py +6 -0
  45. actions/tag_commit/lib.py +63 -0
  46. actions/{tag → tag_commit}/settings.py +4 -4
  47. actions/types.py +18 -0
  48. actions/utilities.py +97 -10
  49. dycw_actions-0.6.4.dist-info/METADATA +21 -0
  50. dycw_actions-0.6.4.dist-info/RECORD +56 -0
  51. {dycw_actions-0.2.2.dist-info → dycw_actions-0.6.4.dist-info}/WHEEL +1 -1
  52. actions/publish/cli.py +0 -45
  53. actions/publish/settings.py +0 -35
  54. actions/settings.py +0 -19
  55. actions/sleep/cli.py +0 -39
  56. actions/tag/cli.py +0 -43
  57. actions/tag/lib.py +0 -60
  58. dycw_actions-0.2.2.dist-info/METADATA +0 -14
  59. dycw_actions-0.2.2.dist-info/RECORD +0 -21
  60. /actions/{publish → action_dicts}/__init__.py +0 -0
  61. /actions/{sleep → clean_dir}/__init__.py +0 -0
  62. /actions/{tag → conformalize_repo}/__init__.py +0 -0
  63. {dycw_actions-0.2.2.dist-info → dycw_actions-0.6.4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1522 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from contextlib import contextmanager, suppress
6
+ from io import StringIO
7
+ from itertools import product
8
+ from pathlib import Path
9
+ from re import MULTILINE, escape, sub
10
+ from shlex import join
11
+ from string import Template
12
+ from subprocess import CalledProcessError
13
+ from typing import TYPE_CHECKING, Any, Literal, assert_never
14
+
15
+ import tomlkit
16
+ from rich.pretty import pretty_repr
17
+ from ruamel.yaml.scalarstring import LiteralScalarString
18
+ from tomlkit import TOMLDocument, aot, array, document, table
19
+ from tomlkit.exceptions import NonExistentKey
20
+ from tomlkit.items import AoT, Array, Table
21
+ from utilities.atomicwrites import writer
22
+ from utilities.functions import ensure_class
23
+ from utilities.inflect import counted_noun
24
+ from utilities.iterables import OneEmptyError, OneNonUniqueError, one
25
+ from utilities.pathlib import get_repo_root
26
+ from utilities.re import extract_groups
27
+ from utilities.subprocess import append_text, ripgrep, run
28
+ from utilities.tempfile import TemporaryFile
29
+ from utilities.text import repr_str, strip_and_dedent
30
+ from utilities.version import ParseVersionError, Version, parse_version
31
+ from utilities.whenever import HOUR, get_now
32
+ from whenever import ZonedDateTime
33
+ from xdg_base_dirs import xdg_cache_home
34
+
35
+ from actions import __version__
36
+ from actions.action_dicts.lib import (
37
+ run_action_pre_commit_dict,
38
+ run_action_publish_dict,
39
+ run_action_pyright_dict,
40
+ run_action_pytest_dict,
41
+ run_action_ruff_dict,
42
+ run_action_tag_dict,
43
+ )
44
+ from actions.conformalize_repo.constants import (
45
+ ACTIONS_URL,
46
+ BUMPVERSION_TOML,
47
+ COVERAGERC_TOML,
48
+ DOCKERFMT_URL,
49
+ ENVRC,
50
+ GITHUB_PULL_REQUEST_YAML,
51
+ GITHUB_PUSH_YAML,
52
+ GITIGNORE,
53
+ MAX_PYTHON_VERSION,
54
+ PATH_CONFIGS,
55
+ PRE_COMMIT_CONFIG_YAML,
56
+ PRE_COMMIT_HOOKS_URL,
57
+ PYPROJECT_TOML,
58
+ PYRIGHTCONFIG_JSON,
59
+ PYTEST_TOML,
60
+ README_MD,
61
+ RUFF_TOML,
62
+ RUFF_URL,
63
+ SHELLCHECK_URL,
64
+ SHFMT_URL,
65
+ TAPLO_URL,
66
+ UV_URL,
67
+ )
68
+ from actions.conformalize_repo.settings import SETTINGS
69
+ from actions.constants import YAML_INSTANCE
70
+ from actions.logging import LOGGER
71
+
72
+ if TYPE_CHECKING:
73
+ from collections.abc import Callable, Iterable, Iterator, MutableSet
74
+
75
+ from utilities.types import PathLike
76
+
77
+ from actions.types import HasAppend, HasSetDefault, StrDict
78
+
79
+
80
+ def conformalize_repo(
81
+ *,
82
+ coverage: bool = SETTINGS.coverage,
83
+ description: str | None = SETTINGS.description,
84
+ envrc: bool = SETTINGS.envrc,
85
+ envrc__uv: bool = SETTINGS.envrc__uv,
86
+ envrc__uv__native_tls: bool = SETTINGS.envrc__uv__native_tls,
87
+ github__pull_request__pre_commit: bool = SETTINGS.github__pull_request__pre_commit,
88
+ github__pull_request__pre_commit__gitea: bool = SETTINGS.github__pull_request__pre_commit__gitea,
89
+ github__pull_request__pyright: bool = SETTINGS.github__pull_request__pyright,
90
+ github__pull_request__pytest__macos: bool = SETTINGS.github__pull_request__pytest__macos,
91
+ github__pull_request__pytest__ubuntu: bool = SETTINGS.github__pull_request__pytest__ubuntu,
92
+ github__pull_request__pytest__windows: bool = SETTINGS.github__pull_request__pytest__windows,
93
+ github__pull_request__ruff: bool = SETTINGS.github__pull_request__ruff,
94
+ github__push__publish: bool = SETTINGS.github__push__publish,
95
+ github__push__tag: bool = SETTINGS.github__push__tag,
96
+ github__push__tag__major: bool = SETTINGS.github__push__tag__major,
97
+ github__push__tag__major_minor: bool = SETTINGS.github__push__tag__major_minor,
98
+ github__push__tag__latest: bool = SETTINGS.github__push__tag__latest,
99
+ gitignore: bool = SETTINGS.gitignore,
100
+ package_name: str | None = SETTINGS.package_name,
101
+ pre_commit__dockerfmt: bool = SETTINGS.pre_commit__dockerfmt,
102
+ pre_commit__prettier: bool = SETTINGS.pre_commit__prettier,
103
+ pre_commit__python: bool = SETTINGS.pre_commit__python,
104
+ pre_commit__ruff: bool = SETTINGS.pre_commit__ruff,
105
+ pre_commit__shell: bool = SETTINGS.pre_commit__shell,
106
+ pre_commit__taplo: bool = SETTINGS.pre_commit__taplo,
107
+ pre_commit__uv: bool = SETTINGS.pre_commit__uv,
108
+ pre_commit__uv__script: str | None = SETTINGS.pre_commit__uv__script,
109
+ pyproject: bool = SETTINGS.pyproject,
110
+ pyproject__project__optional_dependencies__scripts: bool = SETTINGS.pyproject__project__optional_dependencies__scripts,
111
+ pyproject__tool__uv__indexes: list[
112
+ tuple[str, str]
113
+ ] = SETTINGS.pyproject__tool__uv__indexes,
114
+ pyright: bool = SETTINGS.pyright,
115
+ pytest: bool = SETTINGS.pytest,
116
+ pytest__asyncio: bool = SETTINGS.pytest__asyncio,
117
+ pytest__ignore_warnings: bool = SETTINGS.pytest__ignore_warnings,
118
+ pytest__timeout: int | None = SETTINGS.pytest__timeout,
119
+ python_package_name: str | None = SETTINGS.python_package_name,
120
+ python_version: str = SETTINGS.python_version,
121
+ readme: bool = SETTINGS.readme,
122
+ repo_name: str | None = SETTINGS.repo_name,
123
+ ruff: bool = SETTINGS.ruff,
124
+ run_version_bump: bool = SETTINGS.run_version_bump,
125
+ script: str | None = SETTINGS.script,
126
+ ) -> None:
127
+ LOGGER.info(
128
+ strip_and_dedent("""
129
+ Running '%s' (version %s) with settings:
130
+ - coverage = %s
131
+ - description = %s
132
+ - envrc = %s
133
+ - envrc__uv = %s
134
+ - envrc__uv__native_tls = %s
135
+ - github__pull_request__pre_commit = %s
136
+ - github__pull_request__pre_commit__gitea = %s
137
+ - github__pull_request__pyright = %s
138
+ - github__pull_request__pytest__macos = %s
139
+ - github__pull_request__pytest__ubuntu = %s
140
+ - github__pull_request__pytest__windows = %s
141
+ - github__pull_request__ruff = %s
142
+ - github__push__publish = %s
143
+ - github__push__tag = %s
144
+ - github__push__tag__major = %s
145
+ - github__push__tag__major_minor = %s
146
+ - github__push__tag__latest = %s
147
+ - gitignore = %s
148
+ - package_name = %s
149
+ - pre_commit__dockerfmt = %s
150
+ - pre_commit__prettier = %s
151
+ - pre_commit__python = %s
152
+ - pre_commit__ruff = %s
153
+ - pre_commit__shell = %s
154
+ - pre_commit__taplo = %s
155
+ - pre_commit__uv = %s
156
+ - pre_commit__uv__script = %s
157
+ - pyproject = %s
158
+ - pyproject__project__optional_dependencies__scripts = %s
159
+ - pyproject__tool__uv__indexes = %s
160
+ - pyright = %s
161
+ - pytest = %s
162
+ - pytest__asyncio = %s
163
+ - pytest__ignore_warnings = %s
164
+ - pytest__timeout = %s
165
+ - python_package_name = %s
166
+ - python_version = %s
167
+ - readme = %s
168
+ - repo_name = %s
169
+ - ruff = %s
170
+ - run_version_bump = %s
171
+ - script = %s
172
+ """),
173
+ conformalize_repo.__name__,
174
+ __version__,
175
+ coverage,
176
+ description,
177
+ envrc,
178
+ envrc__uv,
179
+ envrc__uv__native_tls,
180
+ github__pull_request__pre_commit,
181
+ github__pull_request__pre_commit__gitea,
182
+ github__pull_request__pyright,
183
+ github__pull_request__pytest__macos,
184
+ github__pull_request__pytest__ubuntu,
185
+ github__pull_request__pytest__windows,
186
+ github__pull_request__ruff,
187
+ github__push__publish,
188
+ github__push__tag,
189
+ github__push__tag__major,
190
+ github__push__tag__major_minor,
191
+ github__push__tag__latest,
192
+ gitignore,
193
+ package_name,
194
+ pre_commit__dockerfmt,
195
+ pre_commit__prettier,
196
+ pre_commit__python,
197
+ pre_commit__ruff,
198
+ pre_commit__shell,
199
+ pre_commit__taplo,
200
+ pre_commit__uv,
201
+ pre_commit__uv__script,
202
+ pyproject,
203
+ pyproject__project__optional_dependencies__scripts,
204
+ pyproject__tool__uv__indexes,
205
+ pyright,
206
+ pytest,
207
+ pytest__asyncio,
208
+ pytest__ignore_warnings,
209
+ pytest__timeout,
210
+ python_package_name,
211
+ python_version,
212
+ readme,
213
+ repo_name,
214
+ ruff,
215
+ run_version_bump,
216
+ script,
217
+ )
218
+ modifications: set[Path] = set()
219
+ add_bumpversion_toml(
220
+ modifications=modifications,
221
+ pyproject=pyproject,
222
+ package_name=package_name,
223
+ python_package_name=python_package_name,
224
+ )
225
+ check_versions()
226
+ run_pre_commit_update(modifications=modifications)
227
+ run_ripgrep_and_replace(modifications=modifications, version=python_version)
228
+ update_action_file_extensions(modifications=modifications)
229
+ update_action_versions(modifications=modifications)
230
+ add_pre_commit_config_yaml(
231
+ modifications=modifications,
232
+ dockerfmt=pre_commit__dockerfmt,
233
+ prettier=pre_commit__prettier,
234
+ python=pre_commit__python,
235
+ ruff=pre_commit__ruff,
236
+ shell=pre_commit__shell,
237
+ taplo=pre_commit__taplo,
238
+ uv=pre_commit__uv,
239
+ script=script,
240
+ )
241
+ if coverage:
242
+ add_coveragerc_toml(modifications=modifications)
243
+ if envrc or envrc__uv or envrc__uv__native_tls:
244
+ add_envrc(
245
+ modifications=modifications,
246
+ uv=envrc__uv,
247
+ uv__native_tls=envrc__uv__native_tls,
248
+ python_version=python_version,
249
+ script=script,
250
+ )
251
+ if (
252
+ github__pull_request__pre_commit
253
+ or github__pull_request__pre_commit__gitea
254
+ or github__pull_request__pyright
255
+ or github__pull_request__pytest__windows
256
+ or github__pull_request__pytest__macos
257
+ or github__pull_request__pytest__ubuntu
258
+ or github__pull_request__ruff
259
+ ):
260
+ add_github_pull_request_yaml(
261
+ modifications=modifications,
262
+ pre_commit=github__pull_request__pre_commit,
263
+ pre_commit__gitea=github__pull_request__pre_commit__gitea,
264
+ pyright=github__pull_request__pyright,
265
+ pytest__windows=github__pull_request__pytest__windows,
266
+ pytest__macos=github__pull_request__pytest__macos,
267
+ pytest__ubuntu=github__pull_request__pytest__ubuntu,
268
+ pytest__timeout=pytest__timeout,
269
+ python_version=python_version,
270
+ ruff=ruff,
271
+ script=script,
272
+ )
273
+ if (
274
+ github__push__publish
275
+ or github__push__tag
276
+ or github__push__tag__major_minor
277
+ or github__push__tag__major
278
+ or github__push__tag__latest
279
+ ):
280
+ add_github_push_yaml(
281
+ modifications=modifications,
282
+ publish=github__push__publish,
283
+ tag=github__push__tag,
284
+ tag__major_minor=github__push__tag__major_minor,
285
+ tag__major=github__push__tag__major,
286
+ tag__latest=github__push__tag__latest,
287
+ )
288
+ if gitignore:
289
+ add_gitignore(modifications=modifications)
290
+ if (
291
+ pyproject
292
+ or pyproject__project__optional_dependencies__scripts
293
+ or (len(pyproject__tool__uv__indexes) >= 1)
294
+ ):
295
+ add_pyproject_toml(
296
+ modifications=modifications,
297
+ python_version=python_version,
298
+ description=description,
299
+ package_name=package_name,
300
+ readme=readme,
301
+ optional_dependencies__scripts=pyproject__project__optional_dependencies__scripts,
302
+ python_package_name=python_package_name,
303
+ tool__uv__indexes=pyproject__tool__uv__indexes,
304
+ )
305
+ if pyright:
306
+ add_pyrightconfig_json(
307
+ modifications=modifications, python_version=python_version, script=script
308
+ )
309
+ if (
310
+ pytest
311
+ or pytest__asyncio
312
+ or pytest__ignore_warnings
313
+ or (pytest__timeout is not None)
314
+ ):
315
+ add_pytest_toml(
316
+ modifications=modifications,
317
+ asyncio=pytest__asyncio,
318
+ ignore_warnings=pytest__ignore_warnings,
319
+ timeout=pytest__timeout,
320
+ coverage=coverage,
321
+ package_name=package_name,
322
+ python_package_name=python_package_name,
323
+ script=script,
324
+ )
325
+ if readme:
326
+ add_readme_md(
327
+ modifications=modifications, name=repo_name, description=description
328
+ )
329
+ if ruff:
330
+ add_ruff_toml(modifications=modifications, python_version=python_version)
331
+ if run_version_bump:
332
+ run_bump_my_version(modifications=modifications)
333
+ if len(modifications) >= 1:
334
+ LOGGER.info(
335
+ "Exiting due to %s: %s",
336
+ counted_noun(modifications, "modification"),
337
+ ", ".join(map(repr_str, sorted(modifications))),
338
+ )
339
+ sys.exit(1)
340
+
341
+
342
+ ##
343
+
344
+
345
+ def add_bumpversion_toml(
346
+ *,
347
+ modifications: MutableSet[Path] | None = None,
348
+ pyproject: bool = SETTINGS.pyproject,
349
+ package_name: str | None = SETTINGS.package_name,
350
+ python_package_name: str | None = SETTINGS.python_package_name,
351
+ ) -> None:
352
+ with yield_bumpversion_toml(modifications=modifications) as doc:
353
+ tool = get_table(doc, "tool")
354
+ bumpversion = get_table(tool, "bumpversion")
355
+ if pyproject:
356
+ files = get_aot(bumpversion, "files")
357
+ ensure_aot_contains(
358
+ files,
359
+ _add_bumpversion_toml_file(PYPROJECT_TOML, 'version = "${version}"'),
360
+ )
361
+ if (
362
+ python_package_name_use := get_python_package_name(
363
+ package_name=package_name, python_package_name=python_package_name
364
+ )
365
+ ) is not None:
366
+ files = get_aot(bumpversion, "files")
367
+ ensure_aot_contains(
368
+ files,
369
+ _add_bumpversion_toml_file(
370
+ f"src/{python_package_name_use}/__init__.py",
371
+ '__version__ = "${version}"',
372
+ ),
373
+ )
374
+
375
+
376
+ def _add_bumpversion_toml_file(path: PathLike, template: str, /) -> Table:
377
+ tab = table()
378
+ tab["filename"] = str(path)
379
+ tab["search"] = Template(template).substitute(version="{current_version}")
380
+ tab["replace"] = Template(template).substitute(version="{new_version}")
381
+ return tab
382
+
383
+
384
+ ##
385
+
386
+
387
+ def add_coveragerc_toml(*, modifications: MutableSet[Path] | None = None) -> None:
388
+ with yield_toml_doc(COVERAGERC_TOML, modifications=modifications) as doc:
389
+ html = get_table(doc, "html")
390
+ html["directory"] = ".coverage/html"
391
+ report = get_table(doc, "report")
392
+ exclude_also = get_array(report, "exclude_also")
393
+ ensure_contains(exclude_also, "@overload", "if TYPE_CHECKING:")
394
+ report["fail_under"] = 100.0
395
+ report["skip_covered"] = True
396
+ report["skip_empty"] = True
397
+ run = get_table(doc, "run")
398
+ run["branch"] = True
399
+ run["data_file"] = ".coverage/data"
400
+ run["parallel"] = True
401
+
402
+
403
+ ##
404
+
405
+
406
+ def add_envrc(
407
+ *,
408
+ modifications: MutableSet[Path] | None = None,
409
+ uv: bool = SETTINGS.envrc__uv,
410
+ uv__native_tls: bool = SETTINGS.envrc__uv__native_tls,
411
+ python_version: str = SETTINGS.python_version,
412
+ script: str | None = SETTINGS.script,
413
+ ) -> None:
414
+ with yield_text_file(ENVRC, modifications=modifications) as temp:
415
+ shebang = strip_and_dedent("""
416
+ #!/usr/bin/env sh
417
+ # shellcheck source=/dev/null
418
+ """)
419
+ append_text(temp, shebang, skip_if_present=True, flags=MULTILINE, blank_lines=2)
420
+
421
+ echo = strip_and_dedent("""
422
+ # echo
423
+ echo_date() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2; }
424
+ """)
425
+ append_text(temp, echo, skip_if_present=True, flags=MULTILINE, blank_lines=2)
426
+
427
+ if uv:
428
+ append_text(
429
+ temp,
430
+ _add_envrc_uv_text(
431
+ native_tls=uv__native_tls,
432
+ python_version=python_version,
433
+ script=script,
434
+ ),
435
+ skip_if_present=True,
436
+ flags=MULTILINE,
437
+ blank_lines=2,
438
+ )
439
+
440
+
441
+ def _add_envrc_uv_text(
442
+ *,
443
+ native_tls: bool = SETTINGS.envrc__uv__native_tls,
444
+ python_version: str = SETTINGS.python_version,
445
+ script: str | None = SETTINGS.script,
446
+ ) -> str:
447
+ lines: list[str] = [
448
+ strip_and_dedent("""
449
+ # uv
450
+ export UV_MANAGED_PYTHON='true'
451
+ """)
452
+ ]
453
+ if native_tls:
454
+ lines.append("export UV_NATIVE_TLS='true'")
455
+ lines.append(
456
+ strip_and_dedent(f"""
457
+ export UV_PRERELEASE='disallow'
458
+ export UV_PYTHON='{python_version}'
459
+ if ! command -v uv >/dev/null 2>&1; then
460
+ echo_date "ERROR: 'uv' not found" && exit 1
461
+ fi
462
+ activate='.venv/bin/activate'
463
+ if [ -f $activate ]; then
464
+ . $activate
465
+ else
466
+ uv venv
467
+ fi
468
+ """)
469
+ )
470
+ args: list[str] = ["uv", "sync"]
471
+ if script is None:
472
+ args.extend(["--all-extras", "--all-groups"])
473
+ args.extend(["--active", "--locked"])
474
+ if script is not None:
475
+ args.extend(["--script", script])
476
+ lines.append(join(args))
477
+ return "\n".join(lines)
478
+
479
+
480
+ ##
481
+
482
+
483
+ def add_github_pull_request_yaml(
484
+ *,
485
+ modifications: MutableSet[Path] | None = None,
486
+ pre_commit: bool = SETTINGS.github__pull_request__pre_commit,
487
+ pre_commit__gitea: bool = SETTINGS.github__pull_request__pre_commit__gitea,
488
+ pyright: bool = SETTINGS.github__pull_request__pyright,
489
+ pytest__macos: bool = SETTINGS.github__pull_request__pytest__macos,
490
+ pytest__ubuntu: bool = SETTINGS.github__pull_request__pytest__ubuntu,
491
+ pytest__windows: bool = SETTINGS.github__pull_request__pytest__windows,
492
+ pytest__timeout: int | None = SETTINGS.pytest__timeout,
493
+ python_version: str = SETTINGS.python_version,
494
+ ruff: bool = SETTINGS.github__pull_request__ruff,
495
+ script: str | None = SETTINGS.script,
496
+ ) -> None:
497
+ with yield_yaml_dict(
498
+ GITHUB_PULL_REQUEST_YAML, modifications=modifications
499
+ ) as dict_:
500
+ dict_["name"] = "pull-request"
501
+ on = get_dict(dict_, "on")
502
+ pull_request = get_dict(on, "pull_request")
503
+ branches = get_list(pull_request, "branches")
504
+ ensure_contains(branches, "master")
505
+ schedule = get_list(on, "schedule")
506
+ ensure_contains(schedule, {"cron": "0 0 * * *"})
507
+ jobs = get_dict(dict_, "jobs")
508
+ if pre_commit:
509
+ pre_commit_dict = get_dict(jobs, "pre-commit")
510
+ pre_commit_dict["runs-on"] = "ubuntu-latest"
511
+ steps = get_list(pre_commit_dict, "steps")
512
+ ensure_contains(
513
+ steps,
514
+ run_action_pre_commit_dict(
515
+ repos=LiteralScalarString(
516
+ strip_and_dedent("""
517
+ dycw/actions
518
+ pre-commit/pre-commit-hooks
519
+ """)
520
+ ),
521
+ gitea=pre_commit__gitea,
522
+ ),
523
+ )
524
+ if pyright:
525
+ pyright_dict = get_dict(jobs, "pyright")
526
+ pyright_dict["runs-on"] = "ubuntu-latest"
527
+ steps = get_list(pyright_dict, "steps")
528
+ ensure_contains(
529
+ steps,
530
+ run_action_pyright_dict(
531
+ python_version=python_version, with_requirements=script
532
+ ),
533
+ )
534
+ if pytest__macos or pytest__ubuntu or pytest__windows:
535
+ pytest_dict = get_dict(jobs, "pytest")
536
+ env = get_dict(pytest_dict, "env")
537
+ env["CI"] = "1"
538
+ pytest_dict["name"] = (
539
+ "pytest (${{matrix.os}}, ${{matrix.python-version}}, ${{matrix.resolution}})"
540
+ )
541
+ pytest_dict["runs-on"] = "${{matrix.os}}"
542
+ steps = get_list(pytest_dict, "steps")
543
+ ensure_contains(
544
+ steps,
545
+ run_action_pytest_dict(
546
+ python_version="${{matrix.python-version}}",
547
+ resolution="${{matrix.resolution}}",
548
+ with_requirements=script,
549
+ ),
550
+ )
551
+ strategy_dict = get_dict(pytest_dict, "strategy")
552
+ strategy_dict["fail-fast"] = False
553
+ matrix = get_dict(strategy_dict, "matrix")
554
+ os = get_list(matrix, "os")
555
+ if pytest__macos:
556
+ ensure_contains(os, "macos-latest")
557
+ if pytest__ubuntu:
558
+ ensure_contains(os, "ubuntu-latest")
559
+ if pytest__windows:
560
+ ensure_contains(os, "windows-latest")
561
+ python_version_dict = get_list(matrix, "python-version")
562
+ ensure_contains(python_version_dict, *yield_python_versions(python_version))
563
+ resolution = get_list(matrix, "resolution")
564
+ ensure_contains(resolution, "highest", "lowest-direct")
565
+ if pytest__timeout is not None:
566
+ pytest_dict["timeout-minutes"] = max(round(pytest__timeout / 60), 1)
567
+ if ruff:
568
+ ruff_dict = get_dict(jobs, "ruff")
569
+ ruff_dict["runs-on"] = "ubuntu-latest"
570
+ steps = get_list(ruff_dict, "steps")
571
+ ensure_contains(steps, run_action_ruff_dict())
572
+
573
+
574
+ ##
575
+
576
+
577
+ def add_github_push_yaml(
578
+ *,
579
+ modifications: MutableSet[Path] | None = None,
580
+ publish: bool = SETTINGS.github__push__publish,
581
+ tag: bool = SETTINGS.github__push__tag,
582
+ tag__major_minor: bool = SETTINGS.github__push__tag__major_minor,
583
+ tag__major: bool = SETTINGS.github__push__tag__major,
584
+ tag__latest: bool = SETTINGS.github__push__tag__latest,
585
+ ) -> None:
586
+ with yield_yaml_dict(GITHUB_PUSH_YAML, modifications=modifications) as dict_:
587
+ dict_["name"] = "push"
588
+ on = get_dict(dict_, "on")
589
+ push = get_dict(on, "push")
590
+ branches = get_list(push, "branches")
591
+ ensure_contains(branches, "master")
592
+ jobs = get_dict(dict_, "jobs")
593
+ if publish:
594
+ publish_dict = get_dict(jobs, "publish")
595
+ environment = get_dict(publish_dict, "environment")
596
+ environment["name"] = "pypi"
597
+ permissions = get_dict(publish_dict, "permissions")
598
+ permissions["id-token"] = "write"
599
+ publish_dict["runs-on"] = "ubuntu-latest"
600
+ steps = get_list(publish_dict, "steps")
601
+ ensure_contains(steps, run_action_publish_dict())
602
+ if tag or tag__major_minor or tag__major or tag__latest:
603
+ tag_dict = get_dict(jobs, "tag")
604
+ tag_dict["runs-on"] = "ubuntu-latest"
605
+ steps = get_list(tag_dict, "steps")
606
+ ensure_contains(
607
+ steps,
608
+ run_action_tag_dict(
609
+ major_minor=tag__major_minor, major=tag__major, latest=tag__latest
610
+ ),
611
+ )
612
+
613
+
614
+ ##
615
+
616
+
617
+ def add_gitignore(*, modifications: MutableSet[Path] | None = None) -> None:
618
+ with yield_text_file(GITIGNORE, modifications=modifications) as temp:
619
+ text = (PATH_CONFIGS / "gitignore").read_text()
620
+ append_text(temp, text, skip_if_present=True, flags=MULTILINE)
621
+
622
+
623
+ ##
624
+
625
+
626
+ def add_pre_commit_config_yaml(
627
+ *,
628
+ modifications: MutableSet[Path] | None = None,
629
+ dockerfmt: bool = SETTINGS.pre_commit__dockerfmt,
630
+ prettier: bool = SETTINGS.pre_commit__prettier,
631
+ python: bool = SETTINGS.pre_commit__python,
632
+ ruff: bool = SETTINGS.pre_commit__ruff,
633
+ shell: bool = SETTINGS.pre_commit__shell,
634
+ taplo: bool = SETTINGS.pre_commit__taplo,
635
+ uv: bool = SETTINGS.pre_commit__uv,
636
+ script: str | None = SETTINGS.script,
637
+ ) -> None:
638
+ with yield_yaml_dict(PRE_COMMIT_CONFIG_YAML, modifications=modifications) as dict_:
639
+ _add_pre_commit_config_repo(dict_, ACTIONS_URL, "conformalize-repo")
640
+ _add_pre_commit_config_repo(
641
+ dict_, PRE_COMMIT_HOOKS_URL, "check-executables-have-shebangs"
642
+ )
643
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "check-merge-conflict")
644
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "check-symlinks")
645
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "destroyed-symlinks")
646
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "detect-private-key")
647
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "end-of-file-fixer")
648
+ _add_pre_commit_config_repo(
649
+ dict_, PRE_COMMIT_HOOKS_URL, "mixed-line-ending", args=("add", ["--fix=lf"])
650
+ )
651
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "no-commit-to-branch")
652
+ _add_pre_commit_config_repo(
653
+ dict_,
654
+ PRE_COMMIT_HOOKS_URL,
655
+ "pretty-format-json",
656
+ args=("add", ["--autofix"]),
657
+ )
658
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "no-commit-to-branch")
659
+ _add_pre_commit_config_repo(dict_, PRE_COMMIT_HOOKS_URL, "trailing-whitespace")
660
+ if dockerfmt:
661
+ _add_pre_commit_config_repo(
662
+ dict_,
663
+ DOCKERFMT_URL,
664
+ "dockerfmt",
665
+ args=("add", ["--newline", "--write"]),
666
+ )
667
+ if prettier:
668
+ _add_pre_commit_config_repo(
669
+ dict_,
670
+ "local",
671
+ "prettier",
672
+ name="prettier",
673
+ entry="npx prettier --write",
674
+ language="system",
675
+ types_or=["markdown", "yaml"],
676
+ )
677
+ if python:
678
+ _add_pre_commit_config_repo(dict_, ACTIONS_URL, "format-requirements")
679
+ _add_pre_commit_config_repo(dict_, ACTIONS_URL, "replace-sequence-strs")
680
+ if ruff:
681
+ _add_pre_commit_config_repo(
682
+ dict_, RUFF_URL, "ruff-check", args=("add", ["--fix"])
683
+ )
684
+ _add_pre_commit_config_repo(dict_, RUFF_URL, "ruff-format")
685
+ if shell:
686
+ _add_pre_commit_config_repo(dict_, SHFMT_URL, "shfmt")
687
+ _add_pre_commit_config_repo(dict_, SHELLCHECK_URL, "shellcheck")
688
+ if taplo:
689
+ _add_pre_commit_config_repo(
690
+ dict_,
691
+ TAPLO_URL,
692
+ "taplo-format",
693
+ args=(
694
+ "exact",
695
+ [
696
+ "--option",
697
+ "indent_tables=true",
698
+ "--option",
699
+ "indent_entries=true",
700
+ "--option",
701
+ "reorder_keys=true",
702
+ ],
703
+ ),
704
+ )
705
+ if uv:
706
+ _add_pre_commit_config_repo(
707
+ dict_,
708
+ UV_URL,
709
+ "uv-lock",
710
+ files=None if script is None else rf"^{escape(script)}$",
711
+ args=(
712
+ "add",
713
+ ["--upgrade", "--resolution", "highest", "--prerelease", "disallow"]
714
+ + ([] if script is None else [f"--script={script}"]),
715
+ ),
716
+ )
717
+
718
+
719
+ def _add_pre_commit_config_repo(
720
+ pre_commit_dict: StrDict,
721
+ url: str,
722
+ id_: str,
723
+ /,
724
+ *,
725
+ name: str | None = None,
726
+ entry: str | None = None,
727
+ language: str | None = None,
728
+ files: str | None = None,
729
+ types_or: list[str] | None = None,
730
+ args: tuple[Literal["add", "exact"], list[str]] | None = None,
731
+ ) -> None:
732
+ repos_list = get_list(pre_commit_dict, "repos")
733
+ repo_dict = ensure_contains_partial(
734
+ repos_list, {"repo": url}, extra={} if url == "local" else {"rev": "master"}
735
+ )
736
+ hooks_list = get_list(repo_dict, "hooks")
737
+ hook_dict = ensure_contains_partial(hooks_list, {"id": id_})
738
+ if name is not None:
739
+ hook_dict["name"] = name
740
+ if entry is not None:
741
+ hook_dict["entry"] = entry
742
+ if language is not None:
743
+ hook_dict["language"] = language
744
+ if files is not None:
745
+ hook_dict["files"] = files
746
+ if types_or is not None:
747
+ hook_dict["types_or"] = types_or
748
+ if args is not None:
749
+ match args:
750
+ case "add", list() as args_i:
751
+ hook_args = get_list(hook_dict, "args")
752
+ ensure_contains(hook_args, *args_i)
753
+ case "exact", list() as args_i:
754
+ hook_dict["args"] = args_i
755
+ case never:
756
+ assert_never(never)
757
+
758
+
759
+ ##
760
+
761
+
762
+ def add_pyproject_toml(
763
+ *,
764
+ modifications: MutableSet[Path] | None = None,
765
+ python_version: str = SETTINGS.python_version,
766
+ description: str | None = SETTINGS.description,
767
+ package_name: str | None = SETTINGS.package_name,
768
+ readme: bool = SETTINGS.readme,
769
+ optional_dependencies__scripts: bool = SETTINGS.pyproject__project__optional_dependencies__scripts,
770
+ python_package_name: str | None = SETTINGS.python_package_name,
771
+ tool__uv__indexes: list[tuple[str, str]] = SETTINGS.pyproject__tool__uv__indexes,
772
+ ) -> None:
773
+ with yield_toml_doc(PYPROJECT_TOML, modifications=modifications) as doc:
774
+ build_system = get_table(doc, "build-system")
775
+ build_system["build-backend"] = "uv_build"
776
+ build_system["requires"] = ["uv_build"]
777
+ project = get_table(doc, "project")
778
+ project["requires-python"] = f">= {python_version}"
779
+ if description is not None:
780
+ project["description"] = description
781
+ if package_name is not None:
782
+ project["name"] = package_name
783
+ if readme:
784
+ project["readme"] = "README.md"
785
+ project.setdefault("version", "0.1.0")
786
+ dependency_groups = get_table(doc, "dependency-groups")
787
+ dev = get_array(dependency_groups, "dev")
788
+ ensure_contains(dev, "dycw-utilities[test]")
789
+ ensure_contains(dev, "rich")
790
+ if optional_dependencies__scripts:
791
+ optional_dependencies = get_table(project, "optional-dependencies")
792
+ scripts = get_array(optional_dependencies, "scripts")
793
+ ensure_contains(scripts, "click >=8.3.1")
794
+ if python_package_name is not None:
795
+ tool = get_table(doc, "tool")
796
+ uv = get_table(tool, "uv")
797
+ build_backend = get_table(uv, "build-backend")
798
+ build_backend["module-name"] = get_python_package_name(
799
+ package_name=package_name, python_package_name=python_package_name
800
+ )
801
+ build_backend["module-root"] = "src"
802
+ if len(tool__uv__indexes) >= 1:
803
+ tool = get_table(doc, "tool")
804
+ uv = get_table(tool, "uv")
805
+ indexes = get_aot(uv, "index")
806
+ for name, url in tool__uv__indexes:
807
+ index = table()
808
+ index["explicit"] = True
809
+ index["name"] = name
810
+ index["url"] = url
811
+ ensure_aot_contains(indexes, index)
812
+
813
+
814
+ ##
815
+
816
+
817
+ def add_pyrightconfig_json(
818
+ *,
819
+ modifications: MutableSet[Path] | None = None,
820
+ python_version: str = SETTINGS.python_version,
821
+ script: str | None = SETTINGS.script,
822
+ ) -> None:
823
+ with yield_json_dict(PYRIGHTCONFIG_JSON, modifications=modifications) as dict_:
824
+ dict_["deprecateTypingAliases"] = True
825
+ dict_["enableReachabilityAnalysis"] = False
826
+ include = get_list(dict_, "include")
827
+ ensure_contains(include, "src" if script is None else script)
828
+ dict_["pythonVersion"] = python_version
829
+ dict_["reportCallInDefaultInitializer"] = True
830
+ dict_["reportImplicitOverride"] = True
831
+ dict_["reportImplicitStringConcatenation"] = True
832
+ dict_["reportImportCycles"] = True
833
+ dict_["reportMissingSuperCall"] = True
834
+ dict_["reportMissingTypeArgument"] = False
835
+ dict_["reportMissingTypeStubs"] = False
836
+ dict_["reportPrivateImportUsage"] = False
837
+ dict_["reportPrivateUsage"] = False
838
+ dict_["reportPropertyTypeMismatch"] = True
839
+ dict_["reportUninitializedInstanceVariable"] = True
840
+ dict_["reportUnknownArgumentType"] = False
841
+ dict_["reportUnknownMemberType"] = False
842
+ dict_["reportUnknownParameterType"] = False
843
+ dict_["reportUnknownVariableType"] = False
844
+ dict_["reportUnnecessaryComparison"] = False
845
+ dict_["reportUnnecessaryTypeIgnoreComment"] = True
846
+ dict_["reportUnusedCallResult"] = True
847
+ dict_["reportUnusedImport"] = False
848
+ dict_["reportUnusedVariable"] = False
849
+ dict_["typeCheckingMode"] = "strict"
850
+
851
+
852
+ ##
853
+
854
+
855
+ def add_pytest_toml(
856
+ *,
857
+ modifications: MutableSet[Path] | None = None,
858
+ asyncio: bool = SETTINGS.pytest__asyncio,
859
+ ignore_warnings: bool = SETTINGS.pytest__ignore_warnings,
860
+ timeout: int | None = SETTINGS.pytest__timeout,
861
+ coverage: bool = SETTINGS.coverage,
862
+ package_name: str | None = SETTINGS.package_name,
863
+ python_package_name: str | None = SETTINGS.python_package_name,
864
+ script: str | None = SETTINGS.script,
865
+ ) -> None:
866
+ with yield_toml_doc(PYTEST_TOML, modifications=modifications) as doc:
867
+ pytest = get_table(doc, "pytest")
868
+ addopts = get_array(pytest, "addopts")
869
+ ensure_contains(
870
+ addopts,
871
+ "-ra",
872
+ "-vv",
873
+ "--color=auto",
874
+ "--durations=10",
875
+ "--durations-min=10",
876
+ )
877
+ if coverage and (
878
+ (
879
+ python_package_name_use := get_python_package_name(
880
+ package_name=package_name, python_package_name=python_package_name
881
+ )
882
+ )
883
+ is not None
884
+ ):
885
+ ensure_contains(
886
+ addopts,
887
+ f"--cov={python_package_name_use}",
888
+ f"--cov-config={COVERAGERC_TOML}",
889
+ "--cov-report=html",
890
+ )
891
+ pytest["collect_imported_tests"] = False
892
+ pytest["empty_parameter_set_mark"] = "fail_at_collect"
893
+ filterwarnings = get_array(pytest, "filterwarnings")
894
+ ensure_contains(filterwarnings, "error")
895
+ pytest["minversion"] = "9.0"
896
+ pytest["strict"] = True
897
+ testpaths = get_array(pytest, "testpaths")
898
+ ensure_contains(testpaths, "src/tests" if script is None else "tests")
899
+ pytest["xfail_strict"] = True
900
+ if asyncio:
901
+ pytest["asyncio_default_fixture_loop_scope"] = "function"
902
+ pytest["asyncio_mode"] = "auto"
903
+ if ignore_warnings:
904
+ filterwarnings = get_array(pytest, "filterwarnings")
905
+ ensure_contains(
906
+ filterwarnings,
907
+ "ignore::DeprecationWarning",
908
+ "ignore::ResourceWarning",
909
+ "ignore::RuntimeWarning",
910
+ )
911
+ if timeout is not None:
912
+ pytest["timeout"] = str(timeout)
913
+
914
+
915
+ ##
916
+
917
+
918
+ def add_readme_md(
919
+ *,
920
+ modifications: MutableSet[Path] | None = None,
921
+ name: str | None = SETTINGS.package_name,
922
+ description: str | None = SETTINGS.description,
923
+ ) -> None:
924
+ with yield_text_file(README_MD, modifications=modifications) as temp:
925
+ lines: list[str] = []
926
+ if name is not None:
927
+ lines.append(f"# `{name}`")
928
+ if description is not None:
929
+ lines.append(description)
930
+ _ = temp.write_text("\n\n".join(lines))
931
+
932
+
933
+ ##
934
+
935
+
936
+ def add_ruff_toml(
937
+ *,
938
+ modifications: MutableSet[Path] | None = None,
939
+ python_version: str = SETTINGS.python_version,
940
+ ) -> None:
941
+ with yield_toml_doc(RUFF_TOML, modifications=modifications) as doc:
942
+ doc["target-version"] = f"py{python_version.replace('.', '')}"
943
+ doc["unsafe-fixes"] = True
944
+ fmt = get_table(doc, "format")
945
+ fmt["preview"] = True
946
+ fmt["skip-magic-trailing-comma"] = True
947
+ lint = get_table(doc, "lint")
948
+ lint["explicit-preview-rules"] = True
949
+ fixable = get_array(lint, "fixable")
950
+ ensure_contains(fixable, "ALL")
951
+ ignore = get_array(lint, "ignore")
952
+ ensure_contains(
953
+ ignore,
954
+ "ANN401", # any-type
955
+ "ASYNC109", # async-function-with-timeout
956
+ "C901", # complex-structure
957
+ "CPY", # flake8-copyright
958
+ "D", # pydocstyle
959
+ "E501", # line-too-long
960
+ "PD", # pandas-vet
961
+ "PERF203", # try-except-in-loop
962
+ "PLC0415", # import-outside-top-level
963
+ "PLE1205", # logging-too-many-args
964
+ "PLR0904", # too-many-public-methods
965
+ "PLR0911", # too-many-return-statements
966
+ "PLR0912", # too-many-branches
967
+ "PLR0913", # too-many-arguments
968
+ "PLR0915", # too-many-statements
969
+ "PLR2004", # magic-value-comparison
970
+ "PT012", # pytest-raises-with-multiple-statements
971
+ "PT013", # pytest-incorrect-pytest-import
972
+ "PYI041", # redundant-numeric-union
973
+ "S202", # tarfile-unsafe-members
974
+ "S310", # suspicious-url-open-usage
975
+ "S311", # suspicious-non-cryptographic-random-usage
976
+ "S602", # subprocess-popen-with-shell-equals-true
977
+ "S603", # subprocess-without-shell-equals-true
978
+ "S607", # start-process-with-partial-path
979
+ # preview
980
+ "S101", # assert
981
+ # formatter
982
+ "W191", # tab-indentation
983
+ "E111", # indentation-with-invalid-multiple
984
+ "E114", # indentation-with-invalid-multiple-comment
985
+ "E117", # over-indented
986
+ "COM812", # missing-trailing-comma
987
+ "COM819", # prohibited-trailing-comma
988
+ "ISC001", # single-line-implicit-string-concatenation
989
+ "ISC002", # multi-line-implicit-string-concatenation
990
+ )
991
+ lint["preview"] = True
992
+ select = get_array(lint, "select")
993
+ selected_rules = [
994
+ "RUF022", # unsorted-dunder-all
995
+ "RUF029", # unused-async
996
+ ]
997
+ ensure_contains(select, "ALL", *selected_rules)
998
+ extend_per_file_ignores = get_table(lint, "extend-per-file-ignores")
999
+ test_py = get_array(extend_per_file_ignores, "test_*.py")
1000
+ test_py_rules = [
1001
+ "S101", # assert
1002
+ "SLF001", # private-member-access
1003
+ ]
1004
+ ensure_contains(test_py, *test_py_rules)
1005
+ ensure_not_contains(ignore, *selected_rules, *test_py_rules)
1006
+ bugbear = get_table(lint, "flake8-bugbear")
1007
+ extend_immutable_calls = get_array(bugbear, "extend-immutable-calls")
1008
+ ensure_contains(extend_immutable_calls, "typing.cast")
1009
+ tidy_imports = get_table(lint, "flake8-tidy-imports")
1010
+ tidy_imports["ban-relative-imports"] = "all"
1011
+ isort = get_table(lint, "isort")
1012
+ req_imps = get_array(isort, "required-imports")
1013
+ ensure_contains(req_imps, "from __future__ import annotations")
1014
+ isort["split-on-trailing-comma"] = False
1015
+
1016
+
1017
+ ##
1018
+
1019
+
1020
+ def check_versions() -> None:
1021
+ version = get_version_from_bumpversion_toml()
1022
+ try:
1023
+ set_version(version)
1024
+ except CalledProcessError:
1025
+ msg = f"Inconsistent versions; should be {version}"
1026
+ raise ValueError(msg) from None
1027
+
1028
+
1029
+ ##
1030
+
1031
+
1032
+ def ensure_aot_contains(array: AoT, /, *tables: Table) -> None:
1033
+ for table_ in tables:
1034
+ if table_ not in array:
1035
+ array.append(table_)
1036
+
1037
+
1038
+ def ensure_contains(array: HasAppend, /, *objs: Any) -> None:
1039
+ if isinstance(array, AoT):
1040
+ msg = f"Use {ensure_aot_contains.__name__!r} instead of {ensure_contains.__name__!r}"
1041
+ raise TypeError(msg)
1042
+ for obj in objs:
1043
+ if obj not in array:
1044
+ array.append(obj)
1045
+
1046
+
1047
+ def ensure_contains_partial(
1048
+ container: HasAppend, partial: StrDict, /, *, extra: StrDict | None = None
1049
+ ) -> StrDict:
1050
+ try:
1051
+ return get_partial_dict(container, partial, skip_log=True)
1052
+ except OneEmptyError:
1053
+ dict_ = partial | ({} if extra is None else extra)
1054
+ container.append(dict_)
1055
+ return dict_
1056
+
1057
+
1058
+ def ensure_not_contains(array: Array, /, *objs: Any) -> None:
1059
+ for obj in objs:
1060
+ try:
1061
+ index = next(i for i, o in enumerate(array) if o == obj)
1062
+ except StopIteration:
1063
+ pass
1064
+ else:
1065
+ del array[index]
1066
+
1067
+
1068
+ ##
1069
+
1070
+
1071
+ def get_aot(container: HasSetDefault, key: str, /) -> AoT:
1072
+ return ensure_class(container.setdefault(key, aot()), AoT)
1073
+
1074
+
1075
+ def get_array(container: HasSetDefault, key: str, /) -> Array:
1076
+ return ensure_class(container.setdefault(key, array()), Array)
1077
+
1078
+
1079
+ def get_dict(container: HasSetDefault, key: str, /) -> StrDict:
1080
+ return ensure_class(container.setdefault(key, {}), dict)
1081
+
1082
+
1083
+ def get_list(container: HasSetDefault, key: str, /) -> list[Any]:
1084
+ return ensure_class(container.setdefault(key, []), list)
1085
+
1086
+
1087
+ def get_table(container: HasSetDefault, key: str, /) -> Table:
1088
+ return ensure_class(container.setdefault(key, table()), Table)
1089
+
1090
+
1091
+ ##
1092
+
1093
+
1094
+ def get_partial_dict(
1095
+ iterable: Iterable[Any], dict_: StrDict, /, *, skip_log: bool = False
1096
+ ) -> StrDict:
1097
+ try:
1098
+ return one(i for i in iterable if is_partial_dict(dict_, i))
1099
+ except OneEmptyError:
1100
+ if not skip_log:
1101
+ LOGGER.exception(
1102
+ "Expected %s to contain %s (as a partial)",
1103
+ pretty_repr(iterable),
1104
+ pretty_repr(dict_),
1105
+ )
1106
+ raise
1107
+ except OneNonUniqueError as error:
1108
+ LOGGER.exception(
1109
+ "Expected %s to contain %s uniquely (as a partial); got %s, %s and perhaps more",
1110
+ pretty_repr(iterable),
1111
+ pretty_repr(dict_),
1112
+ pretty_repr(error.first),
1113
+ pretty_repr(error.second),
1114
+ )
1115
+ raise
1116
+
1117
+
1118
+ def is_partial_dict(obj: Any, dict_: StrDict, /) -> bool:
1119
+ if not isinstance(obj, dict):
1120
+ return False
1121
+ results: dict[str, bool] = {}
1122
+ for key, obj_value in obj.items():
1123
+ try:
1124
+ dict_value = dict_[key]
1125
+ except KeyError:
1126
+ results[key] = False
1127
+ else:
1128
+ if isinstance(obj_value, dict) and isinstance(dict_value, dict):
1129
+ results[key] = is_partial_dict(obj_value, dict_value)
1130
+ else:
1131
+ results[key] = obj_value == dict_value
1132
+ return all(results.values())
1133
+
1134
+
1135
+ ##
1136
+
1137
+
1138
+ def get_python_package_name(
1139
+ *,
1140
+ package_name: str | None = SETTINGS.package_name,
1141
+ python_package_name: str | None = SETTINGS.python_package_name,
1142
+ ) -> str | None:
1143
+ if python_package_name is not None:
1144
+ return python_package_name
1145
+ if package_name is not None:
1146
+ return package_name.replace("-", "_")
1147
+ return None
1148
+
1149
+
1150
+ ##
1151
+
1152
+
1153
+ def get_version_from_bumpversion_toml(
1154
+ *, obj: TOMLDocument | str | None = None
1155
+ ) -> Version:
1156
+ match obj:
1157
+ case TOMLDocument() as doc:
1158
+ tool = get_table(doc, "tool")
1159
+ bumpversion = get_table(tool, "bumpversion")
1160
+ return parse_version(str(bumpversion["current_version"]))
1161
+ case str() as text:
1162
+ return get_version_from_bumpversion_toml(obj=tomlkit.parse(text))
1163
+ case None:
1164
+ with yield_bumpversion_toml() as doc:
1165
+ return get_version_from_bumpversion_toml(obj=doc)
1166
+ case never:
1167
+ assert_never(never)
1168
+
1169
+
1170
+ def get_version_from_git_show() -> Version:
1171
+ text = run("git", "show", f"origin/master:{BUMPVERSION_TOML}", return_=True)
1172
+ return get_version_from_bumpversion_toml(obj=text.rstrip("\n"))
1173
+
1174
+
1175
+ def get_version_from_git_tag() -> Version:
1176
+ text = run("git", "tag", "--points-at", "origin/master", return_=True)
1177
+ for line in text.splitlines():
1178
+ with suppress(ParseVersionError):
1179
+ return parse_version(line)
1180
+ msg = "No valid version from 'git tag'"
1181
+ raise ValueError(msg)
1182
+
1183
+
1184
+ ##
1185
+
1186
+
1187
+ def run_bump_my_version(*, modifications: MutableSet[Path] | None = None) -> None:
1188
+ def run_set_version(version: Version, /) -> None:
1189
+ LOGGER.info("Setting version to %s...", version)
1190
+ set_version(version)
1191
+ if modifications is not None:
1192
+ modifications.add(BUMPVERSION_TOML)
1193
+
1194
+ try:
1195
+ prev = get_version_from_git_tag()
1196
+ except (CalledProcessError, ValueError):
1197
+ try:
1198
+ prev = get_version_from_git_show()
1199
+ except (CalledProcessError, ParseVersionError, NonExistentKey):
1200
+ run_set_version(Version(0, 1, 0))
1201
+ return
1202
+ current = get_version_from_bumpversion_toml()
1203
+ patched = prev.bump_patch()
1204
+ if current not in {patched, prev.bump_minor(), prev.bump_major()}:
1205
+ run_set_version(patched)
1206
+
1207
+
1208
+ ##
1209
+
1210
+
1211
+ def run_pre_commit_update(*, modifications: MutableSet[Path] | None = None) -> None:
1212
+ cache = xdg_cache_home() / "conformalize" / get_repo_root().name
1213
+
1214
+ def run_autoupdate() -> None:
1215
+ current = PRE_COMMIT_CONFIG_YAML.read_text()
1216
+ run("pre-commit", "autoupdate", print=True)
1217
+ with writer(cache, overwrite=True) as temp:
1218
+ _ = temp.write_text(get_now().format_iso())
1219
+ if (modifications is not None) and (
1220
+ PRE_COMMIT_CONFIG_YAML.read_text() != current
1221
+ ):
1222
+ modifications.add(PRE_COMMIT_CONFIG_YAML)
1223
+
1224
+ try:
1225
+ text = cache.read_text()
1226
+ except FileNotFoundError:
1227
+ run_autoupdate()
1228
+ else:
1229
+ prev = ZonedDateTime.parse_iso(text.rstrip("\n"))
1230
+ if prev < (get_now() - 12 * HOUR):
1231
+ run_autoupdate()
1232
+
1233
+
1234
+ ##
1235
+
1236
+
1237
+ def run_ripgrep_and_replace(
1238
+ *,
1239
+ version: str = SETTINGS.python_version,
1240
+ modifications: MutableSet[Path] | None = None,
1241
+ ) -> None:
1242
+ result = ripgrep(
1243
+ "--files-with-matches",
1244
+ "--pcre2",
1245
+ "--type=py",
1246
+ rf'# requires-python = ">=(?!{version})\d+\.\d+"',
1247
+ )
1248
+ if result is None:
1249
+ return
1250
+ for path in map(Path, result.splitlines()):
1251
+ with yield_text_file(path, modifications=modifications) as temp:
1252
+ text = sub(
1253
+ r'# requires-python = ">=\d+\.\d+"',
1254
+ rf'# requires-python = ">={version}"',
1255
+ path.read_text(),
1256
+ flags=MULTILINE,
1257
+ )
1258
+ _ = temp.write_text(text)
1259
+
1260
+
1261
+ ##
1262
+
1263
+
1264
+ def set_version(version: Version, /) -> None:
1265
+ run(
1266
+ "bump-my-version",
1267
+ "replace",
1268
+ "--new-version",
1269
+ str(version),
1270
+ str(BUMPVERSION_TOML),
1271
+ )
1272
+
1273
+
1274
+ ##
1275
+
1276
+
1277
+ def update_action_file_extensions(
1278
+ *, modifications: MutableSet[Path] | None = None
1279
+ ) -> None:
1280
+ try:
1281
+ paths = list(Path(".github").rglob("**/*.yml"))
1282
+ except FileNotFoundError:
1283
+ return
1284
+ for path in paths:
1285
+ new = path.with_suffix(".yaml")
1286
+ LOGGER.info("Renaming '%s' -> '%s'...", path, new)
1287
+ _ = path.rename(new)
1288
+ if modifications is not None:
1289
+ modifications.add(path)
1290
+
1291
+
1292
+ ##
1293
+
1294
+
1295
+ def update_action_versions(*, modifications: MutableSet[Path] | None = None) -> None:
1296
+ try:
1297
+ paths = list(Path(".github").rglob("**/*.yaml"))
1298
+ except FileNotFoundError:
1299
+ return
1300
+ versions = {
1301
+ "actions/checkout": "v6",
1302
+ "actions/setup-python": "v6",
1303
+ "astral-sh/ruff-action": "v3",
1304
+ "astral-sh/setup-uv": "v7",
1305
+ }
1306
+ for path, (action, version) in product(paths, versions.items()):
1307
+ text = sub(
1308
+ rf"^(\s*- uses: {action})@.+$",
1309
+ rf"\1@{version}",
1310
+ path.read_text(),
1311
+ flags=MULTILINE,
1312
+ )
1313
+ with yield_yaml_dict(path, modifications=modifications) as dict_:
1314
+ dict_.clear()
1315
+ dict_.update(YAML_INSTANCE.load(text))
1316
+
1317
+
1318
+ ##
1319
+
1320
+
1321
+ def write_text(
1322
+ verb: str,
1323
+ src: PathLike,
1324
+ dest: PathLike,
1325
+ /,
1326
+ *,
1327
+ modifications: MutableSet[Path] | None = None,
1328
+ ) -> None:
1329
+ src, dest = map(Path, [src, dest])
1330
+ LOGGER.info("%s '%s'...", verb, dest)
1331
+ text = src.read_text().rstrip("\n") + "\n"
1332
+ with writer(dest, overwrite=True) as temp:
1333
+ _ = temp.write_text(text)
1334
+ if modifications is not None:
1335
+ modifications.add(dest)
1336
+
1337
+
1338
+ ##
1339
+
1340
+
1341
+ def yaml_dump(obj: Any, /) -> str:
1342
+ stream = StringIO()
1343
+ YAML_INSTANCE.dump(obj, stream)
1344
+ return stream.getvalue()
1345
+
1346
+
1347
+ ##
1348
+
1349
+
1350
+ @contextmanager
1351
+ def yield_bumpversion_toml(
1352
+ *, modifications: MutableSet[Path] | None = None
1353
+ ) -> Iterator[TOMLDocument]:
1354
+ with yield_toml_doc(BUMPVERSION_TOML, modifications=modifications) as doc:
1355
+ tool = get_table(doc, "tool")
1356
+ bumpversion = get_table(tool, "bumpversion")
1357
+ bumpversion["allow_dirty"] = True
1358
+ bumpversion.setdefault("current_version", str(Version(0, 1, 0)))
1359
+ yield doc
1360
+
1361
+
1362
+ ##
1363
+
1364
+
1365
+ @contextmanager
1366
+ def yield_json_dict(
1367
+ path: PathLike, /, *, modifications: MutableSet[Path] | None = None
1368
+ ) -> Iterator[StrDict]:
1369
+ with yield_write_context(
1370
+ path, json.loads, dict, json.dumps, modifications=modifications
1371
+ ) as dict_:
1372
+ yield dict_
1373
+
1374
+
1375
+ ##
1376
+
1377
+
1378
+ def yield_python_versions(
1379
+ version: str, /, *, max_: str = MAX_PYTHON_VERSION
1380
+ ) -> Iterator[str]:
1381
+ major, minor = _yield_python_version_tuple(version)
1382
+ max_major, max_minor = _yield_python_version_tuple(max_)
1383
+ if major != max_major:
1384
+ msg = f"Major versions must be equal; got {major} and {max_major}"
1385
+ raise ValueError(msg)
1386
+ if minor > max_minor:
1387
+ msg = f"Minor version must be at most {max_minor}; got {minor}"
1388
+ raise ValueError(msg)
1389
+ for i in range(minor, max_minor + 1):
1390
+ yield f"{major}.{i}"
1391
+
1392
+
1393
+ def _yield_python_version_tuple(version: str, /) -> tuple[int, int]:
1394
+ major, minor = extract_groups(r"^(\d+)\.(\d+)$", version)
1395
+ return int(major), int(minor)
1396
+
1397
+
1398
+ ##
1399
+
1400
+
1401
+ @contextmanager
1402
+ def yield_text_file(
1403
+ path: PathLike, /, *, modifications: MutableSet[Path] | None = None
1404
+ ) -> Iterator[Path]:
1405
+ path = Path(path)
1406
+ try:
1407
+ current = path.read_text()
1408
+ except FileNotFoundError:
1409
+ with TemporaryFile() as temp:
1410
+ yield temp
1411
+ write_text("Writing", temp, path, modifications=modifications)
1412
+ else:
1413
+ with TemporaryFile(text=current) as temp:
1414
+ yield temp
1415
+ if temp.read_text().rstrip("\n") != current.rstrip("\n"):
1416
+ write_text("Writing", temp, path, modifications=modifications)
1417
+
1418
+
1419
+ ##
1420
+
1421
+
1422
+ @contextmanager
1423
+ def yield_toml_doc(
1424
+ path: PathLike, /, *, modifications: MutableSet[Path] | None = None
1425
+ ) -> Iterator[TOMLDocument]:
1426
+ with yield_write_context(
1427
+ path, tomlkit.parse, document, tomlkit.dumps, modifications=modifications
1428
+ ) as doc:
1429
+ yield doc
1430
+
1431
+
1432
+ ##
1433
+
1434
+
1435
+ @contextmanager
1436
+ def yield_write_context[T](
1437
+ path: PathLike,
1438
+ loads: Callable[[str], T],
1439
+ get_default: Callable[[], T],
1440
+ dumps: Callable[[T], str],
1441
+ /,
1442
+ *,
1443
+ modifications: MutableSet[Path] | None = None,
1444
+ ) -> Iterator[T]:
1445
+ path = Path(path)
1446
+
1447
+ def run_write(verb: str, data: T, /) -> None:
1448
+ with writer(path, overwrite=True) as temp:
1449
+ _ = temp.write_text(dumps(data))
1450
+ write_text(verb, temp, path, modifications=modifications)
1451
+
1452
+ try:
1453
+ current = path.read_text()
1454
+ except FileNotFoundError:
1455
+ yield (default := get_default())
1456
+ run_write("Writing", default)
1457
+ else:
1458
+ data = loads(current)
1459
+ yield data
1460
+ is_equal = data == loads(current) # tomlkit cannot handle !=
1461
+ if not is_equal:
1462
+ run_write("Modifying", data)
1463
+
1464
+
1465
+ ##
1466
+
1467
+
1468
+ @contextmanager
1469
+ def yield_yaml_dict(
1470
+ path: PathLike, /, *, modifications: MutableSet[Path] | None = None
1471
+ ) -> Iterator[StrDict]:
1472
+ with yield_write_context(
1473
+ path, YAML_INSTANCE.load, dict, yaml_dump, modifications=modifications
1474
+ ) as dict_:
1475
+ yield dict_
1476
+
1477
+
1478
+ __all__ = [
1479
+ "add_bumpversion_toml",
1480
+ "add_coveragerc_toml",
1481
+ "add_envrc",
1482
+ "add_github_pull_request_yaml",
1483
+ "add_github_push_yaml",
1484
+ "add_gitignore",
1485
+ "add_pre_commit_config_yaml",
1486
+ "add_pyproject_toml",
1487
+ "add_pyrightconfig_json",
1488
+ "add_pytest_toml",
1489
+ "add_readme_md",
1490
+ "add_ruff_toml",
1491
+ "check_versions",
1492
+ "ensure_aot_contains",
1493
+ "ensure_contains",
1494
+ "ensure_contains_partial",
1495
+ "ensure_not_contains",
1496
+ "get_aot",
1497
+ "get_array",
1498
+ "get_dict",
1499
+ "get_list",
1500
+ "get_partial_dict",
1501
+ "get_python_package_name",
1502
+ "get_table",
1503
+ "get_version_from_bumpversion_toml",
1504
+ "get_version_from_git_show",
1505
+ "get_version_from_git_tag",
1506
+ "is_partial_dict",
1507
+ "run_bump_my_version",
1508
+ "run_pre_commit_update",
1509
+ "run_ripgrep_and_replace",
1510
+ "set_version",
1511
+ "update_action_file_extensions",
1512
+ "update_action_versions",
1513
+ "write_text",
1514
+ "yaml_dump",
1515
+ "yield_bumpversion_toml",
1516
+ "yield_json_dict",
1517
+ "yield_python_versions",
1518
+ "yield_text_file",
1519
+ "yield_toml_doc",
1520
+ "yield_write_context",
1521
+ "yield_yaml_dict",
1522
+ ]