openvan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openvan/__init__.py +9 -0
- openvan/api.py +332 -0
- openvan/client.py +89 -0
- openvan/py.typed +0 -0
- openvan-0.1.0.dist-info/METADATA +114 -0
- openvan-0.1.0.dist-info/RECORD +8 -0
- openvan-0.1.0.dist-info/WHEEL +4 -0
- openvan-0.1.0.dist-info/licenses/LICENSE +21 -0
openvan/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""OpenVan.camp — free vanlife / RV travel data API client. No API key.
|
|
2
|
+
|
|
3
|
+
https://openvan.camp/docs · data CC BY 4.0
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .api import OpenVan
|
|
7
|
+
from .client import OpenVanClient, OpenVanError, __version__
|
|
8
|
+
|
|
9
|
+
__all__ = ["OpenVan", "OpenVanClient", "OpenVanError", "__version__"]
|
openvan/api.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""OpenVan.camp resources — the same surface as the JavaScript SDK, in snake_case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .client import DEFAULT_BASE_URL, OpenVanClient, OpenVanError
|
|
10
|
+
|
|
11
|
+
LITERS_PER_GALLON = 3.78541
|
|
12
|
+
|
|
13
|
+
JSON = Dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenVan:
|
|
17
|
+
"""OpenVan.camp — free vanlife / RV travel data. No API key.
|
|
18
|
+
|
|
19
|
+
>>> from openvan import OpenVan
|
|
20
|
+
>>> ov = OpenVan()
|
|
21
|
+
>>> ov.fuel.country("DE")["prices"]["diesel"]
|
|
22
|
+
>>> ov.visa.check("RU", "TR")
|
|
23
|
+
>>> ov.tolls.route(["Rome", "Paris"])
|
|
24
|
+
|
|
25
|
+
Data is CC BY 4.0 — credit "Data: OpenVan.camp" with a link to https://openvan.camp/.
|
|
26
|
+
Pass ``source="yourapp.com"`` so we can see and acknowledge your project.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
source: str = "openvan-python",
|
|
32
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
33
|
+
timeout: float = 30.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.client = OpenVanClient(base_url=base_url, source=source, timeout=timeout)
|
|
36
|
+
self.fuel = Fuel(self.client)
|
|
37
|
+
self.currency = Currency(self.client)
|
|
38
|
+
self.basket = Basket(self.client)
|
|
39
|
+
self.weather = Weather(self.client)
|
|
40
|
+
self.events = Events(self.client)
|
|
41
|
+
self.stories = Stories(self.client)
|
|
42
|
+
self.tolls = Tolls(self.client)
|
|
43
|
+
self.visa = Visa(self.client)
|
|
44
|
+
self.plates = Plates(self.client)
|
|
45
|
+
self.holidays = Holidays(self.client)
|
|
46
|
+
self.hazards = Hazards(self.client)
|
|
47
|
+
self.electricity = Electricity(self.client)
|
|
48
|
+
self.customs = Customs(self.client)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Resource:
|
|
52
|
+
def __init__(self, client: OpenVanClient) -> None:
|
|
53
|
+
self.client = client
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Fuel(_Resource):
|
|
57
|
+
def prices(self) -> Dict[str, JSON]:
|
|
58
|
+
"""Retail fuel prices of every country: ISO code → country entry."""
|
|
59
|
+
return self.client.get("/api/fuel/prices")["data"]
|
|
60
|
+
|
|
61
|
+
def country(self, code: str) -> JSON:
|
|
62
|
+
"""Fuel prices of one country."""
|
|
63
|
+
entry = self.prices().get(code.upper())
|
|
64
|
+
if entry is None:
|
|
65
|
+
raise KeyError(f'No fuel data for country code "{code}"')
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
def cheapest(self, fuel_type: str = "diesel", limit: int = 10) -> List[JSON]:
|
|
69
|
+
"""Cheapest countries for a fuel type, in EUR per liter, cheapest first.
|
|
70
|
+
|
|
71
|
+
Each grade is converted with its own currency (``currencies``), gallon prices are
|
|
72
|
+
normalized to liters, and countries whose currency has no rate are left out.
|
|
73
|
+
"""
|
|
74
|
+
prices = self.prices()
|
|
75
|
+
try:
|
|
76
|
+
rates = self.client.get("/api/currency/rates")["rates"]
|
|
77
|
+
except OpenVanError:
|
|
78
|
+
rates = {}
|
|
79
|
+
|
|
80
|
+
def eur_per_liter(country: JSON) -> float:
|
|
81
|
+
price = (country.get("prices") or {}).get(fuel_type)
|
|
82
|
+
if price is None:
|
|
83
|
+
return math.nan
|
|
84
|
+
currency = ((country.get("currencies") or {}).get(fuel_type) or country.get("currency") or "").upper()
|
|
85
|
+
rate = 1 if currency == "EUR" else rates.get(currency)
|
|
86
|
+
if not rate:
|
|
87
|
+
return math.nan
|
|
88
|
+
eur = price / rate
|
|
89
|
+
return eur / LITERS_PER_GALLON if "gal" in str(country.get("unit", "")).lower() else eur
|
|
90
|
+
|
|
91
|
+
ranked = [(eur_per_liter(c), c) for c in prices.values()]
|
|
92
|
+
ranked = [x for x in ranked if not math.isnan(x[0])]
|
|
93
|
+
ranked.sort(key=lambda x: x[0])
|
|
94
|
+
return [c for _, c in ranked[:limit]]
|
|
95
|
+
|
|
96
|
+
def route_cost(self, waypoints: Sequence[str], **options: Any) -> JSON:
|
|
97
|
+
"""Fuel cost for a route of 2–10 place names, with per-country prices along the way.
|
|
98
|
+
|
|
99
|
+
Options: ``fuel`` ("diesel", "gasoline", …), ``cons`` (l/100 km), ``tank`` (l),
|
|
100
|
+
``currency``, ``locale``.
|
|
101
|
+
"""
|
|
102
|
+
return self.client.post("/api/route-cost", {"waypoints": list(waypoints), **options})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Currency(_Resource):
|
|
106
|
+
def rates(self) -> Dict[str, float]:
|
|
107
|
+
"""Exchange rates relative to EUR (1 EUR = rate units)."""
|
|
108
|
+
return self.client.get("/api/currency/rates")["rates"]
|
|
109
|
+
|
|
110
|
+
def convert(self, amount: float, from_currency: str, to_currency: str) -> float:
|
|
111
|
+
"""Convert an amount between two currencies via EUR."""
|
|
112
|
+
rates = self.rates()
|
|
113
|
+
|
|
114
|
+
def rate(code: str) -> float:
|
|
115
|
+
code = code.upper()
|
|
116
|
+
value = 1 if code == "EUR" else rates.get(code)
|
|
117
|
+
if not value:
|
|
118
|
+
raise KeyError(f"Unknown currency: {code}")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
return amount / rate(from_currency) * rate(to_currency)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Basket(_Resource):
|
|
125
|
+
def list(self) -> Dict[str, JSON]:
|
|
126
|
+
"""Food cost index of every country (world average = 100)."""
|
|
127
|
+
return self.client.get("/api/vanbasket/countries")["data"]
|
|
128
|
+
|
|
129
|
+
def country(self, code: str) -> JSON:
|
|
130
|
+
"""Food cost index of one country with historical snapshots."""
|
|
131
|
+
return self.client.get(f"/api/vanbasket/countries/{code.upper()}")["data"]
|
|
132
|
+
|
|
133
|
+
def compare(self, from_country: str, to_country: str) -> JSON:
|
|
134
|
+
"""Compare food cost between two countries."""
|
|
135
|
+
return self.client.get(
|
|
136
|
+
"/api/vanbasket/compare", {"from": from_country.upper(), "to": to_country.upper()}
|
|
137
|
+
)["data"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Weather(_Resource):
|
|
141
|
+
def score(self, code: str) -> JSON:
|
|
142
|
+
"""Vanlife weather of one country: ``van_score`` 0–100, ``score_label``, 7-day ``forecast``."""
|
|
143
|
+
return self.client.get(f"/api/vansky/weather/{code.upper()}")["data"]
|
|
144
|
+
|
|
145
|
+
def all(self) -> List[JSON]:
|
|
146
|
+
"""Every country with weather data (one request, ~1 MB)."""
|
|
147
|
+
return self.client.get("/api/vansky/weather")["data"]
|
|
148
|
+
|
|
149
|
+
def top(self, limit: int = 10) -> List[JSON]:
|
|
150
|
+
"""Top countries by vanlife weather suitability today, best first."""
|
|
151
|
+
return sorted(self.all(), key=lambda c: c.get("van_score") or 0, reverse=True)[:limit]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Events(_Resource):
|
|
155
|
+
def list(self, **filters: Any) -> JSON:
|
|
156
|
+
"""Vanlife events. Filters: ``locale``, ``status``, ``type``, ``country``, ``search``, ``page``, ``limit``.
|
|
157
|
+
|
|
158
|
+
Returns ``{"events": [...], "pagination": {...}}``.
|
|
159
|
+
"""
|
|
160
|
+
res = self.client.get("/api/events", filters)
|
|
161
|
+
return {"events": res["events"], "pagination": res["pagination"]}
|
|
162
|
+
|
|
163
|
+
def get(self, slug: str, locale: Optional[str] = None) -> JSON:
|
|
164
|
+
"""One event with geo coordinates."""
|
|
165
|
+
return self.client.get(f"/api/event/{quote(slug)}", {"locale": locale})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Stories(_Resource):
|
|
169
|
+
def list(self, **filters: Any) -> JSON:
|
|
170
|
+
"""News stories. Filters: ``locale``, ``category``, ``country``, ``search``, ``page``, ``limit``.
|
|
171
|
+
|
|
172
|
+
Returns ``{"stories": [...], "pagination": {...}}``.
|
|
173
|
+
"""
|
|
174
|
+
res = self.client.get("/api/stories", filters)
|
|
175
|
+
return {"stories": res["stories"], "pagination": res["pagination"]}
|
|
176
|
+
|
|
177
|
+
def get(self, slug: str, locale: Optional[str] = None) -> JSON:
|
|
178
|
+
"""One story with all source articles and direct links."""
|
|
179
|
+
return self.client.get(f"/api/story/{quote(slug)}", {"locale": locale})
|
|
180
|
+
|
|
181
|
+
def search(self, query: str, **options: Any) -> JSON:
|
|
182
|
+
"""Semantic search over stories."""
|
|
183
|
+
return self.client.get("/api/news/search", {"q": query, **options})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Tolls(_Resource):
|
|
187
|
+
def countries(self, locale: Optional[str] = None) -> JSON:
|
|
188
|
+
"""Countries with toll data: payment system, per-km rates by vehicle class, vignettes."""
|
|
189
|
+
return self.client.get("/api/tolls/countries", {"locale": locale})
|
|
190
|
+
|
|
191
|
+
def country(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
192
|
+
"""Toll reference for one country: rates, vignettes, concession sections, bridges and tunnels."""
|
|
193
|
+
return self.client.get(f"/api/tolls/countries/{code.upper()}", {"locale": locale})
|
|
194
|
+
|
|
195
|
+
def route(self, waypoints: Sequence[str], vehicle_class: str = "van", locale: Optional[str] = None) -> JSON:
|
|
196
|
+
"""Toll cost for a route of 2–10 place names as a EUR range.
|
|
197
|
+
|
|
198
|
+
Check ``partial`` and ``unknown_countries``: a country without data is not a free country.
|
|
199
|
+
"""
|
|
200
|
+
return self.client.get(
|
|
201
|
+
"/api/tolls/route",
|
|
202
|
+
{"waypoints": "|".join(waypoints), "vehicle_class": vehicle_class, "locale": locale},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Visa(_Resource):
|
|
207
|
+
def check(self, passport: str, destination: str, **options: Any) -> JSON:
|
|
208
|
+
"""Entry rules for one passport and destination. Options: ``weight``, ``plate``, ``locale``."""
|
|
209
|
+
return self.client.get(
|
|
210
|
+
"/api/visa/check", {"passport": passport.upper(), "destination": destination.upper(), **options}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def route(
|
|
214
|
+
self,
|
|
215
|
+
countries: Sequence[str],
|
|
216
|
+
passports: Sequence[str] = (),
|
|
217
|
+
weight: Optional[str] = None,
|
|
218
|
+
plate: Optional[str] = None,
|
|
219
|
+
locale: Optional[str] = None,
|
|
220
|
+
) -> JSON:
|
|
221
|
+
"""Visa rules for a whole route (countries in travel order) for up to 10 passports."""
|
|
222
|
+
return self.client.get(
|
|
223
|
+
"/api/visa/route",
|
|
224
|
+
{
|
|
225
|
+
"t": ",".join(countries),
|
|
226
|
+
"p": ",".join(passports) or None,
|
|
227
|
+
"w": weight,
|
|
228
|
+
"plate": plate,
|
|
229
|
+
"locale": locale,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def passport(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
234
|
+
"""Every destination for one passport."""
|
|
235
|
+
return self.client.get(f"/api/visa/passport/{code.upper()}", {"locale": locale})
|
|
236
|
+
|
|
237
|
+
def vehicle(self, place: str, locale: Optional[str] = None) -> JSON:
|
|
238
|
+
"""Temporary import rules for a foreign-plated vehicle in a country."""
|
|
239
|
+
return self.client.get(f"/api/visa/vehicle/{quote(place)}", {"locale": locale})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Plates(_Resource):
|
|
243
|
+
def list(self, locale: Optional[str] = None) -> JSON:
|
|
244
|
+
"""Countries with license plate data: international code, regions, example plate."""
|
|
245
|
+
return self.client.get("/api/plates", {"locale": locale})
|
|
246
|
+
|
|
247
|
+
def country(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
248
|
+
"""Plate format and every region code of one country."""
|
|
249
|
+
return self.client.get(f"/api/plates/{code.lower()}", {"locale": locale})
|
|
250
|
+
|
|
251
|
+
def types(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
252
|
+
"""Plate types (private, taxi, diplomatic…). Empty for countries without a breakdown."""
|
|
253
|
+
return self.client.get(f"/api/plates/{code.lower()}/types", {"locale": locale})
|
|
254
|
+
|
|
255
|
+
def validate(self, code: str, number: str, **options: Any) -> JSON:
|
|
256
|
+
"""Validate a plate number and resolve its region. Options: ``region``, ``type``, ``locale``."""
|
|
257
|
+
return self.client.get(f"/api/plates/{code.lower()}/validate", {"number": number, **options})
|
|
258
|
+
|
|
259
|
+
def random(self, code: str, type: Optional[str] = None) -> JSON:
|
|
260
|
+
"""A random valid plate with image URLs."""
|
|
261
|
+
return self.client.get(f"/api/plates/{code.lower()}/random", {"type": type})
|
|
262
|
+
|
|
263
|
+
def image_url(
|
|
264
|
+
self,
|
|
265
|
+
code: str,
|
|
266
|
+
number: str,
|
|
267
|
+
format: str = "svg",
|
|
268
|
+
region: Optional[str] = None,
|
|
269
|
+
type: Optional[str] = None,
|
|
270
|
+
width: Optional[int] = None,
|
|
271
|
+
) -> str:
|
|
272
|
+
"""URL of a ready plate image (SVG or PNG). No request is made."""
|
|
273
|
+
return self.client.url(
|
|
274
|
+
f"/api/plates/{code.lower()}/plate.{format}",
|
|
275
|
+
{"number": number, "region": region, "type": type, "width": width if format == "png" else None},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Holidays(_Resource):
|
|
280
|
+
def countries(self, locale: Optional[str] = None) -> JSON:
|
|
281
|
+
"""Countries with holiday data."""
|
|
282
|
+
return self.client.get("/api/holidays/countries", {"locale": locale})
|
|
283
|
+
|
|
284
|
+
def country(self, code: str, **options: Any) -> JSON:
|
|
285
|
+
"""Public holidays, school holidays (ISO 3166-2 regions) and peak traffic days.
|
|
286
|
+
|
|
287
|
+
Options: ``from_`` / ``to`` (YYYY-MM-DD, default today + 90 days), ``kind``
|
|
288
|
+
("public", "school" or "traffic"), ``locale``.
|
|
289
|
+
A country without data raises — that is not the same as "no holidays".
|
|
290
|
+
"""
|
|
291
|
+
if "from_" in options:
|
|
292
|
+
options["from"] = options.pop("from_")
|
|
293
|
+
return self.client.get(f"/api/holidays/countries/{code.upper()}", options)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Hazards(_Resource):
|
|
297
|
+
def country(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
298
|
+
"""UK FCDO travel advice level and current GDACS natural disasters. Not a forecast."""
|
|
299
|
+
return self.client.get(f"/api/hazards/countries/{code.upper()}", {"locale": locale})
|
|
300
|
+
|
|
301
|
+
def fires(self, bbox: Tuple[float, float, float, float]) -> JSON:
|
|
302
|
+
"""NASA FIRMS fires of the last 48 hours in ``(min_lon, min_lat, max_lon, max_lat)``, up to 10°×10°.
|
|
303
|
+
|
|
304
|
+
A new area may answer 503 "being loaded, retry in a minute".
|
|
305
|
+
"""
|
|
306
|
+
return self.client.get("/api/hazards/fires", {"bbox": ",".join(str(v) for v in bbox)})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class Electricity(_Resource):
|
|
310
|
+
def countries(self, locale: Optional[str] = None) -> JSON:
|
|
311
|
+
"""Plug types and mains voltage of every country."""
|
|
312
|
+
return self.client.get("/api/electricity/countries", {"locale": locale})
|
|
313
|
+
|
|
314
|
+
def country(self, code: str, locale: Optional[str] = None) -> JSON:
|
|
315
|
+
"""Plug types (IEC A–N), voltage, frequency and campsite hook-up connector of one country."""
|
|
316
|
+
return self.client.get(f"/api/electricity/countries/{code.upper()}", {"locale": locale})
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Customs(_Resource):
|
|
320
|
+
def countries(self, locale: Optional[str] = None) -> JSON:
|
|
321
|
+
"""Customs jurisdictions and blocs."""
|
|
322
|
+
return self.client.get("/api/customs/countries", {"locale": locale})
|
|
323
|
+
|
|
324
|
+
def country(self, code: str, from_country: Optional[str] = None, locale: Optional[str] = None) -> JSON:
|
|
325
|
+
"""Customs rules on entry by car, each with an official quote.
|
|
326
|
+
|
|
327
|
+
A country without data raises — that is not the same as "nothing is restricted".
|
|
328
|
+
"""
|
|
329
|
+
return self.client.get(
|
|
330
|
+
f"/api/customs/countries/{code.upper()}",
|
|
331
|
+
{"from": from_country.upper() if from_country else None, "locale": locale},
|
|
332
|
+
)
|
openvan/client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""HTTP transport for the OpenVan.camp API — standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://openvan.camp"
|
|
14
|
+
|
|
15
|
+
Query = Mapping[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenVanError(Exception):
|
|
19
|
+
"""An API request failed. ``message`` is the API's own explanation when it gave one."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status: int, url: str, body: Any = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.url = url
|
|
26
|
+
self.body = body
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenVanClient:
|
|
30
|
+
"""Low-level client: builds URLs with the attribution tag and decodes JSON."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
35
|
+
source: str = "openvan-python",
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.base_url = base_url.rstrip("/")
|
|
39
|
+
self.source = source
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
|
|
42
|
+
def url(self, path: str, query: Optional[Query] = None) -> str:
|
|
43
|
+
"""Absolute API URL; empty values are dropped and ``source`` is always added."""
|
|
44
|
+
params = {k: _query_value(v) for k, v in (query or {}).items() if v is not None and v != ""}
|
|
45
|
+
params["source"] = self.source
|
|
46
|
+
return f"{self.base_url}{path}?{urlencode(params)}"
|
|
47
|
+
|
|
48
|
+
def get(self, path: str, query: Optional[Query] = None) -> Any:
|
|
49
|
+
return self._request(Request(self.url(path, query), method="GET"))
|
|
50
|
+
|
|
51
|
+
def post(self, path: str, body: Any) -> Any:
|
|
52
|
+
request = Request(
|
|
53
|
+
self.url(path),
|
|
54
|
+
data=json.dumps(body).encode(),
|
|
55
|
+
method="POST",
|
|
56
|
+
headers={"Content-Type": "application/json"},
|
|
57
|
+
)
|
|
58
|
+
return self._request(request)
|
|
59
|
+
|
|
60
|
+
def _request(self, request: Request) -> Any:
|
|
61
|
+
request.add_header("Accept", "application/json")
|
|
62
|
+
request.add_header("User-Agent", f"openvan-python/{__version__}")
|
|
63
|
+
try:
|
|
64
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
65
|
+
return json.loads(response.read().decode())
|
|
66
|
+
except HTTPError as error:
|
|
67
|
+
# The API explains most errors in the body ("Place not found: …"); keep that message.
|
|
68
|
+
body: Any = None
|
|
69
|
+
try:
|
|
70
|
+
body = json.loads(error.read().decode())
|
|
71
|
+
except (ValueError, OSError):
|
|
72
|
+
pass
|
|
73
|
+
message = None
|
|
74
|
+
if isinstance(body, dict):
|
|
75
|
+
message = body.get("message") or body.get("error")
|
|
76
|
+
raise OpenVanError(
|
|
77
|
+
message if isinstance(message, str) else f"HTTP {error.code}: {error.reason}",
|
|
78
|
+
error.code,
|
|
79
|
+
request.full_url,
|
|
80
|
+
body,
|
|
81
|
+
) from None
|
|
82
|
+
except URLError as error:
|
|
83
|
+
raise OpenVanError(f"Network error: {error.reason}", 0, request.full_url) from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _query_value(value: Any) -> str:
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return "1" if value else "0"
|
|
89
|
+
return str(value)
|
openvan/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openvan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Free vanlife & RV travel data: fuel prices in 160+ countries, toll roads, visa rules, holidays, travel hazards, weather scores, currency. No API key.
|
|
5
|
+
Project-URL: Homepage, https://openvan.camp/en/developers?utm_source=pypi&utm_medium=referral&utm_campaign=python-sdk
|
|
6
|
+
Project-URL: Documentation, https://openvan.camp/docs?utm_source=pypi&utm_medium=referral&utm_campaign=python-sdk
|
|
7
|
+
Project-URL: Source, https://github.com/openvancamp/openvan-camp-public-api/tree/main/python-sdk
|
|
8
|
+
Project-URL: Changelog, https://github.com/openvancamp/openvan-camp-public-api/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/openvancamp/openvan-camp-public-api/issues
|
|
10
|
+
Author-email: "OpenVan.camp" <hello@openvan.camp>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api-client,campervan,currency,fuel-prices,gas-prices,holidays,motorhome,open-data,road-trip,rv,toll-roads,travel,vanlife,visa,weather
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# openvan — Python client for the OpenVan.camp API
|
|
26
|
+
|
|
27
|
+
Free vanlife & RV travel data in Python: fuel prices in 160+ countries, toll roads, visa and
|
|
28
|
+
vehicle-import rules, public and school holidays, travel hazards and wildfires, customs rules,
|
|
29
|
+
power plugs, weather suitability scores, food cost index, currency rates, events, news and
|
|
30
|
+
license plates of the world.
|
|
31
|
+
|
|
32
|
+
**No API key, no registration, no dependencies** — standard library only, Python 3.9+.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install openvan
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from openvan import OpenVan
|
|
42
|
+
|
|
43
|
+
ov = OpenVan(source="myapp.com") # source is optional: it lets us see and credit your project
|
|
44
|
+
|
|
45
|
+
# Cheapest diesel in the world right now, EUR per liter
|
|
46
|
+
for c in ov.fuel.cheapest("diesel", 5):
|
|
47
|
+
print(c["country_name"], c["prices"]["diesel"], c["currency"])
|
|
48
|
+
|
|
49
|
+
# Can a Russian passport enter Turkey, and for how long?
|
|
50
|
+
print(ov.visa.check("RU", "TR")["data"])
|
|
51
|
+
|
|
52
|
+
# Toll cost Rome → Paris for a van, as a EUR range
|
|
53
|
+
print(ov.tolls.route(["Rome", "Paris"], vehicle_class="van"))
|
|
54
|
+
|
|
55
|
+
# Best countries for vanlife weather today
|
|
56
|
+
for c in ov.weather.top(5):
|
|
57
|
+
print(c["code"], c["van_score"], c["score_label"])
|
|
58
|
+
|
|
59
|
+
# School holidays in Germany this autumn, by federal state
|
|
60
|
+
ov.holidays.country("DE", from_="2026-10-01", to="2026-12-31", kind="school")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### With pandas
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import pandas as pd
|
|
67
|
+
from openvan import OpenVan
|
|
68
|
+
|
|
69
|
+
ov = OpenVan()
|
|
70
|
+
fuel = pd.DataFrame.from_dict(ov.fuel.prices(), orient="index")
|
|
71
|
+
basket = pd.DataFrame.from_dict(ov.basket.list(), orient="index")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Resources
|
|
75
|
+
|
|
76
|
+
| Resource | Methods |
|
|
77
|
+
|----------|---------|
|
|
78
|
+
| `ov.fuel` | `prices()`, `country(code)`, `cheapest(fuel_type, limit)`, `route_cost(waypoints, **options)` |
|
|
79
|
+
| `ov.currency` | `rates()`, `convert(amount, from_currency, to_currency)` |
|
|
80
|
+
| `ov.basket` | `list()`, `country(code)`, `compare(from_country, to_country)` — food cost index |
|
|
81
|
+
| `ov.weather` | `score(code)`, `all()`, `top(limit)` — VanSky weather suitability |
|
|
82
|
+
| `ov.tolls` | `countries()`, `country(code)`, `route(waypoints, vehicle_class)` |
|
|
83
|
+
| `ov.visa` | `check(passport, destination)`, `route(countries, passports)`, `passport(code)`, `vehicle(place)` |
|
|
84
|
+
| `ov.holidays` | `countries()`, `country(code, from_=, to=, kind=)` |
|
|
85
|
+
| `ov.hazards` | `country(code)`, `fires((min_lon, min_lat, max_lon, max_lat))` |
|
|
86
|
+
| `ov.electricity` | `countries()`, `country(code)` — plug types and voltage |
|
|
87
|
+
| `ov.customs` | `countries()`, `country(code, from_country=)` |
|
|
88
|
+
| `ov.plates` | `list()`, `country(code)`, `types(code)`, `validate(code, number)`, `random(code)`, `image_url(code, number)` |
|
|
89
|
+
| `ov.events` | `list(**filters)`, `get(slug)` |
|
|
90
|
+
| `ov.stories` | `list(**filters)`, `get(slug)`, `search(query)` |
|
|
91
|
+
|
|
92
|
+
Methods return the API's JSON as plain `dict` / `list`. Field reference:
|
|
93
|
+
[interactive docs](https://openvan.camp/docs?utm_source=pypi&utm_medium=referral&utm_campaign=python-sdk) ·
|
|
94
|
+
[OpenAPI spec](https://openvan.camp/docs.openapi).
|
|
95
|
+
|
|
96
|
+
Errors raise `OpenVanError` with `status`, `url`, `body` and the API's own `message`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from openvan import OpenVan, OpenVanError
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
OpenVan().tolls.route(["Atlantis", "Paris"])
|
|
103
|
+
except OpenVanError as e:
|
|
104
|
+
print(e.status, e.message) # 422 and the reason, e.g. no road route
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License and attribution
|
|
108
|
+
|
|
109
|
+
Code: MIT. Data: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — credit it as
|
|
110
|
+
`Data: OpenVan.camp` with a link to https://openvan.camp/.
|
|
111
|
+
|
|
112
|
+
Also available: [JavaScript SDK](https://www.npmjs.com/package/@openvancamp/sdk) ·
|
|
113
|
+
[MCP server for AI agents](https://github.com/openvancamp/openvan-camp-public-api/tree/main/mcp-server) ·
|
|
114
|
+
[all API docs](https://github.com/openvancamp/openvan-camp-public-api).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
openvan/__init__.py,sha256=g8eOm0MVVvCwo15wM5O-cSWKPhbVtXQMMyoCG7VmsGE,281
|
|
2
|
+
openvan/api.py,sha256=8wsTxD6iQ_EdCsXrc9p7oxFPaThk4bhPBXgarqsp8Nw,13954
|
|
3
|
+
openvan/client.py,sha256=D0PbzBrTYbLq0mEYnpDM9lKN0MeihnnmtpMWraUCgBw,3144
|
|
4
|
+
openvan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
openvan-0.1.0.dist-info/METADATA,sha256=5p0u6M3mgKsL3AxZC9Lb9c8pIKSvWSP2DssSq5NVa3w,5043
|
|
6
|
+
openvan-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
openvan-0.1.0.dist-info/licenses/LICENSE,sha256=VwWnJHQUHwGxoA_mrdQeg0D4y4-fyVXYq_tl4pETK1I,1069
|
|
8
|
+
openvan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenVan.camp
|
|
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.
|