pymeteoclimatic/0000775000175000017500000000000014546754332013563 5ustar edwardedwardpymeteoclimatic/.github/0000775000175000017500000000000014546754332015123 5ustar edwardedwardpymeteoclimatic/.github/workflows/0000775000175000017500000000000014546754332017160 5ustar edwardedwardpymeteoclimatic/.github/workflows/publish.yml0000664000175000017500000000120614546754332021350 0ustar edwardedwardname: Publish to Pypi on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: "3.8" - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* pymeteoclimatic/.github/workflows/build_test.yml0000664000175000017500000000220614546754332022041 0ustar edwardedwardname: Build and Test on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest strategy: matrix: python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python_version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python_version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools coveralls pip install -r test-requirements.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 meteoclimatic --count --max-complexity=10 --max-line-length=127 --statistics - name: Install run: | python setup.py install - name: Test with pytest run: | pytest --cov=meteoclimatic/ -v tests/ - name: Publish Coverage env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} run: | coveralls pymeteoclimatic/tests/0000775000175000017500000000000014755430253014720 5ustar edwardedwardpymeteoclimatic/tests/test_client.py0000664000175000017500000000335714546754332017624 0ustar edwardedwardimport os import unittest from urllib.error import HTTPError from meteoclimatic.exceptions import StationNotFound, MeteoclimaticError from meteoclimatic import MeteoclimaticClient from unittest.mock import patch class TestMeteoclimaticClient(unittest.TestCase): def setUp(self): self.client = MeteoclimaticClient() @patch('meteoclimatic.client.urlopen', autospec=True) def test_get_station_info_ok(self, mock_urlopen): f = open(os.path.join(os.path.dirname( __file__), "feeds", "full_station.xml")) mock_urlopen.return_value.read.return_value = f res = self.client.weather_at_station("ESCAT4300000043206B") mock_urlopen.assert_called_with( "https://www.meteoclimatic.net/feed/rss/ESCAT4300000043206B") self.assertEqual(res.station.code, "ESCAT4300000043206B") @patch('meteoclimatic.client.urlopen', autospec=True) def test_get_station_info_no_xml(self, mock_urlopen): mock_urlopen.return_value.read.return_value = "" with self.assertRaises(StationNotFound) as error: self.client.weather_at_station("ESCAT4300000043206B") self.assertEqual(error.exception.station_code, "ESCAT4300000043206B") self.assertEqual(str( error.exception), "Station code ESCAT4300000043206B did not return any item") @patch('meteoclimatic.client.urlopen', autospec=True) def test_get_station_info_404(self, mock_urlopen): mock_urlopen.side_effect = HTTPError("", 404, "Not Found", [], None) with self.assertRaises(MeteoclimaticError) as error: self.client.weather_at_station("ESCAT4300000043206B") self.assertEqual(str( error.exception), "Error fetching station data [status_code=404]") pymeteoclimatic/tests/test_station.py0000664000175000017500000000211214546754332020013 0ustar edwardedwardimport pytest from meteoclimatic import Station class TestStation: _test_dict = {"name": "Reus - Nord (Tarragona)", "code": "ESCAT4300000043206B", "url": "http://www.meteoclimatic.net/perfil/ESCAT4300000043206B"} def test_init_ok(self): s = Station(**self._test_dict) assert s.name == "Reus - Nord (Tarragona)" assert s.code == "ESCAT4300000043206B" assert s.url == "http://www.meteoclimatic.net/perfil/ESCAT4300000043206B" @pytest.mark.parametrize("field_name,field_value,expected_error", [ ("name", "", "Station name cannot be empty"), ("name", None, "Station name cannot be empty"), ("code", "", "Station code cannot be empty"), ("code", None, "Station code cannot be empty") ]) def test_init_fails_when_wrong_data_provided(self, field_name, field_value, expected_error): d1 = self._test_dict.copy() d1[field_name] = field_value with pytest.raises(ValueError) as error: Station(**d1) assert str(error.value) == expected_error pymeteoclimatic/tests/test_observation.py0000664000175000017500000002522714546754332020701 0ustar edwardedwardimport pytest import os from datetime import datetime, timezone from bs4 import BeautifulSoup from meteoclimatic import Observation, Station, Weather, Condition class TestObservation: _test_dict = { 'reception_time': datetime(2020, 6, 4, 10, 48, 1, 0, timezone.utc), 'station': Station(name="Reus - Nord (Tarragona)", code="ESCAT4300000043206B", url="http://www.meteoclimatic.net/perfil/ESCAT4300000043206B"), 'weather': Weather(reference_time=datetime(2020, 6, 4, 10, 48, 1, 0, timezone.utc), condition=Condition.hazesun, temp_current=17.6, temp_max=17.9, temp_min=16.0, humidity_current=77.0, humidity_max=96.0, humidity_min=74.0, pressure_current=1002.0, pressure_max=1003.8, pressure_min=1000.9, wind_current=0.0, wind_max=29.0, wind_bearing=300.0, rain=3.2) } def test_init_ok(self): o = Observation(**self._test_dict) assert o.reception_time.minute == 48 assert o.station.code == "ESCAT4300000043206B" assert o.weather.pressure_min == 1000.9 @ pytest.mark.parametrize("field_name,field_value,expected_error", [ ('reception_time', 123, 'reception_time is not an instance of datetime.datetime'), ('station', {"foo": "bar"}, 'station is not an instance of meteoclimatic.Station'), ('weather', {"foo": "bar"}, 'weather is not an instance of meteoclimatic.Weather'), ]) def test_init_fails_when_wrong_data_provided(self, field_name, field_value, expected_error): d1 = self._test_dict.copy() d1[field_name] = field_value with pytest.raises(ValueError) as error: Observation(**d1) assert str(error.value) == expected_error @ pytest.mark.parametrize("test_file, expected_result", [ ("full_station.xml", Observation( reception_time=datetime(2020, 6, 4, 10, 48, 1, 0, timezone.utc), station=Station(name="Reus - Nord (Tarragona)", code="ESCAT4300000043206B", url="http://www.meteoclimatic.net/perfil/ESCAT4300000043206B"), weather=Weather(reference_time=datetime(2020, 6, 4, 10, 48, 1, 0, timezone.utc), condition=Condition.hazesun, temp_current=17.6, temp_max=17.9, temp_min=16.0, humidity_current=77.0, humidity_max=96.0, humidity_min=74.0, pressure_current=1002.0, pressure_max=1003.8, pressure_min=1000.9, wind_current=0.0, wind_max=29.0, wind_bearing=300.0, rain=3.2) )), ("no_condition.xml", Observation( reception_time=datetime(2020, 6, 18, 8, 16, 1, 0, timezone.utc), station=Station(name="Cornellà - Gavarra (Barcelona)", code="ESCAT0800000008940B", url="http://www.meteoclimatic.net/perfil/ESCAT0800000008940B"), weather=Weather(reference_time=datetime(2020, 6, 18, 8, 16, 1, 0, timezone.utc), condition=None, temp_current=15.9, temp_max=18.1, temp_min=15.8, humidity_current=83.0, humidity_max=88.0, humidity_min=70.0, pressure_current=1016.0, pressure_max=1016.0, pressure_min=1016.0, wind_current=10.0, wind_max=24.0, wind_bearing=0.0, rain=3.0) )), ("no_rain.xml", Observation( reception_time=datetime(2020, 6, 4, 11, 0, 0, 0, timezone.utc), station=Station(name="Mompía (Cantabria)", code="ESCTB3900000039108A", url="http://www.meteoclimatic.net/perfil/ESCTB3900000039108A"), weather=Weather(reference_time=datetime(2020, 6, 4, 11, 0, 0, 0, timezone.utc), condition=Condition.hazesun, temp_current=17.7, temp_max=18.9, temp_min=13.8, humidity_current=80.0, humidity_max=96.0, humidity_min=76.0, pressure_current=1009.0, pressure_max=1009.6, pressure_min=1006.4, wind_current=21.0, wind_max=48.0, wind_bearing=292.0, rain=None) )), ("no_humidity.xml", Observation( reception_time=datetime(2020, 6, 4, 10, 39, 0, 0, timezone.utc), station=Station(name="Sopeña de Curueño (León)", code="ESCYL2400000024840A", url="http://www.meteoclimatic.net/perfil/ESCYL2400000024840A"), weather=Weather(reference_time=datetime(2020, 6, 4, 10, 39, 0, 0, timezone.utc), condition=Condition.hazesun, temp_current=11.4, temp_max=13.0, temp_min=4.0, humidity_current=None, humidity_max=None, humidity_min=None, pressure_current=1007.9, pressure_max=1007.9, pressure_min=1004.0, wind_current=2.0, wind_max=27.0, wind_bearing=340, rain=0.2) )), ("no_pressure.xml", Observation( reception_time=datetime(2020, 6, 4, 10, 45, 0, 0, timezone.utc), station=Station(name="Zafrilla (La Reclovilla) (Cuenca)", code="ESCLM1600000016317D", url="http://www.meteoclimatic.net/perfil/ESCLM1600000016317D"), weather=Weather(reference_time=datetime(2020, 6, 4, 10, 45, 0, 0, timezone.utc), condition=Condition.rain, temp_current=9.2, temp_max=12.6, temp_min=6.4, humidity_current=89.0, humidity_max=93.0, humidity_min=79.0, pressure_current=None, pressure_max=None, pressure_min=None, wind_current=1.0, wind_max=12.0, wind_bearing=0.0, rain=19.3) )), ("no_wind.xml", Observation( reception_time=datetime(2020, 6, 4, 10, 39, 0, 0, timezone.utc), station=Station(name="Sopeña de Curueño (León)", code="ESCYL2400000024840A", url="http://www.meteoclimatic.net/perfil/ESCYL2400000024840A"), weather=Weather(reference_time=datetime(2020, 6, 4, 10, 39, 0, 0, timezone.utc), condition=Condition.hazesun, temp_current=11.4, temp_max=13.0, temp_min=4.0, humidity_current=76.0, humidity_max=100.0, humidity_min=71.0, pressure_current=1007.9, pressure_max=1007.9, pressure_min=1004.0, wind_current=None, wind_max=None, wind_bearing=None, rain=0.2) )), ("invalid_values.xml", Observation( reception_time=datetime(2020, 6, 4, 10, 45, 0, 0, timezone.utc), station=Station(name="Puçol-Ciudad Jardín (Valencia)", code="ESPVA4600000046530D", url="http://www.meteoclimatic.net/perfil/ESPVA4600000046530D"), weather=Weather(reference_time=datetime(2020, 6, 4, 10, 45, 0, 0, timezone.utc), condition=Condition.sun, temp_current=23.1, temp_max=25.4, temp_min=19.6, humidity_current=49.0, humidity_max=85.0, humidity_min=47.0, pressure_current=1002.2, pressure_max=1002.8, pressure_min=999.9, wind_current=None, wind_max=None, wind_bearing=None, rain=0.2) )) ]) def test_from_feed_item(self, test_file, expected_result): f = open(os.path.join(os.path.dirname( __file__), "feeds", test_file)) soup_page = BeautifulSoup(f, 'xml') f.close() items = soup_page.findAll("item") actual = Observation.from_feed_item(items[0]) assert actual == expected_result pymeteoclimatic/tests/test_weather.py0000664000175000017500000000453614546754332020005 0ustar edwardedwardimport datetime import pytest from meteoclimatic import Condition, Weather class TestWeather: _test_dict = {'reference_time': datetime.datetime(2020, 6, 9, 10, 30, 56, tzinfo=datetime.timezone.utc), 'condition': Condition.suncloud, 'temp_current': 20.9, 'temp_max': 21.0, 'temp_min': 13.7, 'humidity_current': 55.0, 'humidity_max': 80.0, 'humidity_min': 54.0, 'pressure_current': 1015.3, 'pressure_max': 1015.3, 'pressure_min': 1014.0, 'wind_current': 16.0, 'wind_max': 31.0, 'wind_bearing': 268.0, 'rain': 0.2} def test_init_ok(self): w = Weather(**self._test_dict) assert w.wind_current == 16.0 assert w.condition == Condition.suncloud @pytest.mark.parametrize("field_name,field_value,expected_error", [ ('reference_time', 123, 'reference_time is not an instance of datetime.datetime'), ('humidity_current', -1.0, 'humidity must be between 0 and 100'), ('humidity_current', 101.0, 'humidity must be between 0 and 100'), ('humidity_max', -1.0, 'humidity must be between 0 and 100'), ('humidity_max', 101.0, 'humidity must be between 0 and 100'), ('humidity_min', -1.0, 'humidity must be between 0 and 100'), ('humidity_min', 101.0, 'humidity must be between 0 and 100'), ('wind_current', -1.0, 'wind must be greatear than 0'), ('wind_max', -1.0, 'wind must be greatear than 0'), ('wind_bearing', -0.1, 'wind bearing must be between 0 and 360'), ('wind_bearing', 360.1, 'wind bearing must be between 0 and 360'), ('rain', -1.0, 'rain must be greatear than 0') ]) def test_init_fails_when_wrong_data_provided(self, field_name, field_value, expected_error): d1 = self._test_dict.copy() d1[field_name] = field_value with pytest.raises(ValueError) as error: Weather(**d1) assert str(error.value) == expected_error def test_init_when_data_fields_are_none(self): d1 = self._test_dict.copy() for k in d1.keys(): if k != 'reference_time': d1[k] = None w = Weather(**d1) assert w.wind_bearing is None pymeteoclimatic/tests/__init__.py0000664000175000017500000000000014546754332017024 0ustar edwardedwardpymeteoclimatic/README.md0000664000175000017500000001367714546754332015060 0ustar edwardedward# PyMeteoclimatic A Python wrapper around the Meteoclimatic service. [![](https://img.shields.io/pypi/v/pymeteoclimatic)](https://pypi.org/project/pymeteoclimatic/) [![](https://img.shields.io/pypi/pyversions/pymeteoclimatic)](https://pypi.org/project/pymeteoclimatic/) [![Coverage Status](https://coveralls.io/repos/github/adrianmo/pymeteoclimatic/badge.svg?branch=master)](https://coveralls.io/github/adrianmo/pymeteoclimatic?branch=master) [![Build & Test](https://github.com/adrianmo/pymeteoclimatic/workflows/Build%20and%20Test/badge.svg)](https://github.com/adrianmo/pymeteoclimatic/actions?query=workflow%3A%22Build+and+Test%22) [![Publish to Pypi](https://github.com/adrianmo/pymeteoclimatic/workflows/Publish%20to%20Pypi/badge.svg)](https://github.com/adrianmo/pymeteoclimatic/actions?query=workflow%3A%22Publish+to+Pypi%22) PyMeteoclimatic is a client Python wrapper library for [Meteoclimatic](https://www.meteoclimatic.net). Meteoclimatic is a large network of non-professional automatic real-time weather stations and an important directory of weather resources. The geographical scope of Meteoclimatic comprises the Iberian Peninsula, the two Spanish archipelagos (the Balearic Islands and the Canary Islands), southern France and Africa near the Strait of Gibraltar. PyMeteoclimatic relies on the [Meteoclimatic RSS feed](https://www.meteoclimatic.net/index/wp/rss_es.html). More specifically, PyMeteoclimatic leverages the coded, normalized data blocks included as HTML comments in the feeds between the `[[]]` and `[[]]` tags to obtain station weather information. ## What data can I get? With PyMeteoclimatic you can obtain weather information directly from Meteoclimatic stations identified by their code. You can find out the station code from the station profile page in the Meteoclimatic site. When obtaining the weather information from a station, you will get a `meteoclimatic.Observation` object, which represents the weather which is currently being observed from a certain station and contains the following fields. | Field | Type | Description | | --- | --- | --- | | `reception_time` | `datetime.datetime` | Timestamp telling when the weather obervation has been received from the station | | `station` | `meteoclimatic.Station` | The *Station* relative to this observation | | `weather` | `meteoclimatic.Weather` | The *Weather* relative to this observation | A `meteoclimatic.Station` object contains the following data. | Field | Type | Description | | --- | --- | --- | | `name` | `str` | Name of the station | | `code` | `str` | Meteoclimatic code of the station (e.g. "ESCAT4300000043206B") | | `url` | `str` | URL of the Meteoclimatic station page | A `meteoclimatic.Weather` object contains the following data. Note that not all stations have the same physical sensors and capabilities (e.g. pluviometer, barometer, ...), therefore, some of these values may be `None` for some stations. Check the Meteoclimatic station page for more information on your preferred station capabilities. | Field | Type | Description | | --- | --- | --- | | `reference_time` | `datetime.datetime` | Timestamp of weather measurement | | `condition` | `meteoclimatic.Condition` or `str` | Single-word weather condition (e.g. "sun", "suncloud", "rain", ...). If it's a recognized condition, it will be mapped to a value of the `meteoclimatic.Condition` enumerate, otherwise it will be stored as a string | | `temp_current` | `float` | Current temperature in Celsius | | `temp_max` | `float` | Maximum temperature in Celsius for the past 24 hours | | `temp_min` | `float` | Minimum temperature in Celsius for the past 24 hours | | `humidity_current` | `float` | Current humidity in percentage points | | `humidity_max` | `float` | Maximum humidity in percentage points for the past 24 hours | | `humidity_min` | `float` | Minimum humidity in percentage points for the past 24 hours | | `pressure_current` | `float` | Current atmospheric pressure in hPa units | | `pressure_max` | `float` | Maximum atmospheric pressure in hPa units for the past 24 hours | | `pressure_min` | `float` | Minimum atmospheric pressure in hPa units for the past 24 hours | | `wind_current` | `float` | Current wind speed in km/h units | | `wind_max` | `float` | Maximum wind speed in km/h units for the past 24 hours | | `wind_bearing` | `float` | Wind bearing in degree units | | `rain` | `float` | Precipitation in mm units for the past 24 hours | ## Installation Install with `pip` for your ease. ``` $ pip install pymeteoclimatic ``` ## Example ```python from meteoclimatic import MeteoclimaticClient client = MeteoclimaticClient() observation = client.weather_at_station("ESCAT4300000043206B") print("Timestamp") print("~~~~~~~~~") print(observation.reception_time) print() print("Station") print("~~~~~~~") print(observation.station) print() print("Weather") print("~~~~~~~") print(observation.weather) ``` Output: ``` Timestamp ~~~~~~~~~ 2020-06-09 13:45:55+00:00 Station ~~~~~~~ ({'name': 'Reus - Nord (Tarragona)', 'code': 'ESCAT4300000043206B', 'url': 'http://www.meteoclimatic.net/perfil/ESCAT4300000043206B'}) Weather ~~~~~~~ ({'reference_time': datetime.datetime(2020, 6, 9, 13, 45, 55, tzinfo=datetime.timezone.utc), 'condition': , 'temp_current': 24.0, 'temp_max': 24.2, 'temp_min': 13.7, 'humidity_current': 45.0, 'humidity_max': 80.0, 'humidity_min': 44.0, 'pressure_current': 1013.5, 'pressure_max': 1015.3, 'pressure_min': 1013.5, 'wind_current': 13.0, 'wind_max': 31.0, 'wind_bearing': 232.0, 'rain': 0.2}) ``` ## Contributing Please feel free to submit issues or fork the repository and send pull requests to update the library and fix bugs, implement support for new sentence types, refactor code, etc. ## License [MIT License](https://github.com/adrianmo/pymeteoclimatic/blob/master/LICENSE) pymeteoclimatic/.gitignore0000664000175000017500000000403014546754332015550 0ustar edwardedward# VSCode .vscode/ # main.py main.py # 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/ share/python-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/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # 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/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/pymeteoclimatic/test-requirements.txt0000664000175000017500000000003114546754332020016 0ustar edwardedwardflake8 pytest pytest-cov pymeteoclimatic/.gitattributes0000664000175000017500000000010214546754332016447 0ustar edwardedward# Auto detect text files and perform LF normalization * text=auto pymeteoclimatic/LICENSE0000664000175000017500000000203614546754332014571 0ustar edwardedwardCopyright 2020 Adrián Moreno 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. pymeteoclimatic/meteoclimatic/0000775000175000017500000000000014546754332016402 5ustar edwardedwardpymeteoclimatic/meteoclimatic/feed.py0000664000175000017500000000311614546754332017660 0ustar edwardedwardimport re class FeedItemHelper(object): """Helper class to get content from a Meteoclimatic RSS feed item.""" _regex_pattern = r"\[\[\<(?P\w+);\((?P-?[0-9,]+);(?P-?[0-9,]+);(?P-?[0-9,]+);(?P\w*)\);\((?P-?[0-9,]*);(?P-?[0-9,]*);(?P-?[0-9,]*)\);\((?P-?[0-9,]*);(?P-?[0-9,]*);(?P-?[0-9,]*)\);\((?P-?[0-9,]*);(?P-?[0-9,]*);(?P-?[0-9,]*)\);\((?P-?[0-9,]*)\);" # noqa: E501 def __init__(self, feed_item): """Initialize the class.""" self.match = re.search(self._regex_pattern, str(feed_item.description)) if not self.match: raise ValueError("Could not parse station information") def get_text(self, field_name): """Return the value in 'field_name' from the item or None if not found.""" try: value = self.match.group(field_name) except IndexError: return None if len(value) == 0: return None return value def get_float(self, field_name): """Return the value in 'field_name' from the item or None if not found.""" value = self.get_text(field_name) if value is None: return None try: value = float(value.replace(",", ".")) except ValueError: return None if value == -99.0: # Meteoclimatic returns -99,0 when the station does not provide the value return None return value pymeteoclimatic/meteoclimatic/exceptions.py0000664000175000017500000000060314546754332021134 0ustar edwardedwardclass MeteoclimaticError(Exception): """Generic base class for Meteoclimatic exceptions""" pass class StationNotFound(MeteoclimaticError): """Raised when the station code yields no Meteoclimatic station""" def __init__(self, station_code): self.station_code = station_code super().__init__("Station code %s did not return any item" % (station_code, )) pymeteoclimatic/meteoclimatic/weather.py0000664000175000017500000001050614546754332020415 0ustar edwardedwardfrom datetime import datetime from enum import Enum, auto class AutoName(Enum): def _generate_next_value_(name, start, count, last_values): return name.lower() class Condition(AutoName): fog = auto() hazemoon = auto() hazesun = auto() lightning = auto() mist = auto() moon = auto() mooncloud = auto() rain = auto() sun = auto() suncloud = auto() storm = auto() class Weather: """ A class encapsulating raw weather data. :param reference_time: Timestamp of weather measurement :type reference_time: `datetime.datetime` :param condition: Single-word weather condition :type condition: `meteoclimatic.Condition` or `str` :param temp_current: Current temperature in Celsius :type temp_current: `float` :param temp_max: Maximum temperature in Celsius for the past 24 hours :type temp_max: `float` :param temp_min: Minimum temperature in Celsius for the past 24 hours :type temp_min: `float` :param humidity_current: Current humidity in percentage points :type humidity_current: `float` :param humidity_max: Maximum humidity in percentage points for the past 24 hours :type humidity_max: `float` :param humidity_min: Minimum humidity in percentage points for the past 24 hours :type humidity_min: `float` :param pressure_current: Current atmospheric pressure in hPa units :type pressure_current: `float` :param pressure_max: Maximum atmospheric pressure in hPa units for the past 24 hours :type pressure_max: `float` :param pressure_min: Minimum atmospheric pressure in hPa units for the past 24 hours :type pressure_min: `float` :param wind_current: Current wind speed in km/h units :type wind_current: `float` :param wind_max: Maximum wind speed in km/h units for the past 24 hours :type wind_max: `float` :param wind_bearing: Wind bearing in degree units :type wind_bearing: `float` :param rain: Precipitation in mm units for the past 24 hours :type rain: `float` :returns: a *Weather* instance :raises: *ValueError* when invalid values are provided for non-negative or ranged quantities """ def __init__(self, reference_time: datetime, condition: Condition, temp_current: float, temp_max: float, temp_min: float, humidity_current: float, humidity_max: float, humidity_min: float, pressure_current: float, pressure_max: float, pressure_min: float, wind_current: float, wind_max: float, wind_bearing: float, rain: float): """Initialize the class.""" if not isinstance(reference_time, datetime): raise ValueError( "reference_time is not an instance of datetime.datetime") self.reference_time = reference_time self.condition = condition self.temp_current = temp_current self.temp_max = temp_max self.temp_min = temp_min for humidity in [humidity_current, humidity_max, humidity_min]: if humidity is not None and (humidity < 0.0 or humidity > 100.0): raise ValueError("humidity must be between 0 and 100") self.humidity_current = humidity_current self.humidity_max = humidity_max self.humidity_min = humidity_min self.pressure_current = pressure_current self.pressure_max = pressure_max self.pressure_min = pressure_min for wind in [wind_current, wind_max]: if wind is not None and wind < 0.0: raise ValueError("wind must be greatear than 0") self.wind_current = wind_current self.wind_max = wind_max if wind_bearing is not None and (wind_bearing < 0.0 or wind_bearing > 360.0): raise ValueError("wind bearing must be between 0 and 360") self.wind_bearing = wind_bearing if rain is not None and rain < 0.0: raise ValueError("rain must be greatear than 0") self.rain = rain def __eq__(self, other): if not isinstance(other, Weather): return NotImplemented prop_names = list(self.__dict__) for prop in prop_names: if self.__dict__[prop] != other.__dict__[prop]: return False return True def __repr__(self): return "%s(%r)" % (self.__class__, self.__dict__) pymeteoclimatic/meteoclimatic/client.py0000664000175000017500000000205514546754332020234 0ustar edwardedwardfrom urllib.request import urlopen from urllib.error import HTTPError from bs4 import BeautifulSoup from meteoclimatic.exceptions import MeteoclimaticError, StationNotFound from meteoclimatic import Observation class MeteoclimaticClient(object): """ Entry point class providing clients for the Meteoclimatic service. """ _base_url = "https://www.meteoclimatic.net/feed/rss/{station_code}" def weather_at_station(self, station_code): url = self._base_url.format(station_code=station_code) try: parse_xml_url = urlopen(url) except HTTPError as exc: raise MeteoclimaticError("Error fetching station data [status_code=%d]" % (exc.getcode(), )) from exc xml_page = parse_xml_url.read() parse_xml_url.close() soup_page = BeautifulSoup(xml_page, "xml") items = soup_page.findAll("item") if len(items) == 0: raise StationNotFound(station_code) observation = Observation.from_feed_item(items[0]) return observation pymeteoclimatic/meteoclimatic/station.py0000664000175000017500000000214514546754332020437 0ustar edwardedwardclass Station: """ A class representing a Meteoclimatic station. :param name: Name of the station :type name: `str` :param code: Meteoclimatic code of the station :type code: `str` :param url: URL of the station :type url: `str` :returns: a *Station* instance :raises: *ValueError* when invalid empty or null values are provided """ def __init__(self, name: str, code: str, url: str): """Initialize the class.""" if name is None or len(name) == 0: raise ValueError("Station name cannot be empty") self.name = name if code is None or len(code) == 0: raise ValueError("Station code cannot be empty") self.code = code self.url = url def __eq__(self, other): if not isinstance(other, Station): return NotImplemented prop_names = list(self.__dict__) for prop in prop_names: if self.__dict__[prop] != other.__dict__[prop]: return False return True def __repr__(self): return "%s(%r)" % (self.__class__, self.__dict__) pymeteoclimatic/meteoclimatic/observation.py0000664000175000017500000001017114546754332021307 0ustar edwardedwardimport logging from datetime import datetime from meteoclimatic import Station, Weather, Condition from meteoclimatic.feed import FeedItemHelper class Observation: """ A class representing the weather which is currently being observed from a certain station. The station is represented by the encapsulated *Station* object while the observed weather data are held by the encapsulated *Weather* object. :param reception_time: Timestamp telling when the weather obervation has been received from the station :type reception_time: `datetime.datetime` :param station: the *Station* relative to this observation :type station: *Station* :param weather: the *Weather* relative to this observation :type weather: *Weather* :returns: an *Observation* instance :raises: *ValueError* when negative values are provided as reception time """ _feed_datetime_format = "%a, %d %b %Y %H:%M:%S %z" def __init__(self, reception_time: datetime, station: Station, weather: Weather): """Initialize the class.""" if not isinstance(reception_time, datetime): raise ValueError( "reception_time is not an instance of datetime.datetime") self.reception_time = reception_time if not isinstance(station, Station): raise ValueError( "station is not an instance of meteoclimatic.Station") self.station = station if not isinstance(weather, Weather): raise ValueError( "weather is not an instance of meteoclimatic.Weather") self.weather = weather @classmethod def from_feed_item(cls, feed_item): """ Parses an *Observation* instance out of an RSS feed item. :param feed_item: the input RSS feed item :type feed_item: `bs4.element.Tag` :returns: an *Observation* instance :raises: *ValueError* if it is not possible to parse the data """ helper = FeedItemHelper(feed_item) station_name = feed_item.title.text station_code = helper.get_text("station_code") station_url = feed_item.link.text station = Station(station_name, station_code, station_url) reception_time = datetime.strptime( feed_item.pubDate.text, cls._feed_datetime_format) condition_str = helper.get_text("condition") try: condition = Condition(condition_str) except ValueError: logging.info( "Unrecognized condidition '%s', using literal value instead of meteoclimatic.Condition" % (condition_str, )) condition = condition_str temp_current = helper.get_float("temp_current") temp_max = helper.get_float("temp_max") temp_min = helper.get_float("temp_min") humidity_current = helper.get_float("humidity_current") humidity_max = helper.get_float("humidity_max") humidity_min = helper.get_float("humidity_min") pressure_current = helper.get_float("pressure_current") pressure_max = helper.get_float("pressure_max") pressure_min = helper.get_float("pressure_min") wind_current = helper.get_float("wind_current") wind_max = helper.get_float("wind_max") wind_bearing = helper.get_float("wind_bearing") rain = helper.get_float("rain") wind_max = helper.get_float("wind_max") weather = Weather(reception_time, condition, temp_current, temp_max, temp_min, humidity_current, humidity_max, humidity_min, pressure_current, pressure_max, pressure_min, wind_current, wind_max, wind_bearing, rain) return cls(reception_time, station, weather) def __eq__(self, other): if not isinstance(other, Observation): return NotImplemented prop_names = list(self.__dict__) for prop in prop_names: if self.__dict__[prop] != other.__dict__[prop]: return False return True def __repr__(self): return "%s(%r)" % (self.__class__, self.__dict__) pymeteoclimatic/meteoclimatic/__init__.py0000664000175000017500000000037614546754332020521 0ustar edwardedwardfrom meteoclimatic.weather import Weather, Condition # noqa: F401 from meteoclimatic.station import Station # noqa: F401 from meteoclimatic.observation import Observation # noqa: F401 from meteoclimatic.client import MeteoclimaticClient # noqa: F401 pymeteoclimatic/setup.py0000664000175000017500000000273314546754332015302 0ustar edwardedwardfrom setuptools import setup 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='pymeteoclimatic', version='0.1.0', description='A Python wrapper around the Meteoclimatic service', long_description=long_description, long_description_content_type='text/markdown', author='Adrián Moreno', author_email='adrian@morenomartinez.com', url='https://github.com/adrianmo/pymeteoclimatic', packages=['meteoclimatic', ], install_requires=['lxml>=4.5', 'beautifulsoup4>=4.9' ], python_requires='>=3.8', classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Natural Language :: English", "Operating System :: OS Independent", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries"], keywords='meteoclimatic client library api weather', license='MIT', )