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 +3 -0
- git_getpkg/__main__.py +3 -0
- git_getpkg/cli.py +473 -0
- git_getpkg/command.py +21 -0
- git_getpkg/discovery.py +213 -0
- git_getpkg/github.py +127 -0
- git_getpkg/installer.py +162 -0
- git_getpkg/models.py +55 -0
- git_getpkg/security.py +88 -0
- git_getpkg/source.py +161 -0
- git_getpkg/trust.py +69 -0
- git_getpkg-0.1.0.dist-info/METADATA +78 -0
- git_getpkg-0.1.0.dist-info/RECORD +17 -0
- git_getpkg-0.1.0.dist-info/WHEEL +5 -0
- git_getpkg-0.1.0.dist-info/entry_points.txt +2 -0
- git_getpkg-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_getpkg-0.1.0.dist-info/top_level.txt +1 -0
git_getpkg/discovery.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
try: # Python 3.11+
|
|
8
|
+
import tomllib
|
|
9
|
+
except ModuleNotFoundError: # Python 3.9-3.10
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from git_getpkg.models import Package
|
|
14
|
+
|
|
15
|
+
IGNORED_DIRECTORIES = {".git", ".venv", "venv", "node_modules", "vendor", "target", "__pycache__"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _package(
|
|
19
|
+
root: Path,
|
|
20
|
+
manifest: Path,
|
|
21
|
+
name: str,
|
|
22
|
+
version: str | None,
|
|
23
|
+
ecosystem: str,
|
|
24
|
+
*,
|
|
25
|
+
installable: bool,
|
|
26
|
+
warning: str | None = None,
|
|
27
|
+
signals: tuple[str, ...] = (),
|
|
28
|
+
) -> Package:
|
|
29
|
+
relative = manifest.parent.relative_to(root)
|
|
30
|
+
return Package(
|
|
31
|
+
name,
|
|
32
|
+
version,
|
|
33
|
+
ecosystem,
|
|
34
|
+
manifest.parent,
|
|
35
|
+
manifest,
|
|
36
|
+
str(relative) if str(relative) != "." else ".",
|
|
37
|
+
installable,
|
|
38
|
+
warning,
|
|
39
|
+
signals,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _python_signals(directory: Path, data: dict) -> tuple[str, ...]:
|
|
44
|
+
signals: list[str] = []
|
|
45
|
+
build_system = data.get("build-system", {})
|
|
46
|
+
backend = build_system.get("build-backend")
|
|
47
|
+
if backend:
|
|
48
|
+
signals.append(f"Build backend: {backend}")
|
|
49
|
+
build_requires = build_system.get("requires", [])
|
|
50
|
+
if build_requires and any("==" not in requirement for requirement in build_requires):
|
|
51
|
+
signals.append("Build requirements are not exact-pinned")
|
|
52
|
+
project = data.get("project", {})
|
|
53
|
+
dependencies = list(project.get("dependencies", []))
|
|
54
|
+
for group in project.get("optional-dependencies", {}).values():
|
|
55
|
+
dependencies.extend(group)
|
|
56
|
+
pinned = sum("==" in dependency for dependency in dependencies if " @ " not in dependency)
|
|
57
|
+
unpinned = sum("==" not in dependency for dependency in dependencies if " @ " not in dependency)
|
|
58
|
+
if dependencies:
|
|
59
|
+
signals.append(f"Dependencies: {pinned} exact-pinned · {unpinned} unpinned")
|
|
60
|
+
for dependency in dependencies:
|
|
61
|
+
if " @ " not in dependency:
|
|
62
|
+
continue
|
|
63
|
+
source = dependency.split(" @ ", 1)[1].strip().lower()
|
|
64
|
+
if source.startswith("git+"):
|
|
65
|
+
signals.append("VCS dependency declared")
|
|
66
|
+
elif source.startswith(("file:", "./", "../", "/")):
|
|
67
|
+
signals.append("Local-path dependency declared")
|
|
68
|
+
else:
|
|
69
|
+
signals.append("Direct URL dependency declared")
|
|
70
|
+
for lockfile in ("uv.lock", "poetry.lock", "Pipfile.lock"):
|
|
71
|
+
if (directory / lockfile).is_file():
|
|
72
|
+
signals.append(f"Lockfile present: {lockfile}")
|
|
73
|
+
return tuple(dict.fromkeys(signals))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _python_package(root: Path, manifest: Path) -> Package | None:
|
|
77
|
+
if manifest.name == "pyproject.toml":
|
|
78
|
+
data = tomllib.loads(manifest.read_text())
|
|
79
|
+
project = data.get("project", {})
|
|
80
|
+
name = project.get("name") or manifest.parent.name
|
|
81
|
+
version = project.get("version")
|
|
82
|
+
backend = data.get("build-system", {}).get("build-backend")
|
|
83
|
+
warning = (
|
|
84
|
+
f"Uses custom build backend: {backend}"
|
|
85
|
+
if backend and backend not in {"setuptools.build_meta", "setuptools.build_meta:__legacy__"}
|
|
86
|
+
else None
|
|
87
|
+
)
|
|
88
|
+
return _package(
|
|
89
|
+
root,
|
|
90
|
+
manifest,
|
|
91
|
+
name,
|
|
92
|
+
version,
|
|
93
|
+
"Python",
|
|
94
|
+
installable=True,
|
|
95
|
+
warning=warning,
|
|
96
|
+
signals=_python_signals(manifest.parent, data),
|
|
97
|
+
)
|
|
98
|
+
text = manifest.read_text(errors="replace")
|
|
99
|
+
name = re.search(r"name\s*=\s*[\"']([^\"']+)", text)
|
|
100
|
+
version = re.search(r"version\s*=\s*[\"']([^\"']+)", text)
|
|
101
|
+
return _package(
|
|
102
|
+
root,
|
|
103
|
+
manifest,
|
|
104
|
+
name.group(1) if name else manifest.parent.name,
|
|
105
|
+
version.group(1) if version else None,
|
|
106
|
+
"Python",
|
|
107
|
+
installable=True,
|
|
108
|
+
warning="setup.py may execute arbitrary build code",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def discover(root: Path) -> list[Package]:
|
|
113
|
+
packages: list[Package] = []
|
|
114
|
+
resolved_root = root.resolve()
|
|
115
|
+
|
|
116
|
+
def is_safe_file(path: Path) -> bool:
|
|
117
|
+
if path.is_symlink() or not path.is_file():
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
path.resolve().relative_to(resolved_root)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return False
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
manifest_names = {"pyproject.toml", "setup.py", "package.json", "Cargo.toml", "go.mod", "Gemfile", "composer.json"}
|
|
126
|
+
manifests: list[Path] = []
|
|
127
|
+
for directory, directories, filenames in os.walk(root, topdown=True, followlinks=False):
|
|
128
|
+
directories[:] = [name for name in directories if name not in IGNORED_DIRECTORIES]
|
|
129
|
+
for filename in filenames:
|
|
130
|
+
if filename in manifest_names:
|
|
131
|
+
candidate = Path(directory, filename)
|
|
132
|
+
if is_safe_file(candidate):
|
|
133
|
+
manifests.append(candidate)
|
|
134
|
+
manifests.sort()
|
|
135
|
+
python_by_directory: dict[Path, Path] = {}
|
|
136
|
+
priority = {"pyproject.toml": 0, "setup.py": 1}
|
|
137
|
+
for manifest in manifests:
|
|
138
|
+
if manifest.name in priority and not any(
|
|
139
|
+
part in IGNORED_DIRECTORIES for part in manifest.relative_to(root).parts
|
|
140
|
+
):
|
|
141
|
+
existing = python_by_directory.get(manifest.parent)
|
|
142
|
+
if existing is None or priority[manifest.name] < priority[existing.name]:
|
|
143
|
+
python_by_directory[manifest.parent] = manifest
|
|
144
|
+
seen_kinds: set[tuple[Path, str]] = set()
|
|
145
|
+
for manifest in manifests:
|
|
146
|
+
if not manifest.is_file() or any(part in IGNORED_DIRECTORIES for part in manifest.relative_to(root).parts):
|
|
147
|
+
continue
|
|
148
|
+
if manifest.name in priority and python_by_directory.get(manifest.parent) != manifest:
|
|
149
|
+
continue
|
|
150
|
+
package: Package | None = None
|
|
151
|
+
try:
|
|
152
|
+
if manifest.name in {"pyproject.toml", "requirements.txt", "setup.py"}:
|
|
153
|
+
package = _python_package(root, manifest)
|
|
154
|
+
elif manifest.name == "package.json":
|
|
155
|
+
data = json.loads(manifest.read_text())
|
|
156
|
+
scripts = data.get("scripts", {})
|
|
157
|
+
warning = (
|
|
158
|
+
"Defines install lifecycle scripts"
|
|
159
|
+
if any(key in scripts for key in ("preinstall", "install", "postinstall"))
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
package = _package(
|
|
163
|
+
root,
|
|
164
|
+
manifest,
|
|
165
|
+
data.get("name", manifest.parent.name),
|
|
166
|
+
data.get("version"),
|
|
167
|
+
"Node.js",
|
|
168
|
+
installable=False,
|
|
169
|
+
warning=warning,
|
|
170
|
+
)
|
|
171
|
+
elif manifest.name == "Cargo.toml":
|
|
172
|
+
data = tomllib.loads(manifest.read_text())
|
|
173
|
+
item = data.get("package", {})
|
|
174
|
+
package = _package(
|
|
175
|
+
root,
|
|
176
|
+
manifest,
|
|
177
|
+
item.get("name", manifest.parent.name),
|
|
178
|
+
item.get("version"),
|
|
179
|
+
"Rust",
|
|
180
|
+
installable=False,
|
|
181
|
+
)
|
|
182
|
+
elif manifest.name == "go.mod":
|
|
183
|
+
first = manifest.read_text().splitlines()[0]
|
|
184
|
+
package = _package(root, manifest, first.removeprefix("module ").strip(), None, "Go", installable=False)
|
|
185
|
+
elif manifest.name == "Gemfile":
|
|
186
|
+
package = _package(root, manifest, manifest.parent.name, None, "Ruby", installable=False)
|
|
187
|
+
elif manifest.name == "composer.json":
|
|
188
|
+
data = json.loads(manifest.read_text())
|
|
189
|
+
package = _package(
|
|
190
|
+
root,
|
|
191
|
+
manifest,
|
|
192
|
+
data.get("name", manifest.parent.name),
|
|
193
|
+
data.get("version"),
|
|
194
|
+
"PHP",
|
|
195
|
+
installable=False,
|
|
196
|
+
)
|
|
197
|
+
except (OSError, ValueError, tomllib.TOMLDecodeError, json.JSONDecodeError) as error:
|
|
198
|
+
package = _package(
|
|
199
|
+
root,
|
|
200
|
+
manifest,
|
|
201
|
+
manifest.parent.name,
|
|
202
|
+
None,
|
|
203
|
+
"Unknown",
|
|
204
|
+
installable=False,
|
|
205
|
+
warning=f"Could not parse manifest: {error}",
|
|
206
|
+
)
|
|
207
|
+
if package:
|
|
208
|
+
key = (manifest.parent, package.ecosystem)
|
|
209
|
+
if key in seen_kinds:
|
|
210
|
+
continue
|
|
211
|
+
packages.append(package)
|
|
212
|
+
seen_kinds.add(key)
|
|
213
|
+
return sorted(packages, key=lambda item: (item.name.lower(), item.relative_path))
|
git_getpkg/github.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Optional GitHub context, obtained through the user's existing gh CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from git_getpkg.command import run
|
|
11
|
+
from git_getpkg.models import SourceInfo
|
|
12
|
+
|
|
13
|
+
SUPPORTED_PRIMARY_LANGUAGES = frozenset({"Python", "JavaScript", "TypeScript", "Rust", "Go", "Ruby", "PHP"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitHubApiError(RuntimeError):
|
|
17
|
+
"""A failed GitHub CLI API request with enough context for safe recovery."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_not_found(self) -> bool:
|
|
21
|
+
return "404" in str(self) or "not found" in str(self).lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Repository:
|
|
26
|
+
owner: str
|
|
27
|
+
name: str
|
|
28
|
+
url: str
|
|
29
|
+
clone_url: str
|
|
30
|
+
language: str | None
|
|
31
|
+
updated_at: str | None
|
|
32
|
+
private: bool
|
|
33
|
+
archived: bool
|
|
34
|
+
fork: bool
|
|
35
|
+
reported_size_kib: int | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_package_candidate(self) -> bool:
|
|
39
|
+
return self.language in SUPPORTED_PRIMARY_LANGUAGES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _owner_repo(source: SourceInfo) -> tuple[str, str] | None:
|
|
43
|
+
url = source.repository_url
|
|
44
|
+
if not url or urlparse(url).hostname != "github.com" or not source.namespace or "/" in source.namespace:
|
|
45
|
+
return None
|
|
46
|
+
return source.namespace, source.repository_name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _api(endpoint: str) -> object | None:
|
|
50
|
+
if not shutil.which("gh"):
|
|
51
|
+
return None
|
|
52
|
+
result = run(["gh", "api", endpoint], check=False, timeout=20)
|
|
53
|
+
if result.returncode:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(result.stdout)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _paged_api(endpoint: str) -> object:
|
|
62
|
+
if not shutil.which("gh"):
|
|
63
|
+
raise GitHubApiError(
|
|
64
|
+
"GitHub owner discovery requires GitHub CLI (`gh`). Install it, run `gh auth login`, then try again."
|
|
65
|
+
)
|
|
66
|
+
result = run(["gh", "api", "--paginate", "--slurp", endpoint], check=False, timeout=60)
|
|
67
|
+
if result.returncode:
|
|
68
|
+
message = result.stderr.strip() or result.stdout.strip() or "GitHub API request failed"
|
|
69
|
+
raise GitHubApiError(f"Could not list GitHub repositories: {message}")
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(result.stdout)
|
|
72
|
+
except json.JSONDecodeError as error:
|
|
73
|
+
raise GitHubApiError("GitHub returned an invalid repository listing.") from error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def repositories(owner: str) -> list[Repository]:
|
|
77
|
+
"""List an org's repositories, falling back to a user account when needed."""
|
|
78
|
+
if not owner or "/" in owner:
|
|
79
|
+
raise ValueError("GitHub owner must be a single organization or user name.")
|
|
80
|
+
try:
|
|
81
|
+
payload = _paged_api(f"orgs/{owner}/repos?type=all&per_page=100")
|
|
82
|
+
except GitHubApiError as organization_error:
|
|
83
|
+
if not organization_error.is_not_found:
|
|
84
|
+
raise
|
|
85
|
+
try:
|
|
86
|
+
payload = _paged_api(f"users/{owner}/repos?type=owner&per_page=100")
|
|
87
|
+
except GitHubApiError:
|
|
88
|
+
raise organization_error
|
|
89
|
+
pages = payload if isinstance(payload, list) else []
|
|
90
|
+
rows = [row for page in pages if isinstance(page, list) for row in page if isinstance(row, dict)]
|
|
91
|
+
return [
|
|
92
|
+
Repository(
|
|
93
|
+
owner=owner,
|
|
94
|
+
name=str(row.get("name", "unknown")),
|
|
95
|
+
url=str(row.get("html_url", f"https://github.com/{owner}/{row.get('name', '')}")),
|
|
96
|
+
clone_url=str(row.get("clone_url", f"https://github.com/{owner}/{row.get('name', '')}.git")),
|
|
97
|
+
language=row.get("language") if isinstance(row.get("language"), str) else None,
|
|
98
|
+
updated_at=row.get("pushed_at") if isinstance(row.get("pushed_at"), str) else None,
|
|
99
|
+
private=bool(row.get("private")),
|
|
100
|
+
archived=bool(row.get("archived")),
|
|
101
|
+
fork=bool(row.get("fork")),
|
|
102
|
+
reported_size_kib=row.get("size") if isinstance(row.get("size"), int) and row["size"] >= 0 else None,
|
|
103
|
+
)
|
|
104
|
+
for row in rows
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def signals(source: SourceInfo) -> list[str]:
|
|
109
|
+
target = _owner_repo(source)
|
|
110
|
+
if not target:
|
|
111
|
+
return []
|
|
112
|
+
owner, repository = target
|
|
113
|
+
repo = _api(f"repos/{owner}/{repository}")
|
|
114
|
+
if not isinstance(repo, dict):
|
|
115
|
+
return ["GitHub context unavailable"]
|
|
116
|
+
values = [f"GitHub: {repo.get('stargazers_count', 0)} stars · {repo.get('forks_count', 0)} forks"]
|
|
117
|
+
if repo.get("archived"):
|
|
118
|
+
values.append("GitHub: repository archived")
|
|
119
|
+
if repo.get("fork"):
|
|
120
|
+
values.append("GitHub: repository is a fork")
|
|
121
|
+
contributors = _api(f"repos/{owner}/{repository}/contributors?per_page=100")
|
|
122
|
+
if isinstance(contributors, list):
|
|
123
|
+
values.append(f"GitHub: {len(contributors)} visible contributors")
|
|
124
|
+
organization = _api(f"orgs/{owner}")
|
|
125
|
+
if isinstance(organization, dict) and organization.get("is_verified"):
|
|
126
|
+
values.append("GitHub: verified organization")
|
|
127
|
+
return values
|
git_getpkg/installer.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from git_getpkg.command import run
|
|
12
|
+
from git_getpkg.models import Package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ShimResult:
|
|
17
|
+
directory: Path
|
|
18
|
+
created: list[str]
|
|
19
|
+
conflicts: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PathResult:
|
|
24
|
+
configured: bool
|
|
25
|
+
profile: Path | None
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def environment_path(package: Package, commit: str | None, source_identity: str) -> Path:
|
|
30
|
+
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
31
|
+
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "-", package.name).strip("-") or "package"
|
|
32
|
+
identity = f"{source_identity}\0{commit or 'working-tree'}\0{package.relative_path}"
|
|
33
|
+
suffix = hashlib.sha256(identity.encode()).hexdigest()[:12]
|
|
34
|
+
return data_home / "git-getpkg" / "environments" / f"{safe_name}-{suffix}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bin_directory() -> Path:
|
|
38
|
+
return Path(os.environ.get("XDG_BIN_HOME", Path.home() / ".local" / "bin"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_bin_on_path(
|
|
42
|
+
*, directory: Path | None = None, shell: str | None = None, home: Path | None = None
|
|
43
|
+
) -> PathResult:
|
|
44
|
+
"""Persist a narrowly scoped PATH entry for future interactive shells."""
|
|
45
|
+
directory = directory or bin_directory()
|
|
46
|
+
if str(directory) in os.environ.get("PATH", "").split(os.pathsep):
|
|
47
|
+
return PathResult(False, None, f"{directory} is already on PATH")
|
|
48
|
+
home = home or Path.home()
|
|
49
|
+
shell_name = Path(shell or os.environ.get("SHELL", "")).name
|
|
50
|
+
if shell_name == "zsh":
|
|
51
|
+
profile = home / ".zshrc"
|
|
52
|
+
entry = f'export PATH={shlex.quote(str(directory))}:"$PATH"'
|
|
53
|
+
elif shell_name == "bash":
|
|
54
|
+
profile = home / (".bash_profile" if (home / ".bash_profile").exists() else ".bashrc")
|
|
55
|
+
entry = f'export PATH={shlex.quote(str(directory))}:"$PATH"'
|
|
56
|
+
elif shell_name == "fish":
|
|
57
|
+
config_home = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
|
|
58
|
+
profile = config_home / "fish" / "config.fish"
|
|
59
|
+
entry = f"set -gx PATH {shlex.quote(str(directory))} $PATH"
|
|
60
|
+
else:
|
|
61
|
+
return PathResult(False, None, f"Could not identify a supported shell; add {directory} to PATH")
|
|
62
|
+
start = "# >>> git-getpkg PATH >>>"
|
|
63
|
+
end = "# <<< git-getpkg PATH <<<"
|
|
64
|
+
existing = profile.read_text() if profile.exists() else ""
|
|
65
|
+
if start not in existing:
|
|
66
|
+
profile.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
suffix = "" if not existing or existing.endswith("\n") else "\n"
|
|
68
|
+
profile.write_text(f"{existing}{suffix}{start}\n{entry}\n{end}\n")
|
|
69
|
+
return PathResult(True, profile, f"Added {directory} to PATH in {profile}")
|
|
70
|
+
return PathResult(False, profile, f"{directory} is already managed in {profile}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _console_scripts(package: Package, environment: Path) -> list[str]:
|
|
74
|
+
python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
75
|
+
query = """
|
|
76
|
+
import json
|
|
77
|
+
import re
|
|
78
|
+
import sys
|
|
79
|
+
from importlib.metadata import distributions
|
|
80
|
+
|
|
81
|
+
def normalized(value):
|
|
82
|
+
return re.sub(r"[-_.]+", "-", value).lower()
|
|
83
|
+
|
|
84
|
+
target = normalized(sys.argv[1])
|
|
85
|
+
for distribution in distributions():
|
|
86
|
+
name = distribution.metadata.get("Name", "")
|
|
87
|
+
if normalized(name) == target:
|
|
88
|
+
print(json.dumps(sorted(entry.name for entry in distribution.entry_points if entry.group == "console_scripts")))
|
|
89
|
+
break
|
|
90
|
+
else:
|
|
91
|
+
print("[]")
|
|
92
|
+
"""
|
|
93
|
+
result = run([str(python), "-c", query, package.name])
|
|
94
|
+
import json
|
|
95
|
+
|
|
96
|
+
return json.loads(result.stdout)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def create_command_shims(
|
|
100
|
+
package: Package,
|
|
101
|
+
environment: Path,
|
|
102
|
+
*,
|
|
103
|
+
directory: Path | None = None,
|
|
104
|
+
scripts: list[str] | None = None,
|
|
105
|
+
) -> ShimResult:
|
|
106
|
+
"""Expose only this package's console scripts without replacing user-owned commands."""
|
|
107
|
+
directory = directory or bin_directory()
|
|
108
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
scripts = scripts if scripts is not None else _console_scripts(package, environment)
|
|
110
|
+
source_directory = environment / ("Scripts" if os.name == "nt" else "bin")
|
|
111
|
+
created: list[str] = []
|
|
112
|
+
conflicts: list[str] = []
|
|
113
|
+
for name in scripts:
|
|
114
|
+
source = source_directory / (f"{name}.exe" if os.name == "nt" else name)
|
|
115
|
+
destination = directory / (f"{name}.cmd" if os.name == "nt" else name)
|
|
116
|
+
if not source.exists():
|
|
117
|
+
continue
|
|
118
|
+
if destination.exists() and "Managed by git-getpkg" not in destination.read_text(errors="ignore"):
|
|
119
|
+
conflicts.append(name)
|
|
120
|
+
continue
|
|
121
|
+
if os.name == "nt":
|
|
122
|
+
destination.write_text(f'@rem Managed by git-getpkg\r\n@"{source}" %*\r\n')
|
|
123
|
+
else:
|
|
124
|
+
destination.write_text(f'#!/bin/sh\n# Managed by git-getpkg\nexec {shlex.quote(str(source))} "$@"\n')
|
|
125
|
+
destination.chmod(0o755)
|
|
126
|
+
created.append(name)
|
|
127
|
+
return ShimResult(directory, created, conflicts)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def install_python(
|
|
131
|
+
package: Package, commit: str | None, source_identity: str, *, dry_run: bool
|
|
132
|
+
) -> tuple[Path, list[list[str]]]:
|
|
133
|
+
target = environment_path(package, commit, source_identity)
|
|
134
|
+
python = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
135
|
+
commands = [
|
|
136
|
+
[sys.executable, "-m", "venv", str(target)],
|
|
137
|
+
[str(python), "-m", "pip", "install", str(package.directory)],
|
|
138
|
+
]
|
|
139
|
+
if not dry_run:
|
|
140
|
+
if not python.exists():
|
|
141
|
+
run(commands[0])
|
|
142
|
+
run(commands[1])
|
|
143
|
+
return target, commands
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_python(environment: Path) -> str | None:
|
|
147
|
+
"""Return a dependency-consistency error after installation, if any."""
|
|
148
|
+
python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
149
|
+
result = run([str(python), "-m", "pip", "check"], check=False)
|
|
150
|
+
return None if result.returncode == 0 else (result.stdout.strip() or result.stderr.strip() or "pip check failed")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def high_risk_pip_signals() -> list[str]:
|
|
154
|
+
"""Report only dependency-source settings with material supply-chain risk."""
|
|
155
|
+
signals: list[str] = []
|
|
156
|
+
if os.environ.get("PIP_EXTRA_INDEX_URL"):
|
|
157
|
+
signals.append("High risk: PIP_EXTRA_INDEX_URL is active (dependency-confusion exposure)")
|
|
158
|
+
if os.environ.get("PIP_TRUSTED_HOST"):
|
|
159
|
+
signals.append("High risk: PIP_TRUSTED_HOST is active")
|
|
160
|
+
if os.environ.get("PIP_INDEX_URL", "").startswith("http://"):
|
|
161
|
+
signals.append("High risk: PIP_INDEX_URL uses insecure HTTP")
|
|
162
|
+
return signals
|
git_getpkg/models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SourceInfo:
|
|
9
|
+
original: str
|
|
10
|
+
root: Path
|
|
11
|
+
is_remote: bool
|
|
12
|
+
commit: str | None
|
|
13
|
+
default_branch: str | None
|
|
14
|
+
repository_url: str | None
|
|
15
|
+
repository_name: str
|
|
16
|
+
namespace: str | None
|
|
17
|
+
namespace_url: str | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Package:
|
|
22
|
+
name: str
|
|
23
|
+
version: str | None
|
|
24
|
+
ecosystem: str
|
|
25
|
+
directory: Path
|
|
26
|
+
manifest: Path
|
|
27
|
+
relative_path: str
|
|
28
|
+
installable: bool
|
|
29
|
+
install_warning: str | None = None
|
|
30
|
+
metadata_signals: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PackageReport:
|
|
35
|
+
package: Package
|
|
36
|
+
last_touched_by: str | None
|
|
37
|
+
last_touched_at: str | None
|
|
38
|
+
signals: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def as_dict(self, source: SourceInfo) -> dict:
|
|
41
|
+
value = asdict(self.package)
|
|
42
|
+
value["directory"] = str(self.package.directory)
|
|
43
|
+
value["manifest"] = str(self.package.manifest)
|
|
44
|
+
value["signals"] = self.signals
|
|
45
|
+
value["last_touched_by"] = self.last_touched_by
|
|
46
|
+
value["last_touched_at"] = self.last_touched_at
|
|
47
|
+
value["source"] = {
|
|
48
|
+
"url": source.repository_url,
|
|
49
|
+
"commit": source.commit,
|
|
50
|
+
"branch": source.default_branch,
|
|
51
|
+
"repository": source.repository_name,
|
|
52
|
+
"namespace": source.namespace,
|
|
53
|
+
"namespace_url": source.namespace_url,
|
|
54
|
+
}
|
|
55
|
+
return value
|
git_getpkg/security.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Non-executing security analysis for discovered Python packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from git_getpkg.command import CommandError, run
|
|
10
|
+
|
|
11
|
+
SECURITY_SCAN_EXCLUDES = ".git,.venv,venv,node_modules,tests,test,docs,examples,benchmarks,benchmark"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _bandit(root: Path) -> str:
|
|
15
|
+
"""Return a compact Bandit summary; findings are signals, never a safety verdict."""
|
|
16
|
+
scan_root = root / "src" if (root / "src").is_dir() else root
|
|
17
|
+
try:
|
|
18
|
+
result = run(
|
|
19
|
+
[
|
|
20
|
+
sys.executable,
|
|
21
|
+
"-m",
|
|
22
|
+
"bandit",
|
|
23
|
+
"-r",
|
|
24
|
+
str(scan_root),
|
|
25
|
+
"-x",
|
|
26
|
+
SECURITY_SCAN_EXCLUDES,
|
|
27
|
+
"-f",
|
|
28
|
+
"json",
|
|
29
|
+
"--exit-zero",
|
|
30
|
+
"-q",
|
|
31
|
+
],
|
|
32
|
+
check=False,
|
|
33
|
+
timeout=30,
|
|
34
|
+
)
|
|
35
|
+
except CommandError as error:
|
|
36
|
+
return f"Live security scan: unavailable ({error})"
|
|
37
|
+
if result.returncode:
|
|
38
|
+
detail = result.stderr.strip() or result.stdout.strip() or "Bandit failed"
|
|
39
|
+
return f"Live security scan: unavailable ({detail.splitlines()[0]})"
|
|
40
|
+
try:
|
|
41
|
+
findings = json.loads(result.stdout).get("results", [])
|
|
42
|
+
except (json.JSONDecodeError, AttributeError):
|
|
43
|
+
return "Live security scan: unavailable (Bandit returned invalid JSON)"
|
|
44
|
+
counts = {
|
|
45
|
+
severity: sum(item.get("issue_severity") == severity for item in findings) for severity in ("HIGH", "MEDIUM")
|
|
46
|
+
}
|
|
47
|
+
return f"Live security scan: {counts['HIGH']} high · {counts['MEDIUM']} medium"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def scan_python(root: Path) -> str:
|
|
51
|
+
"""Run the built-in non-executing Python security scanner."""
|
|
52
|
+
return _bandit(root)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def findings(root: Path) -> list[tuple[str, str, str, int, str]]:
|
|
56
|
+
scan_root = root / "src" if (root / "src").is_dir() else root
|
|
57
|
+
try:
|
|
58
|
+
result = run(
|
|
59
|
+
[
|
|
60
|
+
sys.executable,
|
|
61
|
+
"-m",
|
|
62
|
+
"bandit",
|
|
63
|
+
"-r",
|
|
64
|
+
str(scan_root),
|
|
65
|
+
"-x",
|
|
66
|
+
SECURITY_SCAN_EXCLUDES,
|
|
67
|
+
"-f",
|
|
68
|
+
"json",
|
|
69
|
+
"--exit-zero",
|
|
70
|
+
"-q",
|
|
71
|
+
],
|
|
72
|
+
check=False,
|
|
73
|
+
timeout=30,
|
|
74
|
+
)
|
|
75
|
+
data = json.loads(result.stdout)
|
|
76
|
+
except (CommandError, json.JSONDecodeError, OSError):
|
|
77
|
+
return []
|
|
78
|
+
return [
|
|
79
|
+
(
|
|
80
|
+
item.get("issue_severity", "UNKNOWN"),
|
|
81
|
+
item.get("test_id", "—"),
|
|
82
|
+
item.get("filename", "—"),
|
|
83
|
+
item.get("line_number", 0),
|
|
84
|
+
item.get("issue_text", ""),
|
|
85
|
+
)
|
|
86
|
+
for item in data.get("results", [])
|
|
87
|
+
if item.get("issue_severity") in {"HIGH", "MEDIUM"}
|
|
88
|
+
]
|