updater-gitplucker 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.
- gitplucker/__init__.py +66 -0
- gitplucker/backend/__init__.py +3 -0
- gitplucker/backend/github_api.py +133 -0
- gitplucker/config.py +105 -0
- gitplucker/deps/__init__.py +13 -0
- gitplucker/deps/python_deps.py +211 -0
- gitplucker/errors.py +43 -0
- gitplucker/events.py +51 -0
- gitplucker/fsutil.py +92 -0
- gitplucker/merge/__init__.py +3 -0
- gitplucker/merge/three_way.py +102 -0
- gitplucker/models.py +117 -0
- gitplucker/planner.py +135 -0
- gitplucker/py.typed +0 -0
- gitplucker/sources/__init__.py +5 -0
- gitplucker/sources/base.py +69 -0
- gitplucker/sources/github_release.py +59 -0
- gitplucker/sources/github_source.py +30 -0
- gitplucker/state.py +91 -0
- gitplucker/strategies/__init__.py +12 -0
- gitplucker/strategies/base.py +129 -0
- gitplucker/strategies/package.py +34 -0
- gitplucker/strategies/selective.py +25 -0
- gitplucker/strategies/whole_app.py +17 -0
- gitplucker/triggers/__init__.py +13 -0
- gitplucker/triggers/background.py +64 -0
- gitplucker/triggers/manual.py +34 -0
- gitplucker/triggers/startup.py +37 -0
- gitplucker/updater.py +187 -0
- gitplucker/version.py +80 -0
- updater_gitplucker-0.1.0.dist-info/METADATA +145 -0
- updater_gitplucker-0.1.0.dist-info/RECORD +35 -0
- updater_gitplucker-0.1.0.dist-info/WHEEL +5 -0
- updater_gitplucker-0.1.0.dist-info/licenses/LICENSE +21 -0
- updater_gitplucker-0.1.0.dist-info/top_level.txt +1 -0
gitplucker/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""gitplucker — modular, pluggable GitHub updater for Python-glued projects.
|
|
2
|
+
|
|
3
|
+
Public API (stable):
|
|
4
|
+
|
|
5
|
+
from gitplucker import (
|
|
6
|
+
Updater, UpdaterConfig, RepoSubscription,
|
|
7
|
+
Channel, ApplyStrategy, ConflictPolicy,
|
|
8
|
+
ManualTrigger, StartupTrigger, BackgroundTrigger,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
See ``docs/INTEGRATION.md`` for a step-by-step wiring guide (written so an AI
|
|
12
|
+
assistant can drop this into an app unattended).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .config import ApplyStrategy, ConflictPolicy, RepoSubscription, UpdaterConfig
|
|
18
|
+
from .errors import (
|
|
19
|
+
ApplyError,
|
|
20
|
+
ConfigError,
|
|
21
|
+
GitHubAPIError,
|
|
22
|
+
GitpluckerError,
|
|
23
|
+
MergeConflictError,
|
|
24
|
+
RepoNotAllowedError,
|
|
25
|
+
SourceError,
|
|
26
|
+
)
|
|
27
|
+
from .merge import MergeResult, merge_text
|
|
28
|
+
from .models import (
|
|
29
|
+
ApplyResult,
|
|
30
|
+
Channel,
|
|
31
|
+
ChangeType,
|
|
32
|
+
DependencyChange,
|
|
33
|
+
FileChange,
|
|
34
|
+
UpdatePlan,
|
|
35
|
+
)
|
|
36
|
+
from .triggers import BackgroundTrigger, ManualTrigger, StartupTrigger
|
|
37
|
+
from .updater import Updater
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Updater",
|
|
43
|
+
"UpdaterConfig",
|
|
44
|
+
"RepoSubscription",
|
|
45
|
+
"Channel",
|
|
46
|
+
"ApplyStrategy",
|
|
47
|
+
"ConflictPolicy",
|
|
48
|
+
"ChangeType",
|
|
49
|
+
"UpdatePlan",
|
|
50
|
+
"FileChange",
|
|
51
|
+
"DependencyChange",
|
|
52
|
+
"ApplyResult",
|
|
53
|
+
"ManualTrigger",
|
|
54
|
+
"StartupTrigger",
|
|
55
|
+
"BackgroundTrigger",
|
|
56
|
+
"merge_text",
|
|
57
|
+
"MergeResult",
|
|
58
|
+
"GitpluckerError",
|
|
59
|
+
"ConfigError",
|
|
60
|
+
"RepoNotAllowedError",
|
|
61
|
+
"SourceError",
|
|
62
|
+
"GitHubAPIError",
|
|
63
|
+
"MergeConflictError",
|
|
64
|
+
"ApplyError",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Zero-dependency GitHub REST client.
|
|
2
|
+
|
|
3
|
+
Uses only the stdlib (``urllib``) so the core library installs with no third
|
|
4
|
+
party packages. Every method enforces the repository allowlist: a repo that
|
|
5
|
+
was not explicitly permitted can never be contacted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
from ..errors import GitHubAPIError, RepoNotAllowedError
|
|
18
|
+
|
|
19
|
+
ProgressCb = Callable[[int, int], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ReleaseInfo:
|
|
24
|
+
tag: str
|
|
25
|
+
name: str
|
|
26
|
+
body: str
|
|
27
|
+
prerelease: bool
|
|
28
|
+
assets: list[dict] = field(default_factory=list) # each: {name, browser_download_url, url, size}
|
|
29
|
+
zipball_url: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GitHubClient:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
allowed_repos: set[str] | list[str],
|
|
36
|
+
token: str | None = None,
|
|
37
|
+
api_base: str = "https://api.github.com",
|
|
38
|
+
) -> None:
|
|
39
|
+
self._allowed = set(allowed_repos)
|
|
40
|
+
self._token = token
|
|
41
|
+
self._api = api_base.rstrip("/")
|
|
42
|
+
|
|
43
|
+
# -- allowlist gate ---------------------------------------------------
|
|
44
|
+
def _guard(self, repo: str) -> None:
|
|
45
|
+
if repo not in self._allowed:
|
|
46
|
+
raise RepoNotAllowedError(
|
|
47
|
+
f"repo {repo!r} is not allowlisted; refusing to contact GitHub."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _headers(self, accept: str = "application/vnd.github+json") -> dict[str, str]:
|
|
51
|
+
h = {
|
|
52
|
+
"Accept": accept,
|
|
53
|
+
"User-Agent": "gitplucker/0.1",
|
|
54
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
55
|
+
}
|
|
56
|
+
if self._token:
|
|
57
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
58
|
+
return h
|
|
59
|
+
|
|
60
|
+
def _get_json(self, url: str) -> dict | list:
|
|
61
|
+
req = urllib.request.Request(url, headers=self._headers())
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(req) as resp:
|
|
64
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
65
|
+
except urllib.error.HTTPError as e:
|
|
66
|
+
raise GitHubAPIError(f"GET {url} failed: {e.reason}", status=e.code) from e
|
|
67
|
+
except urllib.error.URLError as e:
|
|
68
|
+
raise GitHubAPIError(f"GET {url} failed: {e.reason}") from e
|
|
69
|
+
|
|
70
|
+
# -- API surface ------------------------------------------------------
|
|
71
|
+
def get_latest_release(self, repo: str, include_prerelease: bool = False) -> ReleaseInfo | None:
|
|
72
|
+
self._guard(repo)
|
|
73
|
+
if include_prerelease:
|
|
74
|
+
data = self._get_json(f"{self._api}/repos/{repo}/releases")
|
|
75
|
+
if not data:
|
|
76
|
+
return None
|
|
77
|
+
rel = data[0] # releases come newest-first
|
|
78
|
+
else:
|
|
79
|
+
try:
|
|
80
|
+
rel = self._get_json(f"{self._api}/repos/{repo}/releases/latest")
|
|
81
|
+
except GitHubAPIError as e:
|
|
82
|
+
if e.status == 404:
|
|
83
|
+
return None
|
|
84
|
+
raise
|
|
85
|
+
return ReleaseInfo(
|
|
86
|
+
tag=rel.get("tag_name", ""),
|
|
87
|
+
name=rel.get("name") or rel.get("tag_name", ""),
|
|
88
|
+
body=rel.get("body", "") or "",
|
|
89
|
+
prerelease=bool(rel.get("prerelease")),
|
|
90
|
+
assets=rel.get("assets", []) or [],
|
|
91
|
+
zipball_url=rel.get("zipball_url", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def get_branch_head(self, repo: str, branch: str) -> tuple[str, str]:
|
|
95
|
+
"""Return ``(sha, iso_date)`` of the branch tip."""
|
|
96
|
+
self._guard(repo)
|
|
97
|
+
data = self._get_json(f"{self._api}/repos/{repo}/branches/{branch}")
|
|
98
|
+
commit = data.get("commit", {})
|
|
99
|
+
sha = commit.get("sha", "")
|
|
100
|
+
date = (
|
|
101
|
+
commit.get("commit", {}).get("committer", {}).get("date", "")
|
|
102
|
+
or commit.get("commit", {}).get("author", {}).get("date", "")
|
|
103
|
+
)
|
|
104
|
+
return sha, date
|
|
105
|
+
|
|
106
|
+
def download(self, repo: str, url: str, dest: Path, progress: ProgressCb | None = None) -> Path:
|
|
107
|
+
"""Download a URL (asset or zipball) to ``dest``, enforcing the allowlist."""
|
|
108
|
+
self._guard(repo)
|
|
109
|
+
# Asset API URLs need the octet-stream Accept header to get the binary.
|
|
110
|
+
accept = "application/octet-stream" if "/releases/assets/" in url else "*/*"
|
|
111
|
+
req = urllib.request.Request(url, headers=self._headers(accept))
|
|
112
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(req) as resp, open(dest, "wb") as fh:
|
|
115
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
116
|
+
received = 0
|
|
117
|
+
while True:
|
|
118
|
+
chunk = resp.read(65536)
|
|
119
|
+
if not chunk:
|
|
120
|
+
break
|
|
121
|
+
fh.write(chunk)
|
|
122
|
+
received += len(chunk)
|
|
123
|
+
if progress:
|
|
124
|
+
progress(received, total)
|
|
125
|
+
except urllib.error.HTTPError as e:
|
|
126
|
+
raise GitHubAPIError(f"download {url} failed: {e.reason}", status=e.code) from e
|
|
127
|
+
except urllib.error.URLError as e:
|
|
128
|
+
raise GitHubAPIError(f"download {url} failed: {e.reason}") from e
|
|
129
|
+
return dest
|
|
130
|
+
|
|
131
|
+
def zipball_url(self, repo: str, ref: str) -> str:
|
|
132
|
+
self._guard(repo)
|
|
133
|
+
return f"{self._api}/repos/{repo}/zipball/{ref}"
|
gitplucker/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Configuration objects — the whole behaviour of the updater is data-driven.
|
|
2
|
+
|
|
3
|
+
A host constructs an :class:`UpdaterConfig`, passes it to
|
|
4
|
+
:class:`~gitplucker.updater.Updater`, and never has to subclass anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import ConfigError
|
|
13
|
+
from .models import Channel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConflictPolicy(str):
|
|
17
|
+
MARK = "mark" # write git-style conflict markers, keep going
|
|
18
|
+
LOCAL = "local" # keep the local version of a conflicted file
|
|
19
|
+
REMOTE = "remote" # take the remote version of a conflicted file
|
|
20
|
+
ABORT = "abort" # raise MergeConflictError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApplyStrategy(str):
|
|
24
|
+
WHOLE_APP = "whole_app" # replace the tracked tree, backup + rollback
|
|
25
|
+
SELECTIVE = "selective" # only paths matching include_globs
|
|
26
|
+
PACKAGE = "package" # pip-install the downloaded wheel/sdist
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RepoSubscription:
|
|
31
|
+
"""One repo the app follows, on one or more branches, via one channel."""
|
|
32
|
+
|
|
33
|
+
repo: str # "owner/name"
|
|
34
|
+
branches: list[str] = field(default_factory=lambda: ["main"])
|
|
35
|
+
channel: Channel = Channel.PYTHON_SOURCE
|
|
36
|
+
# RELEASE channel: glob picking which asset to download (falls back to zipball).
|
|
37
|
+
asset_pattern: str = "*.zip"
|
|
38
|
+
# Only these paths (relative, glob) are considered part of the app payload.
|
|
39
|
+
include_globs: list[str] = field(default_factory=lambda: ["**/*"])
|
|
40
|
+
exclude_globs: list[str] = field(
|
|
41
|
+
default_factory=lambda: [
|
|
42
|
+
"**/.git/**", "**/__pycache__/**", "**/*.pyc",
|
|
43
|
+
"**/.gitplucker/**", "**/.venv/**", "**/node_modules/**",
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
# Subfolder inside the repo that maps to install_root ("" == repo root).
|
|
47
|
+
source_subdir: str = ""
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if isinstance(self.channel, str):
|
|
51
|
+
self.channel = Channel(self.channel)
|
|
52
|
+
if self.repo.count("/") != 1:
|
|
53
|
+
raise ConfigError(f"repo must be 'owner/name', got {self.repo!r}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class UpdaterConfig:
|
|
58
|
+
"""Top-level configuration.
|
|
59
|
+
|
|
60
|
+
``allowed_repos`` is the security allowlist: any subscription (or ad-hoc
|
|
61
|
+
request) whose repo is not listed here is rejected before any network call.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
install_root: Path
|
|
65
|
+
subscriptions: list[RepoSubscription] = field(default_factory=list)
|
|
66
|
+
allowed_repos: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
token: str | None = None # GitHub PAT for private repos / rate limits
|
|
69
|
+
api_base: str = "https://api.github.com"
|
|
70
|
+
|
|
71
|
+
apply_strategy: str = ApplyStrategy.WHOLE_APP
|
|
72
|
+
selective_globs: list[str] = field(default_factory=list) # used by SELECTIVE
|
|
73
|
+
|
|
74
|
+
merge: bool = True # 3-way merge on python-source
|
|
75
|
+
conflict_policy: str = ConflictPolicy.MARK
|
|
76
|
+
auto_install_deps: bool = True
|
|
77
|
+
requirements_file: str | None = "requirements.txt" # relative to install_root
|
|
78
|
+
backup: bool = True
|
|
79
|
+
|
|
80
|
+
# Where gitplucker keeps its state (base snapshots, version manifest, backups).
|
|
81
|
+
state_dir: Path | None = None
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
self.install_root = Path(self.install_root)
|
|
85
|
+
if self.state_dir is None:
|
|
86
|
+
self.state_dir = self.install_root / ".gitplucker"
|
|
87
|
+
self.state_dir = Path(self.state_dir)
|
|
88
|
+
|
|
89
|
+
allowed = set(self.allowed_repos)
|
|
90
|
+
# A subscription implicitly requires its repo to be allowlisted.
|
|
91
|
+
for sub in self.subscriptions:
|
|
92
|
+
if sub.repo not in allowed:
|
|
93
|
+
raise ConfigError(
|
|
94
|
+
f"subscription repo {sub.repo!r} is not in allowed_repos "
|
|
95
|
+
f"{sorted(allowed)!r}; add it explicitly to permit updates."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def is_allowed(self, repo: str) -> bool:
|
|
99
|
+
return repo in set(self.allowed_repos)
|
|
100
|
+
|
|
101
|
+
def subscription_for(self, repo: str, branch: str | None = None) -> RepoSubscription | None:
|
|
102
|
+
for sub in self.subscriptions:
|
|
103
|
+
if sub.repo == repo and (branch is None or branch in sub.branches):
|
|
104
|
+
return sub
|
|
105
|
+
return None
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Python dependency auto-detection and installation.
|
|
2
|
+
|
|
3
|
+
Flow for the ``python-source`` channel:
|
|
4
|
+
|
|
5
|
+
1. Parse every ``.py`` file in the incoming payload with :mod:`ast`.
|
|
6
|
+
2. Collect top-level imported module names.
|
|
7
|
+
3. Drop the standard library and the app's own local packages.
|
|
8
|
+
4. Whatever's left that isn't importable / installed is surfaced as a
|
|
9
|
+
:class:`~gitplucker.models.DependencyChange` — shown in the update plan and,
|
|
10
|
+
if ``auto_install_deps`` is on, ``pip install``-ed on apply.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import importlib.util
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..models import DependencyChange
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from importlib import metadata as _im
|
|
25
|
+
except ImportError: # pragma: no cover
|
|
26
|
+
import importlib_metadata as _im # type: ignore
|
|
27
|
+
|
|
28
|
+
# Import name -> pip package name, for the common mismatches.
|
|
29
|
+
PACKAGE_ALIASES: dict[str, str] = {
|
|
30
|
+
"cv2": "opencv-python",
|
|
31
|
+
"PIL": "Pillow",
|
|
32
|
+
"yaml": "PyYAML",
|
|
33
|
+
"bs4": "beautifulsoup4",
|
|
34
|
+
"serial": "pyserial",
|
|
35
|
+
"dotenv": "python-dotenv",
|
|
36
|
+
"sklearn": "scikit-learn",
|
|
37
|
+
"skimage": "scikit-image",
|
|
38
|
+
"google": "google-api-python-client",
|
|
39
|
+
"OpenGL": "PyOpenGL",
|
|
40
|
+
"win32api": "pywin32",
|
|
41
|
+
"win32con": "pywin32",
|
|
42
|
+
"win32gui": "pywin32",
|
|
43
|
+
"wx": "wxPython",
|
|
44
|
+
"Crypto": "pycryptodome",
|
|
45
|
+
"usb": "pyusb",
|
|
46
|
+
"zmq": "pyzmq",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_STDLIB = set(getattr(sys, "stdlib_module_names", set())) | {"__future__"}
|
|
50
|
+
|
|
51
|
+
# Source-layout / directory-convention names that show up as dangling imports
|
|
52
|
+
# (e.g. code meant to run from a parent dir with ``src/`` on sys.path) but must
|
|
53
|
+
# NEVER be auto-installed — real PyPI packages by these names are never intended.
|
|
54
|
+
_NON_PACKAGE_NAMES = {
|
|
55
|
+
"src", "test", "tests", "docs", "doc", "examples", "example",
|
|
56
|
+
"scripts", "build", "dist",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _iter_py_files(root: Path) -> list[Path]:
|
|
61
|
+
skip = {".git", "__pycache__", ".gitplucker", ".venv", "venv", "node_modules"}
|
|
62
|
+
files: list[Path] = []
|
|
63
|
+
for p in root.rglob("*.py"):
|
|
64
|
+
if any(part in skip for part in p.parts):
|
|
65
|
+
continue
|
|
66
|
+
files.append(p)
|
|
67
|
+
return files
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _local_top_levels(root: Path) -> set[str]:
|
|
71
|
+
"""Names that resolve to something *inside* the app, so must never be
|
|
72
|
+
mistaken for a PyPI dependency.
|
|
73
|
+
|
|
74
|
+
Import scanning is inherently noisy (dangling/dev-only imports, code that
|
|
75
|
+
imports a sibling package by name), and the dangerous failure mode is
|
|
76
|
+
auto-``pip install``-ing a garbage package that happens to share the name.
|
|
77
|
+
So this is deliberately permissive: a name is treated as local if anywhere
|
|
78
|
+
in the payload there is a directory or a ``<name>.py`` with that name — not
|
|
79
|
+
only at the repo root.
|
|
80
|
+
"""
|
|
81
|
+
local: set[str] = set()
|
|
82
|
+
skip = {".git", "__pycache__", ".gitplucker", ".venv", "venv", "node_modules"}
|
|
83
|
+
for p in root.rglob("*"):
|
|
84
|
+
if any(part in skip for part in p.relative_to(root).parts):
|
|
85
|
+
continue
|
|
86
|
+
if p.is_dir():
|
|
87
|
+
local.add(p.name) # any dir name (package or namespace dir)
|
|
88
|
+
elif p.suffix == ".py":
|
|
89
|
+
local.add(p.stem) # any module file, at any depth
|
|
90
|
+
return local
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scan_imports(root: Path) -> dict[str, str]:
|
|
94
|
+
"""Return ``{top_level_module: first_file_relpath}`` for all imports found."""
|
|
95
|
+
found: dict[str, str] = {}
|
|
96
|
+
root = Path(root)
|
|
97
|
+
for py in _iter_py_files(root):
|
|
98
|
+
try:
|
|
99
|
+
tree = ast.parse(py.read_text(encoding="utf-8", errors="replace"), filename=str(py))
|
|
100
|
+
except SyntaxError:
|
|
101
|
+
continue
|
|
102
|
+
rel = py.relative_to(root).as_posix()
|
|
103
|
+
for node in ast.walk(tree):
|
|
104
|
+
if isinstance(node, ast.Import):
|
|
105
|
+
for alias in node.names:
|
|
106
|
+
top = alias.name.split(".")[0]
|
|
107
|
+
found.setdefault(top, rel)
|
|
108
|
+
elif isinstance(node, ast.ImportFrom):
|
|
109
|
+
if node.level and node.level > 0:
|
|
110
|
+
continue # relative import -> local, never a dependency
|
|
111
|
+
if node.module:
|
|
112
|
+
top = node.module.split(".")[0]
|
|
113
|
+
found.setdefault(top, rel)
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _installed_packages() -> set[str]:
|
|
118
|
+
names: set[str] = set()
|
|
119
|
+
try:
|
|
120
|
+
for dist in _im.distributions():
|
|
121
|
+
name = (dist.metadata["Name"] or "").lower()
|
|
122
|
+
if name:
|
|
123
|
+
names.add(name)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return names
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_satisfied(module: str, package: str, installed: set[str]) -> bool:
|
|
130
|
+
if package.lower() in installed:
|
|
131
|
+
return True
|
|
132
|
+
try:
|
|
133
|
+
return importlib.util.find_spec(module) is not None
|
|
134
|
+
except (ImportError, ValueError, ModuleNotFoundError):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_requirements(path: Path) -> dict[str, str]:
|
|
139
|
+
"""Map lowercased package name -> full requirement spec from a requirements file."""
|
|
140
|
+
specs: dict[str, str] = {}
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return specs
|
|
143
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
146
|
+
continue
|
|
147
|
+
name = line
|
|
148
|
+
for sep in ("==", ">=", "<=", "~=", ">", "<", "!=", "[", ";", " "):
|
|
149
|
+
idx = line.find(sep)
|
|
150
|
+
if idx > 0:
|
|
151
|
+
name = line[:idx]
|
|
152
|
+
break
|
|
153
|
+
specs[name.strip().lower()] = line
|
|
154
|
+
return specs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_dependencies(
|
|
158
|
+
payload_root: Path,
|
|
159
|
+
requirements_path: Path | None = None,
|
|
160
|
+
known_modules: set[str] | None = None,
|
|
161
|
+
) -> list[DependencyChange]:
|
|
162
|
+
"""Compute the dependencies the incoming payload needs but the env lacks.
|
|
163
|
+
|
|
164
|
+
``known_modules`` is the set seen in the *previous* applied version; anything
|
|
165
|
+
outside it is flagged ``is_new`` (a genuinely newly-added module).
|
|
166
|
+
"""
|
|
167
|
+
payload_root = Path(payload_root)
|
|
168
|
+
imports = scan_imports(payload_root)
|
|
169
|
+
local = _local_top_levels(payload_root)
|
|
170
|
+
installed = _installed_packages()
|
|
171
|
+
req_specs = _parse_requirements(requirements_path) if requirements_path else {}
|
|
172
|
+
known = known_modules or set()
|
|
173
|
+
|
|
174
|
+
changes: list[DependencyChange] = []
|
|
175
|
+
for module, first_file in sorted(imports.items()):
|
|
176
|
+
if module in _STDLIB or module in local or module in _NON_PACKAGE_NAMES:
|
|
177
|
+
continue
|
|
178
|
+
package = PACKAGE_ALIASES.get(module, module)
|
|
179
|
+
if _is_satisfied(module, package, installed):
|
|
180
|
+
continue
|
|
181
|
+
spec = ""
|
|
182
|
+
req = req_specs.get(package.lower())
|
|
183
|
+
if req and any(op in req for op in ("==", ">=", "<=", "~=", ">", "<", "!=")):
|
|
184
|
+
# Preserve pinned version from requirements.txt.
|
|
185
|
+
spec = req[len(package):].split(";")[0].strip()
|
|
186
|
+
changes.append(
|
|
187
|
+
DependencyChange(
|
|
188
|
+
module=module,
|
|
189
|
+
package=package,
|
|
190
|
+
spec=spec,
|
|
191
|
+
is_new=module not in known,
|
|
192
|
+
reason=f"imported in {first_file}",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return changes
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def install_requirements(
|
|
199
|
+
requirements: list[str],
|
|
200
|
+
python: str | None = None,
|
|
201
|
+
extra_pip_args: list[str] | None = None,
|
|
202
|
+
) -> tuple[bool, str]:
|
|
203
|
+
"""``pip install`` the given requirement strings. Returns ``(ok, output)``."""
|
|
204
|
+
if not requirements:
|
|
205
|
+
return True, "nothing to install"
|
|
206
|
+
cmd = [python or sys.executable, "-m", "pip", "install", *(extra_pip_args or []), *requirements]
|
|
207
|
+
try:
|
|
208
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
209
|
+
except Exception as e: # pragma: no cover
|
|
210
|
+
return False, f"failed to launch pip: {e}"
|
|
211
|
+
return proc.returncode == 0, (proc.stdout + proc.stderr)
|
gitplucker/errors.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Exception hierarchy for gitplucker.
|
|
2
|
+
|
|
3
|
+
Every error raised by the library derives from :class:`GitpluckerError`, so a
|
|
4
|
+
host application can catch the whole surface with a single ``except``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitpluckerError(Exception):
|
|
11
|
+
"""Base class for all gitplucker errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(GitpluckerError):
|
|
15
|
+
"""The :class:`~gitplucker.config.UpdaterConfig` is invalid or inconsistent."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RepoNotAllowedError(GitpluckerError):
|
|
19
|
+
"""A repository was requested that is not in ``allowed_repos``.
|
|
20
|
+
|
|
21
|
+
This is a hard security boundary: gitplucker will never fetch, download, or
|
|
22
|
+
apply anything from a repo the host did not explicitly permit.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SourceError(GitpluckerError):
|
|
27
|
+
"""An update source failed to produce a usable payload."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GitHubAPIError(SourceError):
|
|
31
|
+
"""The GitHub REST API returned an error or unexpected response."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MergeConflictError(GitpluckerError):
|
|
39
|
+
"""A merge produced conflicts and the policy was to abort."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApplyError(GitpluckerError):
|
|
43
|
+
"""Applying an update failed; a rollback may have been attempted."""
|
gitplucker/events.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Minimal synchronous event bus so hosts can hook into progress/prompts.
|
|
2
|
+
|
|
3
|
+
The library never blocks on user input directly; instead it emits events and
|
|
4
|
+
(optionally) asks a decision callback. This keeps it usable from a Qt GUI, a
|
|
5
|
+
CLI, or a headless service alike.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Well-known event names (documented for integrators). Emitting arbitrary names
|
|
15
|
+
# is fine too; these are just the ones the built-in flow raises.
|
|
16
|
+
CHECK_START = "check.start"
|
|
17
|
+
CHECK_DONE = "check.done"
|
|
18
|
+
DOWNLOAD_PROGRESS = "download.progress" # payload: {repo, received, total}
|
|
19
|
+
MERGE_FILE = "merge.file" # payload: {path, conflict}
|
|
20
|
+
CONFLICT = "conflict" # payload: {path, lines}
|
|
21
|
+
DEP_DETECTED = "dep.detected" # payload: {requirement}
|
|
22
|
+
DEP_INSTALL = "dep.install" # payload: {requirement, ok}
|
|
23
|
+
APPLY_FILE = "apply.file" # payload: {path, change}
|
|
24
|
+
BACKUP = "backup" # payload: {path}
|
|
25
|
+
ROLLBACK = "rollback" # payload: {path}
|
|
26
|
+
APPLY_DONE = "apply.done" # payload: {success}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Listener = Callable[[str, dict], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class EventEmitter:
|
|
34
|
+
_listeners: list[Listener] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def on(self, listener: Listener) -> Listener:
|
|
37
|
+
"""Register a ``listener(event_name, payload)``. Returns it for easy removal."""
|
|
38
|
+
self._listeners.append(listener)
|
|
39
|
+
return listener
|
|
40
|
+
|
|
41
|
+
def off(self, listener: Listener) -> None:
|
|
42
|
+
if listener in self._listeners:
|
|
43
|
+
self._listeners.remove(listener)
|
|
44
|
+
|
|
45
|
+
def emit(self, event: str, **payload: Any) -> None:
|
|
46
|
+
for listener in list(self._listeners):
|
|
47
|
+
try:
|
|
48
|
+
listener(event, payload)
|
|
49
|
+
except Exception:
|
|
50
|
+
# A misbehaving listener must never break an update.
|
|
51
|
+
pass
|