elo-system 1.0.0__tar.gz

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 (56) hide show
  1. elo_system-1.0.0/LICENSE +21 -0
  2. elo_system-1.0.0/PKG-INFO +79 -0
  3. elo_system-1.0.0/README.md +46 -0
  4. elo_system-1.0.0/elo_system/__init__.py +0 -0
  5. elo_system-1.0.0/elo_system/_vendor/__init__.py +1 -0
  6. elo_system-1.0.0/elo_system/_vendor/fantraxapi/LICENSE +21 -0
  7. elo_system-1.0.0/elo_system/_vendor/fantraxapi/__init__.py +25 -0
  8. elo_system-1.0.0/elo_system/_vendor/fantraxapi/api.py +135 -0
  9. elo_system-1.0.0/elo_system/_vendor/fantraxapi/exceptions.py +39 -0
  10. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/__init__.py +36 -0
  11. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/_parse.py +85 -0
  12. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/base.py +13 -0
  13. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/game.py +67 -0
  14. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/league.py +372 -0
  15. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/player.py +93 -0
  16. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/position.py +58 -0
  17. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/roster.py +102 -0
  18. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/scoring_period.py +301 -0
  19. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/standings.py +83 -0
  20. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/status.py +37 -0
  21. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/team.py +100 -0
  22. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/trade.py +113 -0
  23. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/trade_block.py +47 -0
  24. elo_system-1.0.0/elo_system/_vendor/fantraxapi/objs/transaction.py +62 -0
  25. elo_system-1.0.0/elo_system/elo_system.py +201 -0
  26. elo_system-1.0.0/elo_system/tools/__init__.py +2 -0
  27. elo_system-1.0.0/elo_system/tools/basics/__init__.py +19 -0
  28. elo_system-1.0.0/elo_system/tools/basics/common_classes.py +116 -0
  29. elo_system-1.0.0/elo_system/tools/basics/common_funcs.py +109 -0
  30. elo_system-1.0.0/elo_system/tools/basics/constants.py +6 -0
  31. elo_system-1.0.0/elo_system/tools/elo_data.py +92 -0
  32. elo_system-1.0.0/elo_system/tools/elo_league.py +302 -0
  33. elo_system-1.0.0/elo_system/tools/helpers/__init__.py +5 -0
  34. elo_system-1.0.0/elo_system/tools/helpers/calculator.py +105 -0
  35. elo_system-1.0.0/elo_system/tools/helpers/formatter.py +50 -0
  36. elo_system-1.0.0/elo_system/tools/helpers/frame_manager.py +190 -0
  37. elo_system-1.0.0/elo_system/tools/helpers/helper_funcs.py +92 -0
  38. elo_system-1.0.0/elo_system/tools/helpers/league.py +151 -0
  39. elo_system-1.0.0/elo_system/tools/helpers/scraper.py +109 -0
  40. elo_system-1.0.0/elo_system.egg-info/PKG-INFO +79 -0
  41. elo_system-1.0.0/elo_system.egg-info/SOURCES.txt +54 -0
  42. elo_system-1.0.0/elo_system.egg-info/dependency_links.txt +1 -0
  43. elo_system-1.0.0/elo_system.egg-info/requires.txt +14 -0
  44. elo_system-1.0.0/elo_system.egg-info/top_level.txt +1 -0
  45. elo_system-1.0.0/pyproject.toml +57 -0
  46. elo_system-1.0.0/setup.cfg +4 -0
  47. elo_system-1.0.0/tests/test_basics.py +225 -0
  48. elo_system-1.0.0/tests/test_calculator.py +292 -0
  49. elo_system-1.0.0/tests/test_common_funcs.py +297 -0
  50. elo_system-1.0.0/tests/test_elo_csv.py +106 -0
  51. elo_system-1.0.0/tests/test_elo_league.py +570 -0
  52. elo_system-1.0.0/tests/test_formatter.py +197 -0
  53. elo_system-1.0.0/tests/test_frame_manager.py +383 -0
  54. elo_system-1.0.0/tests/test_league.py +225 -0
  55. elo_system-1.0.0/tests/test_scraper.py +210 -0
  56. elo_system-1.0.0/tests/test_sql_helpers.py +177 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rohan V
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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: elo-system
3
+ Version: 1.0.0
4
+ Summary: Elo rating system for fantasy sports leagues (Fantrax NBA, with multi-season dynasty support).
5
+ Author-email: Rohan V <r.vahalia@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/riders994/eloSystem
8
+ Keywords: elo,fantasy,sports,fantrax,sleeper,rating
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Games/Entertainment
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pandas>=2.2
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: PyYAML>=6.0
23
+ Requires-Dist: psycopg2-binary>=2.9
24
+ Requires-Dist: requests>=2.31
25
+ Provides-Extra: sleeper
26
+ Requires-Dist: sleeper>=2.0; extra == "sleeper"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-mock>=3; extra == "dev"
30
+ Requires-Dist: pyflakes>=3; extra == "dev"
31
+ Requires-Dist: build>=1.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # elo-system
35
+
36
+ An Elo rating system for fantasy sports leagues. It scrapes weekly scoreboards
37
+ from a fantasy platform, converts them into per-week Elo ratings, and supports
38
+ multi-season **dynasty** ratings with an offseason regression-to-mean
39
+ adjustment.
40
+
41
+ - **Platforms:** Fantrax (NBA, head-to-head categories) is implemented. A
42
+ Sleeper backend is planned.
43
+ - **Outputs:** per-season and combined-dynasty rating frames, written to CSV
44
+ (a Postgres backend is scaffolded but not yet wired up).
45
+
46
+ ## Installation
47
+
48
+ The project depends on a fork of `fantraxapi` (the PyPI release is broken for
49
+ H2H-rotisserie leagues), so it installs from source rather than from PyPI:
50
+
51
+ ```bash
52
+ python -m venv .venv
53
+ source .venv/bin/activate
54
+ pip install -e . # add [dev] for the test/lint tooling
55
+ ```
56
+
57
+ `requirements.frozen` holds the exact, reproducible dependency pins (including
58
+ the fantraxapi fork commit) used during development.
59
+
60
+ ## Layout
61
+
62
+ ```
63
+ elo_system/
64
+ elo_system.py EloSystem orchestrator (config + reader/writer wiring)
65
+ tools/
66
+ elo_league.py EloLeague: season management + run/dynasty pipeline
67
+ elo_data.py EloCSV / EloSQL persistence backends
68
+ basics/ LeagueBase/DataBase, Elo math, config IO, constants
69
+ helpers/ scraper, league, formatter, calculator, frame_manager
70
+ ```
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ python -m pytest # run from the repo root
76
+ ```
77
+
78
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development notes and conventions,
79
+ and [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,46 @@
1
+ # elo-system
2
+
3
+ An Elo rating system for fantasy sports leagues. It scrapes weekly scoreboards
4
+ from a fantasy platform, converts them into per-week Elo ratings, and supports
5
+ multi-season **dynasty** ratings with an offseason regression-to-mean
6
+ adjustment.
7
+
8
+ - **Platforms:** Fantrax (NBA, head-to-head categories) is implemented. A
9
+ Sleeper backend is planned.
10
+ - **Outputs:** per-season and combined-dynasty rating frames, written to CSV
11
+ (a Postgres backend is scaffolded but not yet wired up).
12
+
13
+ ## Installation
14
+
15
+ The project depends on a fork of `fantraxapi` (the PyPI release is broken for
16
+ H2H-rotisserie leagues), so it installs from source rather than from PyPI:
17
+
18
+ ```bash
19
+ python -m venv .venv
20
+ source .venv/bin/activate
21
+ pip install -e . # add [dev] for the test/lint tooling
22
+ ```
23
+
24
+ `requirements.frozen` holds the exact, reproducible dependency pins (including
25
+ the fantraxapi fork commit) used during development.
26
+
27
+ ## Layout
28
+
29
+ ```
30
+ elo_system/
31
+ elo_system.py EloSystem orchestrator (config + reader/writer wiring)
32
+ tools/
33
+ elo_league.py EloLeague: season management + run/dynasty pipeline
34
+ elo_data.py EloCSV / EloSQL persistence backends
35
+ basics/ LeagueBase/DataBase, Elo math, config IO, constants
36
+ helpers/ scraper, league, formatter, calculator, frame_manager
37
+ ```
38
+
39
+ ## Tests
40
+
41
+ ```bash
42
+ python -m pytest # run from the repo root
43
+ ```
44
+
45
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development notes and conventions,
46
+ and [CHANGELOG.md](CHANGELOG.md) for release history.
File without changes
@@ -0,0 +1 @@
1
+ """Vendored third-party packages bundled with elo_system."""
@@ -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,25 @@
1
+ # NOTE: Vendored copy of riders994/FantraxAPI (fork of meisnate12/FantraxAPI).
2
+ # Pinned to commit ee81bea250024cba670cd0a5a1a1849150dbb38c (@stable, v1.0.1).
3
+ # Absolute ``fantraxapi`` imports were rewritten to relative imports so the
4
+ # package works under ``elo_system._vendor.fantraxapi``. See ./LICENSE (MIT).
5
+ from .exceptions import FantraxException, NotLoggedIn, NotMemberOfLeague, NotTeamInLeague
6
+ from .objs import League
7
+ from .objs import League as FantraxAPI
8
+
9
+ __version__ = "1.0.1"
10
+ __author__ = "Nathan Taggart"
11
+ __credits__ = "meisnate12"
12
+ __package_name__ = "fantraxapi"
13
+ __project_name__ = "FantraxAPI"
14
+ __description__ = "A lightweight Python library for The Fantrax API."
15
+ __url__ = "https://github.com/meisnate12/FantraxAPI"
16
+ __email__ = "meisnate12@gmail.com"
17
+ __license__ = "MIT License"
18
+ __all__ = [
19
+ "FantraxAPI",
20
+ "FantraxException",
21
+ "NotLoggedIn",
22
+ "NotMemberOfLeague",
23
+ "NotTeamInLeague",
24
+ "League",
25
+ ]
@@ -0,0 +1,135 @@
1
+ from datetime import date
2
+ from json.decoder import JSONDecodeError
3
+ from typing import TYPE_CHECKING, ParamSpec
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
+ Param: ParamSpec = ParamSpec("Param")
14
+ default_session: Session = Session()
15
+
16
+ debug: bool = False
17
+
18
+
19
+ class Method:
20
+ def __init__(self, name: str, **kwargs: Param.kwargs) -> None:
21
+ self.name: str = name
22
+ self.kwargs: dict = kwargs
23
+ self.response: dict | None = None
24
+
25
+ def msg_block(self, league_id: str) -> dict[str, str]:
26
+ output_data = {"leagueId": league_id}
27
+ for key, value in self.kwargs.items():
28
+ if value is not None:
29
+ if isinstance(value, date):
30
+ output_data[key] = value.strftime("%Y-%m-%d")
31
+ else:
32
+ output_data[key] = str(value)
33
+ return {"method": self.name, "data": output_data}
34
+
35
+
36
+ def request(league: "League", methods: list[Method] | Method) -> dict:
37
+ return _request(league.league_id, methods, session=league.session)
38
+
39
+
40
+ def _request(league_id: str, methods: list[Method] | Method, session: Session | None = None) -> list[dict] | dict:
41
+ if not isinstance(methods, list):
42
+ methods = [methods]
43
+ json_data = {"msgs": [m.msg_block(league_id) for m in methods]}
44
+ if session is None:
45
+ session = default_session
46
+ if debug:
47
+ print(f"{'_' * 100} Request JSON {'_' * 100}")
48
+ print(json_data)
49
+ response = session.post("https://www.fantrax.com/fxpa/req", params={"leagueId": league_id}, json=json_data)
50
+ try:
51
+ response_json = response.json()
52
+ except JSONDecodeError as e:
53
+ raise FantraxException(f"Invalid JSON Response to {methods}: {e}\nData: {json_data}")
54
+ if debug:
55
+ print(f"{'-' * 100} Response JSON {'-' * 100}")
56
+ print("-" * 100)
57
+ print(response_json)
58
+ print("^" * 215)
59
+ if response.status_code >= 400:
60
+ raise FantraxException(f"({response.status_code} [{response.reason}]) {response_json}")
61
+ if "pageError" in response_json:
62
+ if "code" in response_json["pageError"]:
63
+ match response_json["pageError"]["code"]:
64
+ case "WARNING_NOT_LOGGED_IN":
65
+ raise NotLoggedIn("Not Logged in")
66
+ case "NOT_MEMBER_OF_LEAGUE":
67
+ raise NotMemberOfLeague("Not Member of League")
68
+ case "UNEXPECTED_ERROR":
69
+ raise FantraxException(f"{response_json['pageError']['title']}")
70
+ case _:
71
+ raise FantraxException(f"{response_json}")
72
+
73
+ return response_json["responses"][0]["data"] if len(methods) == 1 else [r["data"] for r in response_json["responses"]]
74
+
75
+
76
+ def get_init_info(league: "League") -> dict:
77
+ return request(
78
+ league,
79
+ [
80
+ Method("getFantasyLeagueInfo"),
81
+ Method("getRefObject", type="FantasyItemStatus"),
82
+ Method("getLiveScoringStats", newView=True),
83
+ Method("getTeamRosterInfo", view="GAMES_PER_POS"),
84
+ Method("getTeamRosterInfo", view="STATS"),
85
+ ],
86
+ )
87
+
88
+
89
+ def get_pending_transactions(league: "League") -> dict:
90
+ return request(league, Method("getPendingTransactions"))
91
+
92
+
93
+ def get_standings(league: "League", views: list[str] | str | None = None, **kwargs: Param.kwargs) -> dict:
94
+ if "view" in kwargs and views is None:
95
+ views = kwargs.pop("view")
96
+ if "view" in kwargs:
97
+ del kwargs["view"]
98
+ if not isinstance(views, list):
99
+ views = [views]
100
+ response = request(league, [Method("getStandings", view=v, **kwargs) for v in views])
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", per_page_results: int = 100) -> dict:
131
+ return request(league, Method("getTransactionDetailsHistory", maxResultsPerPage=str(per_page_results)))
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,36 @@
1
+ from .game import Game
2
+ from .league import League
3
+ from .player import LivePlayer, Player
4
+ from .position import Position, PositionCount
5
+ from .roster import Roster, RosterRow
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
+ "TradeDraftPick",
16
+ "Game",
17
+ "League",
18
+ "LivePlayer",
19
+ "Matchup",
20
+ "Player",
21
+ "Position",
22
+ "PositionCount",
23
+ "Record",
24
+ "Roster",
25
+ "RosterRow",
26
+ "ScoringPeriod",
27
+ "ScoringPeriodResult",
28
+ "Standings",
29
+ "Status",
30
+ "Team",
31
+ "Trade",
32
+ "TradeBlock",
33
+ "TradePlayer",
34
+ "Transaction",
35
+ "TransactionPlayer",
36
+ ]
@@ -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,67 @@
1
+ from datetime import date, datetime, time
2
+ from typing import TYPE_CHECKING, Self
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
+
12
+ class Game(FantraxBaseObject):
13
+ """Represents a single Game.
14
+
15
+ Attributes:
16
+ league (League): The League instance this object belongs to.
17
+ id (str): Game ID.
18
+ player (Player): Player to view this game from.
19
+ date (date): The date this game is played.
20
+ opponent (str): NHL Short Name of the opponent.
21
+ time (time): Time of the game start if it hasn't been played yet.
22
+ home (bool): Is Player Home?
23
+ away (bool): Is Player Away?
24
+
25
+ """
26
+
27
+ def __init__(self, league: "League", player: Player, game_date: str, data: dict) -> None:
28
+ super().__init__(league, data)
29
+ self.id: str = self._data["eventId"]
30
+ self.player: Player = player
31
+ start = datetime.strptime(f"{game_date} {self.league.start_date.year}", "%a %m/%d %Y").date()
32
+ end = datetime.strptime(f"{game_date} {self.league.end_date.year}", "%a %m/%d %Y").date()
33
+ league_start = self.league.start_date.date()
34
+ league_end = self.league.end_date.date()
35
+ if league_end >= start >= league_start:
36
+ self.date: date = start
37
+ elif league_end >= end >= league_start:
38
+ self.date: date = end
39
+ else:
40
+ raise DateNotInSeason(game_date)
41
+
42
+ self.time: time | None = None
43
+ parts = data["content"].removesuffix(" F").split("\u003cbr/\u003e")
44
+ if ":" in parts[1]:
45
+ self.opponent: str = parts[0]
46
+ if self.opponent.startswith("@"):
47
+ self.opponent = self.opponent[1:]
48
+ home = self.player.team_short_name
49
+ else:
50
+ home = self.opponent
51
+
52
+ self.time = datetime.strptime(parts[1].split(" ")[1], "%I:%M%p").time()
53
+ else:
54
+ home = "".join(i for i in parts[0] if not i.isdigit() and i not in [" ", "@"])
55
+ away = "".join(i for i in parts[1] if not i.isdigit() and i not in [" ", "@"])
56
+ self.opponent = away if home == self.player.team_short_name else home
57
+ self.home: bool = home == self.player.team_short_name
58
+ self.away: bool = home != self.player.team_short_name
59
+
60
+ def __eq__(self, other: Self) -> bool:
61
+ return self.id == other.id
62
+
63
+ def __hash__(self) -> int:
64
+ return hash(("Game", self.id))
65
+
66
+ def __str__(self) -> str:
67
+ 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 ''}]"