├── .github ├── dependabot.yml └── workflows │ ├── CI.yml │ ├── release.yml │ └── semantic-pr-check.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .releaserc.json ├── .vscode ├── launch.json └── settings.json ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── openmeteo_requests ├── Client.py └── __init__.py ├── pyproject.toml └── tests ├── conftest.py └── test_methods.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - patrick.zippenfenig 11 | allow: 12 | - dependency-type: direct 13 | commit-message: 14 | prefix: "fix: " 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | time: "13:00" 20 | commit-message: 21 | prefix: "fix: " 22 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | release: 8 | types: [created] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | validation: 13 | uses: microsoft/action-python/.github/workflows/validation.yml@0.7.3 14 | with: 15 | workdir: '.' 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Release system depends on: 2 | # - .releaserc.json (make sure to select the right branch) 3 | # - .github/workflows/semantic-pr-check.yml 4 | 5 | name: Release 6 | 7 | #on: 8 | # push: 9 | # branches: 10 | # - main 11 | 12 | # Only run releases manually 13 | on: 14 | workflow_dispatch: 15 | 16 | jobs: 17 | release: 18 | name: Release 19 | runs-on: ubuntu-latest 20 | environment: release 21 | permissions: 22 | contents: write 23 | issues: write 24 | pull-requests: write 25 | id-token: write 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | - name: Setup Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: 20 35 | - name: Set up Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: 3.11 39 | - name: Install semantic-release 40 | run: | 41 | npm install --no-save semantic-release @semantic-release/git conventional-changelog-conventionalcommits semantic-release-pypi 42 | - name: Release 43 | env: 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | PYPI_TOKEN: ${{ secrets.PYPI_PASSWORD }} 46 | run: npx semantic-release 47 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pr-check.yml: -------------------------------------------------------------------------------- 1 | name: "Semantic PR Check" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v5.5.3 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | node_modules/ 10 | 11 | test.py 12 | .cache.sqlite 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: update pre-commit hooks" 3 | autofix_commit_msg: "style: pre-commit fixes" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.1.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: end-of-file-fixer 16 | - id: mixed-line-ending 17 | - id: requirements-txt-fixer 18 | - id: trailing-whitespace 19 | 20 | - repo: https://github.com/PyCQA/isort 21 | rev: 5.12.0 22 | hooks: 23 | - id: isort 24 | args: ["-a", "from __future__ import annotations"] 25 | 26 | - repo: https://github.com/asottile/pyupgrade 27 | rev: v2.31.0 28 | hooks: 29 | - id: pyupgrade 30 | args: [--py37-plus] 31 | 32 | - repo: https://github.com/hadialqattan/pycln 33 | rev: v1.2.5 34 | hooks: 35 | - id: pycln 36 | args: [--config=pyproject.toml] 37 | stages: [manual] 38 | 39 | - repo: https://github.com/codespell-project/codespell 40 | rev: v2.1.0 41 | hooks: 42 | - id: codespell 43 | 44 | - repo: https://github.com/pre-commit/pygrep-hooks 45 | rev: v1.9.0 46 | hooks: 47 | - id: python-check-blanket-noqa 48 | - id: python-check-blanket-type-ignore 49 | - id: python-no-log-warn 50 | - id: python-no-eval 51 | - id: python-use-type-annotations 52 | - id: rst-backticks 53 | - id: rst-directive-colons 54 | - id: rst-inline-touching-normal 55 | 56 | - repo: https://github.com/mgedmin/check-manifest 57 | rev: "0.47" 58 | hooks: 59 | - id: check-manifest 60 | stages: [manual] 61 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "preset": "conventionalcommits", 10 | "releaseRules": [ 11 | { 12 | "type": "build", 13 | "scope": "deps", 14 | "release": "patch" 15 | } 16 | ] 17 | } 18 | ], 19 | [ 20 | "@semantic-release/release-notes-generator", 21 | { 22 | "preset": "conventionalcommits", 23 | "presetConfig": { 24 | "types": [ 25 | { 26 | "type": "feat", 27 | "section": "Features" 28 | }, 29 | { 30 | "type": "fix", 31 | "section": "Bug Fixes" 32 | }, 33 | { 34 | "type": "build", 35 | "section": "Dependencies and Other Build Updates", 36 | "hidden": false 37 | } 38 | ] 39 | } 40 | } 41 | ], 42 | "@semantic-release/git", 43 | "@semantic-release/github", 44 | "semantic-release-pypi" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Debug Tests", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "purpose": [ 10 | "debug-test" 11 | ], 12 | "console": "integratedTerminal", 13 | "justMyCode": false, 14 | "env": { 15 | "PYTEST_ADDOPTS": "--no-cov -n0 --dist no" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnPaste": true, 4 | "files.trimTrailingWhitespace": true, 5 | "files.autoSave": "onFocusChange", 6 | "git.autofetch": true, 7 | "[jsonc]": { 8 | "editor.defaultFormatter": "vscode.json-language-features" 9 | }, 10 | "[python]": { 11 | "editor.defaultFormatter": "ms-python.black-formatter" 12 | }, 13 | "python.defaultInterpreterPath": "/usr/local/bin/python", 14 | "python.formatting.provider": "black", 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true, 17 | "pylint.args": [ 18 | "--rcfile=pyproject.toml" 19 | ], 20 | "black-formatter.args": [ 21 | "--config=pyproject.toml" 22 | ], 23 | "flake8.args": [ 24 | "--toml-config=pyproject.toml" 25 | ], 26 | "isort.args": [ 27 | "--settings-path=pyproject.toml" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ### Development 2 | 3 | Install dependencies 4 | 5 | ```bash 6 | pip3 install . 7 | pip3 install ".[test]" 8 | pip3 install pytest-xdist 9 | pre-commit install 10 | ``` 11 | 12 | Run linter and tests 13 | ```bash 14 | black . 15 | flake8 16 | bandit -r openmeteo_requests/ 17 | pylint openmeteo_requests/ 18 | python3 -m pytest tests/ 19 | pre-commit run --all-files 20 | ``` 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Open-Meteo 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open-Meteo API Python Client 2 | 3 | This ia an API client to get weather data from the [Open-Meteo Weather API](https://open-meteo.com) based on the Python library `requests`. 4 | 5 | Instead of using JSON, the API client uses FlatBuffers to transfer data. Encoding data in FlatBuffers is more efficient for long time-series data. Data can be transferred to `numpy`, `pandas`, or `polars` using [Zero-Copy](https://en.wikipedia.org/wiki/Zero-copy) to analyze large amount of data quickly. The schema definition files can be found on [GitHub open-meteo/sdk](https://github.com/open-meteo/sdk). 6 | 7 | This library is primarily designed for data-scientists to process weather data. In combination with the [Open-Meteo Historical Weather API](https://open-meteo.com/en/docs/historical-weather-api) data from 1940 onwards can be analyzed quickly. 8 | 9 | ## Basic Usage 10 | 11 | The following example gets an hourly temperature, wind speed and precipitation forecast for Berlin. Additionally, the current temperature and relative humidity is retrieved. It is recommended to only specify the required weather variables. 12 | 13 | ```python 14 | # pip install openmeteo-requests 15 | 16 | import openmeteo_requests 17 | from openmeteo_sdk.Variable import Variable 18 | 19 | om = openmeteo_requests.Client() 20 | params = { 21 | "latitude": 52.54, 22 | "longitude": 13.41, 23 | "hourly": ["temperature_2m", "precipitation", "wind_speed_10m"], 24 | "current": ["temperature_2m", "relative_humidity_2m"] 25 | } 26 | 27 | responses = om.weather_api("https://api.open-meteo.com/v1/forecast", params=params) 28 | response = responses[0] 29 | print(f"Coordinates {response.Latitude()}°N {response.Longitude()}°E") 30 | print(f"Elevation {response.Elevation()} m asl") 31 | print(f"Timezone {response.Timezone()} {response.TimezoneAbbreviation()}") 32 | print(f"Timezone difference to GMT+0 {response.UtcOffsetSeconds()} s") 33 | 34 | # Current values 35 | current = response.Current() 36 | current_variables = list(map(lambda i: current.Variables(i), range(0, current.VariablesLength()))) 37 | current_temperature_2m = next(filter(lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2, current_variables)) 38 | current_relative_humidity_2m = next(filter(lambda x: x.Variable() == Variable.relative_humidity and x.Altitude() == 2, current_variables)) 39 | 40 | print(f"Current time {current.Time()}") 41 | print(f"Current temperature_2m {current_temperature_2m.Value()}") 42 | print(f"Current relative_humidity_2m {current_relative_humidity_2m.Value()}") 43 | ``` 44 | 45 | or the same but using async/wait: 46 | 47 | ```python 48 | # pip install openmeteo-requests 49 | 50 | import openmeteo_requests 51 | from openmeteo_sdk.Variable import Variable 52 | import asyncio 53 | 54 | async def main(): 55 | om = openmeteo_requests.AsyncClient() 56 | params = { 57 | "latitude": 52.54, 58 | "longitude": 13.41, 59 | "hourly": ["temperature_2m", "precipitation", "wind_speed_10m"], 60 | "current": ["temperature_2m", "relative_humidity_2m"] 61 | } 62 | 63 | responses = await om.weather_api("https://api.open-meteo.com/v1/forecast", params=params) 64 | response = responses[0] 65 | print(f"Coordinates {response.Latitude()}°N {response.Longitude()}°E") 66 | print(f"Elevation {response.Elevation()} m asl") 67 | print(f"Timezone {response.Timezone()} {response.TimezoneAbbreviation()}") 68 | print(f"Timezone difference to GMT+0 {response.UtcOffsetSeconds()} s") 69 | 70 | # Current values 71 | current = response.Current() 72 | current_variables = list(map(lambda i: current.Variables(i), range(0, current.VariablesLength()))) 73 | current_temperature_2m = next(filter(lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2, current_variables)) 74 | current_relative_humidity_2m = next(filter(lambda x: x.Variable() == Variable.relative_humidity and x.Altitude() == 2, current_variables)) 75 | 76 | print(f"Current time {current.Time()}") 77 | print(f"Current temperature_2m {current_temperature_2m.Value()}") 78 | print(f"Current relative_humidity_2m {current_relative_humidity_2m.Value()}") 79 | 80 | asyncio.run(main()) 81 | ``` 82 | 83 | Note 1: You can also supply a list of latitude and longitude coordinates to get data for multiple locations. The API will return a array of results, hence in this example, we only consider the first location with `response = responses[0]`. 84 | 85 | Note 2: Please note the function calls `()` for each attribute like `Latitude()`. Those function calls are necessary due to the FlatBuffers format to dynamically get data from an attribute without expensive parsing. 86 | 87 | ### NumPy 88 | 89 | If you are using `NumPy` you can easily get hourly or daily data as `NumPy` array of type float. 90 | 91 | ```python 92 | import numpy as np 93 | 94 | hourly = response.Hourly() 95 | hourly_time = range(hourly.Time(), hourly.TimeEnd(), hourly.Interval()) 96 | hourly_variables = list(map(lambda i: hourly.Variables(i), range(0, hourly.VariablesLength()))) 97 | 98 | hourly_temperature_2m = next(filter(lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2, hourly_variables)).ValuesAsNumpy() 99 | hourly_precipitation = next(filter(lambda x: x.Variable() == Variable.precipitation, hourly_variables)).ValuesAsNumpy() 100 | hourly_wind_speed_10m = next(filter(lambda x: x.Variable() == Variable.wind_speed and x.Altitude() == 10, hourly_variables)).ValuesAsNumpy() 101 | ``` 102 | 103 | ### Pandas 104 | 105 | After using `NumPy` to create arrays for hourly data, you can use `Pandas` to create a DataFrame from hourly data like follows: 106 | 107 | ```python 108 | import pandas as pd 109 | 110 | hourly_data = {"date": pd.date_range( 111 | start = pd.to_datetime(hourly.Time(), unit = "s"), 112 | end = pd.to_datetime(hourly.TimeEnd(), unit = "s"), 113 | freq = pd.Timedelta(seconds = hourly.Interval()), 114 | inclusive = "left" 115 | )} 116 | hourly_data["temperature_2m"] = hourly_temperature_2m 117 | hourly_data["precipitation"] = hourly_precipitation 118 | hourly_data["wind_speed_10m"] = hourly_wind_speed_10m 119 | 120 | hourly_dataframe_pd = pd.DataFrame(data = hourly_data) 121 | print(hourly_dataframe_pd) 122 | # date temperature_2m precipitation wind_speed_10m 123 | # 0 2024-06-21 00:00:00 17.437000 0.0 6.569383 124 | # 1 2024-06-21 01:00:00 17.087000 0.0 6.151683 125 | # 2 2024-06-21 02:00:00 16.786999 0.0 7.421590 126 | # 3 2024-06-21 03:00:00 16.337000 0.0 5.154416 127 | ``` 128 | 129 | ### Polars 130 | 131 | Additionally, `Polars` can also be used to create a DataFrame from hourly data using the `NumPy` arrays created previously: 132 | 133 | ```python 134 | import polars as pl 135 | from datetime import datetime, timedelta, timezone 136 | 137 | start = datetime.fromtimestamp(hourly.Time(), timezone.utc) 138 | end = datetime.fromtimestamp(hourly.TimeEnd(), timezone.utc) 139 | freq = timedelta(seconds = hourly.Interval()) 140 | 141 | hourly_dataframe_pl = pl.select( 142 | date = pl.datetime_range(start, end, freq, closed = "left"), 143 | temperature_2m = hourly_temperature_2m, 144 | precipitation = hourly_precipitation, 145 | wind_speed_10m = hourly_wind_speed_10m 146 | ) 147 | print(hourly_dataframe_pl) 148 | # ┌─────────────────────────┬────────────────┬───────────────┬────────────────┐ 149 | # │ date ┆ temperature_2m ┆ precipitation ┆ wind_speed_10m │ 150 | # │ --- ┆ --- ┆ --- ┆ --- │ 151 | # │ datetime[μs, UTC] ┆ f32 ┆ f32 ┆ f32 │ 152 | # ╞═════════════════════════╪════════════════╪═══════════════╪════════════════╡ 153 | # │ 2024-06-21 00:00:00 UTC ┆ 17.437 ┆ 0.0 ┆ 6.569383 │ 154 | # │ 2024-06-21 01:00:00 UTC ┆ 17.087 ┆ 0.0 ┆ 6.151683 │ 155 | # │ 2024-06-21 02:00:00 UTC ┆ 16.786999 ┆ 0.0 ┆ 7.42159 │ 156 | # │ 2024-06-21 03:00:00 UTC ┆ 16.337 ┆ 0.0 ┆ 5.154416 │ 157 | ``` 158 | 159 | ### Caching Data 160 | 161 | If you are working with large amounts of data, caching data can make it easier to develop. You can pass a cached session from the library `requests-cache` to the Open-Meteo API client. 162 | 163 | The following example stores all data indefinitely (`expire_after=-1`) in a SQLite database called `.cache.sqlite`. For more options read the [requests-cache documentation](https://pypi.org/project/requests-cache/). 164 | 165 | Additionally, `retry-requests` to automatically retry failed API calls in case there has been any unexpected network or server error. 166 | 167 | ```python 168 | # pip install openmeteo-requests 169 | # pip install requests-cache retry-requests 170 | 171 | import openmeteo_requests 172 | import requests_cache 173 | from retry_requests import retry 174 | 175 | # Setup the Open-Meteo API client with a cache and retry mechanism 176 | cache_session = requests_cache.CachedSession('.cache', expire_after=-1) 177 | retry_session = retry(cache_session, retries=5, backoff_factor=0.2) 178 | om = openmeteo_requests.Client(session=retry_session) 179 | 180 | # Using the client object `om` will now cache all weather data 181 | ``` 182 | 183 | # TODO 184 | 185 | - Document multi location/timeinterval usage 186 | - Document FlatBuffers data structure 187 | - Document time start/end/interval 188 | - Document timezones behavior 189 | - Document pressure level and upper level 190 | - Document endpoints for air quality, etc 191 | - Consider dedicated pandas library to convert responses quickly 192 | 193 | # License 194 | 195 | MIT 196 | -------------------------------------------------------------------------------- /openmeteo_requests/Client.py: -------------------------------------------------------------------------------- 1 | """Open-Meteo API client based on the requests library""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TypeVar 6 | 7 | import niquests as requests 8 | from openmeteo_sdk.WeatherApiResponse import WeatherApiResponse 9 | 10 | T = TypeVar("T") 11 | 12 | 13 | class OpenMeteoRequestsError(Exception): 14 | """Open-Meteo Error""" 15 | 16 | 17 | class Client: 18 | """Open-Meteo API Client""" 19 | 20 | def __init__(self, session: requests.Session | None = None): 21 | self.session = session or requests.Session() 22 | 23 | # pylint: disable=too-many-arguments,too-many-positional-arguments 24 | def _get(self, cls: type[T], url: str, params: any, method: str, verify: bool | str | None, **kwargs) -> list[T]: 25 | params["format"] = "flatbuffers" 26 | 27 | if method.upper() == "POST": 28 | response = self.session.request("POST", url, data=params, verify=verify, **kwargs) 29 | else: 30 | response = self.session.request("GET", url, params=params, verify=verify, **kwargs) 31 | 32 | if response.status_code in [400, 429]: 33 | response_body = response.json() 34 | raise OpenMeteoRequestsError(response_body) 35 | 36 | response.raise_for_status() 37 | 38 | data = response.content 39 | messages = [] 40 | total = len(data) 41 | pos = int(0) 42 | while pos < total: 43 | length = int.from_bytes(data[pos : pos + 4], byteorder="little") 44 | message = cls.GetRootAs(data, pos + 4) 45 | messages.append(message) 46 | pos += length + 4 47 | return messages 48 | 49 | def weather_api( 50 | self, url: str, params: any, method: str = "GET", verify: bool | str | None = None, **kwargs 51 | ) -> list[WeatherApiResponse]: 52 | """Get and decode as weather api""" 53 | return self._get(WeatherApiResponse, url, params, method, verify, **kwargs) 54 | 55 | def __del__(self): 56 | """cleanup""" 57 | self.session.close() 58 | 59 | 60 | # pylint: disable=too-few-public-methods 61 | class AsyncClient: 62 | """Open-Meteo API Client""" 63 | 64 | def __init__(self, session: requests.AsyncSession | None = None): 65 | self.session = session or requests.AsyncSession() 66 | 67 | # pylint: disable=too-many-arguments,too-many-positional-arguments 68 | async def _get( 69 | self, 70 | cls: type[T], 71 | url: str, 72 | params: any, 73 | method: str, 74 | verify: bool | str | None, 75 | **kwargs, 76 | ) -> list[T]: 77 | params["format"] = "flatbuffers" 78 | 79 | if method.upper() == "POST": 80 | response = await self.session.request("POST", url, data=params, verify=verify, **kwargs) 81 | else: 82 | response = await self.session.request("GET", url, params=params, verify=verify, **kwargs) 83 | 84 | if response.status_code in [400, 429]: 85 | response_body = response.json() 86 | raise OpenMeteoRequestsError(response_body) 87 | 88 | response.raise_for_status() 89 | 90 | data = response.content 91 | messages = [] 92 | total = len(data) 93 | pos = 0 94 | while pos < total: 95 | length = int.from_bytes(data[pos : pos + 4], byteorder="little") 96 | message = cls.GetRootAs(data, pos + 4) 97 | messages.append(message) 98 | pos += length + 4 99 | return messages 100 | 101 | async def weather_api( 102 | self, url: str, params: any, method: str = "GET", verify: bool | str | None = None, **kwargs 103 | ) -> list[WeatherApiResponse]: 104 | """Get and decode as weather api""" 105 | return await self._get(WeatherApiResponse, url, params, method, verify, **kwargs) 106 | -------------------------------------------------------------------------------- /openmeteo_requests/__init__.py: -------------------------------------------------------------------------------- 1 | """Open-Meteo api top level exposed interfaces.""" 2 | 3 | from __future__ import annotations 4 | 5 | from openmeteo_requests.Client import AsyncClient, Client 6 | 7 | __all__ = ["Client", "AsyncClient"] 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "openmeteo_requests" 7 | version = "0.0.0" 8 | authors = [{ name = "Patrick Zippenfenig", email = "info@open-meteo.com" }] 9 | description = "Open-Meteo Python Library" 10 | readme = "README.md" 11 | classifiers = [ 12 | "Development Status :: 3 - Alpha", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | ] 21 | requires-python = ">=3.8.1" 22 | dependencies = ["openmeteo_sdk>=1.4.0", "niquests>=3,<4"] 23 | 24 | [project.optional-dependencies] 25 | spark = ["pyspark>=3.0.0"] 26 | test = [ 27 | "bandit[toml]>=1.7.5", 28 | "black>=23.10.0", 29 | "check-manifest>=0.49", 30 | "flake8-bugbear>=23.9.16", 31 | "flake8-docstrings", 32 | "flake8-formatter_junit_xml", 33 | "flake8", 34 | "flake8-pyproject", 35 | "pre-commit>=3.5.0", 36 | "pylint>=3.0.1", 37 | "pylint_junit", 38 | "pytest-cov>=4.1.0", 39 | "pytest-mock>=3.11.2", 40 | "pytest-runner", 41 | "pytest>=7.4.0", 42 | "pytest-asyncio", 43 | "pytest-github-actions-annotate-failures", 44 | "shellcheck-py>=0.9.0.6", 45 | ] 46 | 47 | [project.urls] 48 | Documentation = "https://github.com/open-meteo/python-requests/tree/main#readme" 49 | Source = "https://github.com/open-meteo/python-requests" 50 | Tracker = "https://github.com/open-meteo/python-requests/issues" 51 | 52 | [tool.flit.module] 53 | name = "openmeteo_requests" 54 | 55 | [tool.hatch.version] 56 | path = "openmeteo_requests/__init__.py" 57 | 58 | #[tool.hatch.build.targets.sdist] 59 | #include = [ 60 | # "openmeteo_requests", 61 | #] 62 | 63 | [tool.bandit] 64 | exclude_dirs = ["build", "dist", "tests", "scripts"] 65 | number = 4 66 | recursive = true 67 | targets = "openmeteo_requests" 68 | 69 | [tool.black] 70 | line-length = 120 71 | fast = true 72 | 73 | [tool.isort] 74 | profile = "black" 75 | 76 | [tool.coverage.run] 77 | branch = true 78 | 79 | [tool.coverage.report] 80 | fail_under = 100 81 | 82 | [tool.flake8] 83 | max-line-length = 120 84 | select = "F,E,W,B,B901,B902,B903" 85 | exclude = [ 86 | ".eggs", 87 | ".git", 88 | ".tox", 89 | "nssm", 90 | "obj", 91 | "out", 92 | "packages", 93 | "pywin32", 94 | "tests", 95 | "swagger_client", 96 | "./node_modules", 97 | ] 98 | ignore = ["E722", "B001", "W503", "E203"] 99 | 100 | [tool.pyright] 101 | include = ["openmeteo_requests"] 102 | exclude = ["**/node_modules", "**/__pycache__"] 103 | venv = "env37" 104 | 105 | reportMissingImports = true 106 | reportMissingTypeStubs = false 107 | 108 | pythonVersion = "3.7" 109 | pythonPlatform = "Linux" 110 | 111 | executionEnvironments = [{ root = "openmeteo_requests" }] 112 | 113 | [tool.pytest.ini_options] 114 | addopts = "--cov-report xml:coverage.xml --cov openmeteo_requests --cov-fail-under 0 --cov-append -m 'not integration'" 115 | pythonpath = ["openmeteo_requests"] 116 | testpaths = "tests" 117 | junit_family = "xunit2" 118 | markers = [ 119 | "integration: marks as integration test", 120 | "notebooks: marks as notebook test", 121 | "gpu: marks as gpu test", 122 | "spark: marks tests which need Spark", 123 | "slow: marks tests as slow", 124 | "unit: fast offline tests", 125 | ] 126 | 127 | [tool.tox] 128 | legacy_tox_ini = """ 129 | [tox] 130 | envlist = py, integration, spark, all 131 | 132 | [testenv] 133 | commands = 134 | pytest -m "not integration and not spark" {posargs} 135 | 136 | [testenv:integration] 137 | commands = 138 | pytest -m "integration" {posargs} 139 | 140 | [testenv:spark] 141 | extras = spark 142 | setenv = 143 | PYSPARK_DRIVER_PYTHON = {envpython} 144 | PYSPARK_PYTHON = {envpython} 145 | commands = 146 | pytest -m "spark" {posargs} 147 | 148 | [testenv:all] 149 | extras = all 150 | setenv = 151 | PYSPARK_DRIVER_PYTHON = {envpython} 152 | PYSPARK_PYTHON = {envpython} 153 | commands = 154 | pytest {posargs} 155 | """ 156 | 157 | [tool.pylint] 158 | extension-pkg-whitelist = [ 159 | "numpy", 160 | "torch", 161 | "cv2", 162 | "pyodbc", 163 | "pydantic", 164 | "ciso8601", 165 | "netcdf4", 166 | "scipy", 167 | ] 168 | ignore = "CVS" 169 | ignore-patterns = "test.*?py,conftest.py" 170 | ignore-paths = "src/openmeteo_sdk/fb/" 171 | init-hook = 'import sys; sys.setrecursionlimit(8 * sys.getrecursionlimit())' 172 | jobs = 0 173 | limit-inference-results = 100 174 | persistent = "yes" 175 | suggestion-mode = "yes" 176 | unsafe-load-any-extension = "no" 177 | 178 | [tool.pylint.'MESSAGES CONTROL'] 179 | enable = "c-extension-no-member" 180 | 181 | [tool.pylint.'REPORTS'] 182 | evaluation = "10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)" 183 | output-format = "text" 184 | reports = "no" 185 | score = "yes" 186 | 187 | [tool.pylint.'REFACTORING'] 188 | max-nested-blocks = 5 189 | never-returning-functions = "sys.exit" 190 | 191 | [tool.pylint.'BASIC'] 192 | argument-naming-style = "snake_case" 193 | attr-naming-style = "snake_case" 194 | bad-names = ["foo", "bar"] 195 | class-attribute-naming-style = "any" 196 | class-naming-style = "PascalCase" 197 | const-naming-style = "UPPER_CASE" 198 | docstring-min-length = -1 199 | function-naming-style = "snake_case" 200 | good-names = ["i", "j", "k", "ex", "Run", "_"] 201 | include-naming-hint = "yes" 202 | inlinevar-naming-style = "any" 203 | method-naming-style = "snake_case" 204 | module-naming-style = "any" 205 | no-docstring-rgx = "^_" 206 | property-classes = "abc.abstractproperty" 207 | variable-naming-style = "snake_case" 208 | 209 | [tool.pylint.'FORMAT'] 210 | ignore-long-lines = "^\\s*(# )?.*['\"]??" 211 | indent-after-paren = 4 212 | indent-string = ' ' 213 | max-line-length = 120 214 | max-module-lines = 1000 215 | single-line-class-stmt = "no" 216 | single-line-if-stmt = "no" 217 | 218 | [tool.pylint.'LOGGING'] 219 | logging-format-style = "old" 220 | logging-modules = "logging" 221 | 222 | [tool.pylint.'MISCELLANEOUS'] 223 | notes = ["FIXME", "XXX", "TODO"] 224 | 225 | [tool.pylint.'SIMILARITIES'] 226 | ignore-comments = "yes" 227 | ignore-docstrings = "yes" 228 | ignore-imports = "yes" 229 | min-similarity-lines = 7 230 | 231 | [tool.pylint.'SPELLING'] 232 | max-spelling-suggestions = 4 233 | spelling-store-unknown-words = "no" 234 | 235 | [tool.pylint.'STRING'] 236 | check-str-concat-over-line-jumps = "no" 237 | 238 | [tool.pylint.'TYPECHECK'] 239 | contextmanager-decorators = "contextlib.contextmanager" 240 | generated-members = "numpy.*,np.*,pyspark.sql.functions,collect_list" 241 | ignore-mixin-members = "yes" 242 | ignore-none = "yes" 243 | ignore-on-opaque-inference = "yes" 244 | ignored-classes = "optparse.Values,thread._local,_thread._local,numpy,torch,swagger_client" 245 | ignored-modules = "numpy,torch,swagger_client,netCDF4,scipy" 246 | missing-member-hint = "yes" 247 | missing-member-hint-distance = 1 248 | missing-member-max-choices = 1 249 | 250 | [tool.pylint.'VARIABLES'] 251 | additional-builtins = "dbutils" 252 | allow-global-unused-variables = "yes" 253 | callbacks = ["cb_", "_cb"] 254 | dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" 255 | ignored-argument-names = "_.*|^ignored_|^unused_" 256 | init-import = "no" 257 | redefining-builtins-modules = "six.moves,past.builtins,future.builtins,builtins,io" 258 | 259 | [tool.pylint.'CLASSES'] 260 | defining-attr-methods = ["__init__", "__new__", "setUp", "__post_init__"] 261 | exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make"] 262 | valid-classmethod-first-arg = "cls" 263 | valid-metaclass-classmethod-first-arg = "cls" 264 | 265 | [tool.pylint.'DESIGN'] 266 | max-args = 5 267 | max-attributes = 7 268 | max-bool-expr = 5 269 | max-branches = 12 270 | max-locals = 15 271 | max-parents = 7 272 | max-public-methods = 20 273 | max-returns = 6 274 | max-statements = 50 275 | min-public-methods = 2 276 | 277 | [tool.pylint.'IMPORTS'] 278 | allow-wildcard-with-all = "no" 279 | analyse-fallback-blocks = "no" 280 | deprecated-modules = "optparse,tkinter.tix" 281 | 282 | [tool.pylint.'EXCEPTIONS'] 283 | overgeneral-exceptions = ["uiltins.BaseException", "uiltins.Exception"] 284 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a configuration file for pytest containing customizations and fixtures. 3 | 4 | In VSCode, Code Coverage is recorded in config.xml. Delete this file to reset reporting. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import List 10 | 11 | import pytest 12 | from _pytest.nodes import Item 13 | 14 | 15 | def pytest_collection_modifyitems(items: list[Item]): 16 | for item in items: 17 | if "spark" in item.nodeid: 18 | item.add_marker(pytest.mark.spark) 19 | elif "_int_" in item.nodeid: 20 | item.add_marker(pytest.mark.integration) 21 | 22 | 23 | @pytest.fixture 24 | def unit_test_mocks(monkeypatch: None): 25 | """Include Mocks here to execute all commands offline and fast.""" 26 | pass 27 | -------------------------------------------------------------------------------- /tests/test_methods.py: -------------------------------------------------------------------------------- 1 | """Test client""" 2 | from __future__ import annotations 3 | 4 | import pytest 5 | from openmeteo_sdk.Variable import Variable 6 | 7 | import openmeteo_requests 8 | 9 | 10 | def test_fetch_all(): 11 | om = openmeteo_requests.Client() 12 | params = { 13 | "latitude": [52.54, 48.1, 48.4], 14 | "longitude": [13.41, 9.31, 8.5], 15 | "hourly": ["temperature_2m", "precipitation"], 16 | "start_date": "2023-08-01", 17 | "end_date": "2023-08-02", 18 | "models": "era5_seamless" 19 | # 'timezone': 'auto', 20 | # 'current': ['temperature_2m','precipitation'], 21 | # 'current_weather': 1, 22 | } 23 | 24 | responses = om.weather_api("https://archive-api.open-meteo.com/v1/archive", params=params) 25 | # responses = om.get("http://127.0.0.1:8080/v1/archive", params=params) 26 | assert len(responses) == 3 27 | response = responses[0] 28 | assert response.Latitude() == pytest.approx(52.5) 29 | assert response.Longitude() == pytest.approx(13.4) 30 | response = responses[1] 31 | assert response.Latitude() == pytest.approx(48.1) 32 | assert response.Longitude() == pytest.approx(9.3) 33 | response = responses[0] 34 | 35 | print(f"Coordinates {response.Latitude()}°E {response.Longitude()}°N {response.Elevation()} m asl") 36 | print(f"Timezone {response.Timezone()} {response.TimezoneAbbreviation()} {response.UtcOffsetSeconds()}") 37 | print(f"Generation time {response.GenerationTimeMilliseconds()} ms") 38 | 39 | hourly = response.Hourly() 40 | hourly_variables = list(map(lambda i: hourly.Variables(i), range(0, hourly.VariablesLength()))) 41 | 42 | temperature_2m = next(filter(lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2, hourly_variables)) 43 | precipitation = next(filter(lambda x: x.Variable() == Variable.precipitation, hourly_variables)) 44 | 45 | assert temperature_2m.ValuesLength() == 48 46 | assert precipitation.ValuesLength() == 48 47 | 48 | # print(temperature_2m.ValuesAsNumpy()) 49 | # print(precipitation.ValuesAsNumpy()) 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_async_fetch_all(): 54 | om = openmeteo_requests.AsyncClient() 55 | params = { 56 | "latitude": [52.54, 48.1, 48.4], 57 | "longitude": [13.41, 9.31, 8.5], 58 | "hourly": ["temperature_2m", "precipitation"], 59 | "start_date": "2023-08-01", 60 | "end_date": "2023-08-02", 61 | "models": "era5_seamless" 62 | # 'timezone': 'auto', 63 | # 'current': ['temperature_2m','precipitation'], 64 | # 'current_weather': 1, 65 | } 66 | 67 | responses = await om.weather_api("https://archive-api.open-meteo.com/v1/archive", params=params) 68 | # responses = om.get("http://127.0.0.1:8080/v1/archive", params=params) 69 | assert len(responses) == 3 70 | response = responses[0] 71 | assert response.Latitude() == pytest.approx(52.5) 72 | assert response.Longitude() == pytest.approx(13.4) 73 | response = responses[1] 74 | assert response.Latitude() == pytest.approx(48.1) 75 | assert response.Longitude() == pytest.approx(9.3) 76 | response = responses[0] 77 | 78 | print(f"Coordinates {response.Latitude()}°E {response.Longitude()}°N {response.Elevation()} m asl") 79 | print(f"Timezone {response.Timezone()} {response.TimezoneAbbreviation()} {response.UtcOffsetSeconds()}") 80 | print(f"Generation time {response.GenerationTimeMilliseconds()} ms") 81 | 82 | hourly = response.Hourly() 83 | hourly_variables = list(map(lambda i: hourly.Variables(i), range(0, hourly.VariablesLength()))) 84 | 85 | temperature_2m = next(filter(lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2, hourly_variables)) 86 | precipitation = next(filter(lambda x: x.Variable() == Variable.precipitation, hourly_variables)) 87 | 88 | assert temperature_2m.ValuesLength() == 48 89 | assert precipitation.ValuesLength() == 48 90 | 91 | # print(temperature_2m.ValuesAsNumpy()) 92 | # print(precipitation.ValuesAsNumpy()) 93 | 94 | 95 | def test_int_client(): 96 | """ 97 | This test is marked implicitly as an integration test because the name contains "_init_" 98 | https://docs.pytest.org/en/6.2.x/example/markers.html#automatically-adding-markers-based-on-test-names 99 | """ 100 | --------------------------------------------------------------------------------