lucky-cli 0.0.3__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.
lucky/__init__.py ADDED
File without changes
lucky/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version as pkg_version
3
+
4
+ import typer
5
+
6
+ import lucky.lottery # noqa: F401 -- side effect: registers powerball + mega-millions
7
+
8
+ from .commands.check import check
9
+ from .commands.pick import pick
10
+ from .commands.show import show
11
+ from .database.db import get_session
12
+ from .updater.updater import sync_all
13
+
14
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
15
+
16
+
17
+ def _version_callback(value: bool) -> None:
18
+ if not value:
19
+ return
20
+ try:
21
+ v = pkg_version("lucky-cli")
22
+ except PackageNotFoundError:
23
+ v = "0.0.0+dev"
24
+ typer.echo(v)
25
+ raise typer.Exit()
26
+
27
+
28
+ @app.callback()
29
+ def main(
30
+ version: bool = typer.Option(
31
+ False,
32
+ "--version",
33
+ callback=_version_callback,
34
+ is_eager=True,
35
+ help="Show the installed version and exit.",
36
+ ),
37
+ ) -> None:
38
+ """lucky — lottery ticket generator, checker, and stats CLI."""
39
+ session = get_session()
40
+ try:
41
+ sync_all(session)
42
+ finally:
43
+ session.close()
44
+
45
+
46
+ app.command()(pick)
47
+ app.command()(check)
48
+ app.command()(show)
File without changes
@@ -0,0 +1,50 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from ..console.rich import fail, render_check_result
6
+ from ..database import repository
7
+ from ..database.db import get_session
8
+ from ..lottery.base import game_metavar, get_game
9
+ from ..lottery.checker import check_ticket
10
+
11
+
12
+ def check(
13
+ game: Annotated[
14
+ str,
15
+ typer.Argument(
16
+ metavar=game_metavar(),
17
+ help="Game slug",
18
+ ),
19
+ ],
20
+ numbers: Annotated[list[int], typer.Argument(help="Your main numbers")],
21
+ special: Annotated[list[int], typer.Option("--special", help="Your special number(s)")] = [], # noqa: B006
22
+ ) -> None:
23
+ """Check NUMBERS against the latest draw for GAME."""
24
+ try:
25
+ resolved_game = get_game(game)
26
+ except ValueError as exc:
27
+ fail(str(exc))
28
+ return
29
+
30
+ try:
31
+ resolved_game.validate_ticket(numbers, special)
32
+ except ValueError as exc:
33
+ usage = (
34
+ f"Usage: lucky check {resolved_game.slug} "
35
+ f"<{resolved_game.main_count} main numbers> "
36
+ f"--special <{resolved_game.special_count} number(s)>"
37
+ )
38
+ fail(f"{exc}\n{usage}")
39
+ return
40
+
41
+ session = get_session()
42
+ draw = repository.latest_draw(session, resolved_game.slug)
43
+ if draw is None:
44
+ session.close()
45
+ fail(f"No draw data available for {resolved_game.slug} yet.")
46
+ return
47
+
48
+ result = check_ticket(numbers, special, draw.main, draw.special)
49
+ render_check_result(numbers, special, draw.main, draw.special, result)
50
+ session.close()
lucky/commands/pick.py ADDED
@@ -0,0 +1,28 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from ..console.rich import fail, render_tickets
6
+ from ..lottery.base import game_metavar, get_game
7
+ from ..lottery.generator import generate_ticket
8
+
9
+
10
+ def pick(
11
+ game: Annotated[
12
+ str,
13
+ typer.Argument(
14
+ metavar=game_metavar(),
15
+ help="Game slug",
16
+ ),
17
+ ],
18
+ count: Annotated[int, typer.Option("--count", min=1, help="Number of tickets to generate")] = 1,
19
+ ) -> None:
20
+ """Generate one or more valid random tickets for GAME."""
21
+ try:
22
+ resolved_game = get_game(game)
23
+ except ValueError as exc:
24
+ fail(str(exc))
25
+ return
26
+
27
+ tickets = [generate_ticket(resolved_game) for _ in range(count)]
28
+ render_tickets(resolved_game.name, tickets)
lucky/commands/show.py ADDED
@@ -0,0 +1,55 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from ..console.rich import fail, render_draw_panel, render_history_table, render_stats
6
+ from ..database import repository
7
+ from ..database.db import get_session
8
+ from ..lottery.base import game_metavar, get_game
9
+ from ..lottery.stats import compute_stats
10
+
11
+
12
+ def show(
13
+ game: Annotated[
14
+ str,
15
+ typer.Argument(
16
+ metavar=game_metavar(),
17
+ help="Game slug",
18
+ ),
19
+ ],
20
+ history: Annotated[bool, typer.Option("--history", help="Show draw history")] = False,
21
+ stats: Annotated[bool, typer.Option("--stats", help="Show number frequency stats")] = False,
22
+ limit: Annotated[int, typer.Option("--limit", min=1, help="Number of draws to show in history")] = 10,
23
+ ) -> None:
24
+ """Show the latest draw for GAME, or its --history / --stats."""
25
+ if history and stats:
26
+ fail("--history and --stats are mutually exclusive")
27
+ return
28
+
29
+ try:
30
+ resolved_game = get_game(game)
31
+ except ValueError as exc:
32
+ fail(str(exc))
33
+ return
34
+
35
+ session = get_session()
36
+
37
+ if stats:
38
+ draws = repository.all_draws(session, resolved_game.slug)
39
+ render_stats(resolved_game.name, compute_stats(draws))
40
+ session.close()
41
+ return
42
+
43
+ if history:
44
+ draws = repository.history(session, resolved_game.slug, limit)
45
+ render_history_table(resolved_game.name, draws)
46
+ session.close()
47
+ return
48
+
49
+ draw = repository.latest_draw(session, resolved_game.slug)
50
+ if draw is None:
51
+ session.close()
52
+ fail(f"No draw data available for {resolved_game.slug} yet.")
53
+ return
54
+ render_draw_panel(resolved_game.name, draw)
55
+ session.close()
lucky/config.py ADDED
@@ -0,0 +1,9 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ def get_db_path() -> Path:
6
+ override = os.environ.get("LUCKY_DB_PATH")
7
+ if override:
8
+ return Path(override)
9
+ return Path.home() / ".lucky-cli" / "lucky.db"
File without changes
lucky/console/rich.py ADDED
@@ -0,0 +1,129 @@
1
+ from collections.abc import Callable, Iterator
2
+ from contextlib import contextmanager
3
+ from typing import NoReturn
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.progress import Progress
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+
14
+ def print_error(message: str) -> None:
15
+ console.print(f"[bold red]Error:[/bold red] {message}")
16
+
17
+
18
+ def fail(message: str) -> NoReturn:
19
+ print_error(message)
20
+ raise typer.Exit(code=1)
21
+
22
+
23
+ @contextmanager
24
+ def update_progress(label: str, total: int) -> Iterator[Callable[[], None]]:
25
+ with Progress(console=console) as progress:
26
+ task_id = progress.add_task(f"Updating {label}...", total=total)
27
+
28
+ def advance() -> None:
29
+ progress.update(task_id, advance=1)
30
+
31
+ yield advance
32
+
33
+
34
+ def render_tickets(game_name: str, tickets) -> None:
35
+ table = Table(title=f"{game_name} — Picks")
36
+ table.add_column("#")
37
+ table.add_column("Main Numbers")
38
+ table.add_column("Special")
39
+ for i, ticket in enumerate(tickets, start=1):
40
+ table.add_row(
41
+ str(i),
42
+ ", ".join(map(str, ticket.main)),
43
+ ", ".join(map(str, ticket.special)) or "—",
44
+ )
45
+ console.print(table)
46
+
47
+
48
+ def render_draw_panel(game_name: str, draw) -> None:
49
+ body = (
50
+ f"Date: {draw.date}\n"
51
+ f"Numbers: {', '.join(map(str, draw.main))}\n"
52
+ f"Special: {', '.join(map(str, draw.special)) or 'N/A'}\n"
53
+ f"Jackpot: {draw.jackpot or 'N/A'}"
54
+ )
55
+ console.print(Panel(body, title=f"{game_name} — Latest Draw"))
56
+
57
+
58
+ def render_history_table(game_name: str, draws) -> None:
59
+ table = Table(title=f"{game_name} — History")
60
+ table.add_column("Date")
61
+ table.add_column("Main Numbers")
62
+ table.add_column("Special")
63
+ table.add_column("Jackpot")
64
+ for draw in draws:
65
+ table.add_row(
66
+ draw.date,
67
+ ", ".join(map(str, draw.main)),
68
+ ", ".join(map(str, draw.special)) or "—",
69
+ draw.jackpot or "—",
70
+ )
71
+ console.print(table)
72
+
73
+
74
+ def render_stats(game_name: str, stats) -> None:
75
+ table = Table(title=f"{game_name} — Number Frequency")
76
+ table.add_column("Number")
77
+ table.add_column("Frequency")
78
+ for number, freq in sorted(stats.main_frequency.items()):
79
+ table.add_row(str(number), str(freq))
80
+ console.print(table)
81
+
82
+ if stats.special_frequency:
83
+ special_table = Table(title=f"{game_name} — Special Number Frequency")
84
+ special_table.add_column("Number")
85
+ special_table.add_column("Frequency")
86
+ for number, freq in sorted(stats.special_frequency.items()):
87
+ special_table.add_row(str(number), str(freq))
88
+ console.print(special_table)
89
+
90
+ console.print(f"[green]Hot:[/green] {stats.hot_main}")
91
+ console.print(f"[blue]Cold:[/blue] {stats.cold_main}")
92
+ console.print(f"Odd/Even: {stats.odd_count}/{stats.even_count}")
93
+
94
+
95
+ def render_check_result(your_main, your_special, draw_main, draw_special, result) -> None:
96
+ your_main_sorted = sorted(your_main)
97
+ draw_main_sorted = sorted(draw_main)
98
+ your_special_sorted = sorted(your_special)
99
+ draw_special_sorted = sorted(draw_special)
100
+
101
+ your_main_set = set(your_main)
102
+ your_special_set = set(your_special)
103
+ draw_main_set = set(draw_main)
104
+ draw_special_set = set(draw_special)
105
+
106
+ def fmt(value: int, hit: bool) -> str:
107
+ return f"[bold green]{value}[/bold green]" if hit else str(value)
108
+
109
+ table = Table(title="Ticket Comparison")
110
+ table.add_column("")
111
+ for i in range(len(your_main_sorted)):
112
+ table.add_column(f"Main {i + 1}")
113
+ for i in range(len(your_special_sorted)):
114
+ table.add_column(f"Special {i + 1}")
115
+
116
+ table.add_row(
117
+ "Your Numbers",
118
+ *[fmt(n, n in draw_main_set) for n in your_main_sorted],
119
+ *[fmt(n, n in draw_special_set) for n in your_special_sorted],
120
+ )
121
+ table.add_row(
122
+ "Winning Numbers",
123
+ *[fmt(n, n in your_main_set) for n in draw_main_sorted],
124
+ *[fmt(n, n in your_special_set) for n in draw_special_sorted],
125
+ )
126
+ console.print(table)
127
+
128
+ console.print(f"Main matches: {result.main_matches}/{result.main_count}")
129
+ console.print(f"Special matches: {result.special_matches}/{result.special_count}")
File without changes
lucky/database/db.py ADDED
@@ -0,0 +1,19 @@
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.engine import Engine
3
+ from sqlalchemy.orm import Session, sessionmaker
4
+
5
+ from ..config import get_db_path
6
+ from .models import Base
7
+
8
+
9
+ def get_engine() -> Engine:
10
+ db_path = get_db_path()
11
+ db_path.parent.mkdir(parents=True, exist_ok=True)
12
+ engine = create_engine(f"sqlite:///{db_path}")
13
+ Base.metadata.create_all(engine)
14
+ return engine
15
+
16
+
17
+ def get_session() -> Session:
18
+ factory = sessionmaker(bind=get_engine())
19
+ return factory()
@@ -0,0 +1,34 @@
1
+ import json
2
+
3
+ from sqlalchemy import TEXT, String, UniqueConstraint
4
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
5
+ from sqlalchemy.types import TypeDecorator
6
+
7
+
8
+ class Base(DeclarativeBase):
9
+ pass
10
+
11
+
12
+ class JSONIntList(TypeDecorator):
13
+ """Stores a list[int] as a JSON-encoded TEXT column."""
14
+
15
+ impl = TEXT
16
+ cache_ok = True
17
+
18
+ def process_bind_param(self, value, dialect):
19
+ return json.dumps(value if value is not None else [])
20
+
21
+ def process_result_value(self, value, dialect):
22
+ return json.loads(value) if value is not None else []
23
+
24
+
25
+ class Draw(Base):
26
+ __tablename__ = "draws"
27
+ __table_args__ = (UniqueConstraint("game", "date", name="uq_draws_game_date"),)
28
+
29
+ id: Mapped[int] = mapped_column(primary_key=True)
30
+ game: Mapped[str] = mapped_column(String, nullable=False)
31
+ date: Mapped[str] = mapped_column(String, nullable=False)
32
+ main: Mapped[list[int]] = mapped_column(JSONIntList, nullable=False)
33
+ special: Mapped[list[int]] = mapped_column(JSONIntList, nullable=False)
34
+ jackpot: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -0,0 +1,57 @@
1
+ from sqlalchemy import select
2
+ from sqlalchemy.orm import Session
3
+
4
+ from .models import Draw
5
+
6
+
7
+ def latest_draw(session: Session, game: str) -> Draw | None:
8
+ stmt = select(Draw).where(Draw.game == game).order_by(Draw.date.desc()).limit(1)
9
+ return session.scalars(stmt).first()
10
+
11
+
12
+ def history(session: Session, game: str, limit: int) -> list[Draw]:
13
+ stmt = select(Draw).where(Draw.game == game).order_by(Draw.date.desc()).limit(limit)
14
+ return list(session.scalars(stmt))
15
+
16
+
17
+ def all_draws(session: Session, game: str) -> list[Draw]:
18
+ stmt = select(Draw).where(Draw.game == game).order_by(Draw.date.asc())
19
+ return list(session.scalars(stmt))
20
+
21
+
22
+ def existing_dates(session: Session, game: str) -> set[str]:
23
+ stmt = select(Draw.date).where(Draw.game == game)
24
+ return set(session.scalars(stmt))
25
+
26
+
27
+ def add_draw(session: Session, game: str, record) -> None:
28
+ session.add(
29
+ Draw(
30
+ game=game,
31
+ date=record.date,
32
+ main=record.main,
33
+ special=record.special,
34
+ jackpot=record.jackpot,
35
+ )
36
+ )
37
+
38
+
39
+ def filter_new_records(session: Session, game: str, records: list) -> list:
40
+ existing = existing_dates(session, game)
41
+ seen: set[str] = set()
42
+ new_records = []
43
+ for r in records:
44
+ if r.date in existing or r.date in seen:
45
+ continue
46
+ seen.add(r.date)
47
+ new_records.append(r)
48
+ return new_records
49
+
50
+
51
+ def upsert_draws(session: Session, game: str, records: list) -> int:
52
+ new_records = filter_new_records(session, game, records)
53
+ for record in new_records:
54
+ add_draw(session, game, record)
55
+ if new_records:
56
+ session.commit()
57
+ return len(new_records)
@@ -0,0 +1 @@
1
+ from . import mega, powerball # noqa: F401 -- side effect: registers both games
lucky/lottery/base.py ADDED
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Game:
6
+ slug: str
7
+ name: str
8
+ main_count: int
9
+ main_range: tuple[int, int]
10
+ special_count: int
11
+ special_range: tuple[int, int] | None
12
+
13
+ def validate_ticket(self, main: list[int], special: list[int]) -> None:
14
+ if len(main) != self.main_count:
15
+ raise ValueError(
16
+ f"{self.name} requires exactly {self.main_count} main numbers, got {len(main)}"
17
+ )
18
+ if len(set(main)) != len(main):
19
+ raise ValueError("main numbers must be unique")
20
+ lo, hi = self.main_range
21
+ if any(not (lo <= n <= hi) for n in main):
22
+ raise ValueError(f"main numbers must be in range {lo}-{hi}")
23
+
24
+ if len(special) != self.special_count:
25
+ raise ValueError(
26
+ f"{self.name} requires exactly {self.special_count} special number(s), got {len(special)}"
27
+ )
28
+ if self.special_range is not None:
29
+ slo, shi = self.special_range
30
+ if any(not (slo <= n <= shi) for n in special):
31
+ raise ValueError(f"special numbers must be in range {slo}-{shi}")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Ticket:
36
+ main: list[int]
37
+ special: list[int]
38
+
39
+
40
+ GAMES: dict[str, Game] = {}
41
+
42
+
43
+ def register(game: Game) -> Game:
44
+ GAMES[game.slug] = game
45
+ return game
46
+
47
+
48
+ def get_game(slug: str) -> Game:
49
+ try:
50
+ return GAMES[slug]
51
+ except KeyError:
52
+ choices = ", ".join(sorted(GAMES)) or "(none registered)"
53
+ raise ValueError(f"Unknown game '{slug}'. Choices: {choices}") from None
54
+
55
+
56
+ def game_metavar() -> str:
57
+ return f"[{'|'.join(sorted(GAMES))}]"
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class CheckResult:
6
+ main_matches: int
7
+ main_count: int
8
+ special_matches: int
9
+ special_count: int
10
+
11
+
12
+ def check_ticket(
13
+ main: list[int],
14
+ special: list[int],
15
+ draw_main: list[int],
16
+ draw_special: list[int],
17
+ ) -> CheckResult:
18
+ return CheckResult(
19
+ main_matches=len(set(main) & set(draw_main)),
20
+ main_count=len(draw_main),
21
+ special_matches=len(set(special) & set(draw_special)),
22
+ special_count=len(draw_special),
23
+ )
@@ -0,0 +1,16 @@
1
+ import random
2
+
3
+ from .base import Game, Ticket
4
+
5
+
6
+ def generate_ticket(game: Game) -> Ticket:
7
+ main_lo, main_hi = game.main_range
8
+ main = sorted(random.sample(range(main_lo, main_hi + 1), game.main_count))
9
+
10
+ special: list[int] = []
11
+ if game.special_count:
12
+ assert game.special_range is not None
13
+ special_lo, special_hi = game.special_range
14
+ special = sorted(random.sample(range(special_lo, special_hi + 1), game.special_count))
15
+
16
+ return Ticket(main=main, special=special)
lucky/lottery/mega.py ADDED
@@ -0,0 +1,12 @@
1
+ from .base import Game, register
2
+
3
+ MEGA_MILLIONS = register(
4
+ Game(
5
+ slug="mega-millions",
6
+ name="Mega Millions",
7
+ main_count=5,
8
+ main_range=(1, 70),
9
+ special_count=1,
10
+ special_range=(1, 25),
11
+ )
12
+ )
@@ -0,0 +1,12 @@
1
+ from .base import Game, register
2
+
3
+ POWERBALL = register(
4
+ Game(
5
+ slug="powerball",
6
+ name="Powerball",
7
+ main_count=5,
8
+ main_range=(1, 69),
9
+ special_count=1,
10
+ special_range=(1, 26),
11
+ )
12
+ )
lucky/lottery/stats.py ADDED
@@ -0,0 +1,48 @@
1
+ from collections import Counter
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class GameStats:
7
+ main_frequency: dict[int, int]
8
+ special_frequency: dict[int, int]
9
+ hot_main: list[int]
10
+ cold_main: list[int]
11
+ odd_count: int
12
+ even_count: int
13
+ total_draws: int
14
+
15
+
16
+ def compute_stats(draws, top_n: int = 5) -> GameStats:
17
+ main_counter: Counter[int] = Counter()
18
+ special_counter: Counter[int] = Counter()
19
+ odd_count = 0
20
+ even_count = 0
21
+
22
+ for draw in draws:
23
+ main_counter.update(draw.main)
24
+ special_counter.update(draw.special)
25
+ for n in draw.main:
26
+ if n % 2 == 0:
27
+ even_count += 1
28
+ else:
29
+ odd_count += 1
30
+
31
+ def ranked(reverse: bool) -> list[int]:
32
+ return [
33
+ number
34
+ for number, _ in sorted(main_counter.items(), key=lambda item: (-item[1] if reverse else item[1], item[0]))
35
+ ]
36
+
37
+ hot_main = ranked(reverse=True)[:top_n]
38
+ cold_main = ranked(reverse=False)[:top_n]
39
+
40
+ return GameStats(
41
+ main_frequency=dict(main_counter),
42
+ special_frequency=dict(special_counter),
43
+ hot_main=hot_main,
44
+ cold_main=cold_main,
45
+ odd_count=odd_count,
46
+ even_count=even_count,
47
+ total_draws=len(draws),
48
+ )
File without changes
@@ -0,0 +1,7 @@
1
+ from .sources import DataSource, DrawRecord, FixtureSource
2
+
3
+ _source: DataSource = FixtureSource()
4
+
5
+
6
+ def get_draws(game_slug: str) -> list[DrawRecord]:
7
+ return _source.fetch(game_slug)
@@ -0,0 +1,4 @@
1
+ [
2
+ {"date": "2026-06-13", "main": [5, 19, 27, 41, 63], "special": [22], "jackpot": "$89,000,000"},
3
+ {"date": "2026-06-17", "main": [3, 11, 24, 36, 58], "special": [15], "jackpot": "$96,000,000"}
4
+ ]
@@ -0,0 +1,5 @@
1
+ [
2
+ {"date": "2026-06-14", "main": [12, 18, 33, 44, 61], "special": [9], "jackpot": "$135,000,000"},
3
+ {"date": "2026-06-16", "main": [5, 9, 27, 41, 63], "special": [21], "jackpot": "$142,000,000"},
4
+ {"date": "2026-06-18", "main": [2, 14, 29, 38, 55], "special": [3], "jackpot": "$150,000,000"}
5
+ ]
@@ -0,0 +1,35 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from importlib.resources import files
4
+ from typing import Protocol
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DrawRecord:
9
+ date: str
10
+ main: list[int]
11
+ special: list[int]
12
+ jackpot: str | None
13
+
14
+
15
+ class DataSource(Protocol):
16
+ def fetch(self, game_slug: str) -> list[DrawRecord]: ...
17
+
18
+
19
+ class FixtureSource:
20
+ """Reads bundled per-game JSON fixtures as if they were a remote API response."""
21
+
22
+ def fetch(self, game_slug: str) -> list[DrawRecord]:
23
+ resource = files("lucky.updater.fixtures").joinpath(f"{game_slug}.json")
24
+ if not resource.is_file():
25
+ raise FileNotFoundError(f"No fixture data for game '{game_slug}'")
26
+ rows = json.loads(resource.read_text())
27
+ return [
28
+ DrawRecord(
29
+ date=row["date"],
30
+ main=row["main"],
31
+ special=row["special"],
32
+ jackpot=row.get("jackpot"),
33
+ )
34
+ for row in rows
35
+ ]
@@ -0,0 +1,32 @@
1
+ import logging
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ from ..console.rich import update_progress
6
+ from ..database import repository
7
+ from ..lottery.base import GAMES
8
+ from . import client
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def sync_game(session: Session, slug: str) -> int:
14
+ records = client.get_draws(slug)
15
+ new_records = repository.filter_new_records(session, slug, records)
16
+ if not new_records:
17
+ return 0
18
+
19
+ with update_progress(slug, total=len(new_records)) as advance:
20
+ for record in new_records:
21
+ repository.add_draw(session, slug, record)
22
+ advance()
23
+ session.commit()
24
+ return len(new_records)
25
+
26
+
27
+ def sync_all(session: Session) -> None:
28
+ for slug in GAMES:
29
+ try:
30
+ sync_game(session, slug)
31
+ except Exception:
32
+ logger.debug("Update failed for %s; falling back to cached data", slug, exc_info=True)
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: lucky-cli
3
+ Version: 0.0.3
4
+ Summary: A lottery CLI tool built with Python for fun
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 PyDeployer
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.11
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: httpx>=0.28.1
31
+ Requires-Dist: rich>=15.0.0
32
+ Requires-Dist: sqlalchemy>=2.0
33
+ Requires-Dist: typer>=0.26.7
34
+ Dynamic: license-file
35
+
36
+ # lucky-cli
37
+
38
+ A lottery CLI tool built with Python for fun. Installed command: `lucky`.
39
+
40
+ Supports Powerball (5 numbers 1–69 + special 1–26) and Mega Millions (5 numbers 1–70 + special 1–25).
41
+
42
+ ## Install (dev)
43
+
44
+ ```bash
45
+ uv sync
46
+ uv run lucky --help
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ | Command | Description |
52
+ |---|---|
53
+ | `lucky pick <game> [--count N]` | Generate N valid random tickets |
54
+ | `lucky check <game> <n1..n5> --special <s>` | Check a ticket against the latest draw |
55
+ | `lucky show <game>` | Show the latest draw (date, numbers, special, jackpot) — default view |
56
+ | `lucky show <game> --history [--limit N]` | Show the last N draws |
57
+ | `lucky show <game> --stats` | Show number frequency, hot/cold numbers, odd/even distribution |
58
+ | `lucky --version` | Show the installed version |
59
+
60
+ Example:
61
+
62
+ ```bash
63
+ lucky check powerball 12 18 33 44 61 --special 9
64
+ ```
65
+
66
+ On every run, `lucky` checks for new draws and updates its local SQLite cache (`~/.lucky-cli/lucky.db`) before executing your command. If offline, it silently falls back to the cached data.
67
+
68
+ ## Project layout
69
+
70
+ ```
71
+ src/lucky/
72
+ ├── cli.py # Typer app entrypoint
73
+ ├── commands/ # one module per subcommand
74
+ ├── lottery/ # game rules + ticket generator
75
+ ├── database/ # SQLAlchemy models/repository
76
+ ├── updater/ # remote source + sync logic
77
+ └── console/ # Rich rendering helpers
78
+ tests/
79
+ ```
80
+
81
+ See [CLAUDE.md](CLAUDE.md) for the full architecture spec.
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ uv sync # install deps
87
+ uv run pytest # run tests
88
+ uv run lucky <cmd> # exercise the CLI locally
89
+ ```
90
+
91
+ ## Contributing
92
+
93
+ - Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Use `feat!:` or a `BREAKING CHANGE:` footer for breaking changes.
94
+ - Versioning is automated from git tags (`setuptools-scm`) — never hand-edit a version string.
95
+ - Route all CLI output through `src/lucky/console/`; avoid raw `print()`.
96
+ - Add/update tests under `tests/` for any change to generator, stats, checker, or database logic.
97
+ - CI publishes to PyPI on `v*` tags via `.github/workflows/release.yml`.
98
+
99
+ ## Releasing / Versioning
100
+
101
+ Version numbers are never hand-edited — they're derived from git tags via `setuptools-scm`. To cut a release:
102
+
103
+ 1. Find the last release tag (skip if this is the first release):
104
+ ```bash
105
+ git describe --tags --abbrev=0
106
+ ```
107
+ 2. Review commits since that tag:
108
+ ```bash
109
+ git log <last-tag>..HEAD --oneline
110
+ ```
111
+ 3. Classify them by [Conventional Commits](https://www.conventionalcommits.org/) prefix to decide the version bump:
112
+ - `feat:` → MINOR
113
+ - `fix:` → PATCH
114
+ - `feat!:` or a `BREAKING CHANGE:` footer → MAJOR
115
+ - (first release with no prior tag: start at `v0.1.0`)
116
+ 4. Add a new section to `CHANGELOG.md`, grouped by category:
117
+ ```md
118
+ ## v0.3.0
119
+
120
+ ### Features
121
+ - add stats command
122
+
123
+ ### Fixes
124
+ - fix check logic
125
+ ```
126
+ 5. Commit the changelog update, then create and push the tag:
127
+ ```bash
128
+ git add CHANGELOG.md
129
+ git commit -m "chore: release v0.3.0"
130
+ git tag v0.3.0
131
+ git push origin main v0.3.0
132
+ ```
133
+ 6. Pushing the tag triggers `.github/workflows/release.yml`, which runs the test suite, builds the package, and publishes to PyPI using the `PYPI_API_TOKEN` secret — nothing else to do manually.
134
+
135
+ ## Development process
136
+
137
+ This project is built with [Claude Code](https://claude.com/claude-code) using the Superpowers plugin as its development framework — brainstorming a design spec, writing an implementation plan, then executing it task-by-task with subagent-driven TDD and code review before each merge.
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ lucky/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ lucky/cli.py,sha256=NUQCRtmyoTlW3xr0x-y-HGzMwEZXL82Np-eH4MVa8B0,1130
3
+ lucky/config.py,sha256=jmXivLKSgE3WWFzPVvmiIZWLdZMeHS5Pcaqtd76YzoY,209
4
+ lucky/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ lucky/commands/check.py,sha256=6lL6NRD3RhKIembtUZlTIMC_jQ__F2R-JWS6s3aM3gE,1530
6
+ lucky/commands/pick.py,sha256=E1UoO4taUEbTs9s55BCjWyx7nM48tcbQ-F7eYMNP9DA,752
7
+ lucky/commands/show.py,sha256=lzQRO0fMhUbnLO4fFY0zjJkCx70En4Ji1hV5JpoHnGE,1700
8
+ lucky/console/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ lucky/console/rich.py,sha256=BC8aSdWmPfsVAC5KeO0BqfgkBq7qig1WFge4OxeeP3Y,4255
10
+ lucky/database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ lucky/database/db.py,sha256=f9T8SyDckbUPpgUTeqKV6_cOx4ISSliAQQPp855tiEo,499
12
+ lucky/database/models.py,sha256=AQBHsDPHHinWYdsnEUQBvnj_wA2Q4yMxLmUBZkrHlJA,1108
13
+ lucky/database/repository.py,sha256=mbXasRZnhgIVzEwrBbS3u1M0pZiuWkFq5kHTFgk1tjU,1687
14
+ lucky/lottery/__init__.py,sha256=MT3dhjv9bafpKopFOPSV3UZWTPWt_P91kFUJLdhjWKQ,82
15
+ lucky/lottery/base.py,sha256=DZ8ez4yNbg87zMyydqK81IViN7VbkvP2ga_ww0OdogA,1661
16
+ lucky/lottery/checker.py,sha256=h4x-IVEPyatUxG7zUJehpUVjiOnZNeyHuoQt7aFOhA4,530
17
+ lucky/lottery/generator.py,sha256=UiM7OBrdfdrH7lBsAdGzEEbdN5RXXIfq9ayssa9D8rQ,505
18
+ lucky/lottery/mega.py,sha256=2HZecjIG9LNXXZy6UqyrqNitHQYUvXlCOIzz5oh3lSU,244
19
+ lucky/lottery/powerball.py,sha256=CtnYs-ke5LP78w2CMMHIfusEr0_grX4HjG7EiS2bkNk,232
20
+ lucky/lottery/stats.py,sha256=mm_NvtM4q_OOAwX_6b5K070d5u8jV7vjFn5tMDoGDFY,1289
21
+ lucky/updater/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ lucky/updater/client.py,sha256=iXuSTzKJfOXDKPX_JJ2o_bguqlBUBanmDY6qpNwPdbM,187
23
+ lucky/updater/sources.py,sha256=eerYD3Vtd5sTY9VDihFdrI5Y0VMOh-1RLR4jzPPwv_U,983
24
+ lucky/updater/updater.py,sha256=B5Tf6ET5mZ98c-4z194_HhncB8h8L-9qfkr_gy1k_Wc,897
25
+ lucky/updater/fixtures/mega-millions.json,sha256=iG4wPzMOnNe95T2pncU5s-LAoEcwrLrINaHGFshylZA,199
26
+ lucky/updater/fixtures/powerball.json,sha256=RLsEGahCLeRe4ngJCQf4U8CM3RP13ej4Ly6E_gwoKbU,298
27
+ lucky_cli-0.0.3.dist-info/licenses/LICENSE,sha256=qqF1Zf1Z0TinvV92LGdwefIOPedisg9Eut7nrMFRKms,1067
28
+ lucky_cli-0.0.3.dist-info/METADATA,sha256=IyC1y2DK5fxsPXpC65qHNmLVdeVk-d9VMC9fSa181n8,5307
29
+ lucky_cli-0.0.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
30
+ lucky_cli-0.0.3.dist-info/entry_points.txt,sha256=81gNUKt-U-x2nbT0AU9kScWulDzMWGhbzzCNhwF_iIg,40
31
+ lucky_cli-0.0.3.dist-info/scm_file_list.json,sha256=nz6yoBqqevDlHTJb6nn3SZeglniyNnYSgi4v9Y_GW3M,1743
32
+ lucky_cli-0.0.3.dist-info/scm_version.json,sha256=x9sIn6TrwxSWq1vxhGPRLE3e9qrll_LI-Kg5ZaQ2ujA,160
33
+ lucky_cli-0.0.3.dist-info/top_level.txt,sha256=qC4BUwFImNyKkiZlGP2y4HecuUv4DppyIXN7wTt1TlI,6
34
+ lucky_cli-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lucky = lucky.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PyDeployer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "uv.lock",
5
+ ".python-version",
6
+ "LICENSE",
7
+ "pyproject.toml",
8
+ "AGENTS.md",
9
+ "CLAUDE.md",
10
+ ".gitignore",
11
+ "docs/superpowers/specs/2026-06-21-lucky-cli-design.md",
12
+ "docs/superpowers/plans/2026-06-21-lucky-cli-v1.md",
13
+ "src/lucky/__init__.py",
14
+ "src/lucky/config.py",
15
+ "src/lucky/cli.py",
16
+ "src/lucky/commands/__init__.py",
17
+ "src/lucky/commands/pick.py",
18
+ "src/lucky/commands/show.py",
19
+ "src/lucky/commands/check.py",
20
+ "src/lucky/updater/updater.py",
21
+ "src/lucky/updater/__init__.py",
22
+ "src/lucky/updater/sources.py",
23
+ "src/lucky/updater/client.py",
24
+ "src/lucky/updater/fixtures/powerball.json",
25
+ "src/lucky/updater/fixtures/mega-millions.json",
26
+ "src/lucky/console/__init__.py",
27
+ "src/lucky/console/rich.py",
28
+ "src/lucky/lottery/mega.py",
29
+ "src/lucky/lottery/generator.py",
30
+ "src/lucky/lottery/powerball.py",
31
+ "src/lucky/lottery/__init__.py",
32
+ "src/lucky/lottery/stats.py",
33
+ "src/lucky/lottery/checker.py",
34
+ "src/lucky/lottery/base.py",
35
+ "src/lucky/database/__init__.py",
36
+ "src/lucky/database/repository.py",
37
+ "src/lucky/database/models.py",
38
+ "src/lucky/database/db.py",
39
+ "tests/test_command_pick.py",
40
+ "tests/test_models.py",
41
+ "tests/test_repository.py",
42
+ "tests/test_command_check.py",
43
+ "tests/test_client.py",
44
+ "tests/test_console.py",
45
+ "tests/test_command_show.py",
46
+ "tests/test_stats.py",
47
+ "tests/conftest.py",
48
+ "tests/test_base.py",
49
+ "tests/test_updater.py",
50
+ "tests/test_games.py",
51
+ "tests/test_sources.py",
52
+ "tests/test_generator.py",
53
+ "tests/test_cli.py",
54
+ "tests/test_checker.py",
55
+ "tests/test_db.py",
56
+ ".github/workflows/release.yml"
57
+ ]
58
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.0.3",
3
+ "distance": 0,
4
+ "node": "g6e695db495be1678ad07637936dbcbabc4ef8f68",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-25"
8
+ }
@@ -0,0 +1 @@
1
+ lucky