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.
@@ -0,0 +1,3 @@
1
+ """NFL data services and MCP adapter."""
2
+
3
+ __version__ = "0.1.2"
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)
@@ -0,0 +1,5 @@
1
+ """nflverse ingestion and normalization."""
2
+
3
+ from nfl_data_mcp.ingestion.sync import sync_datasets
4
+
5
+ __all__ = ["sync_datasets"]