macpkg-migrate-core 0.3.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.
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Lightweight shared planning and safety primitives for Mac package migration."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from .core import (
|
|
6
|
+
AUTOMATIC,
|
|
7
|
+
REVIEW,
|
|
8
|
+
Candidate,
|
|
9
|
+
Identity,
|
|
10
|
+
candidates_for,
|
|
11
|
+
choose_candidate,
|
|
12
|
+
dry_run,
|
|
13
|
+
install_allowed,
|
|
14
|
+
load_snapshot,
|
|
15
|
+
plan_record,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AUTOMATIC", "REVIEW", "Candidate", "Identity", "candidates_for",
|
|
20
|
+
"choose_candidate", "dry_run", "install_allowed", "load_snapshot",
|
|
21
|
+
"plan_record",
|
|
22
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Manager-neutral migration planning primitives.
|
|
2
|
+
|
|
3
|
+
This package deliberately knows nothing about Homebrew, MacPorts, or Fink
|
|
4
|
+
commands. It handles catalog records, candidate selection, plan records, and
|
|
5
|
+
the shared safety policy used by manager-specific front ends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable, Mapping
|
|
15
|
+
|
|
16
|
+
AUTOMATIC = "automatic"
|
|
17
|
+
REVIEW = "needs-review"
|
|
18
|
+
SAFE_STATUSES = frozenset({AUTOMATIC, "installed"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Identity:
|
|
23
|
+
manager: str
|
|
24
|
+
package_type: str
|
|
25
|
+
native_name: str
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_record(cls, record: Mapping[str, Any]) -> "Identity":
|
|
29
|
+
return cls(
|
|
30
|
+
record.get("manager", ""),
|
|
31
|
+
record.get("package_type", record.get("type", "")),
|
|
32
|
+
record.get("native_name", record.get("name", "")),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, str]:
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Candidate:
|
|
41
|
+
target: Identity
|
|
42
|
+
relation_type: str
|
|
43
|
+
confidence: float
|
|
44
|
+
review_status: str
|
|
45
|
+
matching_method: str
|
|
46
|
+
evidence: tuple[Any, ...]
|
|
47
|
+
source_catalog_versions: Mapping[str, Any]
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_relation(cls, relation: Mapping[str, Any]) -> "Candidate":
|
|
51
|
+
return cls(
|
|
52
|
+
target=Identity.from_record(relation.get("target", {})),
|
|
53
|
+
relation_type=relation.get("type", "equivalent"),
|
|
54
|
+
confidence=float(relation.get("confidence", 0)),
|
|
55
|
+
review_status=relation.get("review_status", REVIEW),
|
|
56
|
+
matching_method=relation.get("matching_method", "unknown"),
|
|
57
|
+
evidence=tuple(relation.get("evidence", ())),
|
|
58
|
+
source_catalog_versions=relation.get("source_catalog_versions", {}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, Any]:
|
|
62
|
+
result = asdict(self)
|
|
63
|
+
result["target"] = self.target.as_dict()
|
|
64
|
+
result["evidence"] = list(self.evidence)
|
|
65
|
+
result["source_catalog_versions"] = dict(self.source_catalog_versions)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_snapshot(path: str | Path) -> dict[str, Any]:
|
|
70
|
+
"""Load a catalog JSON snapshot or SQLite export without network access."""
|
|
71
|
+
path = Path(path)
|
|
72
|
+
if path.suffix in {".sqlite", ".db"}:
|
|
73
|
+
with sqlite3.connect(path) as connection:
|
|
74
|
+
packages = [dict(row) for row in connection.execute("SELECT * FROM packages")]
|
|
75
|
+
relations = [dict(row) for row in connection.execute("SELECT * FROM relations")]
|
|
76
|
+
metadata = {
|
|
77
|
+
key: json.loads(value)
|
|
78
|
+
for key, value in connection.execute("SELECT key, value FROM metadata")
|
|
79
|
+
} if _has_table(connection, "metadata") else {}
|
|
80
|
+
return {"packages": packages, "relations": relations, **metadata}
|
|
81
|
+
data = json.loads(path.read_text())
|
|
82
|
+
if isinstance(data, list):
|
|
83
|
+
return {"relations": data}
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise ValueError("catalog snapshot must be a JSON object or relation array")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_table(connection: sqlite3.Connection, name: str) -> bool:
|
|
90
|
+
return connection.execute(
|
|
91
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
|
|
92
|
+
).fetchone() is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def candidates_for(
|
|
96
|
+
relations: Iterable[Mapping[str, Any]], source: Identity
|
|
97
|
+
) -> list[Candidate]:
|
|
98
|
+
"""Return catalog candidates whose source exactly matches ``source``."""
|
|
99
|
+
candidates = []
|
|
100
|
+
for relation in relations:
|
|
101
|
+
if Identity.from_record(relation.get("source", {})) == source:
|
|
102
|
+
candidate = Candidate.from_relation(relation)
|
|
103
|
+
if candidate.target.native_name:
|
|
104
|
+
candidates.append(candidate)
|
|
105
|
+
return candidates
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def choose_candidate(
|
|
109
|
+
candidates: Iterable[Candidate], preference: Iterable[str] = ()
|
|
110
|
+
) -> Candidate | None:
|
|
111
|
+
"""Choose only an automatic candidate, never a review-only near-hit."""
|
|
112
|
+
preference = tuple(preference)
|
|
113
|
+
eligible = [candidate for candidate in candidates if candidate.review_status == AUTOMATIC]
|
|
114
|
+
eligible.sort(
|
|
115
|
+
key=lambda candidate: (
|
|
116
|
+
-candidate.confidence,
|
|
117
|
+
preference.index(candidate.target.manager)
|
|
118
|
+
if candidate.target.manager in preference else len(preference),
|
|
119
|
+
candidate.target.native_name,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return eligible[0] if eligible else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def plan_record(
|
|
126
|
+
source: Identity,
|
|
127
|
+
candidates: Iterable[Candidate],
|
|
128
|
+
catalog_version: str | None,
|
|
129
|
+
preference: Iterable[str] = (),
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Build a serializable, manager-neutral migration plan record."""
|
|
132
|
+
candidates = list(candidates)
|
|
133
|
+
chosen = choose_candidate(candidates, preference)
|
|
134
|
+
recommendation = None
|
|
135
|
+
if chosen:
|
|
136
|
+
recommendation = {
|
|
137
|
+
"manager": chosen.target.manager,
|
|
138
|
+
"type": chosen.target.package_type,
|
|
139
|
+
"name": chosen.target.native_name,
|
|
140
|
+
"confidence": chosen.confidence,
|
|
141
|
+
"status": chosen.review_status,
|
|
142
|
+
"method": chosen.matching_method,
|
|
143
|
+
"relation_type": chosen.relation_type,
|
|
144
|
+
"evidence": list(chosen.evidence),
|
|
145
|
+
"source_catalog_versions": dict(chosen.source_catalog_versions),
|
|
146
|
+
"review_status": chosen.review_status,
|
|
147
|
+
"target": chosen.target.as_dict(),
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
"source": source.as_dict(),
|
|
151
|
+
"catalog_version": catalog_version,
|
|
152
|
+
"candidates": [candidate.as_dict() for candidate in candidates],
|
|
153
|
+
"recommendation": recommendation,
|
|
154
|
+
"action": "consolidate" if chosen else "review",
|
|
155
|
+
"install_authorized": bool(chosen),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def install_allowed(record: Mapping[str, Any]) -> bool:
|
|
160
|
+
"""Return whether shared policy permits unattended installation."""
|
|
161
|
+
recommendation = record.get("recommendation") or {}
|
|
162
|
+
return bool(
|
|
163
|
+
record.get("install_authorized")
|
|
164
|
+
and recommendation.get("review_status") == AUTOMATIC
|
|
165
|
+
and recommendation.get("target", {}).get("manager") in {"macports", "fink"}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def dry_run(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
170
|
+
"""Return a safe preview result; this function never invokes a manager."""
|
|
171
|
+
return {
|
|
172
|
+
"source": record.get("source"),
|
|
173
|
+
"recommendation": record.get("recommendation"),
|
|
174
|
+
"would_install": install_allowed(record),
|
|
175
|
+
"would_remove": False,
|
|
176
|
+
"rollback": "No packages changed; discard the plan or uninstall only after explicit review.",
|
|
177
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: macpkg-migrate-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Manager-neutral planning and safety primitives for Mac package migration
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# macpkg-migrate-core
|
|
10
|
+
|
|
11
|
+
Lightweight, manager-neutral planning and safety primitives shared by
|
|
12
|
+
`brew2port`, `brew2fink`, and `macpkg-migrate`.
|
|
13
|
+
|
|
14
|
+
The package handles catalog snapshot loading, exact candidate selection,
|
|
15
|
+
review-only near-hit policy, serializable migration plan records, and the
|
|
16
|
+
shared no-implicit-removal safety rule. It never invokes Homebrew, MacPorts,
|
|
17
|
+
Fink, or any other package manager.
|
|
18
|
+
|
|
19
|
+
The public API is available from `macpkg_migrate_core`:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from macpkg_migrate_core import Identity, candidates_for, plan_record
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Manager-specific applications retain inventory, target availability checks,
|
|
26
|
+
installation, verification, rollback guidance, and user-facing workflows.
|
|
27
|
+
|
|
28
|
+
Releases are published to PyPI from the `pypi` GitHub environment using
|
|
29
|
+
trusted publishing. Configure `tomck/macpkg-migrate-core` as a trusted
|
|
30
|
+
publisher on PyPI before publishing the first release.
|
|
31
|
+
|
|
32
|
+
## First PyPI release
|
|
33
|
+
|
|
34
|
+
Publishing uses PyPI Trusted Publishing through GitHub Actions; no API token,
|
|
35
|
+
GPG key, or local signing setup is required. A repository administrator must
|
|
36
|
+
first create the project on PyPI (or configure its pending publisher) with:
|
|
37
|
+
|
|
38
|
+
- Owner: `tomck`
|
|
39
|
+
- Repository: `macpkg-migrate-core`
|
|
40
|
+
- Workflow: `publish.yml`
|
|
41
|
+
- GitHub environment: `pypi`
|
|
42
|
+
|
|
43
|
+
The workflow requests the required OIDC permission and publishes only when a
|
|
44
|
+
GitHub Release is marked published. After the PyPI publisher is saved, create
|
|
45
|
+
a GitHub Release for tag `v0.3.0`. The action builds and publishes the package
|
|
46
|
+
without storing a PyPI credential in GitHub.
|
|
47
|
+
|
|
48
|
+
After the workflow succeeds, verify the public installation in a clean
|
|
49
|
+
environment:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
python3 -m venv /tmp/macpkg-migrate-core-check
|
|
53
|
+
. /tmp/macpkg-migrate-core-check/bin/activate
|
|
54
|
+
python -m pip install --upgrade pip
|
|
55
|
+
python -m pip install macpkg-migrate-core==0.3.0
|
|
56
|
+
python -c 'import macpkg_migrate_core; print(macpkg_migrate_core.__version__)'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Update consumer lock/checksum or Homebrew formula metadata only after this
|
|
60
|
+
clean installation succeeds.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
macpkg_migrate_core/__init__.py,sha256=ijknRgGKpJumoWQlVLTBaUb7moEAU8_ljY8GjP1mUr8,472
|
|
2
|
+
macpkg_migrate_core/core.py,sha256=6pGwT1fKMOW1zLDj6cRtAzpMf-WyHq60LEV-dZrT7P8,6473
|
|
3
|
+
macpkg_migrate_core-0.3.0.dist-info/METADATA,sha256=mNyjut4nDUcFOc8r9cQ9yXzACy63lEdOnw1vAaz8o-Q,2218
|
|
4
|
+
macpkg_migrate_core-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
macpkg_migrate_core-0.3.0.dist-info/top_level.txt,sha256=SZoilNs4rcklCvmXXjqmVHZ_xVgFft9_SGnUzH2tsro,20
|
|
6
|
+
macpkg_migrate_core-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
macpkg_migrate_core
|