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.
- max_pf/__init__.py +41 -0
- max_pf/__main__.py +57 -0
- max_pf/_vendor/NOTICE.md +18 -0
- max_pf/_vendor/__init__.py +7 -0
- max_pf/_vendor/fantraxapi/LICENSE +21 -0
- max_pf/_vendor/fantraxapi/__init__.py +26 -0
- max_pf/_vendor/fantraxapi/api.py +135 -0
- max_pf/_vendor/fantraxapi/exceptions.py +39 -0
- max_pf/_vendor/fantraxapi/objs/__init__.py +40 -0
- max_pf/_vendor/fantraxapi/objs/_parse.py +85 -0
- max_pf/_vendor/fantraxapi/objs/base.py +13 -0
- max_pf/_vendor/fantraxapi/objs/game.py +93 -0
- max_pf/_vendor/fantraxapi/objs/league.py +408 -0
- max_pf/_vendor/fantraxapi/objs/player.py +134 -0
- max_pf/_vendor/fantraxapi/objs/position.py +64 -0
- max_pf/_vendor/fantraxapi/objs/roster.py +221 -0
- max_pf/_vendor/fantraxapi/objs/scoring_period.py +377 -0
- max_pf/_vendor/fantraxapi/objs/standings.py +85 -0
- max_pf/_vendor/fantraxapi/objs/status.py +41 -0
- max_pf/_vendor/fantraxapi/objs/team.py +127 -0
- max_pf/_vendor/fantraxapi/objs/trade.py +127 -0
- max_pf/_vendor/fantraxapi/objs/trade_block.py +49 -0
- max_pf/_vendor/fantraxapi/objs/transaction.py +77 -0
- max_pf/app.py +137 -0
- max_pf/box_bref.py +290 -0
- max_pf/categories.py +49 -0
- max_pf/engine.py +198 -0
- max_pf/estimators.py +94 -0
- max_pf/metric.py +79 -0
- max_pf/models.py +90 -0
- max_pf/optimize.py +228 -0
- max_pf/platforms/__init__.py +6 -0
- max_pf/platforms/base.py +53 -0
- max_pf/platforms/fantrax.py +445 -0
- max_pf/projections.py +45 -0
- max_pf/report.py +109 -0
- max_pf/sources.py +39 -0
- max_pf/zscores.py +104 -0
- max_pf-1.0.0.dist-info/METADATA +226 -0
- max_pf-1.0.0.dist-info/RECORD +43 -0
- max_pf-1.0.0.dist-info/WHEEL +4 -0
- max_pf-1.0.0.dist-info/entry_points.txt +2 -0
- 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())
|
max_pf/_vendor/NOTICE.md
ADDED
|
@@ -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 ''}]"
|