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.
- pyrig/__init__.py +1 -0
- pyrig/dev/__init__.py +6 -0
- pyrig/dev/builders/__init__.py +1 -0
- pyrig/dev/builders/base/__init__.py +5 -0
- pyrig/dev/builders/base/base.py +256 -0
- pyrig/dev/builders/pyinstaller.py +229 -0
- pyrig/dev/cli/__init__.py +5 -0
- pyrig/dev/cli/cli.py +95 -0
- pyrig/dev/cli/commands/__init__.py +1 -0
- pyrig/dev/cli/commands/build_artifacts.py +16 -0
- pyrig/dev/cli/commands/create_root.py +25 -0
- pyrig/dev/cli/commands/create_tests.py +244 -0
- pyrig/dev/cli/commands/init_project.py +160 -0
- pyrig/dev/cli/commands/make_inits.py +27 -0
- pyrig/dev/cli/commands/protect_repo.py +145 -0
- pyrig/dev/cli/shared_subcommands.py +20 -0
- pyrig/dev/cli/subcommands.py +73 -0
- pyrig/dev/configs/__init__.py +1 -0
- pyrig/dev/configs/base/__init__.py +5 -0
- pyrig/dev/configs/base/base.py +826 -0
- pyrig/dev/configs/containers/__init__.py +1 -0
- pyrig/dev/configs/containers/container_file.py +111 -0
- pyrig/dev/configs/dot_env.py +95 -0
- pyrig/dev/configs/dot_python_version.py +88 -0
- pyrig/dev/configs/git/__init__.py +5 -0
- pyrig/dev/configs/git/gitignore.py +181 -0
- pyrig/dev/configs/git/pre_commit.py +170 -0
- pyrig/dev/configs/licence.py +112 -0
- pyrig/dev/configs/markdown/__init__.py +1 -0
- pyrig/dev/configs/markdown/docs/__init__.py +1 -0
- pyrig/dev/configs/markdown/docs/index.py +38 -0
- pyrig/dev/configs/markdown/readme.py +132 -0
- pyrig/dev/configs/py_typed.py +28 -0
- pyrig/dev/configs/pyproject.py +436 -0
- pyrig/dev/configs/python/__init__.py +5 -0
- pyrig/dev/configs/python/builders_init.py +27 -0
- pyrig/dev/configs/python/configs_init.py +28 -0
- pyrig/dev/configs/python/dot_experiment.py +46 -0
- pyrig/dev/configs/python/main.py +59 -0
- pyrig/dev/configs/python/resources_init.py +27 -0
- pyrig/dev/configs/python/shared_subcommands.py +29 -0
- pyrig/dev/configs/python/src_init.py +27 -0
- pyrig/dev/configs/python/subcommands.py +27 -0
- pyrig/dev/configs/testing/__init__.py +5 -0
- pyrig/dev/configs/testing/conftest.py +64 -0
- pyrig/dev/configs/testing/fixtures_init.py +27 -0
- pyrig/dev/configs/testing/main_test.py +74 -0
- pyrig/dev/configs/testing/zero_test.py +43 -0
- pyrig/dev/configs/workflows/__init__.py +5 -0
- pyrig/dev/configs/workflows/base/__init__.py +5 -0
- pyrig/dev/configs/workflows/base/base.py +1662 -0
- pyrig/dev/configs/workflows/build.py +106 -0
- pyrig/dev/configs/workflows/health_check.py +133 -0
- pyrig/dev/configs/workflows/publish.py +68 -0
- pyrig/dev/configs/workflows/release.py +90 -0
- pyrig/dev/tests/__init__.py +5 -0
- pyrig/dev/tests/conftest.py +40 -0
- pyrig/dev/tests/fixtures/__init__.py +1 -0
- pyrig/dev/tests/fixtures/assertions.py +147 -0
- pyrig/dev/tests/fixtures/autouse/__init__.py +5 -0
- pyrig/dev/tests/fixtures/autouse/class_.py +42 -0
- pyrig/dev/tests/fixtures/autouse/module.py +40 -0
- pyrig/dev/tests/fixtures/autouse/session.py +589 -0
- pyrig/dev/tests/fixtures/factories.py +118 -0
- pyrig/dev/utils/__init__.py +1 -0
- pyrig/dev/utils/cli.py +17 -0
- pyrig/dev/utils/git.py +312 -0
- pyrig/dev/utils/packages.py +93 -0
- pyrig/dev/utils/resources.py +77 -0
- pyrig/dev/utils/testing.py +66 -0
- pyrig/dev/utils/versions.py +268 -0
- pyrig/main.py +9 -0
- pyrig/py.typed +0 -0
- pyrig/resources/GITIGNORE +216 -0
- pyrig/resources/LATEST_PYTHON_VERSION +1 -0
- pyrig/resources/MIT_LICENSE_TEMPLATE +21 -0
- pyrig/resources/__init__.py +1 -0
- pyrig/src/__init__.py +1 -0
- pyrig/src/git/__init__.py +6 -0
- pyrig/src/git/git.py +146 -0
- pyrig/src/graph.py +255 -0
- pyrig/src/iterate.py +107 -0
- pyrig/src/modules/__init__.py +22 -0
- pyrig/src/modules/class_.py +369 -0
- pyrig/src/modules/function.py +189 -0
- pyrig/src/modules/inspection.py +148 -0
- pyrig/src/modules/module.py +658 -0
- pyrig/src/modules/package.py +452 -0
- pyrig/src/os/__init__.py +6 -0
- pyrig/src/os/os.py +121 -0
- pyrig/src/project/__init__.py +5 -0
- pyrig/src/project/mgt.py +83 -0
- pyrig/src/resource.py +58 -0
- pyrig/src/string.py +100 -0
- pyrig/src/testing/__init__.py +6 -0
- pyrig/src/testing/assertions.py +66 -0
- pyrig/src/testing/convention.py +203 -0
- pyrig-2.2.6.dist-info/METADATA +174 -0
- pyrig-2.2.6.dist-info/RECORD +102 -0
- pyrig-2.2.6.dist-info/WHEEL +4 -0
- pyrig-2.2.6.dist-info/entry_points.txt +3 -0
- 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())
|
pyrig/src/os/__init__.py
ADDED
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
|
pyrig/src/project/mgt.py
ADDED
|
@@ -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)
|