git-getpkg 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git_getpkg/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """The git-getpkg package."""
2
+
3
+ __version__ = "0.1.0"
git_getpkg/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from git_getpkg.cli import main
2
+
3
+ raise SystemExit(main())
git_getpkg/cli.py ADDED
@@ -0,0 +1,473 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shlex
7
+ import sys
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from contextlib import ExitStack, nullcontext
10
+ from urllib.parse import urlparse
11
+
12
+ from rich.console import Console
13
+
14
+ from git_getpkg.discovery import discover
15
+ from git_getpkg.github import Repository
16
+ from git_getpkg.github import repositories as github_repositories
17
+ from git_getpkg.github import signals as github_signals
18
+ from git_getpkg.installer import (
19
+ bin_directory,
20
+ create_command_shims,
21
+ ensure_bin_on_path,
22
+ high_risk_pip_signals,
23
+ install_python,
24
+ validate_python,
25
+ )
26
+ from git_getpkg.models import PackageReport, SourceInfo
27
+ from git_getpkg.security import findings as security_findings
28
+ from git_getpkg.security import scan_python
29
+ from git_getpkg.source import open_source
30
+ from git_getpkg.trust import assess, source_signals
31
+
32
+ MAX_PARALLEL_REPOSITORY_SCANS = 8
33
+ OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS = 90
34
+
35
+
36
+ def _link(label: str, url: str | None, enabled: bool) -> str:
37
+ if not url or not enabled:
38
+ return label
39
+ return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
40
+
41
+
42
+ def _display_date(value: str | None) -> str:
43
+ return value[:10] if value else "—"
44
+
45
+
46
+ def _display_bytes(value: int | None) -> str:
47
+ if value is None:
48
+ return "—"
49
+ units = ("B", "KiB", "MiB", "GiB", "TiB")
50
+ amount = float(value)
51
+ for unit in units:
52
+ if amount < 1024 or unit == units[-1]:
53
+ return f"{amount:.0f} {unit}" if unit in {"B", "KiB"} else f"{amount:.1f} {unit}"
54
+ amount /= 1024
55
+ return "—"
56
+
57
+
58
+ def _compact_signals(signals: list[str], *, limit: int = 2) -> str:
59
+ value = " · ".join(signals[:limit])
60
+ if len(signals) > limit:
61
+ value += f" +{len(signals) - limit}"
62
+ return value
63
+
64
+
65
+ def render_reports(reports: list[PackageReport], source: SourceInfo, *, links: bool) -> str:
66
+ headers = ["Package", "Type", "Repository", "Namespace", "Path", "Last touched", "Signals"]
67
+ raw_rows: list[list[str]] = []
68
+ for report in reports:
69
+ touched = (
70
+ "—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
71
+ )
72
+ raw_rows.append(
73
+ [
74
+ report.package.name + (f" {report.package.version}" if report.package.version else ""),
75
+ report.package.ecosystem,
76
+ source.repository_name,
77
+ source.namespace or "—",
78
+ report.package.relative_path,
79
+ touched,
80
+ _compact_signals(report.signals),
81
+ ]
82
+ )
83
+ widths = [len(header) for header in headers]
84
+ for row in raw_rows:
85
+ for index, cell in enumerate(row):
86
+ widths[index] = max(widths[index], len(cell))
87
+ output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
88
+ output.append(" ".join("-" * width for width in widths))
89
+ for row in raw_rows:
90
+ rendered = list(row)
91
+ rendered[2] = _link(row[2], source.repository_url, links)
92
+ rendered[3] = _link(row[3], source.namespace_url, links) if row[3] != "—" else row[3]
93
+ output.append(
94
+ " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
95
+ )
96
+ return "\n".join(output)
97
+
98
+
99
+ def render_repositories(repositories: list[Repository], *, links: bool) -> str:
100
+ headers = ["Repository", "Language", "Updated", "Visibility", "Status", "Package scan"]
101
+ rows: list[list[str]] = []
102
+ for repository in repositories:
103
+ status = "archived" if repository.archived else "fork" if repository.fork else "active"
104
+ rows.append(
105
+ [
106
+ repository.name,
107
+ repository.language or "—",
108
+ _display_date(repository.updated_at),
109
+ "private" if repository.private else "public",
110
+ status,
111
+ "eligible" if repository.is_package_candidate else "skipped",
112
+ ]
113
+ )
114
+ widths = [len(header) for header in headers]
115
+ for row in rows:
116
+ for index, cell in enumerate(row):
117
+ widths[index] = max(widths[index], len(cell))
118
+ output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
119
+ output.append(" ".join("-" * width for width in widths))
120
+ for repository, row in zip(repositories, rows):
121
+ rendered = list(row)
122
+ rendered[0] = _link(row[0], repository.url, links)
123
+ output.append(
124
+ " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
125
+ )
126
+ return "\n".join(output)
127
+
128
+
129
+ def render_discovery_size_footer(repositories: list[Repository], table: str) -> str:
130
+ known_size_kib = sum(repository.reported_size_kib or 0 for repository in repositories)
131
+ unknown_sizes = sum(repository.reported_size_kib is None for repository in repositories)
132
+ summary = f"Estimated checkout data: {_display_bytes(known_size_kib * 1024)}"
133
+ if unknown_sizes:
134
+ summary += f" ({unknown_sizes} unavailable)"
135
+ detail = "Dependencies and build artifacts are not included."
136
+ width = max((len(line) for line in table.splitlines()), default=0)
137
+ return "\n".join((summary.rjust(width), detail.rjust(width)))
138
+
139
+
140
+ def render_owner_reports(reports: list[tuple[SourceInfo, PackageReport]], *, links: bool) -> str:
141
+ headers = ["Repository source", "Package", "Install", "Last touched", "Signals"]
142
+ rows: list[list[str]] = []
143
+ for source, report in reports:
144
+ touched = (
145
+ "—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
146
+ )
147
+ rows.append(
148
+ [
149
+ source.repository_url or source.original,
150
+ report.package.name + (f" {report.package.version}" if report.package.version else ""),
151
+ "Python ready" if report.package.ecosystem == "Python" and report.package.installable else "list only",
152
+ touched,
153
+ _compact_signals(report.signals),
154
+ ]
155
+ )
156
+ widths = [len(header) for header in headers]
157
+ for row in rows:
158
+ for index, cell in enumerate(row):
159
+ widths[index] = max(widths[index], len(cell))
160
+ output = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))]
161
+ output.append(" ".join("-" * width for width in widths))
162
+ for (source, _), row in zip(reports, rows):
163
+ rendered = list(row)
164
+ rendered[0] = _link(row[0], source.repository_url, links)
165
+ output.append(
166
+ " ".join(cell + " " * max(0, widths[index] - len(row[index])) for index, cell in enumerate(rendered))
167
+ )
168
+ return "\n".join(output)
169
+
170
+
171
+ def _reports(source: SourceInfo, *, enrich: bool) -> list[PackageReport]:
172
+ packages = discover(source.root)
173
+ context = github_signals(source) if enrich else []
174
+ base = source_signals(source)
175
+ reports = [assess(source, package, context, base) for package in packages]
176
+ for report in reports:
177
+ if report.package.ecosystem == "Python":
178
+ report.signals.append(scan_python(report.package.directory))
179
+ return reports
180
+
181
+
182
+ def _confirm(prompt: str, yes: bool) -> bool:
183
+ if yes:
184
+ return True
185
+ if not sys.stdin.isatty():
186
+ raise RuntimeError("Refusing non-interactive installation without --yes. Use --dry-run to inspect the plan.")
187
+ return input(f"{prompt} [y/N] ").strip().lower() in {"y", "yes"}
188
+
189
+
190
+ def _select(reports: list[PackageReport], name: str | None) -> list[PackageReport]:
191
+ if not name:
192
+ return reports
193
+ matches = [report for report in reports if report.package.name == name]
194
+ if not matches:
195
+ raise ValueError(f"No package named {name!r} was discovered.")
196
+ if len(matches) > 1:
197
+ locations = ", ".join(report.package.relative_path for report in matches)
198
+ raise ValueError(f"Package name {name!r} is ambiguous: {locations}")
199
+ return matches
200
+
201
+
202
+ def _scan_github_repository(repository: Repository) -> list[tuple[SourceInfo, PackageReport]]:
203
+ """Clone, scan, and clean up one approved repository in a worker thread."""
204
+ with open_source(repository.clone_url, clone_timeout=OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS) as source:
205
+ return [(source, report) for report in _reports(source, enrich=False)]
206
+
207
+
208
+ def github_owner_from_source(value: str) -> str | None:
209
+ """Recognize an owner URL without confusing it with a repository URL."""
210
+ if value.startswith("github:"):
211
+ owner = value.removeprefix("github:")
212
+ return owner or None
213
+ parsed = urlparse(value)
214
+ parts = [part for part in parsed.path.split("/") if part]
215
+ if parsed.scheme == "https" and parsed.hostname == "github.com" and len(parts) == 1:
216
+ return parts[0]
217
+ return None
218
+
219
+
220
+ def _list_github_owner(owner: str, args: argparse.Namespace, *, console: Console, show_progress: bool) -> int:
221
+ repositories = [repository for repository in github_repositories(owner) if not repository.archived]
222
+ language_candidates = [repository for repository in repositories if repository.is_package_candidate]
223
+ candidates = language_candidates
224
+ if args.as_json:
225
+ print(
226
+ json.dumps(
227
+ [
228
+ {
229
+ "name": repository.name,
230
+ "url": repository.url,
231
+ "clone_url": repository.clone_url,
232
+ "language": repository.language,
233
+ "updated_at": repository.updated_at,
234
+ "private": repository.private,
235
+ "archived": repository.archived,
236
+ "fork": repository.fork,
237
+ "reported_size_kib": repository.reported_size_kib,
238
+ "package_scan_eligible": repository in candidates,
239
+ }
240
+ for repository in repositories
241
+ ],
242
+ indent=2,
243
+ sort_keys=True,
244
+ )
245
+ )
246
+ return 0
247
+ links = not args.no_links and sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
248
+ print(
249
+ f"Discovered {len(repositories)} active repositories; "
250
+ f"{len(language_candidates)} have a supported primary language; "
251
+ f"{len(candidates)} are eligible for package scanning."
252
+ )
253
+ if not candidates:
254
+ print("No active repositories are eligible for package scanning.")
255
+ return 0
256
+ table = render_repositories(candidates, links=links)
257
+ print(table)
258
+ print(render_discovery_size_footer(candidates, table))
259
+ if not args.discover_packages:
260
+ if not sys.stdin.isatty():
261
+ print("Re-run with --discover-packages to clone eligible repositories and inspect package manifests.")
262
+ return 0
263
+ if not _confirm(f"Discover packages in {len(candidates)} eligible repositories?", False):
264
+ return 0
265
+ reports: list[tuple[SourceInfo, PackageReport]] = []
266
+ failures: list[tuple[Repository, Exception]] = []
267
+ workers = min(MAX_PARALLEL_REPOSITORY_SCANS, len(candidates))
268
+ activity = (
269
+ console.status(f"Scanning repositories (0/{len(candidates)})…", spinner="dots")
270
+ if show_progress
271
+ else nullcontext()
272
+ )
273
+ with activity as status, ThreadPoolExecutor(max_workers=workers, thread_name_prefix="git-getpkg") as executor:
274
+ futures = {executor.submit(_scan_github_repository, repository): repository for repository in candidates}
275
+ for completed, future in enumerate(as_completed(futures), start=1):
276
+ repository = futures[future]
277
+ try:
278
+ reports.extend(future.result())
279
+ except (ValueError, RuntimeError, OSError) as error:
280
+ failures.append((repository, error))
281
+ if status:
282
+ status.update(f"Scanning repositories ({completed}/{len(candidates)})…")
283
+ for repository, error in failures:
284
+ print(f"Could not scan {repository.name}: {error}", file=sys.stderr)
285
+ reports.sort(
286
+ key=lambda item: (
287
+ (item[0].repository_url or item[0].original).lower(),
288
+ item[1].package.name.lower(),
289
+ item[1].package.relative_path,
290
+ )
291
+ )
292
+ if reports:
293
+ num_pkgs = len(reports)
294
+ num_repos = len(candidates) - len(failures)
295
+ print(
296
+ f"\nDiscovered {num_pkgs} package{'s' if len(reports) != 1 else ''} across {num_repos} repositories."
297
+ )
298
+ if sys.stdin.isatty() and _confirm("Show discovered packages?", False):
299
+ print(render_owner_reports(reports, links=links))
300
+ print("\nInstall one: git getpkg <repository source> <package>")
301
+ print("Install all from a repository: git getpkg <repository source>")
302
+ elif not failures:
303
+ print("No supported package manifests found in eligible repositories.")
304
+ return 1 if failures else 0
305
+
306
+
307
+ def _install(
308
+ source: SourceInfo,
309
+ reports: list[PackageReport],
310
+ args: argparse.Namespace,
311
+ *,
312
+ console: Console,
313
+ show_progress: bool,
314
+ ) -> int:
315
+ selected = _select(reports, args.package)
316
+ if not selected:
317
+ print("No packages discovered.")
318
+ return 0
319
+
320
+ print(f"Discovered {len(selected)} package{'s' if len(selected) != 1 else ''}.")
321
+ show_review = args.dry_run or (not args.yes and sys.stdin.isatty() and _confirm("Show package details?", False))
322
+ if show_review:
323
+ _print_install_review(source, selected)
324
+ for signal in high_risk_pip_signals():
325
+ print(f" • {signal}")
326
+
327
+ unsupported = [
328
+ item.package.name for item in selected if item.package.ecosystem != "Python" or not item.package.installable
329
+ ]
330
+ if unsupported:
331
+ raise RuntimeError(f"v1 can install only installable Python projects: {', '.join(unsupported)}")
332
+ source_identity = source.repository_url or str(source.root)
333
+ plans = [install_python(item.package, source.commit, source_identity, dry_run=True) for item in selected]
334
+ if show_review:
335
+ for target, commands in plans:
336
+ print(f"\nTarget environment: {target}")
337
+ for command in commands:
338
+ print(" $ " + shlex.join(command))
339
+ managed_bin = bin_directory()
340
+ if str(managed_bin) not in os.environ.get("PATH", "").split(os.pathsep) and show_review:
341
+ print(f"\nAfter installation, git getpkg will add {managed_bin} to your shell PATH for future terminals.")
342
+ if args.dry_run:
343
+ return 0
344
+ if not args.yes and sys.stdin.isatty() and _confirm("Show Live security scan findings?", False):
345
+ print("\nSeverity Rule File Line Finding")
346
+ for report in selected:
347
+ for severity, rule, filename, line, message in security_findings(report.package.directory):
348
+ print(f"{severity:<8} {rule:<4} {filename} {line:<4} {message}")
349
+ if not _confirm(
350
+ f"Install {len(selected)} package(s)? These commands may execute package build/install code. Continue?",
351
+ args.yes,
352
+ ):
353
+ print("Cancelled.")
354
+ return 0
355
+ failures = 0
356
+ for report in selected:
357
+ try:
358
+ activity = (
359
+ console.status(f"Installing {report.package.name}…", spinner="dots") if show_progress else nullcontext()
360
+ )
361
+ with activity:
362
+ target, _ = install_python(report.package, source.commit, source_identity, dry_run=False)
363
+ print(f"Installed {report.package.name} into {target}")
364
+ validation_error = validate_python(target)
365
+ if validation_error:
366
+ failures += 1
367
+ print(f"Dependency validation failed for {report.package.name}: {validation_error}", file=sys.stderr)
368
+ shims = create_command_shims(report.package, target)
369
+ if shims.created:
370
+ print(f"Added command(s) to {shims.directory}: {', '.join(shims.created)}")
371
+ path_result = ensure_bin_on_path(directory=shims.directory)
372
+ print(path_result.message)
373
+ if path_result.configured:
374
+ print("Open a new terminal to use these commands by name.")
375
+ if shims.conflicts:
376
+ print(f"Did not replace existing command(s): {', '.join(shims.conflicts)}", file=sys.stderr)
377
+ except Exception as error: # Continue to report every requested package.
378
+ failures += 1
379
+ print(f"Failed to install {report.package.name}: {error}", file=sys.stderr)
380
+ return 1 if failures else 0
381
+
382
+
383
+ def _print_install_review(source: SourceInfo, selected: list[PackageReport]) -> None:
384
+ """Show the opt-in pre-install review in a compact table."""
385
+ headers = ["Package", "Commit", "Live security scan", "Signals"]
386
+ rows: list[list[str]] = []
387
+ for report in selected:
388
+ security = next(
389
+ (item for item in report.signals if item.startswith("Live security scan:")),
390
+ "Live security scan: unavailable",
391
+ )
392
+ other = [item for item in report.signals if item != security]
393
+ rows.append(
394
+ [
395
+ report.package.name,
396
+ (source.commit or "unresolved")[:12],
397
+ security.removeprefix("Live security scan: "),
398
+ _compact_signals(other, limit=4),
399
+ ]
400
+ )
401
+ widths = [len(header) for header in headers]
402
+ for row in rows:
403
+ for index, cell in enumerate(row):
404
+ widths[index] = max(widths[index], len(cell))
405
+ print()
406
+ print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
407
+ print(" ".join("-" * width for width in widths))
408
+ for row in rows:
409
+ print(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
410
+
411
+
412
+ def parser() -> argparse.ArgumentParser:
413
+ command = argparse.ArgumentParser(
414
+ prog="git getpkg", description="Safely discover and install packages from Git sources."
415
+ )
416
+ command.add_argument("--version", action="version", version="git-getpkg 0.1.0")
417
+ subcommands = command.add_subparsers(dest="command", required=False)
418
+ list_command = subcommands.add_parser("list", help="Discover packages without executing repository code")
419
+ list_command.add_argument("source")
420
+ list_command.add_argument("--json", action="store_true", dest="as_json")
421
+ list_command.add_argument("--no-links", action="store_true")
422
+ list_command.add_argument(
423
+ "--discover-packages",
424
+ action="store_true",
425
+ help="After listing github:OWNER repositories, clone eligible repositories and scan manifests.",
426
+ )
427
+ install = subcommands.add_parser("install", help="Install packages from source")
428
+ install.add_argument("source")
429
+ install.add_argument("package", nargs="?")
430
+ install.add_argument("--dry-run", action="store_true")
431
+ install.add_argument("--yes", action="store_true")
432
+ return command
433
+
434
+
435
+ def main(argv: list[str] | None = None) -> int:
436
+ values = list(argv if argv is not None else sys.argv[1:])
437
+ # `git getpkg <source>` is the friendly default form.
438
+ if values and values[0] not in {"list", "install", "--help", "-h", "--version"}:
439
+ values.insert(0, "install")
440
+ args = parser().parse_args(values)
441
+ if args.command is None:
442
+ parser().print_help()
443
+ return 0
444
+ try:
445
+ console = Console(stderr=True)
446
+ show_progress = sys.stderr.isatty() and not (args.command == "list" and args.as_json)
447
+ github_owner = github_owner_from_source(args.source) if args.command == "list" else None
448
+ if github_owner:
449
+ if args.as_json and args.discover_packages:
450
+ raise ValueError("--json and --discover-packages cannot be used together.")
451
+ return _list_github_owner(github_owner, args, console=console, show_progress=show_progress)
452
+ with ExitStack() as stack:
453
+ activity = console.status("Preparing source…", spinner="dots") if show_progress else nullcontext()
454
+ with activity as status:
455
+ source = stack.enter_context(open_source(args.source))
456
+ if status:
457
+ status.update("Scanning package manifests and running Security scan…")
458
+ reports = _reports(source, enrich=args.command == "install")
459
+ if status and args.command == "install":
460
+ status.update("Preparing installation review…")
461
+ if args.command == "list":
462
+ if args.as_json:
463
+ print(json.dumps([report.as_dict(source) for report in reports], indent=2, sort_keys=True))
464
+ elif reports:
465
+ links = not args.no_links and sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
466
+ print(render_reports(reports, source, links=links))
467
+ else:
468
+ print("No supported package manifests found.")
469
+ return 0
470
+ return _install(source, reports, args, console=console, show_progress=show_progress)
471
+ except (ValueError, RuntimeError, OSError) as error:
472
+ print(f"git getpkg: {error}", file=sys.stderr)
473
+ return 2
git_getpkg/command.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ class CommandError(RuntimeError):
8
+ pass
9
+
10
+
11
+ def run(
12
+ args: list[str], *, cwd: Path | None = None, check: bool = True, timeout: float | None = None
13
+ ) -> subprocess.CompletedProcess[str]:
14
+ try:
15
+ result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
16
+ except subprocess.TimeoutExpired as error:
17
+ raise CommandError(f"{' '.join(args[:3])}: timed out after {error.timeout:g} seconds") from error
18
+ if check and result.returncode:
19
+ message = result.stderr.strip() or result.stdout.strip() or "command failed"
20
+ raise CommandError(f"{' '.join(args[:3])}: {message}")
21
+ return result