pyopenweathermap 0.2.2__tar.gz → 0.3.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.
Files changed (19) hide show
  1. pyopenweathermap-0.3.0/PKG-INFO +101 -0
  2. pyopenweathermap-0.3.0/README.md +78 -0
  3. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/pyproject.toml +2 -1
  4. pyopenweathermap-0.3.0/src/pyopenweathermap/client/onecall_client.py +175 -0
  5. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/client/owm_client_factory.py +1 -1
  6. pyopenweathermap-0.3.0/src/pyopenweathermap/http_client.py +46 -0
  7. pyopenweathermap-0.2.2/PKG-INFO +0 -23
  8. pyopenweathermap-0.2.2/README.md +0 -3
  9. pyopenweathermap-0.2.2/src/pyopenweathermap/client/onecall_client.py +0 -52
  10. pyopenweathermap-0.2.2/src/pyopenweathermap/http_client.py +0 -36
  11. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/LICENSE +0 -0
  12. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/__init__.py +0 -0
  13. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/air_pollution.py +0 -0
  14. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/client/air_pollution_client.py +0 -0
  15. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/client/free_client.py +0 -0
  16. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/client/owm_abstract_client.py +0 -0
  17. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/data_converter.py +0 -0
  18. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/exception.py +0 -0
  19. {pyopenweathermap-0.2.2 → pyopenweathermap-0.3.0}/src/pyopenweathermap/weather.py +0 -0
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyopenweathermap
3
+ Version: 0.3.0
4
+ Summary: lib for OpenWeatherMap for Home Assistant
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: homeassistant,owm,openweathermap
8
+ Author: Evgeny
9
+ Author-email: iam@freekode.org
10
+ Requires-Python: >=3.11,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Requires-Dist: aiohttp (>=3.9.5,<4.0.0)
19
+ Project-URL: Homepage, https://github.com/freekode/pyopenweathermap
20
+ Project-URL: Repository, https://github.com/freekode/pyopenweathermap
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pyopenweathermap
24
+
25
+ Python library for OpenWeatherMap API for Home Assistant.
26
+
27
+ ## One Call API
28
+
29
+ Use `create_owm_client(api_key, 'v3.0')` for the combined One Call 3.0
30
+ endpoint, or `create_owm_client(api_key, 'v4.0')` for the separate One Call 4.0
31
+ endpoints. Both accept `units` (default `metric`) and `lang` (default `en`),
32
+ and expose `await client.get_weather(lat, lon)` returning a `WeatherReport`.
33
+ The current/forecast 2.5 and air-pollution clients remain available.
34
+
35
+ The 4.0 adapter requests `current`, `timeline/1min`, `timeline/1h`, and
36
+ `timeline/1day`, then converts their `data` arrays to the existing report
37
+ models. It returns up to 60 minute records, 48 hourly records, and 8 daily
38
+ records. Missing optional weather fields keep the existing converter defaults;
39
+ empty data arrays produce empty report sections. Malformed envelopes/records
40
+ raise `RequestError`, and a failed endpoint/page fails the report rather than
41
+ silently returning partially refreshed weather.
42
+
43
+ ### Pagination and request budget
44
+
45
+ A typical complete 4.0 report uses **six HTTP requests**: current, minutely,
46
+ three hourly pages (20 + 20 + 8 returned records), and daily. Each page is a
47
+ separate API call; check account entitlement and budget before enabling 4.0.
48
+ Short pages can require additional requests. The adapter follows `next` only
49
+ while it needs records within the requested count/time horizon, stops on an
50
+ empty page, removes overlapping records, and rejects repeated links or pages
51
+ that make no progress. Daily records are capped at eight; it does not fetch the
52
+ full longer-range 4.0 outlook. Requests are sequential and stop at the first
53
+ error. No automatic retry is performed.
54
+
55
+ Only HTTPS pagination links to the same OpenWeather host and timeline endpoint
56
+ are accepted. Client coordinates, key, units, and language are preserved on
57
+ all pages. HTTP redirects are not followed, and request URL debug logs redact
58
+ `appid`. `validate_key()` uses one 4.0 current request (or the legacy 3.0 key
59
+ validation call); authorization errors return `False`, and other request errors
60
+ are propagated.
61
+
62
+ ### Daily precipitation caveat
63
+
64
+ The [migration guide](https://openweathermap.org/api/one-call-3-migration#daily)
65
+ describes 4.0 daily `rain` and `snow` as scalar daily totals in mm, while the
66
+ [reference](https://openweathermap.org/api/one-call-4#daily_parameter) also lists
67
+ hourly-shaped `rain.1h` and `snow.1h` objects. This adapter accepts nonnegative
68
+ numeric daily totals and defaults absent fields to zero. It raises a descriptive
69
+ `RequestError` for an object or other unsupported value. An hourly rate cannot
70
+ safely be labeled or multiplied into a daily total. The 3.0 converter behavior
71
+ is unchanged. Confirm this ambiguity with a sanitized live response before
72
+ claiming production validation.
73
+
74
+ Reference: [One Call 4.0 endpoints and pagination](https://openweathermap.org/api/one-call-4).
75
+ Weather alerts and 15-minute forecasts are outside the existing report model.
76
+
77
+ ## Development and testing
78
+
79
+ Use Python 3.11+ and Poetry (`poetry install`). The project uses `poetry.lock`.
80
+ The default suite and CI are offline and do not require an API key:
81
+
82
+ ```sh
83
+ poetry run pytest
84
+ poetry run pytest -m 'not network'
85
+ poetry build
86
+ ```
87
+
88
+ Fixtures are synthetic, source-labeled contract examples, not recordings of
89
+ successful authenticated calls. Unmarked tests block socket connections and
90
+ DNS. Run credentialed smoke tests only when you have approved account access,
91
+ 4.0 entitlement, and a request budget; they are explicitly opt-in:
92
+
93
+ ```sh
94
+ # Supply OWM_API_KEY through your local environment, not source control.
95
+ poetry run pytest -m network
96
+ ```
97
+
98
+ The existing skipped legacy smoke tests remain skipped. Offline tests establish
99
+ adapter behavior against the published contract; they do not verify an account's
100
+ entitlement, actual provider responses, Home Assistant integration, or deployment.
101
+
@@ -0,0 +1,78 @@
1
+ # pyopenweathermap
2
+
3
+ Python library for OpenWeatherMap API for Home Assistant.
4
+
5
+ ## One Call API
6
+
7
+ Use `create_owm_client(api_key, 'v3.0')` for the combined One Call 3.0
8
+ endpoint, or `create_owm_client(api_key, 'v4.0')` for the separate One Call 4.0
9
+ endpoints. Both accept `units` (default `metric`) and `lang` (default `en`),
10
+ and expose `await client.get_weather(lat, lon)` returning a `WeatherReport`.
11
+ The current/forecast 2.5 and air-pollution clients remain available.
12
+
13
+ The 4.0 adapter requests `current`, `timeline/1min`, `timeline/1h`, and
14
+ `timeline/1day`, then converts their `data` arrays to the existing report
15
+ models. It returns up to 60 minute records, 48 hourly records, and 8 daily
16
+ records. Missing optional weather fields keep the existing converter defaults;
17
+ empty data arrays produce empty report sections. Malformed envelopes/records
18
+ raise `RequestError`, and a failed endpoint/page fails the report rather than
19
+ silently returning partially refreshed weather.
20
+
21
+ ### Pagination and request budget
22
+
23
+ A typical complete 4.0 report uses **six HTTP requests**: current, minutely,
24
+ three hourly pages (20 + 20 + 8 returned records), and daily. Each page is a
25
+ separate API call; check account entitlement and budget before enabling 4.0.
26
+ Short pages can require additional requests. The adapter follows `next` only
27
+ while it needs records within the requested count/time horizon, stops on an
28
+ empty page, removes overlapping records, and rejects repeated links or pages
29
+ that make no progress. Daily records are capped at eight; it does not fetch the
30
+ full longer-range 4.0 outlook. Requests are sequential and stop at the first
31
+ error. No automatic retry is performed.
32
+
33
+ Only HTTPS pagination links to the same OpenWeather host and timeline endpoint
34
+ are accepted. Client coordinates, key, units, and language are preserved on
35
+ all pages. HTTP redirects are not followed, and request URL debug logs redact
36
+ `appid`. `validate_key()` uses one 4.0 current request (or the legacy 3.0 key
37
+ validation call); authorization errors return `False`, and other request errors
38
+ are propagated.
39
+
40
+ ### Daily precipitation caveat
41
+
42
+ The [migration guide](https://openweathermap.org/api/one-call-3-migration#daily)
43
+ describes 4.0 daily `rain` and `snow` as scalar daily totals in mm, while the
44
+ [reference](https://openweathermap.org/api/one-call-4#daily_parameter) also lists
45
+ hourly-shaped `rain.1h` and `snow.1h` objects. This adapter accepts nonnegative
46
+ numeric daily totals and defaults absent fields to zero. It raises a descriptive
47
+ `RequestError` for an object or other unsupported value. An hourly rate cannot
48
+ safely be labeled or multiplied into a daily total. The 3.0 converter behavior
49
+ is unchanged. Confirm this ambiguity with a sanitized live response before
50
+ claiming production validation.
51
+
52
+ Reference: [One Call 4.0 endpoints and pagination](https://openweathermap.org/api/one-call-4).
53
+ Weather alerts and 15-minute forecasts are outside the existing report model.
54
+
55
+ ## Development and testing
56
+
57
+ Use Python 3.11+ and Poetry (`poetry install`). The project uses `poetry.lock`.
58
+ The default suite and CI are offline and do not require an API key:
59
+
60
+ ```sh
61
+ poetry run pytest
62
+ poetry run pytest -m 'not network'
63
+ poetry build
64
+ ```
65
+
66
+ Fixtures are synthetic, source-labeled contract examples, not recordings of
67
+ successful authenticated calls. Unmarked tests block socket connections and
68
+ DNS. Run credentialed smoke tests only when you have approved account access,
69
+ 4.0 entitlement, and a request budget; they are explicitly opt-in:
70
+
71
+ ```sh
72
+ # Supply OWM_API_KEY through your local environment, not source control.
73
+ poetry run pytest -m network
74
+ ```
75
+
76
+ The existing skipped legacy smoke tests remain skipped. Offline tests establish
77
+ adapter behavior against the published contract; they do not verify an account's
78
+ entitlement, actual provider responses, Home Assistant integration, or deployment.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyopenweathermap"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "lib for OpenWeatherMap for Home Assistant"
5
5
  authors = ["Evgeny <iam@freekode.org>"]
6
6
  license = "MIT"
@@ -26,6 +26,7 @@ requires = ["poetry-core"]
26
26
  build-backend = "poetry.core.masonry.api"
27
27
 
28
28
  [tool.pytest.ini_options]
29
+ addopts = "-m 'not network'"
29
30
  markers = [
30
31
  "network: tests that require network access"
31
32
  ]
@@ -0,0 +1,175 @@
1
+ from collections.abc import Callable
2
+ from math import isfinite
3
+ from typing import TypeVar
4
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
5
+
6
+ from .owm_abstract_client import OWMClient
7
+ from ..data_converter import DataConverter
8
+ from ..exception import RequestError, UnauthorizedError
9
+ from ..weather import WeatherReport
10
+ from ..air_pollution import AirPollutionReport
11
+
12
+ API_URL = 'https://api.openweathermap.org/data/3.0/onecall'
13
+ V4_API_URL = 'https://api.openweathermap.org/data/4.0/onecall'
14
+ WeatherT = TypeVar('WeatherT')
15
+
16
+
17
+ class OWMOneCallClient(OWMClient):
18
+ def __init__(self, api_key, api_version, units="metric", lang='en'):
19
+ super().__init__()
20
+ self.api_key = api_key
21
+ self.api_version = api_version
22
+ self.units = units
23
+ self.lang = lang
24
+
25
+ async def get_weather(self, lat, lon) -> WeatherReport:
26
+ if self.api_version == 'v4.0':
27
+ return await self._get_v4_weather(lat, lon)
28
+
29
+ json_response = await self.http_client.request(self._get_url(lat, lon))
30
+ current, minutely, hourly, daily = None, [], [], []
31
+ if json_response.get('current') is not None:
32
+ current = DataConverter.onecall_to_current_weather(json_response['current'])
33
+ if json_response.get('minutely') is not None:
34
+ minutely = [DataConverter.onecall_to_minutely_weather_forecast(item) for item in json_response['minutely']]
35
+ if json_response.get('hourly') is not None:
36
+ hourly = [DataConverter.onecall_to_hourly_weather_forecast(item) for item in json_response['hourly']]
37
+ if json_response.get('daily') is not None:
38
+ daily = [DataConverter.onecall_to_daily_weather_forecast(item) for item in json_response['daily']]
39
+
40
+ return WeatherReport(current, minutely, hourly, daily)
41
+
42
+ async def _get_v4_weather(self, lat, lon) -> WeatherReport:
43
+ response = await self.http_client.request(self._get_url(lat, lon))
44
+ rows = self._v4_data(response)
45
+ current = self._convert_v4(rows[0], DataConverter.onecall_to_current_weather) if rows else None
46
+ # Keep the legacy report horizon. Requests are sequential so failures do
47
+ # not leave other billable requests running in the background.
48
+ minutely = await self._v4_forecast(
49
+ lat, lon, 'timeline/1min', 60, 60,
50
+ DataConverter.onecall_to_minutely_weather_forecast,
51
+ )
52
+ hourly = await self._v4_forecast(
53
+ lat, lon, 'timeline/1h', 48, 3600,
54
+ DataConverter.onecall_to_hourly_weather_forecast,
55
+ )
56
+ daily = await self._v4_forecast(
57
+ lat, lon, 'timeline/1day', 8, 86400,
58
+ DataConverter.onecall_to_daily_weather_forecast,
59
+ )
60
+ return WeatherReport(current, minutely, hourly, daily)
61
+
62
+ async def _v4_forecast(
63
+ self, lat, lon, endpoint, count, interval, converter: Callable[[dict], WeatherT],
64
+ ) -> list[WeatherT]:
65
+ url = self._get_url(lat, lon, endpoint)
66
+ visited = set()
67
+ result: list[WeatherT] = []
68
+ last_dt = None
69
+ end_dt = None
70
+ while len(result) < count:
71
+ # Canonicalize query order so a reordered repeated link also stops.
72
+ identity = (urlsplit(url).path, tuple(sorted(parse_qsl(urlsplit(url).query))))
73
+ if identity in visited:
74
+ raise RequestError('Repeated One Call 4.0 pagination link')
75
+ visited.add(identity)
76
+ response = await self.http_client.request(url)
77
+ rows = self._v4_data(response)
78
+ if not rows:
79
+ break
80
+ previous_dt = None
81
+ added = 0
82
+ for row in rows:
83
+ dt = row.get('dt')
84
+ if not isinstance(dt, int) or isinstance(dt, bool):
85
+ raise RequestError('Invalid One Call 4.0 forecast timestamp')
86
+ if previous_dt is not None and dt < previous_dt:
87
+ raise RequestError('Unordered One Call 4.0 forecast page')
88
+ previous_dt = dt
89
+ if end_dt is None:
90
+ end_dt = dt + count * interval
91
+ if dt >= end_dt:
92
+ return result
93
+ if last_dt is not None and dt <= last_dt:
94
+ continue # Pagination may overlap at the page boundary.
95
+ if endpoint == 'timeline/1day':
96
+ self._validate_v4_daily_precipitation(row)
97
+ result.append(self._convert_v4(row, converter))
98
+ last_dt = dt
99
+ added += 1
100
+ if len(result) == count:
101
+ return result
102
+ next_url = response.get('next')
103
+ if next_url is None or next_url == '':
104
+ break
105
+ if not added:
106
+ raise RequestError('One Call 4.0 pagination made no progress')
107
+ # Every continued page adds a record, so even changing links cannot
108
+ # cause unbounded requests. Stop at the count/time horizon above.
109
+ url = self._v4_next_url(next_url, lat, lon, endpoint)
110
+ return result
111
+
112
+ @staticmethod
113
+ def _v4_data(response):
114
+ if not isinstance(response, dict) or not isinstance(response.get('data'), list):
115
+ raise RequestError('Invalid One Call 4.0 response: expected a data array')
116
+ if any(not isinstance(row, dict) for row in response['data']):
117
+ raise RequestError('Invalid One Call 4.0 response: expected data objects')
118
+ return response['data']
119
+
120
+ @staticmethod
121
+ def _convert_v4(row, converter: Callable[[dict], WeatherT]) -> WeatherT:
122
+ try:
123
+ return converter(row)
124
+ except (KeyError, TypeError, ValueError, IndexError, OverflowError, OSError) as error:
125
+ raise RequestError('Invalid One Call 4.0 weather record') from error
126
+
127
+ @staticmethod
128
+ def _validate_v4_daily_precipitation(row):
129
+ # The migration guide specifies daily scalar totals, but the reference
130
+ # also lists rain.1h/snow.1h. Never mislabel an hourly rate as a daily total.
131
+ for field in ('rain', 'snow'):
132
+ value = row.get(field, 0)
133
+ if (not isinstance(value, (int, float)) or isinstance(value, bool)
134
+ or (isinstance(value, float) and not isfinite(value)) or value < 0):
135
+ raise RequestError(
136
+ f'Unsupported One Call 4.0 daily {field}: expected a numeric daily total'
137
+ )
138
+
139
+ def _v4_next_url(self, next_url, lat, lon, endpoint):
140
+ if not isinstance(next_url, str):
141
+ raise RequestError('Invalid One Call 4.0 pagination link')
142
+ try:
143
+ parts = urlsplit(next_url)
144
+ except ValueError as error:
145
+ raise RequestError('Invalid One Call 4.0 pagination link') from error
146
+ expected = urlsplit(f'{V4_API_URL}/{endpoint}')
147
+ if (parts.scheme != 'https' or parts.netloc != expected.netloc
148
+ or parts.path != expected.path or parts.fragment):
149
+ raise RequestError('Unsafe One Call 4.0 pagination link')
150
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
151
+ # Some documented links omit units/lang. Preserve the caller's options
152
+ # and location/key even if a next link omits or changes them.
153
+ query.update(self._query_params(lat, lon))
154
+ return urlunsplit(parts._replace(query=urlencode(query)))
155
+
156
+ async def get_air_pollution(self, lat, lon) -> AirPollutionReport:
157
+ raise NotImplementedError('One Call client does not have get_air_pollution()')
158
+
159
+ async def validate_key(self) -> bool:
160
+ url = self._get_url(50.06, 14.44)
161
+ if self.api_version != 'v4.0':
162
+ url += '&exclude=current,minutely,hourly,daily,alerts'
163
+ try:
164
+ await self.http_client.request(url)
165
+ return True
166
+ except UnauthorizedError:
167
+ return False
168
+
169
+ def _query_params(self, lat, lon):
170
+ return {'lat': lat, 'lon': lon, 'appid': self.api_key,
171
+ 'units': self.units, 'lang': self.lang}
172
+
173
+ def _get_url(self, lat, lon, endpoint='current'):
174
+ api_url = f'{V4_API_URL}/{endpoint}' if self.api_version == 'v4.0' else API_URL
175
+ return f'{api_url}?{urlencode(self._query_params(lat, lon))}'
@@ -9,7 +9,7 @@ def create_owm_client(api_key, api_type, units="metric", lang='en'):
9
9
  logger = logging.getLogger(__name__)
10
10
 
11
11
  logger.info('Initializing OWMClient with api type: ' + str(api_type))
12
- if api_type == 'v3.0':
12
+ if api_type == 'v3.0' or api_type == 'v4.0':
13
13
  return OWMOneCallClient(api_key, api_type, units, lang)
14
14
  if api_type == 'current' or api_type == 'forecast':
15
15
  return OWMFreeClient(api_key, api_type, units, lang)
@@ -0,0 +1,46 @@
1
+ import logging
2
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
3
+
4
+ from aiohttp import ClientSession, ContentTypeError
5
+
6
+ from .exception import UnauthorizedError, RequestError, TooManyRequestsError
7
+
8
+
9
+ class HttpClient:
10
+ request_timeout: int
11
+ logger = logging.getLogger(__name__)
12
+
13
+ def __init__(self, request_timeout=20):
14
+ self.request_timeout = request_timeout
15
+
16
+ async def request(self, url):
17
+ parts = urlsplit(url)
18
+ safe_query = urlencode([(key, '[redacted]' if key == 'appid' else value)
19
+ for key, value in parse_qsl(parts.query)])
20
+ self.logger.debug('Requesting url: %s', urlunsplit(parts._replace(query=safe_query)))
21
+ async with ClientSession() as session:
22
+ try:
23
+ async with session.get(url=url, timeout=self.request_timeout, allow_redirects=False) as response:
24
+ try:
25
+ response_json = await response.json()
26
+ except (ValueError, ContentTypeError):
27
+ # aiohttp content-type errors contain the credentialed
28
+ # request URL. Do not expose it through exception chains.
29
+ if response.status == 200:
30
+ raise RequestError('Invalid JSON response') from None
31
+ response_json = {}
32
+ if response.status == 200:
33
+ return response_json
34
+ message = response_json.get('message') if isinstance(response_json, dict) else None
35
+ message = message or f'HTTP status code: {response.status}'
36
+ if response.status == 401:
37
+ raise UnauthorizedError(message)
38
+ if response.status == 429:
39
+ raise TooManyRequestsError(message)
40
+ raise RequestError(message)
41
+ except RequestError:
42
+ raise
43
+ except TimeoutError:
44
+ raise RequestError('Request timeout') from None
45
+ except Exception as error:
46
+ raise RequestError(f'Request failed: {type(error).__name__}') from None
@@ -1,23 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: pyopenweathermap
3
- Version: 0.2.2
4
- Summary: lib for OpenWeatherMap for Home Assistant
5
- License: MIT
6
- Keywords: homeassistant,owm,openweathermap
7
- Author: Evgeny
8
- Author-email: iam@freekode.org
9
- Requires-Python: >=3.11,<4.0
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: aiohttp (>=3.9.5,<4.0.0)
16
- Project-URL: Homepage, https://github.com/freekode/pyopenweathermap
17
- Project-URL: Repository, https://github.com/freekode/pyopenweathermap
18
- Description-Content-Type: text/markdown
19
-
20
- # pyopenweathermap
21
-
22
- Python library for OpenWeatherMap API for Home Assistant
23
-
@@ -1,3 +0,0 @@
1
- # pyopenweathermap
2
-
3
- Python library for OpenWeatherMap API for Home Assistant
@@ -1,52 +0,0 @@
1
- from .owm_abstract_client import OWMClient
2
- from ..data_converter import DataConverter
3
- from ..exception import UnauthorizedError
4
- from ..weather import WeatherReport
5
- from ..air_pollution import AirPollutionReport
6
-
7
- API_URL = 'https://api.openweathermap.org/data/3.0/onecall'
8
-
9
-
10
- class OWMOneCallClient(OWMClient):
11
- def __init__(self, api_key, api_version, units="metric", lang='en'):
12
- super().__init__()
13
- self.api_key = api_key
14
- self.api_version = api_version
15
- self.units = units
16
- self.lang = lang
17
-
18
- async def get_weather(self, lat, lon) -> WeatherReport:
19
- url = self._get_url(lat, lon)
20
- json_response = await self.http_client.request(url)
21
-
22
- current, minutely, hourly, daily = None, [], [], []
23
- if json_response.get('current') is not None:
24
- current = DataConverter.onecall_to_current_weather(json_response['current'])
25
- if json_response.get('minutely') is not None:
26
- minutely = [DataConverter.onecall_to_minutely_weather_forecast(item) for item in json_response['minutely']]
27
- if json_response.get('hourly') is not None:
28
- hourly = [DataConverter.onecall_to_hourly_weather_forecast(item) for item in json_response['hourly']]
29
- if json_response.get('daily') is not None:
30
- daily = [DataConverter.onecall_to_daily_weather_forecast(item) for item in json_response['daily']]
31
-
32
- return WeatherReport(current, minutely, hourly, daily)
33
-
34
- async def get_air_pollution(self, lat, lon) -> AirPollutionReport:
35
- raise NotImplementedError('free client does not have get_air_pollution()')
36
-
37
- async def validate_key(self) -> bool:
38
- url = (f"{self._get_url(50.06, 14.44)}"
39
- f"&exclude=current,minutely,hourly,daily,alerts)")
40
- try:
41
- await self.http_client.request(url)
42
- return True
43
- except UnauthorizedError:
44
- return False
45
-
46
- def _get_url(self, lat, lon):
47
- return (f"{API_URL}?"
48
- f"lat={lat}&"
49
- f"lon={lon}&"
50
- f"appid={self.api_key}&"
51
- f"units={self.units}&"
52
- f"lang={self.lang}")
@@ -1,36 +0,0 @@
1
- from aiohttp import ClientSession
2
-
3
- from .exception import UnauthorizedError, RequestError, TooManyRequestsError
4
- import logging
5
-
6
- class HttpClient:
7
- request_timeout: int
8
- logger = logging.getLogger(__name__)
9
-
10
- def __init__(self, request_timeout=20):
11
- self.request_timeout = request_timeout
12
-
13
- async def request(self, url):
14
- self.logger.debug('Requesting url: ' + url)
15
- async with ClientSession() as session:
16
- try:
17
- async with session.get(url=url, timeout=self.request_timeout) as response:
18
- response_json = await response.json()
19
- if response.status == 200:
20
- return response_json
21
- elif response.status == 400:
22
- raise RequestError(response_json.get('message'))
23
- elif response.status == 401:
24
- raise UnauthorizedError(response_json.get('message'))
25
- elif response.status == 404:
26
- raise RequestError(response_json.get('message'))
27
- elif response.status == 429:
28
- raise TooManyRequestsError(response_json.get('message'))
29
- else:
30
- raise RequestError("Unknown status code: {}".format(response.status))
31
- except RequestError as error:
32
- raise error
33
- except TimeoutError:
34
- raise RequestError("Request timeout")
35
- except Exception as error:
36
- raise RequestError(error) from error