pearatings 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PEARatings
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,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: pearatings
3
+ Version: 0.2.0
4
+ Summary: Python client for the PEARatings college baseball and softball data API
5
+ Author-email: PEARatings <pearatings@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pearatings.com
8
+ Project-URL: Pricing, https://api.pearatings.com/billing
9
+ Project-URL: Documentation, https://api.pearatings.com/docs
10
+ Project-URL: Source, https://github.com/pearatings/pear-api
11
+ Keywords: college baseball,college softball,ratings,sports analytics,api,ncaa,naia
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.25
22
+ Requires-Dist: pandas>=1.3
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # pearatings
28
+
29
+ Python client for the [PEARatings](https://pearatings.com) data API: college baseball (D1, D2, D3, NAIA)
30
+ and softball (D1, D2, D3) ratings, projections, player stats, history, Lookup and tournament simulations.
31
+ Tables come back as pandas DataFrames.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install pearatings
37
+ ```
38
+
39
+ ## Get a key
40
+
41
+ Pick a tier at **https://api.pearatings.com/billing**. Your key is shown once: copy it.
42
+
43
+ | Tier | Price | API calls per month | Per minute |
44
+ |---|---|---|---|
45
+ | Free | $0 | 500 | 10 |
46
+ | Starter | $1.99/month | 1,000 | 30 |
47
+ | Plus | $4.99/month | 5,000 | 60 |
48
+ | Pro | $9.99/month | 20,000 | 120 |
49
+ | Max | $19.99/month | 50,000 | 120 |
50
+
51
+ Every request is one call; `players()` fetches a full leaderboard 200 rows per call. Calls reset on the 1st
52
+ (UTC). `pear.account()` shows your tier and calls used this month; `pear.billing_portal()` changes tier or cancels.
53
+
54
+ ## Set your key
55
+
56
+ ```bash
57
+ export PEAR_API_KEY='your-key' # single quotes if the key has a ! in it
58
+ ```
59
+ or pass it: `Pear(api_key="your-key")`.
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from pearatings import Pear
65
+
66
+ pear = Pear()
67
+
68
+ ratings = pear.ratings() # D1 baseball, current
69
+ ratings.attrs["date"] # when the ratings were updated
70
+ pear.ratings(sport="softball", division="d2")
71
+
72
+ pear.preseason(2027) # preseason ratings
73
+ pear.stats(2025) # team stats + end-of-season ratings for 2025
74
+
75
+ pear.games("2026-04-18") # that day's games with PEAR's line and win probability
76
+ pear.matchup("LSU", "Florida", neutral=True) # any two teams
77
+ pear.team_schedule("LSU", 2026)["schedule"] # every game of a team's season
78
+
79
+ pear.players(2026) # full hitting leaderboard (every page)
80
+ pear.players(2026, side="pitching", conferences=["SEC"], limit=50)
81
+ roster = pear.roster("LSU", 2026)
82
+ pear.player(roster["player_id"].iloc[0])["games"] # one player's game-by-game lines
83
+
84
+ pear.history("LSU") # end-of-season numbers, every season
85
+ pear.tournament_outlook() # projected NCAA field
86
+ pear.simulate_bracket(seed=7) # one simulated tournament
87
+
88
+ open("lsu.png", "wb").write(pear.logo("LSU"))
89
+ ```
90
+
91
+ Every method takes keyword-only `sport` (`"baseball"` default, or `"softball"`) and `division` (`"d1"`
92
+ default, `"d2"`, `"d3"`, `"naia"`) unless it exists for one division only. Use `help(pear.games)` for any
93
+ method's details.
94
+
95
+ ## What comes back
96
+
97
+ - **One table** → a `DataFrame`; the rest of the response (data date, season, totals) is in `df.attrs`.
98
+ - **Anything else** → a `dict`, where every list of records is a `DataFrame`.
99
+ - Nested fields are flattened to dotted columns: `record.wins`, `stats.PA`.
100
+ - `pear.get("baseball/d1/ratings")` / `pear.post(path, body)` return the raw JSON of any endpoint.
101
+
102
+ ## Methods
103
+
104
+ | Area | Methods |
105
+ |---|---|
106
+ | Your account | `account`, `billing_portal` |
107
+ | Teams and ratings | `ratings`, `preseason`, `stats`, `teams`, `team`, `team_conferences`, `conferences`, `conference_standings`, `records`, `pcr_ranks`, `combined_ratings`, `logo`, `conference_logo` |
108
+ | Projections | `games`, `matchup`, `team_schedule`, `simulate_wins`, `season_projection`, `conference_projections` |
109
+ | Players | `players`, `roster`, `player` |
110
+ | History | `history`, `tournament_history`, `historical_table` |
111
+ | Lookup | `lookup_fields`, `lookup_options`, `lookup` |
112
+ | Tournaments | `tournament_outlook`, `tournament_regionals`, `tournament_team`, `simulate_bracket`, `conference_tournament_probs`, `d2_simulate_cary`, `naia_simulate_regional`, `naia_simulate_ws`, `naia_ws_odds` |
113
+
114
+ ## Lookup
115
+
116
+ Find games or player games that meet conditions. Field keys come from `pear.lookup_fields()`.
117
+
118
+ ```python
119
+ pear.lookup_fields() # kind, key, label, group, unit
120
+
121
+ # games where a team scored 25+ in 2026
122
+ pear.lookup(2026, conditions=[{"kind": "team", "field": "R", "op": ">=", "value": 25, "scope": "either"}])
123
+
124
+ # 3-homer games, 2021-2026
125
+ pear.lookup(2021, 2026, mode="players", role="bat",
126
+ conditions=[{"kind": "player", "field": "HR", "op": ">=", "value": 3}], limit=200)
127
+ ```
128
+ Up to 200 rows per call; `df.attrs["total"]` is the full count, page with `offset=`.
129
+
130
+ ## Errors
131
+
132
+ All errors subclass `pearatings.PearError` and carry `.status_code`:
133
+ `AuthenticationError` (bad or missing key, or a lapsed subscription), `NotFoundError` (unknown team / season),
134
+ `InvalidRequestError` (bad parameters), `RateLimitError` (over your tier's limit; `.retry_after` says how many
135
+ seconds to wait), `APIError` (anything else, e.g. data not available out of season).
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ pip install -e '.[test]'
141
+ pytest # offline tests
142
+ PEAR_API_KEY='...' PEAR_API_URL=http://127.0.0.1:8001 pytest -m live # every method against a running API
143
+ ```
144
+ `PEAR_API_URL` (or `Pear(base_url=...)`) points the client at another server; the default is
145
+ `https://api.pearatings.com`.
@@ -0,0 +1,119 @@
1
+ # pearatings
2
+
3
+ Python client for the [PEARatings](https://pearatings.com) data API: college baseball (D1, D2, D3, NAIA)
4
+ and softball (D1, D2, D3) ratings, projections, player stats, history, Lookup and tournament simulations.
5
+ Tables come back as pandas DataFrames.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install pearatings
11
+ ```
12
+
13
+ ## Get a key
14
+
15
+ Pick a tier at **https://api.pearatings.com/billing**. Your key is shown once: copy it.
16
+
17
+ | Tier | Price | API calls per month | Per minute |
18
+ |---|---|---|---|
19
+ | Free | $0 | 500 | 10 |
20
+ | Starter | $1.99/month | 1,000 | 30 |
21
+ | Plus | $4.99/month | 5,000 | 60 |
22
+ | Pro | $9.99/month | 20,000 | 120 |
23
+ | Max | $19.99/month | 50,000 | 120 |
24
+
25
+ Every request is one call; `players()` fetches a full leaderboard 200 rows per call. Calls reset on the 1st
26
+ (UTC). `pear.account()` shows your tier and calls used this month; `pear.billing_portal()` changes tier or cancels.
27
+
28
+ ## Set your key
29
+
30
+ ```bash
31
+ export PEAR_API_KEY='your-key' # single quotes if the key has a ! in it
32
+ ```
33
+ or pass it: `Pear(api_key="your-key")`.
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ from pearatings import Pear
39
+
40
+ pear = Pear()
41
+
42
+ ratings = pear.ratings() # D1 baseball, current
43
+ ratings.attrs["date"] # when the ratings were updated
44
+ pear.ratings(sport="softball", division="d2")
45
+
46
+ pear.preseason(2027) # preseason ratings
47
+ pear.stats(2025) # team stats + end-of-season ratings for 2025
48
+
49
+ pear.games("2026-04-18") # that day's games with PEAR's line and win probability
50
+ pear.matchup("LSU", "Florida", neutral=True) # any two teams
51
+ pear.team_schedule("LSU", 2026)["schedule"] # every game of a team's season
52
+
53
+ pear.players(2026) # full hitting leaderboard (every page)
54
+ pear.players(2026, side="pitching", conferences=["SEC"], limit=50)
55
+ roster = pear.roster("LSU", 2026)
56
+ pear.player(roster["player_id"].iloc[0])["games"] # one player's game-by-game lines
57
+
58
+ pear.history("LSU") # end-of-season numbers, every season
59
+ pear.tournament_outlook() # projected NCAA field
60
+ pear.simulate_bracket(seed=7) # one simulated tournament
61
+
62
+ open("lsu.png", "wb").write(pear.logo("LSU"))
63
+ ```
64
+
65
+ Every method takes keyword-only `sport` (`"baseball"` default, or `"softball"`) and `division` (`"d1"`
66
+ default, `"d2"`, `"d3"`, `"naia"`) unless it exists for one division only. Use `help(pear.games)` for any
67
+ method's details.
68
+
69
+ ## What comes back
70
+
71
+ - **One table** → a `DataFrame`; the rest of the response (data date, season, totals) is in `df.attrs`.
72
+ - **Anything else** → a `dict`, where every list of records is a `DataFrame`.
73
+ - Nested fields are flattened to dotted columns: `record.wins`, `stats.PA`.
74
+ - `pear.get("baseball/d1/ratings")` / `pear.post(path, body)` return the raw JSON of any endpoint.
75
+
76
+ ## Methods
77
+
78
+ | Area | Methods |
79
+ |---|---|
80
+ | Your account | `account`, `billing_portal` |
81
+ | Teams and ratings | `ratings`, `preseason`, `stats`, `teams`, `team`, `team_conferences`, `conferences`, `conference_standings`, `records`, `pcr_ranks`, `combined_ratings`, `logo`, `conference_logo` |
82
+ | Projections | `games`, `matchup`, `team_schedule`, `simulate_wins`, `season_projection`, `conference_projections` |
83
+ | Players | `players`, `roster`, `player` |
84
+ | History | `history`, `tournament_history`, `historical_table` |
85
+ | Lookup | `lookup_fields`, `lookup_options`, `lookup` |
86
+ | Tournaments | `tournament_outlook`, `tournament_regionals`, `tournament_team`, `simulate_bracket`, `conference_tournament_probs`, `d2_simulate_cary`, `naia_simulate_regional`, `naia_simulate_ws`, `naia_ws_odds` |
87
+
88
+ ## Lookup
89
+
90
+ Find games or player games that meet conditions. Field keys come from `pear.lookup_fields()`.
91
+
92
+ ```python
93
+ pear.lookup_fields() # kind, key, label, group, unit
94
+
95
+ # games where a team scored 25+ in 2026
96
+ pear.lookup(2026, conditions=[{"kind": "team", "field": "R", "op": ">=", "value": 25, "scope": "either"}])
97
+
98
+ # 3-homer games, 2021-2026
99
+ pear.lookup(2021, 2026, mode="players", role="bat",
100
+ conditions=[{"kind": "player", "field": "HR", "op": ">=", "value": 3}], limit=200)
101
+ ```
102
+ Up to 200 rows per call; `df.attrs["total"]` is the full count, page with `offset=`.
103
+
104
+ ## Errors
105
+
106
+ All errors subclass `pearatings.PearError` and carry `.status_code`:
107
+ `AuthenticationError` (bad or missing key, or a lapsed subscription), `NotFoundError` (unknown team / season),
108
+ `InvalidRequestError` (bad parameters), `RateLimitError` (over your tier's limit; `.retry_after` says how many
109
+ seconds to wait), `APIError` (anything else, e.g. data not available out of season).
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ pip install -e '.[test]'
115
+ pytest # offline tests
116
+ PEAR_API_KEY='...' PEAR_API_URL=http://127.0.0.1:8001 pytest -m live # every method against a running API
117
+ ```
118
+ `PEAR_API_URL` (or `Pear(base_url=...)`) points the client at another server; the default is
119
+ `https://api.pearatings.com`.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pearatings"
7
+ dynamic = ["version"]
8
+ description = "Python client for the PEARatings college baseball and softball data API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "PEARatings", email = "pearatings@gmail.com" }]
13
+ keywords = ["college baseball", "college softball", "ratings", "sports analytics", "api", "ncaa", "naia"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Scientific/Engineering :: Information Analysis",
21
+ ]
22
+ requires-python = ">=3.9"
23
+ dependencies = ["requests>=2.25", "pandas>=1.3"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://pearatings.com"
27
+ Pricing = "https://api.pearatings.com/billing"
28
+ Documentation = "https://api.pearatings.com/docs"
29
+ Source = "https://github.com/pearatings/pear-api"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest"]
33
+
34
+ [tool.setuptools.dynamic]
35
+ version = { attr = "pearatings.__version__" }
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ markers = ["live: calls a running PEARatings API (needs PEAR_API_KEY and PEAR_API_URL)"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ """PEARatings data API client for college baseball and softball.
2
+
3
+ from pearatings import Pear
4
+ pear = Pear() # reads PEAR_API_KEY
5
+ pear.ratings() # D1 baseball ratings as a DataFrame
6
+ """
7
+ __version__ = "0.2.0"
8
+
9
+ from .client import Pear # noqa: E402
10
+ from .errors import (APIError, AuthenticationError, InvalidRequestError, NotFoundError, PearError, # noqa: E402
11
+ RateLimitError)
12
+
13
+ __all__ = ["Pear", "PearError", "AuthenticationError", "NotFoundError", "InvalidRequestError", "RateLimitError",
14
+ "APIError", "__version__"]
@@ -0,0 +1,393 @@
1
+ """The `Pear` client: one method per PEARatings data API endpoint (baseball and softball).
2
+
3
+ Return values follow two rules:
4
+ - An endpoint that returns one table comes back as a pandas DataFrame. Everything else in the response
5
+ (the data date, season, totals, ...) is in `df.attrs`.
6
+ - Anything else comes back as a dict, in which every list of records is a DataFrame. Nested fields are
7
+ flattened to dotted column names (`record.wins`, `stats.PA`).
8
+ """
9
+ import datetime as _dt
10
+ import os
11
+ from typing import Any, Dict, Iterable, List, Optional, Union
12
+ from urllib.parse import quote
13
+
14
+ import pandas as pd
15
+ import requests
16
+
17
+ from . import __version__
18
+ from .errors import (APIError, AuthenticationError, InvalidRequestError, NotFoundError, PearError,
19
+ RateLimitError)
20
+
21
+ DEFAULT_BASE_URL = "https://api.pearatings.com"
22
+ DIVISIONS = {"baseball": ("d1", "d2", "d3", "naia"), "softball": ("d1", "d2", "d3")}
23
+ _ERRORS = {400: InvalidRequestError, 401: AuthenticationError, 403: AuthenticationError, 404: NotFoundError,
24
+ 422: InvalidRequestError, 429: RateLimitError}
25
+ _PAGE = 200 # the API's largest page for the player leaderboard and Lookup
26
+
27
+ Date = Union[str, _dt.date]
28
+
29
+
30
+ def _records(rows) -> pd.DataFrame:
31
+ return pd.json_normalize(rows) if rows else pd.DataFrame()
32
+
33
+
34
+ def _frames(obj):
35
+ """Every list of records (dicts) inside a response becomes a DataFrame; dicts are walked recursively."""
36
+ if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
37
+ return _records(obj)
38
+ if isinstance(obj, dict):
39
+ return {k: _frames(v) for k, v in obj.items()}
40
+ return obj
41
+
42
+
43
+ def _table(data: dict, key: str) -> pd.DataFrame:
44
+ """data[key] as a DataFrame, with the rest of the response in .attrs."""
45
+ df = _records(data.get(key))
46
+ df.attrs = {k: v for k, v in data.items() if k != key}
47
+ return df
48
+
49
+
50
+ def _drop_none(d: Optional[dict]) -> Optional[dict]:
51
+ return None if d is None else {k: v for k, v in d.items() if v is not None}
52
+
53
+
54
+ def _csv(values: Union[None, str, Iterable[str]]) -> Optional[str]:
55
+ return values if values is None or isinstance(values, str) else ",".join(values)
56
+
57
+
58
+ def _seg(text: str) -> str:
59
+ """A team / conference / player name as one URL path segment."""
60
+ return quote(str(text), safe="")
61
+
62
+
63
+ class Pear:
64
+ """PEARatings data API client.
65
+
66
+ from pearatings import Pear
67
+ pear = Pear() # key from the PEAR_API_KEY environment variable
68
+ pear = Pear(api_key="...") # or pass it
69
+
70
+ Every method takes keyword-only `sport` ("baseball" | "softball", default "baseball") and `division`
71
+ ("d1" | "d2" | "d3" | "naia", default "d1") unless it exists for one division only.
72
+ The API address comes from `base_url`, else the PEAR_API_URL environment variable, else
73
+ https://api.pearatings.com.
74
+ """
75
+
76
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60):
77
+ self.api_key = api_key or os.environ.get("PEAR_API_KEY")
78
+ if not self.api_key:
79
+ raise AuthenticationError("No API key: pass Pear(api_key=...) or set the PEAR_API_KEY environment variable")
80
+ self.base_url = (base_url or os.environ.get("PEAR_API_URL") or DEFAULT_BASE_URL).rstrip("/")
81
+ self.timeout = timeout
82
+ self.session = requests.Session()
83
+ self.session.headers.update({"X-API-Key": self.api_key, "User-Agent": f"pearatings-python/{__version__}"})
84
+
85
+ def __repr__(self):
86
+ return f"Pear(base_url={self.base_url!r})"
87
+
88
+ # ------------------------------------------------------------------ plumbing
89
+
90
+ def _request(self, method: str, path: str, params: Optional[dict] = None, json: Optional[dict] = None):
91
+ try:
92
+ r = self.session.request(method, f"{self.base_url}/v1/{path}", params=_drop_none(params),
93
+ json=_drop_none(json), timeout=self.timeout)
94
+ except requests.RequestException as e:
95
+ raise PearError(f"Could not reach {self.base_url}: {e}") from e
96
+ if r.status_code >= 400:
97
+ try:
98
+ detail = r.json().get("detail", r.text)
99
+ except ValueError:
100
+ detail = r.text or r.reason
101
+ if r.status_code == 429:
102
+ after = r.headers.get("Retry-After")
103
+ raise RateLimitError(f"429: {detail}", status_code=429,
104
+ retry_after=int(after) if after and after.isdigit() else None)
105
+ raise _ERRORS.get(r.status_code, APIError)(f"{r.status_code}: {detail}", status_code=r.status_code)
106
+ return r
107
+
108
+ def get(self, path: str, **params) -> Any:
109
+ """Raw GET of any /v1 path (without the /v1/ prefix); returns the JSON as is.
110
+ Example: pear.get("baseball/d1/ratings")."""
111
+ return self._request("GET", path, params=params).json()
112
+
113
+ def post(self, path: str, body: Optional[dict] = None) -> Any:
114
+ """Raw POST of any /v1 path (without the /v1/ prefix); returns the JSON as is."""
115
+ return self._request("POST", path, json=body or {}).json()
116
+
117
+ @staticmethod
118
+ def _div(sport: str, division: str) -> str:
119
+ sport, division = sport.lower(), division.lower()
120
+ if division not in DIVISIONS.get(sport, ()):
121
+ raise ValueError(f"Unknown sport/division {sport}/{division}; choose from {DIVISIONS}")
122
+ return f"{sport}/{division}"
123
+
124
+ def _only(self, allowed, sport: str, division: str) -> str:
125
+ path = self._div(sport, division)
126
+ if path not in allowed:
127
+ raise ValueError(f"Only available for {', '.join(allowed)}")
128
+ return path
129
+
130
+ # ------------------------------------------------------------------ your account
131
+
132
+ def account(self) -> dict:
133
+ """Your key's tier, its limits (calls per month and per minute), calls used this month (UTC; resets on the
134
+ 1st) and subscription status."""
135
+ return self.get("account")
136
+
137
+ def billing_portal(self, open_browser: bool = True) -> str:
138
+ """A one-time link to Stripe's customer portal (change tier, cancel, change card, download invoices). Opens it
139
+ in your browser unless open_browser=False; returns the link either way. Paid tiers only."""
140
+ url = self.post("account/portal")["url"]
141
+ if open_browser:
142
+ import webbrowser
143
+ webbrowser.open(url)
144
+ return url
145
+
146
+ # ------------------------------------------------------------------ teams and ratings
147
+
148
+ def ratings(self, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
149
+ """Current Team Strength Ratings (TSR) and PCR for every team. attrs: date."""
150
+ return _table(self.get(f"{self._div(sport, division)}/ratings"), "teams")
151
+
152
+ def preseason(self, season: int, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
153
+ """A season's preseason ratings: the projection the season started from. D1 baseball has them back to
154
+ 2001; other divisions from 2026."""
155
+ return _table(self.get(f"{self._div(sport, division)}/preseason", season=season), "teams")
156
+
157
+ def stats(self, season: Optional[int] = None, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
158
+ """Every team's calculated stats and ratings for a season (default: current). For a finished season these
159
+ are the end-of-season ratings. attrs: date, season, columnMetadata."""
160
+ return _table(self.get(f"{self._div(sport, division)}/stats", season=season), "stats")
161
+
162
+ def teams(self, season: Optional[int] = None, *, sport: str = "baseball", division: str = "d1") -> List[str]:
163
+ """Team names, in the exact spelling every other method expects."""
164
+ return self.get(f"{self._div(sport, division)}/teams", season=season)["teams"]
165
+
166
+ def team(self, team: str, *, sport: str = "baseball", division: str = "d1") -> dict:
167
+ """One team: its ratings row (`team`), `upcoming_games` and `recent_games` with projections, `date`."""
168
+ return _frames(self.get(f"{self._div(sport, division)}/team/{_seg(team)}"))
169
+
170
+ def team_conferences(self, season: Optional[int] = None, *, sport: str = "baseball",
171
+ division: str = "d1") -> Dict[str, str]:
172
+ """{team: conference}."""
173
+ return self.get(f"{self._div(sport, division)}/team-conferences", season=season)["team_conferences"]
174
+
175
+ def conferences(self, season: Optional[int] = None, *, sport: str = "baseball", division: str = "d1") -> List[str]:
176
+ """Conference names."""
177
+ return self.get(f"{self._div(sport, division)}/conferences", season=season)["conferences"]
178
+
179
+ def conference_standings(self, conference: str, *, division: str = "d1") -> pd.DataFrame:
180
+ """Current standings of one baseball conference. attrs: conference, date."""
181
+ return _table(self.get(f"{self._div('baseball', division)}/conference/{_seg(conference)}"), "teams")
182
+
183
+ def records(self, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
184
+ """Current W-L, conference W-L, PCR and PCR score for every team, one row per team (in season only)."""
185
+ records = self.get(f"{self._div(sport, division)}/current-records")["records"]
186
+ return pd.DataFrame.from_dict(records, orient="index").rename_axis("Team").reset_index()
187
+
188
+ def pcr_ranks(self, *, sport: str = "baseball", division: str = "d1") -> Dict[str, int]:
189
+ """{team: PCR rank}."""
190
+ return self.get(f"{self._div(sport, division)}/team-pcrs")["team_pcrs"]
191
+
192
+ def combined_ratings(self) -> pd.DataFrame:
193
+ """Every baseball team (D1, D2, D3, NAIA) on one rating scale."""
194
+ return _records(self.get("baseball/combined-ratings"))
195
+
196
+ def logo(self, team: str, *, sport: str = "baseball", division: str = "d1", variant: Optional[str] = None) -> bytes:
197
+ """A team's logo as PNG bytes. `variant="dark"` gives the version drawn for dark backgrounds where there
198
+ is one. Save it with open("logo.png", "wb").write(pear.logo("LSU"))."""
199
+ return self._request("GET", f"{self._div(sport, division)}/logo/{_seg(team)}",
200
+ params={"variant": variant}).content
201
+
202
+ def conference_logo(self, conference: str) -> bytes:
203
+ """A conference's logo as PNG bytes."""
204
+ return self._request("GET", f"conference-logo/{_seg(conference)}").content
205
+
206
+ # ------------------------------------------------------------------ projections
207
+
208
+ def games(self, date: Optional[Date] = None, *, team: Optional[str] = None, conference: Optional[str] = None,
209
+ season: Optional[int] = None, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
210
+ """Games on a date (default: today, or the next day with games) with PEAR's line, win probability and
211
+ game quality (GQI); final scores once played. Filter by `team` or `conference`. `season` defaults to the
212
+ date's year. attrs: date, count, min_date, max_date (the season's first and last game dates)."""
213
+ if isinstance(date, _dt.date):
214
+ date = date.isoformat()
215
+ if date and season is None:
216
+ season = int(date[:4])
217
+ return _table(self.get(f"{self._div(sport, division)}/games", date=date, team=team, conference=conference,
218
+ season=season), "games")
219
+
220
+ def matchup(self, away: str, home: str, *, neutral: bool = False, season: Optional[int] = None,
221
+ sport: str = "baseball", division: str = "d1") -> dict:
222
+ """Projected line and win probabilities for any two teams: spread, formatted_spread, home_win_prob and
223
+ away_win_prob (percent, 0-100; games() gives 0-1), game_quality, home_pr, away_pr."""
224
+ return self.post(f"{self._div(sport, division)}/matchup",
225
+ {"away_team": away, "home_team": home, "neutral": neutral, "season": season})
226
+
227
+ def team_schedule(self, team: str, season: Optional[int] = None, *, sport: str = "baseball",
228
+ division: str = "d1") -> dict:
229
+ """A team's full season: `schedule` (every game with its projection or result), `metrics` (ranks),
230
+ `resume_path` (D1 baseball), `head_coach`, `data_date`."""
231
+ return _frames(self.post(f"{self._div(sport, division)}/team-schedule", {"team_name": team, "season": season}))
232
+
233
+ def simulate_wins(self, team: str, *, rating: Optional[float] = None, num_simulations: Optional[int] = None,
234
+ conference_only: bool = False, sport: str = "baseball", division: str = "d1") -> dict:
235
+ """Simulate the rest of a team's season: expected_wins, min/max, and the `distribution` of final win
236
+ totals. Pass `rating` to see the effect of a different team rating."""
237
+ return _frames(self.post(f"{self._div(sport, division)}/simulate-wins",
238
+ {"team_name": team, "rating": rating, "num_simulations": num_simulations,
239
+ "conference_only": conference_only}))
240
+
241
+ def season_projection(self, *, sport: str = "baseball", division: str = "d1") -> dict:
242
+ """The whole projected season: `projected_pcr` (final PCR projection), `tournament_outlook` (projected
243
+ field) and `team_simulated_results` ({team: projected games})."""
244
+ return _frames(self.get(f"{self._div(sport, division)}/season-projection"))
245
+
246
+ def conference_projections(self, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
247
+ """One row per team: current and projected overall / conference records, PCR, TSR. attrs: date."""
248
+ data = self.get(f"{self._div(sport, division)}/conference-view")
249
+ df = _records([t for conf in data["conferences"] for t in conf["teams"]])
250
+ df.attrs = {"date": data.get("date")}
251
+ return df
252
+
253
+ # ------------------------------------------------------------------ players
254
+
255
+ def players(self, season: Optional[int] = None, *, side: str = "hitting", sort: Optional[str] = None,
256
+ order: Optional[str] = None, conferences: Union[None, str, Iterable[str]] = None,
257
+ positions: Union[None, str, Iterable[str]] = None, search: Optional[str] = None,
258
+ limit: Optional[int] = None, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
259
+ """Player leaderboard for a season: `side` "hitting" or "pitching", sorted by `sort` (a stat column;
260
+ default PRV) in `order` ("asc" | "desc"). Filters: `conferences`, `positions` (hitting: C, IF, OF, UT,
261
+ DH), `search` (player or team name). Returns every matching player unless you set `limit`.
262
+ attrs: total, season, side, sort, and the filter values."""
263
+ path = f"{self._div(sport, division)}/players"
264
+ params = {"season": season, "side": side, "sort": sort, "order": order, "conferences": _csv(conferences),
265
+ "pos": _csv(positions), "q": search}
266
+ pages, offset = [], 0
267
+ while True:
268
+ page = min(_PAGE, limit - offset) if limit else _PAGE
269
+ data = self.get(path, **params, offset=offset, limit=page)
270
+ pages.extend(data["players"])
271
+ offset += len(data["players"])
272
+ if not data["players"] or offset >= data["total"] or (limit and offset >= limit):
273
+ break
274
+ data["players"] = pages
275
+ return _table(data, "players")
276
+
277
+ def roster(self, team: str, season: Optional[int] = None, *, sport: str = "baseball",
278
+ division: str = "d1") -> pd.DataFrame:
279
+ """A team's players for a season with their season stats (hitting, pitching `p_*`, fielding `f_*`) and
280
+ roster details. Before a season starts: next season's roster with each player's status.
281
+ attrs: team, season, platform (where the stats came from), returning."""
282
+ return _table(self.get(f"{self._div(sport, division)}/roster", team=team, season=season), "players")
283
+
284
+ def player(self, player_id: str, *, sport: str = "baseball") -> dict:
285
+ """One player by id (the `player_id` column of `roster` / `players`): bio, `seasons` (season lines) and
286
+ `games` (game-by-game lines)."""
287
+ if sport not in DIVISIONS:
288
+ raise ValueError(f"Unknown sport {sport}")
289
+ return _frames(self.get(f"{sport}/player/{_seg(player_id)}"))
290
+
291
+ # ------------------------------------------------------------------ history
292
+
293
+ def history(self, team: str, *, division: str = "d1") -> pd.DataFrame:
294
+ """A baseball team's end-of-season rating, rank, PCR and RPI for every season on record. attrs: team."""
295
+ return _table(self.get(f"{self._div('baseball', division)}/history", team=team), "history")
296
+
297
+ def tournament_history(self, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
298
+ """Past national tournament results by year (not NAIA)."""
299
+ return _table(self.get(f"{self._div(sport, division)}/tournament-history"), "years")
300
+
301
+ def historical_table(self) -> pd.DataFrame:
302
+ """D1 baseball: every team's end-of-season numbers for every season since 1999."""
303
+ return _table(self.get("baseball/d1/historical-table"), "data")
304
+
305
+ # ------------------------------------------------------------------ lookup
306
+
307
+ def lookup_options(self, *, sport: str = "baseball", division: str = "d1") -> dict:
308
+ """What Lookup can search: `seasons`, `fields` by kind (game, team, bat, pit) and `scopes`."""
309
+ return _frames(self.get(f"{self._div(sport, division)}/lookup-options"))
310
+
311
+ def lookup_fields(self, *, sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
312
+ """Every Lookup field in one table: kind (game | team | bat | pit), key (what a condition's `field`
313
+ takes), label, group, unit."""
314
+ fields = self.get(f"{self._div(sport, division)}/lookup-options")["fields"]
315
+ return _records([dict(f, kind=kind) for kind, rows in fields.items() for f in rows])
316
+
317
+ def lookup(self, season_from: int, season_to: Optional[int] = None, *, mode: str = "games", role: str = "bat",
318
+ conditions: Optional[List[dict]] = None, match: str = "all", team: Optional[str] = None,
319
+ sort: Optional[str] = None, desc: bool = True, limit: int = 50, offset: int = 0,
320
+ sport: str = "baseball", division: str = "d1") -> pd.DataFrame:
321
+ """Search games (`mode="games"`) or player games (`mode="players"`, `role` "bat" | "pit") that meet
322
+ `conditions` (all of them with match="all", any with "any"). Each condition is a dict:
323
+ {"kind": "team", "field": "R", "op": ">=", "value": 20, "scope": "either"} # a team scored 20+
324
+ {"kind": "game", "field": "innings", "op": ">=", "value": 12} # 12+ innings
325
+ {"kind": "player", "field": "HR", "op": ">=", "value": 3} # mode="players"
326
+ `op` is ">=", "<=" or "="; `field` is a key from lookup_fields(); team conditions need a `scope`
327
+ (either, both, total, winner, loser, home, away, team, opp). `team` limits the search to teams whose
328
+ name contains it. Up to 200 rows per call; page with `offset`. attrs: total, columns."""
329
+ return _table(self.post(f"{self._div(sport, division)}/lookup", {
330
+ "mode": mode, "role": role, "season_from": season_from, "season_to": season_to or season_from,
331
+ "conditions": conditions or [], "match": match, "team": team, "sort": sort, "desc": desc,
332
+ "limit": limit, "offset": offset}), "rows")
333
+
334
+ # ------------------------------------------------------------------ tournaments
335
+
336
+ def tournament_outlook(self, *, sport: str = "baseball", division: str = "d1") -> dict:
337
+ """The projected national tournament field (the actual bracket once it is set): regionals (D2:
338
+ regions), bubble lists, automatic qualifiers, multi-bid conferences, team ratings."""
339
+ return _frames(self.get(f"{self._div(sport, division)}/tournament/outlook"))
340
+
341
+ def tournament_regionals(self, season: Optional[int] = None) -> dict:
342
+ """D1 baseball, once the tournament starts (default: current season): regionals, every regional / super
343
+ regional / CWS game with its projection or result, regional winners, the CWS bracket."""
344
+ return _frames(self.get("baseball/d1/tournament/regionals", season=season))
345
+
346
+ def tournament_team(self, team: str, *, sport: str = "baseball", division: str = "d1") -> dict:
347
+ """One tournament team's numbers: rating and rank, PCR, record, Q1 record, regional win probability
348
+ (D1 baseball, D1 softball, D3 baseball)."""
349
+ path = self._only(("baseball/d1", "softball/d1", "baseball/d3"), sport, division)
350
+ return self.get(f"{path}/tournament/team", team=team)
351
+
352
+ def simulate_bracket(self, outlook: Optional[dict] = None, *, seed: Optional[int] = None,
353
+ sport: str = "baseball", division: str = "d1") -> dict:
354
+ """Simulate the national tournament once: regional winners through the champion. D1 baseball, D1
355
+ softball and D3 baseball use `outlook` (from tournament_outlook(); fetched when omitted); D2 baseball
356
+ needs no outlook. Same `seed` = same result."""
357
+ path = self._only(("baseball/d1", "softball/d1", "baseball/d3", "baseball/d2"), sport, division)
358
+ if path == "baseball/d2":
359
+ body = {"seed": seed}
360
+ else:
361
+ outlook = outlook or self.get(f"{path}/tournament/outlook")
362
+ regionals = outlook["regionals"]
363
+ if isinstance(regionals, pd.DataFrame):
364
+ regionals = regionals.to_dict("records")
365
+ body = {"regionals": regionals, "ratings": outlook["ratings"], "seed": seed}
366
+ return _frames(self.post(f"{path}/tournament/simulate-bracket", body))
367
+
368
+ def d2_simulate_cary(self, teams: List[str], *, seed: Optional[int] = None) -> dict:
369
+ """D2 baseball: simulate the 8-team finals in Cary from the 8 regional winners, best PCR first."""
370
+ return _frames(self.post("baseball/d2/tournament/simulate-cary", {"teams": teams, "seed": seed}))
371
+
372
+ def naia_simulate_regional(self, seeds: List[str], *, format: str = "5team", seed: Optional[int] = None) -> dict:
373
+ """NAIA: simulate one opening-round regional (`format` "4team" or "5team", seeds in order)."""
374
+ return _frames(self.post("baseball/naia/tournament/simulate-regional",
375
+ {"seeds": seeds, "format": format, "seed": seed}))
376
+
377
+ def naia_simulate_ws(self, teams: Optional[List[str]] = None, *, regional_picks: Optional[dict] = None,
378
+ seed: Optional[int] = None, n: Optional[int] = None) -> dict:
379
+ """NAIA: simulate the World Series. With no `teams`, one winner per regional is drawn first."""
380
+ return _frames(self.post("baseball/naia/tournament/simulate-ws",
381
+ {"teams": teams or [], "regional_picks": regional_picks or {}, "seed": seed, "n": n}))
382
+
383
+ def naia_ws_odds(self, teams: List[str]) -> dict:
384
+ """NAIA: each team's chance to win the World Series from its 10 seeds (exact, not simulated)."""
385
+ return _frames(self.post("baseball/naia/tournament/ws-champion-odds", {"teams": teams}))
386
+
387
+ def conference_tournament_probs(self, conference: str, *, locked_matchups: Optional[List[dict]] = None) -> dict:
388
+ """D1 baseball: each team's chance to reach every round of its conference tournament and win it.
389
+ `locked_matchups` fixes results already decided: [{"winner": "LSU", "loser": "Florida"}, ...]."""
390
+ if locked_matchups:
391
+ return _frames(self.post("baseball/d1/conference-tournament/probs-with-picks",
392
+ {"conference": conference, "locked_matchups": locked_matchups}))
393
+ return _frames(self.post("baseball/d1/conference-tournament/probs", {"conference": conference}))
@@ -0,0 +1,34 @@
1
+ """Errors raised by the PEARatings client. Catch `PearError` to handle all of them."""
2
+
3
+
4
+ class PearError(Exception):
5
+ """Base class. `status_code` is the HTTP status (None when no request was made)."""
6
+
7
+ def __init__(self, message, status_code=None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+
11
+
12
+ class AuthenticationError(PearError):
13
+ """No API key, the API doesn't know it (HTTP 401), or it is no longer active: revoked or the subscription
14
+ lapsed (HTTP 403)."""
15
+
16
+
17
+ class NotFoundError(PearError):
18
+ """Unknown team, player, season or division, or no data for it yet (HTTP 404)."""
19
+
20
+
21
+ class InvalidRequestError(PearError):
22
+ """The API rejected the parameters (HTTP 400 / 422)."""
23
+
24
+
25
+ class RateLimitError(PearError):
26
+ """Over your tier's per-minute or monthly limit (HTTP 429). `retry_after` is the seconds to wait (or None)."""
27
+
28
+ def __init__(self, message, status_code=None, retry_after=None):
29
+ super().__init__(message, status_code)
30
+ self.retry_after = retry_after
31
+
32
+
33
+ class APIError(PearError):
34
+ """Any other error from the API (HTTP 5xx, or data not available right now, e.g. 503 out of season)."""
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: pearatings
3
+ Version: 0.2.0
4
+ Summary: Python client for the PEARatings college baseball and softball data API
5
+ Author-email: PEARatings <pearatings@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pearatings.com
8
+ Project-URL: Pricing, https://api.pearatings.com/billing
9
+ Project-URL: Documentation, https://api.pearatings.com/docs
10
+ Project-URL: Source, https://github.com/pearatings/pear-api
11
+ Keywords: college baseball,college softball,ratings,sports analytics,api,ncaa,naia
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.25
22
+ Requires-Dist: pandas>=1.3
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # pearatings
28
+
29
+ Python client for the [PEARatings](https://pearatings.com) data API: college baseball (D1, D2, D3, NAIA)
30
+ and softball (D1, D2, D3) ratings, projections, player stats, history, Lookup and tournament simulations.
31
+ Tables come back as pandas DataFrames.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install pearatings
37
+ ```
38
+
39
+ ## Get a key
40
+
41
+ Pick a tier at **https://api.pearatings.com/billing**. Your key is shown once: copy it.
42
+
43
+ | Tier | Price | API calls per month | Per minute |
44
+ |---|---|---|---|
45
+ | Free | $0 | 500 | 10 |
46
+ | Starter | $1.99/month | 1,000 | 30 |
47
+ | Plus | $4.99/month | 5,000 | 60 |
48
+ | Pro | $9.99/month | 20,000 | 120 |
49
+ | Max | $19.99/month | 50,000 | 120 |
50
+
51
+ Every request is one call; `players()` fetches a full leaderboard 200 rows per call. Calls reset on the 1st
52
+ (UTC). `pear.account()` shows your tier and calls used this month; `pear.billing_portal()` changes tier or cancels.
53
+
54
+ ## Set your key
55
+
56
+ ```bash
57
+ export PEAR_API_KEY='your-key' # single quotes if the key has a ! in it
58
+ ```
59
+ or pass it: `Pear(api_key="your-key")`.
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from pearatings import Pear
65
+
66
+ pear = Pear()
67
+
68
+ ratings = pear.ratings() # D1 baseball, current
69
+ ratings.attrs["date"] # when the ratings were updated
70
+ pear.ratings(sport="softball", division="d2")
71
+
72
+ pear.preseason(2027) # preseason ratings
73
+ pear.stats(2025) # team stats + end-of-season ratings for 2025
74
+
75
+ pear.games("2026-04-18") # that day's games with PEAR's line and win probability
76
+ pear.matchup("LSU", "Florida", neutral=True) # any two teams
77
+ pear.team_schedule("LSU", 2026)["schedule"] # every game of a team's season
78
+
79
+ pear.players(2026) # full hitting leaderboard (every page)
80
+ pear.players(2026, side="pitching", conferences=["SEC"], limit=50)
81
+ roster = pear.roster("LSU", 2026)
82
+ pear.player(roster["player_id"].iloc[0])["games"] # one player's game-by-game lines
83
+
84
+ pear.history("LSU") # end-of-season numbers, every season
85
+ pear.tournament_outlook() # projected NCAA field
86
+ pear.simulate_bracket(seed=7) # one simulated tournament
87
+
88
+ open("lsu.png", "wb").write(pear.logo("LSU"))
89
+ ```
90
+
91
+ Every method takes keyword-only `sport` (`"baseball"` default, or `"softball"`) and `division` (`"d1"`
92
+ default, `"d2"`, `"d3"`, `"naia"`) unless it exists for one division only. Use `help(pear.games)` for any
93
+ method's details.
94
+
95
+ ## What comes back
96
+
97
+ - **One table** → a `DataFrame`; the rest of the response (data date, season, totals) is in `df.attrs`.
98
+ - **Anything else** → a `dict`, where every list of records is a `DataFrame`.
99
+ - Nested fields are flattened to dotted columns: `record.wins`, `stats.PA`.
100
+ - `pear.get("baseball/d1/ratings")` / `pear.post(path, body)` return the raw JSON of any endpoint.
101
+
102
+ ## Methods
103
+
104
+ | Area | Methods |
105
+ |---|---|
106
+ | Your account | `account`, `billing_portal` |
107
+ | Teams and ratings | `ratings`, `preseason`, `stats`, `teams`, `team`, `team_conferences`, `conferences`, `conference_standings`, `records`, `pcr_ranks`, `combined_ratings`, `logo`, `conference_logo` |
108
+ | Projections | `games`, `matchup`, `team_schedule`, `simulate_wins`, `season_projection`, `conference_projections` |
109
+ | Players | `players`, `roster`, `player` |
110
+ | History | `history`, `tournament_history`, `historical_table` |
111
+ | Lookup | `lookup_fields`, `lookup_options`, `lookup` |
112
+ | Tournaments | `tournament_outlook`, `tournament_regionals`, `tournament_team`, `simulate_bracket`, `conference_tournament_probs`, `d2_simulate_cary`, `naia_simulate_regional`, `naia_simulate_ws`, `naia_ws_odds` |
113
+
114
+ ## Lookup
115
+
116
+ Find games or player games that meet conditions. Field keys come from `pear.lookup_fields()`.
117
+
118
+ ```python
119
+ pear.lookup_fields() # kind, key, label, group, unit
120
+
121
+ # games where a team scored 25+ in 2026
122
+ pear.lookup(2026, conditions=[{"kind": "team", "field": "R", "op": ">=", "value": 25, "scope": "either"}])
123
+
124
+ # 3-homer games, 2021-2026
125
+ pear.lookup(2021, 2026, mode="players", role="bat",
126
+ conditions=[{"kind": "player", "field": "HR", "op": ">=", "value": 3}], limit=200)
127
+ ```
128
+ Up to 200 rows per call; `df.attrs["total"]` is the full count, page with `offset=`.
129
+
130
+ ## Errors
131
+
132
+ All errors subclass `pearatings.PearError` and carry `.status_code`:
133
+ `AuthenticationError` (bad or missing key, or a lapsed subscription), `NotFoundError` (unknown team / season),
134
+ `InvalidRequestError` (bad parameters), `RateLimitError` (over your tier's limit; `.retry_after` says how many
135
+ seconds to wait), `APIError` (anything else, e.g. data not available out of season).
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ pip install -e '.[test]'
141
+ pytest # offline tests
142
+ PEAR_API_KEY='...' PEAR_API_URL=http://127.0.0.1:8001 pytest -m live # every method against a running API
143
+ ```
144
+ `PEAR_API_URL` (or `Pear(base_url=...)`) points the client at another server; the default is
145
+ `https://api.pearatings.com`.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pearatings/__init__.py
5
+ src/pearatings/client.py
6
+ src/pearatings/errors.py
7
+ src/pearatings.egg-info/PKG-INFO
8
+ src/pearatings.egg-info/SOURCES.txt
9
+ src/pearatings.egg-info/dependency_links.txt
10
+ src/pearatings.egg-info/requires.txt
11
+ src/pearatings.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_live.py
@@ -0,0 +1,5 @@
1
+ requests>=2.25
2
+ pandas>=1.3
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1 @@
1
+ pearatings
@@ -0,0 +1,128 @@
1
+ """Offline tests: no network, the HTTP session is replaced by a fake that records requests."""
2
+ import json
3
+
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ from pearatings import AuthenticationError, NotFoundError, Pear, RateLimitError
8
+
9
+
10
+ class FakeResponse:
11
+ def __init__(self, status=200, body=None, content=b""):
12
+ self.status_code, self._body, self.content, self.headers = status, body, content, {}
13
+ self.text, self.reason = json.dumps(body), "reason"
14
+
15
+ def json(self):
16
+ if self._body is None:
17
+ raise ValueError
18
+ return self._body
19
+
20
+
21
+ class FakeSession:
22
+ def __init__(self, response):
23
+ self.response, self.calls, self.headers = response, [], {}
24
+
25
+ def request(self, method, url, params=None, json=None, timeout=None):
26
+ self.calls.append({"method": method, "url": url, "params": params, "json": json})
27
+ return self.response
28
+
29
+
30
+ def client(response):
31
+ pear = Pear(api_key="k", base_url="http://api.test/")
32
+ pear.session = FakeSession(response)
33
+ return pear
34
+
35
+
36
+ def test_key_required(monkeypatch):
37
+ monkeypatch.delenv("PEAR_API_KEY", raising=False)
38
+ with pytest.raises(AuthenticationError):
39
+ Pear()
40
+ monkeypatch.setenv("PEAR_API_KEY", "from-env")
41
+ assert Pear().session.headers["X-API-Key"] == "from-env"
42
+
43
+
44
+ def test_base_url_from_env(monkeypatch):
45
+ monkeypatch.setenv("PEAR_API_URL", "http://127.0.0.1:8001/")
46
+ assert Pear(api_key="k").base_url == "http://127.0.0.1:8001"
47
+
48
+
49
+ def test_table_endpoint_returns_dataframe_with_attrs():
50
+ pear = client(FakeResponse(body={"teams": [{"Team": "LSU", "power_rating": 6.1}], "date": "May 1", "count": 1}))
51
+ df = pear.ratings(sport="softball", division="d2")
52
+ assert isinstance(df, pd.DataFrame) and df.loc[0, "Team"] == "LSU"
53
+ assert df.attrs == {"date": "May 1", "count": 1}
54
+ assert pear.session.calls[0]["url"] == "http://api.test/v1/softball/d2/ratings"
55
+
56
+
57
+ def test_none_params_are_dropped_and_names_are_escaped():
58
+ pear = client(FakeResponse(body={"team": {}, "upcoming_games": [], "recent_games": []}))
59
+ pear.team("Texas A&M")
60
+ call = pear.session.calls[0]
61
+ assert call["url"].endswith("/v1/baseball/d1/team/Texas%20A%26M")
62
+ pear.games(date=None, team="LSU")
63
+ assert pear.session.calls[1]["params"] == {"team": "LSU"}
64
+ pear.games(__import__("datetime").date(2026, 4, 18))
65
+ assert pear.session.calls[2]["params"] == {"date": "2026-04-18", "season": 2026}
66
+
67
+
68
+ def test_nested_records_become_flat_frames():
69
+ body = {"schedule": [{"Opponent": "UNO", "record": {"wins": 3}}], "metrics": {"pcr": 4}}
70
+ out = client(FakeResponse(body=body)).team_schedule("LSU")
71
+ assert list(out["schedule"].columns) == ["Opponent", "record.wins"] and out["metrics"] == {"pcr": 4}
72
+
73
+
74
+ def test_players_pages_until_total():
75
+ pages = [{"total": 3, "players": [{"n": 1}, {"n": 2}]}, {"total": 3, "players": [{"n": 3}]}]
76
+
77
+ class Paging(FakeSession):
78
+ def request(self, *a, **k):
79
+ super().request(*a, **k)
80
+ return FakeResponse(body=dict(pages[len(self.calls) - 1]))
81
+
82
+ pear = Pear(api_key="k", base_url="http://api.test")
83
+ pear.session = Paging(None)
84
+ df = pear.players(season=2026, conferences=["SEC", "ACC"])
85
+ assert df["n"].tolist() == [1, 2, 3]
86
+ assert pear.session.calls[0]["params"]["conferences"] == "SEC,ACC"
87
+ assert pear.session.calls[1]["params"]["offset"] == 2
88
+
89
+
90
+ def test_records_one_row_per_team():
91
+ body = {"records": {"LSU": {"wins": 5, "losses": 1}, "UNO": {"wins": 2, "losses": 4}}}
92
+ df = client(FakeResponse(body=body)).records()
93
+ assert df.columns[0] == "Team" and df.set_index("Team").loc["UNO", "losses"] == 4
94
+
95
+
96
+ @pytest.mark.parametrize("status,error", [(401, AuthenticationError), (403, AuthenticationError), (404, NotFoundError),
97
+ (429, RateLimitError)])
98
+ def test_http_errors(status, error):
99
+ with pytest.raises(error) as e:
100
+ client(FakeResponse(status=status, body={"detail": "nope"})).ratings()
101
+ assert e.value.status_code == status and "nope" in str(e.value)
102
+
103
+
104
+ def test_bad_division_fails_before_any_request():
105
+ pear = client(FakeResponse(body={}))
106
+ with pytest.raises(ValueError):
107
+ pear.ratings(sport="softball", division="naia")
108
+ with pytest.raises(ValueError):
109
+ pear.tournament_team("X", division="d2")
110
+ assert pear.session.calls == []
111
+
112
+
113
+ def test_logo_returns_bytes():
114
+ assert client(FakeResponse(content=b"\x89PNG")).logo("LSU") == b"\x89PNG"
115
+
116
+
117
+ def test_rate_limit_carries_retry_after():
118
+ resp = FakeResponse(status=429, body={"detail": "Rate limit: 60 requests per minute"})
119
+ resp.headers = {"Retry-After": "17"}
120
+ with pytest.raises(RateLimitError) as e:
121
+ client(resp).ratings()
122
+ assert e.value.retry_after == 17
123
+
124
+
125
+ def test_account_and_portal():
126
+ pear = client(FakeResponse(body={"url": "https://billing.stripe.com/p/session/x"}))
127
+ assert pear.billing_portal(open_browser=False) == "https://billing.stripe.com/p/session/x"
128
+ assert pear.session.calls[0]["method"] == "POST" and pear.session.calls[0]["url"].endswith("/v1/account/portal")
@@ -0,0 +1,100 @@
1
+ """Calls every client method against a running API. Skipped unless PEAR_API_KEY and PEAR_API_URL are set:
2
+ PEAR_API_KEY='...' PEAR_API_URL=http://127.0.0.1:8001 pytest -m live
3
+ Uses 2026 data, so some calls need that season on the server."""
4
+ import os
5
+
6
+ import pandas as pd
7
+ import pytest
8
+
9
+ from pearatings import APIError, NotFoundError, Pear
10
+
11
+ pytestmark = [pytest.mark.live,
12
+ pytest.mark.skipif(not (os.environ.get("PEAR_API_KEY") and os.environ.get("PEAR_API_URL")),
13
+ reason="needs PEAR_API_KEY and PEAR_API_URL")]
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def pear():
18
+ return Pear()
19
+
20
+
21
+ def frame(df, min_rows=1):
22
+ assert isinstance(df, pd.DataFrame) and len(df) >= min_rows, df
23
+ return df
24
+
25
+
26
+ def test_teams_and_ratings(pear):
27
+ frame(pear.ratings(), 250)
28
+ frame(pear.ratings(sport="softball", division="d3"), 100)
29
+ assert frame(pear.preseason(2027)).iloc[0]["Team"]
30
+ frame(pear.preseason(2005))
31
+ with pytest.raises(NotFoundError):
32
+ pear.preseason(1990)
33
+ frame(pear.stats(2026), 250)
34
+ frame(pear.stats(2026, sport="baseball", division="naia"))
35
+ teams = pear.teams()
36
+ assert "LSU" in teams
37
+ assert isinstance(pear.team("LSU")["team"], dict)
38
+ assert pear.team_conferences()["LSU"] == "SEC"
39
+ assert "SEC" in pear.conferences()
40
+ frame(pear.conference_standings("SEC"))
41
+ assert isinstance(pear.pcr_ranks()["LSU"], int)
42
+ frame(pear.combined_ratings(), 1000)
43
+ assert pear.logo("LSU").startswith(b"\x89PNG")
44
+ assert pear.logo("Oklahoma", sport="softball", variant="dark").startswith(b"\x89PNG")
45
+ assert pear.conference_logo("SEC").startswith(b"\x89PNG")
46
+
47
+
48
+ def test_projections(pear):
49
+ games = frame(pear.games("2026-04-18"), 50)
50
+ assert {"home_team", "away_team", "home_win_prob"} <= set(games.columns)
51
+ frame(pear.games(conference="SEC"), 0)
52
+ m = pear.matchup("LSU", "Florida")
53
+ assert 0 < m["home_win_prob"] < 100 # percent here; games() uses 0-1
54
+ s = pear.team_schedule("LSU", 2026)
55
+ frame(s["schedule"], 50)
56
+ w = pear.simulate_wins("LSU", rating=7.0)
57
+ frame(w["distribution"])
58
+ frame(pear.season_projection(division="d2")["projected_pcr"]["teams"], 100)
59
+ assert {"team", "conference"} <= set(frame(pear.conference_projections(), 250).columns)
60
+
61
+
62
+ def test_players(pear):
63
+ hitters = frame(pear.players(2026, limit=250), 250)
64
+ assert len(hitters) == 250 and hitters.attrs["total"] > 250
65
+ frame(pear.players(2026, side="pitching", conferences=["SEC"], sport="softball"))
66
+ roster = frame(pear.roster("LSU", 2026), 20)
67
+ p = pear.player(roster["player_id"].dropna().iloc[0])
68
+ frame(p["games"])
69
+
70
+
71
+ def test_history_and_lookup(pear):
72
+ frame(pear.history("LSU"), 20)
73
+ frame(pear.tournament_history())
74
+ frame(pear.historical_table(), 5000)
75
+ assert {"kind", "key"} <= set(frame(pear.lookup_fields()).columns)
76
+ assert 2026 in pear.lookup_options()["seasons"]
77
+ rows = frame(pear.lookup(2026, conditions=[{"kind": "team", "field": "R", "op": ">=", "value": 25,
78
+ "scope": "either"}]))
79
+ assert rows.attrs["total"] >= len(rows)
80
+ frame(pear.lookup(2026, mode="players", team="LSU", limit=10))
81
+
82
+
83
+ def test_tournaments(pear):
84
+ outlook = pear.tournament_outlook()
85
+ frame(outlook["regionals"], 16)
86
+ assert pear.simulate_bracket(outlook, seed=1)["regional_winners"]
87
+ assert pear.simulate_bracket(sport="softball", seed=1)["regional_winners"]
88
+ assert pear.simulate_bracket(division="d3", seed=1)["regional_winners"]
89
+ assert pear.simulate_bracket(division="d2", seed=1)["regions"]
90
+ frame(pear.tournament_outlook(division="d2")["regions"], 8)
91
+ assert pear.tournament_team("LSU")["team"] == "LSU"
92
+ frame(pear.tournament_regionals(2026)["games"], 100)
93
+ frame(pear.conference_tournament_probs("SEC")["teams"])
94
+ frame(pear.tournament_history(sport="softball"))
95
+ with pytest.raises(APIError): # 503 until NAIA's bracket exists
96
+ pear.tournament_outlook(division="naia")
97
+
98
+
99
+ def test_account(pear):
100
+ assert "plan" in pear.account()