meteoflow 1.0.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 MeteoFlow
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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: meteoflow
3
+ Version: 1.0.0
4
+ Summary: Python SDK for Meteoflow Weather API
5
+ Author: Meteoflow
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.4
9
+ Classifier: Programming Language :: Python :: 3.5
10
+ Classifier: Programming Language :: Python :: 3.6
11
+ Classifier: Programming Language :: Python :: 3.7
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Requires-Python: >=3.4
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: requests>=2.0
18
+ Dynamic: license-file
19
+ Dynamic: requires-python
20
+
21
+ # Meteoflow Python SDK
22
+
23
+ Python SDK for Meteoflow Weather API.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install meteoflow
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from meteoflow import WeatherClient, LocationSlug, LocationCoords, LocationIP
35
+
36
+ client = WeatherClient("TOKEN")
37
+
38
+ # Current weather by slug
39
+ current = client.current(LocationSlug("united-kingdom-london"))
40
+
41
+ # Current weather by coords
42
+ current = client.current(LocationCoords(51.5072, -0.1275))
43
+
44
+ # Current weather by IP
45
+ current = client.current(LocationIP("8.8.8.8"))
46
+ ```
47
+
48
+ ## Forecasts
49
+
50
+ ```python
51
+ from meteoflow import WeatherClient, LocationIP, ForecastOptions
52
+
53
+ client = WeatherClient("TOKEN")
54
+
55
+ # Hourly forecast by IP
56
+ options = ForecastOptions(days=1, units="metric", lang="en")
57
+ forecast = client.forecast_hourly(LocationIP("8.8.8.8"), options)
58
+
59
+ # 3-hour forecast by IP
60
+ options = ForecastOptions(days=2, units="metric", lang="en")
61
+ forecast = client.forecast_3hourly(LocationIP("8.8.8.8"), options)
62
+
63
+ # Daily forecast by IP
64
+ options = ForecastOptions(days=5, units="metric", lang="en")
65
+ forecast = client.forecast_daily(LocationIP("8.8.8.8"), options)
66
+ ```
67
+
68
+ ## Air Quality
69
+
70
+ ```python
71
+ from meteoflow import AirQualityClient, LocationCoords, ForecastOptions
72
+
73
+ client = AirQualityClient("TOKEN")
74
+
75
+ air = client.by_days(LocationCoords(51.5072, -0.1275), ForecastOptions(days=3))
76
+ ```
77
+
78
+ ## Geomagnetic
79
+
80
+ ```python
81
+ from meteoflow import GeomagneticClient, LocationSlug
82
+
83
+ client = GeomagneticClient("TOKEN")
84
+
85
+ geo = client.by_days(LocationSlug("united-kingdom-london"))
86
+ ```
87
+
88
+ ## Geography Search → Weather
89
+
90
+ ```python
91
+ from meteoflow import GeographyClient, WeatherClient, LocationSlug
92
+
93
+ geo = GeographyClient("TOKEN")
94
+ weather = WeatherClient("TOKEN")
95
+
96
+ results = geo.search("London", limit=1)
97
+ slug = results["items"][0]["slug"]
98
+
99
+ current = weather.current(LocationSlug(slug))
100
+ ```
101
+
102
+ ## Auth: Header vs Query
103
+
104
+ ```python
105
+ from meteoflow import WeatherClient, LocationSlug
106
+
107
+ # Default: X-token header
108
+ client = WeatherClient("TOKEN")
109
+ current = client.current(LocationSlug("united-kingdom-london"))
110
+
111
+ # Query auth: ?key=TOKEN
112
+ client_q = WeatherClient("TOKEN", auth_in_query=True)
113
+ current_q = client_q.current(LocationSlug("united-kingdom-london"))
114
+ ```
115
+
116
+ ## Notes
117
+
118
+ - Supported Python versions: 3.4+
119
+ - Sync only in core package
120
+ - Returns parsed JSON as `dict`
@@ -0,0 +1,100 @@
1
+ # Meteoflow Python SDK
2
+
3
+ Python SDK for Meteoflow Weather API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install meteoflow
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from meteoflow import WeatherClient, LocationSlug, LocationCoords, LocationIP
15
+
16
+ client = WeatherClient("TOKEN")
17
+
18
+ # Current weather by slug
19
+ current = client.current(LocationSlug("united-kingdom-london"))
20
+
21
+ # Current weather by coords
22
+ current = client.current(LocationCoords(51.5072, -0.1275))
23
+
24
+ # Current weather by IP
25
+ current = client.current(LocationIP("8.8.8.8"))
26
+ ```
27
+
28
+ ## Forecasts
29
+
30
+ ```python
31
+ from meteoflow import WeatherClient, LocationIP, ForecastOptions
32
+
33
+ client = WeatherClient("TOKEN")
34
+
35
+ # Hourly forecast by IP
36
+ options = ForecastOptions(days=1, units="metric", lang="en")
37
+ forecast = client.forecast_hourly(LocationIP("8.8.8.8"), options)
38
+
39
+ # 3-hour forecast by IP
40
+ options = ForecastOptions(days=2, units="metric", lang="en")
41
+ forecast = client.forecast_3hourly(LocationIP("8.8.8.8"), options)
42
+
43
+ # Daily forecast by IP
44
+ options = ForecastOptions(days=5, units="metric", lang="en")
45
+ forecast = client.forecast_daily(LocationIP("8.8.8.8"), options)
46
+ ```
47
+
48
+ ## Air Quality
49
+
50
+ ```python
51
+ from meteoflow import AirQualityClient, LocationCoords, ForecastOptions
52
+
53
+ client = AirQualityClient("TOKEN")
54
+
55
+ air = client.by_days(LocationCoords(51.5072, -0.1275), ForecastOptions(days=3))
56
+ ```
57
+
58
+ ## Geomagnetic
59
+
60
+ ```python
61
+ from meteoflow import GeomagneticClient, LocationSlug
62
+
63
+ client = GeomagneticClient("TOKEN")
64
+
65
+ geo = client.by_days(LocationSlug("united-kingdom-london"))
66
+ ```
67
+
68
+ ## Geography Search → Weather
69
+
70
+ ```python
71
+ from meteoflow import GeographyClient, WeatherClient, LocationSlug
72
+
73
+ geo = GeographyClient("TOKEN")
74
+ weather = WeatherClient("TOKEN")
75
+
76
+ results = geo.search("London", limit=1)
77
+ slug = results["items"][0]["slug"]
78
+
79
+ current = weather.current(LocationSlug(slug))
80
+ ```
81
+
82
+ ## Auth: Header vs Query
83
+
84
+ ```python
85
+ from meteoflow import WeatherClient, LocationSlug
86
+
87
+ # Default: X-token header
88
+ client = WeatherClient("TOKEN")
89
+ current = client.current(LocationSlug("united-kingdom-london"))
90
+
91
+ # Query auth: ?key=TOKEN
92
+ client_q = WeatherClient("TOKEN", auth_in_query=True)
93
+ current_q = client_q.current(LocationSlug("united-kingdom-london"))
94
+ ```
95
+
96
+ ## Notes
97
+
98
+ - Supported Python versions: 3.4+
99
+ - Sync only in core package
100
+ - Returns parsed JSON as `dict`
@@ -0,0 +1,39 @@
1
+ from .air import AirQualityClient
2
+ from .client import WeatherClient
3
+ from .exceptions import (
4
+ ApiError,
5
+ AuthError,
6
+ NetworkError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ SdkError,
10
+ ServerError,
11
+ ValidationError,
12
+ )
13
+ from .geomagnetic import GeomagneticClient
14
+ from .geography import GeographyClient
15
+ from .location import LocationCoords, LocationIP, LocationSlug
16
+ from .options import ForecastOptions, UNITS_IMPERIAL, UNITS_METRIC
17
+ from .transport import HttpTransport
18
+
19
+ __all__ = [
20
+ "WeatherClient",
21
+ "AirQualityClient",
22
+ "GeomagneticClient",
23
+ "GeographyClient",
24
+ "LocationSlug",
25
+ "LocationCoords",
26
+ "LocationIP",
27
+ "ForecastOptions",
28
+ "UNITS_METRIC",
29
+ "UNITS_IMPERIAL",
30
+ "HttpTransport",
31
+ "SdkError",
32
+ "ValidationError",
33
+ "NetworkError",
34
+ "ApiError",
35
+ "AuthError",
36
+ "RateLimitError",
37
+ "NotFoundError",
38
+ "ServerError",
39
+ ]
@@ -0,0 +1,95 @@
1
+ from .exceptions import ValidationError
2
+ from .location import location_to_params
3
+ from .options import ForecastOptions, validate_days, validate_lang, validate_units
4
+ from .transport import HttpTransport
5
+
6
+
7
+ class BaseClient(object):
8
+ def __init__(self,
9
+ token,
10
+ transport=None,
11
+ base_url=None,
12
+ timeout=None,
13
+ retries=None,
14
+ backoff_factor=None,
15
+ user_agent=None,
16
+ proxies=None,
17
+ verify_ssl=True,
18
+ session=None,
19
+ auth_in_query=False,
20
+ retry_on_429=True,
21
+ default_units=None,
22
+ default_lang=None):
23
+ if not token or not isinstance(token, str):
24
+ raise ValidationError("token must be a non-empty string")
25
+ self._token = token
26
+ self._auth_in_query = auth_in_query
27
+ self._default_units = default_units
28
+ self._default_lang = default_lang
29
+
30
+ if transport is not None:
31
+ self._transport = transport
32
+ else:
33
+ kwargs = {
34
+ "base_url": base_url,
35
+ "timeout": timeout,
36
+ "retries": retries,
37
+ "backoff_factor": backoff_factor,
38
+ "user_agent": user_agent,
39
+ "proxies": proxies,
40
+ "verify_ssl": verify_ssl,
41
+ "session": session,
42
+ "retry_on_429": retry_on_429,
43
+ }
44
+ cleaned = {}
45
+ for key in kwargs:
46
+ if kwargs[key] is not None:
47
+ cleaned[key] = kwargs[key]
48
+ self._transport = HttpTransport(**cleaned)
49
+
50
+ def _with_auth(self, params):
51
+ headers = {}
52
+ if self._auth_in_query:
53
+ params["key"] = self._token
54
+ else:
55
+ headers["X-token"] = self._token
56
+ return params, headers
57
+
58
+ def _build_params(self,
59
+ location,
60
+ options,
61
+ default_days=None,
62
+ supports_days=True,
63
+ supports_units=True,
64
+ supports_lang=True):
65
+ params = location_to_params(location)
66
+ if options is None:
67
+ options = ForecastOptions()
68
+ if not isinstance(options, ForecastOptions):
69
+ raise ValidationError("options must be ForecastOptions or None")
70
+
71
+ if supports_days:
72
+ days = options.days
73
+ if days is None:
74
+ days = default_days
75
+ validate_days(days)
76
+ if days is not None:
77
+ params["days"] = int(days)
78
+
79
+ if supports_units:
80
+ units = options.units
81
+ if units is None:
82
+ units = self._default_units
83
+ validate_units(units)
84
+ if units is not None:
85
+ params["unit"] = units
86
+
87
+ if supports_lang:
88
+ lang = options.lang
89
+ if lang is None:
90
+ lang = self._default_lang
91
+ validate_lang(lang)
92
+ if lang is not None:
93
+ params["lang"] = lang
94
+
95
+ return params
@@ -0,0 +1,17 @@
1
+ from ._base import BaseClient
2
+
3
+
4
+ class AirQualityClient(BaseClient):
5
+ def __init__(self, token, default_days=3, **kwargs):
6
+ super(AirQualityClient, self).__init__(token, **kwargs)
7
+ self._default_days = default_days
8
+
9
+ def by_days(self, location, options=None):
10
+ params = self._build_params(
11
+ location,
12
+ options,
13
+ default_days=self._default_days,
14
+ supports_days=True,
15
+ )
16
+ params, headers = self._with_auth(params)
17
+ return self._transport.get("/v2/air/by-days/", params=params, headers=headers)
@@ -0,0 +1,74 @@
1
+ from ._base import BaseClient
2
+
3
+
4
+ class WeatherClient(BaseClient):
5
+ def __init__(self, token, default_days=None, **kwargs):
6
+ super(WeatherClient, self).__init__(token, **kwargs)
7
+ self._default_days = self._normalize_default_days(default_days)
8
+
9
+ def current(self, location):
10
+ params = self._build_params(location, options=None, supports_days=False, supports_lang=False)
11
+ params, headers = self._with_auth(params)
12
+ return self._transport.get("/v2/current/", params=params, headers=headers)
13
+
14
+ def forecast_hourly(self, location, options=None):
15
+ params = self._build_params(
16
+ location,
17
+ options,
18
+ default_days=self._default_days["forecast_hourly"],
19
+ supports_days=True,
20
+ )
21
+ params, headers = self._with_auth(params)
22
+ return self._transport.get("/v2/forecast/by-hours/", params=params, headers=headers)
23
+
24
+ def forecast_3hourly(self, location, options=None):
25
+ params = self._build_params(
26
+ location,
27
+ options,
28
+ default_days=self._default_days["forecast_3hourly"],
29
+ supports_days=True,
30
+ )
31
+ params, headers = self._with_auth(params)
32
+ return self._transport.get("/v2/forecast/by-3hours/", params=params, headers=headers)
33
+
34
+ def forecast_daily(self, location, options=None):
35
+ params = self._build_params(
36
+ location,
37
+ options,
38
+ default_days=self._default_days["forecast_daily"],
39
+ supports_days=True,
40
+ )
41
+ params, headers = self._with_auth(params)
42
+ return self._transport.get("/v2/forecast/by-days/", params=params, headers=headers)
43
+
44
+ # Aliases for camelCase compatibility
45
+ def forecastHourly(self, location, options=None):
46
+ return self.forecast_hourly(location, options)
47
+
48
+ def forecast3hourly(self, location, options=None):
49
+ return self.forecast_3hourly(location, options)
50
+
51
+ def forecastDaily(self, location, options=None):
52
+ return self.forecast_daily(location, options)
53
+
54
+ def _normalize_default_days(self, default_days):
55
+ defaults = {
56
+ "forecast_hourly": 1,
57
+ "forecast_3hourly": 2,
58
+ "forecast_daily": 3,
59
+ }
60
+ if default_days is None:
61
+ return defaults
62
+ if isinstance(default_days, dict):
63
+ merged = defaults.copy()
64
+ merged.update(default_days)
65
+ return merged
66
+ try:
67
+ days_val = int(default_days)
68
+ except (TypeError, ValueError):
69
+ return defaults
70
+ return {
71
+ "forecast_hourly": days_val,
72
+ "forecast_3hourly": days_val,
73
+ "forecast_daily": days_val,
74
+ }
@@ -0,0 +1,36 @@
1
+ class SdkError(Exception):
2
+ pass
3
+
4
+
5
+ class ValidationError(SdkError):
6
+ pass
7
+
8
+
9
+ class NetworkError(SdkError):
10
+ pass
11
+
12
+
13
+ class ApiError(SdkError):
14
+ def __init__(self, status_code, body, message, url, params):
15
+ super(ApiError, self).__init__(message)
16
+ self.status_code = status_code
17
+ self.body = body
18
+ self.message = message
19
+ self.url = url
20
+ self.params = params
21
+
22
+
23
+ class AuthError(ApiError):
24
+ pass
25
+
26
+
27
+ class RateLimitError(ApiError):
28
+ pass
29
+
30
+
31
+ class NotFoundError(ApiError):
32
+ pass
33
+
34
+
35
+ class ServerError(ApiError):
36
+ pass
@@ -0,0 +1,37 @@
1
+ from ._base import BaseClient
2
+ from .exceptions import ValidationError
3
+
4
+
5
+ class GeographyClient(BaseClient):
6
+ def countries(self):
7
+ params, headers = self._with_auth({})
8
+ return self._transport.get("/v2/geography/countries/", params=params, headers=headers)
9
+
10
+ def country_by_code(self, country_code):
11
+ if not isinstance(country_code, str) or not country_code:
12
+ raise ValidationError("country_code must be a non-empty string")
13
+ params = {"country_code": country_code}
14
+ params, headers = self._with_auth(params)
15
+ return self._transport.get("/v2/geography/countries/", params=params, headers=headers)
16
+
17
+ def cities_by_country(self, country_code):
18
+ if not isinstance(country_code, str) or not country_code:
19
+ raise ValidationError("country_code must be a non-empty string")
20
+ params = {"country_code": country_code}
21
+ params, headers = self._with_auth(params)
22
+ return self._transport.get("/v2/geography/countries/cities/", params=params, headers=headers)
23
+
24
+ def search(self, q, limit=None):
25
+ if not isinstance(q, str) or not q:
26
+ raise ValidationError("q must be a non-empty string")
27
+ params = {"q": q}
28
+ if limit is not None:
29
+ try:
30
+ limit_val = int(limit)
31
+ except (TypeError, ValueError):
32
+ raise ValidationError("limit must be an integer")
33
+ if limit_val < 1:
34
+ raise ValidationError("limit must be >= 1")
35
+ params["limit"] = limit_val
36
+ params, headers = self._with_auth(params)
37
+ return self._transport.get("/v2/geography/search/", params=params, headers=headers)
@@ -0,0 +1,8 @@
1
+ from ._base import BaseClient
2
+
3
+
4
+ class GeomagneticClient(BaseClient):
5
+ def by_days(self, location):
6
+ params = self._build_params(location, options=None, supports_days=False, supports_units=False, supports_lang=False)
7
+ params, headers = self._with_auth(params)
8
+ return self._transport.get("/v2/geomagnetic/by-days/", params=params, headers=headers)
@@ -0,0 +1,46 @@
1
+ import ipaddress
2
+
3
+ from .exceptions import ValidationError
4
+
5
+
6
+ class LocationSlug(object):
7
+ def __init__(self, slug):
8
+ if not isinstance(slug, str) or not slug:
9
+ raise ValidationError("slug must be a non-empty string")
10
+ self.slug = slug
11
+
12
+
13
+ class LocationCoords(object):
14
+ def __init__(self, lat, lon):
15
+ try:
16
+ lat_val = float(lat)
17
+ lon_val = float(lon)
18
+ except (TypeError, ValueError):
19
+ raise ValidationError("lat and lon must be numbers")
20
+ if lat_val < -90 or lat_val > 90:
21
+ raise ValidationError("lat must be between -90 and 90")
22
+ if lon_val < -180 or lon_val > 180:
23
+ raise ValidationError("lon must be between -180 and 180")
24
+ self.lat = lat_val
25
+ self.lon = lon_val
26
+
27
+
28
+ class LocationIP(object):
29
+ def __init__(self, ip):
30
+ if not isinstance(ip, str) or not ip:
31
+ raise ValidationError("ip must be a non-empty string")
32
+ try:
33
+ ipaddress.ip_address(ip)
34
+ except ValueError:
35
+ raise ValidationError("ip must be a valid IPv4 or IPv6 string")
36
+ self.ip = ip
37
+
38
+
39
+ def location_to_params(location):
40
+ if isinstance(location, LocationSlug):
41
+ return {"slug": location.slug}
42
+ if isinstance(location, LocationCoords):
43
+ return {"lat": location.lat, "lon": location.lon}
44
+ if isinstance(location, LocationIP):
45
+ return {"ip": location.ip}
46
+ raise ValidationError("location must be LocationSlug, LocationCoords, or LocationIP")
@@ -0,0 +1,37 @@
1
+ from .exceptions import ValidationError
2
+
3
+
4
+ UNITS_METRIC = "metric"
5
+ UNITS_IMPERIAL = "imperial"
6
+
7
+
8
+ class ForecastOptions(object):
9
+ def __init__(self, days=None, units=None, lang=None):
10
+ self.days = days
11
+ self.units = units
12
+ self.lang = lang
13
+
14
+
15
+ def validate_days(days):
16
+ if days is None:
17
+ return
18
+ try:
19
+ days_val = int(days)
20
+ except (TypeError, ValueError):
21
+ raise ValidationError("days must be an integer")
22
+ if days_val < 1:
23
+ raise ValidationError("days must be >= 1")
24
+
25
+
26
+ def validate_units(units):
27
+ if units is None:
28
+ return
29
+ if units not in (UNITS_METRIC, UNITS_IMPERIAL):
30
+ raise ValidationError("units must be 'metric' or 'imperial'")
31
+
32
+
33
+ def validate_lang(lang):
34
+ if lang is None:
35
+ return
36
+ if not isinstance(lang, str) or not lang:
37
+ raise ValidationError("lang must be a non-empty string")
@@ -0,0 +1,124 @@
1
+ import time
2
+
3
+ import requests
4
+
5
+ from .exceptions import ApiError, AuthError, NetworkError, NotFoundError, RateLimitError, ServerError
6
+
7
+ DEFAULT_BASE_URL = "https://api.meteoflow.com"
8
+ DEFAULT_TIMEOUT = 10
9
+ DEFAULT_RETRIES = 2
10
+ DEFAULT_BACKOFF = 0.5
11
+ DEFAULT_USER_AGENT = "meteoflow-python/0.1.0"
12
+
13
+
14
+ class HttpTransport(object):
15
+ def __init__(self,
16
+ base_url=DEFAULT_BASE_URL,
17
+ timeout=DEFAULT_TIMEOUT,
18
+ retries=DEFAULT_RETRIES,
19
+ backoff_factor=DEFAULT_BACKOFF,
20
+ user_agent=None,
21
+ proxies=None,
22
+ verify_ssl=True,
23
+ session=None,
24
+ retry_on_429=True):
25
+ self.base_url = base_url.rstrip("/")
26
+ self.timeout = timeout
27
+ self.retries = retries
28
+ self.backoff_factor = backoff_factor
29
+ self.user_agent = user_agent or DEFAULT_USER_AGENT
30
+ self.proxies = proxies
31
+ self.verify_ssl = verify_ssl
32
+ self.retry_on_429 = retry_on_429
33
+ self.session = session or requests.Session()
34
+
35
+ def get(self, path, params=None, headers=None):
36
+ return self.request("GET", path, params=params, headers=headers)
37
+
38
+ def request(self, method, path, params=None, headers=None):
39
+ url = self.base_url + path
40
+ request_headers = {
41
+ "User-Agent": self.user_agent,
42
+ }
43
+ if headers:
44
+ request_headers.update(headers)
45
+
46
+ attempt = 0
47
+ while True:
48
+ try:
49
+ response = self.session.request(
50
+ method,
51
+ url,
52
+ params=params,
53
+ headers=request_headers,
54
+ timeout=self.timeout,
55
+ proxies=self.proxies,
56
+ verify=self.verify_ssl,
57
+ )
58
+ except requests.RequestException as exc:
59
+ if attempt >= self.retries:
60
+ raise NetworkError(str(exc))
61
+ self._sleep_backoff(attempt)
62
+ attempt += 1
63
+ continue
64
+
65
+ if self._should_retry(response, method) and attempt < self.retries:
66
+ self._sleep_backoff(attempt)
67
+ attempt += 1
68
+ continue
69
+
70
+ if response.status_code < 200 or response.status_code >= 300:
71
+ self._raise_api_error(response, params)
72
+
73
+ try:
74
+ return response.json()
75
+ except ValueError:
76
+ raise ApiError(
77
+ response.status_code,
78
+ response.text,
79
+ "Invalid JSON response",
80
+ response.url,
81
+ params,
82
+ )
83
+
84
+ def _sleep_backoff(self, attempt):
85
+ if self.backoff_factor is None:
86
+ return
87
+ delay = self.backoff_factor * (2 ** attempt)
88
+ if delay > 0:
89
+ time.sleep(delay)
90
+
91
+ def _should_retry(self, response, method):
92
+ if method != "GET":
93
+ return False
94
+ if response.status_code >= 500:
95
+ return True
96
+ if response.status_code == 429 and self.retry_on_429:
97
+ return True
98
+ return False
99
+
100
+ def _raise_api_error(self, response, params):
101
+ status = response.status_code
102
+ message = self._extract_message(response)
103
+ body = response.text
104
+ url = response.url
105
+ if status in (401, 403):
106
+ raise AuthError(status, body, message, url, params)
107
+ if status == 404:
108
+ raise NotFoundError(status, body, message, url, params)
109
+ if status == 429:
110
+ raise RateLimitError(status, body, message, url, params)
111
+ if status >= 500:
112
+ raise ServerError(status, body, message, url, params)
113
+ raise ApiError(status, body, message, url, params)
114
+
115
+ def _extract_message(self, response):
116
+ try:
117
+ data = response.json()
118
+ except ValueError:
119
+ return response.text
120
+ if isinstance(data, dict):
121
+ for key in ("message", "error", "detail"):
122
+ if key in data and data[key]:
123
+ return data[key]
124
+ return response.text
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: meteoflow
3
+ Version: 1.0.0
4
+ Summary: Python SDK for Meteoflow Weather API
5
+ Author: Meteoflow
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.4
9
+ Classifier: Programming Language :: Python :: 3.5
10
+ Classifier: Programming Language :: Python :: 3.6
11
+ Classifier: Programming Language :: Python :: 3.7
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Requires-Python: >=3.4
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: requests>=2.0
18
+ Dynamic: license-file
19
+ Dynamic: requires-python
20
+
21
+ # Meteoflow Python SDK
22
+
23
+ Python SDK for Meteoflow Weather API.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install meteoflow
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from meteoflow import WeatherClient, LocationSlug, LocationCoords, LocationIP
35
+
36
+ client = WeatherClient("TOKEN")
37
+
38
+ # Current weather by slug
39
+ current = client.current(LocationSlug("united-kingdom-london"))
40
+
41
+ # Current weather by coords
42
+ current = client.current(LocationCoords(51.5072, -0.1275))
43
+
44
+ # Current weather by IP
45
+ current = client.current(LocationIP("8.8.8.8"))
46
+ ```
47
+
48
+ ## Forecasts
49
+
50
+ ```python
51
+ from meteoflow import WeatherClient, LocationIP, ForecastOptions
52
+
53
+ client = WeatherClient("TOKEN")
54
+
55
+ # Hourly forecast by IP
56
+ options = ForecastOptions(days=1, units="metric", lang="en")
57
+ forecast = client.forecast_hourly(LocationIP("8.8.8.8"), options)
58
+
59
+ # 3-hour forecast by IP
60
+ options = ForecastOptions(days=2, units="metric", lang="en")
61
+ forecast = client.forecast_3hourly(LocationIP("8.8.8.8"), options)
62
+
63
+ # Daily forecast by IP
64
+ options = ForecastOptions(days=5, units="metric", lang="en")
65
+ forecast = client.forecast_daily(LocationIP("8.8.8.8"), options)
66
+ ```
67
+
68
+ ## Air Quality
69
+
70
+ ```python
71
+ from meteoflow import AirQualityClient, LocationCoords, ForecastOptions
72
+
73
+ client = AirQualityClient("TOKEN")
74
+
75
+ air = client.by_days(LocationCoords(51.5072, -0.1275), ForecastOptions(days=3))
76
+ ```
77
+
78
+ ## Geomagnetic
79
+
80
+ ```python
81
+ from meteoflow import GeomagneticClient, LocationSlug
82
+
83
+ client = GeomagneticClient("TOKEN")
84
+
85
+ geo = client.by_days(LocationSlug("united-kingdom-london"))
86
+ ```
87
+
88
+ ## Geography Search → Weather
89
+
90
+ ```python
91
+ from meteoflow import GeographyClient, WeatherClient, LocationSlug
92
+
93
+ geo = GeographyClient("TOKEN")
94
+ weather = WeatherClient("TOKEN")
95
+
96
+ results = geo.search("London", limit=1)
97
+ slug = results["items"][0]["slug"]
98
+
99
+ current = weather.current(LocationSlug(slug))
100
+ ```
101
+
102
+ ## Auth: Header vs Query
103
+
104
+ ```python
105
+ from meteoflow import WeatherClient, LocationSlug
106
+
107
+ # Default: X-token header
108
+ client = WeatherClient("TOKEN")
109
+ current = client.current(LocationSlug("united-kingdom-london"))
110
+
111
+ # Query auth: ?key=TOKEN
112
+ client_q = WeatherClient("TOKEN", auth_in_query=True)
113
+ current_q = client_q.current(LocationSlug("united-kingdom-london"))
114
+ ```
115
+
116
+ ## Notes
117
+
118
+ - Supported Python versions: 3.4+
119
+ - Sync only in core package
120
+ - Returns parsed JSON as `dict`
@@ -0,0 +1,22 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ meteoflow/__init__.py
6
+ meteoflow/_base.py
7
+ meteoflow/air.py
8
+ meteoflow/client.py
9
+ meteoflow/exceptions.py
10
+ meteoflow/geography.py
11
+ meteoflow/geomagnetic.py
12
+ meteoflow/location.py
13
+ meteoflow/options.py
14
+ meteoflow/transport.py
15
+ meteoflow.egg-info/PKG-INFO
16
+ meteoflow.egg-info/SOURCES.txt
17
+ meteoflow.egg-info/dependency_links.txt
18
+ meteoflow.egg-info/requires.txt
19
+ meteoflow.egg-info/top_level.txt
20
+ tests/test_location.py
21
+ tests/test_options.py
22
+ tests/test_query.py
@@ -0,0 +1 @@
1
+ requests>=2.0
@@ -0,0 +1 @@
1
+ meteoflow
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "meteoflow"
7
+ version = "1.0.0"
8
+ description = "Python SDK for Meteoflow Weather API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.4"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Meteoflow" }
14
+ ]
15
+ dependencies = [
16
+ "requests>=2.0"
17
+ ]
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.4",
21
+ "Programming Language :: Python :: 3.5",
22
+ "Programming Language :: Python :: 3.6",
23
+ "Programming Language :: Python :: 3.7",
24
+ "Programming Language :: Python :: 3.8",
25
+ "Programming Language :: Python :: 3.9"
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ packages = ["meteoflow"]
@@ -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="meteoflow",
5
+ version="0.1.0",
6
+ description="Python SDK for Meteoflow Weather API",
7
+ long_description=open("README.md").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="Meteoflow",
10
+ license="MIT",
11
+ packages=find_packages(exclude=("tests",)),
12
+ install_requires=[
13
+ "requests>=2.0",
14
+ ],
15
+ python_requires=">=3.4",
16
+ classifiers=[
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.4",
19
+ "Programming Language :: Python :: 3.5",
20
+ "Programming Language :: Python :: 3.6",
21
+ "Programming Language :: Python :: 3.7",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ ],
25
+ )
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+ from meteoflow.exceptions import ValidationError
4
+ from meteoflow.location import LocationCoords, LocationIP, LocationSlug, location_to_params
5
+
6
+
7
+ def test_location_slug_params():
8
+ loc = LocationSlug("paris")
9
+ assert location_to_params(loc) == {"slug": "paris"}
10
+
11
+
12
+ def test_location_coords_params():
13
+ loc = LocationCoords(10.5, -20.25)
14
+ assert location_to_params(loc) == {"lat": 10.5, "lon": -20.25}
15
+
16
+
17
+ def test_location_ip_params():
18
+ loc = LocationIP("8.8.8.8")
19
+ assert location_to_params(loc) == {"ip": "8.8.8.8"}
20
+
21
+
22
+ def test_location_invalid_slug():
23
+ with pytest.raises(ValidationError):
24
+ LocationSlug("")
25
+
26
+
27
+ def test_location_invalid_coords():
28
+ with pytest.raises(ValidationError):
29
+ LocationCoords(91, 0)
30
+
31
+
32
+ def test_location_invalid_ip():
33
+ with pytest.raises(ValidationError):
34
+ LocationIP("999.999.999.999")
@@ -0,0 +1,38 @@
1
+ import pytest
2
+
3
+ from meteoflow.exceptions import ValidationError
4
+ from meteoflow.options import validate_days, validate_lang, validate_units
5
+
6
+
7
+ def test_validate_days_ok():
8
+ validate_days(1)
9
+ validate_days("2")
10
+ validate_days(None)
11
+
12
+
13
+ def test_validate_days_invalid():
14
+ with pytest.raises(ValidationError):
15
+ validate_days(0)
16
+ with pytest.raises(ValidationError):
17
+ validate_days("bad")
18
+
19
+
20
+ def test_validate_units_ok():
21
+ validate_units("metric")
22
+ validate_units("imperial")
23
+ validate_units(None)
24
+
25
+
26
+ def test_validate_units_invalid():
27
+ with pytest.raises(ValidationError):
28
+ validate_units("si")
29
+
30
+
31
+ def test_validate_lang_ok():
32
+ validate_lang("en")
33
+ validate_lang(None)
34
+
35
+
36
+ def test_validate_lang_invalid():
37
+ with pytest.raises(ValidationError):
38
+ validate_lang(123)
@@ -0,0 +1,45 @@
1
+ from meteoflow.client import WeatherClient
2
+ from meteoflow.location import LocationCoords, LocationSlug
3
+ from meteoflow.options import ForecastOptions
4
+
5
+
6
+ class FakeTransport(object):
7
+ def __init__(self):
8
+ self.last = None
9
+
10
+ def get(self, path, params=None, headers=None):
11
+ self.last = {
12
+ "path": path,
13
+ "params": params,
14
+ "headers": headers,
15
+ }
16
+ return {"ok": True}
17
+
18
+
19
+ def test_weather_current_slug_auth_header():
20
+ transport = FakeTransport()
21
+ client = WeatherClient("TOKEN", transport=transport)
22
+ client.current(LocationSlug("london"))
23
+ assert transport.last["path"] == "/v2/current/"
24
+ assert transport.last["params"] == {"slug": "london"}
25
+ assert transport.last["headers"]["X-token"] == "TOKEN"
26
+
27
+
28
+ def test_weather_forecast_coords_options():
29
+ transport = FakeTransport()
30
+ client = WeatherClient("TOKEN", transport=transport, default_units="metric", default_lang="en")
31
+ options = ForecastOptions(days=2, units="imperial", lang="ru")
32
+ client.forecast_daily(LocationCoords(10, 20), options)
33
+ assert transport.last["path"] == "/v2/forecast/by-days/"
34
+ assert transport.last["params"]["lat"] == 10.0
35
+ assert transport.last["params"]["lon"] == 20.0
36
+ assert transport.last["params"]["days"] == 2
37
+ assert transport.last["params"]["unit"] == "imperial"
38
+ assert transport.last["params"]["lang"] == "ru"
39
+
40
+
41
+ def test_weather_auth_query():
42
+ transport = FakeTransport()
43
+ client = WeatherClient("TOKEN", transport=transport, auth_in_query=True)
44
+ client.current(LocationSlug("london"))
45
+ assert transport.last["params"]["key"] == "TOKEN"