pyrig 2.2.6__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 (102) hide show
  1. pyrig/__init__.py +1 -0
  2. pyrig/dev/__init__.py +6 -0
  3. pyrig/dev/builders/__init__.py +1 -0
  4. pyrig/dev/builders/base/__init__.py +5 -0
  5. pyrig/dev/builders/base/base.py +256 -0
  6. pyrig/dev/builders/pyinstaller.py +229 -0
  7. pyrig/dev/cli/__init__.py +5 -0
  8. pyrig/dev/cli/cli.py +95 -0
  9. pyrig/dev/cli/commands/__init__.py +1 -0
  10. pyrig/dev/cli/commands/build_artifacts.py +16 -0
  11. pyrig/dev/cli/commands/create_root.py +25 -0
  12. pyrig/dev/cli/commands/create_tests.py +244 -0
  13. pyrig/dev/cli/commands/init_project.py +160 -0
  14. pyrig/dev/cli/commands/make_inits.py +27 -0
  15. pyrig/dev/cli/commands/protect_repo.py +145 -0
  16. pyrig/dev/cli/shared_subcommands.py +20 -0
  17. pyrig/dev/cli/subcommands.py +73 -0
  18. pyrig/dev/configs/__init__.py +1 -0
  19. pyrig/dev/configs/base/__init__.py +5 -0
  20. pyrig/dev/configs/base/base.py +826 -0
  21. pyrig/dev/configs/containers/__init__.py +1 -0
  22. pyrig/dev/configs/containers/container_file.py +111 -0
  23. pyrig/dev/configs/dot_env.py +95 -0
  24. pyrig/dev/configs/dot_python_version.py +88 -0
  25. pyrig/dev/configs/git/__init__.py +5 -0
  26. pyrig/dev/configs/git/gitignore.py +181 -0
  27. pyrig/dev/configs/git/pre_commit.py +170 -0
  28. pyrig/dev/configs/licence.py +112 -0
  29. pyrig/dev/configs/markdown/__init__.py +1 -0
  30. pyrig/dev/configs/markdown/docs/__init__.py +1 -0
  31. pyrig/dev/configs/markdown/docs/index.py +38 -0
  32. pyrig/dev/configs/markdown/readme.py +132 -0
  33. pyrig/dev/configs/py_typed.py +28 -0
  34. pyrig/dev/configs/pyproject.py +436 -0
  35. pyrig/dev/configs/python/__init__.py +5 -0
  36. pyrig/dev/configs/python/builders_init.py +27 -0
  37. pyrig/dev/configs/python/configs_init.py +28 -0
  38. pyrig/dev/configs/python/dot_experiment.py +46 -0
  39. pyrig/dev/configs/python/main.py +59 -0
  40. pyrig/dev/configs/python/resources_init.py +27 -0
  41. pyrig/dev/configs/python/shared_subcommands.py +29 -0
  42. pyrig/dev/configs/python/src_init.py +27 -0
  43. pyrig/dev/configs/python/subcommands.py +27 -0
  44. pyrig/dev/configs/testing/__init__.py +5 -0
  45. pyrig/dev/configs/testing/conftest.py +64 -0
  46. pyrig/dev/configs/testing/fixtures_init.py +27 -0
  47. pyrig/dev/configs/testing/main_test.py +74 -0
  48. pyrig/dev/configs/testing/zero_test.py +43 -0
  49. pyrig/dev/configs/workflows/__init__.py +5 -0
  50. pyrig/dev/configs/workflows/base/__init__.py +5 -0
  51. pyrig/dev/configs/workflows/base/base.py +1662 -0
  52. pyrig/dev/configs/workflows/build.py +106 -0
  53. pyrig/dev/configs/workflows/health_check.py +133 -0
  54. pyrig/dev/configs/workflows/publish.py +68 -0
  55. pyrig/dev/configs/workflows/release.py +90 -0
  56. pyrig/dev/tests/__init__.py +5 -0
  57. pyrig/dev/tests/conftest.py +40 -0
  58. pyrig/dev/tests/fixtures/__init__.py +1 -0
  59. pyrig/dev/tests/fixtures/assertions.py +147 -0
  60. pyrig/dev/tests/fixtures/autouse/__init__.py +5 -0
  61. pyrig/dev/tests/fixtures/autouse/class_.py +42 -0
  62. pyrig/dev/tests/fixtures/autouse/module.py +40 -0
  63. pyrig/dev/tests/fixtures/autouse/session.py +589 -0
  64. pyrig/dev/tests/fixtures/factories.py +118 -0
  65. pyrig/dev/utils/__init__.py +1 -0
  66. pyrig/dev/utils/cli.py +17 -0
  67. pyrig/dev/utils/git.py +312 -0
  68. pyrig/dev/utils/packages.py +93 -0
  69. pyrig/dev/utils/resources.py +77 -0
  70. pyrig/dev/utils/testing.py +66 -0
  71. pyrig/dev/utils/versions.py +268 -0
  72. pyrig/main.py +9 -0
  73. pyrig/py.typed +0 -0
  74. pyrig/resources/GITIGNORE +216 -0
  75. pyrig/resources/LATEST_PYTHON_VERSION +1 -0
  76. pyrig/resources/MIT_LICENSE_TEMPLATE +21 -0
  77. pyrig/resources/__init__.py +1 -0
  78. pyrig/src/__init__.py +1 -0
  79. pyrig/src/git/__init__.py +6 -0
  80. pyrig/src/git/git.py +146 -0
  81. pyrig/src/graph.py +255 -0
  82. pyrig/src/iterate.py +107 -0
  83. pyrig/src/modules/__init__.py +22 -0
  84. pyrig/src/modules/class_.py +369 -0
  85. pyrig/src/modules/function.py +189 -0
  86. pyrig/src/modules/inspection.py +148 -0
  87. pyrig/src/modules/module.py +658 -0
  88. pyrig/src/modules/package.py +452 -0
  89. pyrig/src/os/__init__.py +6 -0
  90. pyrig/src/os/os.py +121 -0
  91. pyrig/src/project/__init__.py +5 -0
  92. pyrig/src/project/mgt.py +83 -0
  93. pyrig/src/resource.py +58 -0
  94. pyrig/src/string.py +100 -0
  95. pyrig/src/testing/__init__.py +6 -0
  96. pyrig/src/testing/assertions.py +66 -0
  97. pyrig/src/testing/convention.py +203 -0
  98. pyrig-2.2.6.dist-info/METADATA +174 -0
  99. pyrig-2.2.6.dist-info/RECORD +102 -0
  100. pyrig-2.2.6.dist-info/WHEEL +4 -0
  101. pyrig-2.2.6.dist-info/entry_points.txt +3 -0
  102. pyrig-2.2.6.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1662 @@
1
+ """Base class for GitHub Actions workflow configuration.
2
+
3
+ This module provides the Workflow base class that all workflow
4
+ configuration files inherit from. It includes utilities for
5
+ building jobs, steps, triggers, and matrix strategies.
6
+ """
7
+
8
+ from abc import abstractmethod
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pyrig
14
+ from pyrig.dev.builders.base.base import Builder
15
+ from pyrig.dev.configs.base.base import YamlConfigFile
16
+ from pyrig.dev.configs.pyproject import PyprojectConfigFile
17
+ from pyrig.dev.utils.packages import get_src_package
18
+ from pyrig.src.modules.package import (
19
+ DependencyGraph,
20
+ )
21
+ from pyrig.src.project.mgt import (
22
+ PROJECT_MGT,
23
+ PROJECT_MGT_RUN_SCRIPT,
24
+ get_project_mgt_run_pyrig_cli_cmd_script,
25
+ )
26
+ from pyrig.src.string import (
27
+ make_name_from_obj,
28
+ split_on_uppercase,
29
+ )
30
+
31
+
32
+ class Workflow(YamlConfigFile):
33
+ """Abstract base class for GitHub Actions workflow configuration.
34
+
35
+ Provides a declarative API for building workflow YAML files with
36
+ jobs, steps, triggers, and matrix strategies. Subclasses must
37
+ implement get_jobs() to define the workflow's jobs.
38
+
39
+ Attributes:
40
+ UBUNTU_LATEST: Runner label for Ubuntu.
41
+ WINDOWS_LATEST: Runner label for Windows.
42
+ MACOS_LATEST: Runner label for macOS.
43
+ ARTIFACTS_DIR_NAME: Directory name for build artifacts.
44
+ ARTIFACTS_PATTERN: Glob pattern for artifact files.
45
+ """
46
+
47
+ UBUNTU_LATEST = "ubuntu-latest"
48
+ WINDOWS_LATEST = "windows-latest"
49
+ MACOS_LATEST = "macos-latest"
50
+
51
+ ARTIFACTS_DIR_NAME = Builder.ARTIFACTS_DIR_NAME
52
+ ARTIFACTS_PATTERN = f"{ARTIFACTS_DIR_NAME}/*"
53
+
54
+ @classmethod
55
+ def load(cls) -> dict[str, Any]:
56
+ """Load and parse the workflow configuration file.
57
+
58
+ Returns:
59
+ The parsed workflow configuration as a dict.
60
+ """
61
+ content = super().load()
62
+ if not isinstance(content, dict):
63
+ msg = f"Expected dict, got {type(content)}"
64
+ raise TypeError(msg)
65
+ return content
66
+
67
+ @classmethod
68
+ def get_configs(cls) -> dict[str, Any]:
69
+ """Build the complete workflow configuration.
70
+
71
+ Returns:
72
+ Dict with name, triggers, permissions, defaults, env, and jobs.
73
+ """
74
+ return {
75
+ "name": cls.get_workflow_name(),
76
+ "on": cls.get_workflow_triggers(),
77
+ "permissions": cls.get_permissions(),
78
+ "run-name": cls.get_run_name(),
79
+ "defaults": cls.get_defaults(),
80
+ "env": cls.get_global_env(),
81
+ "jobs": cls.get_jobs(),
82
+ }
83
+
84
+ @classmethod
85
+ def get_parent_path(cls) -> Path:
86
+ """Get the parent directory for workflow files.
87
+
88
+ Returns:
89
+ Path to .github/workflows directory.
90
+ """
91
+ return Path(".github/workflows")
92
+
93
+ @classmethod
94
+ def is_correct(cls) -> bool:
95
+ """Check if the workflow configuration is correct.
96
+
97
+ Handles the special case where workflow files cannot be empty.
98
+ If empty, writes a minimal valid workflow that never triggers.
99
+
100
+ Returns:
101
+ True if configuration matches expected state.
102
+ """
103
+ correct = super().is_correct()
104
+
105
+ if cls.get_path().read_text(encoding="utf-8") == "":
106
+ config = cls.get_configs()
107
+ jobs = config["jobs"]
108
+ for job in jobs.values():
109
+ job["steps"] = [cls.step_opt_out_of_workflow()]
110
+ cls.dump(config)
111
+
112
+ config = cls.load()
113
+ jobs = config["jobs"]
114
+ opted_out = all(
115
+ job["steps"] == [cls.step_opt_out_of_workflow()] for job in jobs.values()
116
+ )
117
+
118
+ return correct or opted_out
119
+
120
+ # Overridable Workflow Parts
121
+ # ----------------------------------------------------------------------------
122
+ @classmethod
123
+ @abstractmethod
124
+ def get_jobs(cls) -> dict[str, Any]:
125
+ """Get the workflow jobs.
126
+
127
+ Subclasses must implement this to define their jobs.
128
+
129
+ Returns:
130
+ Dict mapping job IDs to job configurations.
131
+ """
132
+
133
+ @classmethod
134
+ def get_workflow_triggers(cls) -> dict[str, Any]:
135
+ """Get the workflow triggers.
136
+
137
+ Override to customize when the workflow runs.
138
+ Default is manual workflow_dispatch only.
139
+
140
+ Returns:
141
+ Dict of trigger configurations.
142
+ """
143
+ return cls.on_workflow_dispatch()
144
+
145
+ @classmethod
146
+ def get_permissions(cls) -> dict[str, Any]:
147
+ """Get the workflow permissions.
148
+
149
+ Override to request additional permissions.
150
+ Default is no extra permissions.
151
+
152
+ Returns:
153
+ Dict of permission settings.
154
+ """
155
+ return {}
156
+
157
+ @classmethod
158
+ def get_defaults(cls) -> dict[str, Any]:
159
+ """Get the workflow defaults.
160
+
161
+ Override to customize default settings.
162
+ Default uses bash shell.
163
+
164
+ Returns:
165
+ Dict of default settings.
166
+ """
167
+ return {"run": {"shell": "bash"}}
168
+
169
+ @classmethod
170
+ def get_global_env(cls) -> dict[str, Any]:
171
+ """Get the global environment variables.
172
+
173
+ Override to add environment variables.
174
+ Default disables Python bytecode writing.
175
+
176
+ Returns:
177
+ Dict of environment variables.
178
+ """
179
+ return {"PYTHONDONTWRITEBYTECODE": 1, "UV_NO_SYNC": 1}
180
+
181
+ # Workflow Conventions
182
+ # ----------------------------------------------------------------------------
183
+ @classmethod
184
+ def get_workflow_name(cls) -> str:
185
+ """Generate a human-readable workflow name from the class name.
186
+
187
+ Returns:
188
+ Class name split on uppercase letters and joined with spaces.
189
+ """
190
+ name = cls.__name__.removesuffix(Workflow.__name__)
191
+ return " ".join(split_on_uppercase(name))
192
+
193
+ @classmethod
194
+ def get_run_name(cls) -> str:
195
+ """Get the display name for workflow runs.
196
+
197
+ Returns:
198
+ The workflow name by default.
199
+ """
200
+ return cls.get_workflow_name()
201
+
202
+ # Build Utilities
203
+ # ----------------------------------------------------------------------------
204
+ @classmethod
205
+ def get_job( # noqa: PLR0913
206
+ cls,
207
+ job_func: Callable[..., Any],
208
+ needs: list[str] | None = None,
209
+ strategy: dict[str, Any] | None = None,
210
+ permissions: dict[str, Any] | None = None,
211
+ runs_on: str = UBUNTU_LATEST,
212
+ if_condition: str | None = None,
213
+ steps: list[dict[str, Any]] | None = None,
214
+ job: dict[str, Any] | None = None,
215
+ ) -> dict[str, Any]:
216
+ """Build a job configuration.
217
+
218
+ Args:
219
+ job_func: Function representing the job, used to generate the ID.
220
+ needs: List of job IDs this job depends on.
221
+ strategy: Matrix or other strategy configuration.
222
+ permissions: Job-level permissions.
223
+ runs_on: Runner label. Defaults to ubuntu-latest.
224
+ if_condition: Conditional expression for job execution.
225
+ steps: List of step configurations.
226
+ job: Existing job dict to update.
227
+
228
+ Returns:
229
+ Dict mapping job ID to job configuration.
230
+ """
231
+ name = cls.make_id_from_func(job_func)
232
+ if job is None:
233
+ job = {}
234
+ job_config: dict[str, Any] = {}
235
+ if needs is not None:
236
+ job_config["needs"] = needs
237
+ if strategy is not None:
238
+ job_config["strategy"] = strategy
239
+ if permissions is not None:
240
+ job_config["permissions"] = permissions
241
+ job_config["runs-on"] = runs_on
242
+ if if_condition is not None:
243
+ job_config["if"] = if_condition
244
+ if steps is not None:
245
+ job_config["steps"] = steps
246
+ job_config.update(job)
247
+ return {name: job_config}
248
+
249
+ @classmethod
250
+ def make_name_from_func(cls, func: Callable[..., Any]) -> str:
251
+ """Generate a human-readable name from a function.
252
+
253
+ Args:
254
+ func: Function to extract name from.
255
+
256
+ Returns:
257
+ Formatted name with prefix removed.
258
+ """
259
+ name = make_name_from_obj(func, split_on="_", join_on=" ", capitalize=True)
260
+ prefix = split_on_uppercase(name)[0]
261
+ return name.removeprefix(prefix).strip()
262
+
263
+ @classmethod
264
+ def make_id_from_func(cls, func: Callable[..., Any]) -> str:
265
+ """Generate a job/step ID from a function name.
266
+
267
+ Args:
268
+ func: Function to extract ID from.
269
+
270
+ Returns:
271
+ Function name with prefix removed.
272
+ """
273
+ name = getattr(func, "__name__", "")
274
+ if not name:
275
+ msg = f"Cannot extract name from {func}"
276
+ raise ValueError(msg)
277
+ prefix = name.split("_")[0]
278
+ return name.removeprefix(f"{prefix}_")
279
+
280
+ # triggers
281
+ @classmethod
282
+ def on_workflow_dispatch(cls) -> dict[str, Any]:
283
+ """Create a manual workflow dispatch trigger.
284
+
285
+ Returns:
286
+ Trigger configuration for manual runs.
287
+ """
288
+ return {"workflow_dispatch": {}}
289
+
290
+ @classmethod
291
+ def on_push(cls, branches: list[str] | None = None) -> dict[str, Any]:
292
+ """Create a push trigger.
293
+
294
+ Args:
295
+ branches: Branches to trigger on. Defaults to ["main"].
296
+
297
+ Returns:
298
+ Trigger configuration for push events.
299
+ """
300
+ if branches is None:
301
+ branches = ["main"]
302
+ return {"push": {"branches": branches}}
303
+
304
+ @classmethod
305
+ def on_schedule(cls, cron: str) -> dict[str, Any]:
306
+ """Create a scheduled trigger.
307
+
308
+ Args:
309
+ cron: Cron expression for the schedule.
310
+
311
+ Returns:
312
+ Trigger configuration for scheduled runs.
313
+ """
314
+ return {"schedule": [{"cron": cron}]}
315
+
316
+ @classmethod
317
+ def on_pull_request(cls, types: list[str] | None = None) -> dict[str, Any]:
318
+ """Create a pull request trigger.
319
+
320
+ Args:
321
+ types: PR event types. Defaults to opened, synchronize, reopened.
322
+
323
+ Returns:
324
+ Trigger configuration for pull request events.
325
+ """
326
+ if types is None:
327
+ types = ["opened", "synchronize", "reopened"]
328
+ return {"pull_request": {"types": types}}
329
+
330
+ @classmethod
331
+ def on_workflow_run(
332
+ cls, workflows: list[str] | None = None, branches: list[str] | None = None
333
+ ) -> dict[str, Any]:
334
+ """Create a workflow run trigger.
335
+
336
+ Args:
337
+ workflows: Workflow names to trigger on. Defaults to this workflow.
338
+ branches: Branches to filter on.
339
+
340
+ Returns:
341
+ Trigger configuration for workflow completion events.
342
+ """
343
+ if workflows is None:
344
+ workflows = [cls.get_workflow_name()]
345
+ config: dict[str, Any] = {"workflows": workflows, "types": ["completed"]}
346
+ if branches is not None:
347
+ config["branches"] = branches
348
+ return {"workflow_run": config}
349
+
350
+ # permissions
351
+ @classmethod
352
+ def permission_content(cls, permission: str = "read") -> dict[str, Any]:
353
+ """Create a contents permission configuration.
354
+
355
+ Args:
356
+ permission: Permission level (read, write, none).
357
+
358
+ Returns:
359
+ Dict with contents permission.
360
+ """
361
+ return {"contents": permission}
362
+
363
+ # Steps
364
+ @classmethod
365
+ def get_step( # noqa: PLR0913
366
+ cls,
367
+ step_func: Callable[..., Any],
368
+ run: str | None = None,
369
+ if_condition: str | None = None,
370
+ uses: str | None = None,
371
+ with_: dict[str, Any] | None = None,
372
+ env: dict[str, Any] | None = None,
373
+ step: dict[str, Any] | None = None,
374
+ ) -> dict[str, Any]:
375
+ """Build a step configuration.
376
+
377
+ Args:
378
+ step_func: Function representing the step, used to generate name/ID.
379
+ run: Shell command to execute.
380
+ if_condition: Conditional expression for step execution.
381
+ uses: GitHub Action to use.
382
+ with_: Input parameters for the action.
383
+ env: Environment variables for the step.
384
+ step: Existing step dict to update.
385
+
386
+ Returns:
387
+ Step configuration dict.
388
+ """
389
+ if step is None:
390
+ step = {}
391
+ # make name from setup function name if name is a function
392
+ name = cls.make_name_from_func(step_func)
393
+ id_ = cls.make_id_from_func(step_func)
394
+ step_config: dict[str, Any] = {"name": name, "id": id_}
395
+ if run is not None:
396
+ step_config["run"] = run
397
+ if if_condition is not None:
398
+ step_config["if"] = if_condition
399
+ if uses is not None:
400
+ step_config["uses"] = uses
401
+ if with_ is not None:
402
+ step_config["with"] = with_
403
+ if env is not None:
404
+ step_config["env"] = env
405
+
406
+ step_config.update(step)
407
+
408
+ return step_config
409
+
410
+ # Strategy
411
+ @classmethod
412
+ def strategy_matrix_os_and_python_version(
413
+ cls,
414
+ os: list[str] | None = None,
415
+ python_version: list[str] | None = None,
416
+ matrix: dict[str, list[Any]] | None = None,
417
+ strategy: dict[str, Any] | None = None,
418
+ ) -> dict[str, Any]:
419
+ """Create a strategy with OS and Python version matrix.
420
+
421
+ Args:
422
+ os: List of OS runners. Defaults to all major platforms.
423
+ python_version: List of Python versions. Defaults to supported versions.
424
+ matrix: Additional matrix dimensions.
425
+ strategy: Additional strategy options.
426
+
427
+ Returns:
428
+ Strategy configuration with OS and Python matrix.
429
+ """
430
+ return cls.strategy_matrix(
431
+ matrix=cls.matrix_os_and_python_version(
432
+ os=os, python_version=python_version, matrix=matrix
433
+ ),
434
+ strategy=strategy,
435
+ )
436
+
437
+ @classmethod
438
+ def strategy_matrix_python_version(
439
+ cls,
440
+ python_version: list[str] | None = None,
441
+ matrix: dict[str, list[Any]] | None = None,
442
+ strategy: dict[str, Any] | None = None,
443
+ ) -> dict[str, Any]:
444
+ """Create a strategy with Python version matrix.
445
+
446
+ Args:
447
+ python_version: List of Python versions. Defaults to supported versions.
448
+ matrix: Additional matrix dimensions.
449
+ strategy: Additional strategy options.
450
+
451
+ Returns:
452
+ Strategy configuration with Python version matrix.
453
+ """
454
+ return cls.strategy_matrix(
455
+ matrix=cls.matrix_python_version(
456
+ python_version=python_version, matrix=matrix
457
+ ),
458
+ strategy=strategy,
459
+ )
460
+
461
+ @classmethod
462
+ def strategy_matrix_os(
463
+ cls,
464
+ os: list[str] | None = None,
465
+ matrix: dict[str, list[Any]] | None = None,
466
+ strategy: dict[str, Any] | None = None,
467
+ ) -> dict[str, Any]:
468
+ """Create a strategy with OS matrix.
469
+
470
+ Args:
471
+ os: List of OS runners. Defaults to all major platforms.
472
+ matrix: Additional matrix dimensions.
473
+ strategy: Additional strategy options.
474
+
475
+ Returns:
476
+ Strategy configuration with OS matrix.
477
+ """
478
+ return cls.strategy_matrix(
479
+ matrix=cls.matrix_os(os=os, matrix=matrix), strategy=strategy
480
+ )
481
+
482
+ @classmethod
483
+ def strategy_matrix(
484
+ cls,
485
+ *,
486
+ strategy: dict[str, Any] | None = None,
487
+ matrix: dict[str, list[Any]] | None = None,
488
+ ) -> dict[str, Any]:
489
+ """Create a matrix strategy configuration.
490
+
491
+ Args:
492
+ strategy: Base strategy options.
493
+ matrix: Matrix dimensions.
494
+
495
+ Returns:
496
+ Strategy configuration with matrix.
497
+ """
498
+ if strategy is None:
499
+ strategy = {}
500
+ if matrix is None:
501
+ matrix = {}
502
+ strategy["matrix"] = matrix
503
+ return cls.get_strategy(strategy=strategy)
504
+
505
+ @classmethod
506
+ def get_strategy(
507
+ cls,
508
+ *,
509
+ strategy: dict[str, Any],
510
+ ) -> dict[str, Any]:
511
+ """Finalize a strategy configuration.
512
+
513
+ Args:
514
+ strategy: Strategy configuration to finalize.
515
+
516
+ Returns:
517
+ Strategy with fail-fast defaulting to True.
518
+ """
519
+ strategy["fail-fast"] = strategy.pop("fail-fast", True)
520
+ return strategy
521
+
522
+ @classmethod
523
+ def matrix_os_and_python_version(
524
+ cls,
525
+ os: list[str] | None = None,
526
+ python_version: list[str] | None = None,
527
+ matrix: dict[str, list[Any]] | None = None,
528
+ ) -> dict[str, Any]:
529
+ """Create a matrix with OS and Python version dimensions.
530
+
531
+ Args:
532
+ os: List of OS runners. Defaults to all major platforms.
533
+ python_version: List of Python versions. Defaults to supported versions.
534
+ matrix: Additional matrix dimensions.
535
+
536
+ Returns:
537
+ Matrix configuration with os and python-version.
538
+ """
539
+ if matrix is None:
540
+ matrix = {}
541
+ os_matrix = cls.matrix_os(os=os, matrix=matrix)["os"]
542
+ python_version_matrix = cls.matrix_python_version(
543
+ python_version=python_version, matrix=matrix
544
+ )["python-version"]
545
+ matrix["os"] = os_matrix
546
+ matrix["python-version"] = python_version_matrix
547
+ return cls.get_matrix(matrix=matrix)
548
+
549
+ @classmethod
550
+ def matrix_os(
551
+ cls,
552
+ *,
553
+ os: list[str] | None = None,
554
+ matrix: dict[str, list[Any]] | None = None,
555
+ ) -> dict[str, Any]:
556
+ """Create a matrix with OS dimension.
557
+
558
+ Args:
559
+ os: List of OS runners. Defaults to Ubuntu, Windows, macOS.
560
+ matrix: Additional matrix dimensions.
561
+
562
+ Returns:
563
+ Matrix configuration with os.
564
+ """
565
+ if os is None:
566
+ os = [cls.UBUNTU_LATEST, cls.WINDOWS_LATEST, cls.MACOS_LATEST]
567
+ if matrix is None:
568
+ matrix = {}
569
+ matrix["os"] = os
570
+ return cls.get_matrix(matrix=matrix)
571
+
572
+ @classmethod
573
+ def matrix_python_version(
574
+ cls,
575
+ *,
576
+ python_version: list[str] | None = None,
577
+ matrix: dict[str, list[Any]] | None = None,
578
+ ) -> dict[str, Any]:
579
+ """Create a matrix with Python version dimension.
580
+
581
+ Args:
582
+ python_version: List of Python versions. Defaults to supported versions.
583
+ matrix: Additional matrix dimensions.
584
+
585
+ Returns:
586
+ Matrix configuration with python-version.
587
+ """
588
+ if python_version is None:
589
+ python_version = [
590
+ str(v) for v in PyprojectConfigFile.get_supported_python_versions()
591
+ ]
592
+ if matrix is None:
593
+ matrix = {}
594
+ matrix["python-version"] = python_version
595
+ return cls.get_matrix(matrix=matrix)
596
+
597
+ @classmethod
598
+ def get_matrix(cls, matrix: dict[str, list[Any]]) -> dict[str, Any]:
599
+ """Return the matrix configuration.
600
+
601
+ Args:
602
+ matrix: Matrix dimensions.
603
+
604
+ Returns:
605
+ The matrix configuration unchanged.
606
+ """
607
+ return matrix
608
+
609
+ # Workflow Steps
610
+ # ----------------------------------------------------------------------------
611
+ # Combined Steps
612
+ @classmethod
613
+ def steps_core_setup(
614
+ cls, python_version: str | None = None, *, repo_token: bool = False
615
+ ) -> list[dict[str, Any]]:
616
+ """Get the core setup steps for any workflow.
617
+
618
+ Args:
619
+ python_version: Python version to use. Defaults to latest supported.
620
+ repo_token: Whether to use REPO_TOKEN for checkout.
621
+
622
+ Returns:
623
+ List with checkout and project management setup steps.
624
+ """
625
+ if python_version is None:
626
+ python_version = str(
627
+ PyprojectConfigFile.get_latest_possible_python_version(level="minor")
628
+ )
629
+ return [
630
+ cls.step_checkout_repository(repo_token=repo_token),
631
+ cls.step_setup_git(),
632
+ cls.step_setup_project_mgt(python_version=python_version),
633
+ ]
634
+
635
+ @classmethod
636
+ def steps_core_installed_setup(
637
+ cls,
638
+ *,
639
+ no_dev: bool = False,
640
+ python_version: str | None = None,
641
+ repo_token: bool = False,
642
+ ) -> list[dict[str, Any]]:
643
+ """Get core setup steps with dependency installation.
644
+
645
+ Args:
646
+ python_version: Python version to use. Defaults to latest supported.
647
+ repo_token: Whether to use REPO_TOKEN for checkout.
648
+ no_dev: Whether to install dev dependencies.
649
+
650
+ Returns:
651
+ List with setup, install, and dependency update steps.
652
+ """
653
+ return [
654
+ *cls.steps_core_setup(python_version=python_version, repo_token=repo_token),
655
+ cls.step_patch_version(),
656
+ cls.step_install_python_dependencies(no_dev=no_dev),
657
+ cls.step_add_dependency_updates_to_git(),
658
+ ]
659
+
660
+ @classmethod
661
+ def steps_core_matrix_setup(
662
+ cls,
663
+ *,
664
+ no_dev: bool = False,
665
+ python_version: str | None = None,
666
+ repo_token: bool = False,
667
+ ) -> list[dict[str, Any]]:
668
+ """Get core setup steps for matrix jobs.
669
+
670
+ Args:
671
+ python_version: Python version to use. Defaults to matrix value.
672
+ repo_token: Whether to use REPO_TOKEN for checkout.
673
+ no_dev: Whether to install dev dependencies.
674
+
675
+ Returns:
676
+ List with full setup steps for matrix execution.
677
+ """
678
+ return [
679
+ *cls.steps_core_installed_setup(
680
+ python_version=python_version,
681
+ repo_token=repo_token,
682
+ no_dev=no_dev,
683
+ ),
684
+ ]
685
+
686
+ @classmethod
687
+ def steps_configure_keyring_if_needed(cls) -> list[dict[str, Any]]:
688
+ """Get keyring configuration steps if keyring is a dependency.
689
+
690
+ Returns:
691
+ List with keyring setup step if needed, empty otherwise.
692
+ """
693
+ steps: list[dict[str, Any]] = []
694
+ if "keyring" in DependencyGraph.get_all_dependencies():
695
+ steps.append(cls.step_setup_keyring())
696
+ return steps
697
+
698
+ # Single Step
699
+ @classmethod
700
+ def step_opt_out_of_workflow(
701
+ cls,
702
+ *,
703
+ step: dict[str, Any] | None = None,
704
+ ) -> dict[str, Any]:
705
+ """Create a step that opts out of the workflow.
706
+
707
+ Args:
708
+ step: Existing step dict to update.
709
+
710
+ Returns:
711
+ Step that echoes an opt-out message.
712
+ """
713
+ return cls.get_step(
714
+ step_func=cls.step_opt_out_of_workflow,
715
+ run=f"echo 'Opting out of {cls.get_workflow_name()} workflow.'",
716
+ step=step,
717
+ )
718
+
719
+ @classmethod
720
+ def step_aggregate_matrix_results(
721
+ cls,
722
+ *,
723
+ step: dict[str, Any] | None = None,
724
+ ) -> dict[str, Any]:
725
+ """Create a step that aggregates matrix job results.
726
+
727
+ Args:
728
+ step: Existing step dict to update.
729
+
730
+ Returns:
731
+ Step configuration for result aggregation.
732
+ """
733
+ return cls.get_step(
734
+ step_func=cls.step_aggregate_matrix_results,
735
+ run="echo 'Aggregating matrix results into one job.'",
736
+ step=step,
737
+ )
738
+
739
+ @classmethod
740
+ def step_no_builder_defined(
741
+ cls,
742
+ *,
743
+ step: dict[str, Any] | None = None,
744
+ ) -> dict[str, Any]:
745
+ """Create a placeholder step when no builders are defined.
746
+
747
+ Args:
748
+ step: Existing step dict to update.
749
+
750
+ Returns:
751
+ Step that echoes a skip message.
752
+ """
753
+ return cls.get_step(
754
+ step_func=cls.step_no_builder_defined,
755
+ run="echo 'No non-abstract builders defined. Skipping build.'",
756
+ step=step,
757
+ )
758
+
759
+ @classmethod
760
+ def step_install_container_engine(
761
+ cls,
762
+ *,
763
+ step: dict[str, Any] | None = None,
764
+ ) -> dict[str, Any]:
765
+ """Create a step that installs the container engine.
766
+
767
+ We use podman as the container engine.
768
+
769
+ Args:
770
+ step: Existing step dict to update.
771
+
772
+ Returns:
773
+ Step that installs podman.
774
+ """
775
+ return cls.get_step(
776
+ step_func=cls.step_install_container_engine,
777
+ uses="redhat-actions/podman-install@main",
778
+ with_={"github-token": cls.insert_github_token()},
779
+ step=step,
780
+ )
781
+
782
+ @classmethod
783
+ def step_build_container_image(
784
+ cls,
785
+ *,
786
+ step: dict[str, Any] | None = None,
787
+ ) -> dict[str, Any]:
788
+ """Create a step that builds the container image.
789
+
790
+ Args:
791
+ step: Existing step dict to update.
792
+
793
+ Returns:
794
+ Step that builds the container image.
795
+ """
796
+ return cls.get_step(
797
+ step_func=cls.step_build_container_image,
798
+ run=f"podman build -t {PyprojectConfigFile.get_project_name()} .",
799
+ step=step,
800
+ )
801
+
802
+ @classmethod
803
+ def step_save_container_image(
804
+ cls,
805
+ *,
806
+ step: dict[str, Any] | None = None,
807
+ ) -> dict[str, Any]:
808
+ """Create a step that saves the container image to a file.
809
+
810
+ Args:
811
+ step: Existing step dict to update.
812
+
813
+ Returns:
814
+ Step that saves the container image.
815
+ """
816
+ image_file = Path(f"{PyprojectConfigFile.get_project_name()}.tar")
817
+ image_path = Path(cls.ARTIFACTS_DIR_NAME) / image_file
818
+ return cls.get_step(
819
+ step_func=cls.step_save_container_image,
820
+ run=f"podman save -o {image_path.as_posix()} {image_file.stem}",
821
+ step=step,
822
+ )
823
+
824
+ @classmethod
825
+ def step_make_dist_folder(
826
+ cls,
827
+ *,
828
+ step: dict[str, Any] | None = None,
829
+ ) -> dict[str, Any]:
830
+ """Create a step that makes the dist folder.
831
+
832
+ Creates only if it does not exist.
833
+
834
+ Args:
835
+ step: Existing step dict to update.
836
+
837
+ Returns:
838
+ Step that makes the dist folder.
839
+ """
840
+ return cls.get_step(
841
+ step_func=cls.step_make_dist_folder,
842
+ run=f"mkdir -p {Builder.ARTIFACTS_DIR_NAME}",
843
+ step=step,
844
+ )
845
+
846
+ @classmethod
847
+ def step_run_tests(
848
+ cls,
849
+ *,
850
+ step: dict[str, Any] | None = None,
851
+ ) -> dict[str, Any]:
852
+ """Create a step that runs pytest.
853
+
854
+ Args:
855
+ step: Existing step dict to update.
856
+
857
+ Returns:
858
+ Step configuration for running tests.
859
+ """
860
+ if step is None:
861
+ step = {}
862
+ if PyprojectConfigFile.get_package_name() == pyrig.__name__:
863
+ step.setdefault("env", {})["REPO_TOKEN"] = cls.insert_repo_token()
864
+ run = f"{PROJECT_MGT_RUN_SCRIPT} pytest --log-cli-level=INFO --cov-report=xml"
865
+ return cls.get_step(
866
+ step_func=cls.step_run_tests,
867
+ run=run,
868
+ step=step,
869
+ )
870
+
871
+ @classmethod
872
+ def step_upload_coverage_report(
873
+ cls,
874
+ *,
875
+ step: dict[str, Any] | None = None,
876
+ ) -> dict[str, Any]:
877
+ """Create a step that uploads the coverage report.
878
+
879
+ If the repository is private, the workflow will fail and
880
+ a Codecov token has to be added to the repository secrets.
881
+ You need an account on Codecov for this.
882
+ This is why we fail_ci_if_error is not set to true.
883
+ This is an optional service and should not break the workflow.
884
+
885
+ Args:
886
+ step: Existing step dict to update.
887
+
888
+ Returns:
889
+ Step configuration for uploading coverage report.
890
+ """
891
+ return cls.get_step(
892
+ step_func=cls.step_upload_coverage_report,
893
+ uses="codecov/codecov-action@main",
894
+ with_={
895
+ "files": "coverage.xml",
896
+ "token": cls.insert_codecov_token(),
897
+ },
898
+ step=step,
899
+ )
900
+
901
+ @classmethod
902
+ def step_patch_version(
903
+ cls,
904
+ *,
905
+ step: dict[str, Any] | None = None,
906
+ ) -> dict[str, Any]:
907
+ """Create a step that bumps the patch version.
908
+
909
+ Args:
910
+ step: Existing step dict to update.
911
+
912
+ Returns:
913
+ Step that increments version and stages pyproject.toml.
914
+ """
915
+ return cls.get_step(
916
+ step_func=cls.step_patch_version,
917
+ run=f"{PROJECT_MGT} version --bump patch && git add pyproject.toml",
918
+ step=step,
919
+ )
920
+
921
+ @classmethod
922
+ def step_add_dependency_updates_to_git(
923
+ cls,
924
+ *,
925
+ step: dict[str, Any] | None = None,
926
+ ) -> dict[str, Any]:
927
+ """Create a step that stages dependency file changes.
928
+
929
+ Args:
930
+ step: Existing step dict to update.
931
+
932
+ Returns:
933
+ Step that stages pyproject.toml and uv.lock.
934
+ """
935
+ return cls.get_step(
936
+ step_func=cls.step_add_dependency_updates_to_git,
937
+ run=f"git add pyproject.toml {PROJECT_MGT}.lock",
938
+ step=step,
939
+ )
940
+
941
+ @classmethod
942
+ def step_checkout_repository(
943
+ cls,
944
+ *,
945
+ step: dict[str, Any] | None = None,
946
+ fetch_depth: int | None = None,
947
+ repo_token: bool = False,
948
+ ) -> dict[str, Any]:
949
+ """Create a step that checks out the repository.
950
+
951
+ Args:
952
+ step: Existing step dict to update.
953
+ fetch_depth: Git fetch depth. None for full history.
954
+ repo_token: Whether to use REPO_TOKEN for authentication.
955
+
956
+ Returns:
957
+ Step using actions/checkout.
958
+ """
959
+ if step is None:
960
+ step = {}
961
+ if fetch_depth is not None:
962
+ step.setdefault("with", {})["fetch-depth"] = fetch_depth
963
+ if repo_token:
964
+ step.setdefault("with", {})["token"] = cls.insert_repo_token()
965
+ return cls.get_step(
966
+ step_func=cls.step_checkout_repository,
967
+ uses="actions/checkout@main",
968
+ step=step,
969
+ )
970
+
971
+ @classmethod
972
+ def step_setup_git(
973
+ cls,
974
+ *,
975
+ step: dict[str, Any] | None = None,
976
+ ) -> dict[str, Any]:
977
+ """Create a step that configures git user for commits.
978
+
979
+ Args:
980
+ step: Existing step dict to update.
981
+
982
+ Returns:
983
+ Step that sets git user.email and user.name.
984
+ """
985
+ return cls.get_step(
986
+ step_func=cls.step_setup_git,
987
+ run='git config --global user.email "github-actions[bot]@users.noreply.github.com" && git config --global user.name "github-actions[bot]"', # noqa: E501
988
+ step=step,
989
+ )
990
+
991
+ @classmethod
992
+ def step_setup_python(
993
+ cls,
994
+ *,
995
+ step: dict[str, Any] | None = None,
996
+ python_version: str | None = None,
997
+ ) -> dict[str, Any]:
998
+ """Create a step that sets up Python.
999
+
1000
+ Args:
1001
+ step: Existing step dict to update.
1002
+ python_version: Python version to install. Defaults to latest.
1003
+
1004
+ Returns:
1005
+ Step using actions/setup-python.
1006
+ """
1007
+ if step is None:
1008
+ step = {}
1009
+ if python_version is None:
1010
+ python_version = str(
1011
+ PyprojectConfigFile.get_latest_possible_python_version(level="minor")
1012
+ )
1013
+
1014
+ step.setdefault("with", {})["python-version"] = python_version
1015
+ return cls.get_step(
1016
+ step_func=cls.step_setup_python,
1017
+ uses="actions/setup-python@main",
1018
+ step=step,
1019
+ )
1020
+
1021
+ @classmethod
1022
+ def step_setup_project_mgt(
1023
+ cls,
1024
+ *,
1025
+ python_version: str | None = None,
1026
+ step: dict[str, Any] | None = None,
1027
+ ) -> dict[str, Any]:
1028
+ """Create a step that sets up the project management tool (uv).
1029
+
1030
+ Args:
1031
+ python_version: Python version to configure.
1032
+ step: Existing step dict to update.
1033
+
1034
+ Returns:
1035
+ Step using astral-sh/setup-uv.
1036
+ """
1037
+ return cls.get_step(
1038
+ step_func=cls.step_setup_project_mgt,
1039
+ uses="astral-sh/setup-uv@main",
1040
+ with_={"python-version": python_version},
1041
+ step=step,
1042
+ )
1043
+
1044
+ @classmethod
1045
+ def step_build_wheel(
1046
+ cls,
1047
+ *,
1048
+ step: dict[str, Any] | None = None,
1049
+ ) -> dict[str, Any]:
1050
+ """Create a step that builds the Python wheel.
1051
+
1052
+ Args:
1053
+ step: Existing step dict to update.
1054
+
1055
+ Returns:
1056
+ Step that runs uv build.
1057
+ """
1058
+ return cls.get_step(
1059
+ step_func=cls.step_build_wheel,
1060
+ run=f"{PROJECT_MGT} build",
1061
+ step=step,
1062
+ )
1063
+
1064
+ @classmethod
1065
+ def step_publish_to_pypi(
1066
+ cls,
1067
+ *,
1068
+ step: dict[str, Any] | None = None,
1069
+ ) -> dict[str, Any]:
1070
+ """Create a step that publishes the package to PyPI.
1071
+
1072
+ Args:
1073
+ step: Existing step dict to update.
1074
+
1075
+ Returns:
1076
+ Step that runs uv publish with PYPI_TOKEN.
1077
+ """
1078
+ return cls.get_step(
1079
+ step_func=cls.step_publish_to_pypi,
1080
+ run=f"{PROJECT_MGT} publish --token {cls.insert_pypi_token()}",
1081
+ step=step,
1082
+ )
1083
+
1084
+ @classmethod
1085
+ def step_install_python_dependencies(
1086
+ cls,
1087
+ *,
1088
+ no_dev: bool = False,
1089
+ step: dict[str, Any] | None = None,
1090
+ ) -> dict[str, Any]:
1091
+ """Create a step that installs Python dependencies.
1092
+
1093
+ Args:
1094
+ step: Existing step dict to update.
1095
+ no_dev: Whether to install dev dependencies.
1096
+
1097
+ Returns:
1098
+ Step that runs uv sync.
1099
+ """
1100
+ upgrade = f"{PROJECT_MGT} lock --upgrade"
1101
+ install = f"{PROJECT_MGT} sync"
1102
+ if no_dev:
1103
+ install += " --no-group dev"
1104
+ run = f"{upgrade} && {install}"
1105
+
1106
+ return cls.get_step(
1107
+ step_func=cls.step_install_python_dependencies,
1108
+ run=run,
1109
+ step=step,
1110
+ )
1111
+
1112
+ @classmethod
1113
+ def step_setup_keyring(
1114
+ cls,
1115
+ *,
1116
+ step: dict[str, Any] | None = None,
1117
+ ) -> dict[str, Any]:
1118
+ """Create a step that configures keyring for CI.
1119
+
1120
+ Args:
1121
+ step: Existing step dict to update.
1122
+
1123
+ Returns:
1124
+ Step that sets up PlaintextKeyring for CI environments.
1125
+ """
1126
+ return cls.get_step(
1127
+ step_func=cls.step_setup_keyring,
1128
+ run=f'{PROJECT_MGT_RUN_SCRIPT} python -c "import keyring; from keyrings.alt.file import PlaintextKeyring; keyring.set_keyring(PlaintextKeyring());"', # noqa: E501
1129
+ step=step,
1130
+ )
1131
+
1132
+ @classmethod
1133
+ def step_protect_repository(
1134
+ cls,
1135
+ *,
1136
+ step: dict[str, Any] | None = None,
1137
+ ) -> dict[str, Any]:
1138
+ """Create a step that applies repository protection rules.
1139
+
1140
+ Args:
1141
+ step: Existing step dict to update.
1142
+
1143
+ Returns:
1144
+ Step that runs the pyrig protect-repo command.
1145
+ """
1146
+ from pyrig.dev.cli.subcommands import protect_repo # noqa: PLC0415
1147
+
1148
+ return cls.get_step(
1149
+ step_func=cls.step_protect_repository,
1150
+ run=get_project_mgt_run_pyrig_cli_cmd_script(protect_repo),
1151
+ env={"REPO_TOKEN": cls.insert_repo_token()},
1152
+ step=step,
1153
+ )
1154
+
1155
+ @classmethod
1156
+ def step_run_pre_commit_hooks(
1157
+ cls,
1158
+ *,
1159
+ step: dict[str, Any] | None = None,
1160
+ ) -> dict[str, Any]:
1161
+ """Create a step that runs pre-commit hooks.
1162
+
1163
+ Ensures code quality checks pass before commits. Also useful
1164
+ for ensuring git stash pop doesn't fail when there are no changes.
1165
+
1166
+ Args:
1167
+ step: Existing step dict to update.
1168
+
1169
+ Returns:
1170
+ Step that runs pre-commit on all files.
1171
+ """
1172
+ return cls.get_step(
1173
+ step_func=cls.step_run_pre_commit_hooks,
1174
+ run=f"{PROJECT_MGT_RUN_SCRIPT} pre-commit run --all-files",
1175
+ step=step,
1176
+ )
1177
+
1178
+ @classmethod
1179
+ def step_commit_added_changes(
1180
+ cls,
1181
+ *,
1182
+ step: dict[str, Any] | None = None,
1183
+ ) -> dict[str, Any]:
1184
+ """Create a step that commits staged changes.
1185
+
1186
+ Args:
1187
+ step: Existing step dict to update.
1188
+
1189
+ Returns:
1190
+ Step that commits with [skip ci] prefix.
1191
+ """
1192
+ return cls.get_step(
1193
+ step_func=cls.step_commit_added_changes,
1194
+ run="git commit --no-verify -m '[skip ci] CI/CD: Committing possible added changes (e.g.: pyproject.toml)'", # noqa: E501
1195
+ step=step,
1196
+ )
1197
+
1198
+ @classmethod
1199
+ def step_push_commits(
1200
+ cls,
1201
+ *,
1202
+ step: dict[str, Any] | None = None,
1203
+ ) -> dict[str, Any]:
1204
+ """Create a step that pushes commits to the remote.
1205
+
1206
+ Args:
1207
+ step: Existing step dict to update.
1208
+
1209
+ Returns:
1210
+ Step that runs git push.
1211
+ """
1212
+ return cls.get_step(
1213
+ step_func=cls.step_push_commits,
1214
+ run="git push",
1215
+ step=step,
1216
+ )
1217
+
1218
+ @classmethod
1219
+ def step_create_and_push_tag(
1220
+ cls,
1221
+ *,
1222
+ step: dict[str, Any] | None = None,
1223
+ ) -> dict[str, Any]:
1224
+ """Create a step that creates and pushes a version tag.
1225
+
1226
+ Args:
1227
+ step: Existing step dict to update.
1228
+
1229
+ Returns:
1230
+ Step that creates a git tag and pushes it.
1231
+ """
1232
+ return cls.get_step(
1233
+ step_func=cls.step_create_and_push_tag,
1234
+ run=f"git tag {cls.insert_version()} && git push origin {cls.insert_version()}", # noqa: E501
1235
+ step=step,
1236
+ )
1237
+
1238
+ @classmethod
1239
+ def step_create_folder(
1240
+ cls,
1241
+ *,
1242
+ folder: str,
1243
+ step: dict[str, Any] | None = None,
1244
+ ) -> dict[str, Any]:
1245
+ """Create a step that creates a directory.
1246
+
1247
+ Args:
1248
+ folder: Directory name to create.
1249
+ step: Existing step dict to update.
1250
+
1251
+ Returns:
1252
+ Step that runs mkdir (cross-platform).
1253
+ """
1254
+ # should work on all OSs
1255
+ return cls.get_step(
1256
+ step_func=cls.step_create_folder,
1257
+ run=f"mkdir {folder}",
1258
+ step=step,
1259
+ )
1260
+
1261
+ @classmethod
1262
+ def step_create_artifacts_folder(
1263
+ cls,
1264
+ *,
1265
+ folder: str = Builder.ARTIFACTS_DIR_NAME,
1266
+ step: dict[str, Any] | None = None,
1267
+ ) -> dict[str, Any]:
1268
+ """Create a step that creates the artifacts directory.
1269
+
1270
+ Args:
1271
+ folder: Directory name. Defaults to ARTIFACTS_DIR_NAME.
1272
+ step: Existing step dict to update.
1273
+
1274
+ Returns:
1275
+ Step that creates the artifacts folder.
1276
+ """
1277
+ return cls.step_create_folder(folder=folder, step=step)
1278
+
1279
+ @classmethod
1280
+ def step_upload_artifacts(
1281
+ cls,
1282
+ *,
1283
+ name: str | None = None,
1284
+ path: str | Path = ARTIFACTS_DIR_NAME,
1285
+ step: dict[str, Any] | None = None,
1286
+ ) -> dict[str, Any]:
1287
+ """Create a step that uploads build artifacts.
1288
+
1289
+ Args:
1290
+ name: Artifact name. Defaults to package-os format.
1291
+ path: Path to upload. Defaults to artifacts directory.
1292
+ step: Existing step dict to update.
1293
+
1294
+ Returns:
1295
+ Step using actions/upload-artifact.
1296
+ """
1297
+ if name is None:
1298
+ name = cls.insert_artifact_name()
1299
+ return cls.get_step(
1300
+ step_func=cls.step_upload_artifacts,
1301
+ uses="actions/upload-artifact@main",
1302
+ with_={"name": name, "path": str(path)},
1303
+ step=step,
1304
+ )
1305
+
1306
+ @classmethod
1307
+ def step_build_artifacts(
1308
+ cls,
1309
+ *,
1310
+ step: dict[str, Any] | None = None,
1311
+ ) -> dict[str, Any]:
1312
+ """Create a step that builds project artifacts.
1313
+
1314
+ Args:
1315
+ step: Existing step dict to update.
1316
+
1317
+ Returns:
1318
+ Step that runs the pyrig build command.
1319
+ """
1320
+ from pyrig.dev.cli.subcommands import build # noqa: PLC0415
1321
+
1322
+ return cls.get_step(
1323
+ step_func=cls.step_build_artifacts,
1324
+ run=get_project_mgt_run_pyrig_cli_cmd_script(build),
1325
+ step=step,
1326
+ )
1327
+
1328
+ @classmethod
1329
+ def step_download_artifacts(
1330
+ cls,
1331
+ *,
1332
+ name: str | None = None,
1333
+ path: str | Path = ARTIFACTS_DIR_NAME,
1334
+ step: dict[str, Any] | None = None,
1335
+ ) -> dict[str, Any]:
1336
+ """Create a step that downloads build artifacts.
1337
+
1338
+ Args:
1339
+ name: Artifact name to download. None downloads all.
1340
+ path: Path to download to. Defaults to artifacts directory.
1341
+ step: Existing step dict to update.
1342
+
1343
+ Returns:
1344
+ Step using actions/download-artifact.
1345
+ """
1346
+ # omit name downloads all by default
1347
+ with_: dict[str, Any] = {"path": str(path)}
1348
+ if name is not None:
1349
+ with_["name"] = name
1350
+ with_["merge-multiple"] = "true"
1351
+ return cls.get_step(
1352
+ step_func=cls.step_download_artifacts,
1353
+ uses="actions/download-artifact@main",
1354
+ with_=with_,
1355
+ step=step,
1356
+ )
1357
+
1358
+ @classmethod
1359
+ def step_download_artifacts_from_workflow_run(
1360
+ cls,
1361
+ *,
1362
+ name: str | None = None,
1363
+ path: str | Path = ARTIFACTS_DIR_NAME,
1364
+ step: dict[str, Any] | None = None,
1365
+ ) -> dict[str, Any]:
1366
+ """Create a step that downloads artifacts from triggering workflow run.
1367
+
1368
+ Uses the github.event.workflow_run.id to download artifacts from
1369
+ the workflow that triggered this workflow (via workflow_run event).
1370
+
1371
+ Args:
1372
+ name: Artifact name to download. None downloads all.
1373
+ path: Path to download to. Defaults to artifacts directory.
1374
+ step: Existing step dict to update.
1375
+
1376
+ Returns:
1377
+ Step using actions/download-artifact with run-id parameter.
1378
+ """
1379
+ with_: dict[str, Any] = {
1380
+ "path": str(path),
1381
+ "run-id": cls.insert_workflow_run_id(),
1382
+ "github-token": cls.insert_github_token(),
1383
+ }
1384
+ if name is not None:
1385
+ with_["name"] = name
1386
+ with_["merge-multiple"] = "true"
1387
+ return cls.get_step(
1388
+ step_func=cls.step_download_artifacts_from_workflow_run,
1389
+ uses="actions/download-artifact@main",
1390
+ with_=with_,
1391
+ step=step,
1392
+ )
1393
+
1394
+ @classmethod
1395
+ def step_build_changelog(
1396
+ cls,
1397
+ *,
1398
+ step: dict[str, Any] | None = None,
1399
+ ) -> dict[str, Any]:
1400
+ """Create a step that generates a changelog.
1401
+
1402
+ Args:
1403
+ step: Existing step dict to update.
1404
+
1405
+ Returns:
1406
+ Step using release-changelog-builder-action.
1407
+ """
1408
+ return cls.get_step(
1409
+ step_func=cls.step_build_changelog,
1410
+ uses="mikepenz/release-changelog-builder-action@develop",
1411
+ with_={"token": cls.insert_github_token()},
1412
+ step=step,
1413
+ )
1414
+
1415
+ @classmethod
1416
+ def step_extract_version(
1417
+ cls,
1418
+ *,
1419
+ step: dict[str, Any] | None = None,
1420
+ ) -> dict[str, Any]:
1421
+ """Create a step that extracts the version to GITHUB_OUTPUT.
1422
+
1423
+ Args:
1424
+ step: Existing step dict to update.
1425
+
1426
+ Returns:
1427
+ Step that outputs the version for later steps.
1428
+ """
1429
+ return cls.get_step(
1430
+ step_func=cls.step_extract_version,
1431
+ run=f'echo "version={cls.insert_version()}" >> $GITHUB_OUTPUT',
1432
+ step=step,
1433
+ )
1434
+
1435
+ @classmethod
1436
+ def step_create_release(
1437
+ cls,
1438
+ *,
1439
+ step: dict[str, Any] | None = None,
1440
+ artifacts_pattern: str = ARTIFACTS_PATTERN,
1441
+ ) -> dict[str, Any]:
1442
+ """Create a step that creates a GitHub release.
1443
+
1444
+ Args:
1445
+ step: Existing step dict to update.
1446
+ artifacts_pattern: Glob pattern for release artifacts.
1447
+
1448
+ Returns:
1449
+ Step using ncipollo/release-action.
1450
+ """
1451
+ version = cls.insert_version_from_extract_version_step()
1452
+ return cls.get_step(
1453
+ step_func=cls.step_create_release,
1454
+ uses="ncipollo/release-action@main",
1455
+ with_={
1456
+ "tag": version,
1457
+ "name": f"{cls.insert_repository_name()} {version}",
1458
+ "body": cls.insert_changelog(),
1459
+ "artifacts": artifacts_pattern,
1460
+ },
1461
+ step=step,
1462
+ )
1463
+
1464
+ # Insertions
1465
+ # ----------------------------------------------------------------------------
1466
+ @classmethod
1467
+ def insert_repo_token(cls) -> str:
1468
+ """Get the GitHub expression for REPO_TOKEN secret.
1469
+
1470
+ Returns:
1471
+ GitHub Actions expression for secrets.REPO_TOKEN.
1472
+ """
1473
+ return "${{ secrets.REPO_TOKEN }}"
1474
+
1475
+ @classmethod
1476
+ def insert_pypi_token(cls) -> str:
1477
+ """Get the GitHub expression for PYPI_TOKEN secret.
1478
+
1479
+ Returns:
1480
+ GitHub Actions expression for secrets.PYPI_TOKEN.
1481
+ """
1482
+ return "${{ secrets.PYPI_TOKEN }}"
1483
+
1484
+ @classmethod
1485
+ def insert_version(cls) -> str:
1486
+ """Get a shell expression for the current version.
1487
+
1488
+ Returns:
1489
+ Shell command that outputs the version with v prefix.
1490
+ """
1491
+ return f"v$({PROJECT_MGT} version --short)"
1492
+
1493
+ @classmethod
1494
+ def insert_version_from_extract_version_step(cls) -> str:
1495
+ """Get the GitHub expression for version from extract step.
1496
+
1497
+ Returns:
1498
+ GitHub Actions expression referencing the extract_version output.
1499
+ """
1500
+ # make dynamic with cls.make_id_from_func(cls.step_extract_version)
1501
+ return (
1502
+ "${{ "
1503
+ f"steps.{cls.make_id_from_func(cls.step_extract_version)}.outputs.version"
1504
+ " }}"
1505
+ )
1506
+
1507
+ @classmethod
1508
+ def insert_changelog(cls) -> str:
1509
+ """Get the GitHub expression for changelog from build step.
1510
+
1511
+ Returns:
1512
+ GitHub Actions expression referencing the build_changelog output.
1513
+ """
1514
+ return (
1515
+ "${{ "
1516
+ f"steps.{cls.make_id_from_func(cls.step_build_changelog)}.outputs.changelog"
1517
+ " }}"
1518
+ )
1519
+
1520
+ @classmethod
1521
+ def insert_github_token(cls) -> str:
1522
+ """Get the GitHub expression for GITHUB_TOKEN.
1523
+
1524
+ Returns:
1525
+ GitHub Actions expression for secrets.GITHUB_TOKEN.
1526
+ """
1527
+ return "${{ secrets.GITHUB_TOKEN }}"
1528
+
1529
+ @classmethod
1530
+ def insert_codecov_token(cls) -> str:
1531
+ """Get the GitHub expression for CODECOV_TOKEN.
1532
+
1533
+ Returns:
1534
+ GitHub Actions expression for secrets.CODECOV_TOKEN.
1535
+ """
1536
+ return "${{ secrets.CODECOV_TOKEN }}"
1537
+
1538
+ @classmethod
1539
+ def insert_repository_name(cls) -> str:
1540
+ """Get the GitHub expression for repository name.
1541
+
1542
+ Returns:
1543
+ GitHub Actions expression for the repository name.
1544
+ """
1545
+ return "${{ github.event.repository.name }}"
1546
+
1547
+ @classmethod
1548
+ def insert_ref_name(cls) -> str:
1549
+ """Get the GitHub expression for the ref name.
1550
+
1551
+ Returns:
1552
+ GitHub Actions expression for github.ref_name.
1553
+ """
1554
+ return "${{ github.ref_name }}"
1555
+
1556
+ @classmethod
1557
+ def insert_repository_owner(cls) -> str:
1558
+ """Get the GitHub expression for repository owner.
1559
+
1560
+ Returns:
1561
+ GitHub Actions expression for github.repository_owner.
1562
+ """
1563
+ return "${{ github.repository_owner }}"
1564
+
1565
+ @classmethod
1566
+ def insert_workflow_run_id(cls) -> str:
1567
+ """Get the GitHub expression for triggering workflow run ID.
1568
+
1569
+ Used when downloading artifacts from the workflow that triggered
1570
+ this workflow via workflow_run event.
1571
+
1572
+ Returns:
1573
+ GitHub Actions expression for github.event.workflow_run.id.
1574
+ """
1575
+ return "${{ github.event.workflow_run.id }}"
1576
+
1577
+ @classmethod
1578
+ def insert_os(cls) -> str:
1579
+ """Get the GitHub expression for runner OS.
1580
+
1581
+ Returns:
1582
+ GitHub Actions expression for runner.os.
1583
+ """
1584
+ return "${{ runner.os }}"
1585
+
1586
+ @classmethod
1587
+ def insert_matrix_os(cls) -> str:
1588
+ """Get the GitHub expression for matrix OS value.
1589
+
1590
+ Returns:
1591
+ GitHub Actions expression for matrix.os.
1592
+ """
1593
+ return "${{ matrix.os }}"
1594
+
1595
+ @classmethod
1596
+ def insert_matrix_python_version(cls) -> str:
1597
+ """Get the GitHub expression for matrix Python version.
1598
+
1599
+ Returns:
1600
+ GitHub Actions expression for matrix.python-version.
1601
+ """
1602
+ return "${{ matrix.python-version }}"
1603
+
1604
+ @classmethod
1605
+ def insert_artifact_name(cls) -> str:
1606
+ """Generate an artifact name based on package and OS.
1607
+
1608
+ Returns:
1609
+ Artifact name in format: package-os.
1610
+ """
1611
+ return f"{get_src_package().__name__}-{cls.insert_os()}"
1612
+
1613
+ # ifs
1614
+ # ----------------------------------------------------------------------------
1615
+ @classmethod
1616
+ def combined_if(cls, *conditions: str) -> str:
1617
+ """Combine multiple conditions with logical AND.
1618
+
1619
+ Args:
1620
+ *conditions: Individual condition expressions.
1621
+
1622
+ Returns:
1623
+ Combined condition expression.
1624
+ """
1625
+ bare_conditions = [
1626
+ condition.strip().removeprefix("${{").removesuffix("}}").strip()
1627
+ for condition in conditions
1628
+ ]
1629
+ return cls.if_condition(" && ".join(bare_conditions))
1630
+
1631
+ @classmethod
1632
+ def if_condition(cls, condition: str) -> str:
1633
+ """Wrap a condition in GitHub Actions expression syntax.
1634
+
1635
+ Args:
1636
+ condition: Condition expression to wrap.
1637
+
1638
+ Returns:
1639
+ GitHub Actions expression for the condition.
1640
+ """
1641
+ return f"${{{{ {condition} }}}}"
1642
+
1643
+ @classmethod
1644
+ def if_matrix_is_not_os(cls, os: str) -> str:
1645
+ """Create a condition for not matching a specific OS.
1646
+
1647
+ Args:
1648
+ os: OS runner label to not match.
1649
+
1650
+ Returns:
1651
+ Condition expression for matrix.os comparison.
1652
+ """
1653
+ return cls.if_condition(f"matrix.os != '{os}'")
1654
+
1655
+ @classmethod
1656
+ def if_workflow_run_is_success(cls) -> str:
1657
+ """Create a condition for successful workflow run.
1658
+
1659
+ Returns:
1660
+ GitHub Actions expression checking workflow_run conclusion.
1661
+ """
1662
+ return cls.if_condition("github.event.workflow_run.conclusion == 'success'")