max-pf 1.0.0__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.
Files changed (43) hide show
  1. max_pf/__init__.py +41 -0
  2. max_pf/__main__.py +57 -0
  3. max_pf/_vendor/NOTICE.md +18 -0
  4. max_pf/_vendor/__init__.py +7 -0
  5. max_pf/_vendor/fantraxapi/LICENSE +21 -0
  6. max_pf/_vendor/fantraxapi/__init__.py +26 -0
  7. max_pf/_vendor/fantraxapi/api.py +135 -0
  8. max_pf/_vendor/fantraxapi/exceptions.py +39 -0
  9. max_pf/_vendor/fantraxapi/objs/__init__.py +40 -0
  10. max_pf/_vendor/fantraxapi/objs/_parse.py +85 -0
  11. max_pf/_vendor/fantraxapi/objs/base.py +13 -0
  12. max_pf/_vendor/fantraxapi/objs/game.py +93 -0
  13. max_pf/_vendor/fantraxapi/objs/league.py +408 -0
  14. max_pf/_vendor/fantraxapi/objs/player.py +134 -0
  15. max_pf/_vendor/fantraxapi/objs/position.py +64 -0
  16. max_pf/_vendor/fantraxapi/objs/roster.py +221 -0
  17. max_pf/_vendor/fantraxapi/objs/scoring_period.py +377 -0
  18. max_pf/_vendor/fantraxapi/objs/standings.py +85 -0
  19. max_pf/_vendor/fantraxapi/objs/status.py +41 -0
  20. max_pf/_vendor/fantraxapi/objs/team.py +127 -0
  21. max_pf/_vendor/fantraxapi/objs/trade.py +127 -0
  22. max_pf/_vendor/fantraxapi/objs/trade_block.py +49 -0
  23. max_pf/_vendor/fantraxapi/objs/transaction.py +77 -0
  24. max_pf/app.py +137 -0
  25. max_pf/box_bref.py +290 -0
  26. max_pf/categories.py +49 -0
  27. max_pf/engine.py +198 -0
  28. max_pf/estimators.py +94 -0
  29. max_pf/metric.py +79 -0
  30. max_pf/models.py +90 -0
  31. max_pf/optimize.py +228 -0
  32. max_pf/platforms/__init__.py +6 -0
  33. max_pf/platforms/base.py +53 -0
  34. max_pf/platforms/fantrax.py +445 -0
  35. max_pf/projections.py +45 -0
  36. max_pf/report.py +109 -0
  37. max_pf/sources.py +39 -0
  38. max_pf/zscores.py +104 -0
  39. max_pf-1.0.0.dist-info/METADATA +226 -0
  40. max_pf-1.0.0.dist-info/RECORD +43 -0
  41. max_pf-1.0.0.dist-info/WHEEL +4 -0
  42. max_pf-1.0.0.dist-info/entry_points.txt +2 -0
  43. max_pf-1.0.0.dist-info/licenses/LICENSE +21 -0
max_pf/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """max_pf -- "max points for" methodology for 9-cat fantasy basketball.
2
+
3
+ Top-level entry point: ``max_pf.run(login, ...)`` takes a platform login dict and
4
+ returns the season report. See :mod:`max_pf.app`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .app import build_platform, load_login, parse_weeks, run
9
+ from .categories import CATEGORIES, CATEGORY_KEYS, Category
10
+ from .estimators import estimate_attempts
11
+ from .metric import CategoryResult, DeltaResult, catwins, compute_delta
12
+ from .models import PlayerDay, PlayerLine, RosterDay, aggregate
13
+ from .report import TeamSeason, render_csv, render_markdown, render_table
14
+
15
+ __version__ = "0.0.1"
16
+
17
+ __all__ = [
18
+ # entry point
19
+ "run",
20
+ "build_platform",
21
+ "load_login",
22
+ "parse_weeks",
23
+ "TeamSeason",
24
+ "render_table",
25
+ "render_csv",
26
+ "render_markdown",
27
+ # primitives
28
+ "CATEGORIES",
29
+ "CATEGORY_KEYS",
30
+ "Category",
31
+ "estimate_attempts",
32
+ "CategoryResult",
33
+ "DeltaResult",
34
+ "catwins",
35
+ "compute_delta",
36
+ "PlayerLine",
37
+ "PlayerDay",
38
+ "RosterDay",
39
+ "aggregate",
40
+ "__version__",
41
+ ]
max_pf/__main__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Command-line season report: ``python -m max_pf <login-file>``.
2
+
3
+ The login file is a JSON or YAML mapping naming the platform and its details::
4
+
5
+ {"platform": "fantrax", "league_id": "wserh14rmbbpqtcg"}
6
+
7
+ Computes the max-points-for season summary for every team, prints it, and writes
8
+ ``<out>.csv`` and ``<out>.md``.
9
+
10
+ python -m max_pf league.json
11
+ python -m max_pf league.yaml --weeks 1-6 --out reports/half1
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+
18
+ from .app import load_login, run
19
+ from .report import render_csv, render_markdown, render_table
20
+
21
+
22
+ def main(argv: list[str] | None = None) -> int:
23
+ parser = argparse.ArgumentParser(prog="max_pf", description="9-cat max-points-for season report")
24
+ parser.add_argument("login", help="path to a JSON or YAML login file (platform + details)")
25
+ parser.add_argument("--weeks", default=None,
26
+ help='one week or a selection, e.g. "5", "1-6", "1,2,5"; default: season to date')
27
+ parser.add_argument("--out", default="season_report", help="output path prefix (writes .csv and .md)")
28
+ parser.add_argument("--methodology", choices=["expected", "hindsight"], default="expected",
29
+ help="expected = season-to-date box-score projections; "
30
+ "hindsight = realized box scores (both use basketball-reference)")
31
+ parser.add_argument("--objective", choices=["catwins", "zscore", "raw"], default="catwins",
32
+ help="catwins = Objective A (opponent-aware category wins); "
33
+ "zscore = Objective B (max total z-value lineup); "
34
+ "raw = Objective C (max raw output lineup)")
35
+ parser.add_argument("--no-boxscores", action="store_true",
36
+ help="use the platform's built-in estimator instead of box scores (expected only)")
37
+ args = parser.parse_args(argv)
38
+
39
+ login = load_login(args.login)
40
+ rows = run(
41
+ login, weeks=args.weeks, methodology=args.methodology,
42
+ objective=args.objective, boxscores=not args.no_boxscores,
43
+ )
44
+
45
+ print(render_table(rows))
46
+
47
+ out = Path(args.out)
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+ csv_path, md_path = out.with_suffix(".csv"), out.with_suffix(".md")
50
+ csv_path.write_text(render_csv(rows))
51
+ md_path.write_text("# Max Points For — Season Report\n\n" + render_markdown(rows) + "\n")
52
+ print(f"\nwrote {csv_path} and {md_path}")
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ # Vendored dependencies
2
+
3
+ Third-party code bundled into `max_pf` to keep PyPI installs free of direct
4
+ VCS/URL references. Each package keeps its original license alongside its source.
5
+
6
+ ## fantraxapi
7
+
8
+ - **Source:** https://github.com/riders994/FantraxAPI (the `@stable` fork)
9
+ - **Upstream:** https://github.com/meisnate12/FantraxAPI
10
+ - **Pinned commit:** `4f98fa66b2c28505de3f0fa4b274d14feccc7577` (tag `stable`)
11
+ - **Version:** 1.0.1 (fork)
12
+ - **License:** MIT — see `fantraxapi/LICENSE`
13
+ - **Runtime deps:** `requests` (declared in `max_pf`'s dependencies)
14
+
15
+ Imported as `max_pf._vendor.fantraxapi`. To refresh: re-copy the package source
16
+ from the fork at the desired commit, drop `__pycache__`, keep `LICENSE`, and
17
+ update the commit hash above. The source is unmodified (all imports are already
18
+ package-relative, so no patching is required).
@@ -0,0 +1,7 @@
1
+ """Vendored third-party packages.
2
+
3
+ Bundled here (rather than declared as dependencies) so ``max_pf`` installs from
4
+ PyPI without a direct VCS/URL reference, which PyPI rejects. See NOTICE for
5
+ sources, versions, and licenses. Import vendored code via
6
+ ``max_pf._vendor.<pkg>`` — never the top-level name.
7
+ """
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meisnate12
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ import importlib.metadata
2
+
3
+ from .exceptions import FantraxException, NotLoggedIn, NotMemberOfLeague, NotTeamInLeague
4
+ from .objs import League
5
+ from .objs import League as FantraxAPI
6
+
7
+ try:
8
+ __version__ = importlib.metadata.version("fantraxapi")
9
+ except importlib.metadata.PackageNotFoundError:
10
+ __version__ = ""
11
+ __author__ = "Nathan Taggart"
12
+ __credits__ = "meisnate12"
13
+ __package_name__ = "fantraxapi"
14
+ __project_name__ = "FantraxAPI"
15
+ __description__ = "A lightweight Python library for The Fantrax API."
16
+ __url__ = "https://github.com/meisnate12/FantraxAPI"
17
+ __email__ = "meisnate12@gmail.com"
18
+ __license__ = "MIT License"
19
+ __all__ = [
20
+ "FantraxAPI",
21
+ "FantraxException",
22
+ "NotLoggedIn",
23
+ "NotMemberOfLeague",
24
+ "NotTeamInLeague",
25
+ "League",
26
+ ]
@@ -0,0 +1,135 @@
1
+ from datetime import date
2
+ from json.decoder import JSONDecodeError
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ from requests import Session
6
+
7
+ from .exceptions import FantraxException, NotLoggedIn, NotMemberOfLeague
8
+
9
+ if TYPE_CHECKING:
10
+ from .objs import League
11
+
12
+
13
+ default_session: Session = Session()
14
+
15
+ debug: bool = False
16
+
17
+
18
+ class Method:
19
+ def __init__(self, name: str, **kwargs: object) -> None:
20
+ self.name: str = name
21
+ self.kwargs: dict = kwargs
22
+ self.response: dict | None = None
23
+
24
+ def msg_block(self, league_id: str) -> dict[str, str | dict[str, str]]:
25
+ output_data = {"leagueId": league_id}
26
+ for key, value in self.kwargs.items():
27
+ if value is not None:
28
+ if isinstance(value, date):
29
+ output_data[key] = value.strftime("%Y-%m-%d")
30
+ else:
31
+ output_data[key] = str(value)
32
+ return {"method": self.name, "data": output_data}
33
+
34
+
35
+ def request(league: "League", methods: list[Method] | Method) -> dict:
36
+ # _request returns a list only for multi-Method calls; the few callers that pass a
37
+ # list index the result positionally, so the single-Method dict shape is the contract.
38
+ return _request(league.league_id, methods, session=league.session) # type: ignore[return-value]
39
+
40
+
41
+ def _request(league_id: str, methods: list[Method] | Method, session: Session | None = None) -> list[dict] | dict:
42
+ if not isinstance(methods, list):
43
+ methods = [methods]
44
+ json_data = {"msgs": [m.msg_block(league_id) for m in methods]}
45
+ if session is None:
46
+ session = default_session
47
+ if debug:
48
+ print(f"{'_' * 100} Request JSON {'_' * 100}")
49
+ print(json_data)
50
+ response = session.post("https://www.fantrax.com/fxpa/req", params={"leagueId": league_id}, json=json_data)
51
+ try:
52
+ response_json = response.json()
53
+ except JSONDecodeError as e:
54
+ raise FantraxException(f"Invalid JSON Response to {methods}: {e}\nData: {json_data}")
55
+ if debug:
56
+ print(f"{'-' * 100} Response JSON {'-' * 100}")
57
+ print("-" * 100)
58
+ print(response_json)
59
+ print("^" * 215)
60
+ if response.status_code >= 400:
61
+ raise FantraxException(f"({response.status_code} [{response.reason}]) {response_json}")
62
+ if "pageError" in response_json:
63
+ if "code" in response_json["pageError"]:
64
+ match response_json["pageError"]["code"]:
65
+ case "WARNING_NOT_LOGGED_IN":
66
+ raise NotLoggedIn("Not Logged in")
67
+ case "NOT_MEMBER_OF_LEAGUE":
68
+ raise NotMemberOfLeague("Not Member of League")
69
+ case "UNEXPECTED_ERROR":
70
+ raise FantraxException(f"{response_json['pageError']['title']}")
71
+ case _:
72
+ raise FantraxException(f"{response_json}")
73
+
74
+ return response_json["responses"][0]["data"] if len(methods) == 1 else [r["data"] for r in response_json["responses"]]
75
+
76
+
77
+ def get_init_info(league: "League") -> dict:
78
+ return request(
79
+ league,
80
+ [
81
+ Method("getFantasyLeagueInfo"),
82
+ Method("getRefObject", type="FantasyItemStatus"),
83
+ Method("getLiveScoringStats", newView=True),
84
+ Method("getTeamRosterInfo", view="GAMES_PER_POS"),
85
+ Method("getTeamRosterInfo", view="STATS"),
86
+ ],
87
+ )
88
+
89
+
90
+ def get_pending_transactions(league: "League") -> dict:
91
+ return request(league, Method("getPendingTransactions"))
92
+
93
+
94
+ def get_standings(league: "League", views: list[str] | str | None = None, **kwargs: object) -> dict:
95
+ if "view" in kwargs and views is None:
96
+ views = cast("list[str] | str", kwargs.pop("view"))
97
+ if "view" in kwargs:
98
+ del kwargs["view"]
99
+ view_list: list[str | None] = [v for v in views] if isinstance(views, list) else [views]
100
+ response = request(league, [Method("getStandings", view=v, **kwargs) for v in view_list])
101
+ responses = response if isinstance(response, list) else [response]
102
+ for res in responses:
103
+ if "fantasyTeamInfo" in res:
104
+ league._update_teams(res["fantasyTeamInfo"])
105
+ return response
106
+
107
+
108
+ def get_trade_blocks(league: "League") -> dict:
109
+ return request(league, Method("getTradeBlocks"))["tradeBlocks"]
110
+
111
+
112
+ def get_team_roster_position_counts(league: "League", team_id: str, scoring_period_number: int | None = None) -> dict:
113
+ response = request(league, Method("getTeamRosterInfo", teamId=team_id, scoringPeriod=scoring_period_number, view="GAMES_PER_POS"))
114
+ league._update_teams(response["fantasyTeams"])
115
+ return response
116
+
117
+
118
+ def get_team_roster_info(league: "League", team_id: str, period_number: int | None = None) -> dict:
119
+ responses = request(
120
+ league,
121
+ [
122
+ Method("getTeamRosterInfo", teamId=team_id, period=period_number, view="STATS"),
123
+ Method("getTeamRosterInfo", teamId=team_id, period=period_number, view="SCHEDULE_FULL"),
124
+ ],
125
+ )
126
+ league._update_teams(responses[0]["fantasyTeams"])
127
+ return responses
128
+
129
+
130
+ def get_transaction_history(league: "League", max_results_per_page: int = 100, page_number: int = 1) -> dict:
131
+ return request(league, Method("getTransactionDetailsHistory", maxResultsPerPage=str(max_results_per_page), pageNumber=str(page_number)))
132
+
133
+
134
+ def get_live_scoring_stats(league: "League", scoring_date: date | None = None) -> dict:
135
+ return request(league, Method("getLiveScoringStats", date=scoring_date, newView=True, period="1", playerViewType="1", sppId="-1", viewType="1"))
@@ -0,0 +1,39 @@
1
+ from datetime import date, datetime
2
+
3
+
4
+ class FantraxException(Exception):
5
+ """Base class for all FantraxAPI exceptions."""
6
+
7
+ pass
8
+
9
+
10
+ class DateNotInSeason(FantraxException):
11
+ """Exception thrown when trying to query with a date not in the Season"""
12
+
13
+ def __init__(self, error_date: str | date | datetime) -> None:
14
+ super().__init__(f"Date: {error_date if isinstance(error_date, str) else error_date.strftime('%Y-%m-%d')} not in the Season.")
15
+
16
+
17
+ class NotLoggedIn(FantraxException):
18
+ """Exception thrown when accessing a private endpoint without being Logged In"""
19
+
20
+ pass
21
+
22
+
23
+ class NotMemberOfLeague(FantraxException):
24
+ """Exception thrown when accessing an endpoint without being part of that League"""
25
+
26
+ pass
27
+
28
+
29
+ class NotTeamInLeague(FantraxException):
30
+ """Exception thrown when trying to query for a Team not part of that League"""
31
+
32
+ pass
33
+
34
+
35
+ class PeriodNotInSeason(FantraxException):
36
+ """Exception thrown when trying to query with a period not in the Season"""
37
+
38
+ def __init__(self, error_date: str | int) -> None:
39
+ super().__init__(f"Period: {error_date} not in the Season.")
@@ -0,0 +1,40 @@
1
+ from .game import Game
2
+ from .league import League
3
+ from .player import LivePlayer, Player, ScoringCategory
4
+ from .position import Position, PositionCount
5
+ from .roster import CapHitPenalty, Roster, RosterDraftPick, RosterRow, SalaryInfo
6
+ from .scoring_period import Matchup, ScoringPeriod, ScoringPeriodResult
7
+ from .standings import Record, Standings
8
+ from .status import Status
9
+ from .team import Team
10
+ from .trade import Trade, TradeDraftPick, TradePlayer
11
+ from .trade_block import TradeBlock
12
+ from .transaction import Transaction, TransactionPlayer
13
+
14
+ __all__ = [
15
+ "CapHitPenalty",
16
+ "TradeDraftPick",
17
+ "Game",
18
+ "League",
19
+ "LivePlayer",
20
+ "Matchup",
21
+ "Player",
22
+ "Position",
23
+ "PositionCount",
24
+ "Record",
25
+ "Roster",
26
+ "RosterDraftPick",
27
+ "RosterRow",
28
+ "SalaryInfo",
29
+ "ScoringCategory",
30
+ "ScoringPeriod",
31
+ "ScoringPeriodResult",
32
+ "Standings",
33
+ "Status",
34
+ "Team",
35
+ "Trade",
36
+ "TradeBlock",
37
+ "TradePlayer",
38
+ "Transaction",
39
+ "TransactionPlayer",
40
+ ]
@@ -0,0 +1,85 @@
1
+ """Small parsing helpers shared across the data-model classes.
2
+
3
+ These centralise the brittle string/number parsing that Fantrax responses
4
+ require (comma thousands separators, bracketed date ranges, trailing period
5
+ numbers) so the same idioms aren't repeated, and so malformed data raises a
6
+ clear :class:`~fantraxapi.exceptions.FantraxException` instead of an opaque
7
+ ``AttributeError``/``IndexError``.
8
+ """
9
+
10
+ import re
11
+ from datetime import date, datetime
12
+ from decimal import Decimal, InvalidOperation
13
+
14
+ from ..exceptions import FantraxException
15
+
16
+ # Characters that wrap a date range caption, e.g. "(Oct 12/24 - Oct 18/24)".
17
+ _RANGE_WRAPPERS = "()[] "
18
+ _PERIOD_SUFFIX = re.compile(r"(\d+)$")
19
+
20
+
21
+ def parse_float(value: object, default: float = 0.0) -> float:
22
+ """Parse a float from a Fantrax cell value.
23
+
24
+ Handles ``None``, blank/``"-"`` placeholders and comma thousands
25
+ separators, falling back to ``default`` when the value can't be parsed.
26
+ """
27
+ if value is None:
28
+ return default
29
+ text = str(value).strip().replace(",", "")
30
+ if not text or text == "-":
31
+ return default
32
+ try:
33
+ return float(text)
34
+ except ValueError:
35
+ return default
36
+
37
+
38
+ def parse_decimal(value: object, default: Decimal = Decimal("0")) -> Decimal:
39
+ """Parse a :class:`~decimal.Decimal` from a Fantrax cell value.
40
+
41
+ Like :func:`parse_float` but preserves exact decimal scores.
42
+ """
43
+ if value is None:
44
+ return default
45
+ text = str(value).strip().replace(",", "")
46
+ if not text or text == "-":
47
+ return default
48
+ try:
49
+ return Decimal(text)
50
+ except InvalidOperation:
51
+ return default
52
+
53
+
54
+ def parse_date_range(caption: str, fmt: str) -> tuple[date, date]:
55
+ """Parse a ``"(<start> - <end>)"`` caption into ``(start, end)`` dates.
56
+
57
+ Args:
58
+ caption: Bracketed caption such as ``"(Oct 12/24 - Oct 18/24)"``.
59
+ fmt: ``datetime.strptime`` format for each side of the range.
60
+
61
+ Raises:
62
+ FantraxException: When the caption doesn't contain a parseable range.
63
+ """
64
+ parts = caption.strip(_RANGE_WRAPPERS).split(" - ")
65
+ if len(parts) != 2:
66
+ raise FantraxException(f"Could not parse date range from caption: {caption!r}")
67
+ try:
68
+ return (
69
+ datetime.strptime(parts[0], fmt).date(),
70
+ datetime.strptime(parts[1], fmt).date(),
71
+ )
72
+ except ValueError as e:
73
+ raise FantraxException(f"Could not parse date range from caption: {caption!r}: {e}")
74
+
75
+
76
+ def period_number(caption: str) -> int:
77
+ """Extract the trailing period number from a caption.
78
+
79
+ Raises:
80
+ FantraxException: When the caption has no trailing number.
81
+ """
82
+ match = _PERIOD_SUFFIX.search(caption)
83
+ if match is None:
84
+ raise FantraxException(f"Could not find a period number in caption: {caption!r}")
85
+ return int(match.group(1))
@@ -0,0 +1,13 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from .league import League
5
+
6
+
7
+ class FantraxBaseObject:
8
+ def __init__(self, league: "League", data: dict | list[dict]) -> None:
9
+ self.league: "League" = league
10
+ self._data: dict | list[dict] = data
11
+
12
+ def __repr__(self) -> str:
13
+ return self.__str__()
@@ -0,0 +1,93 @@
1
+ from datetime import date, datetime, time
2
+ from typing import TYPE_CHECKING
3
+
4
+ from ..exceptions import DateNotInSeason
5
+ from .base import FantraxBaseObject
6
+ from .player import Player
7
+
8
+ if TYPE_CHECKING:
9
+ from .league import League
10
+
11
+ _WEEKDAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
12
+
13
+
14
+ class Game(FantraxBaseObject):
15
+ """Represents a single Game.
16
+
17
+ Attributes:
18
+ league (League): The League instance this object belongs to.
19
+ id (str): Game ID.
20
+ player (Player): Player to view this game from.
21
+ date (date): The date this game is played.
22
+ opponent (str): Short Name of the opponent.
23
+ time (time): Start time of the (first) game if it hasn't been played yet.
24
+ times (list[time]): All start times on this date; more than one for a doubleheader.
25
+ home (bool): Is Player Home?
26
+ away (bool): Is Player Away?
27
+
28
+ """
29
+
30
+ _data: dict
31
+
32
+ def __init__(self, league: "League", player: Player, game_date: str, data: dict) -> None:
33
+ super().__init__(league, data)
34
+ self.id: str = self._data["eventId"]
35
+ self.player: Player = player
36
+ league_start = self.league.start_date.date()
37
+ league_end = self.league.end_date.date()
38
+ # The game date carries no year; seasons span a year boundary, so try the start
39
+ # year then the end year. Parsing is wrapped because a date like Feb 29 raises
40
+ # ValueError against a non-leap candidate year before the other year is tried.
41
+ self.date: date | None = None
42
+ for year in (self.league.start_date.year, self.league.end_date.year):
43
+ try:
44
+ candidate = datetime.strptime(f"{game_date} {year}", "%a %m/%d %Y").date()
45
+ except ValueError:
46
+ continue
47
+ if league_start <= candidate <= league_end:
48
+ self.date = candidate
49
+ break
50
+ if self.date is None:
51
+ raise DateNotInSeason(game_date)
52
+
53
+ self.time: time | None = None
54
+ self.times: list[time] = []
55
+ parts = data["content"].removesuffix(" F").split("\u003cbr/\u003e")
56
+ if ":" in parts[1]:
57
+ # Single game: parts == ["<opp>", "<Weekday> <Time>"]. Doubleheader (baseball):
58
+ # parts == ["<opp> <Weekday>", "<Time1>", "<Time2>"] -- the weekday moves into
59
+ # parts[0] and each later part is one start time.
60
+ opponent = parts[0].strip()
61
+ tokens = opponent.split(" ")
62
+ if len(tokens) > 1 and tokens[-1] in _WEEKDAYS:
63
+ opponent = " ".join(tokens[:-1])
64
+ self.opponent: str = opponent
65
+ if self.opponent.startswith("@"):
66
+ # "@OPP": the player's team is visiting, so the opponent is the home team.
67
+ self.opponent = self.opponent[1:]
68
+ home = self.opponent
69
+ else:
70
+ # "OPP": the player's team is hosting.
71
+ home = self.player.team_short_name
72
+
73
+ # The time is the last whitespace token of each part that carries one.
74
+ self.times = [datetime.strptime(part.split(" ")[-1], "%I:%M%p").time() for part in parts[1:] if ":" in part]
75
+ self.time = self.times[0] if self.times else None
76
+ else:
77
+ # Played game "<away> <score><br/>@<home> <score>": the "@" side (parts[1]) is home.
78
+ away_team = "".join(i for i in parts[0] if not i.isdigit() and i not in [" ", "@"])
79
+ home = "".join(i for i in parts[1] if not i.isdigit() and i not in [" ", "@"])
80
+ self.opponent = away_team if home == self.player.team_short_name else home
81
+ self.home: bool = home == self.player.team_short_name
82
+ self.away: bool = home != self.player.team_short_name
83
+
84
+ def __eq__(self, other: object) -> bool:
85
+ if not isinstance(other, Game):
86
+ return NotImplemented
87
+ return self.id == other.id
88
+
89
+ def __hash__(self) -> int:
90
+ return hash(("Game", self.id))
91
+
92
+ def __str__(self) -> str:
93
+ return f"[{self.id}:{f'{self.opponent} @{self.player.team_short_name}' if self.home else f'{self.player.team_short_name} @{self.opponent}'}{f' {self.time}' if self.time else ''}]"