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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ pkgmap = pkgmap.apps:cli_app
3
+ pkgmap-pipeline = pkgmap.apps:pipeline_app
4
+