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/source.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from git_getpkg.command import CommandError, run
12
+ from git_getpkg.models import SourceInfo
13
+
14
+ MAX_REMOTE_CHECKOUT_BYTES = 512 * 1024 * 1024
15
+
16
+
17
+ def _remote_parts(value: str) -> tuple[str | None, str | None, str | None, str | None]:
18
+ """Return repository name, namespace, repository URL, and namespace URL when derivable."""
19
+ normalized = value
20
+ if re.match(r"^[^/@:]+@[^:]+:.+", value):
21
+ user_host, path = value.split(":", 1)
22
+ host = user_host.split("@", 1)[1]
23
+ normalized = f"https://{host}/{path}"
24
+ parsed = urlparse(normalized)
25
+ path = parsed.path.strip("/")
26
+ parts = path.removesuffix(".git").split("/") if path else []
27
+ if not parts:
28
+ return None, None, value, None
29
+ repo = parts[-1]
30
+ namespace = "/".join(parts[:-1]) or None
31
+ base = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else None
32
+ repo_url = f"{base}/{'/'.join(parts)}" if base else value
33
+ namespace_url = f"{base}/{namespace}" if base and namespace else None
34
+ return repo, namespace, repo_url, namespace_url
35
+
36
+
37
+ def _git_metadata(root: Path) -> tuple[str | None, str | None]:
38
+ commit = run(["git", "-C", str(root), "rev-parse", "HEAD"], check=False)
39
+ branch = run(["git", "-C", str(root), "branch", "--show-current"], check=False)
40
+ return (
41
+ commit.stdout.strip() if commit.returncode == 0 else None,
42
+ branch.stdout.strip() if branch.returncode == 0 else None,
43
+ )
44
+
45
+
46
+ def _default_branch(url: str) -> str:
47
+ result = run(["git", "-c", "protocol.ext.allow=never", "ls-remote", "--symref", url, "HEAD"], timeout=45)
48
+ for line in result.stdout.splitlines():
49
+ if line.startswith("ref:") and line.endswith("\tHEAD"):
50
+ ref = line.split()[1]
51
+ return ref.removeprefix("refs/heads/")
52
+ raise CommandError("Could not determine the remote repository's default branch.")
53
+
54
+
55
+ def _validate_remote_url(value: str) -> None:
56
+ if re.match(r"^[^/@:\\s]+@[^:\\s]+:.+", value):
57
+ return # Standard Git SSH shorthand: git@example.com:team/repository.git
58
+ parsed = urlparse(value)
59
+ if parsed.scheme not in {"https", "ssh", "file"}:
60
+ raise ValueError("Remote source must use https://, ssh://, file://, or standard SSH Git syntax.")
61
+
62
+
63
+ def _is_github_remote(value: str) -> bool:
64
+ if re.match(r"^[^/@:\s]+@github\.com:[^\s]+", value):
65
+ return True
66
+ return urlparse(value).hostname == "github.com"
67
+
68
+
69
+ def _github_access_guidance(value: str, error: CommandError) -> CommandError:
70
+ """Add an actionable, opt-in recovery path for private GitHub repositories."""
71
+ authentication_markers = (
72
+ "authentication",
73
+ "authorization",
74
+ "permission denied",
75
+ "repository not found",
76
+ "could not read username",
77
+ "http 401",
78
+ "http 403",
79
+ "sso",
80
+ )
81
+ if not _is_github_remote(value) or not any(marker in str(error).lower() for marker in authentication_markers):
82
+ return error
83
+ if not shutil.which("gh"):
84
+ message = (
85
+ f"{error}\n\n"
86
+ "GitHub authentication may be required to access this repository. "
87
+ "GitHub CLI (`gh`) was not found. Install it, run `gh auth login`, then try again."
88
+ )
89
+ else:
90
+ message = (
91
+ f"{error}\n\n"
92
+ "GitHub authentication may be required to access this repository. "
93
+ "Run `gh auth login` and then `gh auth setup-git`, then try again."
94
+ )
95
+ return CommandError(message)
96
+
97
+
98
+ def _directory_size(root: Path) -> int:
99
+ size = 0
100
+ for directory, _, filenames in os.walk(root, followlinks=False):
101
+ for filename in filenames:
102
+ path = Path(directory, filename)
103
+ try:
104
+ size += path.lstat().st_size
105
+ except OSError:
106
+ continue
107
+ return size
108
+
109
+
110
+ @contextlib.contextmanager
111
+ def open_source(value: str, *, clone_timeout: float = 300):
112
+ path = Path(value).expanduser()
113
+ if path.exists():
114
+ root = path.resolve()
115
+ if not root.is_dir():
116
+ raise ValueError(f"Local source is not a directory: {root}")
117
+ commit, branch = _git_metadata(root)
118
+ yield SourceInfo(value, root, False, commit, branch, None, root.name, None, None)
119
+ return
120
+
121
+ temp_root = Path(tempfile.mkdtemp(prefix="git-getpkg-"))
122
+ try:
123
+ _validate_remote_url(value)
124
+ try:
125
+ branch = _default_branch(value)
126
+ except CommandError as error:
127
+ raise _github_access_guidance(value, error) from error
128
+ checkout = temp_root / "source"
129
+ try:
130
+ run(
131
+ [
132
+ "git",
133
+ "-c",
134
+ "protocol.ext.allow=never",
135
+ "clone",
136
+ "--depth",
137
+ "1",
138
+ "--single-branch",
139
+ "--branch",
140
+ branch,
141
+ value,
142
+ str(checkout),
143
+ ],
144
+ timeout=clone_timeout,
145
+ )
146
+ except CommandError as error:
147
+ raise _github_access_guidance(value, error) from error
148
+ size = _directory_size(checkout)
149
+ if size > MAX_REMOTE_CHECKOUT_BYTES:
150
+ remote_mib = size / 1024 / 1024
151
+ max_mib = MAX_REMOTE_CHECKOUT_BYTES / 1024 / 1024
152
+ raise ValueError(
153
+ f"Remote checkout is {remote_mib:.0f} MiB, above the {max_mib:.0f} MiB safety limit."
154
+ )
155
+ commit, _ = _git_metadata(checkout)
156
+ repo, namespace, repo_url, namespace_url = _remote_parts(value)
157
+ yield SourceInfo(
158
+ value, checkout, True, commit, branch, repo_url, repo or checkout.name, namespace, namespace_url
159
+ )
160
+ finally:
161
+ shutil.rmtree(temp_root, ignore_errors=True)
git_getpkg/trust.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from git_getpkg.command import run
6
+ from git_getpkg.models import Package, PackageReport, SourceInfo
7
+
8
+
9
+ def _last_touched(source: SourceInfo, package: Package) -> tuple[str | None, str | None, str | None]:
10
+ if not source.commit:
11
+ return None, None, None
12
+ result = run(
13
+ ["git", "-C", str(source.root), "log", "-1", "--format=%cn%x00%cI%x00%an", "--", package.relative_path],
14
+ check=False,
15
+ )
16
+ if result.returncode or not result.stdout.strip():
17
+ return None, None, None
18
+ committer, date, author = result.stdout.rstrip("\n").split("\x00", 2)
19
+ return committer, date, author
20
+
21
+
22
+ def source_signals(source: SourceInfo) -> list[str]:
23
+ findings: list[str] = []
24
+ if source.commit:
25
+ findings.append(f"Pinned to commit {source.commit[:12]}")
26
+ signature = run(["git", "-C", str(source.root), "verify-commit", source.commit], check=False)
27
+ findings.append(
28
+ "Commit signature verified locally"
29
+ if signature.returncode == 0
30
+ else "Commit signature not verified locally"
31
+ )
32
+ else:
33
+ findings.append("No Git commit could be resolved")
34
+ return findings
35
+
36
+
37
+ def assess(
38
+ source: SourceInfo,
39
+ package: Package,
40
+ repository_signals: list[str] | None = None,
41
+ base_signals: list[str] | None = None,
42
+ ) -> PackageReport:
43
+ findings: list[str] = []
44
+ committer, date, author = _last_touched(source, package)
45
+ findings.extend(base_signals if base_signals is not None else source_signals(source))
46
+ if package.install_warning:
47
+ findings.append(package.install_warning)
48
+ findings.extend(package.metadata_signals)
49
+ display_committer = committer
50
+ if committer and committer.casefold() == "github" and author and author != committer:
51
+ display_committer = f"{author} via GitHub"
52
+ findings.append("Committed by GitHub")
53
+ elif author and author != committer:
54
+ findings.append(f"Authored by {author}")
55
+ if date:
56
+ try:
57
+ touched = datetime.fromisoformat(date.replace("Z", "+00:00"))
58
+ if touched < datetime.now(timezone.utc) - timedelta(days=730):
59
+ findings.append("Package has not changed in over two years")
60
+ except ValueError:
61
+ pass
62
+ if not package.installable:
63
+ findings.append("Installation is not supported in v1")
64
+ if (source.root / "SECURITY.md").is_file():
65
+ findings.append("Security policy present")
66
+ if (source.root / ".gitmodules").is_file():
67
+ findings.append("Submodules detected (not initialized)")
68
+ findings.extend(repository_signals or [])
69
+ return PackageReport(package=package, last_touched_by=display_committer, last_touched_at=date, signals=findings)
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-getpkg
3
+ Version: 0.1.0
4
+ Summary: Discover and easily install packages from Git repositories
5
+ Author: mergefriends
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 mergefriends
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Classifier: Development Status :: 3 - Alpha
29
+ Classifier: Environment :: Console
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.10
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Requires-Python: >=3.10
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Requires-Dist: bandit[toml]>=1.8
39
+ Requires-Dist: pip-audit>=2.10
40
+ Requires-Dist: rich<14,>=13.5.2
41
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
42
+ Dynamic: license-file
43
+
44
+ # git-getpkg
45
+
46
+ `git getpkg` discovers packages in a local directory or Git repository,
47
+ then installs selected Python packages into isolated managed environments.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ brew install pipx && pipx ensurepath && pipx install git+https://github.com/mergefriends/git-getpkg.git
53
+ ```
54
+
55
+ ## Usage
56
+ Find all packages in a repo
57
+ ```bash
58
+ git getpkg list https://github.com/psf/black
59
+ ```
60
+ Find all packages in an organization
61
+ ```bash
62
+ git getpkg list https://github.com/psf
63
+ ```
64
+ Install all packages in a repo
65
+ ```bash
66
+ git getpkg https://github.com/psf/black
67
+ ```
68
+ Install a single package from a repo
69
+ ```bash
70
+ git getpkg https://github.com/psf/black black
71
+ ```
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ python3 -m unittest discover -s tests -v
77
+ PYTHONPATH=src python3 -m git_getpkg list .
78
+ ```
@@ -0,0 +1,17 @@
1
+ git_getpkg/__init__.py,sha256=ugvHV1fz-7Qrp2UA8RYzzYOMhTz57RLs58vtv2VdBUE,53
2
+ git_getpkg/__main__.py,sha256=y0M2kGtoQvnELnQhRP2POE_CzWedD-Bdhltsi-qQ92g,58
3
+ git_getpkg/cli.py,sha256=qqeH0tX2l8Y-skvX1yjnNM2vVBbdlMstNuDAcOPDweE,21171
4
+ git_getpkg/command.py,sha256=vFTVJeUaJHNhQwKf5lzYM9sCrNAnSTrbO6sCbx623fk,745
5
+ git_getpkg/discovery.py,sha256=A0hQbbkIwl5irJPPO7N4URzhN-r5gr_08v24mphWToU,8272
6
+ git_getpkg/github.py,sha256=CVY6BUDwf-ZbHPrwmekBgBHnZci7XMMeBRO5lr0F_OU,4778
7
+ git_getpkg/installer.py,sha256=eQVIM94NPYE152hVR7KxW74r5DNxT6kufdi7Oyj0JNA,6408
8
+ git_getpkg/models.py,sha256=pWs5TA9gbV_b8phjI9WeN-CkESU_xg_Qm3EE5SByabU,1489
9
+ git_getpkg/security.py,sha256=I8TdAX5fZPoxzMsWVk8Z9_JgL10-ZzTPIgKPPBwPqw8,2768
10
+ git_getpkg/source.py,sha256=oEg6FzjIwzlTfsU7U4PVKQ41P9C9e4r-PjNUqT9UXBI,5954
11
+ git_getpkg/trust.py,sha256=ADmP5lFCJ0xLBYrEmLWs-wIrkM23L-iV6Jf8xsGOwAc,2805
12
+ git_getpkg-0.1.0.dist-info/licenses/LICENSE,sha256=1HxqertAHH8VOGBgy9w8W1e7pXD7X6JctF9-gPVF5B4,1069
13
+ git_getpkg-0.1.0.dist-info/METADATA,sha256=vUzz-NUxM84hA-eSn3tgnx66LGO_7Jckyp4ZKcA6790,2742
14
+ git_getpkg-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ git_getpkg-0.1.0.dist-info/entry_points.txt,sha256=kEA6sj9v6wWo19hNYUT91R95cqPp6hweB7JeCCF0EfE,51
16
+ git_getpkg-0.1.0.dist-info/top_level.txt,sha256=3dRJiig2HahCLTV9QopqFSQ0sRWpsrbYFMbMV95bhdw,11
17
+ git_getpkg-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-getpkg = git_getpkg.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mergefriends
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ git_getpkg