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.
- yardage_hub-0.1.0/LICENSE +21 -0
- yardage_hub-0.1.0/PKG-INFO +162 -0
- yardage_hub-0.1.0/README.md +136 -0
- yardage_hub-0.1.0/setup.cfg +4 -0
- yardage_hub-0.1.0/setup.py +25 -0
- yardage_hub-0.1.0/src/yardage_hub/__init__.py +6 -0
- yardage_hub-0.1.0/src/yardage_hub/client.py +117 -0
- yardage_hub-0.1.0/src/yardage_hub.egg-info/PKG-INFO +162 -0
- yardage_hub-0.1.0/src/yardage_hub.egg-info/SOURCES.txt +10 -0
- yardage_hub-0.1.0/src/yardage_hub.egg-info/dependency_links.txt +1 -0
- yardage_hub-0.1.0/src/yardage_hub.egg-info/top_level.txt +1 -0
- yardage_hub-0.1.0/tests/test_client.py +97 -0
|
@@ -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,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,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
|
+
|
|
@@ -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()
|