yardage-hub 0.1.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 lukhed
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,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: yardage_hub
3
+ Version: 0.1.0
4
+ Summary: Python client for the yardage-hub golf course API
5
+ Home-page: https://github.com/lukhed/yardage-hub-python
6
+ Author: lukhed
7
+ Author-email: lukhed.mail@gmail.com
8
+ Project-URL: Homepage, https://lukeheide.com/sports/yardage-hub
9
+ Project-URL: Documentation, https://lukeheide.com/sports/yardage-hub/docs
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: license-file
23
+ Dynamic: project-url
24
+ Dynamic: requires-python
25
+ Dynamic: summary
26
+
27
+ # yardage-hub-python
28
+
29
+ A Python client for [yardage-hub](https://lukeheide.com/sports/yardage-hub), an open,
30
+ crowdsourced database of golf courses: where they are, and their scorecards (holes, par,
31
+ handicaps, tees, ratings, slopes, yardages). It starts from OpenStreetMap, and golfers fill in
32
+ the rest.
33
+
34
+ This README is a quick start. The full API documentation is at
35
+ **[lukeheide.com/sports/yardage-hub/docs](https://lukeheide.com/sports/yardage-hub/docs)**.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install yardage-hub
41
+ ```
42
+
43
+ Python 3.9+, no dependencies.
44
+
45
+ ## Get a key
46
+
47
+ Every call needs a key. Sign in at [lukeheide.com](https://lukeheide.com), open your profile
48
+ page, and generate one. It's shown once, so copy it somewhere safe; if you lose it, regenerate it.
49
+ Free keys allow 1,000 calls a day and 30 a minute.
50
+
51
+ ```python
52
+ from yardage_hub import YardageHub
53
+
54
+ hub = YardageHub("yh_...") # or set YARDAGE_HUB_KEY and call YardageHub()
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ # Find a course
61
+ found = hub.search_courses("pebble beach", state="CA")
62
+ course = hub.get_course(found["courses"][0]["id"])
63
+
64
+ print(course["course_name"], course["par"])
65
+ for tee in course["tee_sets"]:
66
+ print(tee["tee_name"], tee["yardage"], [(r["gender"], r["course_rating"], r["slope"]) for r in tee["ratings"]])
67
+ for hole in course["holes_data"]:
68
+ print(hole["number"], hole["par"], hole["handicap_index"], hole["yardages"])
69
+
70
+ # Courses near a point (km)
71
+ near = hub.search_courses(lat=42.28, lon=-83.74, radius_km=10)
72
+
73
+ # Courses that need help from contributors near you
74
+ help = hub.courses_needing_help(lat=42.28, lon=-83.74, radius_km=25)
75
+ for c in help["courses"]:
76
+ print(c["course_name"], [n["need"] for n in c["needs"]])
77
+ ```
78
+
79
+ Name search forgives abbreviations, punctuation, and typos: "st johns", "mt pleasant", and
80
+ "pebel beach" all find what you mean.
81
+
82
+ ## Methods
83
+
84
+ | Method | What it does |
85
+ |---|---|
86
+ | `search_courses(q, state, lat, lon, radius_km, kinds, page, page_size)` | Search by name, state, or distance. `kinds` defaults to `["course", "par3"]`; also `practice`, `disc`, `mini` |
87
+ | `get_course(course_id)` | One course: holes, tees and ratings, links, completeness. `None` for an unknown id |
88
+ | `course_edits(course_id, page_size, contributor_ref)` | A course's edit history, newest first |
89
+ | `courses_needing_help(state, lat, lon, radius_km, needs, page, page_size)` | Courses missing something a contributor can add, by state or near a point |
90
+ | `submit_edit(course_id, payload, note, evidence_url, contributor_ref, contributor_name)` | Propose a change (contact details, holes, tees, ratings, yardages, scorecard link) |
91
+ | `vote_edit(edit_id, vote, reason, contributor_ref, contributor_name)` | Back (`1`) or doubt (`-1`) a pending edit, or remove your vote (`0`) |
92
+ | `report_link(course_id, field, url, works, contributor_ref, contributor_name)` | Confirm a `website` or `scorecard_url` works, or report it dead |
93
+ | `report_course(course_id, kind, note, evidence_url, contributor_ref, contributor_name)` | Report a scorecard as `scorecard_changed`, `numbers_wrong`, or `other` |
94
+
95
+ Each method returns the API's JSON as Python dicts and lists. The fields are described in the
96
+ [docs](https://lukeheide.com/sports/yardage-hub/docs).
97
+
98
+ ## Writing: apply for write access
99
+
100
+ Every key can read. The last four methods send data to a review queue, and a person reviews
101
+ every edit, so they need a key with **write access**. New keys don't have it; a key without it
102
+ gets a 403.
103
+
104
+ 1. **Contribute on the site first.** Suggest edits on course pages at
105
+ [lukeheide.com/sports/yardage-hub](https://lukeheide.com/sports/yardage-hub).
106
+ 2. **Once 5 of your edits are approved**, apply on your profile page. Say what you'll write, e.g.
107
+ "building an app that lets people suggest edits".
108
+ 3. **The maintainer reviews the application.** Once it's granted, your key can call the write
109
+ methods.
110
+
111
+ Write access can be taken back. After a denial or revocation, you can apply again in 30 days.
112
+
113
+ An edit looks like this:
114
+
115
+ ```python
116
+ hub.submit_edit(course_id, {
117
+ "course": {"phone": "(415) 561-4661", "scorecard_url": "https://example.com/scorecard.pdf"},
118
+ "holes": [{"number": 1, "par": 4, "handicap": 7}],
119
+ "tees": [{"name": "Blue", "color": "blue", "gender": "M", "rating": 70.8, "slope": 132,
120
+ "yardages": {"1": 512, "2": 410}}],
121
+ }, note="From the 2026 scorecard")
122
+ ```
123
+
124
+ It's validated right away (a bad payload raises a 400) and waits as `pending` until it's
125
+ reviewed.
126
+
127
+ ## Errors
128
+
129
+ A failed call raises `YardageHubError`, with `status`, `message`, and `retry_after` (seconds,
130
+ after a 429):
131
+
132
+ ```python
133
+ from yardage_hub import YardageHubError
134
+
135
+ try:
136
+ hub.submit_edit(course_id, {"course": {"phone": "555-0100"}})
137
+ except YardageHubError as e:
138
+ if e.status == 403:
139
+ print("This key is read-only: apply for write access on your profile page")
140
+ elif e.status == 429:
141
+ print(f"Over a limit; retry in {e.retry_after} s")
142
+ else:
143
+ raise
144
+ ```
145
+
146
+ | Status | Meaning |
147
+ |---|---|
148
+ | 400 | Invalid input; the message says what |
149
+ | 401 | Missing, wrong, or revoked key |
150
+ | 403 | A read-only key tried to write |
151
+ | 404 | Unknown course or edit |
152
+ | 429 | Over the daily or per-minute limit |
153
+
154
+ ## Data and license
155
+
156
+ The data contains information from OpenStreetMap, © OpenStreetMap contributors, and is available
157
+ under the [ODbL](https://opendatacommons.org/licenses/odbl/1-0/). Credit it wherever you show
158
+ it: "© OpenStreetMap contributors, yardage-hub". For the whole dataset, download the weekly
159
+ snapshots from [lukhed/yardage-hub-data](https://github.com/lukhed/yardage-hub-data); please
160
+ don't harvest it through the API.
161
+
162
+ This client is MIT licensed.
@@ -0,0 +1,136 @@
1
+ # yardage-hub-python
2
+
3
+ A Python client for [yardage-hub](https://lukeheide.com/sports/yardage-hub), an open,
4
+ crowdsourced database of golf courses: where they are, and their scorecards (holes, par,
5
+ handicaps, tees, ratings, slopes, yardages). It starts from OpenStreetMap, and golfers fill in
6
+ the rest.
7
+
8
+ This README is a quick start. The full API documentation is at
9
+ **[lukeheide.com/sports/yardage-hub/docs](https://lukeheide.com/sports/yardage-hub/docs)**.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install yardage-hub
15
+ ```
16
+
17
+ Python 3.9+, no dependencies.
18
+
19
+ ## Get a key
20
+
21
+ Every call needs a key. Sign in at [lukeheide.com](https://lukeheide.com), open your profile
22
+ page, and generate one. It's shown once, so copy it somewhere safe; if you lose it, regenerate it.
23
+ Free keys allow 1,000 calls a day and 30 a minute.
24
+
25
+ ```python
26
+ from yardage_hub import YardageHub
27
+
28
+ hub = YardageHub("yh_...") # or set YARDAGE_HUB_KEY and call YardageHub()
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ # Find a course
35
+ found = hub.search_courses("pebble beach", state="CA")
36
+ course = hub.get_course(found["courses"][0]["id"])
37
+
38
+ print(course["course_name"], course["par"])
39
+ for tee in course["tee_sets"]:
40
+ print(tee["tee_name"], tee["yardage"], [(r["gender"], r["course_rating"], r["slope"]) for r in tee["ratings"]])
41
+ for hole in course["holes_data"]:
42
+ print(hole["number"], hole["par"], hole["handicap_index"], hole["yardages"])
43
+
44
+ # Courses near a point (km)
45
+ near = hub.search_courses(lat=42.28, lon=-83.74, radius_km=10)
46
+
47
+ # Courses that need help from contributors near you
48
+ help = hub.courses_needing_help(lat=42.28, lon=-83.74, radius_km=25)
49
+ for c in help["courses"]:
50
+ print(c["course_name"], [n["need"] for n in c["needs"]])
51
+ ```
52
+
53
+ Name search forgives abbreviations, punctuation, and typos: "st johns", "mt pleasant", and
54
+ "pebel beach" all find what you mean.
55
+
56
+ ## Methods
57
+
58
+ | Method | What it does |
59
+ |---|---|
60
+ | `search_courses(q, state, lat, lon, radius_km, kinds, page, page_size)` | Search by name, state, or distance. `kinds` defaults to `["course", "par3"]`; also `practice`, `disc`, `mini` |
61
+ | `get_course(course_id)` | One course: holes, tees and ratings, links, completeness. `None` for an unknown id |
62
+ | `course_edits(course_id, page_size, contributor_ref)` | A course's edit history, newest first |
63
+ | `courses_needing_help(state, lat, lon, radius_km, needs, page, page_size)` | Courses missing something a contributor can add, by state or near a point |
64
+ | `submit_edit(course_id, payload, note, evidence_url, contributor_ref, contributor_name)` | Propose a change (contact details, holes, tees, ratings, yardages, scorecard link) |
65
+ | `vote_edit(edit_id, vote, reason, contributor_ref, contributor_name)` | Back (`1`) or doubt (`-1`) a pending edit, or remove your vote (`0`) |
66
+ | `report_link(course_id, field, url, works, contributor_ref, contributor_name)` | Confirm a `website` or `scorecard_url` works, or report it dead |
67
+ | `report_course(course_id, kind, note, evidence_url, contributor_ref, contributor_name)` | Report a scorecard as `scorecard_changed`, `numbers_wrong`, or `other` |
68
+
69
+ Each method returns the API's JSON as Python dicts and lists. The fields are described in the
70
+ [docs](https://lukeheide.com/sports/yardage-hub/docs).
71
+
72
+ ## Writing: apply for write access
73
+
74
+ Every key can read. The last four methods send data to a review queue, and a person reviews
75
+ every edit, so they need a key with **write access**. New keys don't have it; a key without it
76
+ gets a 403.
77
+
78
+ 1. **Contribute on the site first.** Suggest edits on course pages at
79
+ [lukeheide.com/sports/yardage-hub](https://lukeheide.com/sports/yardage-hub).
80
+ 2. **Once 5 of your edits are approved**, apply on your profile page. Say what you'll write, e.g.
81
+ "building an app that lets people suggest edits".
82
+ 3. **The maintainer reviews the application.** Once it's granted, your key can call the write
83
+ methods.
84
+
85
+ Write access can be taken back. After a denial or revocation, you can apply again in 30 days.
86
+
87
+ An edit looks like this:
88
+
89
+ ```python
90
+ hub.submit_edit(course_id, {
91
+ "course": {"phone": "(415) 561-4661", "scorecard_url": "https://example.com/scorecard.pdf"},
92
+ "holes": [{"number": 1, "par": 4, "handicap": 7}],
93
+ "tees": [{"name": "Blue", "color": "blue", "gender": "M", "rating": 70.8, "slope": 132,
94
+ "yardages": {"1": 512, "2": 410}}],
95
+ }, note="From the 2026 scorecard")
96
+ ```
97
+
98
+ It's validated right away (a bad payload raises a 400) and waits as `pending` until it's
99
+ reviewed.
100
+
101
+ ## Errors
102
+
103
+ A failed call raises `YardageHubError`, with `status`, `message`, and `retry_after` (seconds,
104
+ after a 429):
105
+
106
+ ```python
107
+ from yardage_hub import YardageHubError
108
+
109
+ try:
110
+ hub.submit_edit(course_id, {"course": {"phone": "555-0100"}})
111
+ except YardageHubError as e:
112
+ if e.status == 403:
113
+ print("This key is read-only: apply for write access on your profile page")
114
+ elif e.status == 429:
115
+ print(f"Over a limit; retry in {e.retry_after} s")
116
+ else:
117
+ raise
118
+ ```
119
+
120
+ | Status | Meaning |
121
+ |---|---|
122
+ | 400 | Invalid input; the message says what |
123
+ | 401 | Missing, wrong, or revoked key |
124
+ | 403 | A read-only key tried to write |
125
+ | 404 | Unknown course or edit |
126
+ | 429 | Over the daily or per-minute limit |
127
+
128
+ ## Data and license
129
+
130
+ The data contains information from OpenStreetMap, © OpenStreetMap contributors, and is available
131
+ under the [ODbL](https://opendatacommons.org/licenses/odbl/1-0/). Credit it wherever you show
132
+ it: "© OpenStreetMap contributors, yardage-hub". For the whole dataset, download the weekly
133
+ snapshots from [lukhed/yardage-hub-data](https://github.com/lukhed/yardage-hub-data); please
134
+ don't harvest it through the API.
135
+
136
+ This client is MIT licensed.
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="yardage_hub",
5
+ version="0.1.0",
6
+ description="Python client for the yardage-hub golf course API",
7
+ long_description=open("README.md", encoding="utf-8").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="lukhed",
10
+ author_email="lukhed.mail@gmail.com",
11
+ url="https://github.com/lukhed/yardage-hub-python",
12
+ project_urls={
13
+ "Homepage": "https://lukeheide.com/sports/yardage-hub",
14
+ "Documentation": "https://lukeheide.com/sports/yardage-hub/docs",
15
+ },
16
+ package_dir={"": "src"},
17
+ packages=find_packages(where="src"),
18
+ python_requires=">=3.9",
19
+ classifiers=[
20
+ "Programming Language :: Python :: 3",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ ],
24
+ install_requires=[],
25
+ )
@@ -0,0 +1,6 @@
1
+ """Python client for the yardage-hub golf course API."""
2
+
3
+ from .client import BASE_URL, DOCS_URL, YardageHub, YardageHubError
4
+
5
+ __all__ = ["YardageHub", "YardageHubError", "BASE_URL", "DOCS_URL"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,117 @@
1
+ """A thin client for the yardage-hub public API: one method per route, returning the JSON."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ BASE_URL = "https://yardage-hub.lukhed.workers.dev/v1"
10
+ DOCS_URL = "https://lukeheide.com/sports/yardage-hub/docs"
11
+
12
+
13
+ class YardageHubError(Exception):
14
+ """An error response from the API.
15
+
16
+ `status` is the HTTP status (401 bad key, 403 read-only key, 404 unknown course, 429 over a
17
+ limit, ...), `message` the API's explanation, and `retry_after` the seconds to wait after a 429.
18
+ """
19
+
20
+ def __init__(self, status, message, retry_after=None):
21
+ super().__init__(f"{status}: {message}")
22
+ self.status = status
23
+ self.message = message
24
+ self.retry_after = int(retry_after) if retry_after and str(retry_after).isdigit() else None
25
+
26
+
27
+ class YardageHub:
28
+ """Calls the yardage-hub API with your key.
29
+
30
+ `key` defaults to the YARDAGE_HUB_KEY environment variable. Get a key from your profile page at
31
+ lukeheide.com.
32
+ """
33
+
34
+ def __init__(self, key=None, base_url=BASE_URL, timeout=30):
35
+ self.key = key or os.environ.get("YARDAGE_HUB_KEY")
36
+ if not self.key:
37
+ raise ValueError(f"pass key= or set YARDAGE_HUB_KEY; get a key at {DOCS_URL}")
38
+ self.base_url = base_url.rstrip("/")
39
+ self.timeout = timeout
40
+
41
+ # ── Reads ────────────────────────────────────────────────────────────────
42
+
43
+ def search_courses(self, q=None, state=None, lat=None, lon=None, radius_km=None, kinds=None,
44
+ page=None, page_size=None):
45
+ """Search courses by name, state, or distance. Give at least one of q, state, or lat and lon.
46
+ Returns { total, page, page_size, courses }."""
47
+ return self._get("/courses", q=q, state=state, lat=lat, lon=lon, radius_km=radius_km,
48
+ kinds=kinds, page=page, page_size=page_size)
49
+
50
+ def get_course(self, course_id):
51
+ """One course with its holes, tees, ratings, and links. None for an unknown id."""
52
+ return self._get(f"/courses/{course_id}")
53
+
54
+ def course_edits(self, course_id, page_size=None, contributor_ref=None):
55
+ """A course's edit history, newest first."""
56
+ return self._get(f"/courses/{course_id}/edits", page_size=page_size, contributor_ref=contributor_ref)
57
+
58
+ def courses_needing_help(self, state=None, lat=None, lon=None, radius_km=None, needs=None,
59
+ page=None, page_size=None):
60
+ """Courses where a contributor can help, by state or near a point. With no area, a sample.
61
+ Returns { total, page, page_size, courses, counts } (or { sample, page_size, courses })."""
62
+ return self._get("/help", state=state, lat=lat, lon=lon, radius_km=radius_km, needs=needs,
63
+ page=page, page_size=page_size)
64
+
65
+ # ── Writes (need a key with write access) ────────────────────────────────
66
+
67
+ def submit_edit(self, course_id, payload, note=None, evidence_url=None, contributor_ref=None,
68
+ contributor_name=None):
69
+ """Propose a change to a course. It waits for review. Returns { id, status }."""
70
+ return self._post(f"/courses/{course_id}/edits", payload=payload, note=note,
71
+ evidence_url=evidence_url, contributor_ref=contributor_ref,
72
+ contributor_name=contributor_name)
73
+
74
+ def vote_edit(self, edit_id, vote, reason=None, contributor_ref=None, contributor_name=None):
75
+ """Back (1) or doubt (-1) a pending edit, or remove your vote (0). Returns { up, down, my_vote }."""
76
+ return self._post(f"/edits/{edit_id}/votes", vote=vote, reason=reason,
77
+ contributor_ref=contributor_ref, contributor_name=contributor_name)
78
+
79
+ def report_link(self, course_id, field, url, works, contributor_ref=None, contributor_name=None):
80
+ """Confirm a course link works or report it dead. `field` is "scorecard_url" or "website",
81
+ and `url` its current value. Returns the link's status."""
82
+ return self._post(f"/courses/{course_id}/link-reports", field=field, url=url, works=works,
83
+ contributor_ref=contributor_ref, contributor_name=contributor_name)
84
+
85
+ def report_course(self, course_id, kind, note=None, evidence_url=None, contributor_ref=None,
86
+ contributor_name=None):
87
+ """Report a course's scorecard as wrong or out of date. `kind` is "scorecard_changed",
88
+ "numbers_wrong", or "other" (needs a note)."""
89
+ return self._post(f"/courses/{course_id}/reports", kind=kind, note=note, evidence_url=evidence_url,
90
+ contributor_ref=contributor_ref, contributor_name=contributor_name)
91
+
92
+ # ── HTTP ─────────────────────────────────────────────────────────────────
93
+
94
+ def _get(self, path, **params):
95
+ query = {k: ",".join(v) if isinstance(v, (list, tuple)) else v
96
+ for k, v in params.items() if v is not None}
97
+ url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
98
+ return self._send(urllib.request.Request(url, method="GET"))
99
+
100
+ def _post(self, path, **fields):
101
+ body = json.dumps({k: v for k, v in fields.items() if v is not None}).encode()
102
+ req = urllib.request.Request(self.base_url + path, data=body, method="POST")
103
+ req.add_header("Content-Type", "application/json")
104
+ return self._send(req)
105
+
106
+ def _send(self, req):
107
+ req.add_header("x-yardage-key", self.key)
108
+ req.add_header("User-Agent", "yardage-hub-python")
109
+ try:
110
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
111
+ return json.loads(resp.read() or b"null")
112
+ except urllib.error.HTTPError as e:
113
+ try:
114
+ message = json.loads(e.read() or b"{}").get("message") or e.reason
115
+ except ValueError:
116
+ message = e.reason
117
+ raise YardageHubError(e.code, message, e.headers.get("Retry-After")) from None
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: yardage_hub
3
+ Version: 0.1.0
4
+ Summary: Python client for the yardage-hub golf course API
5
+ Home-page: https://github.com/lukhed/yardage-hub-python
6
+ Author: lukhed
7
+ Author-email: lukhed.mail@gmail.com
8
+ Project-URL: Homepage, https://lukeheide.com/sports/yardage-hub
9
+ Project-URL: Documentation, https://lukeheide.com/sports/yardage-hub/docs
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: license-file
23
+ Dynamic: project-url
24
+ Dynamic: requires-python
25
+ Dynamic: summary
26
+
27
+ # yardage-hub-python
28
+
29
+ A Python client for [yardage-hub](https://lukeheide.com/sports/yardage-hub), an open,
30
+ crowdsourced database of golf courses: where they are, and their scorecards (holes, par,
31
+ handicaps, tees, ratings, slopes, yardages). It starts from OpenStreetMap, and golfers fill in
32
+ the rest.
33
+
34
+ This README is a quick start. The full API documentation is at
35
+ **[lukeheide.com/sports/yardage-hub/docs](https://lukeheide.com/sports/yardage-hub/docs)**.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install yardage-hub
41
+ ```
42
+
43
+ Python 3.9+, no dependencies.
44
+
45
+ ## Get a key
46
+
47
+ Every call needs a key. Sign in at [lukeheide.com](https://lukeheide.com), open your profile
48
+ page, and generate one. It's shown once, so copy it somewhere safe; if you lose it, regenerate it.
49
+ Free keys allow 1,000 calls a day and 30 a minute.
50
+
51
+ ```python
52
+ from yardage_hub import YardageHub
53
+
54
+ hub = YardageHub("yh_...") # or set YARDAGE_HUB_KEY and call YardageHub()
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ # Find a course
61
+ found = hub.search_courses("pebble beach", state="CA")
62
+ course = hub.get_course(found["courses"][0]["id"])
63
+
64
+ print(course["course_name"], course["par"])
65
+ for tee in course["tee_sets"]:
66
+ print(tee["tee_name"], tee["yardage"], [(r["gender"], r["course_rating"], r["slope"]) for r in tee["ratings"]])
67
+ for hole in course["holes_data"]:
68
+ print(hole["number"], hole["par"], hole["handicap_index"], hole["yardages"])
69
+
70
+ # Courses near a point (km)
71
+ near = hub.search_courses(lat=42.28, lon=-83.74, radius_km=10)
72
+
73
+ # Courses that need help from contributors near you
74
+ help = hub.courses_needing_help(lat=42.28, lon=-83.74, radius_km=25)
75
+ for c in help["courses"]:
76
+ print(c["course_name"], [n["need"] for n in c["needs"]])
77
+ ```
78
+
79
+ Name search forgives abbreviations, punctuation, and typos: "st johns", "mt pleasant", and
80
+ "pebel beach" all find what you mean.
81
+
82
+ ## Methods
83
+
84
+ | Method | What it does |
85
+ |---|---|
86
+ | `search_courses(q, state, lat, lon, radius_km, kinds, page, page_size)` | Search by name, state, or distance. `kinds` defaults to `["course", "par3"]`; also `practice`, `disc`, `mini` |
87
+ | `get_course(course_id)` | One course: holes, tees and ratings, links, completeness. `None` for an unknown id |
88
+ | `course_edits(course_id, page_size, contributor_ref)` | A course's edit history, newest first |
89
+ | `courses_needing_help(state, lat, lon, radius_km, needs, page, page_size)` | Courses missing something a contributor can add, by state or near a point |
90
+ | `submit_edit(course_id, payload, note, evidence_url, contributor_ref, contributor_name)` | Propose a change (contact details, holes, tees, ratings, yardages, scorecard link) |
91
+ | `vote_edit(edit_id, vote, reason, contributor_ref, contributor_name)` | Back (`1`) or doubt (`-1`) a pending edit, or remove your vote (`0`) |
92
+ | `report_link(course_id, field, url, works, contributor_ref, contributor_name)` | Confirm a `website` or `scorecard_url` works, or report it dead |
93
+ | `report_course(course_id, kind, note, evidence_url, contributor_ref, contributor_name)` | Report a scorecard as `scorecard_changed`, `numbers_wrong`, or `other` |
94
+
95
+ Each method returns the API's JSON as Python dicts and lists. The fields are described in the
96
+ [docs](https://lukeheide.com/sports/yardage-hub/docs).
97
+
98
+ ## Writing: apply for write access
99
+
100
+ Every key can read. The last four methods send data to a review queue, and a person reviews
101
+ every edit, so they need a key with **write access**. New keys don't have it; a key without it
102
+ gets a 403.
103
+
104
+ 1. **Contribute on the site first.** Suggest edits on course pages at
105
+ [lukeheide.com/sports/yardage-hub](https://lukeheide.com/sports/yardage-hub).
106
+ 2. **Once 5 of your edits are approved**, apply on your profile page. Say what you'll write, e.g.
107
+ "building an app that lets people suggest edits".
108
+ 3. **The maintainer reviews the application.** Once it's granted, your key can call the write
109
+ methods.
110
+
111
+ Write access can be taken back. After a denial or revocation, you can apply again in 30 days.
112
+
113
+ An edit looks like this:
114
+
115
+ ```python
116
+ hub.submit_edit(course_id, {
117
+ "course": {"phone": "(415) 561-4661", "scorecard_url": "https://example.com/scorecard.pdf"},
118
+ "holes": [{"number": 1, "par": 4, "handicap": 7}],
119
+ "tees": [{"name": "Blue", "color": "blue", "gender": "M", "rating": 70.8, "slope": 132,
120
+ "yardages": {"1": 512, "2": 410}}],
121
+ }, note="From the 2026 scorecard")
122
+ ```
123
+
124
+ It's validated right away (a bad payload raises a 400) and waits as `pending` until it's
125
+ reviewed.
126
+
127
+ ## Errors
128
+
129
+ A failed call raises `YardageHubError`, with `status`, `message`, and `retry_after` (seconds,
130
+ after a 429):
131
+
132
+ ```python
133
+ from yardage_hub import YardageHubError
134
+
135
+ try:
136
+ hub.submit_edit(course_id, {"course": {"phone": "555-0100"}})
137
+ except YardageHubError as e:
138
+ if e.status == 403:
139
+ print("This key is read-only: apply for write access on your profile page")
140
+ elif e.status == 429:
141
+ print(f"Over a limit; retry in {e.retry_after} s")
142
+ else:
143
+ raise
144
+ ```
145
+
146
+ | Status | Meaning |
147
+ |---|---|
148
+ | 400 | Invalid input; the message says what |
149
+ | 401 | Missing, wrong, or revoked key |
150
+ | 403 | A read-only key tried to write |
151
+ | 404 | Unknown course or edit |
152
+ | 429 | Over the daily or per-minute limit |
153
+
154
+ ## Data and license
155
+
156
+ The data contains information from OpenStreetMap, © OpenStreetMap contributors, and is available
157
+ under the [ODbL](https://opendatacommons.org/licenses/odbl/1-0/). Credit it wherever you show
158
+ it: "© OpenStreetMap contributors, yardage-hub". For the whole dataset, download the weekly
159
+ snapshots from [lukhed/yardage-hub-data](https://github.com/lukhed/yardage-hub-data); please
160
+ don't harvest it through the API.
161
+
162
+ This client is MIT licensed.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ src/yardage_hub/__init__.py
5
+ src/yardage_hub/client.py
6
+ src/yardage_hub.egg-info/PKG-INFO
7
+ src/yardage_hub.egg-info/SOURCES.txt
8
+ src/yardage_hub.egg-info/dependency_links.txt
9
+ src/yardage_hub.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ yardage_hub
@@ -0,0 +1,97 @@
1
+ """The client builds the right request for each route and turns errors into YardageHubError.
2
+ No network: urlopen is replaced. `python -m unittest` from the repo root."""
3
+
4
+ import io
5
+ import json
6
+ import sys
7
+ import unittest
8
+ import urllib.error
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
13
+
14
+ from yardage_hub import YardageHub, YardageHubError # noqa: E402
15
+
16
+ BASE = "https://yardage-hub.lukhed.workers.dev/v1"
17
+ ID = "3f2c9a1e-0000-4000-8000-000000000001"
18
+
19
+
20
+ class Response(io.BytesIO):
21
+ def __enter__(self):
22
+ return self
23
+
24
+ def __exit__(self, *exc):
25
+ return False
26
+
27
+
28
+ class ClientTest(unittest.TestCase):
29
+ def setUp(self):
30
+ self.sent = []
31
+ self.reply = {"ok": True}
32
+
33
+ def urlopen(req, timeout):
34
+ self.sent.append(req)
35
+ return Response(json.dumps(self.reply).encode())
36
+
37
+ patcher = mock.patch("urllib.request.urlopen", urlopen)
38
+ patcher.start()
39
+ self.addCleanup(patcher.stop)
40
+ self.hub = YardageHub("yh_test")
41
+
42
+ def last(self):
43
+ req = self.sent[-1]
44
+ body = json.loads(req.data) if req.data else None
45
+ return req.get_method(), req.full_url, body
46
+
47
+ def test_reads_are_gets_with_a_query_string(self):
48
+ self.hub.search_courses("presidio", state="CA", kinds=["course", "par3"], page_size=5)
49
+ self.assertEqual(self.last(), ("GET", f"{BASE}/courses?q=presidio&state=CA&kinds=course%2Cpar3&page_size=5", None))
50
+ self.hub.get_course(ID)
51
+ self.assertEqual(self.last()[:2], ("GET", f"{BASE}/courses/{ID}"))
52
+ self.hub.course_edits(ID, page_size=10)
53
+ self.assertEqual(self.last()[1], f"{BASE}/courses/{ID}/edits?page_size=10")
54
+ self.hub.courses_needing_help(lat=42.28, lon=-83.74, radius_km=25, needs=["no_website"])
55
+ self.assertEqual(self.last()[1], f"{BASE}/help?lat=42.28&lon=-83.74&radius_km=25&needs=no_website")
56
+ self.hub.courses_needing_help()
57
+ self.assertEqual(self.last()[1], f"{BASE}/help")
58
+
59
+ def test_writes_post_json_without_unset_fields(self):
60
+ self.hub.submit_edit(ID, {"course": {"phone": "555"}}, note="from the card")
61
+ self.assertEqual(self.last(), ("POST", f"{BASE}/courses/{ID}/edits",
62
+ {"payload": {"course": {"phone": "555"}}, "note": "from the card"}))
63
+ self.hub.vote_edit(ID, 1)
64
+ self.assertEqual(self.last(), ("POST", f"{BASE}/edits/{ID}/votes", {"vote": 1}))
65
+ self.hub.report_link(ID, "website", "https://a.example/", False)
66
+ self.assertEqual(self.last()[2], {"field": "website", "url": "https://a.example/", "works": False})
67
+ self.hub.report_course(ID, "numbers_wrong", note="hole 4")
68
+ self.assertEqual(self.last()[1:], (f"{BASE}/courses/{ID}/reports", {"kind": "numbers_wrong", "note": "hole 4"}))
69
+
70
+ def test_every_call_carries_the_key(self):
71
+ self.hub.get_course(ID)
72
+ self.assertEqual(self.sent[-1].get_header("X-yardage-key"), "yh_test")
73
+
74
+ def test_returns_the_json(self):
75
+ self.reply = {"total": 1, "courses": []}
76
+ self.assertEqual(self.hub.search_courses("x"), {"total": 1, "courses": []})
77
+
78
+ def test_errors_carry_status_message_and_retry_after(self):
79
+ def refuse(req, timeout):
80
+ raise urllib.error.HTTPError(req.full_url, 429, "Too Many Requests", {"Retry-After": "30"},
81
+ io.BytesIO(b'{"message": "daily quota used; resets at 00:00 UTC"}'))
82
+
83
+ with mock.patch("urllib.request.urlopen", refuse):
84
+ with self.assertRaises(YardageHubError) as e:
85
+ self.hub.search_courses("x")
86
+ self.assertEqual((e.exception.status, e.exception.retry_after), (429, 30))
87
+ self.assertIn("daily quota", e.exception.message)
88
+
89
+ def test_needs_a_key(self):
90
+ with mock.patch.dict("os.environ", {}, clear=True):
91
+ self.assertRaises(ValueError, YardageHub)
92
+ with mock.patch.dict("os.environ", {"YARDAGE_HUB_KEY": "yh_env"}):
93
+ self.assertEqual(YardageHub().key, "yh_env")
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()