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,452 @@
1
+ """Package discovery, traversal, and dependency graph analysis.
2
+
3
+ This module provides utilities for working with Python packages, including
4
+ package discovery, recursive traversal, and dependency graph analysis. The
5
+ `DependencyGraph` class is central to pyrig's multi-package architecture,
6
+ enabling automatic discovery of all packages that depend on pyrig.
7
+
8
+ Key capabilities:
9
+ - Package discovery: Find packages in a directory with depth filtering
10
+ - Package traversal: Walk through package hierarchies recursively
11
+ - Dependency analysis: Build and query the installed package dependency graph
12
+ - Package copying: Duplicate package structures for scaffolding
13
+
14
+ The dependency graph enables pyrig to find all packages that depend on it,
15
+ then discover ConfigFile implementations, Builder subclasses, and other
16
+ extensible components in those packages.
17
+
18
+ Example:
19
+ >>> from pyrig.src.modules.package import DependencyGraph
20
+ >>> graph = DependencyGraph()
21
+ >>> dependents = graph.get_all_depending_on("pyrig")
22
+ >>> [m.__name__ for m in dependents]
23
+ ['myapp', 'other_pkg']
24
+ """
25
+
26
+ import importlib.machinery
27
+ import importlib.metadata
28
+ import importlib.util
29
+ import logging
30
+ import pkgutil
31
+ import re
32
+ import shutil
33
+ import sys
34
+ from collections.abc import Generator, Iterable
35
+ from importlib import import_module
36
+ from pathlib import Path
37
+ from types import ModuleType
38
+
39
+ from pyrig.src.graph import DiGraph
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ DOCS_DIR_NAME = "docs"
44
+
45
+
46
+ def module_is_package(obj: ModuleType) -> bool:
47
+ """Determine if a module object represents a package.
48
+
49
+ Checks if the given module object is a package by looking for the __path__
50
+ attribute, which is only present in package modules.
51
+
52
+ Args:
53
+ obj: The module object to check
54
+
55
+ Returns:
56
+ True if the module is a package, False otherwise
57
+
58
+ Note:
59
+ This works for both regular packages and namespace packages.
60
+
61
+ """
62
+ return hasattr(obj, "__path__")
63
+
64
+
65
+ def get_modules_and_packages_from_package(
66
+ package: ModuleType,
67
+ ) -> tuple[list[ModuleType], list[ModuleType]]:
68
+ """Extract all direct subpackages and modules from a package.
69
+
70
+ Discovers and imports all direct child modules and subpackages within
71
+ the given package. Returns them as separate lists.
72
+
73
+ Args:
74
+ package: The package module to extract subpackages and modules from
75
+
76
+ Returns:
77
+ A tuple containing (list of subpackages, list of modules)
78
+
79
+ Note:
80
+ Only includes direct children, not recursive descendants.
81
+ All discovered modules and packages are imported during this process.
82
+
83
+ """
84
+ from pyrig.src.modules.module import ( # noqa: PLC0415
85
+ import_module_from_file,
86
+ to_path,
87
+ )
88
+
89
+ modules_and_packages = list(
90
+ pkgutil.iter_modules(package.__path__, prefix=package.__name__ + ".")
91
+ )
92
+ packages: list[ModuleType] = []
93
+ modules: list[ModuleType] = []
94
+ for _finder, name, is_pkg in modules_and_packages:
95
+ path = to_path(name, is_package=is_pkg)
96
+
97
+ mod = import_module_from_file(path)
98
+ if is_pkg:
99
+ packages.append(mod)
100
+ else:
101
+ modules.append(mod)
102
+
103
+ # make consistent order
104
+ packages.sort(key=lambda p: p.__name__)
105
+ modules.sort(key=lambda m: m.__name__)
106
+
107
+ return packages, modules
108
+
109
+
110
+ def walk_package(
111
+ package: ModuleType,
112
+ ) -> Generator[tuple[ModuleType, list[ModuleType]], None, None]:
113
+ """Recursively walk through a package and all its subpackages.
114
+
115
+ Performs a depth-first traversal of the package hierarchy, yielding each
116
+ package along with its direct module children.
117
+
118
+ Args:
119
+ package: The root package module to start walking from
120
+
121
+ Yields:
122
+ Tuples of (package, list of modules in package)
123
+
124
+ Note:
125
+ All packages and modules are imported during this process.
126
+ The traversal is depth-first, so subpackages are fully processed
127
+ before moving to siblings.
128
+
129
+ """
130
+ subpackages, submodules = get_modules_and_packages_from_package(package)
131
+ yield package, submodules
132
+ for subpackage in subpackages:
133
+ yield from walk_package(subpackage)
134
+
135
+
136
+ def copy_package(
137
+ src_package: ModuleType,
138
+ dst: str | Path | ModuleType,
139
+ *,
140
+ with_file_content: bool = True,
141
+ skip_existing: bool = True,
142
+ ) -> None:
143
+ """Copy a package to a different destination.
144
+
145
+ Takes a ModuleType of package and a destination package name and then copies
146
+ the package to the destination. If with_file_content is True, it copies the
147
+ content of the files, otherwise it just creates the files.
148
+
149
+ Args:
150
+ src_package (ModuleType): The package to copy
151
+ dst (str | Path): destination package name as a
152
+ Path with / or as a str with dots
153
+ with_file_content (bool, optional): copies the content of the files.
154
+ skip_existing (bool, optional): skips existing files.
155
+
156
+ """
157
+ from pyrig.src.modules.module import create_module, to_path # noqa: PLC0415
158
+
159
+ # copy the folder with shutil
160
+ src_path = Path(src_package.__path__[0])
161
+ dst_path = to_path(dst, is_package=True)
162
+ # walk thze src_path and copy the files to dst_path if they do not exist
163
+ for src in src_path.rglob("*"):
164
+ dst_ = dst_path / src.relative_to(src_path)
165
+ if skip_existing and dst_.exists():
166
+ continue
167
+ if src.is_dir():
168
+ dst_.mkdir(parents=True, exist_ok=True)
169
+ continue
170
+ # Ensure parent directory exists before copying file
171
+ dst_.parent.mkdir(parents=True, exist_ok=True)
172
+ if with_file_content:
173
+ shutil.copy2(src, dst_)
174
+ else:
175
+ create_module(dst_, is_package=False)
176
+
177
+
178
+ def get_main_package() -> ModuleType:
179
+ """Gets the main package of the executing code.
180
+
181
+ Even when this package is installed as a module.
182
+ """
183
+ from pyrig.src.modules.module import ( # noqa: PLC0415 # avoid circular import
184
+ to_module_name,
185
+ )
186
+
187
+ main = sys.modules.get("__main__")
188
+ if main is None:
189
+ msg = "No __main__ module found"
190
+ raise ValueError(msg)
191
+
192
+ package_name = getattr(main, "__package__", None)
193
+ if package_name:
194
+ package_name = package_name.split(".")[0]
195
+ return import_module(package_name)
196
+
197
+ file_name = getattr(main, "__file__", None)
198
+ if file_name:
199
+ package_name = to_module_name(file_name)
200
+ package_name = package_name.split(".")[0]
201
+ return import_module(package_name)
202
+
203
+ msg = "Not able to determine the main package"
204
+ raise ValueError(msg)
205
+
206
+
207
+ class DependencyGraph(DiGraph):
208
+ """A directed graph of installed Python package dependencies.
209
+
210
+ Builds a graph where nodes are package names and edges represent
211
+ dependency relationships (package -> dependency). This enables
212
+ finding all packages that depend on a given package, which is
213
+ central to pyrig's multi-package discovery system.
214
+
215
+ The graph is built automatically on instantiation by scanning all
216
+ installed distributions via `importlib.metadata`.
217
+
218
+ Attributes:
219
+ Inherits all attributes from DiGraph.
220
+
221
+ Example:
222
+ >>> graph = DependencyGraph()
223
+ >>> # Find all packages that depend on pyrig
224
+ >>> dependents = graph.get_all_depending_on("pyrig")
225
+ >>> [m.__name__ for m in dependents]
226
+ ['myapp', 'other_pkg']
227
+ """
228
+
229
+ def __init__(self) -> None:
230
+ """Initialize and build the dependency graph.
231
+
232
+ Scans all installed Python distributions and builds the dependency
233
+ graph immediately. Package names are normalized (lowercase, hyphens
234
+ replaced with underscores).
235
+ """
236
+ super().__init__()
237
+ self.build()
238
+
239
+ def build(self) -> None:
240
+ """Build the graph from installed Python distributions.
241
+
242
+ Iterates through all installed distributions, adding each as a node
243
+ and creating edges from each package to its dependencies.
244
+ """
245
+ for dist in importlib.metadata.distributions():
246
+ name = self.parse_distname_from_metadata(dist)
247
+ self.add_node(name)
248
+
249
+ requires = dist.requires or []
250
+ for req in requires:
251
+ dep = self.parse_pkg_name_from_req(req)
252
+ if dep:
253
+ self.add_edge(name, dep) # package → dependency
254
+
255
+ @staticmethod
256
+ def parse_distname_from_metadata(dist: importlib.metadata.Distribution) -> str:
257
+ """Extract and normalize the distribution name from metadata.
258
+
259
+ Args:
260
+ dist: A distribution object from importlib.metadata.
261
+
262
+ Returns:
263
+ The normalized package name (lowercase, underscores).
264
+ """
265
+ # replace - with _ to handle packages like pyrig
266
+ name: str = dist.metadata["Name"]
267
+ return DependencyGraph.normalize_package_name(name)
268
+
269
+ @staticmethod
270
+ def get_all_dependencies() -> list[str]:
271
+ """Get all installed package names.
272
+
273
+ Returns:
274
+ A list of all installed package names, normalized.
275
+ """
276
+ dists = importlib.metadata.distributions()
277
+ # extract the name from the metadata
278
+ return [DependencyGraph.parse_distname_from_metadata(dist) for dist in dists]
279
+
280
+ @staticmethod
281
+ def normalize_package_name(name: str) -> str:
282
+ """Normalize a package name for consistent comparison.
283
+
284
+ Converts to lowercase and replaces hyphens with underscores,
285
+ matching Python's import name conventions.
286
+
287
+ Args:
288
+ name: The package name to normalize.
289
+
290
+ Returns:
291
+ The normalized package name.
292
+ """
293
+ return name.lower().replace("-", "_").strip()
294
+
295
+ @staticmethod
296
+ def parse_pkg_name_from_req(req: str) -> str | None:
297
+ """Extract the bare package name from a requirement string.
298
+
299
+ Parses requirement strings like "requests>=2.0" or "numpy[extra]"
300
+ to extract just the package name.
301
+
302
+ Args:
303
+ req: A requirement string (e.g., "requests>=2.0,<3.0").
304
+
305
+ Returns:
306
+ The normalized package name, or None if parsing fails.
307
+ """
308
+ # split on the first non alphanumeric character like >, <, =, etc.
309
+ # keep - and _ for names like pyrig or pyrig
310
+ dep = re.split(r"[^a-zA-Z0-9_-]", req.strip())[0].strip()
311
+ return DependencyGraph.normalize_package_name(dep) if dep else None
312
+
313
+ def get_all_depending_on(
314
+ self, package: ModuleType | str, *, include_self: bool = False
315
+ ) -> list[ModuleType]:
316
+ """Find all packages that depend on the given package.
317
+
318
+ Traverses the dependency graph to find all packages that directly
319
+ or indirectly depend on the specified package. Results are sorted
320
+ in topological order (dependencies before dependents).
321
+
322
+ This is the primary method used by pyrig to discover all packages
323
+ in the ecosystem that extend pyrig's functionality.
324
+
325
+ Args:
326
+ package: The package to find dependents of. Can be a module
327
+ object or a package name string.
328
+ include_self: If True, includes the target package itself
329
+ in the results.
330
+
331
+ Returns:
332
+ A list of imported module objects for all dependent packages.
333
+ Sorted in topological order so dependencies come before dependents.
334
+ For example: [pyrig, pkg1, pkg2] where pkg1 depends on pyrig and
335
+ pkg2 depends on pkg1.
336
+
337
+ Note:
338
+ Only returns packages that can be successfully imported.
339
+ Logs a warning if the target package is not in the graph.
340
+ """
341
+ # replace - with _ to handle packages like pyrig
342
+ if isinstance(package, ModuleType):
343
+ package = package.__name__
344
+ target = package.lower()
345
+ if target not in self:
346
+ msg = f"""Package '{target}' not found in dependency graph."""
347
+ logger.warning(msg)
348
+ return []
349
+
350
+ dependents_set = self.ancestors(target)
351
+ if include_self:
352
+ dependents_set.add(target)
353
+
354
+ # Sort in topological order (dependencies before dependents)
355
+ dependents = self.topological_sort_subgraph(dependents_set)
356
+
357
+ return self.import_packages(dependents)
358
+
359
+ @staticmethod
360
+ def import_packages(names: Iterable[str]) -> list[ModuleType]:
361
+ """Import packages by name, skipping those that cannot be imported.
362
+
363
+ Args:
364
+ names: Package names to import.
365
+
366
+ Returns:
367
+ A list of successfully imported module objects.
368
+ """
369
+ from pyrig.src.modules.module import import_module_with_default # noqa: PLC0415
370
+
371
+ modules: list[ModuleType] = []
372
+ for name in names:
373
+ module = import_module_with_default(name)
374
+ if module is not None:
375
+ modules.append(module)
376
+ return modules
377
+
378
+
379
+ def import_pkg_from_path(package_dir: Path) -> ModuleType:
380
+ """Import a package from a filesystem path.
381
+
382
+ Uses importlib machinery to load a package from its directory path,
383
+ rather than by its module name. Useful when the package is not yet
384
+ in sys.path or when you have a path but not the module name.
385
+
386
+ Args:
387
+ package_dir: Path to the package directory (must contain __init__.py).
388
+
389
+ Returns:
390
+ The imported package module.
391
+
392
+ Raises:
393
+ ValueError: If a module spec cannot be created for the path.
394
+ """
395
+ from pyrig.src.modules.module import to_module_name # noqa: PLC0415
396
+
397
+ package_name = to_module_name(package_dir.resolve().relative_to(Path.cwd()))
398
+ loader = importlib.machinery.SourceFileLoader(
399
+ package_name, str(package_dir / "__init__.py")
400
+ )
401
+ spec = importlib.util.spec_from_loader(package_name, loader, is_package=True)
402
+ if spec is None:
403
+ msg = f"Could not create spec for {package_dir}"
404
+ raise ValueError(msg)
405
+ module = importlib.util.module_from_spec(spec)
406
+ loader.exec_module(module)
407
+ return module
408
+
409
+
410
+ def get_pkg_name_from_project_name(project_name: str) -> str:
411
+ """Convert a project name to a package name.
412
+
413
+ Args:
414
+ project_name: Project name with hyphens.
415
+
416
+ Returns:
417
+ Package name with underscores.
418
+ """
419
+ return project_name.replace("-", "_")
420
+
421
+
422
+ def get_project_name_from_pkg_name(pkg_name: str) -> str:
423
+ """Convert a package name to a project name.
424
+
425
+ Args:
426
+ pkg_name: Package name with underscores.
427
+
428
+ Returns:
429
+ Project name with hyphens.
430
+ """
431
+ return pkg_name.replace("_", "-")
432
+
433
+
434
+ def get_project_name_from_cwd() -> str:
435
+ """Derive the project name from the current directory.
436
+
437
+ The project name is assumed to match the directory name.
438
+
439
+ Returns:
440
+ The current directory name.
441
+ """
442
+ cwd = Path.cwd()
443
+ return cwd.name
444
+
445
+
446
+ def get_pkg_name_from_cwd() -> str:
447
+ """Derive the package name from the current directory.
448
+
449
+ Returns:
450
+ The package name (directory name with hyphens as underscores).
451
+ """
452
+ return get_pkg_name_from_project_name(get_project_name_from_cwd())
@@ -0,0 +1,6 @@
1
+ """Operating system utilities for subprocess execution and command discovery.
2
+
3
+ This package provides utilities for interacting with the operating system,
4
+ including subprocess execution with enhanced error logging and command
5
+ path discovery.
6
+ """
pyrig/src/os/os.py ADDED
@@ -0,0 +1,121 @@
1
+ """Operating system utilities for subprocess execution and command discovery.
2
+
3
+ This module provides utilities for working with the operating system,
4
+ including subprocess execution with enhanced error logging and command
5
+ path discovery. These utilities are used throughout pyrig for running
6
+ external tools like git, uv, and pre-commit.
7
+
8
+ Example:
9
+ >>> from pyrig.src.os.os import run_subprocess, which_with_raise
10
+ >>> uv_path = which_with_raise("uv")
11
+ >>> result = run_subprocess(["uv", "sync"])
12
+ """
13
+
14
+ import logging
15
+ import shutil
16
+ import subprocess # nosec: B404
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def which_with_raise(cmd: str, *, raise_error: bool = True) -> str | None:
25
+ """Find the path to an executable command.
26
+
27
+ A wrapper around `shutil.which()` that optionally raises an exception
28
+ if the command is not found, rather than silently returning None.
29
+
30
+ Args:
31
+ cmd: The command name to find (e.g., "git", "uv", "python").
32
+ raise_error: If True (default), raises FileNotFoundError when the
33
+ command is not found. If False, returns None instead.
34
+
35
+ Returns:
36
+ The absolute path to the command executable, or None if not found
37
+ and `raise_error` is False.
38
+
39
+ Raises:
40
+ FileNotFoundError: If the command is not found and `raise_error` is True.
41
+
42
+ Example:
43
+ >>> which_with_raise("git")
44
+ '/usr/bin/git'
45
+ >>> which_with_raise("nonexistent", raise_error=False)
46
+ None
47
+ """
48
+ path = shutil.which(cmd)
49
+ if path is None:
50
+ msg = f"Command {cmd} not found"
51
+ if raise_error:
52
+ raise FileNotFoundError(msg)
53
+ return path
54
+
55
+
56
+ def run_subprocess( # noqa: PLR0913
57
+ args: Sequence[str],
58
+ *,
59
+ input_: str | bytes | None = None,
60
+ capture_output: bool = True,
61
+ timeout: int | None = None,
62
+ check: bool = True,
63
+ cwd: str | Path | None = None,
64
+ **kwargs: Any,
65
+ ) -> subprocess.CompletedProcess[Any]:
66
+ """Execute a subprocess with enhanced error logging.
67
+
68
+ A wrapper around `subprocess.run()` that provides detailed logging when
69
+ a subprocess fails. On failure, logs the command arguments, return code,
70
+ stdout, and stderr before re-raising the exception.
71
+
72
+ Args:
73
+ args: The command and arguments to execute (e.g., ["git", "status"]).
74
+ input_: Data to send to the subprocess's stdin.
75
+ capture_output: If True (default), captures stdout and stderr.
76
+ timeout: Maximum seconds to wait for the process to complete.
77
+ check: If True (default), raises CalledProcessError on non-zero exit.
78
+ cwd: Working directory for the subprocess. Defaults to current directory.
79
+ **kwargs: Additional arguments passed to `subprocess.run()`.
80
+
81
+ Returns:
82
+ A CompletedProcess instance with return code, stdout, and stderr.
83
+
84
+ Raises:
85
+ subprocess.CalledProcessError: If the process returns non-zero exit
86
+ code and `check` is True. The exception is logged with full
87
+ details before being re-raised.
88
+ subprocess.TimeoutExpired: If the process exceeds `timeout`.
89
+
90
+ Example:
91
+ >>> result = run_subprocess(["git", "status"])
92
+ >>> print(result.stdout.decode())
93
+ On branch main...
94
+ """
95
+ if cwd is None:
96
+ cwd = Path.cwd()
97
+ try:
98
+ return subprocess.run( # noqa: S603 # nosec: B603
99
+ args,
100
+ check=check,
101
+ input=input_,
102
+ capture_output=capture_output,
103
+ timeout=timeout,
104
+ cwd=cwd,
105
+ **kwargs,
106
+ )
107
+ except subprocess.CalledProcessError as e:
108
+ logger.exception(
109
+ """
110
+ Failed to run subprocess:
111
+ args: %s
112
+ returncode: %s
113
+ stdout: %s
114
+ stderr: %s
115
+ """,
116
+ args,
117
+ e.returncode,
118
+ e.stdout.decode("utf-8"),
119
+ e.stderr.decode("utf-8"),
120
+ )
121
+ raise
@@ -0,0 +1,5 @@
1
+ """Project initialization and management utilities.
2
+
3
+ This package provides utilities for initializing new pyrig projects,
4
+ creating project structure, managing dependencies, and versioning.
5
+ """
@@ -0,0 +1,83 @@
1
+ """Command-line argument and script generation utilities.
2
+
3
+ This module provides utilities for building command-line arguments and
4
+ scripts for running Python modules and CLI commands through the project
5
+ management tool (uv). It handles the translation between Python objects
6
+ (modules, functions) and the shell commands needed to invoke them.
7
+
8
+ Key functions:
9
+ - `get_project_mgt_run_cli_cmd_args`: Build args for project CLI commands
10
+ - `get_project_mgt_run_pyrig_cli_cmd_args`: Build args for pyrig CLI commands
11
+ - `get_project_mgt_run_module_args`: Build args for running modules
12
+
13
+ Attributes:
14
+ PROJECT_MGT: The project management tool name ("uv").
15
+ PROJECT_MGT_RUN_ARGS: Base args for running commands ([*PROJECT_MGT_RUN_ARGS]).
16
+ RUN_PYTHON_MODULE_ARGS: Base args for running Python modules.
17
+
18
+ Example:
19
+ >>> from pyrig.src.project.mgt import get_project_mgt_run_pyrig_cli_cmd_args
20
+ >>> args = get_project_mgt_run_pyrig_cli_cmd_args(create_root)
21
+ >>> args
22
+ ['uv', 'run', 'pyrig', 'create-root']
23
+ """
24
+
25
+ import logging
26
+ from collections.abc import Callable, Iterable
27
+ from typing import Any
28
+
29
+ import pyrig
30
+ from pyrig.src.modules.package import get_project_name_from_pkg_name
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ PROJECT_MGT = "uv"
36
+ """The project management tool used by pyrig."""
37
+
38
+ PROJECT_MGT_RUN_ARGS = [PROJECT_MGT, "run"]
39
+ """Base arguments for running commands with the project manager."""
40
+
41
+ RUN_PYTHON_MODULE_ARGS = ["python", "-m"]
42
+ """Base arguments for running Python modules."""
43
+
44
+
45
+ PROJECT_MGT_RUN_SCRIPT = " ".join(PROJECT_MGT_RUN_ARGS)
46
+ """Base script for running commands with the project manager."""
47
+
48
+
49
+ def get_script_from_args(args: Iterable[str]) -> str:
50
+ """Convert command arguments to a shell script string.
51
+
52
+ Args:
53
+ args: Sequence of command arguments.
54
+
55
+ Returns:
56
+ A space-joined string suitable for shell execution.
57
+ """
58
+ return " ".join(args)
59
+
60
+
61
+ def get_pyrig_cli_cmd_args(cmd: Callable[..., Any]) -> list[str]:
62
+ """Returns cli args for pyrig cmd execution."""
63
+ return [
64
+ get_project_name_from_pkg_name(pyrig.__name__),
65
+ get_project_name_from_pkg_name(cmd.__name__), # ty:ignore[unresolved-attribute]
66
+ ]
67
+
68
+
69
+ def get_pyrig_cli_cmd_script(cmd: Callable[..., Any]) -> str:
70
+ """Returns cli script for pyrig cmd execution."""
71
+ args = get_pyrig_cli_cmd_args(cmd)
72
+ return get_script_from_args(args)
73
+
74
+
75
+ def get_project_mgt_run_pyrig_cli_cmd_args(cmd: Callable[..., Any]) -> list[str]:
76
+ """Returns cli args for pyrig cmd execution through project mgt."""
77
+ return [*PROJECT_MGT_RUN_ARGS, *get_pyrig_cli_cmd_args(cmd)]
78
+
79
+
80
+ def get_project_mgt_run_pyrig_cli_cmd_script(cmd: Callable[..., Any]) -> str:
81
+ """Returns cli script for pyrig cmd execution through project mgt."""
82
+ args = get_project_mgt_run_pyrig_cli_cmd_args(cmd)
83
+ return get_script_from_args(args)