Spaces:
Sleeping
Sleeping
HatmanStack commited on
Commit ·
56bab24
1
Parent(s): fd53f23
ci(hooks): add pre-commit config with ruff and mypy hooks
Browse files- .pre-commit-config.yaml +15 -0
- app.py +0 -2
- docs/plans/2026-03-25-audit-streamlit-nba/Phase-4.md +3 -3
- pages/1_home_team.py +10 -3
- pages/2_play_game.py +7 -1
- pyproject.toml +1 -0
- scripts/compile_model.py +1 -3
- src/config.py +0 -1
- src/database/queries.py +1 -3
- src/ml/model.py +1 -3
- src/utils/html.py +1 -2
- tests/test_database.py +18 -9
- tests/test_ml.py +5 -15
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 3 |
+
rev: v0.15.7
|
| 4 |
+
hooks:
|
| 5 |
+
- id: ruff
|
| 6 |
+
args: [--fix]
|
| 7 |
+
- id: ruff-format
|
| 8 |
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
| 9 |
+
rev: v1.19.1
|
| 10 |
+
hooks:
|
| 11 |
+
- id: mypy
|
| 12 |
+
additional_dependencies: [pandas-stubs, pydantic>=2.5.0]
|
| 13 |
+
args: [--config-file=pyproject.toml]
|
| 14 |
+
pass_filenames: false
|
| 15 |
+
entry: mypy src/
|
app.py
CHANGED
|
@@ -12,5 +12,3 @@ safe_paragraph(
|
|
| 12 |
"career stats to compete with a Computer",
|
| 13 |
color="white",
|
| 14 |
)
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 12 |
"career stats to compete with a Computer",
|
| 13 |
color="white",
|
| 14 |
)
|
|
|
|
|
|
docs/plans/2026-03-25-audit-streamlit-nba/Phase-4.md
CHANGED
|
@@ -92,9 +92,9 @@ ci(deps): consolidate to pyproject.toml, remove requirements files
|
|
| 92 |
- Note: Use the latest stable versions of ruff and mypy that are compatible. Check PyPI for current versions.
|
| 93 |
|
| 94 |
**Verification Checklist:**
|
| 95 |
-
- [
|
| 96 |
-
- [
|
| 97 |
-
- [
|
| 98 |
|
| 99 |
**Testing Instructions:** Run `uvx pre-commit run --all-files` and verify all hooks pass.
|
| 100 |
|
|
|
|
| 92 |
- Note: Use the latest stable versions of ruff and mypy that are compatible. Check PyPI for current versions.
|
| 93 |
|
| 94 |
**Verification Checklist:**
|
| 95 |
+
- [x] `.pre-commit-config.yaml` exists at project root
|
| 96 |
+
- [x] `pre-commit` is in dev dependencies
|
| 97 |
+
- [x] `uvx pre-commit run --all-files` passes
|
| 98 |
|
| 99 |
**Testing Instructions:** Run `uvx pre-commit run --all-files` and verify all hooks pass.
|
| 100 |
|
pages/1_home_team.py
CHANGED
|
@@ -25,6 +25,7 @@ configure_page()
|
|
| 25 |
def _load_nba_data() -> pd.DataFrame:
|
| 26 |
return load_data()
|
| 27 |
|
|
|
|
| 28 |
# Initialize session state before any access
|
| 29 |
init_session_state()
|
| 30 |
|
|
@@ -53,7 +54,9 @@ def find_player(search_term: str) -> list[str]:
|
|
| 53 |
# Validate input
|
| 54 |
validated_term = validate_search_term(search_term)
|
| 55 |
if validated_term is None:
|
| 56 |
-
st.warning(
|
|
|
|
|
|
|
| 57 |
return []
|
| 58 |
|
| 59 |
try:
|
|
@@ -104,7 +107,9 @@ home_team_df = find_home_team()
|
|
| 104 |
|
| 105 |
# Combine search results with current team and current unsaved selections
|
| 106 |
# This ensures that selections don't disappear when the search term changes
|
| 107 |
-
current_team_names =
|
|
|
|
|
|
|
| 108 |
current_selections = st.session_state.get("player_selector", [])
|
| 109 |
|
| 110 |
# Merge all into options list, maintaining uniqueness
|
|
@@ -125,7 +130,9 @@ def save_state() -> None:
|
|
| 125 |
|
| 126 |
col1, col2 = st.columns([7, 1])
|
| 127 |
with col1:
|
| 128 |
-
default_selection =
|
|
|
|
|
|
|
| 129 |
player_selected = st.multiselect(
|
| 130 |
"Search Results:",
|
| 131 |
player_search,
|
|
|
|
| 25 |
def _load_nba_data() -> pd.DataFrame:
|
| 26 |
return load_data()
|
| 27 |
|
| 28 |
+
|
| 29 |
# Initialize session state before any access
|
| 30 |
init_session_state()
|
| 31 |
|
|
|
|
| 54 |
# Validate input
|
| 55 |
validated_term = validate_search_term(search_term)
|
| 56 |
if validated_term is None:
|
| 57 |
+
st.warning(
|
| 58 |
+
"Invalid search term. Please use only letters, numbers, and basic punctuation."
|
| 59 |
+
)
|
| 60 |
return []
|
| 61 |
|
| 62 |
try:
|
|
|
|
| 107 |
|
| 108 |
# Combine search results with current team and current unsaved selections
|
| 109 |
# This ensures that selections don't disappear when the search term changes
|
| 110 |
+
current_team_names = (
|
| 111 |
+
home_team_df["FULL_NAME"].tolist() if not home_team_df.empty else []
|
| 112 |
+
)
|
| 113 |
current_selections = st.session_state.get("player_selector", [])
|
| 114 |
|
| 115 |
# Merge all into options list, maintaining uniqueness
|
|
|
|
| 130 |
|
| 131 |
col1, col2 = st.columns([7, 1])
|
| 132 |
with col1:
|
| 133 |
+
default_selection = (
|
| 134 |
+
home_team_df["FULL_NAME"].tolist() if not home_team_df.empty else []
|
| 135 |
+
)
|
| 136 |
player_selected = st.multiselect(
|
| 137 |
"Search Results:",
|
| 138 |
player_search,
|
pages/2_play_game.py
CHANGED
|
@@ -45,6 +45,7 @@ def _load_nba_data() -> pd.DataFrame:
|
|
| 45 |
def _get_model(): # type: ignore[no-untyped-def]
|
| 46 |
return get_winner_model()
|
| 47 |
|
|
|
|
| 48 |
# Initialize session state BEFORE any access
|
| 49 |
init_session_state()
|
| 50 |
|
|
@@ -135,7 +136,10 @@ if home_team_df.empty or home_team_df.shape[0] != TEAM_SIZE:
|
|
| 135 |
box_score = pd.DataFrame()
|
| 136 |
else:
|
| 137 |
# Only generate away team if we don't have one or it's empty
|
| 138 |
-
if
|
|
|
|
|
|
|
|
|
|
| 139 |
st.session_state.away_team_df = find_away_team(stats)
|
| 140 |
|
| 141 |
away_data = st.session_state.away_team_df
|
|
@@ -207,9 +211,11 @@ if teams_good and winner_label:
|
|
| 207 |
safe_heading("Away Team", level=1, color="steelblue")
|
| 208 |
st.dataframe(st.session_state.away_team_df)
|
| 209 |
|
|
|
|
| 210 |
def play_new_team() -> None:
|
| 211 |
"""Clear cached away team and rerun."""
|
| 212 |
logger.info("New Team requested")
|
| 213 |
st.session_state.away_team_df = pd.DataFrame()
|
| 214 |
|
|
|
|
| 215 |
st.button("Play New Team", on_click=play_new_team)
|
|
|
|
| 45 |
def _get_model(): # type: ignore[no-untyped-def]
|
| 46 |
return get_winner_model()
|
| 47 |
|
| 48 |
+
|
| 49 |
# Initialize session state BEFORE any access
|
| 50 |
init_session_state()
|
| 51 |
|
|
|
|
| 136 |
box_score = pd.DataFrame()
|
| 137 |
else:
|
| 138 |
# Only generate away team if we don't have one or it's empty
|
| 139 |
+
if (
|
| 140 |
+
st.session_state.get("away_team_df") is None
|
| 141 |
+
or st.session_state.away_team_df.empty
|
| 142 |
+
):
|
| 143 |
st.session_state.away_team_df = find_away_team(stats)
|
| 144 |
|
| 145 |
away_data = st.session_state.away_team_df
|
|
|
|
| 211 |
safe_heading("Away Team", level=1, color="steelblue")
|
| 212 |
st.dataframe(st.session_state.away_team_df)
|
| 213 |
|
| 214 |
+
|
| 215 |
def play_new_team() -> None:
|
| 216 |
"""Clear cached away team and rerun."""
|
| 217 |
logger.info("New Team requested")
|
| 218 |
st.session_state.away_team_df = pd.DataFrame()
|
| 219 |
|
| 220 |
+
|
| 221 |
st.button("Play New Team", on_click=play_new_team)
|
pyproject.toml
CHANGED
|
@@ -19,6 +19,7 @@ dev = [
|
|
| 19 |
"mypy>=1.7.0",
|
| 20 |
"ruff>=0.1.6",
|
| 21 |
"pandas-stubs>=2.0.0",
|
|
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[tool.mypy]
|
|
|
|
| 19 |
"mypy>=1.7.0",
|
| 20 |
"ruff>=0.1.6",
|
| 21 |
"pandas-stubs>=2.0.0",
|
| 22 |
+
"pre-commit>=3.0.0",
|
| 23 |
]
|
| 24 |
|
| 25 |
[tool.mypy]
|
scripts/compile_model.py
CHANGED
|
@@ -70,9 +70,7 @@ EPOCHS: list[int] = [500, 1000, 1500]
|
|
| 70 |
BATCH_SIZES: list[int] = [50, 100, 200]
|
| 71 |
|
| 72 |
|
| 73 |
-
def create_stats(
|
| 74 |
-
roster: pd.DataFrame, schedule: pd.DataFrame
|
| 75 |
-
) -> list[np.ndarray]:
|
| 76 |
"""Create feature arrays from roster and schedule data.
|
| 77 |
|
| 78 |
Args:
|
|
|
|
| 70 |
BATCH_SIZES: list[int] = [50, 100, 200]
|
| 71 |
|
| 72 |
|
| 73 |
+
def create_stats(roster: pd.DataFrame, schedule: pd.DataFrame) -> list[np.ndarray]:
|
|
|
|
|
|
|
| 74 |
"""Create feature arrays from roster and schedule data.
|
| 75 |
|
| 76 |
Args:
|
src/config.py
CHANGED
|
@@ -91,7 +91,6 @@ def setup_logging(level: int = logging.INFO) -> logging.Logger:
|
|
| 91 |
return logger
|
| 92 |
|
| 93 |
|
| 94 |
-
|
| 95 |
def configure_page() -> None:
|
| 96 |
"""Configure Streamlit page settings and logging."""
|
| 97 |
setup_logging()
|
|
|
|
| 91 |
return logger
|
| 92 |
|
| 93 |
|
|
|
|
| 94 |
def configure_page() -> None:
|
| 95 |
"""Configure Streamlit page settings and logging."""
|
| 96 |
setup_logging()
|
src/database/queries.py
CHANGED
|
@@ -30,9 +30,7 @@ def search_player_by_name(df: pd.DataFrame, name: str) -> list[tuple[str]]:
|
|
| 30 |
return [(player_name,) for player_name in results]
|
| 31 |
|
| 32 |
|
| 33 |
-
def get_players_by_full_names(
|
| 34 |
-
df: pd.DataFrame, names: list[str]
|
| 35 |
-
) -> pd.DataFrame:
|
| 36 |
"""Get multiple players' records in a single batch query.
|
| 37 |
|
| 38 |
Args:
|
|
|
|
| 30 |
return [(player_name,) for player_name in results]
|
| 31 |
|
| 32 |
|
| 33 |
+
def get_players_by_full_names(df: pd.DataFrame, names: list[str]) -> pd.DataFrame:
|
|
|
|
|
|
|
| 34 |
"""Get multiple players' records in a single batch query.
|
| 35 |
|
| 36 |
Args:
|
src/ml/model.py
CHANGED
|
@@ -64,9 +64,7 @@ def predict_winner(combined_stats: np.ndarray) -> tuple[float, int]:
|
|
| 64 |
ValueError: If input shape is invalid
|
| 65 |
"""
|
| 66 |
if combined_stats.shape != (1, 100):
|
| 67 |
-
raise ValueError(
|
| 68 |
-
f"Expected input shape (1, 100), got {combined_stats.shape}"
|
| 69 |
-
)
|
| 70 |
|
| 71 |
model = get_winner_model()
|
| 72 |
sigmoid_output = model.predict(combined_stats, verbose=0)
|
|
|
|
| 64 |
ValueError: If input shape is invalid
|
| 65 |
"""
|
| 66 |
if combined_stats.shape != (1, 100):
|
| 67 |
+
raise ValueError(f"Expected input shape (1, 100), got {combined_stats.shape}")
|
|
|
|
|
|
|
| 68 |
|
| 69 |
model = get_winner_model()
|
| 70 |
sigmoid_output = model.predict(combined_stats, verbose=0)
|
src/utils/html.py
CHANGED
|
@@ -64,7 +64,6 @@ def safe_paragraph(
|
|
| 64 |
safe_align = escape_html(align)
|
| 65 |
|
| 66 |
st.markdown(
|
| 67 |
-
f"<p style='text-align: {safe_align}; color: {safe_color};'>"
|
| 68 |
-
f"{safe_text}</p>",
|
| 69 |
unsafe_allow_html=True,
|
| 70 |
)
|
|
|
|
| 64 |
safe_align = escape_html(align)
|
| 65 |
|
| 66 |
st.markdown(
|
| 67 |
+
f"<p style='text-align: {safe_align}; color: {safe_color};'>{safe_text}</p>",
|
|
|
|
| 68 |
unsafe_allow_html=True,
|
| 69 |
)
|
tests/test_database.py
CHANGED
|
@@ -47,7 +47,9 @@ class TestLoadData:
|
|
| 47 |
@patch("src.database.connection.pd.read_csv")
|
| 48 |
@patch("src.database.connection.CSV_PATH")
|
| 49 |
def test_load_data_parser_error_raises_connection_error(
|
| 50 |
-
self,
|
|
|
|
|
|
|
| 51 |
) -> None:
|
| 52 |
"""Test that CSV parse errors raise DatabaseConnectionError."""
|
| 53 |
mock_path.exists.return_value = True
|
|
@@ -120,10 +122,12 @@ class TestGetAwayTeamByStats:
|
|
| 120 |
def test_max_attempts_raises_error(self) -> None:
|
| 121 |
"""Test that max_attempts limit works when population is too small."""
|
| 122 |
# Create a DF with only 2 players
|
| 123 |
-
df = pd.DataFrame(
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
# Add missing columns to avoid errors if needed, though queries only use these
|
| 128 |
for col in PLAYER_COLUMNS:
|
| 129 |
if col not in df.columns:
|
|
@@ -146,10 +150,15 @@ class TestGetAwayTeamByStats:
|
|
| 146 |
# Create a DF with 10 players meeting criteria
|
| 147 |
data = []
|
| 148 |
for i in range(10):
|
| 149 |
-
data.append(
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
df = pd.DataFrame(data)
|
| 154 |
for col in PLAYER_COLUMNS:
|
| 155 |
if col not in df.columns:
|
|
|
|
| 47 |
@patch("src.database.connection.pd.read_csv")
|
| 48 |
@patch("src.database.connection.CSV_PATH")
|
| 49 |
def test_load_data_parser_error_raises_connection_error(
|
| 50 |
+
self,
|
| 51 |
+
mock_path,
|
| 52 |
+
mock_read_csv, # type: ignore[no-untyped-def]
|
| 53 |
) -> None:
|
| 54 |
"""Test that CSV parse errors raise DatabaseConnectionError."""
|
| 55 |
mock_path.exists.return_value = True
|
|
|
|
| 122 |
def test_max_attempts_raises_error(self) -> None:
|
| 123 |
"""Test that max_attempts limit works when population is too small."""
|
| 124 |
# Create a DF with only 2 players
|
| 125 |
+
df = pd.DataFrame(
|
| 126 |
+
[
|
| 127 |
+
{"FULL_NAME": "P1", "PTS": 1001, "REB": 501, "AST": 301, "STL": 101},
|
| 128 |
+
{"FULL_NAME": "P2", "PTS": 1001, "REB": 501, "AST": 301, "STL": 101},
|
| 129 |
+
]
|
| 130 |
+
)
|
| 131 |
# Add missing columns to avoid errors if needed, though queries only use these
|
| 132 |
for col in PLAYER_COLUMNS:
|
| 133 |
if col not in df.columns:
|
|
|
|
| 150 |
# Create a DF with 10 players meeting criteria
|
| 151 |
data = []
|
| 152 |
for i in range(10):
|
| 153 |
+
data.append(
|
| 154 |
+
{
|
| 155 |
+
"FULL_NAME": f"Player{i}",
|
| 156 |
+
"PTS": 2000,
|
| 157 |
+
"REB": 1000,
|
| 158 |
+
"AST": 500,
|
| 159 |
+
"STL": 200,
|
| 160 |
+
}
|
| 161 |
+
)
|
| 162 |
df = pd.DataFrame(data)
|
| 163 |
for col in PLAYER_COLUMNS:
|
| 164 |
if col not in df.columns:
|
tests/test_ml.py
CHANGED
|
@@ -32,9 +32,7 @@ class TestAnalyzeTeamStats:
|
|
| 32 |
home_stats = [[float(i * 10 + j) for j in range(10)] for i in range(5)]
|
| 33 |
away_stats = [[float(50 + i * 10 + j) for j in range(10)] for i in range(5)]
|
| 34 |
|
| 35 |
-
_home_array, _away_array, combined = analyze_team_stats(
|
| 36 |
-
home_stats, away_stats
|
| 37 |
-
)
|
| 38 |
|
| 39 |
# Combined should have 100 values: 50 home + 50 away
|
| 40 |
assert combined.shape == (1, 100)
|
|
@@ -100,9 +98,7 @@ class TestPredictWinner:
|
|
| 100 |
assert prediction in (0, 1)
|
| 101 |
|
| 102 |
@patch("src.ml.model.get_winner_model")
|
| 103 |
-
def test_high_probability_predicts_win(
|
| 104 |
-
self, mock_get_model: MagicMock
|
| 105 |
-
) -> None:
|
| 106 |
"""Test that high probability (>0.5) predicts home win (1)."""
|
| 107 |
mock_model = MagicMock()
|
| 108 |
mock_model.predict.return_value = np.array([[0.8]])
|
|
@@ -115,9 +111,7 @@ class TestPredictWinner:
|
|
| 115 |
assert prediction == 1
|
| 116 |
|
| 117 |
@patch("src.ml.model.get_winner_model")
|
| 118 |
-
def test_low_probability_predicts_loss(
|
| 119 |
-
self, mock_get_model: MagicMock
|
| 120 |
-
) -> None:
|
| 121 |
"""Test that low probability (<0.5) predicts home loss (0)."""
|
| 122 |
mock_model = MagicMock()
|
| 123 |
mock_model.predict.return_value = np.array([[0.3]])
|
|
@@ -130,9 +124,7 @@ class TestPredictWinner:
|
|
| 130 |
assert prediction == 0
|
| 131 |
|
| 132 |
@patch("src.ml.model.get_winner_model")
|
| 133 |
-
def test_invalid_shape_raises_error(
|
| 134 |
-
self, mock_get_model: MagicMock
|
| 135 |
-
) -> None:
|
| 136 |
"""Test that invalid input shape raises ValueError."""
|
| 137 |
mock_model = MagicMock()
|
| 138 |
mock_get_model.return_value = mock_model
|
|
@@ -146,9 +138,7 @@ class TestPredictWinner:
|
|
| 146 |
assert "Expected input shape (1, 100)" in str(exc_info.value)
|
| 147 |
|
| 148 |
@patch("src.ml.model.get_winner_model")
|
| 149 |
-
def test_model_called_with_verbose_zero(
|
| 150 |
-
self, mock_get_model: MagicMock
|
| 151 |
-
) -> None:
|
| 152 |
"""Test that model.predict is called with verbose=0."""
|
| 153 |
mock_model = MagicMock()
|
| 154 |
mock_model.predict.return_value = np.array([[0.5]])
|
|
|
|
| 32 |
home_stats = [[float(i * 10 + j) for j in range(10)] for i in range(5)]
|
| 33 |
away_stats = [[float(50 + i * 10 + j) for j in range(10)] for i in range(5)]
|
| 34 |
|
| 35 |
+
_home_array, _away_array, combined = analyze_team_stats(home_stats, away_stats)
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# Combined should have 100 values: 50 home + 50 away
|
| 38 |
assert combined.shape == (1, 100)
|
|
|
|
| 98 |
assert prediction in (0, 1)
|
| 99 |
|
| 100 |
@patch("src.ml.model.get_winner_model")
|
| 101 |
+
def test_high_probability_predicts_win(self, mock_get_model: MagicMock) -> None:
|
|
|
|
|
|
|
| 102 |
"""Test that high probability (>0.5) predicts home win (1)."""
|
| 103 |
mock_model = MagicMock()
|
| 104 |
mock_model.predict.return_value = np.array([[0.8]])
|
|
|
|
| 111 |
assert prediction == 1
|
| 112 |
|
| 113 |
@patch("src.ml.model.get_winner_model")
|
| 114 |
+
def test_low_probability_predicts_loss(self, mock_get_model: MagicMock) -> None:
|
|
|
|
|
|
|
| 115 |
"""Test that low probability (<0.5) predicts home loss (0)."""
|
| 116 |
mock_model = MagicMock()
|
| 117 |
mock_model.predict.return_value = np.array([[0.3]])
|
|
|
|
| 124 |
assert prediction == 0
|
| 125 |
|
| 126 |
@patch("src.ml.model.get_winner_model")
|
| 127 |
+
def test_invalid_shape_raises_error(self, mock_get_model: MagicMock) -> None:
|
|
|
|
|
|
|
| 128 |
"""Test that invalid input shape raises ValueError."""
|
| 129 |
mock_model = MagicMock()
|
| 130 |
mock_get_model.return_value = mock_model
|
|
|
|
| 138 |
assert "Expected input shape (1, 100)" in str(exc_info.value)
|
| 139 |
|
| 140 |
@patch("src.ml.model.get_winner_model")
|
| 141 |
+
def test_model_called_with_verbose_zero(self, mock_get_model: MagicMock) -> None:
|
|
|
|
|
|
|
| 142 |
"""Test that model.predict is called with verbose=0."""
|
| 143 |
mock_model = MagicMock()
|
| 144 |
mock_model.predict.return_value = np.array([[0.5]])
|