pkgmap-py 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.
- pkgmap/__init__.py +26 -0
- pkgmap/apps.py +83 -0
- pkgmap/cli.py +171 -0
- pkgmap/consumer.py +145 -0
- pkgmap/db.py +90 -0
- pkgmap/downloader.py +64 -0
- pkgmap/logger.py +40 -0
- pkgmap/pipeline.py +253 -0
- pkgmap/resolver.py +203 -0
- pkgmap/schema.py +78 -0
- pkgmap_py-0.1.0.dist-info/METADATA +155 -0
- pkgmap_py-0.1.0.dist-info/RECORD +14 -0
- pkgmap_py-0.1.0.dist-info/WHEEL +4 -0
- pkgmap_py-0.1.0.dist-info/entry_points.txt +4 -0
pkgmap/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Cross-distribution Linux package name resolution library."""
|
|
2
|
+
|
|
3
|
+
from pkgmap.db import ensure_database, get_database_path, update_database
|
|
4
|
+
from pkgmap.resolver import (
|
|
5
|
+
PackageResolution,
|
|
6
|
+
list_repositories,
|
|
7
|
+
resolve,
|
|
8
|
+
resolve_all,
|
|
9
|
+
resolve_batch,
|
|
10
|
+
resolve_batch_all,
|
|
11
|
+
resolve_project,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PackageResolution",
|
|
17
|
+
"ensure_database",
|
|
18
|
+
"get_database_path",
|
|
19
|
+
"list_repositories",
|
|
20
|
+
"resolve",
|
|
21
|
+
"resolve_all",
|
|
22
|
+
"resolve_batch",
|
|
23
|
+
"resolve_batch_all",
|
|
24
|
+
"resolve_project",
|
|
25
|
+
"update_database",
|
|
26
|
+
]
|
pkgmap/apps.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from pkgmap import pipeline
|
|
7
|
+
from pkgmap.cli import cli_app
|
|
8
|
+
from pkgmap.pipeline import download_dump_file, set_github_output
|
|
9
|
+
|
|
10
|
+
pipeline_app = typer.Typer(
|
|
11
|
+
name="pkgmap-pipeline",
|
|
12
|
+
help="Download dump, transform, and build package map database",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pipeline_app.command(name="download")
|
|
18
|
+
def pipeline_download(
|
|
19
|
+
link: Annotated[
|
|
20
|
+
str | None,
|
|
21
|
+
typer.Option("--link", "-l", help="Dump link to download from"),
|
|
22
|
+
] = None,
|
|
23
|
+
dest_dir: Annotated[
|
|
24
|
+
Path | None,
|
|
25
|
+
typer.Option("--dest-dir", "-d", help="Directory where to save the dump file"),
|
|
26
|
+
] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Download the latest Repology database dump."""
|
|
29
|
+
dump_file, dump_date = download_dump_file(dump_link=link, dest_dir=dest_dir)
|
|
30
|
+
set_github_output(
|
|
31
|
+
date=dump_date,
|
|
32
|
+
tag=f"db-{dump_date}",
|
|
33
|
+
dump_path=str(dump_file.resolve()),
|
|
34
|
+
)
|
|
35
|
+
typer.echo(f"Downloaded Repology dump ({dump_date}) to {dump_file}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pipeline_app.command(name="build")
|
|
39
|
+
def pipeline_build(
|
|
40
|
+
input_dump: Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--input-dump",
|
|
44
|
+
"-i",
|
|
45
|
+
help="Dump file to take as an input to the processing pipeline",
|
|
46
|
+
),
|
|
47
|
+
] = None,
|
|
48
|
+
output_file: Annotated[
|
|
49
|
+
Path,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"-o",
|
|
52
|
+
"--output-path",
|
|
53
|
+
help="Path for the output SQLite database",
|
|
54
|
+
),
|
|
55
|
+
] = Path("pkgmap.db"),
|
|
56
|
+
batch_size: Annotated[
|
|
57
|
+
int,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--batch-size",
|
|
60
|
+
help="Number of records per batch in the processing pipeline",
|
|
61
|
+
),
|
|
62
|
+
] = 50_000,
|
|
63
|
+
release_notes: Annotated[
|
|
64
|
+
Path | None,
|
|
65
|
+
typer.Option("--release-notes", help="Path to generate release notes markdown"),
|
|
66
|
+
] = None,
|
|
67
|
+
dump_date: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
typer.Option("--dump-date", help="Snapshot date (YYYY-MM-DD)"),
|
|
70
|
+
] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Process Repology dump and generate SQLite package map database."""
|
|
73
|
+
pipeline.main(
|
|
74
|
+
dump_file=input_dump,
|
|
75
|
+
output_file=output_file,
|
|
76
|
+
batch_size=batch_size,
|
|
77
|
+
release_notes=release_notes,
|
|
78
|
+
dump_date=dump_date,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
cli_app()
|
pkgmap/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from pkgmap.db import get_database_path, update_database
|
|
8
|
+
from pkgmap.resolver import list_repositories, resolve_all, resolve_project
|
|
9
|
+
|
|
10
|
+
cli_app = typer.Typer(
|
|
11
|
+
name="pkgmap",
|
|
12
|
+
help="Cross-distribution Linux package name resolution CLI",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli_app.callback()
|
|
18
|
+
def cli_callback(
|
|
19
|
+
verbose: Annotated[
|
|
20
|
+
bool,
|
|
21
|
+
typer.Option("--verbose", "-v", help="Enable verbose debug logging"),
|
|
22
|
+
] = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
if verbose:
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
for name in ("pkgmap", "pkgmap-db", "pkgmap-downloader", "pkgmap-consumer"):
|
|
28
|
+
log = logging.getLogger(name)
|
|
29
|
+
log.setLevel(logging.DEBUG)
|
|
30
|
+
for handler in log.handlers:
|
|
31
|
+
handler.setLevel(logging.DEBUG)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cli_app.command(name="resolve")
|
|
35
|
+
def resolve_cmd(
|
|
36
|
+
package: Annotated[
|
|
37
|
+
str, typer.Argument(help="Package or upstream project name to resolve")
|
|
38
|
+
],
|
|
39
|
+
to_repo: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--to",
|
|
43
|
+
"-t",
|
|
44
|
+
help="Target repository/distribution (e.g. nixpkgs, alpine). If omitted, matches across all repositories.",
|
|
45
|
+
),
|
|
46
|
+
] = None,
|
|
47
|
+
from_repo: Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--from",
|
|
51
|
+
"-f",
|
|
52
|
+
help="Source repository/distribution (e.g. debian, arch). If omitted, package is resolved by upstream project name.",
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
project: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--project",
|
|
59
|
+
"-p",
|
|
60
|
+
help="Resolve package directly as an upstream project name",
|
|
61
|
+
),
|
|
62
|
+
] = False,
|
|
63
|
+
all_matches: Annotated[
|
|
64
|
+
bool,
|
|
65
|
+
typer.Option("--all", "-a", help="Show all matching target packages"),
|
|
66
|
+
] = False,
|
|
67
|
+
json_output: Annotated[
|
|
68
|
+
bool,
|
|
69
|
+
typer.Option("--json", "-j", help="Output results in JSON format"),
|
|
70
|
+
] = False,
|
|
71
|
+
db_path: Annotated[
|
|
72
|
+
Path | None,
|
|
73
|
+
typer.Option("--db-path", help="Custom path to pkgmap.db database"),
|
|
74
|
+
] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Resolve a package name across Linux distributions."""
|
|
77
|
+
if project and from_repo:
|
|
78
|
+
typer.secho(
|
|
79
|
+
"Error: Cannot specify both --from and --project.",
|
|
80
|
+
fg=typer.colors.RED,
|
|
81
|
+
)
|
|
82
|
+
raise typer.Exit(code=2)
|
|
83
|
+
|
|
84
|
+
if from_repo and not project:
|
|
85
|
+
results = resolve_all(
|
|
86
|
+
package, from_repo=from_repo, to_repo=to_repo, db_path=db_path
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
results = resolve_project(package, to_repo=to_repo, db_path=db_path)
|
|
90
|
+
|
|
91
|
+
if not results:
|
|
92
|
+
if json_output:
|
|
93
|
+
typer.echo(json.dumps([]))
|
|
94
|
+
else:
|
|
95
|
+
if from_repo and not project:
|
|
96
|
+
if to_repo:
|
|
97
|
+
msg = f"No equivalent package found for '{package}' from '{from_repo}' to '{to_repo}'."
|
|
98
|
+
else:
|
|
99
|
+
msg = f"No equivalent package found for '{package}' from '{from_repo}'."
|
|
100
|
+
else:
|
|
101
|
+
if to_repo:
|
|
102
|
+
msg = f"No equivalent package found for project '{package}' in '{to_repo}'."
|
|
103
|
+
else:
|
|
104
|
+
msg = f"No equivalent package found for project '{package}'."
|
|
105
|
+
typer.secho(msg, fg=typer.colors.YELLOW)
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
items_to_show = results if (all_matches or to_repo is None) else results[:1]
|
|
109
|
+
|
|
110
|
+
if json_output:
|
|
111
|
+
data = [
|
|
112
|
+
{
|
|
113
|
+
"name": r.name,
|
|
114
|
+
"version": r.version,
|
|
115
|
+
"category": r.category,
|
|
116
|
+
"repo": r.repo,
|
|
117
|
+
"repo_family": r.repo_family,
|
|
118
|
+
"install_name": r.install_name,
|
|
119
|
+
}
|
|
120
|
+
for r in items_to_show
|
|
121
|
+
]
|
|
122
|
+
typer.echo(json.dumps(data, indent=2))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
for res in items_to_show:
|
|
126
|
+
version_str = f" ({res.version})" if res.version else ""
|
|
127
|
+
meta_parts = []
|
|
128
|
+
if res.category and res.repo_family != "nix":
|
|
129
|
+
meta_parts.append(f"category: {res.category}")
|
|
130
|
+
meta_str = f" [{', '.join(meta_parts)}]" if meta_parts else ""
|
|
131
|
+
repo_prefix = f"{res.repo}: " if to_repo is None else ""
|
|
132
|
+
typer.echo(f"{repo_prefix}{res.install_name}{version_str}{meta_str}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@cli_app.command(name="update")
|
|
136
|
+
def update_cmd(
|
|
137
|
+
db_path: Annotated[
|
|
138
|
+
Path | None,
|
|
139
|
+
typer.Option("--db-path", help="Custom path to save pkgmap.db database"),
|
|
140
|
+
] = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Explicitly download and update the local package map database."""
|
|
143
|
+
target_path = get_database_path(db_path)
|
|
144
|
+
typer.echo(f"Updating package database at {target_path}...")
|
|
145
|
+
update_database(db_path=target_path, show_progress=True)
|
|
146
|
+
typer.secho(
|
|
147
|
+
f"Successfully updated database at {target_path}", fg=typer.colors.GREEN
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@cli_app.command(name="repos")
|
|
152
|
+
def repos_cmd(
|
|
153
|
+
db_path: Annotated[
|
|
154
|
+
Path | None,
|
|
155
|
+
typer.Option("--db-path", help="Custom path to pkgmap.db database"),
|
|
156
|
+
] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""List all available distributions and repositories."""
|
|
159
|
+
repos = list_repositories(db_path=db_path)
|
|
160
|
+
typer.echo(f"{'Repository':<25} {'Family':<15} {'Packages':>10}")
|
|
161
|
+
typer.echo("-" * 52)
|
|
162
|
+
for r in sorted(repos, key=lambda x: x.name):
|
|
163
|
+
typer.echo(f"{r.name:<25} {r.family:<15} {r.package_count:>10,}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main() -> None:
|
|
167
|
+
cli_app()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
pkgmap/consumer.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pkgmap.logger import setup_logger
|
|
6
|
+
from pkgmap.schema import PackageRecord, RepoFamilyMap, Repository
|
|
7
|
+
|
|
8
|
+
logger = setup_logger("pkgmap-consumer")
|
|
9
|
+
|
|
10
|
+
SCHEMA_SQL = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS repositories (
|
|
12
|
+
id INTEGER PRIMARY KEY,
|
|
13
|
+
name TEXT UNIQUE NOT NULL,
|
|
14
|
+
family TEXT NOT NULL,
|
|
15
|
+
package_count INTEGER DEFAULT 0
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS repository_aliases (
|
|
18
|
+
alias TEXT PRIMARY KEY,
|
|
19
|
+
target_repo TEXT NOT NULL REFERENCES repositories(name)
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS packages (
|
|
22
|
+
repo_id INTEGER NOT NULL REFERENCES repositories(id),
|
|
23
|
+
package_name TEXT NOT NULL,
|
|
24
|
+
effname TEXT NOT NULL,
|
|
25
|
+
version TEXT,
|
|
26
|
+
category TEXT,
|
|
27
|
+
PRIMARY KEY (repo_id, package_name)
|
|
28
|
+
) WITHOUT ROWID;
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
INSERT_PACKAGES_QUERY = (
|
|
32
|
+
"INSERT OR IGNORE INTO packages (repo_id, package_name, effname, version, category) "
|
|
33
|
+
"VALUES (?, ?, ?, ?, ?)"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
INSERT_REPOSITORIES_QUERY = "INSERT OR REPLACE INTO repositories (id, name, family, package_count) VALUES (?, ?, ?, ?)"
|
|
37
|
+
|
|
38
|
+
SELECT_REPO_COUNTS_QUERY = "SELECT repo_id, COUNT(*) FROM packages GROUP BY repo_id"
|
|
39
|
+
|
|
40
|
+
CREATE_INDEX_QUERY = "CREATE INDEX IF NOT EXISTS idx_effname ON packages (effname);"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SQLiteConsumer:
|
|
44
|
+
def __init__(self, output_file: Path, batch_size: int = 50_000) -> None:
|
|
45
|
+
self.output_file = output_file
|
|
46
|
+
self.batch_size = batch_size
|
|
47
|
+
self.batch_counter = 0
|
|
48
|
+
self.total_records = 0
|
|
49
|
+
self.repo_ids: dict[str, int] = {}
|
|
50
|
+
|
|
51
|
+
def init_db_schema(self, conn: sqlite3.Connection) -> None:
|
|
52
|
+
conn.executescript(SCHEMA_SQL)
|
|
53
|
+
|
|
54
|
+
def send_batch(self, conn: sqlite3.Connection, batch: list[PackageRecord]) -> None:
|
|
55
|
+
self.batch_counter += 1
|
|
56
|
+
self.total_records += len(batch)
|
|
57
|
+
logger.info(
|
|
58
|
+
f"Sending batch {self.batch_counter} (size={len(batch):,}, total={self.total_records:,})"
|
|
59
|
+
)
|
|
60
|
+
rows = [
|
|
61
|
+
(
|
|
62
|
+
self.repo_ids.setdefault(pkg.repo, len(self.repo_ids) + 1),
|
|
63
|
+
pkg.package_name,
|
|
64
|
+
pkg.effname,
|
|
65
|
+
pkg.version,
|
|
66
|
+
pkg.category,
|
|
67
|
+
)
|
|
68
|
+
for pkg in batch
|
|
69
|
+
]
|
|
70
|
+
conn.executemany(INSERT_PACKAGES_QUERY, rows)
|
|
71
|
+
conn.commit()
|
|
72
|
+
batch.clear()
|
|
73
|
+
logger.info(
|
|
74
|
+
f"Batch {self.batch_counter} saved ({self.total_records:,} records committed so far)."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def populate_repositories(
|
|
78
|
+
self, conn: sqlite3.Connection, repo_families: RepoFamilyMap
|
|
79
|
+
) -> None:
|
|
80
|
+
cursor = conn.cursor()
|
|
81
|
+
cursor.execute(SELECT_REPO_COUNTS_QUERY)
|
|
82
|
+
counts = dict(cursor.fetchall())
|
|
83
|
+
for repo_name in repo_families:
|
|
84
|
+
if repo_name not in self.repo_ids:
|
|
85
|
+
self.repo_ids[repo_name] = len(self.repo_ids) + 1
|
|
86
|
+
|
|
87
|
+
repositories = [
|
|
88
|
+
Repository(
|
|
89
|
+
id=repo_id,
|
|
90
|
+
name=repo_name,
|
|
91
|
+
family=repo_families.get(repo_name, "unknown"),
|
|
92
|
+
package_count=counts.get(repo_id, 0),
|
|
93
|
+
)
|
|
94
|
+
for repo_name, repo_id in sorted(
|
|
95
|
+
self.repo_ids.items(), key=lambda item: item[1]
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
cursor.executemany(
|
|
99
|
+
INSERT_REPOSITORIES_QUERY,
|
|
100
|
+
[(r.id, r.name, r.family, r.package_count) for r in repositories],
|
|
101
|
+
)
|
|
102
|
+
conn.commit()
|
|
103
|
+
|
|
104
|
+
def __call__(
|
|
105
|
+
self, records: Iterable[PackageRecord], repo_families: RepoFamilyMap
|
|
106
|
+
) -> None:
|
|
107
|
+
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
self.repo_ids = {}
|
|
109
|
+
conn = sqlite3.connect(self.output_file)
|
|
110
|
+
|
|
111
|
+
# WAL mode: Appends writes sequentially to a write-ahead log rather than altering the main DB
|
|
112
|
+
# file directly with rollback journals, providing high write throughput for bulk ingestion.
|
|
113
|
+
conn.execute("PRAGMA journal_mode = WAL;")
|
|
114
|
+
|
|
115
|
+
# NORMAL sync: Syncs WAL to disk at checkpoint intervals rather than blocking on every
|
|
116
|
+
# batch commit with hardware fsync, eliminating disk I/O bottlenecks.
|
|
117
|
+
conn.execute("PRAGMA synchronous = NORMAL;")
|
|
118
|
+
|
|
119
|
+
self.init_db_schema(conn)
|
|
120
|
+
|
|
121
|
+
batch: list[PackageRecord] = []
|
|
122
|
+
for pkg in records:
|
|
123
|
+
batch.append(pkg)
|
|
124
|
+
if len(batch) >= self.batch_size:
|
|
125
|
+
self.send_batch(conn, batch)
|
|
126
|
+
|
|
127
|
+
if batch:
|
|
128
|
+
self.send_batch(conn, batch)
|
|
129
|
+
|
|
130
|
+
self.populate_repositories(conn, repo_families)
|
|
131
|
+
|
|
132
|
+
conn.execute(CREATE_INDEX_QUERY)
|
|
133
|
+
conn.commit()
|
|
134
|
+
|
|
135
|
+
# Checkpoint WAL: Merges all pages from the write-ahead log back into the main database
|
|
136
|
+
# file and truncates the WAL file, leaving a clean, self-contained single .sqlite file.
|
|
137
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
|
138
|
+
|
|
139
|
+
# VACUUM: Rebuilds the database file into a contiguous structure and reclaims unallocated pages.
|
|
140
|
+
conn.execute("VACUUM;")
|
|
141
|
+
conn.close()
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
f"Database generation complete: {self.total_records:,} packages saved to {self.output_file}"
|
|
145
|
+
)
|
pkgmap/db.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import zstandard as zstd
|
|
5
|
+
|
|
6
|
+
from pkgmap.downloader import download_file
|
|
7
|
+
from pkgmap.logger import setup_logger
|
|
8
|
+
|
|
9
|
+
logger = setup_logger("pkgmap-db")
|
|
10
|
+
|
|
11
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "pkgmap"
|
|
12
|
+
DEFAULT_DB_PATH = DEFAULT_CACHE_DIR / "pkgmap.db"
|
|
13
|
+
GITHUB_RELEASE_LATEST_URL = (
|
|
14
|
+
"https://github.com/Levizor/pkgmap/releases/latest/download/pkgmap.db.zst"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_database_path(db_path: Path | str | None = None) -> Path:
|
|
19
|
+
"""Resolve the path to the pkgmap.db database.
|
|
20
|
+
|
|
21
|
+
Precedence:
|
|
22
|
+
1. Explicit db_path argument
|
|
23
|
+
2. PKGMAP_DB_PATH environment variable
|
|
24
|
+
3. Default cache location: ~/.cache/pkgmap/pkgmap.db
|
|
25
|
+
"""
|
|
26
|
+
if db_path is not None:
|
|
27
|
+
return Path(db_path).expanduser().resolve()
|
|
28
|
+
env_path = os.environ.get("PKGMAP_DB_PATH")
|
|
29
|
+
if env_path:
|
|
30
|
+
return Path(env_path).expanduser().resolve()
|
|
31
|
+
return DEFAULT_DB_PATH.expanduser().resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decompress_zstd_file(src_path: Path, dest_path: Path) -> None:
|
|
35
|
+
"""Stream decompress a .zst file to destination using chunked streaming."""
|
|
36
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
dctx = zstd.ZstdDecompressor()
|
|
38
|
+
with open(src_path, "rb") as in_fh, open(dest_path, "wb") as out_fh:
|
|
39
|
+
dctx.copy_stream(in_fh, out_fh)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_database(
|
|
43
|
+
db_path: Path | str | None = None,
|
|
44
|
+
url: str = GITHUB_RELEASE_LATEST_URL,
|
|
45
|
+
show_progress: bool = True,
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Download the latest database release from GitHub and decompress it locally."""
|
|
48
|
+
target_path = get_database_path(db_path)
|
|
49
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
compressed_tmp = target_path.with_name(f"{target_path.name}.zst.tmp")
|
|
52
|
+
decompressed_tmp = target_path.with_name(f"{target_path.name}.decompressed.tmp")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
logger.info(f"Downloading latest database release from {url}")
|
|
56
|
+
download_file(
|
|
57
|
+
url=url,
|
|
58
|
+
dest_path=compressed_tmp,
|
|
59
|
+
show_progress=show_progress,
|
|
60
|
+
label="Downloading package database",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
logger.info("Decompressing package database...")
|
|
64
|
+
decompress_zstd_file(compressed_tmp, decompressed_tmp)
|
|
65
|
+
|
|
66
|
+
# Atomic replacement of final database file
|
|
67
|
+
decompressed_tmp.replace(target_path)
|
|
68
|
+
logger.info(f"Database successfully saved to {target_path}")
|
|
69
|
+
return target_path
|
|
70
|
+
finally:
|
|
71
|
+
if compressed_tmp.exists():
|
|
72
|
+
compressed_tmp.unlink(missing_ok=True)
|
|
73
|
+
if decompressed_tmp.exists():
|
|
74
|
+
decompressed_tmp.unlink(missing_ok=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_database(
|
|
78
|
+
db_path: Path | str | None = None,
|
|
79
|
+
url: str = GITHUB_RELEASE_LATEST_URL,
|
|
80
|
+
show_progress: bool = True,
|
|
81
|
+
) -> Path:
|
|
82
|
+
"""Ensure the package database exists locally, downloading it lazily if missing."""
|
|
83
|
+
resolved_path = get_database_path(db_path)
|
|
84
|
+
if resolved_path.exists():
|
|
85
|
+
return resolved_path
|
|
86
|
+
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Local package map database not found at {resolved_path}. Fetching release..."
|
|
89
|
+
)
|
|
90
|
+
return update_database(db_path=resolved_path, url=url, show_progress=show_progress)
|
pkgmap/downloader.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
import urllib.request
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from pkgmap.logger import setup_logger
|
|
8
|
+
|
|
9
|
+
logger = setup_logger("pkgmap-downloader")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def download_file(
|
|
13
|
+
url: str,
|
|
14
|
+
dest_path: Path,
|
|
15
|
+
show_progress: bool = True,
|
|
16
|
+
label: str = "Downloading",
|
|
17
|
+
chunk_size: int = 1024 * 1024,
|
|
18
|
+
) -> Path:
|
|
19
|
+
"""Download a file with streaming chunks and optional Typer progress bar.
|
|
20
|
+
|
|
21
|
+
Writes to a temporary `.tmp` file and atomically renames on completion to
|
|
22
|
+
prevent corrupt or partial files.
|
|
23
|
+
"""
|
|
24
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
logger.info(f"Downloading from {url} to {dest_path}")
|
|
26
|
+
|
|
27
|
+
req = urllib.request.Request(
|
|
28
|
+
url,
|
|
29
|
+
headers={"User-Agent": "pkgmap/0.1.0 (https://github.com/levizor/pkgmap)"},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
temp_path = dest_path.with_name(f"{dest_path.name}.tmp")
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(req) as response:
|
|
35
|
+
content_length = getattr(response, "headers", {}).get("Content-Length")
|
|
36
|
+
total_size = (
|
|
37
|
+
int(content_length)
|
|
38
|
+
if isinstance(content_length, str) and content_length.isdigit()
|
|
39
|
+
else None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if show_progress and total_size is not None:
|
|
43
|
+
with typer.progressbar(
|
|
44
|
+
length=total_size,
|
|
45
|
+
label=label,
|
|
46
|
+
show_eta=True,
|
|
47
|
+
show_percent=True,
|
|
48
|
+
) as progress:
|
|
49
|
+
with open(temp_path, "wb") as out_file:
|
|
50
|
+
while chunk := response.read(chunk_size):
|
|
51
|
+
out_file.write(chunk)
|
|
52
|
+
progress.update(len(chunk))
|
|
53
|
+
else:
|
|
54
|
+
with open(temp_path, "wb") as out_file:
|
|
55
|
+
while chunk := response.read(chunk_size):
|
|
56
|
+
out_file.write(chunk)
|
|
57
|
+
|
|
58
|
+
temp_path.replace(dest_path)
|
|
59
|
+
finally:
|
|
60
|
+
if temp_path.exists():
|
|
61
|
+
temp_path.unlink()
|
|
62
|
+
|
|
63
|
+
logger.info(f"Download complete: {dest_path}")
|
|
64
|
+
return dest_path
|
pkgmap/logger.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def setup_logger(
|
|
8
|
+
name: str = "pkgmap",
|
|
9
|
+
level: int = logging.WARNING,
|
|
10
|
+
file: bool = False,
|
|
11
|
+
log_file: Path | str | None = None,
|
|
12
|
+
) -> logging.Logger:
|
|
13
|
+
"""Configure and return a logger suitable for CLI or pipeline use."""
|
|
14
|
+
env_level = os.environ.get("PKGMAP_LOG_LEVEL")
|
|
15
|
+
effective_level = (
|
|
16
|
+
getattr(logging, env_level.upper(), logging.WARNING) if env_level else level
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
target_logger = logging.getLogger(name)
|
|
20
|
+
target_logger.setLevel(effective_level)
|
|
21
|
+
target_logger.handlers.clear()
|
|
22
|
+
|
|
23
|
+
formatter = logging.Formatter(
|
|
24
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
25
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
29
|
+
console_handler.setLevel(effective_level)
|
|
30
|
+
console_handler.setFormatter(formatter)
|
|
31
|
+
target_logger.addHandler(console_handler)
|
|
32
|
+
|
|
33
|
+
if file:
|
|
34
|
+
dest = log_file or f"{name}.log"
|
|
35
|
+
file_handler = logging.FileHandler(dest, mode="a", encoding="utf-8")
|
|
36
|
+
file_handler.setLevel(effective_level)
|
|
37
|
+
file_handler.setFormatter(formatter)
|
|
38
|
+
target_logger.addHandler(file_handler)
|
|
39
|
+
|
|
40
|
+
return target_logger
|
pkgmap/pipeline.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import zstandard as zstd
|
|
13
|
+
|
|
14
|
+
from pkgmap.consumer import SQLiteConsumer
|
|
15
|
+
from pkgmap.downloader import download_file
|
|
16
|
+
from pkgmap.logger import setup_logger
|
|
17
|
+
from pkgmap.schema import (
|
|
18
|
+
PackageRecord,
|
|
19
|
+
RepoFamilyMap,
|
|
20
|
+
RepologyPackageRow,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
REPOLOGY_LATEST_DUMP_URL = (
|
|
24
|
+
"https://dumps.repology.org/repology-database-dump-latest.sql.zst"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = setup_logger("pkgmap-pipeline", logging.DEBUG, file=True)
|
|
28
|
+
|
|
29
|
+
OutputConsumer = Callable[[Iterable[PackageRecord], RepoFamilyMap], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_latest_database_dump_link(
|
|
33
|
+
url: str = REPOLOGY_LATEST_DUMP_URL,
|
|
34
|
+
) -> tuple[str, str]:
|
|
35
|
+
"""Inspect latest dump URL and return (url, date) based on HTTP Last-Modified header."""
|
|
36
|
+
req = urllib.request.Request(
|
|
37
|
+
url,
|
|
38
|
+
method="HEAD",
|
|
39
|
+
headers={"User-Agent": "pkgmap/0.1.0 (https://github.com/Levizor/pkgmap)"},
|
|
40
|
+
)
|
|
41
|
+
with urllib.request.urlopen(req) as resp:
|
|
42
|
+
last_modified = resp.headers.get("Last-Modified")
|
|
43
|
+
if last_modified:
|
|
44
|
+
dump_date = parsedate_to_datetime(last_modified).strftime("%Y-%m-%d")
|
|
45
|
+
else:
|
|
46
|
+
dump_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
47
|
+
|
|
48
|
+
logger.info(f"Targeting latest dump from {dump_date}: {url}")
|
|
49
|
+
return url, dump_date
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def download_dump_file(
|
|
53
|
+
dump_link: str | None = None,
|
|
54
|
+
dest_dir: Path | None = None,
|
|
55
|
+
show_progress: bool = True,
|
|
56
|
+
) -> tuple[Path, str]:
|
|
57
|
+
"""Download the Repology dump file and return (dump_path, dump_date)."""
|
|
58
|
+
if not dump_link:
|
|
59
|
+
dump_link, dump_date = fetch_latest_database_dump_link()
|
|
60
|
+
else:
|
|
61
|
+
match = re.search(r"(\d{4}-\d{2}-\d{2})", dump_link)
|
|
62
|
+
dump_date = (
|
|
63
|
+
match.group(1) if match else datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
target_dir = dest_dir or Path.cwd()
|
|
67
|
+
parsed_path = urllib.parse.urlparse(dump_link).path
|
|
68
|
+
filename = Path(urllib.parse.unquote(parsed_path)).name or "repology_dump.sql.zst"
|
|
69
|
+
dest_path = target_dir / filename
|
|
70
|
+
|
|
71
|
+
dump_path = download_file(
|
|
72
|
+
url=dump_link,
|
|
73
|
+
dest_path=dest_path,
|
|
74
|
+
show_progress=show_progress,
|
|
75
|
+
label="Downloading Repology dump",
|
|
76
|
+
)
|
|
77
|
+
return dump_path, dump_date
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stream_dump_lines(dump: Path) -> Iterator[str]:
|
|
81
|
+
"""Stream lines from a dump file (.sql.zst or plain .sql)."""
|
|
82
|
+
if str(dump).endswith(".zst"):
|
|
83
|
+
dctx = zstd.ZstdDecompressor()
|
|
84
|
+
with open(dump, "rb") as fh:
|
|
85
|
+
with dctx.stream_reader(fh) as reader:
|
|
86
|
+
text_stream = io.TextIOWrapper(
|
|
87
|
+
reader, encoding="utf-8", errors="replace"
|
|
88
|
+
)
|
|
89
|
+
for line in text_stream:
|
|
90
|
+
yield line
|
|
91
|
+
else:
|
|
92
|
+
with open(dump, "r", encoding="utf-8", errors="replace") as fh:
|
|
93
|
+
for line in fh:
|
|
94
|
+
yield line
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_repology_records(lines: Iterable[str]) -> Iterator[RepologyPackageRow]:
|
|
98
|
+
"""Parse tab-separated package rows from PostgreSQL COPY stream."""
|
|
99
|
+
in_packages = False
|
|
100
|
+
columns: list[str] = []
|
|
101
|
+
|
|
102
|
+
for line in lines:
|
|
103
|
+
if line.startswith("COPY "):
|
|
104
|
+
table_part = line[5 : line.find("(")].strip()
|
|
105
|
+
table_name = table_part.split(".")[-1]
|
|
106
|
+
if table_name == "packages":
|
|
107
|
+
in_packages = True
|
|
108
|
+
col_str = line[line.find("(") + 1 : line.rfind(")")]
|
|
109
|
+
columns = [c.strip().strip('"') for c in col_str.split(",")]
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if in_packages:
|
|
113
|
+
if line.startswith(r"\.") or line.startswith("\\."):
|
|
114
|
+
in_packages = False
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
parts = line.rstrip("\r\n").split("\t")
|
|
118
|
+
row = dict(zip(columns, parts, strict=False))
|
|
119
|
+
|
|
120
|
+
repo = row.get("repo", "")
|
|
121
|
+
visiblename = row.get("visiblename", "")
|
|
122
|
+
effname = row.get("effname", "")
|
|
123
|
+
if not repo or not visiblename or not effname:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
yield RepologyPackageRow(
|
|
127
|
+
repo=repo,
|
|
128
|
+
family=row.get("family", ""),
|
|
129
|
+
visiblename=visiblename,
|
|
130
|
+
effname=effname,
|
|
131
|
+
srcname=None
|
|
132
|
+
if row.get("srcname") in (None, r"\N")
|
|
133
|
+
else row.get("srcname"),
|
|
134
|
+
version=None
|
|
135
|
+
if row.get("version") in (None, r"\N")
|
|
136
|
+
else row.get("version"),
|
|
137
|
+
category=None
|
|
138
|
+
if row.get("category") in (None, r"\N")
|
|
139
|
+
else row.get("category"),
|
|
140
|
+
subrepo=None
|
|
141
|
+
if row.get("subrepo") in (None, r"\N")
|
|
142
|
+
else row.get("subrepo"),
|
|
143
|
+
origversion=None
|
|
144
|
+
if row.get("origversion") in (None, r"\N")
|
|
145
|
+
else row.get("origversion"),
|
|
146
|
+
flags=int(row["flags"]) if row.get("flags", "").isdigit() else 0,
|
|
147
|
+
shadow=row.get("shadow") in ("t", "true", "1"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def do_fn(
|
|
152
|
+
records: Iterable[RepologyPackageRow], repo_families: RepoFamilyMap
|
|
153
|
+
) -> Iterator[PackageRecord]:
|
|
154
|
+
for row in records:
|
|
155
|
+
if row.shadow:
|
|
156
|
+
continue
|
|
157
|
+
repo_families[row.repo] = row.family
|
|
158
|
+
yield row.to_package_record()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_pipeline(dump: Path, consumer: OutputConsumer) -> None:
|
|
162
|
+
lines = stream_dump_lines(dump)
|
|
163
|
+
raw_records = parse_repology_records(lines)
|
|
164
|
+
repo_families: RepoFamilyMap = {}
|
|
165
|
+
packages = do_fn(raw_records, repo_families)
|
|
166
|
+
consumer(packages, repo_families)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def set_github_output(**kwargs: str) -> None:
|
|
170
|
+
"""Append key-value pairs to GITHUB_OUTPUT environment file if present."""
|
|
171
|
+
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
172
|
+
if not gh_output:
|
|
173
|
+
return
|
|
174
|
+
with open(gh_output, "a", encoding="utf-8") as f:
|
|
175
|
+
for key, value in kwargs.items():
|
|
176
|
+
f.write(f"{key}={value}\n")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def generate_release_notes(
|
|
180
|
+
dump_date: str,
|
|
181
|
+
total_packages: int,
|
|
182
|
+
total_repos: int,
|
|
183
|
+
output_path: Path,
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Generate compact markdown release notes for GitHub Releases."""
|
|
186
|
+
content = (
|
|
187
|
+
f"> **{dump_date} snapshot**: {total_packages:,} packages · {total_repos} repositories"
|
|
188
|
+
f" · Sourced from [Repology](https://repology.org) ([CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/))\n"
|
|
189
|
+
)
|
|
190
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
output_path.write_text(content, encoding="utf-8")
|
|
192
|
+
logger.info(f"Generated release notes at {output_path}")
|
|
193
|
+
return content
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(
|
|
197
|
+
dump_file: Path | None = None,
|
|
198
|
+
dump_link: str | None = None,
|
|
199
|
+
output_file: Path = Path("pkgmap.db"),
|
|
200
|
+
batch_size: int = 50_000,
|
|
201
|
+
release_notes: Path | None = None,
|
|
202
|
+
dump_date: str | None = None,
|
|
203
|
+
) -> SQLiteConsumer:
|
|
204
|
+
logger.debug(
|
|
205
|
+
f"input arguments: {
|
|
206
|
+
dict(
|
|
207
|
+
{
|
|
208
|
+
'dump_file': dump_file,
|
|
209
|
+
'dump_link': dump_link,
|
|
210
|
+
'output_file': output_file,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if not dump_file:
|
|
217
|
+
dump_file, detected_date = download_dump_file(dump_link=dump_link)
|
|
218
|
+
if not dump_date:
|
|
219
|
+
dump_date = detected_date
|
|
220
|
+
elif not dump_date:
|
|
221
|
+
match = re.search(r"(\d{4}-\d{2}-\d{2})", dump_file.name)
|
|
222
|
+
dump_date = (
|
|
223
|
+
match.group(1) if match else datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if not dump_file.exists():
|
|
227
|
+
raise FileNotFoundError(f"Dump file does not exist: {dump_file}")
|
|
228
|
+
|
|
229
|
+
logger.info("Starting the pipeline!")
|
|
230
|
+
consumer = SQLiteConsumer(output_file, batch_size)
|
|
231
|
+
run_pipeline(dump_file, consumer)
|
|
232
|
+
logger.info("Finished!")
|
|
233
|
+
|
|
234
|
+
if release_notes:
|
|
235
|
+
generate_release_notes(
|
|
236
|
+
dump_date=dump_date,
|
|
237
|
+
total_packages=consumer.total_records,
|
|
238
|
+
total_repos=len(consumer.repo_ids),
|
|
239
|
+
output_path=release_notes,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
set_github_output(
|
|
243
|
+
packages=str(consumer.total_records),
|
|
244
|
+
repos=str(len(consumer.repo_ids)),
|
|
245
|
+
date=dump_date,
|
|
246
|
+
tag=f"db-{dump_date}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return consumer
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
main()
|
pkgmap/resolver.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pkgmap.db import ensure_database
|
|
6
|
+
from pkgmap.schema import PackageResolution, Repository
|
|
7
|
+
|
|
8
|
+
DEFAULT_ALIASES: dict[str, str] = {
|
|
9
|
+
"debian": "debian_12",
|
|
10
|
+
"debian_stable": "debian_12",
|
|
11
|
+
"ubuntu": "ubuntu_24_04",
|
|
12
|
+
"ubuntu_lts": "ubuntu_24_04",
|
|
13
|
+
"nix": "nix_unstable",
|
|
14
|
+
"nixpkgs": "nix_unstable",
|
|
15
|
+
"alpine": "alpine_3_20",
|
|
16
|
+
"fedora": "fedora_rawhide",
|
|
17
|
+
"arch": "arch",
|
|
18
|
+
"archlinux": "arch",
|
|
19
|
+
"void": "void_packages",
|
|
20
|
+
"freebsd": "freebsd",
|
|
21
|
+
"gentoo": "gentoo",
|
|
22
|
+
"macports": "macports",
|
|
23
|
+
"opensuse": "opensuse_tumbleweed",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_repo_name(conn: sqlite3.Connection, repo_input: str) -> str:
|
|
28
|
+
"""Resolve a user-supplied repository alias or name to its canonical slug."""
|
|
29
|
+
normalized = repo_input.strip().lower().replace("-", "_")
|
|
30
|
+
target = DEFAULT_ALIASES.get(normalized, normalized)
|
|
31
|
+
|
|
32
|
+
cursor = conn.cursor()
|
|
33
|
+
|
|
34
|
+
# 1. Exact match in repositories
|
|
35
|
+
cursor.execute("SELECT name FROM repositories WHERE name = ?", (target,))
|
|
36
|
+
row = cursor.fetchone()
|
|
37
|
+
if row:
|
|
38
|
+
return row[0]
|
|
39
|
+
|
|
40
|
+
# 2. Check repository_aliases table
|
|
41
|
+
cursor.execute(
|
|
42
|
+
"SELECT target_repo FROM repository_aliases WHERE alias = ?", (target,)
|
|
43
|
+
)
|
|
44
|
+
row = cursor.fetchone()
|
|
45
|
+
if row:
|
|
46
|
+
return row[0]
|
|
47
|
+
|
|
48
|
+
# 3. Prefix matching (e.g. 'debian' -> highest package_count debian_* repo)
|
|
49
|
+
cursor.execute(
|
|
50
|
+
"SELECT name FROM repositories WHERE name LIKE ? ORDER BY package_count DESC LIMIT 1",
|
|
51
|
+
(f"{target}%",),
|
|
52
|
+
)
|
|
53
|
+
row = cursor.fetchone()
|
|
54
|
+
if row:
|
|
55
|
+
return row[0]
|
|
56
|
+
|
|
57
|
+
# 4. Family matching (e.g. 'debuntu' or family name)
|
|
58
|
+
cursor.execute(
|
|
59
|
+
"SELECT name FROM repositories WHERE family = ? ORDER BY package_count DESC LIMIT 1",
|
|
60
|
+
(target,),
|
|
61
|
+
)
|
|
62
|
+
row = cursor.fetchone()
|
|
63
|
+
if row:
|
|
64
|
+
return row[0]
|
|
65
|
+
|
|
66
|
+
return repo_input
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
BATCH_CHUNK_SIZE = 500
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_batch_all(
|
|
73
|
+
packages: Sequence[str],
|
|
74
|
+
from_repo: str | None = None,
|
|
75
|
+
to_repo: str | None = None,
|
|
76
|
+
db_path: Path | str | None = None,
|
|
77
|
+
) -> dict[str, list[PackageResolution]]:
|
|
78
|
+
"""Resolve a batch of packages or projects, returning all matches for each package."""
|
|
79
|
+
if not packages:
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
results: dict[str, list[PackageResolution]] = {pkg: [] for pkg in packages}
|
|
83
|
+
unique_pkgs = list(dict.fromkeys(packages))
|
|
84
|
+
conn = sqlite3.connect(ensure_database(db_path))
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
cursor = conn.cursor()
|
|
88
|
+
src_repo = resolve_repo_name(conn, from_repo) if from_repo else None
|
|
89
|
+
dst_repo = resolve_repo_name(conn, to_repo) if to_repo else None
|
|
90
|
+
|
|
91
|
+
for i in range(0, len(unique_pkgs), BATCH_CHUNK_SIZE):
|
|
92
|
+
chunk = unique_pkgs[i : i + BATCH_CHUNK_SIZE]
|
|
93
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
94
|
+
|
|
95
|
+
if src_repo:
|
|
96
|
+
sql = f"""
|
|
97
|
+
SELECT src.package_name, dst.package_name, dst.version, dst.category, dst_repo.name, dst_repo.family
|
|
98
|
+
FROM packages src
|
|
99
|
+
JOIN repositories src_repo ON src.repo_id = src_repo.id
|
|
100
|
+
JOIN packages dst ON src.effname = dst.effname
|
|
101
|
+
JOIN repositories dst_repo ON dst.repo_id = dst_repo.id
|
|
102
|
+
WHERE src_repo.name = ? AND src.package_name IN ({placeholders})
|
|
103
|
+
"""
|
|
104
|
+
params: list[str] = [src_repo, *chunk]
|
|
105
|
+
else:
|
|
106
|
+
sql = f"""
|
|
107
|
+
SELECT dst.effname, dst.package_name, dst.version, dst.category, dst_repo.name, dst_repo.family
|
|
108
|
+
FROM packages dst
|
|
109
|
+
JOIN repositories dst_repo ON dst.repo_id = dst_repo.id
|
|
110
|
+
WHERE dst.effname IN ({placeholders})
|
|
111
|
+
"""
|
|
112
|
+
params = list(chunk)
|
|
113
|
+
|
|
114
|
+
if dst_repo:
|
|
115
|
+
sql += " AND dst_repo.name = ?"
|
|
116
|
+
params.append(dst_repo)
|
|
117
|
+
|
|
118
|
+
sql += " ORDER BY 1, dst_repo.name, dst.package_name;"
|
|
119
|
+
|
|
120
|
+
cursor.execute(sql, params)
|
|
121
|
+
for row in cursor.fetchall():
|
|
122
|
+
key = row[0]
|
|
123
|
+
if key in results:
|
|
124
|
+
results[key].append(
|
|
125
|
+
PackageResolution(
|
|
126
|
+
name=row[1],
|
|
127
|
+
version=row[2],
|
|
128
|
+
category=row[3],
|
|
129
|
+
repo=row[4],
|
|
130
|
+
repo_family=row[5],
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return results
|
|
134
|
+
finally:
|
|
135
|
+
conn.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_batch(
|
|
139
|
+
packages: Sequence[str],
|
|
140
|
+
from_repo: str | None = None,
|
|
141
|
+
to_repo: str | None = None,
|
|
142
|
+
db_path: Path | str | None = None,
|
|
143
|
+
) -> dict[str, PackageResolution | None]:
|
|
144
|
+
"""Resolve a batch of packages or projects, returning the first match for each package."""
|
|
145
|
+
all_res = resolve_batch_all(
|
|
146
|
+
packages, from_repo=from_repo, to_repo=to_repo, db_path=db_path
|
|
147
|
+
)
|
|
148
|
+
return {pkg: matches[0] if matches else None for pkg, matches in all_res.items()}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_all(
|
|
152
|
+
package: str,
|
|
153
|
+
from_repo: str | None = None,
|
|
154
|
+
to_repo: str | None = None,
|
|
155
|
+
db_path: Path | str | None = None,
|
|
156
|
+
) -> list[PackageResolution]:
|
|
157
|
+
"""Resolve all equivalent packages across target repository or all repositories."""
|
|
158
|
+
return resolve_batch_all(
|
|
159
|
+
[package], from_repo=from_repo, to_repo=to_repo, db_path=db_path
|
|
160
|
+
).get(package, [])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve(
|
|
164
|
+
package: str,
|
|
165
|
+
from_repo: str | None = None,
|
|
166
|
+
to_repo: str | None = None,
|
|
167
|
+
db_path: Path | str | None = None,
|
|
168
|
+
) -> PackageResolution | None:
|
|
169
|
+
"""Resolve a package or project to its primary equivalent."""
|
|
170
|
+
return resolve_batch(
|
|
171
|
+
[package], from_repo=from_repo, to_repo=to_repo, db_path=db_path
|
|
172
|
+
).get(package)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_project(
|
|
176
|
+
effname: str,
|
|
177
|
+
to_repo: str | None = None,
|
|
178
|
+
db_path: Path | str | None = None,
|
|
179
|
+
) -> list[PackageResolution]:
|
|
180
|
+
"""Resolve packages matching the canonical upstream project name."""
|
|
181
|
+
return resolve_all(effname, from_repo=None, to_repo=to_repo, db_path=db_path)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def list_repositories(db_path: Path | str | None = None) -> list[Repository]:
|
|
185
|
+
"""Return all repositories registered in the package database."""
|
|
186
|
+
resolved_db = ensure_database(db_path)
|
|
187
|
+
conn = sqlite3.connect(resolved_db)
|
|
188
|
+
try:
|
|
189
|
+
cursor = conn.cursor()
|
|
190
|
+
cursor.execute(
|
|
191
|
+
"SELECT name, family, package_count, id FROM repositories ORDER BY name"
|
|
192
|
+
)
|
|
193
|
+
return [
|
|
194
|
+
Repository(
|
|
195
|
+
name=row[0],
|
|
196
|
+
family=row[1],
|
|
197
|
+
package_count=row[2],
|
|
198
|
+
id=row[3],
|
|
199
|
+
)
|
|
200
|
+
for row in cursor.fetchall()
|
|
201
|
+
]
|
|
202
|
+
finally:
|
|
203
|
+
conn.close()
|
pkgmap/schema.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Repository(NamedTuple):
|
|
5
|
+
name: str
|
|
6
|
+
family: str
|
|
7
|
+
package_count: int = 0
|
|
8
|
+
id: int | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RepositoryAlias(NamedTuple):
|
|
12
|
+
alias: str
|
|
13
|
+
target_repo: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
type RepoFamilyMap = dict[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PackageRecord(NamedTuple):
|
|
20
|
+
repo: str
|
|
21
|
+
package_name: str # native package name
|
|
22
|
+
effname: str # unique project name, same for all packages
|
|
23
|
+
version: str | None = None
|
|
24
|
+
category: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PackageResolution(NamedTuple):
|
|
28
|
+
name: str
|
|
29
|
+
version: str | None = None
|
|
30
|
+
category: str | None = None
|
|
31
|
+
repo: str = ""
|
|
32
|
+
repo_family: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def install_name(self) -> str:
|
|
36
|
+
"""The canonical package identifier for package managers or declarative configs."""
|
|
37
|
+
return self.name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Upstream Repology PostgreSQL schema definition:
|
|
41
|
+
# https://github.com/repology/repology-updater/blob/master/sql.d/schema/create_schema_tables.sql
|
|
42
|
+
REPOLOGY_SCHEMA_URL = "https://github.com/repology/repology-updater/blob/master/sql.d/schema/create_schema_tables.sql"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RepologyPackageRow(NamedTuple):
|
|
46
|
+
"""Repology 'packages' table row schema extracted from PostgreSQL dumps.
|
|
47
|
+
|
|
48
|
+
Reference:
|
|
49
|
+
https://github.com/repology/repology-updater/blob/master/sql.d/schema/create_schema_tables.sql#L31-L75
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
repo: str
|
|
53
|
+
family: str
|
|
54
|
+
visiblename: str
|
|
55
|
+
effname: str # Canonical upstream project name
|
|
56
|
+
srcname: str | None = None
|
|
57
|
+
version: str | None = None
|
|
58
|
+
category: str | None = None
|
|
59
|
+
subrepo: str | None = None
|
|
60
|
+
origversion: str | None = None
|
|
61
|
+
flags: int = 0
|
|
62
|
+
shadow: bool = False
|
|
63
|
+
|
|
64
|
+
def to_package_record(self) -> "PackageRecord":
|
|
65
|
+
package_name = self.visiblename
|
|
66
|
+
if self.family == "nix":
|
|
67
|
+
if self.srcname:
|
|
68
|
+
package_name = self.srcname
|
|
69
|
+
elif self.category:
|
|
70
|
+
package_name = f"{self.category}.{self.visiblename}"
|
|
71
|
+
|
|
72
|
+
return PackageRecord(
|
|
73
|
+
repo=self.repo,
|
|
74
|
+
package_name=package_name,
|
|
75
|
+
effname=self.effname,
|
|
76
|
+
version=self.version,
|
|
77
|
+
category=self.category,
|
|
78
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pkgmap-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-distribution Linux package name resolution
|
|
5
|
+
Author: Levizor
|
|
6
|
+
Author-email: Levizor <levizor@disroot.org>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: typer>=0.12.0
|
|
9
|
+
Requires-Dist: zstandard>=0.22.0
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Project-URL: Homepage, https://github.com/Levizor/pkgmap
|
|
12
|
+
Project-URL: Repository, https://github.com/Levizor/pkgmap
|
|
13
|
+
Project-URL: Issues, https://github.com/Levizor/pkgmap/issues
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pkgmap
|
|
17
|
+
|
|
18
|
+
Cross-distribution Linux package name resolution.
|
|
19
|
+
|
|
20
|
+
Different distributions often package the exact same software under different names (for example, `ninja` on Arch is `ninja-build` on Debian and Alpine; `bat` on Arch is `rust-bat` on Debian and Fedora; `fd` on Arch is `rust-fd-find` on Debian; Nixpkgs uses specific attribute paths). `pkgmap` provides an offline SQLite database and a companion Python library/CLI to translate package names between any pair of Linux distributions, resolve packages across all repositories, or look up native packages by upstream project name.
|
|
21
|
+
|
|
22
|
+
Data is updated daily from [Repology](https://repology.org) and indexed for instant local lookups. On first run, `pkgmap` automatically downloads and decompresses the latest daily database release (`~/.cache/pkgmap/pkgmap.db`).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## CLI Usage
|
|
27
|
+
|
|
28
|
+
### Translate between distributions
|
|
29
|
+
|
|
30
|
+
Use `--from` (`-f`) and `--to` (`-t`):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# What is Arch's 'bat' called in Debian?
|
|
34
|
+
pkgmap resolve bat --from arch --to debian
|
|
35
|
+
# Output: rust-bat (0.22.1) [category: misc]
|
|
36
|
+
|
|
37
|
+
# What is Arch's 'fd' in Nixpkgs?
|
|
38
|
+
pkgmap resolve fd -f arch -t nixpkgs
|
|
39
|
+
# Output: fd (10.5.0)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Resolve across all distributions
|
|
43
|
+
|
|
44
|
+
Omit `--to` to see equivalents across every indexed distribution:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pkgmap resolve bat --from arch
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Output:
|
|
51
|
+
```text
|
|
52
|
+
alpine_3_20: bat (0.24.0)
|
|
53
|
+
debian_12: rust-bat (0.22.1) [category: misc]
|
|
54
|
+
fedora_rawhide: rust-bat (0.26.1)
|
|
55
|
+
nix_unstable: bat (0.26.1)
|
|
56
|
+
ubuntu_24_04: rust-bat (0.24.0)
|
|
57
|
+
...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Look up by upstream project name
|
|
61
|
+
|
|
62
|
+
Omit `--from` (or pass `--project` / `-p`) to resolve packages directly from an upstream project name:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pkgmap resolve ripgrep --to debian
|
|
66
|
+
# Output: rust-ripgrep (13.0.0) [category: misc]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### JSON output
|
|
70
|
+
|
|
71
|
+
To output machine-readable JSON (great for automation and scripts), add `--json` / `-j`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pkgmap resolve bat -f arch -t debian --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
"name": "rust-bat",
|
|
81
|
+
"version": "0.22.1",
|
|
82
|
+
"category": "misc",
|
|
83
|
+
"repo": "debian_12",
|
|
84
|
+
"repo_family": "debuntu",
|
|
85
|
+
"install_name": "rust-bat"
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Other commands
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# List available distributions and repository slugs
|
|
94
|
+
pkgmap repos
|
|
95
|
+
|
|
96
|
+
# Manually refresh the local database snapshot
|
|
97
|
+
pkgmap update
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Python API
|
|
103
|
+
|
|
104
|
+
You can also use `pkgmap` as a Python library:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from pkgmap import resolve, resolve_all, resolve_batch
|
|
108
|
+
|
|
109
|
+
# 1. Single package resolution
|
|
110
|
+
res = resolve("bat", from_repo="arch", to_repo="debian")
|
|
111
|
+
if res:
|
|
112
|
+
print(res.install_name, res.version)
|
|
113
|
+
# Output: rust-bat 0.22.1
|
|
114
|
+
|
|
115
|
+
# 2. Query all repositories for equivalents
|
|
116
|
+
matches = resolve_all("bat", from_repo="arch")
|
|
117
|
+
for r in matches:
|
|
118
|
+
print(f"{r.repo}: {r.install_name}")
|
|
119
|
+
|
|
120
|
+
# 3. High-performance batch resolution (executed in a single SQL query)
|
|
121
|
+
packages = ["bat", "fd", "ripgrep"]
|
|
122
|
+
results = resolve_batch(packages, from_repo="arch", to_repo="nixpkgs")
|
|
123
|
+
|
|
124
|
+
for pkg, match in results.items():
|
|
125
|
+
if match:
|
|
126
|
+
print(f"{pkg} -> {match.install_name}")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### API Methods
|
|
130
|
+
|
|
131
|
+
- `resolve(package, from_repo=None, to_repo=None, db_path=None)`: Resolves a package to a single match.
|
|
132
|
+
- `resolve_all(package, from_repo=None, to_repo=None, db_path=None)`: Returns all matching packages.
|
|
133
|
+
- `resolve_batch(packages, from_repo=None, to_repo=None, db_path=None)`: Resolves a sequence of packages in a single batch query, returning `{package: PackageResolution | None}`.
|
|
134
|
+
- `resolve_batch_all(packages, from_repo=None, to_repo=None, db_path=None)`: Batch resolution returning `{package: list[PackageResolution]}`.
|
|
135
|
+
- `list_repositories(db_path=None)`: Returns all repositories with package counts and families.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Configuration
|
|
140
|
+
|
|
141
|
+
By default, the database is stored at:
|
|
142
|
+
- `~/.cache/pkgmap/pkgmap.db`
|
|
143
|
+
|
|
144
|
+
You can customize the location using:
|
|
145
|
+
- The `PKGMAP_DB_PATH` environment variable:
|
|
146
|
+
```bash
|
|
147
|
+
export PKGMAP_DB_PATH="/path/to/custom/pkgmap.db"
|
|
148
|
+
```
|
|
149
|
+
- Or the `--db-path` argument on the CLI / `db_path` parameter in Python.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Attribution
|
|
154
|
+
|
|
155
|
+
Data is sourced daily from [Repology](https://repology.org). Repology's data is published under the [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) license.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pkgmap/__init__.py,sha256=BEokLUaLyHjBrnk6N1VDsx3FrR62cvYKZoyIQLBDYgw,571
|
|
2
|
+
pkgmap/apps.py,sha256=CetY2UbJ-VcTCf8gok6wL8q0eSzEww1vSc08GuxCrDw,2307
|
|
3
|
+
pkgmap/cli.py,sha256=Hl_TaieaKPK7C0xJIIk4gLz2cLpQPAl45R0xeFgcBAY,5421
|
|
4
|
+
pkgmap/consumer.py,sha256=rrdQlTGHNfUnsEa9NjIrXI16pyhsnmCqfjzVdSsDnmM,5065
|
|
5
|
+
pkgmap/db.py,sha256=IgUFFd0eGoD8LE_FwpQ-Eyl9k7hNhxxrX_bnhbJaT9k,3084
|
|
6
|
+
pkgmap/downloader.py,sha256=_burjvCvrNtQAiK2BghRi8VMjDlDUuthFxd0f_Wgiag,2053
|
|
7
|
+
pkgmap/logger.py,sha256=wmBZIXOSZqfvKhGebEqrS-l6a3aFo-ywI_AAMnLdmgs,1220
|
|
8
|
+
pkgmap/pipeline.py,sha256=7NXwTG-zlRhRQgzQqqpZhrKCtGUYIQHWuatxtBuOPE0,8297
|
|
9
|
+
pkgmap/resolver.py,sha256=nCfAReUow4BToshwzWAOFni8fqYOJ3ofaGX4Rb7QbV0,6674
|
|
10
|
+
pkgmap/schema.py,sha256=XnMP72QMeG6fHAR_8cNpf_Eos2QefKRdWNBgoARnZK0,2140
|
|
11
|
+
pkgmap_py-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
12
|
+
pkgmap_py-0.1.0.dist-info/entry_points.txt,sha256=cXriBEVusq58y9gFDN6AbNuVj0LwEWAU9Iwg6zfuo-Y,91
|
|
13
|
+
pkgmap_py-0.1.0.dist-info/METADATA,sha256=4j4ufQWXUEguoqWltamONZ6fh_xuY0kXe_heg6KGnRU,4614
|
|
14
|
+
pkgmap_py-0.1.0.dist-info/RECORD,,
|