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.
@@ -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
@@ -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
+ ]