foilstack 0.2.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.
Files changed (80) hide show
  1. foilstack/__init__.py +1 -0
  2. foilstack/cli.py +634 -0
  3. foilstack/config.py +149 -0
  4. foilstack/db.py +471 -0
  5. foilstack/embedder/__init__.py +0 -0
  6. foilstack/embedder/app.py +122 -0
  7. foilstack/embedding.py +36 -0
  8. foilstack/images.py +72 -0
  9. foilstack/importing.py +385 -0
  10. foilstack/inventory.py +439 -0
  11. foilstack/migrations/README +11 -0
  12. foilstack/migrations/env.py +93 -0
  13. foilstack/migrations/script.py.mako +24 -0
  14. foilstack/migrations/versions/2026_08_22_2105-04a130322022_pgvector_extension.py +33 -0
  15. foilstack/migrations/versions/2026_08_22_2105-327c4a89b5f1_baseline_schema.py +195 -0
  16. foilstack/migrations/versions/2026_08_22_2106-ece6f4eba34c_card_embedding_hnsw_index.py +54 -0
  17. foilstack/migrations/versions/2026_08_22_2139-a6e3135d319e_scan_best_score.py +27 -0
  18. foilstack/migrations/versions/2026_08_22_2213-fd1c94a2fc8f_inventory_finish_sold_state_and_notes.py +65 -0
  19. foilstack/migrations/versions/2026_08_22_2229-ab360b649e52_drop_inventory_quantity.py +41 -0
  20. foilstack/migrations/versions/2026_08_22_2326-36ee08bea9f1_card_prices_price_history_and_sync_state.py +71 -0
  21. foilstack/migrations/versions/2026_08_22_2349-d46268462eb9_declared_printing_on_inventory.py +27 -0
  22. foilstack/migrations/versions/2026_08_23_0918-6842dd86917f_scan_size_in_bytes.py +37 -0
  23. foilstack/migrations/versions/2026_08_23_1400-b91c4e2f7a10_card_name_trigram_index.py +37 -0
  24. foilstack/migrations/versions/2026_08_23_1520-c73f8a1d4b62_scan_chosen_card.py +35 -0
  25. foilstack/migrations/versions/2026_08_24_0210-d18b3f9c5e41_remember_missing_card_images.py +27 -0
  26. foilstack/plugins/__init__.py +45 -0
  27. foilstack/plugins/base.py +104 -0
  28. foilstack/plugins/exports/ebay.toml +31 -0
  29. foilstack/plugins/exports/tcgplayer.toml +44 -0
  30. foilstack/plugins/exports.py +95 -0
  31. foilstack/plugins/sources/__init__.py +0 -0
  32. foilstack/plugins/sources/tcgcsv.py +265 -0
  33. foilstack/prices.py +162 -0
  34. foilstack/search.py +144 -0
  35. foilstack/web/app.py +235 -0
  36. foilstack/web/auth.py +209 -0
  37. foilstack/web/chrome.py +178 -0
  38. foilstack/web/deps.py +72 -0
  39. foilstack/web/joblog.py +51 -0
  40. foilstack/web/proof.py +54 -0
  41. foilstack/web/ratelimit.py +103 -0
  42. foilstack/web/routes/__init__.py +18 -0
  43. foilstack/web/routes/accounts.py +227 -0
  44. foilstack/web/routes/inventory.py +384 -0
  45. foilstack/web/routes/listings.py +216 -0
  46. foilstack/web/routes/media.py +181 -0
  47. foilstack/web/routes/scans.py +527 -0
  48. foilstack/web/static/app.css +1165 -0
  49. foilstack/web/static/brand/apple-touch-icon.png +0 -0
  50. foilstack/web/static/brand/favicon-32.png +0 -0
  51. foilstack/web/static/brand/favicon.svg +15 -0
  52. foilstack/web/static/brand/mark.svg +16 -0
  53. foilstack/web/static/demo/foilstack.gif +0 -0
  54. foilstack/web/static/demo/foilstack.webp +0 -0
  55. foilstack/web/static/fonts/OFL.txt +93 -0
  56. foilstack/web/static/fonts/README.md +15 -0
  57. foilstack/web/static/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  58. foilstack/web/static/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  59. foilstack/web/static/fonts/jetbrains-mono-greek.woff2 +0 -0
  60. foilstack/web/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  61. foilstack/web/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  62. foilstack/web/static/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  63. foilstack/web/static/zoom.js +93 -0
  64. foilstack/web/templates/_card_panel.html +141 -0
  65. foilstack/web/templates/_match_options.html +17 -0
  66. foilstack/web/templates/_match_panel.html +28 -0
  67. foilstack/web/templates/analytics.html +126 -0
  68. foilstack/web/templates/base.html +91 -0
  69. foilstack/web/templates/card.html +295 -0
  70. foilstack/web/templates/import.html +484 -0
  71. foilstack/web/templates/inventory.html +177 -0
  72. foilstack/web/templates/landing.html +208 -0
  73. foilstack/web/templates/listings.html +124 -0
  74. foilstack/web/templates/login.html +78 -0
  75. foilstack/web/templates/plugins.html +90 -0
  76. foilstack-0.2.0.dist-info/METADATA +241 -0
  77. foilstack-0.2.0.dist-info/RECORD +80 -0
  78. foilstack-0.2.0.dist-info/WHEEL +4 -0
  79. foilstack-0.2.0.dist-info/entry_points.txt +2 -0
  80. foilstack-0.2.0.dist-info/licenses/LICENSE +661 -0
foilstack/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
foilstack/cli.py ADDED
@@ -0,0 +1,634 @@
1
+ """Command line: ingest a catalogue, then encode it.
2
+
3
+ Two commands and not one, because they fail for different reasons and take
4
+ wildly different amounts of time. Ingest is a few minutes of HTTP against the
5
+ upstream source. Encoding downloads every reference image and pushes it through
6
+ the model, which is the long pole. Separating them means a network blip in hour
7
+ two does not cost you hour one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import datetime as dt
15
+ import logging
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+ from sqlalchemy import func, select, text
22
+
23
+ from foilstack import __version__, db
24
+ from foilstack.config import get_settings
25
+ from foilstack.embedding import embed_image
26
+ from foilstack.plugins import export_plugins, source_plugins
27
+
28
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
29
+ # Distinguishes "upstream has no image for this card", which is ordinary and
30
+ # permanent, from "we could not get it", which is worth another go later.
31
+ MISSING = object()
32
+
33
+
34
+ def image_is_permanently_missing(status_code: int, body: bytes) -> bool:
35
+ """Whether upstream has told us, for good, that there is no image here.
36
+
37
+ Two ways it says so. A 4xx is the obvious one. The other is a 200 carrying
38
+ an empty body — `image/jpeg`, zero bytes — which a handful of products
39
+ answer with and which is just as permanent, but arrives looking like
40
+ success. Untreated it reached the encoder, failed to decode, and was
41
+ retried four times with backoff on every run, forever.
42
+
43
+ 429 is excluded even though it is a 4xx. The caller handles it before ever
44
+ reaching here, so in practice it never arrives — but "too many requests" is
45
+ the opposite of a permanent answer, and a policy function that is only
46
+ correct because of what its one caller happens to do first is a trap for
47
+ the second caller.
48
+
49
+ Deliberately not a method on anything: it is the whole retry policy for
50
+ catalogue images in two lines, and it is worth being able to read and test
51
+ it without a network.
52
+ """
53
+ if status_code == 429:
54
+ return False
55
+ return (400 <= status_code < 500) or (status_code == 200 and not body)
56
+
57
+
58
+ IMAGE_HEADERS = {
59
+ "User-Agent": f"foilstack/{__version__} (+https://github.com/foilstack/foilstack)",
60
+ "Accept": "image/*,*/*",
61
+ }
62
+
63
+ log = logging.getLogger("foilstack")
64
+
65
+
66
+ async def cmd_ingest(args) -> int:
67
+ settings = get_settings()
68
+ db.init(settings.database_url)
69
+ plugins = source_plugins()
70
+ plugin = plugins.get(args.source)
71
+ if plugin is None:
72
+ log.error("unknown source %r; available: %s", args.source, sorted(plugins))
73
+ return 2
74
+
75
+ if args.game or getattr(args, "set", None):
76
+ plugin = type(plugin)(game=args.game or plugin.game, set_code=getattr(args, "set", None))
77
+
78
+ session = db.session()
79
+ seen = 0
80
+ async for record in plugin.fetch(limit=args.limit):
81
+ namespaced = f"{plugin.name}:{record.source_id}"
82
+ existing = session.scalar(select(db.Card).where(db.Card.source_id == namespaced))
83
+ if existing is None:
84
+ session.add(
85
+ db.Card(
86
+ source=plugin.name,
87
+ source_id=namespaced,
88
+ name=record.name,
89
+ game=record.game,
90
+ set_name=record.set_name,
91
+ number=record.number,
92
+ variant=record.variant,
93
+ image_url=record.image_url,
94
+ market=record.market,
95
+ currency=record.currency,
96
+ )
97
+ )
98
+ else:
99
+ existing.market = record.market
100
+ existing.image_url = record.image_url
101
+ seen += 1
102
+ if seen % 250 == 0:
103
+ session.commit()
104
+ log.info(" %s cards", seen)
105
+ session.commit()
106
+ log.info("ingested %s cards from %s", seen, plugin.name)
107
+ return 0
108
+
109
+
110
+ async def cmd_embed(args) -> int:
111
+ """Encode every catalogue image that this encoder has not already done.
112
+
113
+ Resumable by default, which matters because this is the long pole: on a
114
+ real catalogue it is hours, and a network blip an hour in should cost the
115
+ blip, not the hour. `--all` forces a re-encode, which is what a model
116
+ change requires — vectors from two encoders are not comparable.
117
+ """
118
+ settings = get_settings()
119
+ db.init(settings.database_url)
120
+ session = db.session()
121
+
122
+ query = select(db.Card).where(db.Card.image_url.is_not(None))
123
+ if not args.retry_missing:
124
+ # Cards upstream has already said it has no image for. Skipped by
125
+ # default because that answer does not change: re-asking costs a
126
+ # request per card per run and returns the same 404.
127
+ query = query.where(db.Card.image_missing_at.is_(None))
128
+ if not args.all:
129
+ done = select(db.CardEmbedding.card_id).where(
130
+ db.CardEmbedding.model == settings.embed_model
131
+ )
132
+ query = query.where(db.Card.id.not_in(done))
133
+ cards = session.scalars(query.order_by(db.Card.id)).all()
134
+ if args.limit:
135
+ cards = cards[: args.limit]
136
+ if not cards:
137
+ total = session.scalar(select(func.count(db.Card.id))) or 0
138
+ if total == 0:
139
+ log.error("no cards with images — run `ingest` first")
140
+ return 2
141
+ skipped = (
142
+ session.scalar(
143
+ select(func.count(db.Card.id)).where(db.Card.image_missing_at.is_not(None))
144
+ )
145
+ or 0
146
+ )
147
+ log.info("nothing to do — all %s cards are encoded with %s", total, settings.embed_model)
148
+ if skipped:
149
+ # Named rather than left as a silent gap between the catalogue
150
+ # count and the vector count, which otherwise looks like a
151
+ # half-finished encode that never finishes.
152
+ log.info(" (%s have no image upstream and are skipped)", skipped)
153
+ return 0
154
+
155
+ log.info(
156
+ "encoding %s cards with %s (%s at a time)",
157
+ len(cards),
158
+ settings.embed_model,
159
+ args.concurrency,
160
+ )
161
+ written = failed = missing = processed = 0
162
+ # Bounded, not unbounded. The reference images come from a CDN rather than
163
+ # from the catalogue API, so the pacing that applies to `sync-prices` is not
164
+ # the constraint here — but "not the constraint" is not a licence to open a
165
+ # thousand sockets at somebody else's expense. A small pool overlaps the
166
+ # download of the next card with the encoding of the current one, which is
167
+ # where nearly all of the wall clock goes.
168
+ limiter = asyncio.Semaphore(max(1, args.concurrency))
169
+
170
+ async def fetch_and_encode(client, card):
171
+ """One card, with the manners a shared CDN deserves.
172
+
173
+ Retries what might work and gives up immediately on what will not. A
174
+ catalogue this size is full of promo and staff entries that upstream
175
+ has no image for at all — those answer 403 or 404, forever, and
176
+ retrying one four times with backoff costs seven seconds to learn
177
+ nothing. Thousands of them turned a run that should take an hour into
178
+ one that had not finished a tenth of it.
179
+
180
+ "Will not work" includes a 200 carrying an empty body, which is the
181
+ same permanent answer dressed as success.
182
+ """
183
+ async with limiter:
184
+ for attempt in range(4):
185
+ try:
186
+ image = await client.get(card.image_url, headers=IMAGE_HEADERS)
187
+ if image.status_code in (429, 502, 503, 504):
188
+ # Honour Retry-After when they send one; they know
189
+ # better than a guess does.
190
+ wait = float(image.headers.get("Retry-After") or 2**attempt)
191
+ log.warning(
192
+ " %s on %s — waiting %.0fs", image.status_code, card.name, wait
193
+ )
194
+ await asyncio.sleep(min(wait, 60))
195
+ continue
196
+ if image_is_permanently_missing(image.status_code, image.content):
197
+ # Not a warning: on this catalogue it is thousands of
198
+ # lines saying the same ordinary thing, and the count
199
+ # at the end says it once.
200
+ log.debug(" no image upstream for %s (%s)", card.name, image.status_code)
201
+ return card.id, MISSING
202
+ image.raise_for_status()
203
+ vector = await embed_image(settings.embedder_url, image.content)
204
+ except Exception as exc: # noqa: BLE001 - one bad image must not end the run
205
+ if attempt == 3:
206
+ log.warning(" skip %s (%s)", card.name, type(exc).__name__)
207
+ return card.id, None
208
+ await asyncio.sleep(2**attempt)
209
+ continue
210
+ return card.id, vector
211
+ return card.id, None
212
+
213
+ async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
214
+ pending = [asyncio.create_task(fetch_and_encode(client, c)) for c in cards]
215
+ for coro in asyncio.as_completed(pending):
216
+ card_id, vector = await coro
217
+ processed += 1
218
+ if vector is MISSING:
219
+ missing += 1
220
+ # Recorded, not just counted. This is the whole point of the
221
+ # sentinel: a permanent answer is worth keeping.
222
+ card = session.get(db.Card, card_id)
223
+ if card is not None:
224
+ card.image_missing_at = dt.datetime.now(dt.UTC)
225
+ elif vector is None:
226
+ failed += 1
227
+ else:
228
+ session.merge(
229
+ db.CardEmbedding(
230
+ card_id=card_id,
231
+ embedding=[float(x) for x in vector],
232
+ model=settings.embed_model,
233
+ )
234
+ )
235
+ written += 1
236
+ # Committed in batches so an interrupted run keeps its work: the
237
+ # point of resumability is lost if everything lives in one
238
+ # transaction that a Ctrl-C rolls back.
239
+ if processed % 100 == 0:
240
+ session.commit()
241
+ log.info(
242
+ " encoded %s/%s (%s no image, %s failed)",
243
+ processed,
244
+ len(cards),
245
+ missing,
246
+ failed,
247
+ )
248
+
249
+ session.commit()
250
+ log.info("wrote %s vectors (%s have no image upstream, %s failed)", written, missing, failed)
251
+ if missing:
252
+ log.info(" those %s will be skipped from now on — `--retry-missing` to ask again", missing)
253
+ # A batch where every remaining card simply has no image upstream is a
254
+ # finished job, not a failed one — and on a resumed run over a large
255
+ # catalogue that is exactly what the last batch looks like.
256
+ return 0 if (written or missing) else 1
257
+
258
+
259
+ async def cmd_sets(args) -> int:
260
+ """List the sets a source can fetch, so `--set` is a choice not a guess."""
261
+ plugins = source_plugins()
262
+ plugin = plugins.get(args.source)
263
+ if plugin is None:
264
+ log.error("unknown source %r; available: %s", args.source, sorted(plugins))
265
+ return 2
266
+ plugin = type(plugin)(game=args.game)
267
+ if not hasattr(plugin, "sets"):
268
+ log.error("%s does not publish a set list", plugin.name)
269
+ return 2
270
+
271
+ rows = await plugin.sets()
272
+ log.info("%-10s %-46s %s", "CODE", "SET", "RELEASED")
273
+ for row in rows:
274
+ if args.contains and args.contains.lower() not in (row["name"] or "").lower():
275
+ continue
276
+ log.info(
277
+ "%-10s %-46s %s",
278
+ row["abbreviation"] or row["group_id"],
279
+ (row["name"] or "")[:46],
280
+ row["published_on"],
281
+ )
282
+ log.info("%s sets in %s", len(rows), args.game)
283
+ return 0
284
+
285
+
286
+ async def cmd_rematch(args) -> int:
287
+ """Re-run matching over scans already imported.
288
+
289
+ The reason this exists: ingesting the set a seller actually collects is the
290
+ fix for "everything matched the wrong game", and without this the only way
291
+ to benefit from a newly ingested set is to upload the same archive again.
292
+ The images are already on disk and already paid for.
293
+ """
294
+ settings = get_settings()
295
+ db.init(settings.database_url)
296
+ session = db.session()
297
+
298
+ from foilstack.importing import rematch_scan
299
+
300
+ query = select(db.Scan)
301
+ if args.status != "all":
302
+ query = query.where(db.Scan.status == args.status)
303
+ if args.user:
304
+ user = session.scalar(select(db.User).where(db.User.email == args.user.lower()))
305
+ if user is None:
306
+ log.error("no account with email %s", args.user)
307
+ return 2
308
+ query = query.where(db.Scan.user_id == user.id)
309
+ scans = session.scalars(query.order_by(db.Scan.id)).all()
310
+ if not scans:
311
+ log.info("no scans with status %r", args.status)
312
+ return 0
313
+
314
+ log.info("re-matching %s scans", len(scans))
315
+ changed = failed = 0
316
+ for i, scan in enumerate(scans, 1):
317
+ try:
318
+ if await rematch_scan(session, scan, settings):
319
+ changed += 1
320
+ except Exception as exc: # noqa: BLE001 - one bad scan must not end the run
321
+ log.warning(" skip %s (%s)", scan.filename, type(exc).__name__)
322
+ failed += 1
323
+ if i % 25 == 0:
324
+ session.commit()
325
+ log.info(" %s/%s", i, len(scans))
326
+ session.commit()
327
+ log.info("re-matched %s scans (%s now have a match, %s failed)", len(scans), changed, failed)
328
+ return 0
329
+
330
+
331
+ async def cmd_sync_prices(args) -> int:
332
+ """Refresh prices for one game, or for every game in the catalogue.
333
+
334
+ `--game all` exists because the alternative was a list someone has to
335
+ remember to update. Prices were synced for whatever games were named in an
336
+ environment variable, so ingesting a game and forgetting to add it there
337
+ left it priced at whatever `ingest` first saw — for good, with nothing
338
+ anywhere saying so. What should be synced is not a setting; it is whatever
339
+ has been ingested, which the database already knows.
340
+ """
341
+ settings = get_settings()
342
+ db.init(settings.database_url)
343
+ session = db.session()
344
+
345
+ plugins = source_plugins()
346
+ plugin = plugins.get(args.source)
347
+ if plugin is None:
348
+ log.error("unknown source %r; available: %s", args.source, sorted(plugins))
349
+ return 2
350
+ if not hasattr(plugin, "fetch_prices"):
351
+ log.error("%s does not publish prices", plugin.name)
352
+ return 2
353
+
354
+ if args.game == "all":
355
+ games = list(
356
+ session.scalars(
357
+ select(db.Card.game)
358
+ .where(db.Card.source == plugin.name)
359
+ .distinct()
360
+ .order_by(db.Card.game)
361
+ ).all()
362
+ )
363
+ if not games:
364
+ log.error("no %s cards ingested yet — run `foilstack ingest` first", plugin.name)
365
+ return 2
366
+ log.info("syncing every ingested game: %s", " ".join(games))
367
+ else:
368
+ games = [args.game]
369
+
370
+ # Read once and shared across games. The build timestamp is one file
371
+ # covering the whole source, so asking for it per game would multiply the
372
+ # cheapest part of this by the number of catalogues for no new information.
373
+ stamp = None
374
+ probe = type(plugin)(game=games[0], set_code=args.set)
375
+ if hasattr(probe, "last_updated"):
376
+ try:
377
+ stamp = await probe.last_updated()
378
+ except Exception as exc: # noqa: BLE001 - a missing stamp is not fatal
379
+ log.warning("could not read upstream timestamp (%s)", type(exc).__name__)
380
+
381
+ worst = 0
382
+ for game in games:
383
+ code = await _sync_one_game(session, plugin, game, stamp, args)
384
+ worst = max(worst, code)
385
+ return worst
386
+
387
+
388
+ async def _sync_one_game(session, plugin, game: str, stamp: str | None, args) -> int:
389
+ """Refresh prices for one game.
390
+
391
+ Follows TCGCSV's stated usage guidelines rather than polling blindly: their
392
+ files rebuild exactly once a day, they publish the build timestamp, and
393
+ they ask that a full sync run only when it is newer than your last pull.
394
+ So on an unchanged stamp this returns having made no requests at all.
395
+
396
+ History is appended only where a number actually changed. A catalogue that
397
+ has not moved writes nothing.
398
+ """
399
+ plugin = type(plugin)(game=game, set_code=args.set)
400
+
401
+ # Keyed per game, not per source. One row for the whole source meant a
402
+ # successful Magic sync recorded the upstream stamp globally, and every
403
+ # other catalogue then saw its own first run as "already up to date" and
404
+ # never pulled a price.
405
+ kind = f"prices:{game}"
406
+ state = session.get(db.SyncState, (plugin.name, kind))
407
+ if stamp and state is not None and state.upstream_stamp == stamp and not args.force:
408
+ log.info("%s: upstream unchanged since %s — nothing to do", game, stamp)
409
+ state.last_run_at = _now()
410
+ session.commit()
411
+ return 0
412
+ log.info("syncing %s prices (upstream build %s)", game, stamp or "unknown")
413
+
414
+ # Map upstream ids to our card rows once, rather than querying per price.
415
+ cards = {
416
+ source_id.split(":", 1)[-1]: card_id
417
+ for card_id, source_id in session.execute(
418
+ select(db.Card.id, db.Card.source_id).where(
419
+ db.Card.source == plugin.name, db.Card.game == game
420
+ )
421
+ ).all()
422
+ }
423
+ if not cards:
424
+ log.error("no %s %s cards ingested yet — run `foilstack ingest` first", plugin.name, game)
425
+ return 2
426
+
427
+ today = dt.date.today()
428
+ seen = changed = 0
429
+ async for record in plugin.fetch_prices():
430
+ card_id = cards.get(record.source_id)
431
+ if card_id is None:
432
+ continue # a printing we have not ingested
433
+ seen += 1
434
+ current = session.get(db.CardPrice, (card_id, record.sub_type))
435
+ fields = ("market", "low", "mid", "high")
436
+ incoming = {f: getattr(record, f) for f in fields}
437
+
438
+ if current is None:
439
+ session.add(db.CardPrice(card_id=card_id, sub_type=record.sub_type, **incoming))
440
+ moved = True
441
+ else:
442
+ moved = any(getattr(current, f) != incoming[f] for f in fields)
443
+ for f in fields:
444
+ setattr(current, f, incoming[f])
445
+
446
+ if moved:
447
+ # `merge` rather than `add`: a second run on the same day should
448
+ # correct that day's reading, not collide with it.
449
+ session.merge(
450
+ db.CardPriceHistory(
451
+ card_id=card_id,
452
+ sub_type=record.sub_type,
453
+ recorded_on=today,
454
+ **incoming,
455
+ )
456
+ )
457
+ changed += 1
458
+ if seen % 500 == 0:
459
+ session.commit()
460
+ log.info(" %s printings (%s changed)", seen, changed)
461
+
462
+ # Keep the card's headline price current too. It is the fallback used when
463
+ # a printing has no row of its own, and left alone it would still hold
464
+ # whatever `ingest` saw months ago — a stale number quietly standing in for
465
+ # a fresh one is worse than no number at all.
466
+ session.execute(
467
+ text("""
468
+ UPDATE cards c SET market = p.market, updated_at = now()
469
+ FROM (
470
+ SELECT DISTINCT ON (card_id) card_id, market
471
+ FROM card_prices
472
+ WHERE market IS NOT NULL
473
+ ORDER BY card_id, (sub_type ILIKE '%foil%'), market
474
+ ) p
475
+ WHERE p.card_id = c.id AND c.source = :source AND c.game = :game
476
+ AND (c.market IS DISTINCT FROM p.market)
477
+ """),
478
+ {"source": plugin.name, "game": game},
479
+ )
480
+
481
+ session.merge(
482
+ db.SyncState(
483
+ source=plugin.name,
484
+ kind=kind,
485
+ upstream_stamp=stamp,
486
+ last_run_at=_now(),
487
+ rows_changed=changed,
488
+ message=f"{seen} printings, {changed} changed",
489
+ )
490
+ )
491
+ session.commit()
492
+ log.info("%s: synced %s printings, %s price changes recorded", game, seen, changed)
493
+ return 0
494
+
495
+
496
+ def _now() -> dt.datetime:
497
+ return dt.datetime.now(dt.UTC)
498
+
499
+
500
+ def cmd_migrate(args) -> int:
501
+ """Bring the database up to the current schema.
502
+
503
+ Exists so a pip-installed foilstack can create its own tables. The compose
504
+ deployment runs `alembic upgrade head` from the repository, which needs
505
+ `alembic.ini` and a migrations directory beside it — neither of which a
506
+ wheel installed into site-packages has any reason to have.
507
+
508
+ The revisions therefore live inside the package, at
509
+ `foilstack/migrations`, and this points alembic at wherever that turned out
510
+ to be. `env.py` already reads the URL from `foilstack.config`, so there is
511
+ nothing for a config file to carry.
512
+ """
513
+ from alembic import command
514
+ from alembic.config import Config
515
+
516
+ settings = get_settings()
517
+ here = Path(__file__).resolve().parent / "migrations"
518
+ if not here.is_dir():
519
+ log.error("migrations are missing from the installed package (%s)", here)
520
+ return 2
521
+
522
+ cfg = Config()
523
+ cfg.set_main_option("script_location", str(here))
524
+ cfg.set_main_option("sqlalchemy.url", settings.database_url)
525
+ log.info("migrating %s", settings.database_url.rsplit("@", 1)[-1])
526
+ command.upgrade(cfg, args.revision)
527
+ log.info("schema is up to date")
528
+ return 0
529
+
530
+
531
+ def cmd_plugins(_args) -> int:
532
+ sources = source_plugins()
533
+ exports = export_plugins()
534
+ print("sources:")
535
+ for name, plugin in sources.items():
536
+ print(f" {name:12s} games: {', '.join(plugin.games)}")
537
+ print("exports:")
538
+ for name, spec in exports.items():
539
+ print(f" {name:12s} {len(spec.columns)} columns -> {spec.filename}")
540
+ return 0
541
+
542
+
543
+ def main(argv: list[str] | None = None) -> int:
544
+ parser = argparse.ArgumentParser(prog="foilstack")
545
+ sub = parser.add_subparsers(dest="command", required=True)
546
+
547
+ p_ingest = sub.add_parser("ingest", help="pull a catalogue from a source plugin")
548
+ p_ingest.add_argument("--source", default="tcgcsv")
549
+ p_ingest.add_argument("--game", default=None)
550
+ p_ingest.add_argument(
551
+ "--set",
552
+ default=None,
553
+ help="one set only, by code or name (see `foilstack sets`)",
554
+ )
555
+ p_ingest.add_argument("--limit", type=int, default=None)
556
+ p_ingest.set_defaults(fn=cmd_ingest, is_async=True)
557
+
558
+ p_embed = sub.add_parser("embed", help="encode catalogue images into vectors")
559
+ p_embed.add_argument("--limit", type=int, default=None)
560
+ p_embed.add_argument(
561
+ "--concurrency",
562
+ type=int,
563
+ default=int(os.getenv("FOILSTACK_EMBED_CONCURRENCY", "8")),
564
+ help="cards in flight at once (default 8)",
565
+ )
566
+ p_embed.add_argument(
567
+ "--all",
568
+ action="store_true",
569
+ help="re-encode cards that already have a vector for this model",
570
+ )
571
+ p_embed.add_argument(
572
+ "--retry-missing",
573
+ action="store_true",
574
+ help="also try cards upstream previously had no image for",
575
+ )
576
+ p_embed.set_defaults(fn=cmd_embed, is_async=True)
577
+
578
+ p_sets = sub.add_parser("sets", help="list the sets a source can fetch")
579
+ p_sets.add_argument("--source", default="tcgcsv")
580
+ p_sets.add_argument("--game", default="pokemon")
581
+ p_sets.add_argument("--contains", default=None, help="filter by name substring")
582
+ p_sets.set_defaults(fn=cmd_sets, is_async=True)
583
+
584
+ p_rematch = sub.add_parser(
585
+ "rematch",
586
+ help="re-run matching over scans already imported",
587
+ )
588
+ p_rematch.add_argument(
589
+ "--status",
590
+ default="unmatched",
591
+ choices=["unmatched", "pending", "error", "all"],
592
+ help="which scans to redo (default: unmatched)",
593
+ )
594
+ p_rematch.add_argument("--user", default=None, help="limit to one account's scans")
595
+ p_rematch.set_defaults(fn=cmd_rematch, is_async=True)
596
+
597
+ p_sync = sub.add_parser(
598
+ "sync-prices",
599
+ help="refresh prices and record the ones that changed",
600
+ )
601
+ p_sync.add_argument("--source", default="tcgcsv")
602
+ p_sync.add_argument(
603
+ "--game",
604
+ default="all",
605
+ help="one game, or 'all' for every game in the catalogue (default)",
606
+ )
607
+ p_sync.add_argument("--set", default=None, help="one set only")
608
+ p_sync.add_argument(
609
+ "--force",
610
+ action="store_true",
611
+ help="sync even if upstream reports no new build",
612
+ )
613
+ p_sync.set_defaults(fn=cmd_sync_prices, is_async=True)
614
+
615
+ p_migrate = sub.add_parser("migrate", help="create or update the database schema")
616
+ p_migrate.add_argument(
617
+ "revision",
618
+ nargs="?",
619
+ default="head",
620
+ help="target revision (default: head)",
621
+ )
622
+ p_migrate.set_defaults(fn=cmd_migrate, is_async=False)
623
+
624
+ p_plugins = sub.add_parser("plugins", help="list installed plugins")
625
+ p_plugins.set_defaults(fn=cmd_plugins, is_async=False)
626
+
627
+ args = parser.parse_args(argv)
628
+ if getattr(args, "is_async", False):
629
+ return asyncio.run(args.fn(args))
630
+ return args.fn(args)
631
+
632
+
633
+ if __name__ == "__main__":
634
+ sys.exit(main())