updater-gitplucker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. updater_gitplucker-0.1.0/LICENSE +21 -0
  2. updater_gitplucker-0.1.0/PKG-INFO +145 -0
  3. updater_gitplucker-0.1.0/README.md +118 -0
  4. updater_gitplucker-0.1.0/pyproject.toml +40 -0
  5. updater_gitplucker-0.1.0/setup.cfg +4 -0
  6. updater_gitplucker-0.1.0/src/gitplucker/__init__.py +66 -0
  7. updater_gitplucker-0.1.0/src/gitplucker/backend/__init__.py +3 -0
  8. updater_gitplucker-0.1.0/src/gitplucker/backend/github_api.py +133 -0
  9. updater_gitplucker-0.1.0/src/gitplucker/config.py +105 -0
  10. updater_gitplucker-0.1.0/src/gitplucker/deps/__init__.py +13 -0
  11. updater_gitplucker-0.1.0/src/gitplucker/deps/python_deps.py +211 -0
  12. updater_gitplucker-0.1.0/src/gitplucker/errors.py +43 -0
  13. updater_gitplucker-0.1.0/src/gitplucker/events.py +51 -0
  14. updater_gitplucker-0.1.0/src/gitplucker/fsutil.py +92 -0
  15. updater_gitplucker-0.1.0/src/gitplucker/merge/__init__.py +3 -0
  16. updater_gitplucker-0.1.0/src/gitplucker/merge/three_way.py +102 -0
  17. updater_gitplucker-0.1.0/src/gitplucker/models.py +117 -0
  18. updater_gitplucker-0.1.0/src/gitplucker/planner.py +135 -0
  19. updater_gitplucker-0.1.0/src/gitplucker/py.typed +0 -0
  20. updater_gitplucker-0.1.0/src/gitplucker/sources/__init__.py +5 -0
  21. updater_gitplucker-0.1.0/src/gitplucker/sources/base.py +69 -0
  22. updater_gitplucker-0.1.0/src/gitplucker/sources/github_release.py +59 -0
  23. updater_gitplucker-0.1.0/src/gitplucker/sources/github_source.py +30 -0
  24. updater_gitplucker-0.1.0/src/gitplucker/state.py +91 -0
  25. updater_gitplucker-0.1.0/src/gitplucker/strategies/__init__.py +12 -0
  26. updater_gitplucker-0.1.0/src/gitplucker/strategies/base.py +129 -0
  27. updater_gitplucker-0.1.0/src/gitplucker/strategies/package.py +34 -0
  28. updater_gitplucker-0.1.0/src/gitplucker/strategies/selective.py +25 -0
  29. updater_gitplucker-0.1.0/src/gitplucker/strategies/whole_app.py +17 -0
  30. updater_gitplucker-0.1.0/src/gitplucker/triggers/__init__.py +13 -0
  31. updater_gitplucker-0.1.0/src/gitplucker/triggers/background.py +64 -0
  32. updater_gitplucker-0.1.0/src/gitplucker/triggers/manual.py +34 -0
  33. updater_gitplucker-0.1.0/src/gitplucker/triggers/startup.py +37 -0
  34. updater_gitplucker-0.1.0/src/gitplucker/updater.py +187 -0
  35. updater_gitplucker-0.1.0/src/gitplucker/version.py +80 -0
  36. updater_gitplucker-0.1.0/src/updater_gitplucker.egg-info/PKG-INFO +145 -0
  37. updater_gitplucker-0.1.0/src/updater_gitplucker.egg-info/SOURCES.txt +42 -0
  38. updater_gitplucker-0.1.0/src/updater_gitplucker.egg-info/dependency_links.txt +1 -0
  39. updater_gitplucker-0.1.0/src/updater_gitplucker.egg-info/requires.txt +3 -0
  40. updater_gitplucker-0.1.0/src/updater_gitplucker.egg-info/top_level.txt +1 -0
  41. updater_gitplucker-0.1.0/tests/test_config.py +29 -0
  42. updater_gitplucker-0.1.0/tests/test_deps.py +28 -0
  43. updater_gitplucker-0.1.0/tests/test_merge.py +40 -0
  44. updater_gitplucker-0.1.0/tests/test_planner.py +86 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niccc2007 (https://github.com/uukjtisa)
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,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: updater-gitplucker
3
+ Version: 0.1.0
4
+ Summary: Modular, pluggable GitHub updater library for Python-glued projects (release / source / python-source channels with 3-way merge and dependency auto-detection).
5
+ Author: Niccc2007
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/uukjtisa/updater-gitplucker
8
+ Project-URL: Repository, https://github.com/uukjtisa/updater-gitplucker
9
+ Project-URL: Issues, https://github.com/uukjtisa/updater-gitplucker/issues
10
+ Keywords: updater,github,auto-update,self-update,merge,dependencies
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Classifier: Topic :: System :: Software Distribution
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # updater-gitplucker
29
+
30
+ A **modular, pluggable GitHub updater library** for Python-glued projects. Drop
31
+ it into any of your apps to keep them up to date from GitHub — with a safety
32
+ allowlist, a 3-way merge that preserves your local edits, and automatic Python
33
+ dependency detection/installation.
34
+
35
+ - **Import name:** `gitplucker` · **Package:** `updater-gitplucker`
36
+ - **Zero required dependencies** — the core runs on the Python standard library.
37
+ - **Passive by design** — it hands you an inspectable *plan*; you decide when to
38
+ apply. Trigger it manually, at startup, or from a background thread.
39
+
40
+ ```python
41
+ from gitplucker import Updater, UpdaterConfig, RepoSubscription, Channel
42
+ from pathlib import Path
43
+
44
+ config = UpdaterConfig(
45
+ install_root=Path("/path/to/your/app"), # where the app lives on disk
46
+ allowed_repos=["your-org/your-app"], # nothing outside this is ever fetched
47
+ subscriptions=[
48
+ RepoSubscription("your-org/your-app", branches=["main"],
49
+ channel=Channel.PYTHON_SOURCE),
50
+ ],
51
+ token="ghp_...", # optional (private repos / rate limits)
52
+ )
53
+
54
+ updater = Updater(config)
55
+ for plan in updater.check(): # dry run — nothing written yet
56
+ print(plan.summary())
57
+ if plan.has_update and not plan.conflicts:
58
+ updater.apply(plan) # backup → write → install deps
59
+ else:
60
+ updater.discard(plan)
61
+ ```
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install updater-gitplucker
67
+ ```
68
+
69
+ Or the latest from source:
70
+
71
+ ```bash
72
+ pip install "git+https://github.com/uukjtisa/updater-gitplucker.git"
73
+ ```
74
+
75
+ ## Concepts
76
+
77
+ ### Allowlist (security boundary)
78
+ `allowed_repos` is enforced at the network layer: any repo not listed is
79
+ rejected **before** any request. Every subscription's repo must be allowlisted.
80
+
81
+ ### Channels — *how* a repo is updated
82
+ | Channel | Source | Best for |
83
+ |---|---|---|
84
+ | `Channel.RELEASE` | Latest GitHub Release + assets (`.zip`, `.whl`, …) | Versioned/shipped builds |
85
+ | `Channel.SOURCE` | Raw source at a branch tip (zipball) | Repos without formal releases |
86
+ | `Channel.PYTHON_SOURCE` | `SOURCE` **+ 3-way merge + dependency auto-detect** | Python apps held together by scripts (the main use case) |
87
+
88
+ ### Apply strategies — *what* an update replaces (`config.apply_strategy`)
89
+ | Strategy | Effect |
90
+ |---|---|
91
+ | `ApplyStrategy.WHOLE_APP` (default) | Add/modify/merge/delete the tracked tree, with backup + rollback |
92
+ | `ApplyStrategy.SELECTIVE` | Only paths matching `selective_globs`; never deletes |
93
+ | `ApplyStrategy.PACKAGE` | `pip install` the downloaded wheel/sdist (RELEASE channel) |
94
+
95
+ ### Triggers — *when* it runs
96
+ - **Manual** — just call `updater.check()` / `updater.apply()` (or `ManualTrigger`).
97
+ - **Startup** — `StartupTrigger(updater).run(on_update=prompt_fn)`.
98
+ - **Background** — `BackgroundTrigger(updater, interval_seconds=3600).start(...)`.
99
+
100
+ All three are optional wrappers; the library never acts on its own.
101
+
102
+ ### 3-way merge (PYTHON_SOURCE)
103
+ After each apply, gitplucker snapshots the exact files it pulled (the *base*).
104
+ On the next update it merges `base → your local edits` with `base → upstream`.
105
+ Non-overlapping changes merge cleanly; only edits to the **same lines** produce a
106
+ conflict. `config.conflict_policy` decides what happens then: `mark` (git-style
107
+ markers, default), `local`, `remote`, or `abort`.
108
+
109
+ ### Dependency auto-detection (PYTHON_SOURCE)
110
+ Every incoming `.py` is scanned for imports; standard-library and the app's own
111
+ packages are filtered out; anything left that isn't installed is surfaced in
112
+ `plan.dependency_changes` and (if `auto_install_deps=True`) `pip install`-ed on
113
+ apply. Version pins in your `requirements.txt` are honored. Newly-added modules
114
+ are flagged `is_new`.
115
+
116
+ ## Inspecting a plan
117
+
118
+ ```python
119
+ plan.summary() # one-line human summary
120
+ plan.has_update # bool
121
+ plan.file_changes # list[FileChange] (added/modified/merged/conflict/deleted)
122
+ plan.conflicts # list[FileChange] (subset needing attention)
123
+ plan.dependency_changes # list[DependencyChange]
124
+ plan.new_dependencies # subset that are newly referenced
125
+ plan.warnings # e.g. "local changes overwritten (no merge available)"
126
+ plan.release_notes # release body / source stamp
127
+ ```
128
+
129
+ ## Events
130
+
131
+ ```python
132
+ updater.events.on(lambda name, payload: print(name, payload))
133
+ ```
134
+ Emitted: `check.start/done`, `download.progress`, `dep.detected/install`,
135
+ `apply.file`, `backup`, `rollback`, `apply.done`. A throwing listener can never
136
+ break an update.
137
+
138
+ ## State & rollback
139
+ Everything lives in `install_root/.gitplucker/` (override via `state_dir`):
140
+ `manifest.json` (installed version + known modules per repo/branch), `base/`
141
+ (merge snapshots), `backups/` (timestamped pre-apply copies for manual
142
+ rollback). A failed apply auto-rolls-back the current batch.
143
+
144
+ See [`docs/INTEGRATION.md`](docs/INTEGRATION.md) for a step-by-step wiring guide
145
+ and [`examples/basic.py`](examples/basic.py) for a runnable script.
@@ -0,0 +1,118 @@
1
+ # updater-gitplucker
2
+
3
+ A **modular, pluggable GitHub updater library** for Python-glued projects. Drop
4
+ it into any of your apps to keep them up to date from GitHub — with a safety
5
+ allowlist, a 3-way merge that preserves your local edits, and automatic Python
6
+ dependency detection/installation.
7
+
8
+ - **Import name:** `gitplucker` · **Package:** `updater-gitplucker`
9
+ - **Zero required dependencies** — the core runs on the Python standard library.
10
+ - **Passive by design** — it hands you an inspectable *plan*; you decide when to
11
+ apply. Trigger it manually, at startup, or from a background thread.
12
+
13
+ ```python
14
+ from gitplucker import Updater, UpdaterConfig, RepoSubscription, Channel
15
+ from pathlib import Path
16
+
17
+ config = UpdaterConfig(
18
+ install_root=Path("/path/to/your/app"), # where the app lives on disk
19
+ allowed_repos=["your-org/your-app"], # nothing outside this is ever fetched
20
+ subscriptions=[
21
+ RepoSubscription("your-org/your-app", branches=["main"],
22
+ channel=Channel.PYTHON_SOURCE),
23
+ ],
24
+ token="ghp_...", # optional (private repos / rate limits)
25
+ )
26
+
27
+ updater = Updater(config)
28
+ for plan in updater.check(): # dry run — nothing written yet
29
+ print(plan.summary())
30
+ if plan.has_update and not plan.conflicts:
31
+ updater.apply(plan) # backup → write → install deps
32
+ else:
33
+ updater.discard(plan)
34
+ ```
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install updater-gitplucker
40
+ ```
41
+
42
+ Or the latest from source:
43
+
44
+ ```bash
45
+ pip install "git+https://github.com/uukjtisa/updater-gitplucker.git"
46
+ ```
47
+
48
+ ## Concepts
49
+
50
+ ### Allowlist (security boundary)
51
+ `allowed_repos` is enforced at the network layer: any repo not listed is
52
+ rejected **before** any request. Every subscription's repo must be allowlisted.
53
+
54
+ ### Channels — *how* a repo is updated
55
+ | Channel | Source | Best for |
56
+ |---|---|---|
57
+ | `Channel.RELEASE` | Latest GitHub Release + assets (`.zip`, `.whl`, …) | Versioned/shipped builds |
58
+ | `Channel.SOURCE` | Raw source at a branch tip (zipball) | Repos without formal releases |
59
+ | `Channel.PYTHON_SOURCE` | `SOURCE` **+ 3-way merge + dependency auto-detect** | Python apps held together by scripts (the main use case) |
60
+
61
+ ### Apply strategies — *what* an update replaces (`config.apply_strategy`)
62
+ | Strategy | Effect |
63
+ |---|---|
64
+ | `ApplyStrategy.WHOLE_APP` (default) | Add/modify/merge/delete the tracked tree, with backup + rollback |
65
+ | `ApplyStrategy.SELECTIVE` | Only paths matching `selective_globs`; never deletes |
66
+ | `ApplyStrategy.PACKAGE` | `pip install` the downloaded wheel/sdist (RELEASE channel) |
67
+
68
+ ### Triggers — *when* it runs
69
+ - **Manual** — just call `updater.check()` / `updater.apply()` (or `ManualTrigger`).
70
+ - **Startup** — `StartupTrigger(updater).run(on_update=prompt_fn)`.
71
+ - **Background** — `BackgroundTrigger(updater, interval_seconds=3600).start(...)`.
72
+
73
+ All three are optional wrappers; the library never acts on its own.
74
+
75
+ ### 3-way merge (PYTHON_SOURCE)
76
+ After each apply, gitplucker snapshots the exact files it pulled (the *base*).
77
+ On the next update it merges `base → your local edits` with `base → upstream`.
78
+ Non-overlapping changes merge cleanly; only edits to the **same lines** produce a
79
+ conflict. `config.conflict_policy` decides what happens then: `mark` (git-style
80
+ markers, default), `local`, `remote`, or `abort`.
81
+
82
+ ### Dependency auto-detection (PYTHON_SOURCE)
83
+ Every incoming `.py` is scanned for imports; standard-library and the app's own
84
+ packages are filtered out; anything left that isn't installed is surfaced in
85
+ `plan.dependency_changes` and (if `auto_install_deps=True`) `pip install`-ed on
86
+ apply. Version pins in your `requirements.txt` are honored. Newly-added modules
87
+ are flagged `is_new`.
88
+
89
+ ## Inspecting a plan
90
+
91
+ ```python
92
+ plan.summary() # one-line human summary
93
+ plan.has_update # bool
94
+ plan.file_changes # list[FileChange] (added/modified/merged/conflict/deleted)
95
+ plan.conflicts # list[FileChange] (subset needing attention)
96
+ plan.dependency_changes # list[DependencyChange]
97
+ plan.new_dependencies # subset that are newly referenced
98
+ plan.warnings # e.g. "local changes overwritten (no merge available)"
99
+ plan.release_notes # release body / source stamp
100
+ ```
101
+
102
+ ## Events
103
+
104
+ ```python
105
+ updater.events.on(lambda name, payload: print(name, payload))
106
+ ```
107
+ Emitted: `check.start/done`, `download.progress`, `dep.detected/install`,
108
+ `apply.file`, `backup`, `rollback`, `apply.done`. A throwing listener can never
109
+ break an update.
110
+
111
+ ## State & rollback
112
+ Everything lives in `install_root/.gitplucker/` (override via `state_dir`):
113
+ `manifest.json` (installed version + known modules per repo/branch), `base/`
114
+ (merge snapshots), `backups/` (timestamped pre-apply copies for manual
115
+ rollback). A failed apply auto-rolls-back the current batch.
116
+
117
+ See [`docs/INTEGRATION.md`](docs/INTEGRATION.md) for a step-by-step wiring guide
118
+ and [`examples/basic.py`](examples/basic.py) for a runnable script.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "updater-gitplucker"
7
+ version = "0.1.0"
8
+ description = "Modular, pluggable GitHub updater library for Python-glued projects (release / source / python-source channels with 3-way merge and dependency auto-detection)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Niccc2007" }]
13
+ keywords = ["updater", "github", "auto-update", "self-update", "merge", "dependencies"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Version Control :: Git",
24
+ "Topic :: System :: Software Distribution",
25
+ ]
26
+ dependencies = [] # zero required runtime deps: core runs on the stdlib
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=7"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/uukjtisa/updater-gitplucker"
33
+ Repository = "https://github.com/uukjtisa/updater-gitplucker"
34
+ Issues = "https://github.com/uukjtisa/updater-gitplucker/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ gitplucker = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from .github_api import GitHubClient, ReleaseInfo
2
+
3
+ __all__ = ["GitHubClient", "ReleaseInfo"]
@@ -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}"
@@ -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,13 @@
1
+ from .python_deps import (
2
+ scan_imports,
3
+ resolve_dependencies,
4
+ install_requirements,
5
+ PACKAGE_ALIASES,
6
+ )
7
+
8
+ __all__ = [
9
+ "scan_imports",
10
+ "resolve_dependencies",
11
+ "install_requirements",
12
+ "PACKAGE_ALIASES",
13
+ ]