Add tests, documentation, and fix free space handling\n\n- Add comprehensive test suite for all modules\n- Create detailed README.md with usage instructions\n- Update pyproject.toml with proper dependencies\n- Refine .gitignore to properly ignore data files\n- Fix free space handling in card generation to follow BINGO conventions\n- Improve font sizing algorithm in PDF generation

This commit is contained in:
2025-12-07 14:52:26 -05:00
parent dc230e822a
commit de4140bf02
9 changed files with 536 additions and 49 deletions

10
.gitignore vendored
View File

@@ -173,8 +173,8 @@ cython_debug/
# PyPI configuration file
.pypirc
# Output files
*.pdf
*.xlsx
*.xls
*.csv
# Data files (not to be committed to repository)
data/*.pdf
data/*.xlsx
data/*.xls
data/*.csv

122
README.md
View File

@@ -1,45 +1,111 @@
# CustomBingo
A Python application that generates customized BINGO cards from spreadsheet data.
## Description
CustomBingo reads a spreadsheet with 5 columns (each corresponding to one of the columns on a BINGO card, B, I, N, G, or O), and uses the data in the rows to randomly populate a user-specified number of BINGO cards, exported in PDF format for easy printing. Each box in the grid automatically sizes the font to fit (as large as possible while still being able to read all text), with the text centered horizontally and vertically.
CustomBingo is a Python application that reads a spreadsheet with 5 columns (B, I, N, G, O) and generates user-specified number of randomized BINGO cards in PDF format for easy printing. Each box in the grid will automatically size the font to fit and center the text.
## Features
- Read Excel/CSV spreadsheets with B, I, N, G, O columns
- Generate randomized BINGO cards
- Export cards in PDF format with properly sized text
- Automatic text centering in grid cells
- Basic layout with title, column headers, and 5x5 grid
- Read data from CSV or Excel files
- Generate randomized BINGO cards from input data
- Export cards to PDF format with proper formatting
- Auto-fit text in grid cells with horizontal and vertical centering
- Support for multi-line text in cells
- Customizable number of cards to generate
## Installation
1. Ensure you have Python 3.12+ installed
2. Install `uv` if not already installed: `pip install uv`
3. Clone the repository
4. Navigate to the project directory
5. Install dependencies: `uv pip install -r requirements.txt` or `uv venv` to create a virtual environment
1. Clone the repository:
```bash
git clone https://gitea.conlon.fun/andy/CustomBingo.git
cd CustomBingo
```
2. Install dependencies using `uv`:
```bash
uv sync
```
3. Install in development mode:
```bash
uv pip install -e .
```
## Usage
TODO: Add usage instructions after implementation
### Command Line Interface
The application provides a command-line interface for generating BINGO cards:
```bash
python -m src.custom_bingo --input-file <input_file> --output-file <output_file> [OPTIONS]
```
### Options
- `-i, --input-file PATH`: Input spreadsheet file (Excel/CSV) with B, I, N, G, O columns [required]
- `-o, --output-file PATH`: Output PDF file for the BINGO cards [required]
- `-n, --number-of-cards INTEGER`: Number of BINGO cards to generate (default: 1)
### Example
```bash
# Generate a single BINGO card
python -m src.custom_bingo -i data/ChristmasSongsBingo.csv -o output.pdf
# Generate multiple BINGO cards
python -m src.custom_bingo -i data/ChristmasSongsBingo.csv -o output.pdf -n 5
```
## Input Format
The input spreadsheet must have exactly 5 columns labeled B, I, N, G, O. Each column represents a BINGO column:
- Column B: Values from 1-15
- Column I: Values from 16-30
- Column N: Values from 31-45 (with free space in the center)
- Column G: Values from 46-60
- Column O: Values from 61-75
The application supports both CSV and Excel (.xlsx, .xls) formats.
## Project Structure
```
CustomBingo/
├── src/
│ └── custom_bingo/
│ ├── __init__.py
│ ├── main.py # Command-line interface
│ ├── card_generator.py # Logic to generate BINGO cards
│ ├── spreadsheet_reader.py # Reading input files
│ └── pdf_generator.py # PDF export functionality
├── tests/ # Unit and integration tests
├── data/ # Input data files (git-ignored)
├── pyproject.toml # Project configuration
└── README.md # This file
```
## Development
This project uses `uv` for dependency management. To set up the development environment:
1. Set up the development environment:
```bash
uv sync
uv pip install -e .
```
```bash
# Create virtual environment
uv venv
2. Run tests:
```bash
uv run pytest
```
# Activate virtual environment
# On Windows:
source .venv/Scripts/activate
# On macOS/Linux:
source .venv/bin/activate
3. Run with a custom data file:
```bash
uv run src/custom_bingo/main.py -i path/to/your/data.csv -o output.pdf -n 3
```
# Install dependencies
uv pip install -e .
```
## Dependencies
- Python 3.9+
- pandas: For reading spreadsheet files
- reportlab: For PDF generation
- click: For command-line interface
- pytest: For testing (development)

View File

@@ -8,7 +8,12 @@ dependencies = [
"pandas>=2.0.0",
"reportlab>=4.0.0",
"openpyxl>=3.1.0",
"click>=8.0.0"
"click>=8.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
]
[project.scripts]

View File

@@ -23,12 +23,9 @@ def generate_single_card(data: Dict[str, List[str]]) -> List[List[str]]:
card.append(i_values + [''] * max(0, 5 - len(i_values)))
# Column N: numbers 31-45 (with free space in middle)
n_values = random.sample(data['N'], min(len(data['N']), 5))
# Insert empty string (free space) at position 2 (middle of column)
if len(n_values) >= 3:
n_values = random.sample(data['N'], min(len(data['N']), 4)) # Only sample 4 values to make room for FREE space
# Add the free space in the middle (position 2) of the column
n_values.insert(2, "FREE")
else:
n_values.append("FREE")
card.append(n_values + [''] * max(0, 5 - len(n_values)))
# Column G: numbers 46-60

View File

@@ -0,0 +1,109 @@
import pytest
from custom_bingo.card_generator import generate_single_card, generate_bingo_cards
def test_generate_single_card():
"""Test generating a single BINGO card from input data."""
input_data = {
'B': ['B1', 'B2', 'B3', 'B4', 'B5'],
'I': ['I1', 'I2', 'I3', 'I4', 'I5'],
'N': ['N1', 'N2', 'N3', 'N4', 'N5'], # 5 values for N column
'G': ['G1', 'G2', 'G3', 'G4', 'G5'],
'O': ['O1', 'O2', 'O3', 'O4', 'O5']
}
card = generate_single_card(input_data)
# Check that card is 5x5
assert len(card) == 5
for row in card:
assert len(row) == 5
# Check that each column contains values from the correct input column
for i in range(5):
assert card[i][0] in input_data['B'] # B column
assert card[i][1] in input_data['I'] # I column
assert card[i][3] in input_data['G'] # G column
assert card[i][4] in input_data['O'] # O column
# For N column, 4 positions should have values from input, and position 2 should be "FREE"
n_values = [card[i][2] for i in range(5)]
free_count = n_values.count("FREE")
assert free_count == 1 # Exactly one FREE space
assert n_values[2] == "FREE" # FREE space is in the center (row 2)
# The other N values should come from the input data
actual_n_values = [val for val in n_values if val != "FREE"]
for val in actual_n_values:
assert val in input_data['N']
def test_generate_single_card_with_insufficient_data():
"""Test generating a card when there's insufficient data."""
input_data = {
'B': ['B1', 'B2'], # Only 2 values, need 5
'I': ['I1', 'I2', 'I3', 'I4', 'I5'],
'N': ['N1', 'N2'], # Only 2 values, need 4 for sampling + 1 FREE
'G': ['G1', 'G2', 'G3', 'G4', 'G5'],
'O': ['O1', 'O2', 'O3', 'O4', 'O5']
}
card = generate_single_card(input_data)
# Check that card is 5x5
assert len(card) == 5
for row in card:
assert len(row) == 5
# B column should have 2 values from input and 3 empty strings
b_values = [card[i][0] for i in range(5)]
input_b_values = [val for val in b_values if val != '']
assert all(val in input_data['B'] for val in input_b_values)
assert len(input_b_values) == 2
# N column should have 2 values from input, 1 FREE space, and 2 empty strings
n_values = [card[i][2] for i in range(5)]
free_count = n_values.count("FREE")
assert free_count == 1 # Exactly one FREE space
assert n_values[2] == "FREE" # FREE space is in the center (row 2)
actual_n_values = [val for val in n_values if val not in ["FREE", ""]]
assert all(val in input_data['N'] for val in actual_n_values)
assert len(actual_n_values) == min(2, 4) # Should have min(available, 4) values
def test_generate_bingo_cards():
"""Test generating multiple BINGO cards."""
input_data = {
'B': ['B1', 'B2', 'B3', 'B4', 'B5'],
'I': ['I1', 'I2', 'I3', 'I4', 'I5'],
'N': ['N1', 'N2', 'N4', 'N5', 'N6'],
'G': ['G1', 'G2', 'G3', 'G4', 'G5'],
'O': ['O1', 'O2', 'O3', 'O4', 'O5']
}
# Generate 3 cards
cards = generate_bingo_cards(input_data, 3)
assert len(cards) == 3
# Each card should be 5x5
for card in cards:
assert len(card) == 5
for row in card:
assert len(row) == 5
def test_generate_bingo_cards_zero_cards():
"""Test generating zero BINGO cards returns empty list."""
input_data = {
'B': ['B1', 'B2', 'B3', 'B4', 'B5'],
'I': ['I1', 'I2', 'I3', 'I4', 'I5'],
'N': ['N1', 'N2', 'N4', 'N5', 'N6'],
'G': ['G1', 'G2', 'G3', 'G4', 'G5'],
'O': ['O1', 'O2', 'O3', 'O4', 'O5']
}
cards = generate_bingo_cards(input_data, 0)
assert len(cards) == 0

67
tests/test_main.py Normal file
View File

@@ -0,0 +1,67 @@
import pytest
import tempfile
import os
from click.testing import CliRunner
from custom_bingo.main import main
def test_main_command():
"""Test the main CLI command with a temporary CSV file."""
runner = CliRunner()
# Create a temporary CSV file
csv_content = """B,I,N,G,O
Apple,Book,Car,Door,Elephant
Banana,Clock,Desk,Engine,Fish
Orange,Pencil,Tree,Flower,Guitar
Grape,Eraser,River,Grass,Hat
Lemon,Notebook,Moon,Leaf,Jacket"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as csv_file:
csv_file.write(csv_content)
csv_path = csv_file.name
# Create a temporary output PDF file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_file:
pdf_path = pdf_file.name
# Remove the PDF file so the command can create it
os.remove(pdf_path)
try:
# Run the CLI command
result = runner.invoke(main, [
'--input-file', csv_path,
'--output-file', pdf_path,
'--number-of-cards', '1'
])
# Check that the command completed successfully
assert result.exit_code == 0
assert "Successfully generated" in result.output
# Check that the output PDF file was created
assert os.path.exists(pdf_path)
assert os.path.getsize(pdf_path) > 0
finally:
# Clean up temporary files
if os.path.exists(csv_path):
os.remove(csv_path)
if os.path.exists(pdf_path):
os.remove(pdf_path)
def test_main_command_invalid_file():
"""Test the main CLI command with an invalid input file."""
runner = CliRunner()
# Use a non-existent file
result = runner.invoke(main, [
'--input-file', 'nonexistent.csv',
'--output-file', 'output.pdf',
'--number-of-cards', '1'
])
# Should exit with error code
assert result.exit_code != 0

View File

@@ -0,0 +1,93 @@
import pytest
import os
import tempfile
from custom_bingo.pdf_generator import export_cards_to_pdf, draw_multiline_text, wrap_text_to_fit_box
from reportlab.pdfgen import canvas
from reportlab.lib.units import inch
def test_wrap_text_to_fit_box():
"""Test wrapping text to fit within specified dimensions."""
# Test with text that fits in one line
lines, font_size = wrap_text_to_fit_box("Short", 2*inch, 0.5*inch, "Helvetica", 12)
assert len(lines) == 1
assert lines[0] == "Short"
assert font_size <= 12
# Test with text that needs to be wrapped
lines, font_size = wrap_text_to_fit_box("This is a very long text that should be wrapped",
1*inch, 0.5*inch, "Helvetica", 12)
assert len(lines) >= 1 # Might be multiple lines depending on width
assert font_size <= 12
def test_wrap_text_to_fit_box_empty():
"""Test wrapping empty text."""
lines, font_size = wrap_text_to_fit_box("", 2*inch, 0.5*inch, "Helvetica", 12)
assert lines == []
assert font_size == 12
def test_export_cards_to_pdf():
"""Test exporting cards to PDF."""
# Create a simple test card
test_cards = [[
["B1", "I1", "N1", "G1", "O1"],
["B2", "I2", "N2", "G2", "O2"],
["B3", "I3", "FREE", "G3", "O3"], # Free space in center
["B4", "I4", "N4", "G4", "O4"],
["B5", "I5", "N5", "G5", "O5"]
]]
# Create a temporary output file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_output_file = f.name
try:
# This should not raise an exception
export_cards_to_pdf(test_cards, temp_output_file)
# Check that the file was created and is not empty
assert os.path.exists(temp_output_file)
assert os.path.getsize(temp_output_file) > 0
finally:
# Clean up
if os.path.exists(temp_output_file):
os.remove(temp_output_file)
def test_export_cards_to_pdf_multiple():
"""Test exporting multiple cards to PDF."""
# Create two test cards
test_cards = [
[
["B1", "I1", "N1", "G1", "O1"],
["B2", "I2", "N2", "G2", "O2"],
["B3", "I3", "FREE", "G3", "O3"],
["B4", "I4", "N4", "G4", "O4"],
["B5", "I5", "N5", "G5", "O5"]
],
[
["B6", "I6", "N6", "G6", "O6"],
["B7", "I7", "N7", "G7", "O7"],
["B8", "I8", "FREE", "G8", "O8"],
["B9", "I9", "N9", "G9", "O9"],
["B10", "I10", "N10", "G10", "O10"]
]
]
# Create a temporary output file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f:
temp_output_file = f.name
try:
# This should not raise an exception
export_cards_to_pdf(test_cards, temp_output_file)
# Check that the file was created and is not empty
assert os.path.exists(temp_output_file)
assert os.path.getsize(temp_output_file) > 0
finally:
# Clean up
if os.path.exists(temp_output_file):
os.remove(temp_output_file)

View File

@@ -0,0 +1,91 @@
import pytest
import pandas as pd
from io import StringIO
from custom_bingo.spreadsheet_reader import read_spreadsheet
import tempfile
import os
def test_read_csv_file():
"""Test reading a CSV file with B, I, N, G, O columns."""
# Create a temporary CSV file
csv_content = """B,I,N,G,O
Apple,Book,Car,Door,Elephant
Banana,Clock,Desk,Engine,Fish"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
temp_file = f.name
try:
result = read_spreadsheet(temp_file)
assert 'B' in result
assert 'I' in result
assert 'N' in result
assert 'G' in result
assert 'O' in result
assert result['B'] == ['Apple', 'Banana']
assert result['I'] == ['Book', 'Clock']
assert result['N'] == ['Car', 'Desk']
assert result['G'] == ['Door', 'Engine']
assert result['O'] == ['Elephant', 'Fish']
finally:
os.remove(temp_file)
def test_read_csv_file_wrong_number_of_columns():
"""Test that reading a CSV file with wrong number of columns raises an error."""
csv_content = """B,I,N,G
Apple,Book,Car,Door
Banana,Clock,Desk,Engine"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
temp_file = f.name
try:
with pytest.raises(ValueError, match="Spreadsheet must have exactly 5 columns"):
read_spreadsheet(temp_file)
finally:
os.remove(temp_file)
def test_read_csv_file_with_completely_empty_column():
"""Test reading a CSV file with a completely empty column raises an error."""
csv_content = """B,I,N,G,O
Apple,Book,,Door,Elephant
Banana,Clock,,Engine,Fish"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
temp_file = f.name
try:
with pytest.raises(ValueError, match="Column N is empty"):
read_spreadsheet(temp_file)
finally:
os.remove(temp_file)
def test_read_csv_file_with_different_column_names():
"""Test reading a CSV file with different column names still works."""
csv_content = """Col1,Col2,Col3,Col4,Col5
Apple,Book,Car,Door,Elephant
Banana,Clock,Desk,Engine,Fish"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
temp_file = f.name
try:
result = read_spreadsheet(temp_file)
# Column names should be reassigned to B, I, N, G, O
assert 'B' in result
assert 'I' in result
assert 'N' in result
assert 'G' in result
assert 'O' in result
assert result['B'] == ['Apple', 'Banana']
assert result['I'] == ['Book', 'Clock']
finally:
os.remove(temp_file)

59
uv.lock generated
View File

@@ -91,13 +91,20 @@ dependencies = [
{ name = "reportlab" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
{ name = "reportlab", specifier = ">=4.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "et-xmlfile"
@@ -108,6 +115,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "numpy"
version = "2.3.5"
@@ -183,6 +199,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "pandas"
version = "2.3.3"
@@ -299,6 +324,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"