A command-line tournament management tool built in Python. Supports Swiss and Round-Robin pairing systems with a PostgreSQL-backed standings table, automatic Buchholz tiebreaker calculation, and Docker Compose support for easy deployment.
- Requirements
- Installation
- Configuration
- Commands
- Tournament Workflows
- Scoring
- Tiebreakers
- Testing
- Database Schema
- Project Structure
- Python 3.11+
- PostgreSQL 15+
- pip
- Docker + Docker Compose (optional, for containerised setup)
git clone https://github.com/sdmmdv/tourman.git
cd tourman
python3 -m venv .venv
source .venv/bin/activate
pip install .For development — changes to source files take effect immediately without reinstalling:
pip install -e .The repository ships with a docker-compose.yml that starts a PostgreSQL 15 database and a tourman application container.
1. Start the services:
docker compose up -dThis will:
- Start
tourman_db— PostgreSQL 15 on port5432 - Start
tourman_app— Python 3.11 container withtourmaninstalled
2. Open a shell in the app container:
docker exec -it tourman_app bash3. Run commands inside the container:
tourman init-db
tourman generate-players
tourman register-players
tourman register-standings4. Stop services:
docker compose downTo also remove the database volume (
docker compose down -vtourman connects to PostgreSQL using environment variables. Set them in your shell or in a .env file:
export DB_NAME=tournament
export DB_USER=app_admin
export DB_PASS=admin_secure_pass
export DB_HOST=localhost # default: localhost
export DB_PORT=5432 # default: 5432When using Docker Compose, these variables are already set in the app service definition inside docker-compose.yml.
You can also override the connection string per-command with --conn:
tourman generate-swiss-pairings -r 1 --conn "postgresql://app_admin:admin_secure_pass@localhost:5432/tournament"| Command | Description |
|---|---|
tourman init-db |
Create database tables and roles |
tourman generate-players [-n N] |
Generate N fake players (default: 10) |
tourman register-players |
Insert generated players into the DB |
tourman register-standings |
Initialise standings rows for all players |
| Command | Description |
|---|---|
tourman generate-swiss-pairings -r <N> |
Generate Swiss pairings for round N |
tourman generate-roundrobin-pairings |
Generate all Round-Robin pairings at once |
tourman populate-results -f <file> |
Fill a pairings CSV with random scores (testing) |
tourman register-results -f <file> |
Validate and insert a results CSV into the DB |
tourman apply-results -r <N> |
Apply round N results to the standings table |
| Command | Description |
|---|---|
tourman print-table -t <table> |
Print standings, results, or players |
tourman convert-excel-to-csv |
Convert an Excel file to CSV |
tourman convert-table-to-excel |
Export DB tables to Excel |
| Flag | Description |
|---|---|
-v, --verbose |
Enable DEBUG-level logging |
--conn <string> |
Override PostgreSQL connection string |
Swiss pairing matches players of similar scores each round, avoiding rematches. The recommended number of rounds is ceil(log2(N)) where N is the player count — for 10 players that is 4 rounds, though up to N/2 rounds is acceptable.
tourman init-db
tourman generate-players -n 10
tourman register-players
tourman register-standingsExpected output:
[20:26:41] [core.init_db] INFO: Connection to the database was successful.
[20:26:41] [core.init_db] INFO: tables.sql executed.
[20:26:41] [core.init_db] INFO: table_permissions.sql executed.
[20:26:41] [core.generate_players] INFO: Generating 10 players...
[20:26:41] [core.generate_players] INFO: Successfully wrote 10 players to /home/user/tourman/data/players.csv
[20:26:41] [core.register_players] INFO: All players registered successfully.
[20:26:41] [core.register_standings] INFO: Standings table filled successfully
Generate pairings, fill in real scores in the CSV, then register and apply:
tourman generate-swiss-pairings -r 1This writes data/pairings_r1.csv:
round_id,player1_id,player1_name,player1_score,player2_score,player2_name,player2_id
1,p001,Aaron Duncan,?,?,Jessica Vega,p002
1,p003,Amy Friedman,?,?,Denise English,p004
...
Edit the file and replace ? with real scores (1.0/0.0, 0.5/0.5), save it as data/results_r1.csv, then:
tourman register-results -f data/results_r1.csv
tourman apply-results -r 1
tourman print-table -t standingsStandings after round 1:
rank | name | matches | t2 | t1 | points
------+---------------------------+---------+------+------+--------
1 | Jessica Vega | 1 | 0.00 | 0.00 | 1.0
2 | Amy Friedman | 1 | 0.00 | 0.00 | 1.0
3 | Denise English | 1 | 0.00 | 0.00 | 1.0
4 | Aaron Duncan | 1 | 0.00 | 0.00 | 1.0
5 | Timothy Levine | 1 | 0.00 | 0.50 | 0.5
6 | Stephen Bernard | 1 | 0.00 | 0.50 | 0.5
7 | Brad Smith | 1 | 0.00 | 1.00 | 0.0
8 | James Thompson | 1 | 0.00 | 1.00 | 0.0
9 | Nicholas Cantu | 1 | 0.00 | 1.00 | 0.0
10 | Debra Jensen DDS | 1 | 0.00 | 1.00 | 0.0
Columns: t2 = Tiebreaker B (reserved), t1 = Tiebreaker A (Buchholz — sum of opponents' points).
Note: After round 1, Buchholz scores are
0.00because no opponents have accumulated points from previous rounds yet. They become meaningful from round 2 onwards.
Use populate-results to fill pairings with random scores instead of entering them manually. Useful for testing and development:
for r in $(seq 1 5); do
echo "=== Round $r ==="
tourman generate-swiss-pairings -r $r &&
tourman populate-results -f data/pairings_r$r.csv &&
tourman register-results -f data/results_r$r.csv &&
tourman apply-results -r $r
tourman print-table -t standings
doneAfter 5 rounds the final standings look like:
=== Round 5 ===
[core.generate_swiss_pairings] WARNING: Recommended rounds ≈ 4 (based on log2(10)), but using up to 5 is acceptable.
[core.generate_swiss_pairings] INFO: Round 5 is valid (previous max round = 4)
[core.generate_swiss_pairings] INFO: Pairings CSV file generated successfully: data/pairings_r5.csv
[utils.populate_results] INFO: Results written to data/results_r5.csv
[core.register_results] INFO: Results stored successfully.
[core.apply_results_to_standings] INFO: Record applied successfully
[core.apply_results_to_standings] INFO: Buchholz tie-breaker recalculated successfully.
rank | name | matches | t2 | t1 | points
------+---------------------------+---------+------+-------+--------
1 | Aaron Duncan | 5 | 0.00 | 13.50 | 4.5
2 | Stephen Bernard | 5 | 0.00 | 10.00 | 3.5
3 | Jessica Vega | 5 | 0.00 | 13.50 | 3.0
4 | Brad Smith | 5 | 0.00 | 11.00 | 3.0
5 | Denise English | 5 | 0.00 | 15.00 | 2.5
6 | Amy Friedman | 5 | 0.00 | 13.00 | 2.5
7 | Debra Jensen DDS | 5 | 0.00 | 12.00 | 2.5
8 | Timothy Levine | 5 | 0.00 | 11.50 | 2.5
9 | James Thompson | 5 | 0.00 | 12.00 | 1.5
10 | Nicholas Cantu | 5 | 0.00 | 13.00 | 0.5
Notice that players 3 and 4 (Jessica Vega and Brad Smith) are both on 3.0 points — Buchholz (t1) breaks the tie. Similarly, players 5–8 are all on 2.5 and are separated by their Buchholz scores.
Every player faces every other player exactly once. For N players: N-1 rounds (even N) or N rounds (odd N, with a rotating BYE). For 10 players that is 9 rounds. Unlike Swiss, all pairings are generated upfront in a single command before any round is played.
tourman init-db
tourman generate-players -n 10
tourman register-players
tourman register-standingsExpected output:
[21:00:15] [core.init_db] INFO: Connection to the database was successful.
[21:00:15] [core.init_db] INFO: tables.sql executed.
[21:00:15] [core.init_db] INFO: table_permissions.sql executed.
[21:00:15] [core.generate_players] INFO: Generating 10 players...
[21:00:15] [core.generate_players] INFO: Successfully wrote 10 players to /home/user/tourman/data/players.csv
[21:00:15] [core.register_players] INFO: All players registered successfully.
[21:00:15] [core.register_standings] INFO: Standings table filled successfully
tourman generate-roundrobin-pairingsAll 9 round CSV files are written immediately:
[21:00:15] [core.generate_roundrobin_pairings] INFO: Pairings CSV file generated successfully: data/pairings_r1.csv
[21:00:15] [core.generate_roundrobin_pairings] INFO: Pairings CSV file generated successfully: data/pairings_r2.csv
[21:00:15] [core.generate_roundrobin_pairings] INFO: Pairings CSV file generated successfully: data/pairings_r3.csv
...
[21:00:15] [core.generate_roundrobin_pairings] INFO: Pairings CSV file generated successfully: data/pairings_r9.csv
Each file (data/pairings_r1.csv through data/pairings_r9.csv) contains that round's matchups with ? placeholders for scores:
round_id,player1_id,player1_name,player1_score,player2_score,player2_name,player2_id
1,p001,Beth Castillo,?,?,Katie Goodwin,p002
1,p003,Rachel Goodwin,?,?,Christopher Pennington,p004
...
Important: Do not re-run
generate-roundrobin-pairingsbetween rounds — all pairings are fixed upfront and must remain consistent throughout the tournament.
For each round, fill in scores (or use populate-results for testing), then register and apply:
# Fill data/pairings_r1.csv with real scores and save as data/results_r1.csv, or:
tourman populate-results -f data/pairings_r1.csv # random scores for testing
tourman register-results -f data/results_r1.csv
tourman apply-results -r 1
tourman print-table -t standingsStandings after round 1:
rank | name | matches | t2 | t1 | points
------+---------------------------+---------+------+------+--------
1 | Beth Castillo | 1 | 0.00 | 0.00 | 1.0
2 | Rachel Goodwin | 1 | 0.00 | 0.00 | 1.0
3 | John Gonzalez | 1 | 0.00 | 0.00 | 1.0
4 | Steven Williams | 1 | 0.00 | 0.50 | 0.5
5 | Trevor Robinson | 1 | 0.00 | 0.50 | 0.5
6 | Mr. Jonathan Gilbert | 1 | 0.00 | 0.50 | 0.5
7 | Alicia Turner | 1 | 0.00 | 0.50 | 0.5
8 | Katie Goodwin | 1 | 0.00 | 1.00 | 0.0
9 | Christopher Pennington | 1 | 0.00 | 1.00 | 0.0
10 | Stephanie Burnett | 1 | 0.00 | 1.00 | 0.0
for r in $(seq 1 9); do
echo "=== Round $r ==="
tourman populate-results -f data/pairings_r$r.csv &&
tourman register-results -f data/results_r$r.csv &&
tourman apply-results -r $r
tourman print-table -t standings
doneFinal standings after all 9 rounds (every player has faced every other player once):
=== Round 9 ===
[core.apply_results_to_standings] INFO: Record applied successfully
[core.apply_results_to_standings] INFO: Buchholz tie-breaker recalculated successfully.
rank | name | matches | t2 | t1 | points
------+---------------------------+---------+------+-------+--------
1 | Beth Castillo | 9 | 0.00 | 19.00 | 6.5
2 | Mr. Jonathan Gilbert | 9 | 0.00 | 19.00 | 6.0
3 | John Gonzalez | 9 | 0.00 | 19.00 | 5.5
4 | Stephanie Burnett | 9 | 0.00 | 26.00 | 5.0
5 | Steven Williams | 9 | 0.00 | 19.00 | 4.5
6 | Trevor Robinson | 9 | 0.00 | 26.00 | 4.0
7 | Alicia Turner | 9 | 0.00 | 26.00 | 4.0
8 | Christopher Pennington | 9 | 0.00 | 26.00 | 3.5
9 | Rachel Goodwin | 9 | 0.00 | 19.00 | 3.5
10 | Katie Goodwin | 9 | 0.00 | 26.00 | 2.5
Every player has played exactly 9 matches. Players 6 and 7 (Trevor Robinson and Alicia Turner) share 4.0 points — Buchholz (t1) resolves the tie. Players with a high t1 (e.g. 26.00) faced stronger competition overall, even if their final score is lower.
Note: In Round-Robin, Buchholz scores grow significantly as the tournament progresses because every player has accumulated points from many games. By round 9,
t1values clearly separate players who played equally well on points.
| Outcome | player1_score | player2_score |
|---|---|---|
| Player 1 wins | 1.0 | 0.0 |
| Draw | 0.5 | 0.5 |
| Player 2 wins | 0.0 | 1.0 |
| BYE (player 1 rests) | 1.0 | — |
Scores in the results CSV must sum to exactly 1.0. Invalid scores are rejected by register-results.
When players are equal on points, standings are ordered by:
- Points — total accumulated score
- Tiebreaker A (Buchholz) — sum of all opponents' total points; rewards playing stronger competition
- Tiebreaker B — reserved for future use
- Tiebreaker C — reserved for future use
Buchholz is recalculated automatically after every apply-results call.
The test suite is written with pytest and covers the full Swiss-tournament CLI pipeline end-to-end: database initialisation, player generation, registration, Swiss pairing, result population, result registration, standings updates, and table printing.
| Requirement | Version |
|---|---|
| Python | 3.11+ |
| pytest | 9.1+ |
| pytest-timeout | 2.4+ |
| pytest-order | 1.5+ |
| Faker | 40+ |
| psycopg2-binary | 2.9+ |
| PostgreSQL | 14+ (running) |
Install all test dependencies:
pip install -e ".[test]"Or individually:
pip install pytest pytest-timeout pytest-order Faker psycopg2-binaryThe test suite connects to PostgreSQL using the same variables as the application:
export DB_NAME=tournament
export DB_USER=app_admin
export DB_PASS=admin_secure_pass
export DB_HOST=localhost
export DB_PORT=5432# Run all tests
pytest src/tests/test_cli.py -v --tb=short --timeout=30
# Run a specific test class
pytest src/tests/test_cli.py::TestRegisterStandings -v
# Run a single test
pytest src/tests/test_cli.py::TestPopulateResults::test_scores_sum_to_one -vTests are in src/tests/test_cli.py. Shared fixtures live in src/tests/conftest.py — pytest loads it automatically; never run conftest.py directly.
| Fixture | Scope | What it does |
|---|---|---|
set_working_directory |
session | chdir to project root so data/ paths resolve |
fresh_database |
session | Calls init_db() on setup; drops all tables on teardown |
num_players |
session | Returns 10 — change here to test with a different player count |
session_setup |
session | Generates data/players.csv, registers players and standings once |
run / run_rounds |
class | Runs the CLI command under test before each class's tests execute |
session_setup depends on fresh_database, so the database is always clean at the start of a run and dropped at the end.
| Class | CLI command | # Tests |
|---|---|---|
TestInitDb |
init-db |
6 |
TestGeneratePlayers |
generate-players |
5 |
TestRegisterPlayers |
register-players |
1 |
TestRegisterStandings |
register-standings |
5 |
TestGenerateSwissPairings |
generate-swiss-pairings |
5 |
TestPopulateResults |
populate-results |
5 |
TestPrintTable |
print-table |
3 |
Total: 30 tests
Three tables are used:
| Column | Type | Description |
|---|---|---|
id |
VARCHAR(25) PK | Unique player identifier |
name |
VARCHAR(255) | Full name |
email |
VARCHAR(255) | Email address |
| Column | Type | Description |
|---|---|---|
id |
VARCHAR(25) FK | References Players |
name |
VARCHAR(255) | Player name |
is_active |
BOOLEAN | Whether the player is active |
is_bye |
BOOLEAN | Whether the player received a BYE |
matches |
INTEGER | Number of matches played |
points |
DECIMAL(4,1) | Total score |
tiebreaker_A |
DECIMAL(8,2) | Buchholz score |
tiebreaker_B |
DECIMAL(8,2) | Reserved |
tiebreaker_C |
DECIMAL(8,2) | Reserved |
| Column | Type | Description |
|---|---|---|
round_id |
INTEGER | Round number |
player1_id |
VARCHAR(25) FK | References Players |
player1_name |
VARCHAR(255) | Player 1 name |
player1_score |
DECIMAL(2,1) | Score: 0.0, 0.5, or 1.0 |
player2_score |
DECIMAL(2,1) | Score: 0.0, 0.5, or 1.0 |
player2_name |
VARCHAR(255) | Player 2 name (NULL for BYE) |
player2_id |
VARCHAR(25) FK | References Players (NULL for BYE) |
tourman/
├── src/
│ ├── main.py # CLI entry point — all commands routed here
│ ├── core/
│ │ ├── init_db.py # Database initialisation
│ │ ├── generate_players.py # Fake player generation (Faker)
│ │ ├── register_players.py # Insert players into DB
│ │ ├── register_standings.py # Initialise standings rows
│ │ ├── generate_swiss_pairings.py # Swiss pairing algorithm (O(n²))
│ │ ├── generate_roundrobin_pairings.py # Circle-method round-robin
│ │ ├── register_results.py # Validate and insert results CSV
│ │ └── apply_results_to_standings.py # Update standings + Buchholz
│ ├── utils/
│ │ ├── populate_results.py # Random score generator (testing)
│ │ ├── print_table.py # Console table printer
│ │ ├── convert_excel_to_csv.py
│ │ └── convert_table_to_excel.py
│ ├── common/
│ │ ├── db_utils.py # Connection string from env vars
│ │ ├── logger.py # Structured logging
│ │ └── common.py
│ └── db/
│ ├── tables.sql # CREATE TABLE statements
│ ├── roles.sql # DB roles and permissions
│ ├── schema_permissions.sql
│ └── table_permissions.sql
├── data/ # Runtime output — CSV pairings and results
├── docker-compose.yml # PostgreSQL + app services
├── tourman.Dockerfile
├── runscript.sh # End-to-end Swiss demo script
├── setup.py
├── pyproject.toml
└── README.md
MIT — see LICENSE.