pkgeter 1.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.
- pkgeter-1.1.0/PKG-INFO +148 -0
- pkgeter-1.1.0/README.md +122 -0
- pkgeter-1.1.0/pkgeter/__init__.py +3 -0
- pkgeter-1.1.0/pkgeter/__main__.py +12 -0
- pkgeter-1.1.0/pkgeter/_version.py +1 -0
- pkgeter-1.1.0/pkgeter/backend/__init__.py +69 -0
- pkgeter-1.1.0/pkgeter/backend/debian.py +277 -0
- pkgeter-1.1.0/pkgeter/backend/rpm.py +393 -0
- pkgeter-1.1.0/pkgeter/cache.py +150 -0
- pkgeter-1.1.0/pkgeter/cli.py +76 -0
- pkgeter-1.1.0/pkgeter/config.py +193 -0
- pkgeter-1.1.0/pkgeter/context.py +119 -0
- pkgeter-1.1.0/pkgeter/data/d3.v7.min.js +2 -0
- pkgeter-1.1.0/pkgeter/data/presets.yaml +91 -0
- pkgeter-1.1.0/pkgeter/data/tree_template.html +222 -0
- pkgeter-1.1.0/pkgeter/db/__init__.py +0 -0
- pkgeter-1.1.0/pkgeter/db/dpkg_list.py +37 -0
- pkgeter-1.1.0/pkgeter/db/package_cache.py +343 -0
- pkgeter-1.1.0/pkgeter/db/packages.py +76 -0
- pkgeter-1.1.0/pkgeter/db/source_cache.py +220 -0
- pkgeter-1.1.0/pkgeter/deps/__init__.py +0 -0
- pkgeter-1.1.0/pkgeter/deps/provides_index.py +76 -0
- pkgeter-1.1.0/pkgeter/deps/resolver.py +171 -0
- pkgeter-1.1.0/pkgeter/deps/tree.py +281 -0
- pkgeter-1.1.0/pkgeter/deps/virtual.py +45 -0
- pkgeter-1.1.0/pkgeter/downloader.py +102 -0
- pkgeter-1.1.0/pkgeter/get.py +291 -0
- pkgeter-1.1.0/pkgeter/models.py +139 -0
- pkgeter-1.1.0/pkgeter/output/__init__.py +14 -0
- pkgeter-1.1.0/pkgeter/output/apt_repo_gen.py +61 -0
- pkgeter-1.1.0/pkgeter/output/base.py +37 -0
- pkgeter-1.1.0/pkgeter/output/deb_directory.py +38 -0
- pkgeter-1.1.0/pkgeter/output/deb_mirror.py +133 -0
- pkgeter-1.1.0/pkgeter/output/repomd_gen.py +153 -0
- pkgeter-1.1.0/pkgeter/output/rpm_directory.py +32 -0
- pkgeter-1.1.0/pkgeter/output/rpm_mirror.py +185 -0
- pkgeter-1.1.0/pkgeter/output/tree_html.py +74 -0
- pkgeter-1.1.0/pkgeter/preset.py +347 -0
- pkgeter-1.1.0/pkgeter/repl.py +285 -0
- pkgeter-1.1.0/pkgeter/repo.py +78 -0
- pkgeter-1.1.0/pkgeter/search.py +142 -0
- pkgeter-1.1.0/pkgeter/tree.py +82 -0
- pkgeter-1.1.0/pkgeter.egg-info/PKG-INFO +148 -0
- pkgeter-1.1.0/pkgeter.egg-info/SOURCES.txt +59 -0
- pkgeter-1.1.0/pkgeter.egg-info/dependency_links.txt +1 -0
- pkgeter-1.1.0/pkgeter.egg-info/entry_points.txt +2 -0
- pkgeter-1.1.0/pkgeter.egg-info/requires.txt +8 -0
- pkgeter-1.1.0/pkgeter.egg-info/top_level.txt +1 -0
- pkgeter-1.1.0/pyproject.toml +48 -0
- pkgeter-1.1.0/setup.cfg +4 -0
- pkgeter-1.1.0/tests/test_backend_debian.py +168 -0
- pkgeter-1.1.0/tests/test_backend_rpm.py +456 -0
- pkgeter-1.1.0/tests/test_cli.py +301 -0
- pkgeter-1.1.0/tests/test_config.py +308 -0
- pkgeter-1.1.0/tests/test_context.py +197 -0
- pkgeter-1.1.0/tests/test_downloader.py +63 -0
- pkgeter-1.1.0/tests/test_get.py +27 -0
- pkgeter-1.1.0/tests/test_models.py +161 -0
- pkgeter-1.1.0/tests/test_preset.py +532 -0
- pkgeter-1.1.0/tests/test_repl.py +143 -0
- pkgeter-1.1.0/tests/test_repo.py +39 -0
pkgeter-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pkgeter
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Offline package downloader - support Debian/apt and RPM/dnf, resolve deps, download packages, generate offline install script
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/mlzxgzy/pkgeter
|
|
7
|
+
Project-URL: Repository, https://github.com/mlzxgzy/pkgeter.git
|
|
8
|
+
Project-URL: BugTracker, https://github.com/mlzxgzy/pkgeter/issues
|
|
9
|
+
Keywords: debian,rpm,centos,offline,package,apt,dnf,yum,downloader
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Archiving :: Packaging
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.25
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: pyreadline3; sys_platform == "win32"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# pkgeter <small>v1.1</small>
|
|
28
|
+
|
|
29
|
+
**English** | [中文](README_CH.md)
|
|
30
|
+
|
|
31
|
+
**Offline package downloader** — supports **Debian/apt** and **RPM/dnf** (CentOS Stream). Resolve dependency trees, download `.deb` or `.rpm` files, and generate an offline install script.
|
|
32
|
+
|
|
33
|
+
Works on any platform (Linux, Windows, macOS) — useful when you need to install packages on an air-gapped or offline machine.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Dual backend** — supports Debian (`dpkg`) and RPM (`rpm`) based distributions
|
|
38
|
+
- **Distribution presets** — one-command selection: `--distro debian-bookworm`, `--distro centos-9`
|
|
39
|
+
- **Interactive REPL** — run `pkgeter` with no arguments to enter a switch-style CLI with prefix matching and TAB completion
|
|
40
|
+
- **Multi-repo merge** — automatically combines repositories (e.g., main + security, BaseOS + AppStream + EPEL)
|
|
41
|
+
- **Dependency resolution** — recursively resolves all dependencies for the target packages
|
|
42
|
+
- **Skip installed packages** — optionally provide a `dpkg -l` output to skip already-installed packages
|
|
43
|
+
- **SHA256 verification** — validates every downloaded `.deb` or `.rpm` file
|
|
44
|
+
- **Source caching** — caches repository metadata with SHA256 validation (like APT), only re-downloads when changed
|
|
45
|
+
- **Offline install script** — auto-generates `install.sh` that runs `dpkg -i` or `rpm -ivh` in dependency order
|
|
46
|
+
- **Multiple mirrors** — specify fallback mirrors, tried in order until one succeeds
|
|
47
|
+
- **Persistent config** — preferences saved to `~/.config/pkgeter/config.yaml`
|
|
48
|
+
- **Repo management** — add, list, and remove custom repositories via `pkgeter repo`
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
### From source
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/mlzxgzy/pkgeter.git
|
|
55
|
+
cd pkgeter
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Interactive REPL (no arguments)
|
|
63
|
+
pkgeter
|
|
64
|
+
|
|
65
|
+
# Download packages using a distribution preset
|
|
66
|
+
pkgeter get -p nginx --distro debian-bookworm
|
|
67
|
+
pkgeter get -p nginx --distro centos-9
|
|
68
|
+
pkgeter get -p nginx --distro debian-bullseye
|
|
69
|
+
|
|
70
|
+
# Short prefix forms work too
|
|
71
|
+
pkgeter g -p nginx --distro centos-9
|
|
72
|
+
|
|
73
|
+
# Legacy usage (backward compatible)
|
|
74
|
+
pkgeter get -p vim
|
|
75
|
+
pkgeter get -p nginx -r bookworm -a amd64
|
|
76
|
+
|
|
77
|
+
# Specify multiple mirrors (tried in order)
|
|
78
|
+
pkgeter get -p nginx -m https://deb.debian.org/debian -m https://ftp.debian.org/debian
|
|
79
|
+
|
|
80
|
+
# Manage repositories
|
|
81
|
+
pkgeter repo list
|
|
82
|
+
pkgeter repo add --name myrepo --type deb --url https://example.com/debian --release bookworm
|
|
83
|
+
pkgeter repo remove myrepo
|
|
84
|
+
|
|
85
|
+
# List and apply distribution presets
|
|
86
|
+
pkgeter preset list
|
|
87
|
+
pkgeter preset apply centos-9
|
|
88
|
+
|
|
89
|
+
# Specify a custom output directory
|
|
90
|
+
pkgeter get -p python3 -o ./my-output
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
For Debian mode, all `.deb` files are placed in a `debs/` subdirectory. For RPM mode, all `.rpm` files are placed in a `rpms/` subdirectory. An `install.sh` script is generated that runs `dpkg -i` or `rpm -ivh` in dependency order.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Debian: copy the debs/ directory and install.sh to the target machine, then:
|
|
99
|
+
sudo bash install.sh
|
|
100
|
+
|
|
101
|
+
# RPM: copy the rpms/ directory and install.sh to the target machine, then:
|
|
102
|
+
sudo bash install.sh
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
pkgeter stores persistent preferences at `~/.config/pkgeter/config.yaml`. This file is automatically created when you run the tool.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
backend: debian
|
|
111
|
+
arch: amd64
|
|
112
|
+
repos:
|
|
113
|
+
- name: debian-main
|
|
114
|
+
type: deb
|
|
115
|
+
url: https://deb.debian.org/debian
|
|
116
|
+
release: bookworm
|
|
117
|
+
- name: debian-security
|
|
118
|
+
type: deb
|
|
119
|
+
url: https://security.debian.org/debian-security
|
|
120
|
+
release: bookworm-security
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
CLI flags override config file values. Apply a preset to quickly populate the config:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pkgeter preset apply centos-9
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## How It Works
|
|
130
|
+
|
|
131
|
+
1. **Download package database** — fetches metadata from configured repositories (Packages.gz for Debian, repomd.xml + primary.xml.gz for RPM)
|
|
132
|
+
2. **Parse dependency tree** — recursively resolves all required packages
|
|
133
|
+
3. **Download package files** — downloads each package with SHA256 verification
|
|
134
|
+
4. **Generate output** — creates `debs/` or `rpms/` directory with `install.sh`
|
|
135
|
+
|
|
136
|
+
## Distribution Presets
|
|
137
|
+
|
|
138
|
+
| Preset | Backend | Included Repositories |
|
|
139
|
+
|--------|---------|----------------------|
|
|
140
|
+
| `debian-bookworm` | deb | main, security, updates |
|
|
141
|
+
| `debian-bullseye` | deb | main, security, updates |
|
|
142
|
+
| `debian-trixie` | deb | main, security, updates |
|
|
143
|
+
| `centos-9` | rpm | BaseOS, AppStream, EPEL |
|
|
144
|
+
| `pve8` | deb | bookworm main, security, updates + pve-no-subscription |
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
pkgeter-1.1.0/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# pkgeter <small>v1.1</small>
|
|
2
|
+
|
|
3
|
+
**English** | [中文](README_CH.md)
|
|
4
|
+
|
|
5
|
+
**Offline package downloader** — supports **Debian/apt** and **RPM/dnf** (CentOS Stream). Resolve dependency trees, download `.deb` or `.rpm` files, and generate an offline install script.
|
|
6
|
+
|
|
7
|
+
Works on any platform (Linux, Windows, macOS) — useful when you need to install packages on an air-gapped or offline machine.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Dual backend** — supports Debian (`dpkg`) and RPM (`rpm`) based distributions
|
|
12
|
+
- **Distribution presets** — one-command selection: `--distro debian-bookworm`, `--distro centos-9`
|
|
13
|
+
- **Interactive REPL** — run `pkgeter` with no arguments to enter a switch-style CLI with prefix matching and TAB completion
|
|
14
|
+
- **Multi-repo merge** — automatically combines repositories (e.g., main + security, BaseOS + AppStream + EPEL)
|
|
15
|
+
- **Dependency resolution** — recursively resolves all dependencies for the target packages
|
|
16
|
+
- **Skip installed packages** — optionally provide a `dpkg -l` output to skip already-installed packages
|
|
17
|
+
- **SHA256 verification** — validates every downloaded `.deb` or `.rpm` file
|
|
18
|
+
- **Source caching** — caches repository metadata with SHA256 validation (like APT), only re-downloads when changed
|
|
19
|
+
- **Offline install script** — auto-generates `install.sh` that runs `dpkg -i` or `rpm -ivh` in dependency order
|
|
20
|
+
- **Multiple mirrors** — specify fallback mirrors, tried in order until one succeeds
|
|
21
|
+
- **Persistent config** — preferences saved to `~/.config/pkgeter/config.yaml`
|
|
22
|
+
- **Repo management** — add, list, and remove custom repositories via `pkgeter repo`
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
### From source
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/mlzxgzy/pkgeter.git
|
|
29
|
+
cd pkgeter
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Interactive REPL (no arguments)
|
|
37
|
+
pkgeter
|
|
38
|
+
|
|
39
|
+
# Download packages using a distribution preset
|
|
40
|
+
pkgeter get -p nginx --distro debian-bookworm
|
|
41
|
+
pkgeter get -p nginx --distro centos-9
|
|
42
|
+
pkgeter get -p nginx --distro debian-bullseye
|
|
43
|
+
|
|
44
|
+
# Short prefix forms work too
|
|
45
|
+
pkgeter g -p nginx --distro centos-9
|
|
46
|
+
|
|
47
|
+
# Legacy usage (backward compatible)
|
|
48
|
+
pkgeter get -p vim
|
|
49
|
+
pkgeter get -p nginx -r bookworm -a amd64
|
|
50
|
+
|
|
51
|
+
# Specify multiple mirrors (tried in order)
|
|
52
|
+
pkgeter get -p nginx -m https://deb.debian.org/debian -m https://ftp.debian.org/debian
|
|
53
|
+
|
|
54
|
+
# Manage repositories
|
|
55
|
+
pkgeter repo list
|
|
56
|
+
pkgeter repo add --name myrepo --type deb --url https://example.com/debian --release bookworm
|
|
57
|
+
pkgeter repo remove myrepo
|
|
58
|
+
|
|
59
|
+
# List and apply distribution presets
|
|
60
|
+
pkgeter preset list
|
|
61
|
+
pkgeter preset apply centos-9
|
|
62
|
+
|
|
63
|
+
# Specify a custom output directory
|
|
64
|
+
pkgeter get -p python3 -o ./my-output
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Output
|
|
68
|
+
|
|
69
|
+
For Debian mode, all `.deb` files are placed in a `debs/` subdirectory. For RPM mode, all `.rpm` files are placed in a `rpms/` subdirectory. An `install.sh` script is generated that runs `dpkg -i` or `rpm -ivh` in dependency order.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Debian: copy the debs/ directory and install.sh to the target machine, then:
|
|
73
|
+
sudo bash install.sh
|
|
74
|
+
|
|
75
|
+
# RPM: copy the rpms/ directory and install.sh to the target machine, then:
|
|
76
|
+
sudo bash install.sh
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
pkgeter stores persistent preferences at `~/.config/pkgeter/config.yaml`. This file is automatically created when you run the tool.
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
backend: debian
|
|
85
|
+
arch: amd64
|
|
86
|
+
repos:
|
|
87
|
+
- name: debian-main
|
|
88
|
+
type: deb
|
|
89
|
+
url: https://deb.debian.org/debian
|
|
90
|
+
release: bookworm
|
|
91
|
+
- name: debian-security
|
|
92
|
+
type: deb
|
|
93
|
+
url: https://security.debian.org/debian-security
|
|
94
|
+
release: bookworm-security
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
CLI flags override config file values. Apply a preset to quickly populate the config:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pkgeter preset apply centos-9
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## How It Works
|
|
104
|
+
|
|
105
|
+
1. **Download package database** — fetches metadata from configured repositories (Packages.gz for Debian, repomd.xml + primary.xml.gz for RPM)
|
|
106
|
+
2. **Parse dependency tree** — recursively resolves all required packages
|
|
107
|
+
3. **Download package files** — downloads each package with SHA256 verification
|
|
108
|
+
4. **Generate output** — creates `debs/` or `rpms/` directory with `install.sh`
|
|
109
|
+
|
|
110
|
+
## Distribution Presets
|
|
111
|
+
|
|
112
|
+
| Preset | Backend | Included Repositories |
|
|
113
|
+
|--------|---------|----------------------|
|
|
114
|
+
| `debian-bookworm` | deb | main, security, updates |
|
|
115
|
+
| `debian-bullseye` | deb | main, security, updates |
|
|
116
|
+
| `debian-trixie` | deb | main, security, updates |
|
|
117
|
+
| `centos-9` | rpm | BaseOS, AppStream, EPEL |
|
|
118
|
+
| `pve8` | deb | bookworm main, security, updates + pve-no-subscription |
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Backend abstraction for package managers (apt, dnf/yum)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from pkgeter.models import PackageInfo, RepoConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PmBackend(ABC):
|
|
11
|
+
"""Abstract interface for package manager backends."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
"""Backend identifier, e.g. 'debian', 'rpm'."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def download_package_db(
|
|
21
|
+
self,
|
|
22
|
+
repos: list[RepoConfig],
|
|
23
|
+
arch: str,
|
|
24
|
+
timeout: int = 60,
|
|
25
|
+
force_update: bool = False,
|
|
26
|
+
) -> dict[str, PackageInfo]:
|
|
27
|
+
"""Download metadata from all repos, merge into a single package DB."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def build_download_url(self, base_url: str, pkg: PackageInfo) -> str:
|
|
32
|
+
"""Construct the remote download URL for a package file."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def generate_install_script(
|
|
37
|
+
self, files: list[str], target_packages: list[str],
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Generate shell script for offline installation."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def merge_package_dbs(
|
|
43
|
+
self, dbs: list[dict[str, PackageInfo]],
|
|
44
|
+
) -> dict[str, PackageInfo]:
|
|
45
|
+
"""Merge multiple package DBs. Later repos override earlier ones."""
|
|
46
|
+
merged: dict[str, PackageInfo] = {}
|
|
47
|
+
for db in dbs:
|
|
48
|
+
merged.update(db)
|
|
49
|
+
return merged
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def cache(self) -> "PackageCache | None":
|
|
53
|
+
"""Lazily-initialized SQLite package cache."""
|
|
54
|
+
if not hasattr(self, "_cache"):
|
|
55
|
+
try:
|
|
56
|
+
from pkgeter.db.package_cache import PackageCache
|
|
57
|
+
self._cache: PackageCache | None = PackageCache()
|
|
58
|
+
except Exception:
|
|
59
|
+
self._cache = None
|
|
60
|
+
return self._cache
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def build_source_id(backend_type: str, url: str, release: str, arch: str, component: str = "") -> str:
|
|
64
|
+
"""Build a unique source identifier for the cache."""
|
|
65
|
+
sanitized = url.removeprefix("https://").removeprefix("http://").rstrip("/")
|
|
66
|
+
parts = [backend_type, sanitized, release, arch]
|
|
67
|
+
if component:
|
|
68
|
+
parts.append(component)
|
|
69
|
+
return ":".join(parts)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Debian/APT backend implementation.
|
|
2
|
+
|
|
3
|
+
Implements :class:`PmBackend` for the Debian package manager (``dpkg`` / ``apt``).
|
|
4
|
+
Downloads and caches ``Packages.gz`` metadata from Debian repositories, parses
|
|
5
|
+
stanzas into :class:`PackageInfo` objects, and provides utilities for download
|
|
6
|
+
URL construction and install script generation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from pkgeter.backend import PmBackend
|
|
18
|
+
from pkgeter.db.source_cache import SourceCache
|
|
19
|
+
from pkgeter.models import PackageInfo, RepoConfig, parse_depends_line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DebianBackend(PmBackend):
|
|
23
|
+
"""Debian/APT backend.
|
|
24
|
+
|
|
25
|
+
Handles Debian-style ``Packages.gz`` metadata, ``Release``-file-based cache
|
|
26
|
+
validation, and deterministic ``.deb`` download URLs.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
# PmBackend interface
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
return "apt"
|
|
36
|
+
|
|
37
|
+
def download_package_db(
|
|
38
|
+
self,
|
|
39
|
+
repos: list[RepoConfig],
|
|
40
|
+
arch: str,
|
|
41
|
+
timeout: int = 60,
|
|
42
|
+
force_update: bool = False,
|
|
43
|
+
) -> Dict[str, PackageInfo]:
|
|
44
|
+
"""Download metadata from all Debian repos, merge into a single package DB.
|
|
45
|
+
|
|
46
|
+
For each repository:
|
|
47
|
+
|
|
48
|
+
* For the ``main`` component: uses :class:`SourceCache` (Release-based
|
|
49
|
+
SHA256 caching, same strategy as APT).
|
|
50
|
+
* For other components (or when the cache is unavailable): falls back
|
|
51
|
+
to a direct HTTP download.
|
|
52
|
+
|
|
53
|
+
Errors on individual repos/components are silently skipped so that a
|
|
54
|
+
single unavailable repo does not break the whole resolution.
|
|
55
|
+
"""
|
|
56
|
+
dbs: list[Dict[str, PackageInfo]] = []
|
|
57
|
+
|
|
58
|
+
for repo in repos:
|
|
59
|
+
repo_db = self._download_repo(repo, arch, timeout=timeout, force_update=force_update)
|
|
60
|
+
if repo_db:
|
|
61
|
+
dbs.append(repo_db)
|
|
62
|
+
|
|
63
|
+
return self.merge_package_dbs(dbs)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def build_download_url(base_url: str, pkg: PackageInfo) -> str:
|
|
67
|
+
"""Construct the remote download URL for a .deb file.
|
|
68
|
+
|
|
69
|
+
For Debian, the ``PackageInfo.filename`` stores the relative path from
|
|
70
|
+
the mirror root (e.g. ``pool/main/v/vsftpd/vsftpd_3.0.5-3_amd64.deb``).
|
|
71
|
+
"""
|
|
72
|
+
return f"{base_url.rstrip('/')}/{pkg.filename}"
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def generate_install_script(
|
|
76
|
+
files: list[str],
|
|
77
|
+
target_packages: list[str],
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Generate a bash install script that uses ``dpkg -i``."""
|
|
80
|
+
pkg_list = " ".join(target_packages)
|
|
81
|
+
deb_cmds = "\n".join(
|
|
82
|
+
f'sudo dpkg -i "{name}"' for name in files
|
|
83
|
+
)
|
|
84
|
+
return (
|
|
85
|
+
"#!/bin/bash\n"
|
|
86
|
+
"# pkgeter - Offline Debian package installation script\n"
|
|
87
|
+
f"# Target packages: {pkg_list}\n"
|
|
88
|
+
"#\n"
|
|
89
|
+
"# Install packages one by one in dependency order.\n"
|
|
90
|
+
"#\n"
|
|
91
|
+
'# Auto-detect sudo availability\n'
|
|
92
|
+
'if ! command -v sudo >/dev/null 2>&1; then\n'
|
|
93
|
+
' sudo() { "$@"; }\n'
|
|
94
|
+
'fi\n'
|
|
95
|
+
"\n"
|
|
96
|
+
'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
|
|
97
|
+
'cd "$SCRIPT_DIR"\n'
|
|
98
|
+
f"{deb_cmds}\n"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Stanza / Packages.gz parsing (static for easy re-export by compat)
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _parse_deb_stanza(text: str) -> PackageInfo | None:
|
|
107
|
+
"""Parse a single Debian package stanza from a ``Packages`` file.
|
|
108
|
+
|
|
109
|
+
Returns ``None`` when the stanza does not contain a ``Package`` field.
|
|
110
|
+
"""
|
|
111
|
+
text = text.strip()
|
|
112
|
+
pkg = PackageInfo(package="", version="")
|
|
113
|
+
|
|
114
|
+
current_key: str | None = None
|
|
115
|
+
current_value: list[str] = []
|
|
116
|
+
|
|
117
|
+
def _set_field(key: str, value: str) -> None:
|
|
118
|
+
if key == "Package":
|
|
119
|
+
pkg.package = value
|
|
120
|
+
elif key == "Version":
|
|
121
|
+
pkg.version = value
|
|
122
|
+
elif key == "Architecture":
|
|
123
|
+
pkg.arch = value
|
|
124
|
+
elif key == "Filename":
|
|
125
|
+
pkg.filename = value
|
|
126
|
+
elif key == "SHA256":
|
|
127
|
+
pkg.sha256 = value
|
|
128
|
+
elif key == "Size":
|
|
129
|
+
try:
|
|
130
|
+
pkg.size = int(value)
|
|
131
|
+
except ValueError:
|
|
132
|
+
pass
|
|
133
|
+
elif key == "Description":
|
|
134
|
+
pkg.description = value
|
|
135
|
+
elif key == "Provides":
|
|
136
|
+
pkg.provides = [s.strip() for s in value.split(",")]
|
|
137
|
+
elif key == "Depends":
|
|
138
|
+
pkg.depends = parse_depends_line(value)
|
|
139
|
+
|
|
140
|
+
for line in text.split("\n"):
|
|
141
|
+
if line.startswith(" ") or line.startswith("\t"):
|
|
142
|
+
if current_key:
|
|
143
|
+
current_value.append(line.strip())
|
|
144
|
+
continue
|
|
145
|
+
if ":" in line:
|
|
146
|
+
if current_key:
|
|
147
|
+
_set_field(current_key, " ".join(current_value))
|
|
148
|
+
current_key = line.split(":", 1)[0].strip()
|
|
149
|
+
rest = line.split(":", 1)[1].strip()
|
|
150
|
+
current_value = [rest]
|
|
151
|
+
else:
|
|
152
|
+
current_key = None
|
|
153
|
+
current_value = []
|
|
154
|
+
|
|
155
|
+
if current_key and current_value:
|
|
156
|
+
_set_field(current_key, " ".join(current_value))
|
|
157
|
+
|
|
158
|
+
return pkg if pkg.package else None
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _parse_packages_gz(data: bytes) -> Dict[str, PackageInfo]:
|
|
162
|
+
"""Parse gzip-compressed (or raw) ``Packages`` content into a dict.
|
|
163
|
+
|
|
164
|
+
Keys are package names (lowercase), values are :class:`PackageInfo`.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
raw = gzip.decompress(data)
|
|
168
|
+
except OSError:
|
|
169
|
+
raw = data
|
|
170
|
+
|
|
171
|
+
text = raw.decode("utf-8", errors="replace")
|
|
172
|
+
packages: Dict[str, PackageInfo] = {}
|
|
173
|
+
|
|
174
|
+
stanzas = text.split("\n\n")
|
|
175
|
+
for stanza in stanzas:
|
|
176
|
+
stanza = stanza.strip()
|
|
177
|
+
if not stanza:
|
|
178
|
+
continue
|
|
179
|
+
info = DebianBackend._parse_deb_stanza(stanza)
|
|
180
|
+
if info and info.package:
|
|
181
|
+
packages[info.package] = info
|
|
182
|
+
return packages
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# Internal helpers
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _download_repo(
|
|
189
|
+
self,
|
|
190
|
+
repo: RepoConfig,
|
|
191
|
+
arch: str,
|
|
192
|
+
*,
|
|
193
|
+
timeout: int = 60,
|
|
194
|
+
force_update: bool = False,
|
|
195
|
+
) -> Dict[str, PackageInfo] | None:
|
|
196
|
+
"""Download and parse *all components* of a single repository."""
|
|
197
|
+
components = repo.components or ["main"]
|
|
198
|
+
repo_db: Dict[str, PackageInfo] = {}
|
|
199
|
+
|
|
200
|
+
for component in components:
|
|
201
|
+
try:
|
|
202
|
+
parsed = self._download_component(
|
|
203
|
+
repo.url, repo.release, component, arch,
|
|
204
|
+
timeout=timeout, force_update=force_update,
|
|
205
|
+
)
|
|
206
|
+
if parsed:
|
|
207
|
+
repo_db.update(parsed)
|
|
208
|
+
except Exception:
|
|
209
|
+
# Don't fail entirely just because one component is bad
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
for pkg in repo_db.values():
|
|
213
|
+
pkg.base_url = repo.url
|
|
214
|
+
return repo_db if repo_db else None
|
|
215
|
+
|
|
216
|
+
def _download_component(
|
|
217
|
+
self,
|
|
218
|
+
mirror: str,
|
|
219
|
+
release: str,
|
|
220
|
+
component: str,
|
|
221
|
+
arch: str,
|
|
222
|
+
*,
|
|
223
|
+
timeout: int = 60,
|
|
224
|
+
force_update: bool = False,
|
|
225
|
+
) -> Dict[str, PackageInfo] | None:
|
|
226
|
+
source_id = self.build_source_id("deb", mirror, release, arch, component)
|
|
227
|
+
|
|
228
|
+
if component == "main":
|
|
229
|
+
cache_obj = SourceCache(mirror, release, arch)
|
|
230
|
+
if cache_obj.update(timeout=timeout, force_update=force_update):
|
|
231
|
+
action = cache_obj.last_action
|
|
232
|
+
if action == "cache_hit":
|
|
233
|
+
print(" (cached)", end="", flush=True)
|
|
234
|
+
elif action == "downloaded":
|
|
235
|
+
print(" (downloaded)", end="", flush=True)
|
|
236
|
+
|
|
237
|
+
# Check SQLite cache before parsing
|
|
238
|
+
raw = cache_obj.read_packages_gz()
|
|
239
|
+
if raw is not None:
|
|
240
|
+
file_sha = cache_obj._file_sha256(cache_obj._packages_gz_path)
|
|
241
|
+
if file_sha and not force_update and self.cache:
|
|
242
|
+
if self.cache.is_fresh(source_id, file_sha):
|
|
243
|
+
loaded = self.cache.load(source_id)
|
|
244
|
+
if loaded is not None:
|
|
245
|
+
return loaded
|
|
246
|
+
# Parse and cache
|
|
247
|
+
parsed = self._parse_packages_gz(raw)
|
|
248
|
+
if file_sha and self.cache:
|
|
249
|
+
self.cache.store(source_id, file_sha, parsed)
|
|
250
|
+
return parsed
|
|
251
|
+
|
|
252
|
+
# Direct HTTP fallback (non-main components, or cache failure)
|
|
253
|
+
print(" (downloading)", end="", flush=True)
|
|
254
|
+
url = self._build_component_url(mirror, release, component, arch)
|
|
255
|
+
with httpx.Client(timeout=timeout) as client:
|
|
256
|
+
resp = client.get(url, follow_redirects=True)
|
|
257
|
+
resp.raise_for_status()
|
|
258
|
+
parsed = self._parse_packages_gz(resp.content)
|
|
259
|
+
|
|
260
|
+
# Cache the directly-downloaded result too
|
|
261
|
+
if self.cache:
|
|
262
|
+
import hashlib
|
|
263
|
+
content_sha = hashlib.sha256(resp.content).hexdigest()
|
|
264
|
+
self.cache.store(source_id, content_sha, parsed)
|
|
265
|
+
|
|
266
|
+
return parsed
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _build_component_url(
|
|
270
|
+
mirror: str,
|
|
271
|
+
release: str,
|
|
272
|
+
component: str,
|
|
273
|
+
arch: str,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Build the ``Packages.gz`` URL for an arbitrary component."""
|
|
276
|
+
base = mirror.rstrip("/")
|
|
277
|
+
return f"{base}/dists/{release}/{component}/binary-{arch}/Packages.gz"
|