diff --git a/src/beaky/cli.py b/src/beaky/cli.py index 0203021..db6ca69 100644 --- a/src/beaky/cli.py +++ b/src/beaky/cli.py @@ -1,5 +1,6 @@ import argparse import re as _re +import shutil from datetime import datetime import yaml @@ -28,6 +29,8 @@ def _verdict_str(verdict: TicketVerdict) -> str: _FC = 14 # field column visual width _VC = 24 # value column visual width +_BET_W = 1 + (_FC + 2) + 1 + (_VC + 2) + 1 + (_VC + 2) + 1 # one bet table width +_GAP = " " # space between side-by-side tables _FIELD_LABELS: dict[str, str] = { "team1Name": "team1", @@ -35,15 +38,14 @@ _FIELD_LABELS: dict[str, str] = { } _FIELD_ORDER = ["type", "team1Name", "team2Name", "date", "league"] _SKIP_FIELDS = {"ticketType"} +_BLANK_ROW = f"│{' ' * (_FC + 2)}│{' ' * (_VC + 2)}│{' ' * (_VC + 2)}│" def _vlen(text: str) -> int: - """Visual length of a string — strips ANSI escape codes.""" return len(_re.sub(r"\033\[[^m]*m", "", text)) def _vpad(text: str, width: int) -> str: - """Pad text to visual width, accounting for ANSI codes.""" return text + " " * max(0, width - _vlen(text)) @@ -52,70 +54,81 @@ def _bet_fields(bet: Bet) -> dict[str, str]: for k, v in vars(bet).items(): if k in _SKIP_FIELDS: continue - if k == "date" and isinstance(v, datetime): - fields[k] = v.strftime("%Y-%m-%d %H:%M") - else: - fields[k] = str(v) + fields[k] = v.strftime("%Y-%m-%d %H:%M") if k == "date" and isinstance(v, datetime) else str(v) return fields def _tbl_row(field: str, lval: str, ival: str) -> str: label = _FIELD_LABELS.get(field, field) - return f" │ {_vpad(label, _FC)} │ {_vpad(lval, _VC)} │ {_vpad(ival, _VC)} │" + return f"│ {_vpad(label, _FC)} │ {_vpad(lval, _VC)} │ {_vpad(ival, _VC)} │" -def _tbl_divider(left: str, mid: str, right: str, fill: str = "─") -> str: - return f" {left}{fill * (_FC + 2)}{mid}{fill * (_VC + 2)}{mid}{fill * (_VC + 2)}{right}" +def _tbl_sep(left: str, mid: str, right: str) -> str: + return f"{left}{'─' * (_FC + 2)}{mid}{'─' * (_VC + 2)}{mid}{'─' * (_VC + 2)}{right}" -def _print_bet_compare(idx: int, link_bet: Bet | None, img_bet: Bet | None) -> None: - print(f"\n {_ansi.bold(_ansi.cyan(f' Bet {idx} '))}") - +def _bet_to_lines(idx: int, link_bet: Bet | None, img_bet: Bet | None) -> list[str]: link_fields = _bet_fields(link_bet) if link_bet is not None else {} img_fields = _bet_fields(img_bet) if img_bet is not None else {} all_keys = link_fields.keys() | img_fields.keys() keys = [k for k in _FIELD_ORDER if k in all_keys] + [k for k in all_keys if k not in _FIELD_ORDER] - print(_tbl_divider("┌", "┬", "┐")) - print(_tbl_row("field", _ansi.bold("link classifier"), _ansi.bold("image classifier"))) - print(_tbl_divider("├", "┼", "┤")) - + data_rows = [] for key in keys: lval_raw = link_fields.get(key, "") ival_raw = img_fields.get(key, "") - match = lval_raw == ival_raw + match = lval_raw == ival_raw + both = bool(lval_raw) and bool(ival_raw) + lval_raw = lval_raw[:_VC - 1] + "…" if len(lval_raw) > _VC else lval_raw + ival_raw = ival_raw[:_VC - 1] + "…" if len(ival_raw) > _VC else ival_raw + lval = _ansi.gray("—") if not lval_raw else (lval_raw if (match or not both) else _ansi.yellow(lval_raw)) + ival = _ansi.gray("—") if not ival_raw else (ival_raw if (match or not both) else _ansi.yellow(ival_raw)) + data_rows.append(_tbl_row(key, lval, ival)) - lval = _ansi.gray("—") if lval_raw == "" else (lval_raw if match else _ansi.yellow(lval_raw)) - ival = _ansi.gray("—") if ival_raw == "" else (ival_raw if match else _ansi.yellow(ival_raw)) + header = _vpad(_ansi.bold(_ansi.cyan(f" Bet {idx} ")), _BET_W) + return [ + header, + _tbl_sep("┌", "┬", "┐"), + _tbl_row("", _ansi.bold("link classifier"), _ansi.bold("image classifier")), + _tbl_sep("├", "┼", "┤"), + *data_rows, + _tbl_sep("└", "┴", "┘"), + ] - # truncate raw value if too long before applying color - if len(lval_raw) > _VC: - lval_raw = lval_raw[:_VC - 1] + "…" - lval = lval_raw if match else _ansi.yellow(lval_raw) - if len(ival_raw) > _VC: - ival_raw = ival_raw[:_VC - 1] + "…" - ival = ival_raw if match else _ansi.yellow(ival_raw) - print(_tbl_row(key, lval, ival)) - - print(_tbl_divider("└", "┴", "┘")) +def _pad_to(lines: list[str], target: int) -> list[str]: + result = list(lines) + while len(result) < target: + result.insert(-1, _BLANK_ROW) + return result def _print_compare(link_ticket: Ticket, img_ticket: Ticket) -> None: n_link = len(link_ticket.bets) n_img = len(img_ticket.bets) - header = f" Ticket {link_ticket.id} — link: {n_link} bet{'s' if n_link != 1 else ''} │ img: {n_img} bet{'s' if n_img != 1 else ''}" - total_w = _FC + _VC * 2 + 10 - print(f"\n{'═' * total_w}") - print(_ansi.bold(header)) - print(f"{'═' * total_w}") + n_max = max(n_link, n_img) - for i, (lb, ib) in enumerate(zip(link_ticket.bets, img_ticket.bets), start=1): - _print_bet_compare(i, lb, ib) - for i, lb in enumerate(link_ticket.bets[n_img:], start=n_img + 1): - _print_bet_compare(i, lb, None) - for i, ib in enumerate(img_ticket.bets[n_link:], start=n_link + 1): - _print_bet_compare(i, None, ib) + term_w = shutil.get_terminal_size((120, 24)).columns + n_cols = max(1, term_w // (_BET_W + len(_GAP))) + row_w = min(term_w, n_cols * (_BET_W + len(_GAP)) - len(_GAP) + 2) + + header = f" Ticket {link_ticket.id} — link: {n_link} bet{'s' if n_link != 1 else ''} │ img: {n_img} bet{'s' if n_img != 1 else ''}" + print(f"\n{'═' * row_w}") + print(_ansi.bold(header)) + print(f"{'═' * row_w}") + + all_lines = [ + _bet_to_lines(i + 1, link_ticket.bets[i] if i < n_link else None, img_ticket.bets[i] if i < n_img else None) + for i in range(n_max) + ] + + for start in range(0, n_max, n_cols): + chunk = all_lines[start:start + n_cols] + max_h = max(len(b) for b in chunk) + padded = [_pad_to(b, max_h) for b in chunk] + print() + for row in zip(*padded): + print(" " + _GAP.join(row)) def load_config(path: str) -> Config | None: diff --git a/src/beaky/resolvers/resolver.py b/src/beaky/resolvers/resolver.py index 7f0eed3..07bfc2b 100644 --- a/src/beaky/resolvers/resolver.py +++ b/src/beaky/resolvers/resolver.py @@ -13,7 +13,7 @@ from beaky.datamodels.ticket import ( BetOutcome, MatchInfo, Ticket, - UnknownTicket, + UnknownBet, ) from beaky.resolvers.config import ResolverConfig @@ -53,8 +53,8 @@ class ResolvedTicket: @property def verdict(self) -> TicketVerdict: - resolvable = [b for b in self.bets if not isinstance(b.bet, UnknownTicket)] - unresolvable = [b for b in self.bets if isinstance(b.bet, UnknownTicket)] + resolvable = [b for b in self.bets if not isinstance(b.bet, UnknownBet)] + unresolvable = [b for b in self.bets if isinstance(b.bet, UnknownBet)] if not resolvable: return TicketVerdict.UNKNOWN if any(b.outcome == BetOutcome.LOSE for b in resolvable): @@ -99,7 +99,7 @@ class TicketResolver: print(f"\n {_ansi.bold(_ansi.cyan(f'┌─ [{bet_type}]'))} {_ansi.bold(f'{bet.team1Name} vs {bet.team2Name}')}" f" {_ansi.dim(f'{bet.date.strftime('%Y-%m-%d')} | {bet.league}')}") - if isinstance(bet, UnknownTicket): + if isinstance(bet, UnknownBet): print(_ansi.gray(f" │ skipping — not implemented: {bet.raw_text!r}")) print(_ansi.gray(" └─ UNKNOWN")) return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN)