Build a Volleyball Stat Tracker CLI
Build a Python CLI that tracks volleyball stats and pulls live pro data.
Introduction
30 Second Summary
Whether you're watching a volleyball match or playing in one, the stats behind every set tell a story. Tracking kills, aces, and hitting percentages turns raw plays into insights that coaches and analysts rely on every day.
In this project, you will build a Python command-line tool that records volleyball match stats, calculates real analytics, saves stat sheets to JSON files, and pulls live professional league data from the Highlightly Volleyball API.
What You'll Build
A menu-driven CLI that records player performance, calculates volleyball analytics like hitting percentage and kill efficiency, persists stat sheets as JSON files, and connects to a live sports API for professional league standings.
By the end of this project, you'll have:
- A menu-driven CLI that records volleyball stats and calculates real analytics like hitting percentage and kill efficiency.
- A JSON save/load system and live REST API integration with the Highlightly Volleyball API for professional league data.
- A Git repository pushed to GitHub with meaningful commit history showing iterative development.
- Secret Mission: Add a player comparison feature that displays a side-by-side performance breakdown.
Are there any prerequisites?
This project assumes you're comfortable with basic Python: variables, functions, loops, and dictionaries. You do not need any prior experience with APIs, Git, or command-line tools.
Build the Stat Recorder
Your environment is ready and your GitHub repo is live. Now it is time to build the core of your volleyball tracker.
Every CLI tool needs two things: a menu that lets users pick what to do, and logic that carries out their choice. In this step, you will create the menu loop and the stat recording flow that captures a player's match performance.
In this step, get ready to:
- Create the main menu with six options for your stat tracker.
- Build the stat recording function with input validation.
- Commit and push your progress to GitHub.
Create the main menu and player data structure
Your tracker needs a file to live in. You will create tracker.py with all the foundational pieces: imports, a helper to load your API key, and a menu loop that drives the entire program.
- In VS Code, create a new file called tracker.py inside your volleyball-tracker folder.
- Add the imports, the stats directory constant, and the API key loader by pasting this code:
import json
import os
import requests
STATS_DIR = "stats"
def load_api_key():
"""Read the Highlightly API key from the .env file."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if not os.path.exists(env_path):
return None
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line.startswith("HIGHLIGHTLY_API_KEY="):
return line.split("=", 1)[1]
return None
What does this code do?
The three imports bring in tools you will use throughout the project. json handles saving and loading data. os works with file paths and directories. requests makes HTTP calls to the Highlightly API.
STATS_DIR defines a single folder name for all saved stat sheets. load_api_key() reads your .env file line by line, looking for the HIGHLIGHTLY_API_KEY entry. If the file is missing or the key is not found, it returns None so the program can handle that gracefully later.
Next, you will add the main menu loop. This is the heart of your CLI. It displays options, reads the user's choice, and routes to the correct function.
- Below load_api_key(), add the main() function and the entry point by pasting this code at the bottom of tracker.py:
def main():
"""Main program loop."""
players = {}
api_key = load_api_key()
print("=" * 40)
print(" Volleyball Stat Tracker")
print("=" * 40)
while True:
print("1. Record player stats")
print("2. View analytics")
print("3. Save stats")
print("4. Load stats")
print("5. Look up pro team")
print("6. Exit")
choice = input("\nSelect an option: ").strip()
if choice == "1":
record_player(players)
elif choice == "6":
print("Goodbye!")
break
else:
print("Invalid option. Please try again.\n")
if __name__ == "__main__":
main()
What does this code do?
players = {} creates an empty dictionary that will store every player's stats. Each player's name becomes a key, and their stat numbers become the value. This is the core data structure for the whole program.
The while True loop prints the menu on every cycle, reads a choice, and calls the matching function. Option 1 calls record_player() which you will build next. Option 6 breaks out of the loop and ends the program.
if __name__ == "__main__" ensures main() only runs when you execute the file directly. Python resolves function names at runtime, so record_player does not need to exist above main() yet. It only needs to be defined before main() is actually called.
- Save tracker.py.
- In your terminal, test the menu by running:
python tracker.py
- You should see the Volleyball Stat Tracker banner and the six menu options. Type 6 and press Enter to exit cleanly.
Not seeing the menu?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, run venv\Scripts\activate first.
- Check that you are in the volleyball-tracker folder. Run cd volleyball-tracker if needed.
- If you see a syntax error, double check that the if __name__ line is at the very bottom of the file with no extra indentation.
Ask for help: My tracker.py won't run. I see an error when I try python tracker.py in my volleyball-tracker folder.
Build the stat recording flow
The menu is working, but option 1 will crash because record_player() does not exist yet. This function prompts for a player's name and seven stat categories, validates each input, and stores everything in the players dictionary.
- In tracker.py, add the record_player() function between load_api_key() and main() by pasting this code:
def record_player(players):
"""Record stats for a player."""
name = input("Enter player name: ").strip()
if not name:
print("Player name cannot be empty.")
return
print(f"Recording stats for {name}:")
stats = {}
for category in ["kills", "errors", "attempts", "aces", "digs", "blocks", "sets_played"]:
while True:
try:
value = int(input(f" {category}: "))
if value < 0:
print(" Value must be non-negative.")
continue
if category == "sets_played" and value < 1:
print(" Sets played must be at least 1.")
continue
stats[category] = value
break
except ValueError:
print(" Please enter a whole number.")
players[name] = stats
print(f"Stats recorded for {name}.\n")
What does this code do?
The function asks for a player name first. If the user hits Enter without typing anything, it exits early with a message.
The for loop walks through all seven stat categories. For each one, a while True loop keeps asking until the user provides a valid non-negative integer. The special rule for sets_played requires at least 1, because you cannot play zero sets.
Once all stats are collected, players[name] = stats stores the entire stat dictionary under that player's name. This is how the program remembers data between menu actions.
- Save tracker.py.
- Run the program again:
python tracker.py
- Select option 1 to record player stats. Enter a player name and fill in values for each category. After all seven stats, you should see a confirmation message and return to the menu.
Seeing a NameError?
- Make sure record_player() is placed above main() in the file. It must be defined before main() calls it at runtime.
- Check your indentation. Every line inside record_player should be indented one level (4 spaces). The def record_player(players): line itself should be at the leftmost column.
Ask for help: I am getting a NameError or indentation error when I try to record player stats. Help me figure out what is wrong.
✔️ Awesome, I've got everything!
Great. Double check you have saved tracker.py before moving on.
ⓧ I'd like to double check the full code
Here is the full tracker.py at this point:
import json
import os
import requests
STATS_DIR = "stats"
def load_api_key():
"""Read the Highlightly API key from the .env file."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if not os.path.exists(env_path):
return None
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line.startswith("HIGHLIGHTLY_API_KEY="):
return line.split("=", 1)[1]
return None
def record_player(players):
"""Record stats for a player."""
name = input("Enter player name: ").strip()
if not name:
print("Player name cannot be empty.")
return
print(f"Recording stats for {name}:")
stats = {}
for category in ["kills", "errors", "attempts", "aces", "digs", "blocks", "sets_played"]:
while True:
try:
value = int(input(f" {category}: "))
if value < 0:
print(" Value must be non-negative.")
continue
if category == "sets_played" and value < 1:
print(" Sets played must be at least 1.")
continue
stats[category] = value
break
except ValueError:
print(" Please enter a whole number.")
players[name] = stats
print(f"Stats recorded for {name}.\n")
def main():
"""Main program loop."""
players = {}
api_key = load_api_key()
print("=" * 40)
print(" Volleyball Stat Tracker")
print("=" * 40)
while True:
print("1. Record player stats")
print("2. View analytics")
print("3. Save stats")
print("4. Load stats")
print("5. Look up pro team")
print("6. Exit")
choice = input("\nSelect an option: ").strip()
if choice == "1":
record_player(players)
elif choice == "6":
print("Goodbye!")
break
else:
print("Invalid option. Please try again.\n")
if __name__ == "__main__":
main()
Commit your progress
You have a working menu and stat recording flow. Time to save this milestone in Git so you have a clean checkpoint on GitHub.
- Stage, commit, and push your new file by running these commands:
git add tracker.py
git commit -m "Add stat recording menu and player data input"
git push
What do these commands do?
git add tracker.py stages the file, telling Git you want to include it in the next snapshot. git commit saves that snapshot with a descriptive message. git push uploads the commit to your GitHub repository so it is backed up remotely.
- After pushing, open your volleyball-tracker repository on GitHub in your browser. You should see tracker.py listed alongside your .gitignore from the first commit.
Push rejected or authentication error?
- If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS. Use a Personal Access Token instead. Go to GitHub, then Settings, then Developer settings, then Personal access tokens to create one.
- Make sure the remote URL matches your repository. Run git remote -v to check.
Ask for help: Git push is failing with an authentication or rejected error. How do I fix this?
Your stat recorder is live on GitHub. Next up, you will add the analytics engine that turns raw stat numbers into real volleyball metrics like hitting percentage and kill efficiency.
Calculate Volleyball Analytics
Your tracker can record raw stats, but numbers alone don't tell the full story. A player with 15 kills might sound impressive until you learn they had 20 errors on 50 attempts.
In this step, you'll turn raw stats into real volleyball analytics. These are the same metrics used in NCAA and professional volleyball to evaluate player performance.
In this step, get ready to:
- Build a function that calculates hitting percentage, kill efficiency, and per-set averages.
- Display a formatted analytics table with a performance rating.
- Commit and push your analytics feature to GitHub.
Implement the analytics calculations
Hitting percentage is the standard volleyball metric for measuring attacking effectiveness. The formula is (kills - errors) / attempts, producing a value between -1.0 and 1.0.
- In tracker.py, find the record_player function.
- Add the following calculate_analytics function directly below it:
def calculate_analytics(player_stats):
"""Calculate volleyball analytics from raw stats."""
attempts = player_stats["attempts"]
sets_played = player_stats["sets_played"]
analytics = {}
if attempts > 0:
analytics["hitting_pct"] = (player_stats["kills"] - player_stats["errors"]) / attempts
analytics["kill_efficiency"] = player_stats["kills"] / attempts
else:
analytics["hitting_pct"] = None
analytics["kill_efficiency"] = None
analytics["aces_per_set"] = player_stats["aces"] / sets_played
analytics["digs_per_set"] = player_stats["digs"] / sets_played
analytics["blocks_per_set"] = player_stats["blocks"] / sets_played
return analytics
What does this code do?
This function takes a player's raw stat dictionary and returns a new dictionary with calculated metrics:
- hitting_pct: the standard volleyball hitting percentage formula. A .300+ is considered excellent.
- kill_efficiency: the proportion of attempts that resulted in kills (ignores errors).
- Per-set averages (aces, digs, blocks) normalize stats across matches of different lengths.
- When attempts is 0, hitting percentage and kill efficiency are set to None to avoid a division-by-zero crash.
You won't see a change yet. The next chunk builds the display functions that use these calculations.
Display formatted analytics
The analytics numbers need two helper functions before they can be displayed. One assigns a human-readable performance rating, and the other formats decimal values consistently.
- Add the get_performance_rating and format_stat functions below calculate_analytics:
def get_performance_rating(hitting_pct):
"""Return a performance rating based on hitting percentage."""
if hitting_pct is None:
return "N/A"
if hitting_pct < 0.0:
return "Needs improvement"
if hitting_pct < 0.200:
return "Average"
if hitting_pct < 0.300:
return "Good"
return "Excellent"
def format_stat(value):
"""Format a stat value for display."""
if value is None:
return "N/A"
return f"{value:.3f}"
What do these helpers do?
get_performance_rating converts a hitting percentage into a label. Below .000 means the player is making more errors than kills. Between .200 and .299 is good at most competitive levels. Anything .300+ is excellent.
format_stat displays numbers to three decimal places (like .325) or shows "N/A" when the value is None.
You won't see a change yet. The next function ties everything together into a display the user can trigger from the menu.
- Add the view_analytics function below format_stat:
def view_analytics(players):
"""Display analytics for a selected player."""
if not players:
print("No players recorded yet.\n")
return
print("Recorded players:")
player_names = list(players.keys())
for i, name in enumerate(player_names, 1):
print(f" {i}. {name}")
try:
choice = int(input("Select a player number: ")) - 1
if choice < 0 or choice >= len(player_names):
print("Invalid selection.\n")
return
except ValueError:
print("Invalid selection.\n")
return
name = player_names[choice]
stats = players[name]
analytics = calculate_analytics(stats)
print(f"\n{'='*40}")
print(f" Stats for {name}")
print(f"{'='*40}")
print(f" Kills: {stats['kills']}")
print(f" Errors: {stats['errors']}")
print(f" Attempts: {stats['attempts']}")
print(f" Aces: {stats['aces']}")
print(f" Digs: {stats['digs']}")
print(f" Blocks: {stats['blocks']}")
print(f" Sets Played: {stats['sets_played']}")
print(f"{'-'*40}")
print(f" Hitting %: {format_stat(analytics['hitting_pct'])}")
print(f" Kill Efficiency: {format_stat(analytics['kill_efficiency'])}")
print(f" Aces/Set: {format_stat(analytics['aces_per_set'])}")
print(f" Digs/Set: {format_stat(analytics['digs_per_set'])}")
print(f" Blocks/Set: {format_stat(analytics['blocks_per_set'])}")
print(f" Rating: {get_performance_rating(analytics['hitting_pct'])}")
print(f"{'='*40}\n")
What does this function do?
This function handles the full "View analytics" workflow:
- Lists all recorded players and asks the user to pick one by number.
- Calls calculate_analytics on that player's stats.
- Prints a formatted table showing raw stats on top and calculated analytics below the divider line.
- Displays the performance rating at the bottom so the player gets a quick summary.
Now you need to wire this function into the menu so option 2 actually calls it.
- In the main() function, find this line:
elif choice == "6":
print("Goodbye!")
break
- Add the following elif block directly above it, so the line now looks like this:
elif choice == "2":
view_analytics(players)
elif choice == "6":
print("Goodbye!")
break
- Save tracker.py.
- Run the program, record stats for a player (option 1), then select option 2 to view analytics.
python tracker.py
You should see a formatted table with raw stats, calculated hitting percentage, kill efficiency, per-set averages, and a performance rating.
Not seeing analytics output?
- Make sure you recorded at least one player's stats in the same session before selecting option 2. The data lives in memory only.
- Check that the elif choice == "2" block is placed before the elif choice == "6" block. Python evaluates conditions top-to-bottom.
- If you see a NameError, verify that calculate_analytics, get_performance_rating, and format_stat are all defined above view_analytics in the file.
Still stuck? Why isn't my view_analytics function displaying output?
✔️ Awesome, I've got everything!
Great. Double check you've saved tracker.py before moving on to the commit.
ⓧ I'd like to double check the full code
import json
import os
import requests
STATS_DIR = "stats"
def load_api_key():
"""Read the Highlightly API key from the .env file."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if not os.path.exists(env_path):
return None
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line.startswith("HIGHLIGHTLY_API_KEY="):
return line.split("=", 1)[1]
return None
def record_player(players):
"""Record stats for a player."""
name = input("Enter player name: ").strip()
if not name:
print("Player name cannot be empty.")
return
print(f"Recording stats for {name}:")
stats = {}
for category in ["kills", "errors", "attempts", "aces", "digs", "blocks", "sets_played"]:
while True:
try:
value = int(input(f" {category}: "))
if value < 0:
print(" Value must be non-negative.")
continue
if category == "sets_played" and value < 1:
print(" Sets played must be at least 1.")
continue
stats[category] = value
break
except ValueError:
print(" Please enter a whole number.")
players[name] = stats
print(f"Stats recorded for {name}.\n")
def calculate_analytics(player_stats):
"""Calculate volleyball analytics from raw stats."""
attempts = player_stats["attempts"]
sets_played = player_stats["sets_played"]
analytics = {}
if attempts > 0:
analytics["hitting_pct"] = (player_stats["kills"] - player_stats["errors"]) / attempts
analytics["kill_efficiency"] = player_stats["kills"] / attempts
else:
analytics["hitting_pct"] = None
analytics["kill_efficiency"] = None
analytics["aces_per_set"] = player_stats["aces"] / sets_played
analytics["digs_per_set"] = player_stats["digs"] / sets_played
analytics["blocks_per_set"] = player_stats["blocks"] / sets_played
return analytics
def get_performance_rating(hitting_pct):
"""Return a performance rating based on hitting percentage."""
if hitting_pct is None:
return "N/A"
if hitting_pct < 0.0:
return "Needs improvement"
if hitting_pct < 0.200:
return "Average"
if hitting_pct < 0.300:
return "Good"
return "Excellent"
def format_stat(value):
"""Format a stat value for display."""
if value is None:
return "N/A"
return f"{value:.3f}"
def view_analytics(players):
"""Display analytics for a selected player."""
if not players:
print("No players recorded yet.\n")
return
print("Recorded players:")
player_names = list(players.keys())
for i, name in enumerate(player_names, 1):
print(f" {i}. {name}")
try:
choice = int(input("Select a player number: ")) - 1
if choice < 0 or choice >= len(player_names):
print("Invalid selection.\n")
return
except ValueError:
print("Invalid selection.\n")
return
name = player_names[choice]
stats = players[name]
analytics = calculate_analytics(stats)
print(f"\n{'='*40}")
print(f" Stats for {name}")
print(f"{'='*40}")
print(f" Kills: {stats['kills']}")
print(f" Errors: {stats['errors']}")
print(f" Attempts: {stats['attempts']}")
print(f" Aces: {stats['aces']}")
print(f" Digs: {stats['digs']}")
print(f" Blocks: {stats['blocks']}")
print(f" Sets Played: {stats['sets_played']}")
print(f"{'-'*40}")
print(f" Hitting %: {format_stat(analytics['hitting_pct'])}")
print(f" Kill Efficiency: {format_stat(analytics['kill_efficiency'])}")
print(f" Aces/Set: {format_stat(analytics['aces_per_set'])}")
print(f" Digs/Set: {format_stat(analytics['digs_per_set'])}")
print(f" Blocks/Set: {format_stat(analytics['blocks_per_set'])}")
print(f" Rating: {get_performance_rating(analytics['hitting_pct'])}")
print(f"{'='*40}\n")
def main():
"""Main program loop."""
players = {}
api_key = load_api_key()
print("=" * 40)
print(" Volleyball Stat Tracker")
print("=" * 40)
while True:
print("1. Record player stats")
print("2. View analytics")
print("3. Save stats")
print("4. Load stats")
print("5. Look up pro team")
print("6. Exit")
choice = input("\nSelect an option: ").strip()
if choice == "1":
record_player(players)
elif choice == "2":
view_analytics(players)
elif choice == "6":
print("Goodbye!")
break
else:
print("Invalid option. Please try again.\n")
if __name__ == "__main__":
main()
Commit your progress
- Stage, commit, and push your analytics feature by running these commands:
git add tracker.py
git commit -m "Add volleyball analytics calculations and display"
git push
Why commit after each feature?
Each commit captures a working state of your project. If something breaks later, you can roll back to this exact point. It also shows recruiters a structured development process on your GitHub profile.
Push rejected?
- If you see a credentials prompt, enter your GitHub username and a personal access token (not your password).
- If the push is rejected with "non-fast-forward", run git pull --rebase origin main first, then try pushing again.
Help me fix a git push error for my volleyball-tracker repository.
Your tracker now does real analytics. Next up, you'll add the ability to save stat sheets to files and load them back later so your data survives between sessions.
Save and Load Stat Sheets
Your tracker can record stats and crunch numbers, but everything disappears the moment you close the program. That means you would have to re-enter every player's stats from scratch each time you run it.
In this step you will add file persistence. You will save stat sheets to JSON files and load them back later, so your data survives between sessions.
In this step, get ready to:
- Build a save function that writes player stats to a JSON file.
- Build a load function that reads saved stat sheets back into memory.
- Wire both functions into your menu and commit the changes to GitHub.
Implement save functionality
The save function needs to handle a few things: creating the stats/ directory if it does not exist yet, prompting for a filename, and writing the players dictionary as formatted JSON.
- In tracker.py, find the view_analytics function's closing line. Add the following save_stats function below it:
def save_stats(players):
"""Save all player stats to a JSON file."""
if not players:
print("No players to save.\n")
return
os.makedirs(STATS_DIR, exist_ok=True)
default_name = f"match_{len(os.listdir(STATS_DIR)) + 1}"
filename = input(f"Enter filename (default: {default_name}): ").strip()
if not filename:
filename = default_name
if not filename.endswith(".json"):
filename += ".json"
filepath = os.path.join(STATS_DIR, filename)
with open(filepath, "w") as f:
json.dump(players, f, indent=2)
print(f"Stats saved to {filepath}\n")
What does this code do?
- os.makedirs(STATS_DIR, exist_ok=True) creates the stats/ folder if it does not already exist. The exist_ok=True flag prevents an error when the folder is already there.
- The default filename is generated based on how many files are already in the directory, so each save gets a unique name like match_1.json, match_2.json, etc.
- json.dump(players, f, indent=2) writes the dictionary as nicely formatted JSON with 2-space indentation, making it human-readable if you open the file directly.
- Save the file. You will test this after adding the load function and wiring both into the menu.
Implement load functionality
The load function is the mirror image of save. It lists available stat sheet files, lets the user pick one, and reads the data back into a Python dictionary.
- Below your save_stats function, add the load_stats function:
def load_stats():
"""Load player stats from a JSON file."""
if not os.path.exists(STATS_DIR):
print("No saved stat sheets found.\n")
return None
files = [f for f in os.listdir(STATS_DIR) if f.endswith(".json")]
if not files:
print("No saved stat sheets found.\n")
return None
print("Saved stat sheets:")
for i, f in enumerate(files, 1):
print(f" {i}. {f}")
try:
choice = int(input("Select a file number: ")) - 1
if choice < 0 or choice >= len(files):
print("Invalid selection.\n")
return None
except ValueError:
print("Invalid selection.\n")
return None
filepath = os.path.join(STATS_DIR, files[choice])
with open(filepath, "r") as f:
players = json.load(f)
print(f"Loaded {len(players)} player(s) from {files[choice]}\n")
return players
What does this code do?
- The function checks two things before proceeding: does the stats/ directory exist, and does it contain any .json files. If either check fails, it shows a friendly message and returns None.
- It presents a numbered list of saved files, similar to how view_analytics lists players. The user picks a file by number.
- json.load(f) converts the JSON file back into a Python dictionary. The function returns this dictionary so main() can replace the in-memory players data.
Now you need to connect both functions to the menu. Your main() function already prints options 3 and 4 in the menu, but selecting them currently falls through to the "Invalid option" message.
- In your main() function, find this line:
elif choice == "6":
print("Goodbye!")
break
- Add the following two elif blocks directly above that line, between the elif choice == "2" block and elif choice == "6":
elif choice == "3":
save_stats(players)
elif choice == "4":
loaded = load_stats()
if loaded is not None:
players = loaded
Why check for None before assigning?
The load_stats() function returns None when no file is found or the user makes an invalid selection. The if loaded is not None guard prevents wiping out the current session's stats with an empty value.
- Save tracker.py.
- Run your tracker by running:
python tracker.py
- Record stats for a player (option 1), then save stats (option 3). You should see a confirmation message like Stats saved to stats/match_1.json.
- Exit the program (option 6), then run it again. Select option 4 to load the file you just saved. You should see a message confirming how many players were loaded.
- Select option 2 to view analytics. The analytics should be identical to what you saw before saving.
Stats not loading correctly?
- Check that both elif choice == "3" and elif choice == "4" are indented at the same level as the other elif blocks in your main() function.
- Make sure you placed the new blocks above elif choice == "6", not below it. Python evaluates elif chains in order.
- If you see "No saved stat sheets found" after saving, confirm that a stats/ folder appeared in your project directory with a .json file inside it.
Help me debug my save and load functions in tracker.py
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import json
import os
import requests
STATS_DIR = "stats"
def load_api_key():
"""Read the Highlightly API key from the .env file."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if not os.path.exists(env_path):
return None
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line.startswith("HIGHLIGHTLY_API_KEY="):
return line.split("=", 1)[1]
return None
def record_player(players):
"""Record stats for a player."""
name = input("Enter player name: ").strip()
if not name:
print("Player name cannot be empty.")
return
print(f"Recording stats for {name}:")
stats = {}
for category in ["kills", "errors", "attempts", "aces", "digs", "blocks", "sets_played"]:
while True:
try:
value = int(input(f" {category}: "))
if value < 0:
print(" Value must be non-negative.")
continue
if category == "sets_played" and value < 1:
print(" Sets played must be at least 1.")
continue
stats[category] = value
break
except ValueError:
print(" Please enter a whole number.")
players[name] = stats
print(f"Stats recorded for {name}.\n")
def calculate_analytics(player_stats):
"""Calculate volleyball analytics from raw stats."""
attempts = player_stats["attempts"]
sets_played = player_stats["sets_played"]
analytics = {}
if attempts > 0:
analytics["hitting_pct"] = (player_stats["kills"] - player_stats["errors"]) / attempts
analytics["kill_efficiency"] = player_stats["kills"] / attempts
else:
analytics["hitting_pct"] = None
analytics["kill_efficiency"] = None
analytics["aces_per_set"] = player_stats["aces"] / sets_played
analytics["digs_per_set"] = player_stats["digs"] / sets_played
analytics["blocks_per_set"] = player_stats["blocks"] / sets_played
return analytics
def get_performance_rating(hitting_pct):
"""Return a performance rating based on hitting percentage."""
if hitting_pct is None:
return "N/A"
if hitting_pct < 0.0:
return "Needs improvement"
if hitting_pct < 0.200:
return "Average"
if hitting_pct < 0.300:
return "Good"
return "Excellent"
def format_stat(value):
"""Format a stat value for display."""
if value is None:
return "N/A"
return f"{value:.3f}"
def view_analytics(players):
"""Display analytics for a selected player."""
if not players:
print("No players recorded yet.\n")
return
print("Recorded players:")
player_names = list(players.keys())
for i, name in enumerate(player_names, 1):
print(f" {i}. {name}")
try:
choice = int(input("Select a player number: ")) - 1
if choice < 0 or choice >= len(player_names):
print("Invalid selection.\n")
return
except ValueError:
print("Invalid selection.\n")
return
name = player_names[choice]
stats = players[name]
analytics = calculate_analytics(stats)
print(f"\n{'='*40}")
print(f" Stats for {name}")
print(f"{'='*40}")
print(f" Kills: {stats['kills']}")
print(f" Errors: {stats['errors']}")
print(f" Attempts: {stats['attempts']}")
print(f" Aces: {stats['aces']}")
print(f" Digs: {stats['digs']}")
print(f" Blocks: {stats['blocks']}")
print(f" Sets Played: {stats['sets_played']}")
print(f"{'-'*40}")
print(f" Hitting %: {format_stat(analytics['hitting_pct'])}")
print(f" Kill Efficiency: {format_stat(analytics['kill_efficiency'])}")
print(f" Aces/Set: {format_stat(analytics['aces_per_set'])}")
print(f" Digs/Set: {format_stat(analytics['digs_per_set'])}")
print(f" Blocks/Set: {format_stat(analytics['blocks_per_set'])}")
print(f" Rating: {get_performance_rating(analytics['hitting_pct'])}")
print(f"{'='*40}\n")
def save_stats(players):
"""Save all player stats to a JSON file."""
if not players:
print("No players to save.\n")
return
os.makedirs(STATS_DIR, exist_ok=True)
default_name = f"match_{len(os.listdir(STATS_DIR)) + 1}"
filename = input(f"Enter filename (default: {default_name}): ").strip()
if not filename:
filename = default_name
if not filename.endswith(".json"):
filename += ".json"
filepath = os.path.join(STATS_DIR, filename)
with open(filepath, "w") as f:
json.dump(players, f, indent=2)
print(f"Stats saved to {filepath}\n")
def load_stats():
"""Load player stats from a JSON file."""
if not os.path.exists(STATS_DIR):
print("No saved stat sheets found.\n")
return None
files = [f for f in os.listdir(STATS_DIR) if f.endswith(".json")]
if not files:
print("No saved stat sheets found.\n")
return None
print("Saved stat sheets:")
for i, f in enumerate(files, 1):
print(f" {i}. {f}")
try:
choice = int(input("Select a file number: ")) - 1
if choice < 0 or choice >= len(files):
print("Invalid selection.\n")
return None
except ValueError:
print("Invalid selection.\n")
return None
filepath = os.path.join(STATS_DIR, files[choice])
with open(filepath, "r") as f:
players = json.load(f)
print(f"Loaded {len(players)} player(s) from {files[choice]}\n")
return players
def main():
"""Main program loop."""
players = {}
api_key = load_api_key()
print("=" * 40)
print(" Volleyball Stat Tracker")
print("=" * 40)
while True:
print("1. Record player stats")
print("2. View analytics")
print("3. Save stats")
print("4. Load stats")
print("5. Look up pro team")
print("6. Exit")
choice = input("\nSelect an option: ").strip()
if choice == "1":
record_player(players)
elif choice == "2":
view_analytics(players)
elif choice == "3":
save_stats(players)
elif choice == "4":
loaded = load_stats()
if loaded is not None:
players = loaded
elif choice == "6":
print("Goodbye!")
break
else:
print("Invalid option. Please try again.\n")
if __name__ == "__main__":
main()
Commit your progress
- Stage, commit, and push your changes by running these commands:
git add tracker.py
git commit -m "Add JSON save/load for stat sheets"
git push
- You should see output confirming the push to your main branch on GitHub.
Push rejected or authentication error?
- If Git asks for credentials, enter your GitHub username and a personal access token (not your password). GitHub no longer accepts passwords for HTTPS pushes.
- If you see "nothing to commit", make sure you saved tracker.py before running git add.
Help me fix a Git push error
Your tracker now has real persistence. Players and their stats survive between sessions, saved as clean JSON files you can even open and read directly. Next up, you will connect to a live sports API and pull real professional volleyball data into your tool.
Connect to the Highlightly API
Your stat tracker can record, analyze, save, and reload player data. Now it is time to connect to the real world of professional volleyball.
The Highlightly API provides live data about professional volleyball teams and league standings. In this step, you will build functions that call this REST API and display real team data directly in your CLI.
In this step, get ready to:
- Build an API client function to search for professional volleyball teams.
- Add a standings lookup that displays league tables.
- Commit and push the final version to GitHub.
Build the API client
Your code already loads the API key from .env using the load_api_key() function. Now you need a function that uses this key to query the Highlightly Volleyball API for team data.
What is a REST API?
A REST API is a way for programs to request data from a remote server over the internet using standard HTTP requests. Your Python code sends a request to a URL, and the server sends back structured data in JSON format.
The Highlightly API works this way. You send a GET request with your API key in the headers, and it responds with volleyball team and standings data.
- In tracker.py, add the following lookup_team function below the load_stats() function:
def lookup_team(api_key):
"""Search for a volleyball team using the Highlightly API."""
team_name = input("Enter team name to search: ").strip()
if not team_name:
print("Team name cannot be empty.\n")
return
# Send a GET request to the Highlightly teams endpoint
try:
response = requests.get(
"https://volleyball.highlightly.net/teams",
headers={"x-rapidapi-key": api_key},
params={"name": team_name, "limit": 5},
)
response.raise_for_status()
except requests.RequestException as e:
print(f"API request failed: {e}\n")
return
# Parse the JSON response and extract the teams list
data = response.json()
teams = data.get("data", [])
if not teams:
print(f"No teams found matching '{team_name}'.\n")
return
# Display matching teams with their name and ID
print(f"\nTeams matching '{team_name}':")
for i, team in enumerate(teams, 1):
print(f" {i}. {team['name']} (ID: {team['id']})")
print()
# Offer to look up standings for a league
show_standings = input("Look up standings for a league? (y/n): ").strip().lower()
if show_standings == "y":
try:
league_id = int(input("Enter league ID: "))
season = int(input("Enter season year: "))
except ValueError:
print("Invalid input.\n")
return
lookup_standings(api_key, league_id, season)
What does this code do?
- requests.get() sends an HTTP GET request to the Highlightly teams endpoint at https://volleyball.highlightly.net/teams.
- The headers dictionary passes your API key in the x-rapidapi-key header, which authenticates the request.
- The params dictionary adds name and limit as query parameters so the API knows what to search for.
- response.raise_for_status() throws an error if the API returns a non-200 status code, which the except block catches and displays.
- After displaying results, the function offers to look up league standings by calling lookup_standings() (which you will add next).
You will not be able to test this function yet because it calls lookup_standings(), which you will add in the next substep.
Add standings and wire the menu
The standings endpoint returns a full league table showing each team's position, wins, losses, and points. You will add this function, then wire both API functions into the main menu.
- Add the following lookup_standings function directly below lookup_team in tracker.py:
def lookup_standings(api_key, league_id, season):
"""Fetch and display league standings from the Highlightly API."""
try:
response = requests.get(
"https://volleyball.highlightly.net/standings",
headers={"x-rapidapi-key": api_key},
params={"leagueId": league_id, "season": season},
)
response.raise_for_status()
except requests.RequestException as e:
print(f"API request failed: {e}\n")
return
# Parse response and extract standings groups
data = response.json()
groups = data.get("groups", [])
league = data.get("league", {})
if not groups:
print("No standings found for this league/season.\n")
return
# Display a formatted standings table
print(f"\n{'='*55}")
print(f" {league.get('name', 'Unknown')} - {league.get('season', 'N/A')}")
print(f"{'='*55}")
print(f" {'Pos':<5}{'Team':<25}{'W':<5}{'L':<5}{'Pts':<5}")
print(f" {'-'*50}")
for group in groups:
for entry in group.get("standings", []):
team = entry.get("team", {})
print(
f" {entry.get('position', '-'):<5}"
f"{team.get('name', 'Unknown'):<25}"
f"{entry.get('wins', 0):<5}"
f"{entry.get('loses', 0):<5}"
f"{entry.get('points', 0):<5}"
)
print(f"{'='*55}\n")
What does this code do?
- It calls the /standings endpoint with leagueId and season parameters to fetch a specific league's table.
- The response contains a groups array where each group holds a standings list of team entries.
- Python f-string formatting with :<5 and :<25 creates fixed-width columns so the table aligns neatly in the terminal.
- The API uses loses (not "losses") as the field name for defeats. This is how the Highlightly API names the field.
Now you need to wire option 5 in main() so selecting "Look up pro team" actually calls your new functions.
- In tracker.py, scroll down to the main() function and find this line:
elif choice == "6":
print("Goodbye!")
break
- Add the following elif block directly above that line, so option 5 is handled before option 6:
elif choice == "5":
if api_key:
lookup_team(api_key)
else:
print("No API key found. Add HIGHLIGHTLY_API_KEY to your .env file.\n")
Why check if api_key exists?
The load_api_key() function returns None if the .env file is missing or the key is not set. This check prevents a confusing error from the API and instead tells the learner exactly what to fix.
- Save tracker.py.
- Run your program by executing:
python tracker.py
- Select option 5 and enter a team name like Trentino.
- You should see a list of matching teams with their names and IDs printed in the terminal.
Seeing "API request failed"?
- Check that your .env file contains the correct API key from your Highlightly dashboard. Make sure there are no extra spaces around the = sign.
- Confirm you signed up at highlightly.net (not RapidAPI). Accounts are not cross-compatible between platforms.
- Make sure your virtual environment is activated. You should see (venv) in your terminal prompt.
Help me debug my Highlightly API connection in Python
✔️ Awesome, I've got everything!
Great. Double check you have saved tracker.py before moving on.
ⓧ I'd like to double check the full code
import json
import os
import requests
STATS_DIR = "stats"
def load_api_key():
"""Read the Highlightly API key from the .env file."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if not os.path.exists(env_path):
return None
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line.startswith("HIGHLIGHTLY_API_KEY="):
return line.split("=", 1)[1]
return None
def record_player(players):
"""Record stats for a player."""
name = input("Enter player name: ").strip()
if not name:
print("Player name cannot be empty.")
return
print(f"Recording stats for {name}:")
stats = {}
for category in ["kills", "errors", "attempts", "aces", "digs", "blocks", "sets_played"]:
while True:
try:
value = int(input(f" {category}: "))
if value < 0:
print(" Value must be non-negative.")
continue
if category == "sets_played" and value < 1:
print(" Sets played must be at least 1.")
continue
stats[category] = value
break
except ValueError:
print(" Please enter a whole number.")
players[name] = stats
print(f"Stats recorded for {name}.\n")
def calculate_analytics(player_stats):
"""Calculate volleyball analytics from raw stats."""
attempts = player_stats["attempts"]
sets_played = player_stats["sets_played"]
analytics = {}
if attempts > 0:
analytics["hitting_pct"] = (player_stats["kills"] - player_stats["errors"]) / attempts
analytics["kill_efficiency"] = player_stats["kills"] / attempts
else:
analytics["hitting_pct"] = None
analytics["kill_efficiency"] = None
analytics["aces_per_set"] = player_stats["aces"] / sets_played
analytics["digs_per_set"] = player_stats["digs"] / sets_played
analytics["blocks_per_set"] = player_stats["blocks"] / sets_played
return analytics
def get_performance_rating(hitting_pct):
"""Return a performance rating based on hitting percentage."""
if hitting_pct is None:
return "N/A"
if hitting_pct < 0.0:
return "Needs improvement"
if hitting_pct < 0.200:
return "Average"
if hitting_pct < 0.300:
return "Good"
return "Excellent"
def format_stat(value):
"""Format a stat value for display."""
if value is None:
return "N/A"
return f"{value:.3f}"
def view_analytics(players):
"""Display analytics for a selected player."""
if not players:
print("No players recorded yet.\n")
return
print("Recorded players:")
player_names = list(players.keys())
for i, name in enumerate(player_names, 1):
print(f" {i}. {name}")
try:
choice = int(input("Select a player number: ")) - 1
if choice < 0 or choice >= len(player_names):
print("Invalid selection.\n")
return
except ValueError:
print("Invalid selection.\n")
return
name = player_names[choice]
stats = players[name]
analytics = calculate_analytics(stats)
print(f"\n{'='*40}")
print(f" Stats for {name}")
print(f"{'='*40}")
print(f" Kills: {stats['kills']}")
print(f" Errors: {stats['errors']}")
print(f" Attempts: {stats['attempts']}")
print(f" Aces: {stats['aces']}")
print(f" Digs: {stats['digs']}")
print(f" Blocks: {stats['blocks']}")
print(f" Sets Played: {stats['sets_played']}")
print(f"{'-'*40}")
print(f" Hitting %: {format_stat(analytics['hitting_pct'])}")
print(f" Kill Efficiency: {format_stat(analytics['kill_efficiency'])}")
print(f" Aces/Set: {format_stat(analytics['aces_per_set'])}")
print(f" Digs/Set: {format_stat(analytics['digs_per_set'])}")
print(f" Blocks/Set: {format_stat(analytics['blocks_per_set'])}")
print(f" Rating: {get_performance_rating(analytics['hitting_pct'])}")
print(f"{'='*40}\n")
def save_stats(players):
"""Save all player stats to a JSON file."""
if not players:
print("No players to save.\n")
return
os.makedirs(STATS_DIR, exist_ok=True)
default_name = f"match_{len(os.listdir(STATS_DIR)) + 1}"
filename = input(f"Enter filename (default: {default_name}): ").strip()
if not filename:
filename = default_name
if not filename.endswith(".json"):
filename += ".json"
filepath = os.path.join(STATS_DIR, filename)
with open(filepath, "w") as f:
json.dump(players, f, indent=2)
print(f"Stats saved to {filepath}\n")
def load_stats():
"""Load player stats from a JSON file."""
if not os.path.exists(STATS_DIR):
print("No saved stat sheets found.\n")
return None
files = [f for f in os.listdir(STATS_DIR) if f.endswith(".json")]
if not files:
print("No saved stat sheets found.\n")
return None
print("Saved stat sheets:")
for i, f in enumerate(files, 1):
print(f" {i}. {f}")
try:
choice = int(input("Select a file number: ")) - 1
if choice < 0 or choice >= len(files):
print("Invalid selection.\n")
return None
except ValueError:
print("Invalid selection.\n")
return None
filepath = os.path.join(STATS_DIR, files[choice])
with open(filepath, "r") as f:
players = json.load(f)
print(f"Loaded {len(players)} player(s) from {files[choice]}\n")
return players
def lookup_team(api_key):
"""Search for a volleyball team using the Highlightly API."""
team_name = input("Enter team name to search: ").strip()
if not team_name:
print("Team name cannot be empty.\n")
return
try:
response = requests.get(
"https://volleyball.highlightly.net/teams",
headers={"x-rapidapi-key": api_key},
params={"name": team_name, "limit": 5},
)
response.raise_for_status()
except requests.RequestException as e:
print(f"API request failed: {e}\n")
return
data = response.json()
teams = data.get("data", [])
if not teams:
print(f"No teams found matching '{team_name}'.\n")
return
print(f"\nTeams matching '{team_name}':")
for i, team in enumerate(teams, 1):
print(f" {i}. {team['name']} (ID: {team['id']})")
print()
show_standings = input("Look up standings for a league? (y/n): ").strip().lower()
if show_standings == "y":
try:
league_id = int(input("Enter league ID: "))
season = int(input("Enter season year: "))
except ValueError:
print("Invalid input.\n")
return
lookup_standings(api_key, league_id, season)
def lookup_standings(api_key, league_id, season):
"""Fetch and display league standings from the Highlightly API."""
try:
response = requests.get(
"https://volleyball.highlightly.net/standings",
headers={"x-rapidapi-key": api_key},
params={"leagueId": league_id, "season": season},
)
response.raise_for_status()
except requests.RequestException as e:
print(f"API request failed: {e}\n")
return
data = response.json()
groups = data.get("groups", [])
league = data.get("league", {})
if not groups:
print("No standings found for this league/season.\n")
return
print(f"\n{'='*55}")
print(f" {league.get('name', 'Unknown')} - {league.get('season', 'N/A')}")
print(f"{'='*55}")
print(f" {'Pos':<5}{'Team':<25}{'W':<5}{'L':<5}{'Pts':<5}")
print(f" {'-'*50}")
for group in groups:
for entry in group.get("standings", []):
team = entry.get("team", {})
print(
f" {entry.get('position', '-'):<5}"
f"{team.get('name', 'Unknown'):<25}"
f"{entry.get('wins', 0):<5}"
f"{entry.get('loses', 0):<5}"
f"{entry.get('points', 0):<5}"
)
print(f"{'='*55}\n")
def main():
"""Main program loop."""
players = {}
api_key = load_api_key()
print("=" * 40)
print(" Volleyball Stat Tracker")
print("=" * 40)
while True:
print("1. Record player stats")
print("2. View analytics")
print("3. Save stats")
print("4. Load stats")
print("5. Look up pro team")
print("6. Exit")
choice = input("\nSelect an option: ").strip()
if choice == "1":
record_player(players)
elif choice == "2":
view_analytics(players)
elif choice == "3":
save_stats(players)
elif choice == "4":
loaded = load_stats()
if loaded is not None:
players = loaded
elif choice == "5":
if api_key:
lookup_team(api_key)
else:
print("No API key found. Add HIGHLIGHTLY_API_KEY to your .env file.\n")
elif choice == "6":
print("Goodbye!")
break
else:
print("Invalid option. Please try again.\n")
if __name__ == "__main__":
main()
Commit and push the final version
- Stage, commit, and push your changes by running these commands:
git add .
git commit -m "Add Highlightly API integration for pro team lookup"
git push
- You should see your commit pushed to the main branch on GitHub.
Push rejected or authentication error?
- If Git asks for credentials, follow the prompts to authenticate via your browser. GitHub uses token-based authentication for HTTPS pushes.
- Make sure you are inside the volleyball-tracker folder when running Git commands.
Help me push my volleyball-tracker project to GitHub
Your volleyball stat tracker is now a complete tool. It records stats, calculates analytics, saves and loads data, and pulls live professional volleyball data from the internet. Nice work building a real-world Python project from scratch.
Secret mission
Add Player Comparison Mode
Your stat tracker records and analyzes individual players. But what if you could put two players head-to-head? Add a comparison feature that loads stats from memory or saved files and displays a side-by-side breakdown with arrow indicators showing who leads each category.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses only free-tier services. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- volleyball-tracker project folder on your Desktop (includes virtual environment, code, and saved stat sheets).
- GitHub repository (volleyball-tracker, free).
- Highlightly API account (free tier, no charges).
Keep everything running
No action needed. Choose this if you are still actively building or want to add more features to your tracker.
- Your GitHub repo and Highlightly account are both free. No charges will accumulate.
- Your local project folder stays on your Desktop for easy access.
- Your virtual environment remains ready to activate whenever you want to work on the project again.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and accounts stay intact for when you return.
- Deactivate your virtual environment by running:
deactivate
- Close the VS Code window for the project.
- Your GitHub repo and Highlightly account remain available at no cost whenever you are ready to return.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Delete the local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf volleyball-tracker
- On Windows, run:
cd ~/Desktop
rmdir /s /q volleyball-tracker
- Delete the GitHub repository by going to your volleyball-tracker repo on GitHub.
- Click Settings in the repo navigation bar.
- Scroll down to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
- Delete your Highlightly account by visiting your account settings at highlightly.net.
Nice Work!
Nice Work!
You just built a fully functional volleyball stat tracker from scratch and pushed it to GitHub. That's a real Python project solving a real problem, live on your profile for anyone to see.
Here's what you accomplished:
- Built a menu-driven CLI tool in Python that records volleyball match stats and calculates real analytics like hitting percentage and kill efficiency.
- Connected to the Highlightly Volleyball API to look up live professional team data and league standings.
- Learned Git and GitHub from scratch with structured commits and a real project pushed to your profile.
- Secret Mission: Added a player comparison feature with side-by-side analytics and arrow indicators.
Ready to quiz yourself?