pax_global_header00006660000000000000000000000064147204412030014507gustar00rootroot0000000000000052 comment=ac8f2684ff400ff7f30e1a131c4a9776c936e874 PyMetEireann-2024.11.0/000077500000000000000000000000001472044120300143565ustar00rootroot00000000000000PyMetEireann-2024.11.0/.github/000077500000000000000000000000001472044120300157165ustar00rootroot00000000000000PyMetEireann-2024.11.0/.github/stale.yml000066400000000000000000000012541472044120300175530ustar00rootroot00000000000000# Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - pinned - security # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false PyMetEireann-2024.11.0/.github/workflows/000077500000000000000000000000001472044120300177535ustar00rootroot00000000000000PyMetEireann-2024.11.0/.github/workflows/publish-to-pypi.yml000066400000000000000000000026011472044120300235420ustar00rootroot00000000000000name: Publish Python 🐍 distributions 📦 to PyPI on: create: tags: jobs: build-and-publish: name: Build and publish Python 🐍 distributions 📦 to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python 3.7 uses: actions/setup-python@v1 with: python-version: 3.7 - name: Install dependencies run: python -m pip install --user --upgrade setuptools wheel flake8 pylint xmltodict aiohttp async_timeout pytz - name: Style checking run: python -m flake8 meteireann --max-line-length=120 - name: Linting run: python -m pylint meteireann --max-line-length=120 - name: Build a binary wheel and a source tarball run: python setup.py sdist bdist_wheel # - name: Publish distribution 📦 to Test PyPI # uses: pypa/gh-action-pypi-publish@master # with: # password: ${{ secrets.test_pypi_password }} # repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.pypi_password }} PyMetEireann-2024.11.0/.gitignore000066400000000000000000000022631472044120300163510ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ PyMetEireann-2024.11.0/LICENSE000066400000000000000000000021241472044120300153620ustar00rootroot00000000000000MIT License Copyright (c) 2018 Daniel Høyer Iversen Copyright (c) 2020 Dylan Gore Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PyMetEireann-2024.11.0/README.md000066400000000000000000000026131472044120300156370ustar00rootroot00000000000000# PyMetEireann ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/DylanGore/PyMetEireann/Publish%20Python%20%F0%9F%90%8D%20distributions%20%F0%9F%93%A6%20to%20PyPI?style=for-the-badge) ![GitHub](https://img.shields.io/github/license/DylanGore/PyMetEireann?style=for-the-badge) ![PyPI](https://img.shields.io/pypi/v/PyMetEireann?style=for-the-badge) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/PyMetEireann?style=for-the-badge) A Python library to communicate with the [Met Éireann](https://www.met.ie/) Public [Weather Forecast API](https://data.gov.ie/dataset/met-eireann-weather-forecast-api) and [Weather Warning API](https://data.gov.ie/dataset/weather-warnings). The project is an independant fork of [pyMetno](https://github.com/Danielhiversen/pyMetno) by [Daniel Hjelseth Høyer](https://github.com/Danielhiversen/pyMetno/). It is in no way affiliated with or endorsed by Met Éireann. ## License The PyMetEireann library is licensed under the MIT License. The data provided by the API is licensed under the Met Eireann Custom Open Data Licence, the basic requirements of the license are described at [https://data.gov.ie/dataset/met-eireann-weather-forecast-api](https://data.gov.ie/dataset/met-eireann-weather-forecast-api). The full license is available [here](https://www.met.ie/cms/assets/uploads/2018/05/Met-%C3%89ireann-Open-Data-Custom-Licence_Final.odt). PyMetEireann-2024.11.0/example.py000066400000000000000000000020041472044120300163570ustar00rootroot00000000000000import meteireann import asyncio import datetime weather_data = meteireann.WeatherData() warning_data = meteireann.WarningData() async def fetch_data(): """Fetch data from API - (current weather and forecast).""" await weather_data.fetching_data() current_weather_data = weather_data.get_current_weather() print('current:', current_weather_data) await warning_data.fetching_data() current_warning_data = warning_data.get_warnings() print('warnings:', current_warning_data) time_zone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo daily_forecast = weather_data.get_forecast(time_zone, False) print('daily:', daily_forecast) hourly_forecast = weather_data.get_forecast(time_zone, True) print('hourly:', hourly_forecast) return True async def main(): await fetch_data() await weather_data.close_session() await warning_data.close_session() if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) PyMetEireann-2024.11.0/meteireann/000077500000000000000000000000001472044120300165055ustar00rootroot00000000000000PyMetEireann-2024.11.0/meteireann/__init__.py000066400000000000000000000327701472044120300206270ustar00rootroot00000000000000'''Library to handle communications with the Met Éireann forecast and weather warning APIs.''' import asyncio import datetime import logging from xml.parsers.expat import ExpatError import aiohttp import async_timeout import pytz import xmltodict API_URL = 'http://openaccess.pf.api.met.ie/metno-wdb2ts/locationforecast' WARNING_API_URL = 'https://www.met.ie/Open_Data/json/warning_' # Map each region code to a region name REGION_MAP = { # Counties 'EI01': 'Carlow', 'EI02': 'Cavan', 'EI03': 'Clare', 'EI04': 'Cork', 'EI06': 'Donegal', 'EI07': 'Dublin', 'EI10': 'Galway', 'EI11': 'Kerry', 'EI12': 'Kildare', 'EI13': 'Kilkenny', 'EI15': 'Laois', 'EI14': 'Leitrim', 'EI16': 'Limerick', 'EI18': 'Longford', 'EI19': 'Louth', 'EI20': 'Mayo', 'EI21': 'Meath', 'EI22': 'Monaghan', 'EI23': 'Offaly', 'EI24': 'Roscommon', 'EI25': 'Sligo', 'EI26': 'Tipperary', 'EI27': 'Waterford', 'EI29': 'Westmeath', 'EI30': 'Wexford', 'EI31': 'Wicklow', # Marine 'EI805': 'Malin-Fair', 'EI806': 'Fair-Belfast', 'EI807': 'Belfast-Strang', 'EI808': 'Strang-Carl', 'EI809': 'Carling-Howth', 'EI810': 'Howth-Wicklow', 'EI811': 'Wicklow-Carns', 'EI812': 'Carns-Hook', 'EI813': 'Hook-Dungarvan', 'EI814': 'Dungarvan-Roches', 'EI815': 'Roches-Mizen', 'EI816': 'Mizen-Valentia', 'EI817': 'Valentia-Loop', 'EI818': 'Loop-Slayne', 'EI819': 'Slayne-Ennis', 'EI820': 'Erris-Rossan', 'EI821': 'Rossan-BloodyF', 'EI822': 'BloodyF-Malin', 'EI823': 'IrishSea-South', 'EI824': 'IrishSea-IOM-S', 'EI825': 'IrishSea-IOM-N' } _LOGGER = logging.getLogger(__name__) class WarningData: '''Representation of Met Éireann warning data.''' def __init__(self, websession=None, api_url=WARNING_API_URL, region='Ireland', convert_to_utc=True, ignore_blight=True): '''Initialize the warning object.''' # pylint: disable=too-many-arguments # Set the various option variables self._ignore_blight = ignore_blight self._convert_to_utc = convert_to_utc # Convert region name to region code (if applicable) if not region.upper().startswith('EI') and region.upper() != 'IRELAND': keys = [k for k, v in REGION_MAP.items() if v == region] self._region = keys[0].upper() else: self._region = region.upper() # Set the API URL to include the required region self._api_url = f'{api_url}{self._region}.json' # Create a new session if one isn't passed in if websession is None: async def _create_session(): self.created_session = True return aiohttp.ClientSession() loop = asyncio.get_event_loop() self._websession = loop.run_until_complete(_create_session()) else: self._websession = websession self.created_session = False self.data = None async def fetching_data(self, *_): '''Get the latest data from the warning API''' try: with async_timeout.timeout(10): res = await self._websession.get(self._api_url) # Log any 400+ HTTP error codes if res.status >= 400: _LOGGER.error('%s returned %s', self._api_url, res.status) return False json = await res.json() except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error('%s returned %s', self._api_url, err) return False try: self.data = { 'count': len(json), 'warnings': json } except (ExpatError, IndexError) as err: _LOGGER.error('%s returned %s', res.url, err) return False return True def get_warnings(self): '''Get the latest warnings from Met Éireann.''' if self.data is None: return {'count': 0, 'warnings': []} # Update the timestamps to datetime objects (and to UTC if required) for entry in self.data['warnings']: entry['issued'] = format_warning_date(entry['issued'], self._convert_to_utc) entry['updated'] = format_warning_date(entry['updated'], self._convert_to_utc) entry['onset'] = format_warning_date(entry['onset'], self._convert_to_utc) entry['expiry'] = format_warning_date(entry['expiry'], self._convert_to_utc) # Remove blight warnings from the list if required if self._ignore_blight: new_warnings_list = [] for entry in self.data['warnings']: if entry['type'].lower() != 'blight': new_warnings_list.append(entry) return {'count': len(new_warnings_list), 'warnings': new_warnings_list} return self.data async def close_session(self): '''Close a session if the user did not pass one in.''' if self.created_session: await self._websession.close() _LOGGER.debug('Closed session (Warnings)') else: _LOGGER.warning('Cannot close an external session') class WeatherData: '''Representation of Met Éireann weather data.''' def __init__(self, websession=None, api_url=API_URL, latitude=54.7210798611, longitude=-8.7237392806, altitude=0): '''Initialize the weather object.''' # pylint: disable=too-many-arguments # Get the current UTC time now = datetime.datetime.utcnow() # Store the forecast parameters self._api_url = f'{api_url}?lat={latitude};long={longitude};alt={altitude};from={now.date()}T{now.hour}:00' # Create a new session if one isn't passed in if websession is None: async def _create_session(): self.created_session = True return aiohttp.ClientSession() loop = asyncio.get_event_loop() self._websession = loop.run_until_complete(_create_session()) else: self._websession = websession self.created_session = False self.data = None async def fetching_data(self, *_): '''Get the latest data from the API''' try: with async_timeout.timeout(10): resp = await self._websession.get(self._api_url) # Log any 400+ HTTP error codes if resp.status >= 400: _LOGGER.error('%s returned %s', self._api_url, resp.status) return False text = await resp.text() except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error('%s returned %s', self._api_url, err) return False try: self.data = xmltodict.parse(text)['weatherdata'] except (ExpatError, IndexError) as err: _LOGGER.error('%s returned %s', resp.url, err) return False return True def get_current_weather(self): '''Get the current weather data from Met Éireann.''' return self.get_weather(datetime.datetime.now(pytz.utc).replace(minute=0, second=0, microsecond=0), hourly=True) def get_forecast(self, time_zone, hourly=False): '''Get the forecast weather data from Met Éireann.''' if self.data is None: return [] if hourly: now = datetime.datetime.now(time_zone).replace( minute=0, second=0, microsecond=0 ) times = [now + datetime.timedelta(hours=k) for k in range(1, 25)] else: now = datetime.datetime.now(time_zone).replace( hour=12, minute=0, second=0, microsecond=0 ) times = [now + datetime.timedelta(days=k) for k in range(1, 6)] return [self.get_weather(_time, hourly=hourly) for _time in times] def get_weather(self, time, max_hour=6, hourly=False): '''Get the current weather data from Met Éireann.''' # pylint: disable=too-many-locals if self.data is None: return {} day = time.date() daily_temperatures = [] daily_precipitation = [] daily_windspeed = [] daily_windgust = [] ordered_entries = [] for time_entry in self.data['product']['time']: valid_from = parse_datetime(time_entry['@from']) valid_to = parse_datetime(time_entry['@to']) if time > valid_to: # Has already passed. Never select this. continue # Collect all daily values to calculate min/max/sum if valid_from.date() == day or valid_to.date() == day: if 'temperature' in time_entry['location']: daily_temperatures.append( get_value(time_entry['location']['temperature'], '@value') ) if 'precipitation' in time_entry['location']: daily_precipitation.append( get_value(time_entry['location']['precipitation'], '@value') ) if 'windSpeed' in time_entry['location']: daily_windspeed.append( get_value(time_entry['location']['windSpeed'], '@mps') ) if 'windGust' in time_entry['location']: daily_windgust.append( get_value(time_entry['location']['windGust'], '@mps') ) average_dist = abs((valid_to - time).total_seconds()) + abs( (valid_from - time).total_seconds() ) if average_dist > max_hour * 3600: continue ordered_entries.append((average_dist, time_entry)) if not ordered_entries: return {} ordered_entries.sort(key=lambda item: item[0]) res = dict() res['datetime'] = time res['condition'] = get_data('symbol', ordered_entries) res['pressure'] = get_data('pressure', ordered_entries) res['humidity'] = get_data('humidity', ordered_entries) res['wind_bearing'] = get_data('windDirection', ordered_entries) if hourly: res['temperature'] = get_data('temperature', ordered_entries) res['precipitation'] = get_data('precipitation', ordered_entries) res['wind_speed'] = get_data('windSpeed', ordered_entries) res['wind_gust'] = get_data('windGust', ordered_entries) res['cloudiness'] = get_data('cloudiness', ordered_entries) else: res['temperature'] = ( None if daily_temperatures == [] else max(daily_temperatures) ) res['templow'] = ( None if daily_temperatures == [] else min(daily_temperatures) ) res['precipitation'] = ( None if daily_precipitation == [] else round(sum(daily_precipitation), 1) ) res['wind_speed'] = ( None if daily_windspeed == [] else max(daily_windspeed) ) res['wind_gust'] = ( None if daily_windgust == [] else max(daily_windgust) ) return res async def close_session(self): '''Close a session if the user did not pass one in''' if self.created_session: await self._websession.close() _LOGGER.debug('Closed session (Forecast)') else: _LOGGER.warning('Cannot close an external session') def get_value(data, value): '''Retrieve weather value.''' try: if value == '@mps': return round(float(data[value]) * 3.6, 1) return round(float(data[value]), 1) except (ValueError, IndexError, KeyError): return None def get_data(param, data): '''Retrieve weather parameter.''' try: for (_, selected_time_entry) in data: loc_data = selected_time_entry['location'] if param not in loc_data: continue if param == 'symbol': new_state = loc_data[param]['@id'] elif param in ( 'temperature', 'pressure', 'humidity', 'dewpointTemperature', 'precipitation', ): new_state = get_value(loc_data[param], '@value') elif param in ('windSpeed', 'windGust'): new_state = get_value(loc_data[param], '@mps') elif param == 'windDirection': new_state = get_value(loc_data[param], '@deg') elif param in ( 'fog', 'cloudiness', 'lowClouds', 'mediumClouds', 'highClouds', ): new_state = get_value(loc_data[param], '@percent') return new_state except (ValueError, IndexError, KeyError): return None def parse_datetime(dt_str): '''Parse datetime for forecast data.''' date_format = '%Y-%m-%dT%H:%M:%S %z' dt_str = dt_str.replace('Z', ' +0000') return datetime.datetime.strptime(dt_str, date_format) def format_warning_date(date_str, convert_to_utc=False): '''Convert a timestamp string to datetime and convert to UTC if required.''' date_format = '%Y-%m-%dT%H:%M:%S%z' new_timestamp = datetime.datetime.strptime(date_str, date_format) # Convert the datetime object to UTC if required if convert_to_utc: return new_timestamp.astimezone(tz=datetime.timezone.utc) # Return just the datetime object if UTC isn't required return new_timestamp PyMetEireann-2024.11.0/setup.py000066400000000000000000000023051472044120300160700ustar00rootroot00000000000000from setuptools import setup # Read the contents of the README file from os import path this_directory = path.abspath(path.dirname(__file__)) with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: long_description = f.read() setup( name='PyMetEireann', packages=['meteireann'], install_requires=['xmltodict', 'aiohttp', 'async_timeout', 'pytz'], version='2024.11.0', description='A library to communicate with the Met Éireann Public Weather Forecast and Weather Warning APIs', long_description=long_description, long_description_content_type='text/markdown', author='Dylan Gore', author_email='hello@dylangore.ie', license='MIT', url='https://github.com/DylanGore/PyMetEireann/', classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console', 'Environment :: Other Environment', 'Framework :: aiohttp', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries :: Python Modules' ] )