backfill_marketplace_domain_links.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | #!/usr/bin/env python3 |
| 2 | """Backfill `marketplace_domain_id` for repos created before musehub#120 |
| 3 | Phase 2 shipped β one-time, idempotent, never guesses. |
| 4 | |
| 5 | Every repo whose `marketplace_domain_id` is still NULL is re-resolved via |
| 6 | the same `resolve_unambiguous_domain_id_by_category` helper Phase 2 wired |
| 7 | into `create_repo` β a repo is only linked when exactly one non-deprecated |
| 8 | marketplace domain matches its plain category string (e.g. "code"). |
| 9 | Ambiguous (2+ candidates) or unmatched (0 candidates) repos are left NULL |
| 10 | and reported explicitly by `repo_id`, never silently skipped. |
| 11 | |
| 12 | Usage: |
| 13 | python3 scripts/backfill_marketplace_domain_links.py # dry-run (default) |
| 14 | python3 scripts/backfill_marketplace_domain_links.py --apply # perform the writes |
| 15 | |
| 16 | Connects via the app's configured DATABASE_URL (same as the running |
| 17 | server) β run locally against local Postgres, or inside the container to |
| 18 | backfill a deployed environment: |
| 19 | |
| 20 | docker exec musehub python3 scripts/backfill_marketplace_domain_links.py --apply |
| 21 | """ |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import argparse |
| 25 | import asyncio |
| 26 | from dataclasses import dataclass, field |
| 27 | |
| 28 | from sqlalchemy import func, select |
| 29 | from sqlalchemy.ext.asyncio import AsyncSession |
| 30 | |
| 31 | from musehub.db.musehub_domain_models import MusehubDomain |
| 32 | from musehub.db.musehub_repo_models import MusehubRepo |
| 33 | from musehub.services.musehub_domains import ( |
| 34 | record_domain_install, |
| 35 | resolve_unambiguous_domain_id_by_category, |
| 36 | ) |
| 37 | |
| 38 | |
| 39 | @dataclass |
| 40 | class BackfillReport: |
| 41 | """Every repo the backfill considered, bucketed by outcome β never a |
| 42 | bare count. `linked`/`skipped_ambiguous`/`skipped_no_match` are lists |
| 43 | of `repo_id` so nothing is silently under-reported.""" |
| 44 | |
| 45 | linked: list[str] = field(default_factory=list) |
| 46 | skipped_ambiguous: list[str] = field(default_factory=list) |
| 47 | skipped_no_match: list[str] = field(default_factory=list) |
| 48 | |
| 49 | |
| 50 | async def backfill_marketplace_domain_links( |
| 51 | session: AsyncSession, |
| 52 | *, |
| 53 | apply: bool, |
| 54 | ) -> BackfillReport: |
| 55 | """Resolve and (optionally) write `marketplace_domain_id` for every |
| 56 | repo that doesn't have one yet. |
| 57 | |
| 58 | ``apply=False`` (dry-run): computes and returns the report but writes |
| 59 | nothing β no `session.commit()`, no ORM attribute mutation. |
| 60 | ``apply=True``: writes the resolved link and calls |
| 61 | ``record_domain_install`` once per linked repo, matching what |
| 62 | `create_repo` already does at creation time (musehub#120 Phase 2), so |
| 63 | `install_count` is correct after the backfill too. Caller commits. |
| 64 | |
| 65 | Idempotent: only repos with `marketplace_domain_id IS NULL` are ever |
| 66 | considered β a repo linked by a prior run (or by `create_repo` in the |
| 67 | interim) is untouched by construction, not by a special-case check. |
| 68 | """ |
| 69 | report = BackfillReport() |
| 70 | |
| 71 | stmt = select(MusehubRepo).where(MusehubRepo.marketplace_domain_id.is_(None)) |
| 72 | result = await session.execute(stmt) |
| 73 | repos = result.scalars().all() |
| 74 | |
| 75 | for repo in repos: |
| 76 | if repo.domain_id is None: |
| 77 | # No category at all to resolve against β same "no match" |
| 78 | # bucket as a category with zero live marketplace domains. |
| 79 | report.skipped_no_match.append(repo.repo_id) |
| 80 | continue |
| 81 | |
| 82 | resolved = await resolve_unambiguous_domain_id_by_category(session, repo.domain_id) |
| 83 | if resolved is None: |
| 84 | candidate_count_stmt = select(func.count()).select_from(MusehubDomain).where( |
| 85 | MusehubDomain.slug == repo.domain_id, |
| 86 | MusehubDomain.is_deprecated.is_(False), |
| 87 | ) |
| 88 | candidate_count = (await session.execute(candidate_count_stmt)).scalar_one() |
| 89 | if candidate_count >= 2: |
| 90 | report.skipped_ambiguous.append(repo.repo_id) |
| 91 | else: |
| 92 | report.skipped_no_match.append(repo.repo_id) |
| 93 | continue |
| 94 | |
| 95 | report.linked.append(repo.repo_id) |
| 96 | if apply: |
| 97 | repo.marketplace_domain_id = resolved |
| 98 | await record_domain_install(session, repo.owner_user_id, resolved) |
| 99 | |
| 100 | return report |
| 101 | |
| 102 | |
| 103 | def _print_report(report: BackfillReport, apply: bool) -> None: |
| 104 | mode = "APPLY" if apply else "DRY-RUN" |
| 105 | print(f"\nMarketplace domain link backfill β {mode}") |
| 106 | print(f" linked : {len(report.linked)}") |
| 107 | for repo_id in report.linked: |
| 108 | print(f" + {repo_id}") |
| 109 | print(f" skipped (ambiguous): {len(report.skipped_ambiguous)}") |
| 110 | for repo_id in report.skipped_ambiguous: |
| 111 | print(f" ~ {repo_id}") |
| 112 | print(f" skipped (no match) : {len(report.skipped_no_match)}") |
| 113 | for repo_id in report.skipped_no_match: |
| 114 | print(f" ~ {repo_id}") |
| 115 | print() |
| 116 | if not apply and report.linked: |
| 117 | print("Dry-run only β re-run with --apply to write these links.\n") |
| 118 | |
| 119 | |
| 120 | async def _main_async(apply: bool) -> BackfillReport: |
| 121 | from musehub.db.database import AsyncSessionLocal, close_db, init_db |
| 122 | |
| 123 | await init_db() |
| 124 | try: |
| 125 | async with AsyncSessionLocal() as session: |
| 126 | report = await backfill_marketplace_domain_links(session, apply=apply) |
| 127 | if apply: |
| 128 | await session.commit() |
| 129 | else: |
| 130 | await session.rollback() |
| 131 | finally: |
| 132 | await close_db() |
| 133 | return report |
| 134 | |
| 135 | |
| 136 | def main() -> None: |
| 137 | parser = argparse.ArgumentParser( |
| 138 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 139 | ) |
| 140 | parser.add_argument( |
| 141 | "--apply", action="store_true", help="Perform the writes (default: dry-run, no writes)." |
| 142 | ) |
| 143 | args = parser.parse_args() |
| 144 | |
| 145 | report = asyncio.run(_main_async(args.apply)) |
| 146 | _print_report(report, args.apply) |
| 147 | |
| 148 | |
| 149 | if __name__ == "__main__": |
| 150 | main() |