Compare commits

...

11 Commits
v0.2.2 ... main

Author SHA1 Message Date
cfba2303e6 remove default empty option
select now defaults to Lotto

patch bump
2026-02-26 01:02:35 +00:00
a2ab27fa5b bump to version 1.0.0. TUI is considered complete. 2026-02-25 21:39:47 +00:00
5d6836a603 add _read_lottery_selection() helper method
patch bump
2026-02-23 19:58:47 +00:00
dbbc32f4ec swap out the NamedTuple for a frozen dataclass.
move the responsibility of sorting the numbers from the caller to the class initialisation.
(now the string representation matches the internal state)

move logging of ValueError from tui.py to lottery.py

patch bump
2026-02-23 16:53:33 +00:00
8b743abcfb rename UK Lotto to Lotto to match the website.
patch  bump
2026-02-22 23:34:10 +00:00
56028b2c52 get Result kind name from class type, it's more flexible to change.
patch bump
2026-02-22 23:24:36 +00:00
67b3887bc8 should we get a ValueError when requesting a lottery object allow it to bubble up.
patch bump
2026-02-22 14:33:48 +00:00
2e0052b5fe upd docstrings 2026-02-22 13:29:06 +00:00
a986348168 add python version classifiers
patch bump
2026-02-21 22:57:25 +00:00
bccf1b1ad2 upd pdm badge 2026-02-21 22:16:14 +00:00
3ecfb84a87 remove comment 2026-02-21 22:12:55 +00:00
5 changed files with 110 additions and 39 deletions

View File

@ -1,6 +1,6 @@
# Lottery TUI
[![pdm-managed](https://img.shields.io/badge/pdm-managed-blueviolet)](https://pdm.fming.dev)
[![pdm-managed](https://img.shields.io/endpoint?url=https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fpdm-project%2F.github%2Fbadge.json)](https://pdm-project.org)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![PyPI - Version](https://img.shields.io/pypi/v/lottery-tui.svg)](https://pypi.org/project/lottery-tui)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/lottery-tui.svg)](https://pypi.org/project/lottery-tui)

43
pdm.lock generated
View File

@ -5,11 +5,23 @@
groups = ["default"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:842afa14523f463c1a73e53c7aeb6d697673d95a2db9adbf935807b1fe5d021a"
content_hash = "sha256:6cd4ed6668a18d93170023df0e7cf183ac36d04df220f4dcb4c091eb6623b65f"
[[metadata.targets]]
requires_python = "==3.13.*"
[[package]]
name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["default"]
marker = "sys_platform == \"win32\" and python_version == \"3.13\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "linkify-it-py"
version = "2.0.3"
@ -25,6 +37,23 @@ files = [
{file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"},
]
[[package]]
name = "loguru"
version = "0.7.3"
requires_python = "<4.0,>=3.5"
summary = "Python logging made (stupidly) simple"
groups = ["default"]
marker = "python_version == \"3.13\""
dependencies = [
"aiocontextvars>=0.2.0; python_version < \"3.7\"",
"colorama>=0.3.4; sys_platform == \"win32\"",
"win32-setctime>=1.0.0; sys_platform == \"win32\"",
]
files = [
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@ -167,3 +196,15 @@ files = [
{file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"},
{file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"},
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
requires_python = ">=3.5"
summary = "A small Python utility to set file creation time on Windows"
groups = ["default"]
marker = "sys_platform == \"win32\" and python_version == \"3.13\""
files = [
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
]

View File

@ -1,12 +1,22 @@
[project]
name = "lottery-tui"
version = "0.2.2"
version = "1.0.1"
description = "A terminal user interface for lottery games."
authors = [{ name = "onyx-and-iris", email = "code@onyxandiris.online" }]
dependencies = ["textual>=8.0.0"]
dependencies = ["textual>=8.0.0", "loguru>=0.7.3"]
requires-python = ">=3.10"
readme = "README.md"
license = { text = "MIT" }
classifiers = [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
[project.scripts]
lottery-tui = "lottery_tui.tui:main"

View File

@ -1,29 +1,41 @@
import random
from abc import ABC, abstractmethod
from typing import NamedTuple
from dataclasses import dataclass
from loguru import logger
class Result(NamedTuple):
"""A named tuple to hold the results of a lottery draw."""
@dataclass(frozen=True)
class Result:
"""A dataclass to hold the results of a lottery draw with auto-sorted numbers."""
kind: str
numbers: list[int]
bonus: list[int] | None
def __post_init__(self):
"""Sort the numbers after initialization.
We use super().__setattr__ to bypass the frozen nature of the dataclass for sorting.
"""
super().__setattr__('numbers', sorted(self.numbers))
if self.bonus:
super().__setattr__('bonus', sorted(self.bonus))
def __str__(self) -> str:
"""Return a string representation of the lottery result."""
out = f'Numbers: {", ".join(str(n) for n in sorted(self.numbers))}'
out = f'Numbers: {", ".join(str(n) for n in self.numbers)}'
if self.bonus:
match self.kind:
case 'EuroMillions':
bonus_name = 'Lucky Stars'
case 'Set For Life':
case 'SetForLife':
bonus_name = 'Life Ball'
case 'Thunderball':
bonus_name = 'Thunderball'
case _:
bonus_name = 'Bonus Numbers'
out += f'\n{bonus_name}: {", ".join(str(n) for n in sorted(self.bonus))}'
out += f'\n{bonus_name}: {", ".join(str(n) for n in self.bonus)}'
return out
@ -45,19 +57,19 @@ class Lottery(ABC):
@register_lottery
class UKlotto(Lottery):
"""A class representing the UK Lotto lottery.
class Lotto(Lottery):
"""A class representing the Lotto lottery.
Uk Lotto draws 6 numbers from a pool of 1 to 59, without replacement.
There is no bonus number in UK Lotto.
Lotto draws 6 numbers from a pool of 1 to 59, without replacement.
There is no bonus number in Lotto.
"""
POSSIBLE_NUMBERS = range(1, 60)
def draw(self) -> Result:
"""Perform a UK Lotto draw."""
result = random.sample(UKlotto.POSSIBLE_NUMBERS, 6)
return Result(kind='UK Lotto', numbers=result, bonus=None)
"""Perform a Lotto draw."""
result = random.sample(Lotto.POSSIBLE_NUMBERS, 6)
return Result(kind=type(self).__name__, numbers=result, bonus=None)
@register_lottery
@ -75,7 +87,7 @@ class EuroMillions(Lottery):
"""Perform a EuroMillions draw."""
numbers = random.sample(EuroMillions.POSSIBLE_NUMBERS, 5)
bonus = random.sample(EuroMillions.POSSIBLE_BONUS_NUMBERS, 2)
return Result(kind='EuroMillions', numbers=numbers, bonus=bonus)
return Result(kind=type(self).__name__, numbers=numbers, bonus=bonus)
@register_lottery
@ -83,7 +95,7 @@ class SetForLife(Lottery):
"""A class representing the Set For Life lottery.
Set For Life draws 5 numbers from a pool of 1 to 39, without replacement,
and 1 "Life Ball" number from a separate pool of 1 to 10, also without replacement.
and 1 "Life Ball" number from a separate pool of 1 to 10.
"""
POSSIBLE_NUMBERS = range(1, 40)
@ -92,7 +104,7 @@ class SetForLife(Lottery):
"""Perform a Set For Life draw."""
numbers = random.sample(SetForLife.POSSIBLE_NUMBERS, 5)
life_ball = [random.randint(1, 10)]
return Result(kind='Set For Life', numbers=numbers, bonus=life_ball)
return Result(kind=type(self).__name__, numbers=numbers, bonus=life_ball)
@register_lottery
@ -100,21 +112,23 @@ class Thunderball(Lottery):
"""A class representing the Thunderball lottery.
Thunderball draws 5 numbers from a pool of 1 to 39, without replacement,
and 1 "Thunderball" number from a separate pool of 1 to 14, also without replacement.
and 1 "Thunderball" number from a separate pool of 1 to 14.
"""
POSSIBLE_NUMBERS = range(1, 40) # Thunderball numbers range from 1 to 39
POSSIBLE_NUMBERS = range(1, 40)
def draw(self) -> Result:
"""Perform a Thunderball draw."""
numbers = random.sample(Thunderball.POSSIBLE_NUMBERS, 5)
thunderball = [random.randint(1, 14)]
return Result(kind='Thunderball', numbers=numbers, bonus=thunderball)
return Result(kind=type(self).__name__, numbers=numbers, bonus=thunderball)
def request_lottery_obj(lottery_name: str) -> Lottery:
"""Return a lottery object based on the provided lottery name."""
lottery_cls = registry.get(lottery_name.lower())
if lottery_cls is None:
raise ValueError(f"Lottery '{lottery_name}' not found.")
ERR_MSG = f"Lottery '{lottery_name}' not found. Available lotteries: {', '.join(registry.keys())}"
logger.error(ERR_MSG)
raise ValueError(ERR_MSG)
return lottery_cls()

View File

@ -1,5 +1,9 @@
from typing import NoReturn
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.events import Key
from textual.types import SelectType
from textual.widgets import Button, Label, Select, Static
from .lottery import request_lottery_obj
@ -17,11 +21,13 @@ class LotteryTUI(App):
Static('Pick a lottery to play:', id='instructions'),
Select(
options=[
('UK Lotto', 'uklotto'),
('Lotto', 'lotto'),
('EuroMillions', 'euromillions'),
('Set For Life', 'setforlife'),
('Thunderball', 'thunderball'),
],
value='lotto',
allow_blank=False,
id='lottery-select',
),
Button('Draw', id='draw-button'),
@ -29,29 +35,29 @@ class LotteryTUI(App):
id='main-container',
)
def on_key(self, event):
def on_key(self, event: Key) -> NoReturn:
"""Handle key events."""
if event.key == 'q':
self.exit()
def on_button_pressed(self, event):
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press events."""
if event.button.id == 'draw-button':
if self.query_one('#lottery-select').is_blank():
self.query_one('#result-label').update(
'Please select a lottery before drawing.'
)
return
self._draw_button_handler()
selected_lottery = self.query_one('#lottery-select').value
try:
lottery_obj = request_lottery_obj(selected_lottery)
def _draw_button_handler(self) -> None:
"""Handle the draw button press."""
lottery_obj = request_lottery_obj(self._read_lottery_selection())
result = lottery_obj.draw()
except ValueError as e:
self.query_one('#result-label').update(str(e))
self._update_result_label(str(result))
self.query_one('#result-label').update(str(result))
def _read_lottery_selection(self) -> SelectType:
"""Read the selected lottery from the dropdown."""
return self.query_one('#lottery-select').value
def _update_result_label(self, message: str) -> None:
"""Update the result label with a new message."""
self.query_one('#result-label').update(message)
def main():