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.
Files changed (61) hide show
  1. pkgeter-1.1.0/PKG-INFO +148 -0
  2. pkgeter-1.1.0/README.md +122 -0
  3. pkgeter-1.1.0/pkgeter/__init__.py +3 -0
  4. pkgeter-1.1.0/pkgeter/__main__.py +12 -0
  5. pkgeter-1.1.0/pkgeter/_version.py +1 -0
  6. pkgeter-1.1.0/pkgeter/backend/__init__.py +69 -0
  7. pkgeter-1.1.0/pkgeter/backend/debian.py +277 -0
  8. pkgeter-1.1.0/pkgeter/backend/rpm.py +393 -0
  9. pkgeter-1.1.0/pkgeter/cache.py +150 -0
  10. pkgeter-1.1.0/pkgeter/cli.py +76 -0
  11. pkgeter-1.1.0/pkgeter/config.py +193 -0
  12. pkgeter-1.1.0/pkgeter/context.py +119 -0
  13. pkgeter-1.1.0/pkgeter/data/d3.v7.min.js +2 -0
  14. pkgeter-1.1.0/pkgeter/data/presets.yaml +91 -0
  15. pkgeter-1.1.0/pkgeter/data/tree_template.html +222 -0
  16. pkgeter-1.1.0/pkgeter/db/__init__.py +0 -0
  17. pkgeter-1.1.0/pkgeter/db/dpkg_list.py +37 -0
  18. pkgeter-1.1.0/pkgeter/db/package_cache.py +343 -0
  19. pkgeter-1.1.0/pkgeter/db/packages.py +76 -0
  20. pkgeter-1.1.0/pkgeter/db/source_cache.py +220 -0
  21. pkgeter-1.1.0/pkgeter/deps/__init__.py +0 -0
  22. pkgeter-1.1.0/pkgeter/deps/provides_index.py +76 -0
  23. pkgeter-1.1.0/pkgeter/deps/resolver.py +171 -0
  24. pkgeter-1.1.0/pkgeter/deps/tree.py +281 -0
  25. pkgeter-1.1.0/pkgeter/deps/virtual.py +45 -0
  26. pkgeter-1.1.0/pkgeter/downloader.py +102 -0
  27. pkgeter-1.1.0/pkgeter/get.py +291 -0
  28. pkgeter-1.1.0/pkgeter/models.py +139 -0
  29. pkgeter-1.1.0/pkgeter/output/__init__.py +14 -0
  30. pkgeter-1.1.0/pkgeter/output/apt_repo_gen.py +61 -0
  31. pkgeter-1.1.0/pkgeter/output/base.py +37 -0
  32. pkgeter-1.1.0/pkgeter/output/deb_directory.py +38 -0
  33. pkgeter-1.1.0/pkgeter/output/deb_mirror.py +133 -0
  34. pkgeter-1.1.0/pkgeter/output/repomd_gen.py +153 -0
  35. pkgeter-1.1.0/pkgeter/output/rpm_directory.py +32 -0
  36. pkgeter-1.1.0/pkgeter/output/rpm_mirror.py +185 -0
  37. pkgeter-1.1.0/pkgeter/output/tree_html.py +74 -0
  38. pkgeter-1.1.0/pkgeter/preset.py +347 -0
  39. pkgeter-1.1.0/pkgeter/repl.py +285 -0
  40. pkgeter-1.1.0/pkgeter/repo.py +78 -0
  41. pkgeter-1.1.0/pkgeter/search.py +142 -0
  42. pkgeter-1.1.0/pkgeter/tree.py +82 -0
  43. pkgeter-1.1.0/pkgeter.egg-info/PKG-INFO +148 -0
  44. pkgeter-1.1.0/pkgeter.egg-info/SOURCES.txt +59 -0
  45. pkgeter-1.1.0/pkgeter.egg-info/dependency_links.txt +1 -0
  46. pkgeter-1.1.0/pkgeter.egg-info/entry_points.txt +2 -0
  47. pkgeter-1.1.0/pkgeter.egg-info/requires.txt +8 -0
  48. pkgeter-1.1.0/pkgeter.egg-info/top_level.txt +1 -0
  49. pkgeter-1.1.0/pyproject.toml +48 -0
  50. pkgeter-1.1.0/setup.cfg +4 -0
  51. pkgeter-1.1.0/tests/test_backend_debian.py +168 -0
  52. pkgeter-1.1.0/tests/test_backend_rpm.py +456 -0
  53. pkgeter-1.1.0/tests/test_cli.py +301 -0
  54. pkgeter-1.1.0/tests/test_config.py +308 -0
  55. pkgeter-1.1.0/tests/test_context.py +197 -0
  56. pkgeter-1.1.0/tests/test_downloader.py +63 -0
  57. pkgeter-1.1.0/tests/test_get.py +27 -0
  58. pkgeter-1.1.0/tests/test_models.py +161 -0
  59. pkgeter-1.1.0/tests/test_preset.py +532 -0
  60. pkgeter-1.1.0/tests/test_repl.py +143 -0
  61. 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
@@ -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,3 @@
1
+ """pkgeter - Offline Debian package downloader."""
2
+
3
+ from pkgeter._version import __version__
@@ -0,0 +1,12 @@
1
+ """Entry point for `python -m pkgeter` and `pkgeter` CLI command."""
2
+
3
+ import sys
4
+
5
+
6
+ def main():
7
+ from pkgeter.cli import run_cli
8
+ run_cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -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"