Skip to content

Commit 6e4f918

Browse files
jag426claudedreamiurg
authored
feat: add Python library API for programmatic use (#69)
Adds a high-level PeakBagger class that wraps the client and scraper, making it easy to use this package as a Python dependency rather than only through the CLI. Also exports all public model types from the top-level package. Note: pre-existing ty errors in scraper.py/models.py block the hook (see main branch); not caused by these changes. Co-authored-by: jag426 <jag426@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Dmytro Gaivoronsky <the@dreamiurg.net>
1 parent fb8b0a1 commit 6e4f918

File tree

3 files changed

+369
-1
lines changed

3 files changed

+369
-1
lines changed

peakbagger/__init__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
"""PeakBagger CLI - A command-line interface for PeakBagger.com"""
1+
"""PeakBagger - Python library and CLI for PeakBagger.com data.
2+
3+
Quick start::
4+
5+
from peakbagger import PeakBagger
6+
7+
with PeakBagger() as pb:
8+
results = pb.search("Mount Rainier")
9+
peak = pb.get_peak("2296")
10+
ascents = pb.get_ascents("2296")
11+
ascent = pb.get_ascent("12963")
12+
"""
213

314
from importlib.metadata import PackageNotFoundError, version
415

@@ -7,3 +18,15 @@
718
except PackageNotFoundError:
819
# Package is not installed (e.g., running from source during development)
920
__version__ = "0.0.0.dev"
21+
22+
from peakbagger.api import PeakBagger
23+
from peakbagger.models import Ascent, AscentStatistics, Peak, SearchResult
24+
25+
__all__ = [
26+
"Ascent",
27+
"AscentStatistics",
28+
"Peak",
29+
"PeakBagger",
30+
"SearchResult",
31+
"__version__",
32+
]

peakbagger/api.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""High-level Python API for PeakBagger.com data.
2+
3+
Example usage::
4+
5+
from peakbagger import PeakBagger
6+
7+
pb = PeakBagger()
8+
9+
# Search for peaks
10+
results = pb.search("Mount Rainier")
11+
for r in results:
12+
print(r.name, r.elevation_ft)
13+
14+
# Get peak details
15+
peak = pb.get_peak("2296")
16+
print(peak.name, peak.prominence_ft)
17+
18+
# List ascents
19+
ascents = pb.get_ascents("2296")
20+
for a in ascents:
21+
print(a.date, a.climber_name)
22+
23+
# Get ascent detail
24+
ascent = pb.get_ascent("12963")
25+
print(ascent.trip_report_text)
26+
"""
27+
28+
from peakbagger.client import PeakBaggerClient
29+
from peakbagger.models import Ascent, Peak, SearchResult
30+
from peakbagger.scraper import PeakBaggerScraper
31+
32+
33+
class PeakBagger:
34+
"""High-level client for retrieving data from PeakBagger.com.
35+
36+
Manages the HTTP client lifecycle and provides simple methods for
37+
common operations. Use as a context manager to ensure the session
38+
is properly closed::
39+
40+
with PeakBagger() as pb:
41+
results = pb.search("Denali")
42+
43+
Or manage the lifecycle manually::
44+
45+
pb = PeakBagger()
46+
results = pb.search("Denali")
47+
pb.close()
48+
"""
49+
50+
def __init__(self, rate_limit_seconds: float = 2.0) -> None:
51+
"""
52+
Initialize the PeakBagger client.
53+
54+
Args:
55+
rate_limit_seconds: Minimum seconds between HTTP requests (default: 2.0).
56+
Reduce below 2.0 only for testing against saved HTML.
57+
"""
58+
self._client = PeakBaggerClient(rate_limit_seconds=rate_limit_seconds)
59+
self._scraper = PeakBaggerScraper()
60+
61+
def search(self, query: str) -> list[SearchResult]:
62+
"""
63+
Search for peaks by name.
64+
65+
Args:
66+
query: Search term (e.g., "Mount Rainier", "Denali")
67+
68+
Returns:
69+
List of SearchResult objects matching the query.
70+
71+
Raises:
72+
Exception: If the HTTP request fails.
73+
"""
74+
html = self._client.get("/search.aspx", params={"ss": query, "tid": "M"})
75+
return self._scraper.parse_search_results(html)
76+
77+
def get_peak(self, peak_id: str | int) -> Peak | None:
78+
"""
79+
Get detailed information about a specific peak.
80+
81+
Args:
82+
peak_id: The PeakBagger peak ID (e.g., "2296" or 2296 for Mount Rainier)
83+
84+
Returns:
85+
Peak object with full details, or None if parsing fails.
86+
87+
Raises:
88+
Exception: If the HTTP request fails.
89+
"""
90+
pid = str(peak_id)
91+
html = self._client.get("/peak.aspx", params={"pid": pid})
92+
return self._scraper.parse_peak_detail(html, pid)
93+
94+
def get_ascents(self, peak_id: str | int) -> list[Ascent]:
95+
"""
96+
List all logged ascents for a specific peak.
97+
98+
Args:
99+
peak_id: The PeakBagger peak ID (e.g., "2296" or 2296 for Mount Rainier)
100+
101+
Returns:
102+
List of Ascent objects, sorted by date descending (most recent first).
103+
104+
Raises:
105+
Exception: If the HTTP request fails.
106+
"""
107+
pid = str(peak_id)
108+
html = self._client.get(
109+
"/climber/PeakAscents.aspx",
110+
params={"pid": pid, "sort": "ascentdate", "u": "ft", "y": "9999"},
111+
)
112+
return self._scraper.parse_peak_ascents(html)
113+
114+
def get_ascent(self, ascent_id: str | int) -> Ascent | None:
115+
"""
116+
Get detailed information about a specific ascent.
117+
118+
Args:
119+
ascent_id: The PeakBagger ascent ID (e.g., "12963")
120+
121+
Returns:
122+
Ascent object with full details including trip report, or None if parsing fails.
123+
124+
Raises:
125+
Exception: If the HTTP request fails.
126+
"""
127+
aid = str(ascent_id)
128+
html = self._client.get("/climber/ascent.aspx", params={"aid": aid})
129+
return self._scraper.parse_ascent_detail(html, aid)
130+
131+
def close(self) -> None:
132+
"""Close the underlying HTTP session."""
133+
self._client.close()
134+
135+
def __enter__(self) -> "PeakBagger":
136+
return self
137+
138+
def __exit__(self, *args: object) -> None:
139+
self.close()

tests/test_api.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Tests for the high-level PeakBagger API."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from peakbagger import Ascent, Peak, PeakBagger, SearchResult
8+
9+
SEARCH_HTML = """<html><body>
10+
<h2>Peak Search Results</h2>
11+
<table class="gray">
12+
<tr><th>Type</th><th>Peak</th><th>Location</th><th>Range</th><th>Elevation</th></tr>
13+
<tr>
14+
<td>Summit</td>
15+
<td><a href="peak.aspx?pid=2296">Mount Rainier</a></td>
16+
<td>USA-WA</td>
17+
<td>Cascade Range</td>
18+
<td>14,411 ft / 4392 m</td>
19+
</tr>
20+
</table>
21+
</body></html>"""
22+
23+
PEAK_HTML = """<html><body>
24+
<h1>Mount Rainier, Washington</h1>
25+
<h2>Elevation: 14,411 feet, 4392 meters</h2>
26+
<p>Prominence: 13,210 ft, 4027 m</p>
27+
<p>True Isolation: 1,326.0 mi, 2134.3 km</p>
28+
<p>47.87967, -121.72616 (Dec Deg)</p>
29+
United States
30+
<p>Total ascents/attempts logged by registered Peakbagger.com users: <b>4388</b></p>
31+
</body></html>"""
32+
33+
ASCENT_HTML = """<html><body>
34+
<h1>Ascent of Mount Rainier on July 4, 2020</h1>
35+
<h2>Climber: <a href="climber.aspx?cid=999">John Doe</a></h2>
36+
<table class="gray" width="49%" align="left">
37+
<tr><td><b>Date</b></td><td>Saturday, July 4, 2020</td></tr>
38+
<tr><td><b>Ascent Type</b></td><td>Successful Summit Attained</td></tr>
39+
<tr><td><b>Peak</b></td><td><a href="peak.aspx?pid=2296">Mount Rainier</a></td></tr>
40+
</table>
41+
</body></html>"""
42+
43+
44+
def _make_client_mock(html: str) -> MagicMock:
45+
mock = MagicMock()
46+
mock.get.return_value = html
47+
return mock
48+
49+
50+
class TestPeakBaggerSearch:
51+
def test_search_returns_results(self):
52+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock(SEARCH_HTML)):
53+
pb = PeakBagger()
54+
results = pb.search("Mount Rainier")
55+
56+
assert len(results) == 1
57+
assert isinstance(results[0], SearchResult)
58+
assert results[0].name == "Mount Rainier"
59+
assert results[0].pid == "2296"
60+
assert results[0].elevation_ft == 14411
61+
62+
def test_search_empty_results(self):
63+
empty_html = "<html><body><h2>Peak Search Results</h2></body></html>"
64+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock(empty_html)):
65+
pb = PeakBagger()
66+
results = pb.search("xyznonexistent")
67+
68+
assert results == []
69+
70+
def test_search_passes_query_to_client(self):
71+
mock_client = _make_client_mock(SEARCH_HTML)
72+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client):
73+
pb = PeakBagger()
74+
pb.search("Denali")
75+
76+
mock_client.get.assert_called_once_with("/search.aspx", params={"ss": "Denali", "tid": "M"})
77+
78+
79+
class TestPeakBaggerGetPeak:
80+
def test_get_peak_returns_peak(self):
81+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock(PEAK_HTML)):
82+
pb = PeakBagger()
83+
peak = pb.get_peak("2296")
84+
85+
assert isinstance(peak, Peak)
86+
assert peak.pid == "2296"
87+
assert peak.name == "Mount Rainier"
88+
assert peak.elevation_ft == 14411
89+
90+
def test_get_peak_accepts_int_id(self):
91+
mock_client = _make_client_mock(PEAK_HTML)
92+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client):
93+
pb = PeakBagger()
94+
pb.get_peak(2296)
95+
96+
mock_client.get.assert_called_once_with("/peak.aspx", params={"pid": "2296"})
97+
98+
def test_get_peak_returns_none_on_bad_html(self):
99+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock("<html/>")):
100+
pb = PeakBagger()
101+
peak = pb.get_peak("9999")
102+
103+
assert peak is None
104+
105+
106+
class TestPeakBaggerGetAscents:
107+
ASCENTS_HTML = """<html><body>
108+
<table>
109+
<tr><td></td></tr>
110+
<tr><th>Climber</th><th>Ascent Date</th></tr>
111+
<tr>
112+
<td><a href="climber.aspx?cid=1">Alice</a></td>
113+
<td><a href="ascent.aspx?aid=100">2024-06-15</a></td>
114+
</tr>
115+
</table>
116+
</body></html>"""
117+
118+
def test_get_ascents_passes_correct_params(self):
119+
mock_client = _make_client_mock(self.ASCENTS_HTML)
120+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client):
121+
pb = PeakBagger()
122+
pb.get_ascents("2296")
123+
124+
mock_client.get.assert_called_once_with(
125+
"/climber/PeakAscents.aspx",
126+
params={"pid": "2296", "sort": "ascentdate", "u": "ft", "y": "9999"},
127+
)
128+
129+
def test_get_ascents_returns_list(self):
130+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock("<html/>")):
131+
pb = PeakBagger()
132+
result = pb.get_ascents("2296")
133+
134+
assert isinstance(result, list)
135+
136+
137+
class TestPeakBaggerGetAscent:
138+
def test_get_ascent_returns_ascent(self):
139+
with patch("peakbagger.api.PeakBaggerClient", return_value=_make_client_mock(ASCENT_HTML)):
140+
pb = PeakBagger()
141+
ascent = pb.get_ascent("12963")
142+
143+
assert isinstance(ascent, Ascent)
144+
assert ascent.ascent_id == "12963"
145+
assert ascent.climber_name == "John Doe"
146+
assert ascent.date == "2020-07-04"
147+
148+
def test_get_ascent_accepts_int_id(self):
149+
mock_client = _make_client_mock(ASCENT_HTML)
150+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client):
151+
pb = PeakBagger()
152+
pb.get_ascent(12963)
153+
154+
mock_client.get.assert_called_once_with("/climber/ascent.aspx", params={"aid": "12963"})
155+
156+
157+
class TestPeakBaggerLifecycle:
158+
def test_close_delegates_to_client(self):
159+
mock_client = _make_client_mock("")
160+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client):
161+
pb = PeakBagger()
162+
pb.close()
163+
164+
mock_client.close.assert_called_once()
165+
166+
def test_context_manager_closes_on_exit(self):
167+
mock_client = _make_client_mock("")
168+
with patch("peakbagger.api.PeakBaggerClient", return_value=mock_client), PeakBagger():
169+
pass
170+
171+
mock_client.close.assert_called_once()
172+
173+
def test_context_manager_closes_on_exception(self):
174+
mock_client = _make_client_mock("")
175+
with (
176+
patch("peakbagger.api.PeakBaggerClient", return_value=mock_client),
177+
pytest.raises(ValueError),
178+
PeakBagger(),
179+
):
180+
raise ValueError("test error")
181+
182+
mock_client.close.assert_called_once()
183+
184+
def test_custom_rate_limit_passed_to_client(self):
185+
with patch("peakbagger.api.PeakBaggerClient") as mock_cls:
186+
mock_cls.return_value = _make_client_mock("")
187+
PeakBagger(rate_limit_seconds=5.0)
188+
189+
mock_cls.assert_called_once_with(rate_limit_seconds=5.0)
190+
191+
192+
class TestPublicImports:
193+
def test_all_public_types_importable(self):
194+
from peakbagger import Ascent, AscentStatistics, Peak, PeakBagger, SearchResult
195+
196+
assert PeakBagger is not None
197+
assert Peak is not None
198+
assert SearchResult is not None
199+
assert Ascent is not None
200+
assert AscentStatistics is not None
201+
202+
def test_version_importable(self):
203+
from peakbagger import __version__
204+
205+
assert isinstance(__version__, str)
206+
assert len(__version__) > 0

0 commit comments

Comments
 (0)