slab-cli 0.14.0__tar.gz → 0.16.0__tar.gz

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 (34) hide show
  1. {slab_cli-0.14.0 → slab_cli-0.16.0}/PKG-INFO +2 -2
  2. {slab_cli-0.14.0 → slab_cli-0.16.0}/pyproject.toml +3 -2
  3. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/browse.py +83 -28
  4. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/catalog.py +20 -3
  5. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/display.py +77 -11
  6. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/flags.py +6 -1
  7. {slab_cli-0.14.0 → slab_cli-0.16.0}/README.md +0 -0
  8. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/__init__.py +0 -0
  9. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/banner.py +0 -0
  10. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/client.py +0 -0
  11. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/__init__.py +0 -0
  12. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/breaks.py +0 -0
  13. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/collection.py +0 -0
  14. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/custom_sets.py +0 -0
  15. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/export.py +0 -0
  16. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/lots.py +0 -0
  17. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/pricing.py +0 -0
  18. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/registry.py +0 -0
  19. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/setup.py +0 -0
  20. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/commands/update.py +0 -0
  21. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/config.py +0 -0
  22. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/context.py +0 -0
  23. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/help.py +0 -0
  24. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/main.py +0 -0
  25. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/platforms/__init__.py +0 -0
  28. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/platforms/base.py +0 -0
  29. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/platforms/posix.py +0 -0
  30. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/platforms/windows.py +0 -0
  31. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/prompts.py +0 -0
  32. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.14.0 → slab_cli-0.16.0}/src/slab_cli/updates.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
- Requires-Dist: slab-schemas>=0.1.0
6
+ Requires-Dist: slab-schemas>=0.31.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.14.0"
3
+ version = "0.16.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
7
7
  requires-python = ">=3.13"
8
8
  dependencies = [
9
- "slab-schemas>=0.1.0",
9
+ # 0.31: CardSearchQuery.collapse_parallels — `card search` sends it on every query.
10
+ "slab-schemas>=0.31.0",
10
11
  "httpx>=0.27",
11
12
  "rich>=13.0",
12
13
  "InquirerPy>=0.3",
@@ -4,11 +4,17 @@
4
4
  keyboard-driven viewport: ↑/↓ (or j/k) move the highlight, PgUp/PgDn jump, `/` type-to-filters
5
5
  across every column, and q leaves — printing the plain table on the way out so the data
6
6
  survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
7
- detail view REPLACES the list as a scrollable page (Esc/q climbs back up to the list, same row
7
+ detail view REPLACES the list as a scrollable page (Esc climbs back up to the list, same row
8
8
  highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
9
9
  isn't an interactive terminal (a pipe, CI, redirection) it degrades to the static table, so
10
10
  `slab ... | grep` keeps working unchanged.
11
11
 
12
+ **A drill-down may be another list.** When `detail` returns a `Page` instead of printing, that
13
+ page is pushed as its own list screen with its own columns and its own `detail` — so a search can
14
+ show one row per card, its parallels one level down, and that printing's market view one level
15
+ below THAT, without any screen knowing how deep it sits. Esc pops exactly one level; q leaves the
16
+ whole stack at once (and prints the top-level table as the receipt).
17
+
12
18
  Reading keys is the one OS-specific part, and this module doesn't do it: `platforms.current()`
13
19
  hands back a backend whose `key_source()` yields a `read()` returning platform-neutral key NAMES
14
20
  ('up', 'enter', …). Everything below is written against those names, so it behaves identically on
@@ -21,6 +27,7 @@ the same spec drives the interactive view, the static fallback, and the exit rec
21
27
  from __future__ import annotations
22
28
 
23
29
  import sys
30
+ from dataclasses import dataclass, field
24
31
  from typing import Any, Callable, Sequence
25
32
 
26
33
  from rich.console import Group
@@ -33,6 +40,22 @@ from .theme import console, heading, slab_table
33
40
  # One row: the domain object Enter should act on, plus its cell markup strings (one per column).
34
41
  Row = tuple[Any, Sequence[str]]
35
42
 
43
+
44
+ @dataclass
45
+ class Page:
46
+ """A list screen. Return one from a `detail` callback to drill from a list INTO another list
47
+ (instead of into a printed page) — same columns/rows/hint contract the root browse takes, so a
48
+ level is described the same way whether it's the first screen or the third."""
49
+
50
+ columns: list[tuple[str, dict]]
51
+ rows: list[Row]
52
+ title: str
53
+ note: str | None = None
54
+ hint: str = "open"
55
+ footer: str | None = None
56
+ detail: Callable[[Any], "Page | None"] | None = None
57
+ sel: int = field(default=0, repr=False) # where this level's highlight sits, kept across pops
58
+
36
59
  # The highlight bar — a slate a step brighter than the card border, so the selected row reads as
37
60
  # lit without fighting the cells' own foreground colors (markup styles set color only, never bg).
38
61
  _SELECTED_BG = "on #223052"
@@ -149,9 +172,13 @@ def _run_list(
149
172
  note: str | None,
150
173
  hint: str,
151
174
  start: int,
152
- ) -> tuple[Any | None, int]:
153
- """The list screen. Returns (chosen item, its index in `rows`) on Enter, or (None, last
154
- highlighted index) on quit — the index lets a drill-down return to the same row."""
175
+ ) -> tuple[Any | None, int, str]:
176
+ """The list screen. Returns (chosen item, its index in `rows`, action).
177
+
178
+ The action is "select" (Enter — item is set), "back" (Esc: up one level) or "quit" (q: leave
179
+ browsing entirely). The index is where the highlight ended up, so a drill-down returns to the
180
+ same row; Esc and q are different answers because with nested lists only one of them means
181
+ "I'm done", and collapsing them would strand a user three levels deep."""
155
182
  plains = [row_plain(cells) for _item, cells in rows]
156
183
  query, typing = "", False
157
184
  sel = min(max(start, 0), len(rows) - 1)
@@ -198,14 +225,14 @@ def _run_list(
198
225
  if typing:
199
226
  typing = False # keep the filter, back to moving
200
227
  elif view:
201
- return view[sel][0], row_index(view[sel])
228
+ return view[sel][0], row_index(view[sel]), "select"
202
229
  elif key == "esc":
203
230
  if typing or query:
204
231
  typing, query = False, ""
205
232
  else:
206
- return None, sel
233
+ return None, (row_index(view[sel]) if view else 0), "back"
207
234
  elif key == "quit" or (not typing and key == "q"):
208
- return None, (row_index(view[sel]) if view else 0)
235
+ return None, (row_index(view[sel]) if view else 0), "quit"
209
236
  elif typing:
210
237
  if key == "backspace":
211
238
  if query:
@@ -260,11 +287,9 @@ def _run_pager(read: KeyReader, lines: list[Text], *, back_hint: str) -> None:
260
287
  return
261
288
 
262
289
 
263
- def _capture_lines(render: Callable[[], None]) -> list[Text]:
264
- """Run a print-to-console renderer and capture its output as styled lines for the pager."""
265
- with console.capture() as cap:
266
- render()
267
- lines = [Text.from_ansi(line) for line in cap.get().splitlines()]
290
+ def _lines_from(captured: str) -> list[Text]:
291
+ """Captured console output as trimmed styled lines — what the pager scrolls."""
292
+ lines = [Text.from_ansi(line) for line in captured.splitlines()]
268
293
  while lines and not lines[-1].plain.strip():
269
294
  lines.pop()
270
295
  while lines and not lines[0].plain.strip():
@@ -272,6 +297,20 @@ def _capture_lines(render: Callable[[], None]) -> list[Text]:
272
297
  return lines or [Text.from_markup("[slab.dim]Nothing to show.[/]")]
273
298
 
274
299
 
300
+ def _open(detail: Callable[[Any], "Page | None"], item: Any) -> tuple["Page | None", list[Text]]:
301
+ """Run a drill-down and report which kind it was: `(page, lines)`.
302
+
303
+ A detail callback either returns a Page (another list) or prints its view and returns None, and
304
+ it can only say which by running — so the console is captured either way and the lines are kept
305
+ only for the printing kind. An empty Page falls back to its captured lines too, so a drill-down
306
+ that found nothing shows a page saying so rather than a dead list screen."""
307
+ with console.capture() as cap:
308
+ page = detail(item)
309
+ if page is not None and page.rows:
310
+ return page, []
311
+ return None, _lines_from(cap.get())
312
+
313
+
275
314
  def browse(
276
315
  columns: list[tuple[str, dict]],
277
316
  rows: list[Row],
@@ -281,19 +320,23 @@ def browse(
281
320
  hint: str = "open",
282
321
  footer: str | None = None,
283
322
  start: int = 0,
284
- detail: Callable[[Any], None] | None = None,
323
+ detail: Callable[[Any], "Page | None"] | None = None,
285
324
  ) -> Any | None:
286
325
  """Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
287
326
  takes; each row is `(item, cells)` with one markup string per column. `hint` names what
288
327
  Enter does in the footer (e.g. "market view"); `start` pre-selects a row.
289
328
 
290
- With a `detail` callback, Enter drills down: `detail(item)` runs (it may fetch and must
291
- print to the console), its output replaces the list as a scrollable page, and leaving the
292
- page returns to the list on the same row — the loop lives here, and browse returns None
293
- when the user finally quits. Without `detail`, Enter returns the chosen item directly.
329
+ With a `detail` callback, Enter drills down and `detail(item)` decides into WHAT:
294
330
 
295
- Quitting from the list prints the plain table (with `footer`) as a scrollback receipt. On
296
- a non-interactive stdout/stdin the static table is printed instead and None returned.
331
+ * it prints to the console and returns None — the output replaces the list as a scrollable
332
+ page (the leaf view: a market report, a break's card);
333
+ * it returns a `Page` — that page becomes another list screen, with its own `detail`.
334
+
335
+ Either way Esc climbs back one level, landing on the row it left from, and q leaves the whole
336
+ stack. Without a `detail`, Enter returns the chosen item to the caller instead.
337
+
338
+ Quitting prints the top-level table (with `footer`) as a scrollback receipt. On a
339
+ non-interactive stdout/stdin the static table is printed instead and None returned.
297
340
  """
298
341
  if not rows:
299
342
  return None
@@ -301,24 +344,36 @@ def browse(
301
344
  print_table(columns, rows, title=title, note=note, footer=footer)
302
345
  return None
303
346
 
347
+ root = Page(columns, rows, title, note, hint, footer, detail, sel=start)
304
348
  picked: Any | None = None
305
- sel = start
306
349
  try:
307
350
  with current_platform().key_source() as read:
308
- while True:
309
- item, sel = _run_list(read, columns, rows, title=title, note=note,
310
- hint=hint, start=sel)
311
- if item is None:
351
+ stack = [root]
352
+ while stack:
353
+ page = stack[-1]
354
+ item, page.sel, action = _run_list(
355
+ read, page.columns, page.rows,
356
+ title=page.title, note=page.note, hint=page.hint, start=page.sel,
357
+ )
358
+ if action == "quit":
312
359
  break
313
- if detail is None:
360
+ if action == "back":
361
+ stack.pop()
362
+ continue
363
+ if page.detail is None:
314
364
  picked = item
315
365
  break
316
- _run_pager(read, _capture_lines(lambda: detail(item)), back_hint="back to list")
366
+ drilled, lines = _open(page.detail, item)
367
+ if drilled is not None and drilled.rows:
368
+ stack.append(drilled)
369
+ else:
370
+ _run_pager(read, lines, back_hint="back to list")
317
371
  except KeyboardInterrupt:
318
372
  pass
319
373
 
320
374
  if picked is not None:
321
375
  return picked
322
- # Quit: leave the classic table behind so the results live on in scrollback.
323
- print_table(columns, rows, title=title, note=note, footer=footer)
376
+ # Quit: leave the classic table behind so the results live on in scrollback. It's the TOP
377
+ # level's table however deep the user was — that's the list they asked for.
378
+ print_table(root.columns, root.rows, title=root.title, note=root.note, footer=root.footer)
324
379
  return None
@@ -34,25 +34,42 @@ def cmd_search(args: list[str]) -> None:
34
34
  slab card search --brand "Upper Deck" --run-min 1 --run-max 99 # print-run range
35
35
  slab card search McDavid --auto --parallel --sort -year # stack modifiers, newest first
36
36
  slab card search --subset "Young Guns" --rookie --limit 50
37
+ slab card search Coffey --printings # every finish as its own row
37
38
 
38
39
  Every flag combines with AND; repeat an any-of flag (--team, --brand, --finish…) to widen it.
39
40
 
40
41
  Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
41
42
 
42
- In a terminal the results are a scrollable list — Enter drills into the highlighted
43
- card's market view, Esc climbs back. Piped output prints the plain table.
43
+ Results are one row per CARD, however many parallels it has — a player with a 17-finish
44
+ rainbow would otherwise bury his other cards under one of them. In a terminal that list is
45
+ scrollable and drills down: Enter opens the card's printings (base + every parallel, with
46
+ print run and odds), Enter again opens that printing's market view, Esc climbs back one level
47
+ and q leaves. `--printings` flattens it back to one row per printing (finish, run, and odds
48
+ on every row) — what you want when the finish IS the thing you're scanning for, and the form
49
+ to pipe when a script wants every parallel. Piped output prints the plain table of whichever
50
+ shape you asked for; the row shape doesn't change just because output is a terminal.
44
51
  """
45
52
  if not args:
46
53
  args = [ask_text("Search cards:")]
47
54
 
48
55
  values, q = parse_flags(args, CARD_SEARCH_FLAGS)
49
56
  values.setdefault("limit", 25)
57
+ values.setdefault("collapse_parallels", True)
50
58
  result = ctx.client.search_cards(CardSearchQuery(q=q, **values))
51
59
 
52
60
  def market_view(c) -> None:
53
61
  render_card_market(ctx.client.get_card_market(c.uuid))
54
62
 
55
- browse_cards(result, detail=market_view)
63
+ # The rainbow is fetched on drill-in, not up front: one call per card the user actually opens,
64
+ # and it returns the WHOLE slot even when the search matched a single finish.
65
+ def rainbow(c) -> list:
66
+ return ctx.client.get_parallels(c.uuid)
67
+
68
+ browse_cards(
69
+ result,
70
+ detail=market_view,
71
+ printings=rainbow if values["collapse_parallels"] else None,
72
+ )
56
73
 
57
74
 
58
75
  cmd_search.flags = CARD_SEARCH_FLAGS
@@ -24,7 +24,7 @@ from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTop
24
24
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
25
25
  from slab_schemas.timeseries import CardPriceHistory
26
26
 
27
- from .browse import browse
27
+ from .browse import Page, browse
28
28
  from .theme import (
29
29
  bar,
30
30
  console,
@@ -102,7 +102,25 @@ CARD_COLUMNS = [
102
102
  ("Finish", {"style": "slab.accent"}),
103
103
  ("Attrs", {"style": "slab.dim"}),
104
104
  ("Run", {"justify": "right", "style": "slab.dim"}),
105
- ("UUID", {"style": "slab.muted"}),
105
+ ]
106
+
107
+ # The collapsed shape: one row per CARD, not per printing. Finish and Run are the two columns that
108
+ # vary *within* a slot, so they move down to the printings level and their place is taken by the
109
+ # count of printings there — the row says "this card exists 17 ways", the level below says which.
110
+ SLOT_COLUMNS = [
111
+ ("Number", {"style": "slab.value"}),
112
+ ("Subject(s)", {}),
113
+ ("Set", {}),
114
+ ("Subset", {}),
115
+ ("Attrs", {"style": "slab.dim"}),
116
+ ("Printings", {"justify": "right", "style": "slab.accent"}),
117
+ ]
118
+
119
+ PRINTING_COLUMNS = [
120
+ ("Finish", {"style": "slab.accent"}),
121
+ ("Run", {"justify": "right"}),
122
+ ("Attrs", {"style": "slab.dim"}),
123
+ ("Odds", {"style": "slab.dim"}),
106
124
  ]
107
125
 
108
126
 
@@ -112,21 +130,69 @@ def card_cells(c: CardOut) -> list[str]:
112
130
  attrs = ", ".join(a.name for a in c.attributes)
113
131
  finish = c.finish or "[slab.dim]Base[/]"
114
132
  pr = f"/{c.print_run}" if c.print_run else ""
115
- return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr, c.uuid[:8]]
133
+ return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr]
134
+
135
+
136
+ def slot_cells(c: CardOut) -> list[str]:
137
+ """One card SLOT as its SLOT_COLUMNS cells — the base card plus how many printings it has."""
138
+ subjects = ", ".join(s.name for s in c.subjects)
139
+ attrs = ", ".join(a.name for a in c.attributes)
140
+ n = c.printing_count
141
+ # A card with no parallels is a dead end, so it reads as a dim 1 rather than an invitation.
142
+ printings = "[slab.dim]1[/]" if not n or n <= 1 else str(n)
143
+ return [c.card_number, subjects, c.set_name or "", c.subset or "", attrs, printings]
144
+
145
+
146
+ def printing_cells(c: CardOut) -> list[str]:
147
+ """One printing of a slot as its PRINTING_COLUMNS cells — what differs between rainbow rows."""
148
+ attrs = ", ".join(a.name for a in c.attributes)
149
+ return [
150
+ c.finish or "[slab.dim]Base[/]",
151
+ f"/{c.print_run}" if c.print_run else "[slab.dim]—[/]",
152
+ attrs,
153
+ c.odds or "",
154
+ ]
155
+
156
+
157
+ def card_title(c: CardOut) -> str:
158
+ """A single card named for a heading: '#86 Paul Coffey — 2021-22 Stature'."""
159
+ subjects = ", ".join(s.name for s in c.subjects) or "—"
160
+ return f"#{c.card_number} {subjects} — {c.set_name or ''} {c.subset or ''}".strip()
116
161
 
117
162
 
118
- def browse_cards(result: CardSearchResult, *, detail=None) -> CardOut | None:
119
- """Browse a card search. With `detail` (a `CardOut -> None` renderer), Enter drills into
120
- that view as a page; Esc climbs back. Static table on non-interactive output."""
163
+ def browse_cards(result: CardSearchResult, *, detail=None, printings=None) -> CardOut | None:
164
+ """Browse a card search. `detail` (a `CardOut -> None` renderer) is what Enter opens on a
165
+ PRINTING; Esc climbs back. Static table on non-interactive output.
166
+
167
+ With `printings` (a `CardOut -> list[CardOut]` loader, i.e. the parallels endpoint) the list is
168
+ one row per card and Enter opens that card's printings as a nested list, whose Enter runs
169
+ `detail` — so a search for a player reads as his cards, not as every finish of every card.
170
+ Without it, every printing is its own row and Enter runs `detail` straight away."""
121
171
  if not result.items:
122
172
  console.print("[slab.dim]No cards found.[/]")
123
173
  return None
174
+ footer = _shown_footer(len(result.items), result.total,
175
+ "narrow your search, or `--limit N` to see more.")
176
+ if printings is None:
177
+ return browse(
178
+ CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
179
+ title="Cards", note=f"{result.total} total", hint="market view",
180
+ footer=footer, detail=detail,
181
+ )
182
+
183
+ def rainbow(card: CardOut) -> Page:
184
+ rows = printings(card)
185
+ return Page(
186
+ PRINTING_COLUMNS, [(p, printing_cells(p)) for p in rows],
187
+ title=card_title(card),
188
+ note=f"{len(rows)} printing{'' if len(rows) == 1 else 's'}",
189
+ hint="market view", detail=detail,
190
+ )
191
+
124
192
  return browse(
125
- CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
126
- title="Cards", note=f"{result.total} total", hint="market view",
127
- footer=_shown_footer(len(result.items), result.total,
128
- "narrow your search, or `--limit N` to see more."),
129
- detail=detail,
193
+ SLOT_COLUMNS, [(c, slot_cells(c)) for c in result.items],
194
+ title="Cards", note=f"{result.total} total", hint="printings",
195
+ footer=footer, detail=rainbow,
130
196
  )
131
197
 
132
198
 
@@ -162,7 +162,12 @@ _PAGING_FLAGS: list[Flag] = [
162
162
 
163
163
  # What each search command parses: the shared card grammar + its scope's extras.
164
164
 
165
- CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + _PAGING_FLAGS
165
+ CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
166
+ # Catalog-only: mirrors CardSearchQuery.collapse_parallels, which CollectionSearchQuery has no
167
+ # equivalent of (a collection lists the copies you own, and a copy already IS one printing).
168
+ Flag("--printings", "collapse_parallels", "bool",
169
+ "One row per printing instead of per card", store=False),
170
+ ] + _PAGING_FLAGS
166
171
 
167
172
  COLLECTION_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
168
173
  # collection-only filters — mirrors CollectionSearchQuery's extension of CardFilter
File without changes