git-getpkg 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/src/git_getpkg.egg-info → git_getpkg-0.2.0}/PKG-INFO +10 -6
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/README.md +8 -5
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/pyproject.toml +2 -1
- git_getpkg-0.2.0/setup.cfg +4 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/__init__.py +1 -1
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/cli.py +208 -138
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/command.py +16 -2
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/discovery.py +33 -3
- git_getpkg-0.2.0/src/git_getpkg/installer.py +164 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/models.py +1 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/security.py +5 -5
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/source.py +64 -53
- {git_getpkg-0.1.0 → git_getpkg-0.2.0/src/git_getpkg.egg-info}/PKG-INFO +10 -6
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/SOURCES.txt +1 -1
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/requires.txt +1 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_cli.py +16 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_discovery.py +27 -28
- git_getpkg-0.2.0/tests/test_installer.py +48 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/tests/test_source.py +23 -4
- git_getpkg-0.1.0/setup.cfg +0 -31
- git_getpkg-0.1.0/src/git_getpkg/installer.py +0 -162
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/LICENSE +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/__main__.py +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/github.py +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg/trust.py +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/dependency_links.txt +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/entry_points.txt +0 -0
- {git_getpkg-0.1.0 → git_getpkg-0.2.0}/src/git_getpkg.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-getpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Discover and easily install packages from Git repositories
|
|
5
5
|
Author: mergefriends
|
|
6
6
|
License: MIT License
|
|
@@ -37,6 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
License-File: LICENSE
|
|
38
38
|
Requires-Dist: bandit[toml]>=1.8
|
|
39
39
|
Requires-Dist: pip-audit>=2.10
|
|
40
|
+
Requires-Dist: pipx<2,>=1.7
|
|
40
41
|
Requires-Dist: rich<14,>=13.5.2
|
|
41
42
|
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
42
43
|
Dynamic: license-file
|
|
@@ -49,7 +50,7 @@ then installs selected Python packages into isolated managed environments.
|
|
|
49
50
|
## Install
|
|
50
51
|
|
|
51
52
|
```bash
|
|
52
|
-
brew install pipx && pipx ensurepath && pipx install git
|
|
53
|
+
brew install pipx && pipx ensurepath && pipx install git-getpkg
|
|
53
54
|
```
|
|
54
55
|
|
|
55
56
|
## Usage
|
|
@@ -70,9 +71,12 @@ Install a single package from a repo
|
|
|
70
71
|
git getpkg https://github.com/psf/black black
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
##
|
|
74
|
+
## Develop, build, and test locally
|
|
75
|
+
|
|
76
|
+
Requires Python 3.10+ and pipx. From the repository root:
|
|
74
77
|
|
|
75
78
|
```bash
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
pipx install --editable . --force
|
|
80
|
+
pipx run pytest -q
|
|
81
|
+
git getpkg list .
|
|
82
|
+
g```
|
|
@@ -6,7 +6,7 @@ then installs selected Python packages into isolated managed environments.
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
brew install pipx && pipx ensurepath && pipx install git
|
|
9
|
+
brew install pipx && pipx ensurepath && pipx install git-getpkg
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
## Usage
|
|
@@ -27,9 +27,12 @@ Install a single package from a repo
|
|
|
27
27
|
git getpkg https://github.com/psf/black black
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Develop, build, and test locally
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10+ and pipx. From the repository root:
|
|
31
33
|
|
|
32
34
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
pipx install --editable . --force
|
|
36
|
+
pipx run pytest -q
|
|
37
|
+
git getpkg list .
|
|
38
|
+
g```
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-getpkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Discover and easily install packages from Git repositories"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"bandit[toml]>=1.8",
|
|
13
13
|
"pip-audit>=2.10",
|
|
14
|
+
"pipx>=1.7,<2",
|
|
14
15
|
"rich>=13.5.2,<14",
|
|
15
16
|
"tomli>=2.0; python_version < '3.11'",
|
|
16
17
|
]
|
|
@@ -4,39 +4,77 @@ import argparse
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import shlex
|
|
7
|
+
import shutil
|
|
7
8
|
import sys
|
|
8
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
10
|
from contextlib import ExitStack, nullcontext
|
|
11
|
+
from io import StringIO
|
|
10
12
|
from urllib.parse import urlparse
|
|
11
13
|
|
|
12
14
|
from rich.console import Console
|
|
15
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
13
18
|
|
|
14
19
|
from git_getpkg.discovery import discover
|
|
15
20
|
from git_getpkg.github import Repository
|
|
16
21
|
from git_getpkg.github import repositories as github_repositories
|
|
17
22
|
from git_getpkg.github import signals as github_signals
|
|
18
23
|
from git_getpkg.installer import (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
audit_python,
|
|
25
|
+
audit_python_application,
|
|
26
|
+
ensure_pipx_path,
|
|
27
|
+
expose_python_application,
|
|
22
28
|
high_risk_pip_signals,
|
|
23
29
|
install_python,
|
|
30
|
+
install_python_application,
|
|
31
|
+
remove_environment,
|
|
32
|
+
remove_python_application,
|
|
24
33
|
validate_python,
|
|
34
|
+
validate_python_application,
|
|
25
35
|
)
|
|
26
36
|
from git_getpkg.models import PackageReport, SourceInfo
|
|
27
37
|
from git_getpkg.security import findings as security_findings
|
|
28
38
|
from git_getpkg.security import scan_python
|
|
29
|
-
from git_getpkg.source import open_source
|
|
39
|
+
from git_getpkg.source import cleanup_stale_remote_checkouts, open_source
|
|
30
40
|
from git_getpkg.trust import assess, source_signals
|
|
31
41
|
|
|
32
42
|
MAX_PARALLEL_REPOSITORY_SCANS = 8
|
|
33
43
|
OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS = 90
|
|
34
44
|
|
|
35
45
|
|
|
36
|
-
def _link(label: str, url: str | None, enabled: bool) ->
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
def _link(label: str, url: str | None, enabled: bool) -> Text:
|
|
47
|
+
return Text(label, style=f"link {url}") if url and enabled else Text(label)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _terminal_width() -> int:
|
|
51
|
+
return shutil.get_terminal_size(fallback=(100, 24)).columns
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_table(headers: list[str], rows: list[list[str | Text]], *, links: bool = False) -> str:
|
|
55
|
+
"""Render a compact table that adapts to the active terminal width."""
|
|
56
|
+
width = _terminal_width()
|
|
57
|
+
stream = StringIO()
|
|
58
|
+
console = Console(
|
|
59
|
+
file=stream,
|
|
60
|
+
width=width,
|
|
61
|
+
force_terminal=links,
|
|
62
|
+
color_system="standard" if links else None,
|
|
63
|
+
highlight=False,
|
|
64
|
+
)
|
|
65
|
+
table = Table(box=None, pad_edge=False, collapse_padding=True, show_edge=False, header_style="bold")
|
|
66
|
+
for header in headers:
|
|
67
|
+
table.add_column(
|
|
68
|
+
header,
|
|
69
|
+
overflow="ellipsis",
|
|
70
|
+
max_width=max(24, width // 2) if header in {"Signals", "Finding"} else None,
|
|
71
|
+
no_wrap=header not in {"Signals", "Finding"},
|
|
72
|
+
ratio=1 if header in {"Signals", "Finding"} else None,
|
|
73
|
+
)
|
|
74
|
+
for row in rows:
|
|
75
|
+
table.add_row(*row)
|
|
76
|
+
console.print(table)
|
|
77
|
+
return stream.getvalue().rstrip()
|
|
40
78
|
|
|
41
79
|
|
|
42
80
|
def _display_date(value: str | None) -> str:
|
|
@@ -63,47 +101,39 @@ def _compact_signals(signals: list[str], *, limit: int = 2) -> str:
|
|
|
63
101
|
|
|
64
102
|
|
|
65
103
|
def render_reports(reports: list[PackageReport], source: SourceInfo, *, links: bool) -> str:
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
width = _terminal_width()
|
|
105
|
+
if width < 80:
|
|
106
|
+
headers = ["Package", "Last touched", "Signals"]
|
|
107
|
+
elif width < 110:
|
|
108
|
+
headers = ["Package", "Type", "Last touched", "Signals"]
|
|
109
|
+
else:
|
|
110
|
+
headers = ["Package", "Type", "Repository", "Namespace", "Path", "Last touched", "Signals"]
|
|
111
|
+
rows: list[list[str | Text]] = []
|
|
68
112
|
for report in reports:
|
|
69
113
|
touched = (
|
|
70
114
|
"—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
|
|
71
115
|
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
116
|
+
values: dict[str, str | Text] = {
|
|
117
|
+
"Package": report.package.name + (f" {report.package.version}" if report.package.version else ""),
|
|
118
|
+
"Type": report.package.ecosystem,
|
|
119
|
+
"Repository": _link(source.repository_name, source.repository_url, links),
|
|
120
|
+
"Namespace": _link(source.namespace, source.namespace_url, links) if source.namespace else "—",
|
|
121
|
+
"Path": report.package.relative_path,
|
|
122
|
+
"Last touched": touched,
|
|
123
|
+
"Signals": _compact_signals(report.signals),
|
|
124
|
+
}
|
|
125
|
+
rows.append([values[header] for header in headers])
|
|
126
|
+
return _render_table(headers, rows, links=links)
|
|
97
127
|
|
|
98
128
|
|
|
99
129
|
def render_repositories(repositories: list[Repository], *, links: bool) -> str:
|
|
100
130
|
headers = ["Repository", "Language", "Updated", "Visibility", "Status", "Package scan"]
|
|
101
|
-
rows: list[list[str]] = []
|
|
131
|
+
rows: list[list[str | Text]] = []
|
|
102
132
|
for repository in repositories:
|
|
103
133
|
status = "archived" if repository.archived else "fork" if repository.fork else "active"
|
|
104
134
|
rows.append(
|
|
105
135
|
[
|
|
106
|
-
repository.name,
|
|
136
|
+
_link(repository.name, repository.url, links),
|
|
107
137
|
repository.language or "—",
|
|
108
138
|
_display_date(repository.updated_at),
|
|
109
139
|
"private" if repository.private else "public",
|
|
@@ -111,19 +141,7 @@ def render_repositories(repositories: list[Repository], *, links: bool) -> str:
|
|
|
111
141
|
"eligible" if repository.is_package_candidate else "skipped",
|
|
112
142
|
]
|
|
113
143
|
)
|
|
114
|
-
|
|
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)
|
|
144
|
+
return _render_table(headers, rows, links=links)
|
|
127
145
|
|
|
128
146
|
|
|
129
147
|
def render_discovery_size_footer(repositories: list[Repository], table: str) -> str:
|
|
@@ -138,43 +156,39 @@ def render_discovery_size_footer(repositories: list[Repository], table: str) ->
|
|
|
138
156
|
|
|
139
157
|
|
|
140
158
|
def render_owner_reports(reports: list[tuple[SourceInfo, PackageReport]], *, links: bool) -> str:
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
width = _terminal_width()
|
|
160
|
+
headers = (
|
|
161
|
+
["Repository source", "Package", "Signals"]
|
|
162
|
+
if width < 100
|
|
163
|
+
else ["Repository source", "Package", "Install", "Last touched", "Signals"]
|
|
164
|
+
)
|
|
165
|
+
rows: list[list[str | Text]] = []
|
|
143
166
|
for source, report in reports:
|
|
144
167
|
touched = (
|
|
145
168
|
"—" if not report.last_touched_by else f"{report.last_touched_by}, {_display_date(report.last_touched_at)}"
|
|
146
169
|
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"Python ready" if report.package.ecosystem == "Python" and report.package.installable else "list only"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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]:
|
|
170
|
+
values: dict[str, str | Text] = {
|
|
171
|
+
"Repository source": _link(source.repository_url or source.original, source.repository_url, links),
|
|
172
|
+
"Package": report.package.name + (f" {report.package.version}" if report.package.version else ""),
|
|
173
|
+
"Install": (
|
|
174
|
+
"Python ready" if report.package.ecosystem == "Python" and report.package.installable else "list only"
|
|
175
|
+
),
|
|
176
|
+
"Last touched": touched,
|
|
177
|
+
"Signals": _compact_signals(report.signals),
|
|
178
|
+
}
|
|
179
|
+
rows.append([values[header] for header in headers])
|
|
180
|
+
return _render_table(headers, rows, links=links)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _reports(source: SourceInfo, *, enrich: bool, security_package: str | None = None) -> list[PackageReport]:
|
|
172
184
|
packages = discover(source.root)
|
|
173
185
|
context = github_signals(source) if enrich else []
|
|
174
186
|
base = source_signals(source)
|
|
175
187
|
reports = [assess(source, package, context, base) for package in packages]
|
|
176
188
|
for report in reports:
|
|
177
|
-
if report.package.ecosystem == "Python"
|
|
189
|
+
if report.package.ecosystem == "Python" and (
|
|
190
|
+
security_package is None or report.package.name == security_package
|
|
191
|
+
):
|
|
178
192
|
report.signals.append(scan_python(report.package.directory))
|
|
179
193
|
return reports
|
|
180
194
|
|
|
@@ -199,6 +213,26 @@ def _select(reports: list[PackageReport], name: str | None) -> list[PackageRepor
|
|
|
199
213
|
return matches
|
|
200
214
|
|
|
201
215
|
|
|
216
|
+
def _print_source_risk_findings(reports: list[PackageReport]) -> None:
|
|
217
|
+
"""Print detailed non-executing Bandit findings for selected Python packages."""
|
|
218
|
+
rows: list[list[str]] = []
|
|
219
|
+
for report in reports:
|
|
220
|
+
for severity, rule, filename, line, message in security_findings(report.package.directory):
|
|
221
|
+
rows.append([severity, rule, filename, str(line), message])
|
|
222
|
+
if rows:
|
|
223
|
+
print(_render_table(["Severity", "Rule", "File", "Line", "Finding"], rows))
|
|
224
|
+
else:
|
|
225
|
+
print("No high or medium source-risk findings.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _source_risk_command(source: str, package: str | None) -> str:
|
|
229
|
+
"""Return a copy-pasteable command for detailed source-risk findings."""
|
|
230
|
+
values = ["git", "getpkg", "scan", source]
|
|
231
|
+
if package:
|
|
232
|
+
values.append(package)
|
|
233
|
+
return shlex.join(values)
|
|
234
|
+
|
|
235
|
+
|
|
202
236
|
def _scan_github_repository(repository: Repository) -> list[tuple[SourceInfo, PackageReport]]:
|
|
203
237
|
"""Clone, scan, and clean up one approved repository in a worker thread."""
|
|
204
238
|
with open_source(repository.clone_url, clone_timeout=OWNER_DISCOVERY_CLONE_TIMEOUT_SECONDS) as source:
|
|
@@ -330,22 +364,11 @@ def _install(
|
|
|
330
364
|
if unsupported:
|
|
331
365
|
raise RuntimeError(f"v1 can install only installable Python projects: {', '.join(unsupported)}")
|
|
332
366
|
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
367
|
if args.dry_run:
|
|
343
368
|
return 0
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
for severity, rule, filename, line, message in security_findings(report.package.directory):
|
|
348
|
-
print(f"{severity:<8} {rule:<4} {filename} {line:<4} {message}")
|
|
369
|
+
console.print(
|
|
370
|
+
f"[yellow]Source-risk details:[/] [dim]{_source_risk_command(args.source, args.package)}[/]"
|
|
371
|
+
)
|
|
349
372
|
if not _confirm(
|
|
350
373
|
f"Install {len(selected)} package(s)? These commands may execute package build/install code. Continue?",
|
|
351
374
|
args.yes,
|
|
@@ -353,67 +376,99 @@ def _install(
|
|
|
353
376
|
print("Cancelled.")
|
|
354
377
|
return 0
|
|
355
378
|
failures = 0
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
379
|
+
progress = (
|
|
380
|
+
Progress(
|
|
381
|
+
SpinnerColumn(),
|
|
382
|
+
TextColumn("[progress.description]{task.description}"),
|
|
383
|
+
BarColumn(),
|
|
384
|
+
console=console,
|
|
385
|
+
transient=True,
|
|
386
|
+
)
|
|
387
|
+
if show_progress
|
|
388
|
+
else None
|
|
389
|
+
)
|
|
390
|
+
with progress or nullcontext():
|
|
391
|
+
for report in selected:
|
|
392
|
+
task = progress.add_task(f"Installing {report.package.name}", total=4) if progress else None
|
|
393
|
+
try:
|
|
394
|
+
if report.package.has_console_scripts:
|
|
395
|
+
install_python_application(report.package, dry_run=False)
|
|
396
|
+
target = None
|
|
397
|
+
else:
|
|
398
|
+
target, _ = install_python(report.package, source.commit, source_identity, dry_run=False)
|
|
399
|
+
if progress:
|
|
400
|
+
progress.update(task, description=f"Validating {report.package.name}", completed=1)
|
|
401
|
+
validation_error = (
|
|
402
|
+
validate_python_application(report.package) if target is None else validate_python(target)
|
|
403
|
+
)
|
|
404
|
+
if progress:
|
|
405
|
+
progress.update(task, description=f"Auditing dependencies for {report.package.name}", completed=2)
|
|
406
|
+
audit = audit_python_application(report.package) if target is None else audit_python(target)
|
|
407
|
+
if validation_error or audit.error or audit.findings:
|
|
408
|
+
failures += 1
|
|
409
|
+
if report.package.has_console_scripts:
|
|
410
|
+
remove_python_application(report.package)
|
|
411
|
+
elif target:
|
|
412
|
+
remove_environment(target)
|
|
413
|
+
console.print(
|
|
414
|
+
f"[bold red]✗ Installation failed for {report.package.name}; staged environment removed.[/]"
|
|
415
|
+
)
|
|
416
|
+
if validation_error:
|
|
417
|
+
print(f"Dependency validation failed: {validation_error}", file=sys.stderr)
|
|
418
|
+
if audit.error:
|
|
419
|
+
print(f"Dependency audit unavailable: {audit.error}", file=sys.stderr)
|
|
420
|
+
for package, version, advisory, fixes in audit.findings:
|
|
421
|
+
fixed = ", ".join(fixes) or "no fix published"
|
|
422
|
+
print(f"Dependency audit: {package} {version} · {advisory} · fixed in {fixed}", file=sys.stderr)
|
|
423
|
+
continue
|
|
424
|
+
if target:
|
|
425
|
+
print(f"Installed {report.package.name} into {target}")
|
|
426
|
+
else:
|
|
427
|
+
if progress:
|
|
428
|
+
progress.update(task, description=f"Exposing commands for {report.package.name}", completed=3)
|
|
429
|
+
expose_python_application(report.package)
|
|
430
|
+
ensure_pipx_path()
|
|
431
|
+
print("Pipx manages this package's commands. Open a new terminal if they are not yet on PATH.")
|
|
432
|
+
if progress:
|
|
433
|
+
progress.update(task, description=f"Installed {report.package.name}", completed=4)
|
|
434
|
+
console.print(f"[bold green]✓ Installed {report.package.name} · dependency audit clean (OSV).[/]")
|
|
435
|
+
except Exception as error: # Continue to report every requested package.
|
|
366
436
|
failures += 1
|
|
367
|
-
print(f"
|
|
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)
|
|
437
|
+
console.print(f"[bold red]✗ Failed to install {report.package.name}: {error}[/]")
|
|
380
438
|
return 1 if failures else 0
|
|
381
439
|
|
|
382
440
|
|
|
383
441
|
def _print_install_review(source: SourceInfo, selected: list[PackageReport]) -> None:
|
|
384
442
|
"""Show the opt-in pre-install review in a compact table."""
|
|
385
|
-
headers = ["Package", "
|
|
443
|
+
headers = ["Package", "Source risk scan", "Signals"] if _terminal_width() < 90 else [
|
|
444
|
+
"Package",
|
|
445
|
+
"Commit",
|
|
446
|
+
"Source risk scan",
|
|
447
|
+
"Signals",
|
|
448
|
+
]
|
|
386
449
|
rows: list[list[str]] = []
|
|
387
450
|
for report in selected:
|
|
388
451
|
security = next(
|
|
389
|
-
(item for item in report.signals if item.startswith("
|
|
390
|
-
"
|
|
452
|
+
(item for item in report.signals if item.startswith("Source risk scan (Bandit):")),
|
|
453
|
+
"Source risk scan (Bandit): unavailable",
|
|
391
454
|
)
|
|
392
455
|
other = [item for item in report.signals if item != security]
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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))
|
|
456
|
+
values = {
|
|
457
|
+
"Package": report.package.name,
|
|
458
|
+
"Commit": (source.commit or "unresolved")[:12],
|
|
459
|
+
"Source risk scan": security.removeprefix("Source risk scan (Bandit): "),
|
|
460
|
+
"Signals": _compact_signals(other, limit=4),
|
|
461
|
+
}
|
|
462
|
+
rows.append([values[header] for header in headers])
|
|
405
463
|
print()
|
|
406
|
-
print(
|
|
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)))
|
|
464
|
+
print(_render_table(headers, rows))
|
|
410
465
|
|
|
411
466
|
|
|
412
467
|
def parser() -> argparse.ArgumentParser:
|
|
413
468
|
command = argparse.ArgumentParser(
|
|
414
469
|
prog="git getpkg", description="Safely discover and install packages from Git sources."
|
|
415
470
|
)
|
|
416
|
-
command.add_argument("--version", action="version", version="git-getpkg 0.
|
|
471
|
+
command.add_argument("--version", action="version", version="git-getpkg 0.2.0")
|
|
417
472
|
subcommands = command.add_subparsers(dest="command", required=False)
|
|
418
473
|
list_command = subcommands.add_parser("list", help="Discover packages without executing repository code")
|
|
419
474
|
list_command.add_argument("source")
|
|
@@ -429,13 +484,16 @@ def parser() -> argparse.ArgumentParser:
|
|
|
429
484
|
install.add_argument("package", nargs="?")
|
|
430
485
|
install.add_argument("--dry-run", action="store_true")
|
|
431
486
|
install.add_argument("--yes", action="store_true")
|
|
487
|
+
scan = subcommands.add_parser("scan", help="Show detailed non-executing source-risk findings")
|
|
488
|
+
scan.add_argument("source")
|
|
489
|
+
scan.add_argument("package", nargs="?")
|
|
432
490
|
return command
|
|
433
491
|
|
|
434
492
|
|
|
435
493
|
def main(argv: list[str] | None = None) -> int:
|
|
436
494
|
values = list(argv if argv is not None else sys.argv[1:])
|
|
437
495
|
# `git getpkg <source>` is the friendly default form.
|
|
438
|
-
if values and values[0] not in {"list", "install", "--help", "-h", "--version"}:
|
|
496
|
+
if values and values[0] not in {"list", "install", "scan", "--help", "-h", "--version"}:
|
|
439
497
|
values.insert(0, "install")
|
|
440
498
|
args = parser().parse_args(values)
|
|
441
499
|
if args.command is None:
|
|
@@ -443,6 +501,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
443
501
|
return 0
|
|
444
502
|
try:
|
|
445
503
|
console = Console(stderr=True)
|
|
504
|
+
cleanup_stale_remote_checkouts()
|
|
446
505
|
show_progress = sys.stderr.isatty() and not (args.command == "list" and args.as_json)
|
|
447
506
|
github_owner = github_owner_from_source(args.source) if args.command == "list" else None
|
|
448
507
|
if github_owner:
|
|
@@ -454,8 +513,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
454
513
|
with activity as status:
|
|
455
514
|
source = stack.enter_context(open_source(args.source))
|
|
456
515
|
if status:
|
|
457
|
-
status.update("Scanning package manifests and running
|
|
458
|
-
reports = _reports(
|
|
516
|
+
status.update("Scanning package manifests and running source risk scan…")
|
|
517
|
+
reports = _reports(
|
|
518
|
+
source,
|
|
519
|
+
enrich=args.command == "install",
|
|
520
|
+
security_package=args.package if args.command in {"install", "scan"} else None,
|
|
521
|
+
)
|
|
459
522
|
if status and args.command == "install":
|
|
460
523
|
status.update("Preparing installation review…")
|
|
461
524
|
if args.command == "list":
|
|
@@ -467,6 +530,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
467
530
|
else:
|
|
468
531
|
print("No supported package manifests found.")
|
|
469
532
|
return 0
|
|
533
|
+
if args.command == "scan":
|
|
534
|
+
selected = _select(reports, args.package)
|
|
535
|
+
if not selected:
|
|
536
|
+
print("No packages discovered.")
|
|
537
|
+
return 0
|
|
538
|
+
_print_source_risk_findings(selected)
|
|
539
|
+
return 0
|
|
470
540
|
return _install(source, reports, args, console=console, show_progress=show_progress)
|
|
471
541
|
except (ValueError, RuntimeError, OSError) as error:
|
|
472
542
|
print(f"git getpkg: {error}", file=sys.stderr)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -9,10 +10,23 @@ class CommandError(RuntimeError):
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def run(
|
|
12
|
-
args: list[str],
|
|
13
|
+
args: list[str],
|
|
14
|
+
*,
|
|
15
|
+
cwd: Path | None = None,
|
|
16
|
+
check: bool = True,
|
|
17
|
+
timeout: float | None = None,
|
|
18
|
+
env: dict[str, str] | None = None,
|
|
13
19
|
) -> subprocess.CompletedProcess[str]:
|
|
14
20
|
try:
|
|
15
|
-
result = subprocess.run(
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
args,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
text=True,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
check=False,
|
|
27
|
+
timeout=timeout,
|
|
28
|
+
env=None if env is None else os.environ | env,
|
|
29
|
+
)
|
|
16
30
|
except subprocess.TimeoutExpired as error:
|
|
17
31
|
raise CommandError(f"{' '.join(args[:3])}: timed out after {error.timeout:g} seconds") from error
|
|
18
32
|
if check and result.returncode:
|