nfl-data-mcp 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nfl_data_mcp/__init__.py +3 -0
- nfl_data_mcp/cli.py +63 -0
- nfl_data_mcp/config.py +52 -0
- nfl_data_mcp/domain/__init__.py +21 -0
- nfl_data_mcp/domain/models.py +184 -0
- nfl_data_mcp/domain/provenance.py +26 -0
- nfl_data_mcp/domain/resolution.py +102 -0
- nfl_data_mcp/errors.py +52 -0
- nfl_data_mcp/ingestion/__init__.py +5 -0
- nfl_data_mcp/ingestion/normalize.py +307 -0
- nfl_data_mcp/ingestion/sync.py +143 -0
- nfl_data_mcp/py.typed +1 -0
- nfl_data_mcp/repositories/__init__.py +5 -0
- nfl_data_mcp/repositories/nfl.py +418 -0
- nfl_data_mcp/server.py +429 -0
- nfl_data_mcp/services/__init__.py +5 -0
- nfl_data_mcp/services/aggregation.py +184 -0
- nfl_data_mcp/services/availability.py +142 -0
- nfl_data_mcp/services/nfl.py +853 -0
- nfl_data_mcp/storage/__init__.py +5 -0
- nfl_data_mcp/storage/catalog.py +312 -0
- nfl_data_mcp-0.1.2.dist-info/METADATA +253 -0
- nfl_data_mcp-0.1.2.dist-info/RECORD +27 -0
- nfl_data_mcp-0.1.2.dist-info/WHEEL +4 -0
- nfl_data_mcp-0.1.2.dist-info/entry_points.txt +4 -0
- nfl_data_mcp-0.1.2.dist-info/licenses/LICENSE +201 -0
- nfl_data_mcp-0.1.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +37 -0
nfl_data_mcp/__init__.py
ADDED
nfl_data_mcp/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Command-line entry points for synchronization and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from nfl_data_mcp import __version__
|
|
10
|
+
from nfl_data_mcp.config import get_settings
|
|
11
|
+
from nfl_data_mcp.ingestion.sync import SUPPORTED_DATASETS, sync_datasets
|
|
12
|
+
from nfl_data_mcp.storage.catalog import Catalog
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sync_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="nfl-data-sync",
|
|
18
|
+
description="Explicitly synchronize nflverse datasets into the local DuckDB catalog.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--datasets",
|
|
22
|
+
nargs="+",
|
|
23
|
+
choices=SUPPORTED_DATASETS,
|
|
24
|
+
default=list(SUPPORTED_DATASETS),
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--season", type=int, required=True)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--allow-network",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Required confirmation that the command may download nflverse data.",
|
|
31
|
+
)
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sync_main(argv: Sequence[str] | None = None) -> int:
|
|
36
|
+
args = _sync_parser().parse_args(argv)
|
|
37
|
+
if not args.allow_network:
|
|
38
|
+
raise SystemExit("Refusing to download data without --allow-network.")
|
|
39
|
+
settings = get_settings()
|
|
40
|
+
statuses = sync_datasets(
|
|
41
|
+
Catalog(settings.catalog_path),
|
|
42
|
+
datasets=args.datasets,
|
|
43
|
+
season=args.season,
|
|
44
|
+
)
|
|
45
|
+
print(json.dumps([status.model_dump(mode="json") for status in statuses], indent=2))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def doctor_main(argv: Sequence[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="nfl-data-doctor",
|
|
52
|
+
description="Validate that the local catalog is readable and report coverage.",
|
|
53
|
+
)
|
|
54
|
+
parser.parse_args(argv)
|
|
55
|
+
settings = get_settings()
|
|
56
|
+
catalog = Catalog(settings.catalog_path)
|
|
57
|
+
result = {
|
|
58
|
+
**catalog.health(),
|
|
59
|
+
"server_version": __version__,
|
|
60
|
+
"datasets": [status.model_dump(mode="json") for status in catalog.list_statuses()],
|
|
61
|
+
}
|
|
62
|
+
print(json.dumps(result, indent=2))
|
|
63
|
+
return 0
|
nfl_data_mcp/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Application configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_data_path
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeMode(StrEnum):
|
|
14
|
+
AUTO = "auto"
|
|
15
|
+
OFFLINE = "offline"
|
|
16
|
+
SNAPSHOT = "snapshot"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Settings(BaseSettings):
|
|
20
|
+
"""Validated runtime settings loaded from NFL_MCP_* variables."""
|
|
21
|
+
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
env_prefix="NFL_MCP_",
|
|
24
|
+
env_file=".env",
|
|
25
|
+
env_file_encoding="utf-8",
|
|
26
|
+
extra="ignore",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
data_dir: Path = Field(
|
|
30
|
+
default_factory=lambda: user_data_path("nfl-data-mcp", ensure_exists=False)
|
|
31
|
+
)
|
|
32
|
+
log_level: str = "INFO"
|
|
33
|
+
max_results: int = Field(default=200, ge=1, le=1_000)
|
|
34
|
+
mode: RuntimeMode = RuntimeMode.AUTO
|
|
35
|
+
refresh_ttl_players_hours: int = Field(default=24, ge=1)
|
|
36
|
+
refresh_ttl_schedules_hours: int = Field(default=6, ge=1)
|
|
37
|
+
refresh_ttl_player_stats_hours: int = Field(default=6, ge=1)
|
|
38
|
+
refresh_ttl_team_stats_hours: int = Field(default=6, ge=1)
|
|
39
|
+
refresh_ttl_rosters_hours: int = Field(default=6, ge=1)
|
|
40
|
+
refresh_ttl_injuries_hours: int = Field(default=2, ge=1)
|
|
41
|
+
refresh_ttl_depth_charts_hours: int = Field(default=6, ge=1)
|
|
42
|
+
refresh_ttl_snap_counts_hours: int = Field(default=12, ge=1)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def catalog_path(self) -> Path:
|
|
46
|
+
return self.data_dir / "catalog.duckdb"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_settings() -> Settings:
|
|
50
|
+
"""Build settings at the application boundary."""
|
|
51
|
+
|
|
52
|
+
return Settings()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Public domain contracts."""
|
|
2
|
+
|
|
3
|
+
from nfl_data_mcp.domain.models import (
|
|
4
|
+
AsOfStatus,
|
|
5
|
+
DataEnvelope,
|
|
6
|
+
DatasetStatus,
|
|
7
|
+
Game,
|
|
8
|
+
PageInfo,
|
|
9
|
+
Player,
|
|
10
|
+
ResponseMeta,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AsOfStatus",
|
|
15
|
+
"DataEnvelope",
|
|
16
|
+
"DatasetStatus",
|
|
17
|
+
"Game",
|
|
18
|
+
"PageInfo",
|
|
19
|
+
"Player",
|
|
20
|
+
"ResponseMeta",
|
|
21
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Stable models returned by services and MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
from pydantic.types import JsonValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsOfStatus(StrEnum):
|
|
14
|
+
CURRENT_SNAPSHOT = "current_snapshot"
|
|
15
|
+
WEEK_KEYED_NOT_KNOWLEDGE_TIME = "week_keyed_not_knowledge_time"
|
|
16
|
+
ARCHIVED_AS_OF = "archived_as_of"
|
|
17
|
+
DERIVED_PRIOR_ONLY = "derived_prior_only"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheStatus(StrEnum):
|
|
21
|
+
HIT = "hit"
|
|
22
|
+
REFRESHED = "refreshed"
|
|
23
|
+
STALE = "stale"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DomainModel(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Player(DomainModel):
|
|
31
|
+
player_id: str
|
|
32
|
+
display_name: str
|
|
33
|
+
first_name: str | None = None
|
|
34
|
+
last_name: str | None = None
|
|
35
|
+
position: str | None = None
|
|
36
|
+
team: str | None = None
|
|
37
|
+
status: str | None = None
|
|
38
|
+
gsis_id: str | None = None
|
|
39
|
+
espn_id: str | None = None
|
|
40
|
+
pfr_id: str | None = None
|
|
41
|
+
rookie_season: int | None = None
|
|
42
|
+
last_season: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StatSnapshot(DomainModel):
|
|
46
|
+
entity_id: str
|
|
47
|
+
entity_name: str
|
|
48
|
+
team: str
|
|
49
|
+
season: int | None
|
|
50
|
+
week: int | None
|
|
51
|
+
season_type: str
|
|
52
|
+
aggregation: str = "game"
|
|
53
|
+
game_id: str | None = None
|
|
54
|
+
opponent_team: str | None = None
|
|
55
|
+
position: str | None = None
|
|
56
|
+
offense: dict[str, JsonValue] = Field(default_factory=dict)
|
|
57
|
+
defense: dict[str, JsonValue] = Field(default_factory=dict)
|
|
58
|
+
special_teams: dict[str, JsonValue] = Field(default_factory=dict)
|
|
59
|
+
miscellaneous: dict[str, JsonValue] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Game(DomainModel):
|
|
63
|
+
game_id: str
|
|
64
|
+
season: int
|
|
65
|
+
week: int
|
|
66
|
+
season_type: str
|
|
67
|
+
home_team: str
|
|
68
|
+
away_team: str
|
|
69
|
+
kickoff: datetime | None = None
|
|
70
|
+
home_score: int | None = None
|
|
71
|
+
away_score: int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Team(DomainModel):
|
|
75
|
+
team: str
|
|
76
|
+
name: str
|
|
77
|
+
nickname: str
|
|
78
|
+
conference: str | None = None
|
|
79
|
+
division: str | None = None
|
|
80
|
+
primary_color: str | None = None
|
|
81
|
+
secondary_color: str | None = None
|
|
82
|
+
logo_url: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RosterEntry(DomainModel):
|
|
86
|
+
season: int
|
|
87
|
+
week: int
|
|
88
|
+
team: str
|
|
89
|
+
player_id: str
|
|
90
|
+
full_name: str
|
|
91
|
+
position: str | None = None
|
|
92
|
+
depth_chart_position: str | None = None
|
|
93
|
+
jersey_number: int | None = None
|
|
94
|
+
status: str | None = None
|
|
95
|
+
status_description: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InjuryReport(DomainModel):
|
|
99
|
+
season: int
|
|
100
|
+
week: int
|
|
101
|
+
team: str
|
|
102
|
+
player_id: str
|
|
103
|
+
full_name: str
|
|
104
|
+
position: str | None = None
|
|
105
|
+
primary_injury: str | None = None
|
|
106
|
+
secondary_injury: str | None = None
|
|
107
|
+
report_status: str | None = None
|
|
108
|
+
practice_status: str | None = None
|
|
109
|
+
modified_at: datetime | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DepthChartEntry(DomainModel):
|
|
113
|
+
season: int
|
|
114
|
+
week: int
|
|
115
|
+
team: str
|
|
116
|
+
player_id: str
|
|
117
|
+
full_name: str
|
|
118
|
+
position: str | None = None
|
|
119
|
+
formation: str | None = None
|
|
120
|
+
depth_position: str | None = None
|
|
121
|
+
depth_rank: int | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SnapCount(DomainModel):
|
|
125
|
+
game_id: str
|
|
126
|
+
season: int
|
|
127
|
+
week: int
|
|
128
|
+
team: str
|
|
129
|
+
opponent_team: str | None = None
|
|
130
|
+
player_id: str
|
|
131
|
+
player_name: str
|
|
132
|
+
position: str | None = None
|
|
133
|
+
offense_snaps: int = 0
|
|
134
|
+
offense_pct: float = 0
|
|
135
|
+
defense_snaps: int = 0
|
|
136
|
+
defense_pct: float = 0
|
|
137
|
+
special_teams_snaps: int = 0
|
|
138
|
+
special_teams_pct: float = 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class StatLeader(DomainModel):
|
|
142
|
+
rank: int
|
|
143
|
+
entity_id: str
|
|
144
|
+
entity_name: str
|
|
145
|
+
team: str
|
|
146
|
+
stat: str
|
|
147
|
+
value: float
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DatasetStatus(DomainModel):
|
|
151
|
+
dataset: str
|
|
152
|
+
source: str
|
|
153
|
+
source_version: str
|
|
154
|
+
ingested_at: datetime
|
|
155
|
+
row_count: int = Field(ge=0)
|
|
156
|
+
season_min: int | None = None
|
|
157
|
+
season_max: int | None = None
|
|
158
|
+
week_min: int | None = None
|
|
159
|
+
week_max: int | None = None
|
|
160
|
+
as_of_status: AsOfStatus
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ResponseMeta(DomainModel):
|
|
164
|
+
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
165
|
+
dataset: str
|
|
166
|
+
source: str = "nflverse"
|
|
167
|
+
source_version: str
|
|
168
|
+
ingested_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
169
|
+
as_of_status: AsOfStatus
|
|
170
|
+
mode: str = "offline"
|
|
171
|
+
cache_status: CacheStatus = CacheStatus.HIT
|
|
172
|
+
freshness: str = "cached"
|
|
173
|
+
warnings: tuple[str, ...] = ()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PageInfo(DomainModel):
|
|
177
|
+
limit: int = Field(ge=1)
|
|
178
|
+
next_cursor: str | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DataEnvelope[T](DomainModel):
|
|
182
|
+
data: T
|
|
183
|
+
meta: ResponseMeta
|
|
184
|
+
page: PageInfo | None = None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Helpers for producing consistent response provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nfl_data_mcp.domain.models import CacheStatus, DatasetStatus, ResponseMeta
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def response_meta(
|
|
9
|
+
status: DatasetStatus,
|
|
10
|
+
*,
|
|
11
|
+
mode: str = "offline",
|
|
12
|
+
cache_status: CacheStatus = CacheStatus.HIT,
|
|
13
|
+
freshness: str = "cached",
|
|
14
|
+
warnings: tuple[str, ...] = (),
|
|
15
|
+
) -> ResponseMeta:
|
|
16
|
+
return ResponseMeta(
|
|
17
|
+
dataset=status.dataset,
|
|
18
|
+
source=status.source,
|
|
19
|
+
source_version=status.source_version,
|
|
20
|
+
ingested_at=status.ingested_at,
|
|
21
|
+
as_of_status=status.as_of_status,
|
|
22
|
+
mode=mode,
|
|
23
|
+
cache_status=cache_status,
|
|
24
|
+
freshness=freshness,
|
|
25
|
+
warnings=warnings,
|
|
26
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Resolve human-friendly team and season references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from nfl_data_mcp.errors import InvalidArgumentError
|
|
8
|
+
|
|
9
|
+
SeasonRef = int | str
|
|
10
|
+
|
|
11
|
+
TEAM_ALIASES = {
|
|
12
|
+
"ARI": ("arizona", "arizona cardinals", "cardinals"),
|
|
13
|
+
"ATL": ("atlanta", "atlanta falcons", "falcons"),
|
|
14
|
+
"BAL": ("baltimore", "baltimore ravens", "ravens"),
|
|
15
|
+
"BUF": ("buffalo", "buffalo bills", "bills"),
|
|
16
|
+
"CAR": ("carolina", "carolina panthers", "panthers"),
|
|
17
|
+
"CHI": ("chicago", "chicago bears", "bears"),
|
|
18
|
+
"CIN": ("cincinnati", "cincinnati bengals", "bengals"),
|
|
19
|
+
"CLE": ("cleveland", "cleveland browns", "browns"),
|
|
20
|
+
"DAL": ("dallas", "dallas cowboys", "cowboys"),
|
|
21
|
+
"DEN": ("denver", "denver broncos", "broncos"),
|
|
22
|
+
"DET": ("detroit", "detroit lions", "lions"),
|
|
23
|
+
"GB": ("green bay", "green bay packers", "packers"),
|
|
24
|
+
"HOU": ("houston", "houston texans", "texans"),
|
|
25
|
+
"IND": ("indianapolis", "indianapolis colts", "colts"),
|
|
26
|
+
"JAX": ("jacksonville", "jacksonville jaguars", "jaguars", "jac"),
|
|
27
|
+
"KC": ("kansas city", "kansas city chiefs", "chiefs"),
|
|
28
|
+
"LA": ("los angeles rams", "la rams", "rams", "lar"),
|
|
29
|
+
"LAC": ("los angeles chargers", "la chargers", "chargers"),
|
|
30
|
+
"LV": ("las vegas", "las vegas raiders", "raiders", "oak"),
|
|
31
|
+
"MIA": ("miami", "miami dolphins", "dolphins"),
|
|
32
|
+
"MIN": ("minnesota", "minnesota vikings", "vikings"),
|
|
33
|
+
"NE": ("new england", "new england patriots", "patriots"),
|
|
34
|
+
"NO": ("new orleans", "new orleans saints", "saints"),
|
|
35
|
+
"NYG": ("new york giants", "ny giants", "giants"),
|
|
36
|
+
"NYJ": ("new york jets", "ny jets", "jets"),
|
|
37
|
+
"PHI": ("philadelphia", "philadelphia eagles", "eagles"),
|
|
38
|
+
"PIT": ("pittsburgh", "pittsburgh steelers", "steelers"),
|
|
39
|
+
"SEA": ("seattle", "seattle seahawks", "seahawks"),
|
|
40
|
+
"SF": ("san francisco", "san francisco 49ers", "49ers", "niners"),
|
|
41
|
+
"TB": ("tampa bay", "tampa bay buccaneers", "buccaneers", "bucs"),
|
|
42
|
+
"TEN": ("tennessee", "tennessee titans", "titans"),
|
|
43
|
+
"WAS": ("washington", "washington commanders", "commanders", "wsh"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_TEAM_LOOKUP = {
|
|
47
|
+
alias.casefold(): abbreviation
|
|
48
|
+
for abbreviation, aliases in TEAM_ALIASES.items()
|
|
49
|
+
for alias in (abbreviation, *aliases)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_team(value: str | None) -> str | None:
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
normalized = " ".join(value.strip().casefold().replace(".", "").split())
|
|
57
|
+
team = _TEAM_LOOKUP.get(normalized)
|
|
58
|
+
if team is None:
|
|
59
|
+
raise InvalidArgumentError(
|
|
60
|
+
f"Unknown NFL team {value!r}.",
|
|
61
|
+
hint="Use a team abbreviation, city, nickname, or full name.",
|
|
62
|
+
)
|
|
63
|
+
return team
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def football_season(today: date | None = None) -> int:
|
|
67
|
+
"""Return the NFL season currently in progress, including Jan/Feb playoffs."""
|
|
68
|
+
|
|
69
|
+
resolved = today or date.today()
|
|
70
|
+
return resolved.year - 1 if resolved.month <= 2 else resolved.year
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def latest_stat_season(today: date | None = None) -> int:
|
|
74
|
+
"""Return the latest season expected to have weekly stat files."""
|
|
75
|
+
|
|
76
|
+
resolved = today or date.today()
|
|
77
|
+
return resolved.year - 1 if resolved.month <= 8 else resolved.year
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_season(value: SeasonRef, *, today: date | None = None) -> int:
|
|
81
|
+
if isinstance(value, int):
|
|
82
|
+
season = value
|
|
83
|
+
else:
|
|
84
|
+
normalized = value.strip().casefold()
|
|
85
|
+
current = football_season(today)
|
|
86
|
+
aliases = {
|
|
87
|
+
"current": current,
|
|
88
|
+
"upcoming": (today or date.today()).year,
|
|
89
|
+
"previous": current - 1,
|
|
90
|
+
}
|
|
91
|
+
if normalized.isdigit():
|
|
92
|
+
season = int(normalized)
|
|
93
|
+
elif normalized in aliases:
|
|
94
|
+
season = aliases[normalized]
|
|
95
|
+
else:
|
|
96
|
+
raise InvalidArgumentError(
|
|
97
|
+
f"Unknown season reference {value!r}.",
|
|
98
|
+
hint="Use a four-digit year, current, upcoming, or previous.",
|
|
99
|
+
)
|
|
100
|
+
if not 1999 <= season <= 2100:
|
|
101
|
+
raise InvalidArgumentError("season must be between 1999 and 2100.")
|
|
102
|
+
return season
|
nfl_data_mcp/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Stable public error types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorCode(StrEnum):
|
|
9
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
10
|
+
NOT_FOUND = "NOT_FOUND"
|
|
11
|
+
DATASET_NOT_SYNCED = "DATASET_NOT_SYNCED"
|
|
12
|
+
SOURCE_UNAVAILABLE = "SOURCE_UNAVAILABLE"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NflDataError(Exception):
|
|
16
|
+
"""An expected error safe to translate at public boundaries."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: ErrorCode, message: str, *, hint: str | None = None) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
21
|
+
self.message = message
|
|
22
|
+
self.hint = hint
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidArgumentError(NflDataError):
|
|
26
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
27
|
+
super().__init__(ErrorCode.INVALID_ARGUMENT, message, hint=hint)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(NflDataError):
|
|
31
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
32
|
+
super().__init__(ErrorCode.NOT_FOUND, message, hint=hint)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatasetNotSyncedError(NflDataError):
|
|
36
|
+
def __init__(self, dataset: str) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
ErrorCode.DATASET_NOT_SYNCED,
|
|
39
|
+
f"The {dataset!r} dataset has not been synchronized.",
|
|
40
|
+
hint=f"Run `nfl-data-sync --datasets {dataset} --season <year>` first.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SourceUnavailableError(NflDataError):
|
|
45
|
+
def __init__(self, dataset: str, *, cached: bool = False) -> None:
|
|
46
|
+
message = f"The upstream source for {dataset!r} is currently unavailable."
|
|
47
|
+
hint = (
|
|
48
|
+
"A stale cached copy was not available; retry when network access is restored."
|
|
49
|
+
if not cached
|
|
50
|
+
else "A stale cached copy is available."
|
|
51
|
+
)
|
|
52
|
+
super().__init__(ErrorCode.SOURCE_UNAVAILABLE, message, hint=hint)
|