diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac4b4f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Python version +PYTHON_VER_MIN_MAJOR=3 +PYTHON_VER_MIN_MINOR=9 + +# Input data +INPUT_DAY_HOURS_START=9 +INPUT_DAY_HOURS_END=19 +INPUT_HOUR_CONDITIONS_SCORE="{'clear': 1, 'partly-cloudy': 1, 'cloudy': 1, 'overcast': 1}" +INPUT_LINKS_PATH="src/core/cities.json" +INPUT_COND_WEIGHT=0.5 +INPUT_TEMP_WEIGHT=0.5 +INPUT_TOP_LOCATIONS_COUNT=5 + +# Output data +OUTPUT_PATH="output.json" diff --git a/.github/workflows/app-testing.yml b/.github/workflows/app-testing.yml index 7f2c15c..fbde5a3 100644 --- a/.github/workflows/app-testing.yml +++ b/.github/workflows/app-testing.yml @@ -1,31 +1,100 @@ -name: build-and-test +name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: - build: + check: + + runs-on: ubuntu-latest + + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + changed_files: ${{ steps.check.outputs.changed_files }} + + steps: + # 1. Checkout the code from the repository + - name: Checkout Code + uses: actions/checkout@v4 + + + # 2. Get a list of changed .py files + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + **.py + + # 3. Set outputs for changed files + - name: Set change flag and files + id: check + run: | + if [ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]; then + echo "has_changes=${{ steps.changed-files.outputs.any_changed }}" >> $GITHUB_OUTPUT + echo "changed_files=${{ steps.changed-files.outputs.all_changed_files }}" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "changed_files=" >> $GITHUB_OUTPUT + fi + + + tests: + needs: [check] + if: ${{ needs.check.outputs.has_changes == 'true' }} + runs-on: ubuntu-latest + strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ['3.12', '3.13'] + steps: - - uses: actions/checkout@v2 + # 1. Checkout the code from the repository + - name: Checkout Code + uses: actions/checkout@v4 + + # 2. Rename .env.example file to .env + - name: Rename env file + run: mv .env.example .env + + # 3. Set up the Python environment - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + + # 4. Install uv + - name: Install uv + uses: astral-sh/setup-uv@v3 + + # 5. Install python + - name: Set up Python + run: uv python install + + # 6. Install the project + - name: Install the project + run: uv sync --all-extras --dev + + # 7. Install missing stub packages + - name: Install missing type stubs + run: | + uv run mypy --install-types --non-interactive . + + # 8. Run Ruff Linter and show all errors + - name: Run Ruff Linter run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 + echo "${{ needs.check.outputs.changed_files }}" | xargs uv run ruff check --output-format=github --config=pyproject.toml + + # 9. Run Mypy Type Checking and show all errors + - name: Run Mypy Type Checking + run: | + echo "${{ needs.check.outputs.changed_files }}" | xargs uv run mypy --config-file=pyproject.toml + + # 10. Run Pytest + - name: Run Pytest run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=119 --statistics --config=setup.cfg + uv run pytest --maxfail=10 --disable-warnings --tb=short diff --git a/.gitignore b/.gitignore index 0d73fbe..e7fca24 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,112 @@ creds.ini .idea/ __pycache__/ *.pyc +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/aws.xml +.idea/**/contentModel.xml +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml +.idea/**/gradle.xml +.idea/**/libraries +cmake-build-*/ +.idea/**/mongoSettings.xml +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +.idea/replstate.xml +.idea/sonarlint/ +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.idea/httpRequests +.idea/caches/build_file_checksums.ser +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +*.mo +*.pot +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +instance/ +.webassets-cache +.scrapy +docs/_build/ +.pybuilder/ +target/ +.ipynb_checkpoints +profile_default/ +ipython_config.py +.pdm.toml +__pypackages__/ +celerybeat-schedule +celerybeat.pid +*.sage.py +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.spyderproject +.spyproject +.ropeproject +/site +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +cython_debug/ +mypy_cache/ +ruff_cache/ + +output.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fbe42e2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: no-commit-to-branch + - id: debug-statements + - id: check-json + - id: name-tests-test + args: [--pytest-test-first] + - id: pretty-format-json + args: [--autofix, --indent, '2'] + - id: check-added-large-files + +- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.14.0 + hooks: + - id: pretty-format-toml + args: [--autofix, --indent, '2', --inline-comment-spaces, '2', --trailing-commas] + - id: pretty-format-yaml + args: [--autofix, --indent, '2'] + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff-format + args: [--config=pyproject.toml] + - id: ruff + args: [--config=pyproject.toml, --fix, --exit-non-zero-on-fix] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + args: [--config-file=pyproject.toml, --install-types, --non-interactive] + additional_dependencies: + - pydantic diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c67ddd4 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +ONESHELL: + +.PHONY: env +env: + @find . -name ".env.example" | while read file; do \ + cp "$$file" "$$(dirname $$file)/.env"; \ + done + + +.PHONY: sync +sync: + @uv sync --frozen --all-extras + +.PHONY: setup +setup: + @curl -LsSf https://astral.sh/uv/install.sh | sh + +.PHONY: upd_hooks +upd_hooks: + @pre-commit clean + @pre-commit install --install-hooks + +.PHONY: check +check: + @git add . + @pre-commit run + +.PHONY: up +up: env setup sync + +.PHONY: run +run: sync env + @python -m src.main + +.PHONY: test +test: + pytest diff --git a/README.md b/README.md index 5c3e6eb..5ca4224 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,34 @@ -# Проектное задание первого спринта - -Ваша задача — проанализировать данные по погодным условиям, полученные от API Яндекс Погоды. - -## Описание задания - -**1. Получите информацию о погодных условиях для указанного списка городов, используя API Яндекс Погоды.** - -
- Описание - -Список городов находится в переменной `CITIES` в файле [utils.py](utils.py). Для взаимодействия с API используйте готовый класс `YandexWeatherAPI` в модуле `external/client.py`. Пример работы с классом `YandexWeatherAPI` описан в примере. Пример ответа от API для анализа вы найдёте в [файле](examples/response.json). - -
- -**2. Вычислите среднюю температуру и проанализируйте информацию об осадках за указанный период для всех городов.** - -
- Описание - -Условия и требования: -- период вычислений в течение дня — с 9 до 19 часов; -- средняя температура рассчитывается за указанный промежуток времени; -- сумма времени (часов), когда погода без осадков (без дождя, снега, града или грозы), рассчитывается за указанный промежуток времени; -- информация о температуре для указанного дня за определённый час находится по следующему пути: `forecasts> [день]> hours> temp`; -- информация об осадках для указанного дня за определённый час находится по следующему пути: `forecasts> [день]> hours> condition`. - -[Пример данных](examples/response-day-info.png) с информацией о температуре и осадках за день. - -Список вариантов погодных условий находится [в таблице в блоке `condition`](https://yandex.ru/dev/weather/doc/dg/concepts/forecast-test.html#resp-format__forecasts) или в [файле](examples/conditions.txt). - -Для анализа данных используйте подготовленный скрипт в модуле `external/analyzer.py`. Скрипт имеет два параметра запуска: -- `-i` – путь до файла с данными, как результат ответа от `YandexWeatherAPI` в формате `json`; -- `-o` – путь до файла для сохранения результата выполнения работы. - -Пример запуска скрипта: +# Weather Data Analysis +![Python](https://img.shields.io/badge/python-3.13-blue) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Actions status](https://github.com/agredyaev/async-python-sprint-1/actions/workflows/app-testing.yml/badge.svg)](https://github.com/agredyaev/async-python-sprint-1/actions) +![Pydantic](https://img.shields.io/badge/Pydantic-red?logo=pydantic&logoColor=white) +![HTTPX](https://img.shields.io/badge/HTTPX-green?logo=httpx&logoColor=white) +![Polars](https://img.shields.io/badge/Polars-blue?logo=polars&logoColor=white) +[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +## Project Overview + +This project analyzes weather conditions using data from the Yandex Weather API. The task involves retrieving weather data for a list of cities, calculating the average temperature and analyzing precipitation conditions for a specific period within a day. + +### Key Features +- **FetchTask**: Fetches weather data from an external API (e.g., YandexWeatherAPI). +- **ExtractTask**: Extracts and validates relevant weather data. +- **TransformTask**: Processes and transforms raw weather data into structured formats. +- **AnalyzeTask**: Analyzes transformed data to compute key metrics like weather scores. + +## Deploy ```bash -python3 external/analyzer.py -i examples/response.json -o output.json -``` - -[Пример данных](examples/output.json) с информацией об анализе данных для одного города за период времени, указанный во входном файле. - - -
- -**3. Объедините полученные данные и сохраните результат в текстовом файле.** - -
- Описание - -Формат сохраняемого файла – **json**, **yml**, **csv** или **xls/xlsx**. - -Возможный формат таблицы для сохранения, где рейтинг — это позиция города относительно других при анализе «благоприятности поездки» (п.4). - -| Город/день | | 14-06 | ... | 19-06 | Среднее | Рейтинг | -|-------------|:--------------------------|:-----:|:---:|:-----:|--------:|--------:| -| Москва | Температура, среднее | 24 | | 27 | 25.6 | 8 | -| | Без осадков, часов | 8 | | 4 | 6 | | -| Абу-Даби | Температура, среднее | 34 | | 37 | 35.5 | 2 | -| | Без осадков, часов | 9 | | 10 | 9.5 | | -| ... | | | | | | | - -
- - -**4. Проанализируйте результат и сделайте вывод, какой из городов наиболее благоприятен для поездки.** - -
- Описание - -Наиболее благоприятным городом считать тот, в котором средняя температура за всё время была самой высокой, а количество времени без осадков — максимальным. -Если таких городов более одного, то выводить все. - -
- -## Требования к решению - -1. Используйте для решения как процессы, так и потоки. Для этого разделите все задачи по их типу – IO-bound или CPU-bound. -2. Используйте для решения и очередь, и пул задач. -3. Опишите этапы решения в виде отдельных классов в модуле [tasks.py](tasks.py): - - `DataFetchingTask` — получение данных через API; - - `DataCalculationTask` — вычисление погодных параметров; - - `DataAggregationTask` — объединение вычисленных данных; - - `DataAnalyzingTask` — финальный анализ и получение результата. -4. Используйте концепции ООП. -5. Предусмотрите обработку исключительных ситуаций. -6. Логируйте результаты действий. -7. Используйте аннотацию типов. -8. Приведите стиль кода в соответствие pep8, flake8, mypy. - - -## Рекомендации к решению - -1. Предусмотрите и обработайте ситуации с некорректным обращением к внешнему API: отсутствующая/битая ссылка, неверный ответ, невалидное содержимое или иной формат ответа. -2. Покройте написанный код тестами. -3. Используйте таймауты для ограничения времени выполнения частей программы и принудительного завершения при зависаниях или нештатных ситуациях. - - ---- - - - -## Пример использования `YandexWeatherAPI` для работы с API - -```python -from external.client import YandexWeatherAPI -from utils import get_url_by_city_name - -city_name = "MOSCOW" -url_with_data = get_url_by_city_name(city_name) -resp = YandexWeatherAPI.get_forecasting(data_url) +# clone the repository +git clone https://github.com/agredyaev/async-python-sprint-1.git +cd async-python-sprint-1 +# setup the environment +make setup +# activate the virtual environment +. ./.venv/bin/activate +# run the app +make run +# run the tests +make test ``` diff --git a/api_test.py b/api_test.py deleted file mode 100644 index 9dc3952..0000000 --- a/api_test.py +++ /dev/null @@ -1,35 +0,0 @@ -import subprocess - - -def check_python_version(): - from utils import check_python_version - - check_python_version() - - -def check_api(): - from external.client import YandexWeatherAPI - from utils import get_url_by_city_name - - CITY_NAME_FOR_TEST = "MOSCOW" - - data_url = get_url_by_city_name(CITY_NAME_FOR_TEST) - resp = YandexWeatherAPI.get_forecasting(data_url) - all_keys = resp.keys() - print(all_keys) - print(resp["info"]) - - # command_to_execute = [ - # "python3", - # "./external/analyzer.py", - # "-i", - # "./examples/response.json", - # "-o", - # "./output.json", - # ] - # run = subprocess.run(command_to_execute, capture_output=True) - - -if __name__ == "__main__": - check_python_version() - check_api() diff --git a/examples/conditions.txt b/examples/conditions.txt deleted file mode 100644 index c9cbbf3..0000000 --- a/examples/conditions.txt +++ /dev/null @@ -1,20 +0,0 @@ -Код расшифровки погодного описания. Возможные значения: -clear — ясно. -partly-cloudy — малооблачно. -cloudy — облачно с прояснениями. -overcast — пасмурно. -drizzle — морось. -light-rain — небольшой дождь. -rain — дождь. -moderate-rain — умеренно сильный дождь. -heavy-rain — сильный дождь. -continuous-heavy-rain — длительный сильный дождь. -showers — ливень. -wet-snow — дождь со снегом. -light-snow — небольшой снег. -snow — снег. -snow-showers — снегопад. -hail — град. -thunderstorm — гроза. -thunderstorm-with-rain — дождь с грозой. -thunderstorm-with-hail — гроза с градом. \ No newline at end of file diff --git a/examples/output.json b/examples/output.json deleted file mode 100644 index ad30299..0000000 --- a/examples/output.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "days": [ - { - "date": "2022-05-18", - "hours_start": 9, - "hours_end": 19, - "hours_count": 11, - "temp_avg": 13.091, - "relevant_cond_hours": 11 - }, - { - "date": "2022-05-19", - "hours_start": 9, - "hours_end": 19, - "hours_count": 11, - "temp_avg": 10.727, - "relevant_cond_hours": 5 - }, - { - "date": "2022-05-20", - "hours_start": 9, - "hours_end": 19, - "hours_count": 11, - "temp_avg": 11.364, - "relevant_cond_hours": 11 - }, - { - "date": "2022-05-21", - "hours_start": null, - "hours_end": null, - "hours_count": 0, - "temp_avg": null, - "relevant_cond_hours": 0 - }, - { - "date": "2022-05-22", - "hours_start": null, - "hours_end": null, - "hours_count": 0, - "temp_avg": null, - "relevant_cond_hours": 0 - } - ] -} \ No newline at end of file diff --git a/examples/response-day-info.png b/examples/response-day-info.png deleted file mode 100644 index 598976f..0000000 Binary files a/examples/response-day-info.png and /dev/null differ diff --git a/examples/response.json b/examples/response.json deleted file mode 100644 index e7fcdcd..0000000 --- a/examples/response.json +++ /dev/null @@ -1,1933 +0,0 @@ -{ - "now": 1652833102, - "now_dt": "2022-05-18T00:18:22.763458Z", - "info": { - "n": true, - "geoid": 213, - "url": "https://yandex.com/weather/213?lat=55.753&lon=37.616", - "lat": 55.753, - "lon": 37.616, - "tzinfo": { - "name": "Europe/Moscow", - "abbr": "MSK", - "dst": false, - "offset": 10800 - }, - "def_pressure_mm": 745, - "def_pressure_pa": 993, - "slug": "213", - "zoom": 10, - "nr": true, - "ns": true, - "nsr": true, - "p": false, - "f": true, - "_h": false - }, - "geo_object": { - "district": { - "id": 120540, - "name": "Tverskoy District" - }, - "locality": { - "id": 213, - "name": "Moscow" - }, - "province": { - "id": 213, - "name": "Moscow" - }, - "country": { - "id": 225, - "name": "Russian Federation" - } - }, - "yesterday": { - "temp": 8 - }, - "fact": { - "obs_time": 1652832000, - "uptime": 1652833102, - "temp": 9, - "feels_like": 5, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_prob": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_speed": 3.6, - "wind_dir": "n", - "pressure_mm": 741, - "pressure_pa": 987, - "humidity": 79, - "daytime": "n", - "polar": false, - "season": "spring", - "source": "station", - "accum_prec": { - "1": 18.13, - "7": 29.914318, - "3": 22.819426 - }, - "soil_moisture": 0.34, - "soil_temp": 8, - "uv_index": 0, - "wind_gust": 7.8 - }, - "forecasts": [ - { - "date": "2022-05-18", - "date_ts": 1652821200, - "week": 20, - "sunrise": "04:13", - "sunset": "20:38", - "rise_begin": "03:22", - "set_end": "21:29", - "moon_code": 1, - "moon_text": "moon-code-1", - "hours": [ - { - "hour": "0", - "hour_ts": 1652821200, - "temp": 10, - "feels_like": 7, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.3, - "wind_gust": 7.5, - "pressure_mm": 740, - "pressure_pa": 986, - "humidity": 81, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "1", - "hour_ts": 1652824800, - "temp": 9, - "feels_like": 6, - "icon": "ovc_ra", - "condition": "rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.5, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.6, - "wind_gust": 7.5, - "pressure_mm": 740, - "pressure_pa": 986, - "humidity": 83, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.34, - "prec_mm": 0.5, - "prec_period": 60, - "prec_prob": 90 - }, - { - "hour": "2", - "hour_ts": 1652828400, - "temp": 9, - "feels_like": 6, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.6, - "wind_gust": 7.5, - "pressure_mm": 740, - "pressure_pa": 986, - "humidity": 81, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "3", - "hour_ts": 1652832000, - "temp": 9, - "feels_like": 6, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.5, - "wind_gust": 7.8, - "pressure_mm": 741, - "pressure_pa": 987, - "humidity": 80, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "4", - "hour_ts": 1652835600, - "temp": 9, - "feels_like": 5, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.9, - "wind_gust": 7.8, - "pressure_mm": 741, - "pressure_pa": 987, - "humidity": 79, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "5", - "hour_ts": 1652839200, - "temp": 8, - "feels_like": 5, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.8, - "wind_gust": 7.8, - "pressure_mm": 741, - "pressure_pa": 987, - "humidity": 78, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "6", - "hour_ts": 1652842800, - "temp": 8, - "feels_like": 5, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.6, - "wind_gust": 7.7, - "pressure_mm": 742, - "pressure_pa": 989, - "humidity": 79, - "uv_index": 0, - "soil_temp": 7, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "7", - "hour_ts": 1652846400, - "temp": 9, - "feels_like": 5, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.3, - "wind_gust": 7.7, - "pressure_mm": 742, - "pressure_pa": 989, - "humidity": 73, - "uv_index": 1, - "soil_temp": 7, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "8", - "hour_ts": 1652850000, - "temp": 10, - "feels_like": 6, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.4, - "wind_gust": 7.7, - "pressure_mm": 742, - "pressure_pa": 989, - "humidity": 69, - "uv_index": 1, - "soil_temp": 7, - "soil_moisture": 0.34, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "9", - "hour_ts": 1652853600, - "temp": 11, - "feels_like": 7, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.4, - "wind_gust": 9.8, - "pressure_mm": 743, - "pressure_pa": 990, - "humidity": 63, - "uv_index": 2, - "soil_temp": 10, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "10", - "hour_ts": 1652857200, - "temp": 12, - "feels_like": 8, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.8, - "wind_gust": 9.8, - "pressure_mm": 743, - "pressure_pa": 990, - "humidity": 59, - "uv_index": 3, - "soil_temp": 10, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "11", - "hour_ts": 1652860800, - "temp": 13, - "feels_like": 9, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.7, - "wind_gust": 9.8, - "pressure_mm": 742, - "pressure_pa": 989, - "humidity": 56, - "uv_index": 4, - "soil_temp": 10, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "12", - "hour_ts": 1652864400, - "temp": 14, - "feels_like": 10, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 5.1, - "wind_gust": 11.6, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 47, - "uv_index": 5, - "soil_temp": 13, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "13", - "hour_ts": 1652868000, - "temp": 14, - "feels_like": 9, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 5.6, - "wind_gust": 11.6, - "pressure_mm": 743, - "pressure_pa": 990, - "humidity": 45, - "uv_index": 4, - "soil_temp": 13, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "14", - "hour_ts": 1652871600, - "temp": 15, - "feels_like": 10, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 5.6, - "wind_gust": 11.6, - "pressure_mm": 743, - "pressure_pa": 990, - "humidity": 45, - "uv_index": 4, - "soil_temp": 13, - "soil_moisture": 0.33, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "15", - "hour_ts": 1652875200, - "temp": 14, - "feels_like": 9, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.8, - "wind_gust": 12.3, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 48, - "uv_index": 3, - "soil_temp": 14, - "soil_moisture": 0.32, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "16", - "hour_ts": 1652878800, - "temp": 14, - "feels_like": 8, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 6.1, - "wind_gust": 12.3, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 49, - "uv_index": 1, - "soil_temp": 14, - "soil_moisture": 0.32, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "17", - "hour_ts": 1652882400, - "temp": 13, - "feels_like": 7, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 6.1, - "wind_gust": 12.3, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 49, - "uv_index": 1, - "soil_temp": 14, - "soil_moisture": 0.32, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "18", - "hour_ts": 1652886000, - "temp": 12, - "feels_like": 7, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.3, - "wind_gust": 11.6, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 53, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "19", - "hour_ts": 1652889600, - "temp": 12, - "feels_like": 7, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.3, - "wind_gust": 11.6, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 55, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "20", - "hour_ts": 1652893200, - "temp": 11, - "feels_like": 6, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 4.8, - "wind_gust": 11.6, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 56, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "21", - "hour_ts": 1652896800, - "temp": 11, - "feels_like": 7, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.1, - "wind_gust": 8.3, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 52, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "22", - "hour_ts": 1652900400, - "temp": 10, - "feels_like": 6, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.4, - "wind_gust": 8.3, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 57, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "23", - "hour_ts": 1652904000, - "temp": 9, - "feels_like": 5, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.1, - "wind_gust": 8.3, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 60, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - } - ], - "biomet": { - "index": 0, - "condition": "magnetic-field_0" - } - }, - { - "date": "2022-05-19", - "date_ts": 1652907600, - "week": 20, - "sunrise": "04:11", - "sunset": "20:40", - "rise_begin": "03:20", - "set_end": "21:31", - "moon_code": 2, - "moon_text": "moon-code-2", - "hours": [ - { - "hour": "0", - "hour_ts": 1652907600, - "temp": 9, - "feels_like": 6, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 1.8, - "wind_gust": 4.1, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 65, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "1", - "hour_ts": 1652911200, - "temp": 9, - "feels_like": 6, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 2, - "wind_gust": 4.1, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 68, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "2", - "hour_ts": 1652914800, - "temp": 9, - "feels_like": 6, - "icon": "ovc", - "condition": "overcast", - "cloudness": 1, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 2, - "wind_gust": 4.1, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 67, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "3", - "hour_ts": 1652918400, - "temp": 8, - "feels_like": 4, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 3.3, - "wind_gust": 8, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 68, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "4", - "hour_ts": 1652922000, - "temp": 8, - "feels_like": 4, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 3.8, - "wind_gust": 8, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 69, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "5", - "hour_ts": 1652925600, - "temp": 8, - "feels_like": 4, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 3.7, - "wind_gust": 8, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 70, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "6", - "hour_ts": 1652929200, - "temp": 8, - "feels_like": 4, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 3.9, - "wind_gust": 9.2, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 67, - "uv_index": 0, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "7", - "hour_ts": 1652932800, - "temp": 9, - "feels_like": 4, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 4.6, - "wind_gust": 9.2, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 65, - "uv_index": 1, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "8", - "hour_ts": 1652936400, - "temp": 10, - "feels_like": 5, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "w", - "wind_speed": 4.7, - "wind_gust": 9.2, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 62, - "uv_index": 1, - "soil_temp": 8, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "9", - "hour_ts": 1652940000, - "temp": 10, - "feels_like": 5, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.2, - "wind_gust": 11.7, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 63, - "uv_index": 2, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "10", - "hour_ts": 1652943600, - "temp": 9, - "feels_like": 4, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.9, - "wind_gust": 11.7, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 66, - "uv_index": 1, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "11", - "hour_ts": 1652947200, - "temp": 9, - "feels_like": 4, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 5.7, - "wind_gust": 11.7, - "pressure_mm": 744, - "pressure_pa": 991, - "humidity": 67, - "uv_index": 2, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "12", - "hour_ts": 1652950800, - "temp": 10, - "feels_like": 6, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.6, - "wind_gust": 8.2, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 71, - "uv_index": 2, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "13", - "hour_ts": 1652954400, - "temp": 10, - "feels_like": 7, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 4.4, - "wind_gust": 8.2, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 66, - "uv_index": 3, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "14", - "hour_ts": 1652958000, - "temp": 11, - "feels_like": 7, - "icon": "ovc_-ra", - "condition": "light-rain", - "cloudness": 1, - "prec_type": 1, - "prec_strength": 0.25, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 4.5, - "wind_gust": 8.2, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 65, - "uv_index": 3, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0.2, - "prec_period": 60, - "prec_prob": 20 - }, - { - "hour": "15", - "hour_ts": 1652961600, - "temp": 11, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.7, - "wind_gust": 8.3, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 60, - "uv_index": 3, - "soil_temp": 11, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "16", - "hour_ts": 1652965200, - "temp": 12, - "feels_like": 7, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 4.2, - "wind_gust": 8.3, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 55, - "uv_index": 2, - "soil_temp": 11, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "17", - "hour_ts": 1652968800, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.8, - "wind_gust": 8.3, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 54, - "uv_index": 1, - "soil_temp": 11, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "18", - "hour_ts": 1652972400, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.3, - "wind_gust": 8.5, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 50, - "uv_index": 1, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "19", - "hour_ts": 1652976000, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3.3, - "wind_gust": 8.5, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 53, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "20", - "hour_ts": 1652979600, - "temp": 11, - "feels_like": 7, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 3, - "wind_gust": 8.5, - "pressure_mm": 745, - "pressure_pa": 993, - "humidity": 57, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "21", - "hour_ts": 1652983200, - "temp": 10, - "feels_like": 8, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.2, - "wind_gust": 4, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 60, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "22", - "hour_ts": 1652986800, - "temp": 9, - "feels_like": 6, - "icon": "bkn_n", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.3, - "wind_gust": 4, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 65, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "23", - "hour_ts": 1652990400, - "temp": 8, - "feels_like": 6, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.2, - "wind_gust": 4, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 69, - "uv_index": 0, - "soil_temp": 10, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - } - ], - "biomet": { - "index": 0, - "condition": "magnetic-field_0" - } - }, - { - "date": "2022-05-20", - "date_ts": 1652994000, - "week": 20, - "sunrise": "04:09", - "sunset": "20:42", - "rise_begin": "03:18", - "set_end": "21:33", - "moon_code": 2, - "moon_text": "moon-code-2", - "hours": [ - { - "hour": "0", - "hour_ts": 1652994000, - "temp": 7, - "feels_like": 5, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.1, - "wind_gust": 4.2, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 72, - "uv_index": 0, - "soil_temp": 7, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "1", - "hour_ts": 1652997600, - "temp": 7, - "feels_like": 4, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.1, - "wind_gust": 4.2, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 76, - "uv_index": 0, - "soil_temp": 7, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "2", - "hour_ts": 1653001200, - "temp": 6, - "feels_like": 4, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "nw", - "wind_speed": 1.3, - "wind_gust": 4.2, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 77, - "uv_index": 0, - "soil_temp": 7, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "3", - "hour_ts": 1653004800, - "temp": 6, - "feels_like": 3, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 1.8, - "wind_gust": 6.3, - "pressure_mm": 746, - "pressure_pa": 994, - "humidity": 76, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "4", - "hour_ts": 1653008400, - "temp": 5, - "feels_like": 2, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2, - "wind_gust": 6.3, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 78, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "5", - "hour_ts": 1653012000, - "temp": 6, - "feels_like": 3, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2, - "wind_gust": 6.3, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 77, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "6", - "hour_ts": 1653015600, - "temp": 7, - "feels_like": 3, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2.3, - "wind_gust": 8, - "pressure_mm": 749, - "pressure_pa": 998, - "humidity": 73, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "7", - "hour_ts": 1653019200, - "temp": 7, - "feels_like": 4, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3, - "wind_gust": 8, - "pressure_mm": 749, - "pressure_pa": 998, - "humidity": 67, - "uv_index": 1, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "8", - "hour_ts": 1653022800, - "temp": 8, - "feels_like": 4, - "icon": "skc_d", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.1, - "wind_gust": 8, - "pressure_mm": 749, - "pressure_pa": 998, - "humidity": 63, - "uv_index": 1, - "soil_temp": 6, - "soil_moisture": 0.31, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "9", - "hour_ts": 1653026400, - "temp": 9, - "feels_like": 4, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.6, - "wind_gust": 11.2, - "pressure_mm": 750, - "pressure_pa": 999, - "humidity": 58, - "uv_index": 2, - "soil_temp": 8, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "10", - "hour_ts": 1653030000, - "temp": 9, - "feels_like": 5, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.1, - "wind_gust": 11.2, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 55, - "uv_index": 3, - "soil_temp": 8, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "11", - "hour_ts": 1653033600, - "temp": 10, - "feels_like": 6, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 4.2, - "wind_gust": 11.2, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 51, - "uv_index": 4, - "soil_temp": 8, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "12", - "hour_ts": 1653037200, - "temp": 11, - "feels_like": 7, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.3, - "wind_gust": 9.9, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 45, - "uv_index": 5, - "soil_temp": 11, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "13", - "hour_ts": 1653040800, - "temp": 11, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.7, - "wind_gust": 9.9, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 43, - "uv_index": 5, - "soil_temp": 11, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "14", - "hour_ts": 1653044400, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.7, - "wind_gust": 9.9, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 41, - "uv_index": 4, - "soil_temp": 11, - "soil_moisture": 0.3, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "15", - "hour_ts": 1653048000, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.4, - "wind_gust": 9.8, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 36, - "uv_index": 3, - "soil_temp": 13, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "16", - "hour_ts": 1653051600, - "temp": 13, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.5, - "wind_gust": 9.8, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 34, - "uv_index": 2, - "soil_temp": 13, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "17", - "hour_ts": 1653055200, - "temp": 13, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 3.4, - "wind_gust": 9.8, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 34, - "uv_index": 1, - "soil_temp": 13, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "18", - "hour_ts": 1653058800, - "temp": 13, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2.8, - "wind_gust": 9.3, - "pressure_mm": 747, - "pressure_pa": 995, - "humidity": 36, - "uv_index": 1, - "soil_temp": 12, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "19", - "hour_ts": 1653062400, - "temp": 12, - "feels_like": 8, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2.7, - "wind_gust": 9.3, - "pressure_mm": 747, - "pressure_pa": 995, - "humidity": 41, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "20", - "hour_ts": 1653066000, - "temp": 11, - "feels_like": 7, - "icon": "bkn_d", - "condition": "cloudy", - "cloudness": 0.5, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "n", - "wind_speed": 2.6, - "wind_gust": 9.3, - "pressure_mm": 747, - "pressure_pa": 995, - "humidity": 45, - "uv_index": 0, - "soil_temp": 12, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "21", - "hour_ts": 1653069600, - "temp": 10, - "feels_like": 7, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "ne", - "wind_speed": 1.5, - "wind_gust": 4.2, - "pressure_mm": 748, - "pressure_pa": 997, - "humidity": 49, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "22", - "hour_ts": 1653073200, - "temp": 8, - "feels_like": 5, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "ne", - "wind_speed": 1.6, - "wind_gust": 4.2, - "pressure_mm": 750, - "pressure_pa": 999, - "humidity": 54, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "23", - "hour_ts": 1653076800, - "temp": 7, - "feels_like": 4, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "ne", - "wind_speed": 1.8, - "wind_gust": 4.2, - "pressure_mm": 750, - "pressure_pa": 999, - "humidity": 58, - "uv_index": 0, - "soil_temp": 9, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - } - ], - "biomet": { - "index": 0, - "condition": "magnetic-field_0" - } - }, - { - "date": "2022-05-21", - "date_ts": 1653080400, - "week": 20, - "sunrise": "04:08", - "sunset": "20:44", - "rise_begin": "03:16", - "set_end": "21:35", - "moon_code": 3, - "moon_text": "moon-code-3", - "hours": [ - { - "hour": "0", - "hour_ts": 1653080400, - "temp": 7, - "feels_like": 4, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "ne", - "wind_speed": 1.1, - "wind_gust": 3.3, - "pressure_mm": 749, - "pressure_pa": 998, - "humidity": 63, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - }, - { - "hour": "1", - "hour_ts": 1653084000, - "temp": 6, - "feels_like": 3, - "icon": "skc_n", - "condition": "clear", - "cloudness": 0, - "prec_type": 0, - "prec_strength": 0, - "is_thunder": false, - "wind_dir": "ne", - "wind_speed": 1.1, - "wind_gust": 3.3, - "pressure_mm": 749, - "pressure_pa": 998, - "humidity": 67, - "uv_index": 0, - "soil_temp": 6, - "soil_moisture": 0.29, - "prec_mm": 0, - "prec_period": 60, - "prec_prob": 0 - } - ] - }, - { - "date": "2022-05-22", - "date_ts": 1653166800, - "week": 20, - "sunrise": "04:06", - "sunset": "20:45", - "rise_begin": "03:14", - "set_end": "21:37", - "moon_code": 3, - "moon_text": "moon-code-3", - "hours": [] - } - ] -} \ No newline at end of file diff --git a/external/analyzer.py b/external/analyzer.py deleted file mode 100644 index 56663e1..0000000 --- a/external/analyzer.py +++ /dev/null @@ -1,217 +0,0 @@ -import argparse -import json -import logging -from dataclasses import dataclass, field -from functools import reduce -from operator import getitem -from typing import Optional, List, Dict - -PATH_FROM_INPUT = "./../examples/response.json" -PATH_TO_OUTPUT = "./../examples/output.json" - -INPUT_FORECAST_PATH = "forecasts" -INPUT_DATE_PATH = "date" - -INPUT_HOURS_PATH = "hours" -INPUT_HOUR_PATH = "hour" -INPUT_TEMPERATURE_PATH = "temp" -INPUT_CONDITION_PATH = "condition" -INPUT_DAY_HOURS_START = 9 -INPUT_DAY_HOURS_END = 19 -INPUT_DAY_SUITABLE_CONDITIONS = [ - "clear", - "partly-cloudy", - "cloudy", - "overcast", - # "drizzle", - # "light-rain", - # "rain", - # "moderate-rain", - # "heavy-rain", - # "continuous-heavy-rain", - # "showers", - # "wet-snow", - # "light-snow", - # "snow", - # "snow-showers", - # "hail", - # "thunderstorm", - # "thunderstorm-with-rain", - # "thunderstorm-with-hail" -] - -OUTPUT_RAW_DATA_KEY = "raw_data" -OUTPUT_DAYS_KEY = "days" -DEFAULT_OUTPUT_RESULT = { - OUTPUT_DAYS_KEY: [], - # OUTPUT_RAW_DATA_KEY: None, -} - - -def deep_getitem(obj, path: str): - try: - return reduce(getitem, path.split(">"), obj) - except (KeyError, TypeError): - return None - - -def load_data(input_path: str = PATH_FROM_INPUT): - with open(input_path) as file: - data = file.read() - return json.loads(data) - - -def dump_data(data, output_path: str = PATH_TO_OUTPUT): - with open(output_path, mode="w") as file: - formatted_data = json.dumps(data, indent=2) - file.write(formatted_data) - - -def parse_args(): - parser = argparse.ArgumentParser() - parser.add_argument( - "-i", - "--input", - default=PATH_FROM_INPUT, - type=str, - help="path to file with input data", - ) - parser.add_argument( - "-o", - "--output", - default=PATH_TO_OUTPUT, - type=str, - help="path to file with result", - ) - parser.add_argument("-v", "--verbose", action="store_true") - return parser.parse_args() - - -@dataclass -class HourInfo: - raw_data: Dict[str, tuple[str, int]] = field(repr=False) - condition: Optional[str] = field(init=False, default=None) - temperature: Optional[int] = field(init=False, default=None) - hour: Optional[int] = field(init=False, default=None) - - @staticmethod - def is_hour_suitable(data): - hour = int(data[INPUT_HOUR_PATH]) - return (hour >= INPUT_DAY_HOURS_START) and (hour <= INPUT_DAY_HOURS_END) - - @property - def is_cond_suitable(self): - return self.condition in INPUT_DAY_SUITABLE_CONDITIONS - - def __post_init__(self): - self.parse() - - def parse(self): - if not self.raw_data: - return - - self.hour = int(self.raw_data[INPUT_HOUR_PATH]) - self.temperature = int(deep_getitem(self.raw_data, INPUT_TEMPERATURE_PATH)) - self.condition = deep_getitem(self.raw_data, INPUT_CONDITION_PATH) - - -@dataclass -class DayInfo: - raw_data: Dict[str, tuple[str, int]] = field(repr=False) - hours: Optional[List[HourInfo]] = field(init=False, repr=False, default=None) - - date: Optional[str] = field(init=False, default=None) - hour_start: Optional[int] = field(init=False, default=None) - hour_end: Optional[int] = field(init=False, default=None) - - hours_count: Optional[int] = field(init=False, default=None) - temperature_avg: Optional[float] = field(init=False, default=None) - relevant_condition_hours: int = field(init=False, default=0) - - def to_json(self): - return { - "date": self.date, - "hours_start": self.hour_start, - "hours_end": self.hour_end, - "hours_count": self.hours_count, - "temp_avg": round(self.temperature_avg, 3) - if self.temperature_avg - else self.temperature_avg, - "relevant_cond_hours": self.relevant_condition_hours, - } - - def __post_init__(self): - self.parse() - - def parse(self): - if not self.raw_data: - return - - self.date = self.raw_data[INPUT_DATE_PATH] - - temp = 0 - hours_count = 0 - conds_count = 0 - - self.hours = self.raw_data[INPUT_HOURS_PATH] - # ToDo force sort by hour key in asc mode - for hour_data in self.hours: - if not HourInfo.is_hour_suitable(hour_data): - continue - - h_info = HourInfo(raw_data=hour_data) - h_hour = h_info.hour - self.hour_start = self.hour_start or h_hour - self.hour_end = h_hour - - temp += h_info.temperature - if h_info.is_cond_suitable: - conds_count += 1 - hours_count += 1 - - self.relevant_condition_hours = conds_count - self.hours_count = hours_count - if hours_count > 0: - self.temperature_avg = temp / hours_count - - -def analyze_json(data): - if not data: - logging.warning("Input data is empty...") - return {} - - # analyzing days - time_start = None - time_end = None - - days_data = deep_getitem(data, INPUT_FORECAST_PATH) - days = [] - # ToDo force sort by day in asc mode - for day_data in days_data: - d_info = DayInfo(raw_data=day_data) - d_date = d_info.date - - time_start = time_start or d_date - time_end = d_date - - days.append(d_info.to_json()) - - result = DEFAULT_OUTPUT_RESULT - # result[OUTPUT_RAW_DATA_KEY] = data - result[OUTPUT_DAYS_KEY] = days - return result - - -if __name__ == "__main__": - args = parse_args() - input_path = args.input - output_path = args.output - verbose_mode = args.verbose - - logging.basicConfig(level=logging.DEBUG if verbose_mode else logging.WARNING) - logging.info(args) - - data = load_data(input_path) - data = analyze_json(data) - - dump_data(data, output_path) diff --git a/external/client.py b/external/client.py deleted file mode 100644 index 04259f3..0000000 --- a/external/client.py +++ /dev/null @@ -1,40 +0,0 @@ -import json -import logging -from http import HTTPStatus -from urllib.request import urlopen - -ERR_MESSAGE_TEMPLATE = "Unexpected error: {error}" - - -logger = logging.getLogger() - - -class YandexWeatherAPI: - """ - Base class for requests - """ - - def __do_req(url: str) -> str: - """Base request method""" - try: - with urlopen(url) as response: - resp_body = response.read().decode("utf-8") - data = json.loads(resp_body) - if response.status != HTTPStatus.OK: - raise Exception( - "Error during execute request. {}: {}".format( - resp_body.status, resp_body.reason - ) - ) - return data - except Exception as ex: - logger.error(ex) - raise Exception(ERR_MESSAGE_TEMPLATE.format(error=ex)) - - @staticmethod - def get_forecasting(url: str): - """ - :param url: url_to_json_data as str - :return: response data as json - """ - return YandexWeatherAPI.__do_req(url) diff --git a/forecasting.py b/forecasting.py deleted file mode 100644 index 2a9ae1c..0000000 --- a/forecasting.py +++ /dev/null @@ -1,29 +0,0 @@ -# import logging -# import threading -# import subprocess -# import multiprocessing - - -from external.client import YandexWeatherAPI -from tasks import ( - DataFetchingTask, - DataCalculationTask, - DataAggregationTask, - DataAnalyzingTask, -) -from utils import CITIES, get_url_by_city_name - - -def forecast_weather(): - """ - Анализ погодных условий по городам - """ - # city_name = "MOSCOW" - # url_with_data = get_url_by_city_name(city_name) - # resp = YandexWeatherAPI.get_forecasting(url_with_data) - # print(resp) - pass - - -if __name__ == "__main__": - forecast_weather() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7a0836 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,151 @@ +[coverage.report] +show_missing = true + +[coverage.run] +branch = true + +[project] +dependencies = [ + "pydantic>=2.9.2", + "pydantic-settings>=2.5.2", + "httpx>=0.27.2", + "polars>=1.9.0", +] +description = "Add your description here" +name = "async-python-sprint-1" +readme = "README.md" +requires-python = ">=3.12" +version = "0.5.0" + +[tool.coverage.run] +omit = ["*/tests/*"] + +[tool.mypy] +allow_redefinition = false +check_untyped_defs = true +disallow_subclassing_any = false +disallow_untyped_decorators = false +explicit_package_bases = true +follow_imports = "skip" +ignore_missing_imports = true +implicit_reexport = false +plugins = ["pydantic.mypy"] # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file +pretty = true +strict = true +warn_return_any = false + +[[tool.mypy.overrides]] +ignore_errors = true +module = ["tests.*"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers" +filterwarnings = [ + "ignore:.*pkg_resources.declare_namespace\\('sphinxcontrib'\\).*:DeprecationWarning", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning", +] +markers = [ + # Marks tests that use `attrs` library + "attrs", +] + +[tool.ruff] +force-exclude = true +line-length = 120 +respect-gitignore = true + +[tool.ruff.format] +docstring-code-format = true +indent-style = "space" +quote-style = "double" +skip-magic-trailing-comma = true + +[tool.ruff.lint] +ignore = [ + "D", # pydocstyle + "TD", # flake8-todos + "FIX", # flake8-fixme + "ANN", # flake8-annotations (handled by mypy) + "EM", # flake8-errmsg - String literals in exceptions + "E501", # pycodestyle - line too long (handled by formatter) + "COM812", # forced by ruff formatter + "ISC001", # forced by ruff formatter + "TRY003", # long message for exceptions + "EM101", # allow string literals for exceptions + "EM102", # allow f-string literals for exceptions + "TCH001", + "TCH003", + "TCH004", +] +select = ["ALL"] +unfixable = [ + "F", # pyflakes + "ERA", # eradicate - commented-out code +] + +[tool.ruff.lint.flake8-tidy-imports] # https://docs.astral.sh/ruff/settings/#lintflake8-tidy-imports +ban-relative-imports = "all" + +[tool.ruff.lint.flake8-type-checking] +exempt-modules = ["typing", "typing_extensions"] +quote-annotations = true # https://docs.astral.sh/ruff/settings/#lint_flake8-type-checking_quote-annotations + +[tool.ruff.lint.isort] # https://docs.astral.sh/ruff/settings/#isort +known-first-party = ["src", "tests"] +lines-between-types = 1 +section-order = [ + "future", + "typing", + "standard-library", + "third-party", + "first-party", + "local-folder", +] +split-on-trailing-comma = false + +[tool.ruff.lint.isort.sections] +"typing" = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.per-file-ignores] +"**/tests/**/*.py" = [ + "S101", # Use of assert detected (assert allowed in tests) + "PLR2004", # Magic value used in comparison +] +"__init__.py" = [ + "F401", # Unused import + "F403", # Import star + "ARG001", # Unused function argument (using fixtures from other fixtures) +] + +[tool.ruff.lint.pycodestyle] # https://docs.astral.sh/ruff/settings/#pycodestyle +max-doc-length = 100 + +[tool.ruff.lint.pydocstyle] # https://docs.astral.sh/ruff/settings/#pydocstyle +convention = "google" + +[tool.ruff.lint.pylint] # https://docs.astral.sh/ruff/settings/#pylint +allow-dunder-method-names = ["__tablename__", "__table_args__"] +allow-magic-value-types = ["int", "str", "bytes"] +max-args = 10 +max-statements = 30 + +[tool.uv] +dev-dependencies = [ + "coverage>=7.6.1", + "mypy>=1.11.2", + "pre-commit>=3.8.0", + "pytest>=8.3.3", + "pytest-cov>=5.0.0", + "ruff>=0.6.9", + "pyright>=1.1.384", + "polyfactory>=2.17.0", + "pytest-mock>=3.14.0", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9e61d1f..0000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[flake8] -ignore = - W503, - F811 -exclude = - tests/, - */migrations/, - venv/, - env/ -per-file-ignores = - */settings.py:E501 -max-complexity = 10 diff --git a/external/__init__.py b/src/__init__.py similarity index 100% rename from external/__init__.py rename to src/__init__.py diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/yandex_weather.py b/src/clients/yandex_weather.py new file mode 100644 index 0000000..019f058 --- /dev/null +++ b/src/clients/yandex_weather.py @@ -0,0 +1,39 @@ +from typing import Any + +from http import HTTPStatus + +import httpx + +from pydantic_core import from_json + +from src.core.exceptions import HTTPRequestError +from src.core.logger import get_logger + +logger = get_logger("YandexWeatherAPI") + + +class YandexWeatherAPI: + """Base class for handling weather-related requests.""" + + __slots__ = ("url",) + + def __init__(self, url: str) -> None: + self.url = url + + def _do_req(self) -> httpx.Response: + """Asynchronous request method using httpx.""" + response = httpx.get(self.url) + + if response.status_code != HTTPStatus.OK: + raise HTTPRequestError(f"Error during request. {response.status_code}: {response.reason_phrase}") + + return response + + def get_data(self) -> dict[str, Any] | None: + """Asynchronous method for getting data from API.""" + try: + response = self._do_req() + return from_json(response.read()) + except (httpx.RequestError, HTTPRequestError, ValueError) as ex: + logger.exception("Request to %s failed", self.url, exc_info=ex) + return None diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/cities.json b/src/core/cities.json new file mode 100644 index 0000000..324940d --- /dev/null +++ b/src/core/cities.json @@ -0,0 +1,20 @@ +{ + "ABUDHABI": "https://code.s3.yandex.net/async-module/abudhabi-response.json", + "BEIJING": "https://code.s3.yandex.net/async-module/beijing-response.json", + "BERLIN": "https://code.s3.yandex.net/async-module/berlin-response.json", + "BUCHAREST": "https://code.s3.yandex.net/async-module/bucharest-response.json", + "CAIRO": "https://code.s3.yandex.net/async-module/cairo-response.json", + "GIZA": "https://code.s3.yandex.net/async-module/giza-response.json", + "KALININGRAD": "https://code.s3.yandex.net/async-module/kaliningrad-response.json", + "KAZAN": "https://code.s3.yandex.net/async-module/kazan-response.json", + "LONDON": "https://code.s3.yandex.net/async-module/london-response.json", + "MADRID": "https://code.s3.yandex.net/async-module/madrid-response.json", + "MOSCOW": "https://code.s3.yandex.net/async-module/moscow-response.json", + "NOVOSIBIRSK": "https://code.s3.yandex.net/async-module/novosibirsk-response.json", + "PARIS": "https://code.s3.yandex.net/async-module/paris-response.json", + "ROMA": "https://code.s3.yandex.net/async-module/roma-response.json", + "SPETERSBURG": "https://code.s3.yandex.net/async-module/spetersburg-response.json", + "TORONTO": "https://code.s3.yandex.net/async-module/toronto-response.json", + "VOLGOGRAD": "https://code.s3.yandex.net/async-module/volgograd-response.json", + "WARSZAWA": "https://code.s3.yandex.net/async-module/warszawa-response.json" +} diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..3f7b3ab --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,10 @@ +class BaseError(Exception): ... + + +class CityNotFoundError(BaseError): ... + + +class UnsupportedPythonVersionError(BaseError): ... + + +class HTTPRequestError(BaseError): ... diff --git a/src/core/logger.py b/src/core/logger.py new file mode 100644 index 0000000..b310d3c --- /dev/null +++ b/src/core/logger.py @@ -0,0 +1,31 @@ +import logging + +from logging import config + +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"verbose": {"format": LOG_FORMAT}}, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + "level": "DEBUG", + "stream": "ext://sys.stdout", + } + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} + + +def setup_logging() -> None: + config.dictConfig(LOGGING) + + +setup_logging() + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/src/core/settings.py b/src/core/settings.py new file mode 100644 index 0000000..41077bd --- /dev/null +++ b/src/core/settings.py @@ -0,0 +1,47 @@ +from dotenv import find_dotenv, load_dotenv +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +load_dotenv(find_dotenv()) + + +class DefaultSettings(BaseSettings): + """Class to store default project settings.""" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class PythonVersionSettings(DefaultSettings): + """Class to store Python version settings.""" + + min_major: int = Field(..., description="Minimum major version") + min_minor: int = Field(..., description="Minimum minor version") + + model_config = SettingsConfigDict(env_prefix="PYTHON_VER_") + + +class InputSettings(DefaultSettings): + day_hours_start: int = Field(..., description="Day hours start") + day_hours_end: int = Field(..., description="Day hours end") + hour_conditions_score: str = Field(..., description="Hour conditions score") + links_path: str = Field(..., description="Links path") + top_locations_count: int = Field(..., description="Top locations count") + cond_weight: float = Field(..., description="Condition weights") + temp_weight: float = Field(..., description="Temperature weights") + + model_config = SettingsConfigDict(env_prefix="INPUT_") + + +class OutputSettings(DefaultSettings): + path: str = Field(..., description="Output path") + + model_config = SettingsConfigDict(env_prefix="OUTPUT_") + + +class Settings(BaseSettings): + py_ver: PythonVersionSettings = PythonVersionSettings() + inpt: InputSettings = InputSettings() + otpt: OutputSettings = OutputSettings() + + +settings = Settings() diff --git a/src/helpers/__init__.py b/src/helpers/__init__.py new file mode 100644 index 0000000..54658a9 --- /dev/null +++ b/src/helpers/__init__.py @@ -0,0 +1,4 @@ +from src.helpers.check_python_version import requires_python_version +from src.helpers.get_json_data import from_json_file + +__all__: list[str] = ["requires_python_version", "from_json_file"] diff --git a/src/helpers/check_python_version.py b/src/helpers/check_python_version.py new file mode 100644 index 0000000..622b52a --- /dev/null +++ b/src/helpers/check_python_version.py @@ -0,0 +1,29 @@ +from typing import Any, TypeVar, cast + +import sys + +from collections.abc import Callable +from functools import wraps + +from src.core.exceptions import UnsupportedPythonVersionError +from src.core.settings import settings + +F = TypeVar("F", bound=Callable[..., Any]) + + +def requires_python_version() -> Callable[[F], F]: + min_major = settings.py_ver.min_major + min_minor = settings.py_ver.min_minor + + def decorator(func: F) -> F: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if sys.version_info.major < min_major or ( + sys.version_info.major == min_major and sys.version_info.minor < min_minor + ): + raise UnsupportedPythonVersionError(f"Required python version >= {min_major}.{min_minor}.") + return func(*args, **kwargs) + + return cast(F, wrapper) + + return decorator diff --git a/src/helpers/executor_manager.py b/src/helpers/executor_manager.py new file mode 100644 index 0000000..1133fbb --- /dev/null +++ b/src/helpers/executor_manager.py @@ -0,0 +1,42 @@ +from typing import Any + +import os + +from collections.abc import Generator +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed, wait + + +class ExecutorManager: + """Manages executor and processing of futures.""" + + def __init__(self, executor_type: str = "thread") -> None: + self.cpu_count = os.cpu_count() or 2 + self.executor_type = executor_type + (self.executor_class, self.num_workers) = self._select_executor() + + def _select_executor(self) -> tuple[type[ProcessPoolExecutor | ThreadPoolExecutor], int]: + """Select the appropriate executor and determine the number of workers.""" + if self.executor_type == "process": + return ProcessPoolExecutor, self.cpu_count - 1 + return ThreadPoolExecutor, self.cpu_count * 2 - 1 + + def execute_tasks( + self, tasks: Generator[Any, None, None], process_func: Any, mode: str = "as_completed" + ) -> Generator[Any, None, None]: + """Execute the tasks using the selected executor and mode.""" + + with self.executor_class(max_workers=self.num_workers) as executor: + futures = [executor.submit(process_func, task) for task in tasks] + + futures_iter: Any + + if mode == "wait": + done, _ = wait(futures) + futures_iter = done + else: + futures_iter = as_completed(futures) + + for future in futures_iter: + result = future.result() + if result is not None: + yield result diff --git a/src/helpers/get_json_data.py b/src/helpers/get_json_data.py new file mode 100644 index 0000000..5514916 --- /dev/null +++ b/src/helpers/get_json_data.py @@ -0,0 +1,10 @@ +from typing import Any + +from pathlib import Path + +from pydantic_core import from_json + + +def from_json_file(path: str) -> dict[str, Any]: + with Path(path).open("rb") as file: + return from_json(file.read()) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..d154bd1 --- /dev/null +++ b/src/main.py @@ -0,0 +1,16 @@ +from src.helpers import requires_python_version +from src.pipeline import AnalyzeTask, ExtractTask, FetchTask, TransformTask +from src.schemas import generate_links + + +@requires_python_version() +def main() -> None: + links = generate_links() + fetched_data = FetchTask(links).run() + extracted_data = ExtractTask(fetched_data).run() + transformed_data = TransformTask(extracted_data).run(mode="wait") + AnalyzeTask(transformed_data).run() + + +if __name__ == "__main__": + main() diff --git a/src/pipeline/__init__.py b/src/pipeline/__init__.py new file mode 100644 index 0000000..4ea3f85 --- /dev/null +++ b/src/pipeline/__init__.py @@ -0,0 +1,6 @@ +from src.pipeline.analyze import AnalyzeTask +from src.pipeline.extract import ExtractTask +from src.pipeline.fetch import FetchTask +from src.pipeline.transform import TransformTask + +__all__: list[str] = ["FetchTask", "TransformTask", "AnalyzeTask", "ExtractTask"] diff --git a/src/pipeline/analyze.py b/src/pipeline/analyze.py new file mode 100644 index 0000000..fa88892 --- /dev/null +++ b/src/pipeline/analyze.py @@ -0,0 +1,69 @@ +from typing import Any + +from collections.abc import Generator + +import polars as pl + +from polars import Expr + +from src.core.logger import get_logger +from src.core.settings import settings as s +from src.pipeline.base import BaseTask + +logger = get_logger("AnalyzeTask") + + +class AnalyzeTask(BaseTask): + """Analyze transformed data.""" + + def __init__(self, data_in: Generator[dict[str, Any], None, None]) -> None: + super().__init__(data_in) + + @staticmethod + def _normalize_column(df: pl.DataFrame, column_name: str) -> pl.Series: + """Normalize values in a column to the range [0, 1].""" + column_series = df.get_column(column_name) + + if column_series.is_empty(): + return column_series + + if not column_series.dtype.is_numeric(): + raise ValueError("Column must be of numeric type") + + return (column_series - column_series.mean()) / column_series.std() + + @staticmethod + def _get_weighted_sum(col1: str, col2: str, w1: float, w2: float) -> Expr: + """Calculate the weighted sum of two columns.""" + return (pl.col(col1) * w1).fill_null(0) + (pl.col(col2) * w2).fill_null(0) + + def process(self, item: Any | None = None) -> pl.DataFrame: # noqa:ARG002 + """Calculate scores for each location based on the transformed data.""" + + transformed_data = (pl.DataFrame(item, infer_schema_length=100) for item in self.data_in) + pr_df = pl.concat(transformed_data, how="vertical") + + pr_df = pr_df.with_columns( + [ + self._normalize_column(pr_df, "temp_avg").alias("norm_temp_avg"), + self._normalize_column(pr_df, "cond_score").alias("norm_cond_score"), + ] + ) + + pr_df = pr_df.with_columns( + [ + self._get_weighted_sum( + "norm_temp_avg", "norm_cond_score", s.inpt.temp_weight, s.inpt.cond_weight + ).alias("weighted_sum") + ] + ) + return pr_df.group_by("location").agg([pl.sum("weighted_sum").alias("score")]) + + def run(self, mode: str | None = None) -> Any: # noqa:ARG002 + top_locations = self.process() + top_locations = top_locations.sort("score", descending=True).head(s.inpt.top_locations_count) + + top_locations.write_json(s.otpt.path) + + logger.info("Process finished, result saved to %s", s.otpt.path) + logger.info("Top locations: %s", top_locations) diff --git a/src/pipeline/base.py b/src/pipeline/base.py new file mode 100644 index 0000000..b5a5758 --- /dev/null +++ b/src/pipeline/base.py @@ -0,0 +1,36 @@ +from typing import Any + +from abc import ABC, abstractmethod +from collections.abc import Generator + +from src.helpers.executor_manager import ExecutorManager + + +class BaseTask(ABC): + """Base class for all tasks.""" + + __slots__ = ("data_in", "executor_manager") + + def __init__(self, data_in: Generator[Any, None, None], executor_type: str = "thread") -> None: + self.data_in = data_in + self.executor_manager = ExecutorManager(executor_type) + + @abstractmethod + def process(self, item: Any) -> Any: + """Method for processing item of the iterable. + Args: + item (Any): Item of the iterable. + Returns: + Any: Processed item. + """ + raise NotImplementedError("Process method must be implemented") + + def run(self, mode: str = "as_completed") -> Generator[Any, None, None]: + """ + Method for running task. + Args: + mode (str): Mode of the executor. "as_completed" or "wait" (default: "as_completed"). + Returns: + Generator[Any, None, None]: Generator of processed items. + """ + yield from self.executor_manager.execute_tasks(self.data_in, self.process, mode) diff --git a/src/pipeline/extract.py b/src/pipeline/extract.py new file mode 100644 index 0000000..5b04162 --- /dev/null +++ b/src/pipeline/extract.py @@ -0,0 +1,35 @@ +from typing import Any + +from collections.abc import Generator + +from src.core.logger import get_logger +from src.pipeline.base import BaseTask +from src.schemas.dto import DayDTO, ExtractTaskDTO, HourDTO +from src.schemas.weather_response import WeatherResponse + +logger = get_logger("ExtractTask") + + +class ExtractTask(BaseTask): + """Extract required data from WeatherResponse objects.""" + + def __init__(self, data_in: Generator[dict[str, Any], None, None]) -> None: + super().__init__(data_in) + + def process(self, item: Any) -> dict[str, Any]: + item = WeatherResponse(**item) + days = [ + DayDTO( + date=forecast.date, + hours=[ + HourDTO(hour=int(hour.hour), temp=hour.temp, cond_score=hour.condition.score) + for hour in forecast.hours + if hour.is_focus + ], + ) + for forecast in item.forecasts + ] + + location = item.geo_object.locality.name + logger.info("Extracted data for %s", location) + return ExtractTaskDTO(location=location, days=days).model_dump() diff --git a/src/pipeline/fetch.py b/src/pipeline/fetch.py new file mode 100644 index 0000000..244d764 --- /dev/null +++ b/src/pipeline/fetch.py @@ -0,0 +1,32 @@ +from typing import Any + +from collections.abc import Generator + +from src.clients.yandex_weather import YandexWeatherAPI +from src.core.logger import get_logger +from src.pipeline.base import BaseTask +from src.schemas.weather_response import WeatherResponse + +logger = get_logger("FetchTask") + + +class FetchTask(BaseTask): + """Fetch data from the API.""" + + def __init__(self, data_in: Generator[str, None, None]) -> None: + super().__init__(data_in) + + def process(self, item: str) -> dict[str, Any] | None: + api = YandexWeatherAPI(item) + data = api.get_data() + + if data is None: + logger.error("Data not found for city: %s", item) + return None + try: + validated_data = WeatherResponse(**data) + logger.info("Fetched data for %s", validated_data.geo_object.locality.name) + return validated_data.model_dump() + except ValueError: + logger.exception("Data not valid for city: %s", item) + return None diff --git a/src/pipeline/transform.py b/src/pipeline/transform.py new file mode 100644 index 0000000..7190286 --- /dev/null +++ b/src/pipeline/transform.py @@ -0,0 +1,49 @@ +from typing import Any + +from collections.abc import Generator + +import polars as pl + +from src.core.logger import get_logger +from src.pipeline.base import BaseTask +from src.schemas.dto import ExtractTaskDTO, TransformedDayDTO, TransformTaskDTO + +logger = get_logger("TransformTask") + + +class TransformTask(BaseTask): + """Transform extracted data.""" + + def __init__(self, data_in: Generator[dict[str, Any], None, None]) -> None: + super().__init__(data_in) + + @staticmethod + def _transform(item: TransformTaskDTO) -> dict[str, Any]: + """ + Transform data to dataframe. + Args: + item (TransformTaskDTO): Data to transform. + Returns: + dict[str, Any]: Transformed data. + """ + location_df = pl.DataFrame({"location": [item.location] * len(item.days)}) + days_df = pl.DataFrame([day.model_dump() for day in item.days]) + result = pl.concat([location_df, days_df], how="horizontal") + + return result.to_dict() + + def process(self, item: Any) -> dict[str, Any]: + item = ExtractTaskDTO(**item) + days = [ + TransformedDayDTO( + date=day.date, + hours_count=len(day.hours), + cond_score=sum(hour.cond_score for hour in day.hours), + temp_avg=sum(hour.temp for hour in day.hours) / (len(day.hours) or 1), + ) + for day in item.days + ] + transform_dto = TransformTaskDTO(location=item.location, days=days) + result = self._transform(transform_dto) + logger.info("Transformed data for %s", item.location) + return result diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py new file mode 100644 index 0000000..db736a1 --- /dev/null +++ b/src/schemas/__init__.py @@ -0,0 +1,3 @@ +from src.schemas.city import generate_links + +__all__: list[str] = ["generate_links"] diff --git a/src/schemas/city.py b/src/schemas/city.py new file mode 100644 index 0000000..41696ed --- /dev/null +++ b/src/schemas/city.py @@ -0,0 +1,17 @@ +from collections.abc import Generator + +from pydantic import BaseModel, HttpUrl + +from src.core.settings import settings as s +from src.helpers.get_json_data import from_json_file + + +class City(BaseModel): + name: str + url: HttpUrl + + +def generate_links() -> Generator[str, None, None]: + links = from_json_file(s.inpt.links_path) + for name, url in links.items(): + yield City(name=name, url=url).url.unicode_string() diff --git a/src/schemas/condition.py b/src/schemas/condition.py new file mode 100644 index 0000000..eb0a14e --- /dev/null +++ b/src/schemas/condition.py @@ -0,0 +1,33 @@ +from ast import literal_eval +from enum import StrEnum, auto + +from src.core.settings import settings as s + +score = literal_eval(s.inpt.hour_conditions_score) + + +class Condition(StrEnum): + clear = auto() + partly_cloudy = "partly-cloudy" + cloudy = auto() + overcast = auto() + drizzle = auto() + light_rain = "light-rain" + light_snow = "light-snow" + wet_snow = "wet-snow" + rain = auto() + showers = auto() + snow = auto() + snow_showers = "snow-showers" + moderate_rain = "moderate-rain" + heavy_rain = "heavy-rain" + continuous_heavy_rain = "continuous-heavy-rain" + hail = auto() + thunderstorm = auto() + thunderstorm_with_rain = "thunderstorm-with-rain" + thunderstorm_with_hail = "thunderstorm-with-hail" + unknown = auto() + + @property + def score(self) -> int: + return score.get(self.name, 0) diff --git a/src/schemas/dto.py b/src/schemas/dto.py new file mode 100644 index 0000000..b0b63a1 --- /dev/null +++ b/src/schemas/dto.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + +from src.schemas.mixins import DateMixin + + +class CommonDTO(BaseModel): + location: str + + +class HourDTO(BaseModel): + hour: int + temp: int + cond_score: int + + +class DayDTO(DateMixin): + hours: list[HourDTO] + + +class ExtractTaskDTO(CommonDTO): + days: list[DayDTO] + + +class TransformedDayDTO(DateMixin): + hours_count: int + cond_score: int + temp_avg: float + + +class TransformTaskDTO(CommonDTO): + days: list[TransformedDayDTO] diff --git a/src/schemas/mixins.py b/src/schemas/mixins.py new file mode 100644 index 0000000..631d95d --- /dev/null +++ b/src/schemas/mixins.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field + +from src.schemas.condition import Condition + + +class WeatherDataMixin(BaseModel): + temp: int = Field(lt=80, description="Temperature") + feels_like: int + icon: str + condition: Condition = Field(default=Condition.unknown, description="Condition with default unknown") + cloudness: float + prec_type: int + prec_strength: float + prec_prob: int + is_thunder: bool + wind_speed: float + wind_dir: str + pressure_mm: int + pressure_pa: int + humidity: int + uv_index: int + soil_temp: int | None = None + soil_moisture: float | None = None + wind_gust: float | None = None + + +class DateMixin(BaseModel): + date: str diff --git a/src/schemas/weather_response.py b/src/schemas/weather_response.py new file mode 100644 index 0000000..7611884 --- /dev/null +++ b/src/schemas/weather_response.py @@ -0,0 +1,95 @@ +from pydantic import BaseModel + +from src.core.settings import settings as s +from src.schemas.mixins import WeatherDataMixin + + +class Tzinfo(BaseModel): + name: str + abbr: str + dst: bool + offset: int + + +class Info(BaseModel): + n: bool + geoid: int + url: str + lat: float + lon: float + tzinfo: Tzinfo + def_pressure_mm: int + def_pressure_pa: int + slug: str + zoom: int + nr: bool + ns: bool + nsr: bool + p: bool + f: bool + _h: bool + + +class Location(BaseModel): + id: int + name: str + + +class GeoObject(BaseModel): + district: Location | None = None + locality: Location + province: Location | None = None + country: Location | None = None + + +class Yesterday(BaseModel): + temp: int + + +class Fact(WeatherDataMixin): + obs_time: int + uptime: int + daytime: str + polar: bool + season: str + source: str + + +class Hour(WeatherDataMixin): + hour: str + hour_ts: int + prec_mm: float + prec_period: int + + @property + def is_focus(self) -> bool: + return bool(s.inpt.day_hours_start <= int(self.hour) and s.inpt.day_hours_end) + + +class Biomet(BaseModel): + index: int + condition: str + + +class Forecast(BaseModel): + date: str + date_ts: int + week: int + sunrise: str + sunset: str + rise_begin: str + set_end: str + moon_code: int + moon_text: str + hours: list[Hour] + biomet: Biomet | None = None + + +class WeatherResponse(BaseModel): + now: int + now_dt: str + info: Info + geo_object: GeoObject + yesterday: Yesterday + fact: Fact | None = None + forecasts: list[Forecast] diff --git a/tasks.py b/tasks.py deleted file mode 100644 index f7f8645..0000000 --- a/tasks.py +++ /dev/null @@ -1,14 +0,0 @@ -class DataFetchingTask: - pass - - -class DataCalculationTask: - pass - - -class DataAggregationTask: - pass - - -class DataAnalyzingTask: - pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..d592f7c --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,55 @@ +import logging + +import pytest + +from src.pipeline.analyze import AnalyzeTask +from src.schemas.dto import TransformedDayDTO, TransformTaskDTO + + +@pytest.fixture +def mock_transformed_data(): + return [ + TransformTaskDTO( + location="Location A", + days=[TransformedDayDTO(date="2024-10-10", hours_count=10, cond_score=9, temp_avg=20)], + ), + TransformTaskDTO( + location="Location B", + days=[TransformedDayDTO(date="2024-10-10", hours_count=10, cond_score=8, temp_avg=18)], + ), + TransformTaskDTO( + location="Location C", + days=[TransformedDayDTO(date="2024-10-10", hours_count=10, cond_score=7, temp_avg=15)], + ), + TransformTaskDTO( + location="Location D", + days=[TransformedDayDTO(date="2024-10-10", hours_count=10, cond_score=5, temp_avg=10)], + ), + ] + + +def test_analyze_task_top_3_locations(mock_transformed_data, mocker, caplog): + mocker.patch("src.core.settings.settings.inpt.temp_weight", 0.7) + mocker.patch("src.core.settings.settings.inpt.cond_weight", 0.3) + mocker.patch("src.core.settings.settings.inpt.top_locations_count", 3) + mocker.patch("src.core.settings.settings.otpt.path", "/mocked_path") + + mocker.patch("polars.DataFrame.write_json") + + # Preparing the mock data + data_in = ( + {"location": dto.location, "temp_avg": day.temp_avg, "cond_score": day.cond_score} + for dto in mock_transformed_data + for day in dto.days + ) + + with caplog.at_level(logging.INFO): + analyze_task = AnalyzeTask(data_in=data_in) + analyze_task.run() + + log_output = [record.message for record in caplog.records if "Top locations:" in record.message] + assert len(log_output) > 0, "Expected top locations to be logged but none found." + + expected_top_3 = ["Location A", "Location B", "Location C"] + for loc in expected_top_3: + assert loc in log_output[0], f"Expected {loc} in top locations, but not found." diff --git a/tests/test_fetch.py b/tests/test_fetch.py new file mode 100644 index 0000000..502d692 --- /dev/null +++ b/tests/test_fetch.py @@ -0,0 +1,65 @@ +import pytest + +from polyfactory.factories.pydantic_factory import ModelFactory + +from src.clients.yandex_weather import YandexWeatherAPI +from src.pipeline.fetch import FetchTask +from src.schemas.weather_response import WeatherResponse + + +class WeatherResponseFactory(ModelFactory[WeatherResponse]): + __random_seed__ = 1 + + +@pytest.fixture +def weather_response(): + return WeatherResponseFactory.build() + + +@pytest.fixture +def mock_invalid_data(): + return {"geo_object": {"locality": {"name": "Test City"}}, "fact": {"temp": "invalid_temperature"}} + + +@pytest.fixture +def mock_missing_forecast(): + return {"geo_object": {"locality": {"name": "Test City"}}, "fact": {"temp": 20}} + + +def test_fetch_task_invalid_url(mocker): + mocker.patch.object(YandexWeatherAPI, "get_data", return_value=None) + fetch_task = FetchTask(data_in=(city for city in ["http://invalid-url.com"])) + result = fetch_task.process("http://invalid-url.com") + assert result is None + + +def test_fetch_task_no_data(mocker): + mocker.patch.object(YandexWeatherAPI, "get_data", return_value=None) + fetch_task = FetchTask(data_in=(city for city in ["http://no-data-url.com"])) + result = fetch_task.process("http://no-data-url.com") + assert result is None + + +def test_fetch_task_invalid_data(mock_invalid_data, mocker): + mocker.patch.object(YandexWeatherAPI, "get_data", return_value=mock_invalid_data) + fetch_task = FetchTask(data_in=(city for city in ["http://invalid-data-url.com"])) + result = fetch_task.process("http://invalid-data-url.com") + assert result is None + + +def test_fetch_task_missing_forecast(mock_missing_forecast, mocker): + mocker.patch.object(YandexWeatherAPI, "get_data", return_value=mock_missing_forecast) + fetch_task = FetchTask(data_in=(city for city in ["http://missing-forecast-url.com"])) + result = fetch_task.process("http://missing-forecast-url.com") + assert result is None + + +def test_fetch_task_success(weather_response, mocker): + mocker.patch.object(YandexWeatherAPI, "get_data", return_value=weather_response.model_dump()) + + fetch_task = FetchTask(data_in=(city for city in ["http://valid-url.com"])) + result = fetch_task.process("http://valid-url.com") + + assert result is not None + assert result["geo_object"]["locality"]["name"] == weather_response.geo_object.locality.name + assert result["fact"]["temp"] == weather_response.fact.temp diff --git a/utils.py b/utils.py deleted file mode 100644 index 2e0fe71..0000000 --- a/utils.py +++ /dev/null @@ -1,45 +0,0 @@ -CITIES = { - "MOSCOW": "https://code.s3.yandex.net/async-module/moscow-response.json", - "PARIS": "https://code.s3.yandex.net/async-module/paris-response.json", - "LONDON": "https://code.s3.yandex.net/async-module/london-response.json", - "BERLIN": "https://code.s3.yandex.net/async-module/berlin-response.json", - "BEIJING": "https://code.s3.yandex.net/async-module/beijing-response.json", - "KAZAN": "https://code.s3.yandex.net/async-module/kazan-response.json", - "SPETERSBURG": "https://code.s3.yandex.net/async-module/spetersburg-response.json", - "VOLGOGRAD": "https://code.s3.yandex.net/async-module/volgograd-response.json", - "NOVOSIBIRSK": "https://code.s3.yandex.net/async-module/novosibirsk-response.json", - "KALININGRAD": "https://code.s3.yandex.net/async-module/kaliningrad-response.json", - "ABUDHABI": "https://code.s3.yandex.net/async-module/abudhabi-response.json", - "WARSZAWA": "https://code.s3.yandex.net/async-module/warszawa-response.json", - "BUCHAREST": "https://code.s3.yandex.net/async-module/bucharest-response.json", - "ROMA": "https://code.s3.yandex.net/async-module/roma-response.json", - "CAIRO": "https://code.s3.yandex.net/async-module/cairo-response.json", - - "GIZA": "https://code.s3.yandex.net/async-module/giza-response.json", - "MADRID": "https://code.s3.yandex.net/async-module/madrid-response.json", - "TORONTO": "https://code.s3.yandex.net/async-module/toronto-response.json" -} - -MIN_MAJOR_PYTHON_VER = 3 -MIN_MINOR_PYTHON_VER = 9 - - -def check_python_version(): - import sys - - if ( - sys.version_info.major < MIN_MAJOR_PYTHON_VER - or sys.version_info.minor < MIN_MINOR_PYTHON_VER - ): - raise Exception( - "Please use python version >= {}.{}".format( - MIN_MAJOR_PYTHON_VER, MIN_MINOR_PYTHON_VER - ) - ) - - -def get_url_by_city_name(city_name): - try: - return CITIES[city_name] - except KeyError: - raise Exception("Please check that city {} exists".format(city_name)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..76f1c09 --- /dev/null +++ b/uv.lock @@ -0,0 +1,527 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, +] + +[[package]] +name = "async-python-sprint-1" +version = "0.5.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "polars" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "mypy" }, + { name = "polyfactory" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "polars", specifier = ">=1.9.0" }, + { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.6.1" }, + { name = "mypy", specifier = ">=1.11.2" }, + { name = "polyfactory", specifier = ">=2.17.0" }, + { name = "pre-commit", specifier = ">=3.8.0" }, + { name = "pyright", specifier = ">=1.1.384" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, +] + +[[package]] +name = "distlib" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, +] + +[[package]] +name = "faker" +version = "30.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/f4/a6fe96b1bc0a8b864ddcd087e7909d8c6ccb74a1247baadbf5c7094a9067/faker-30.1.0.tar.gz", hash = "sha256:e0593931bd7be9a9ea984b5d8c302ef1cec19392585d1e90d444199271d0a94d", size = 1796615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/49/34f3615d1f4770f70cda4df1ce09d1203cf66b62baabac307c28887c69af/Faker-30.1.0-py3-none-any.whl", hash = "sha256:dbf81295c948270a9e96cd48a9a3ebec73acac9a153d0c854fbbd0294557609f", size = 1836618 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "identify" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "mypy" +version = "1.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "polars" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/09/c2fb0b231d551e0c8e68097d08577712bdff1ba91346cda8228e769602f5/polars-1.9.0.tar.gz", hash = "sha256:8e1206ef876f61c1d50a81e102611ea92ee34631cb135b46ad314bfefd3cb122", size = 4027431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/cc/3d0292048d8f9045a03510aeecda2e6ed9df451ae8853274946ff841f98b/polars-1.9.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a471d2ce96f6fa5dd0ef16bcdb227f3dbe3af8acb776ca52f9e64ef40c7489a0", size = 31870933 }, + { url = "https://files.pythonhosted.org/packages/ee/be/15af97f4d8b775630da16a8bf0141507d9c0ae5f2637b9a27ed337b3b1ba/polars-1.9.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94b12d731cd200d2c50b13fc070d6353f708e632bca6529c5a72aa6a69e5285d", size = 28171055 }, + { url = "https://files.pythonhosted.org/packages/bb/57/b286b317f061d8f17bab4726a27e7b185fbf3d3db65cf689074256ea34a9/polars-1.9.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f85f132732aa63c6f3b502b0fdfc3ba9f0b78cc6330059b5a2d6f9fd78508acb", size = 33063367 }, + { url = "https://files.pythonhosted.org/packages/e5/25/bf5d43dcb538bf6573b15f3d5995a52be61b8fbce0cd737e72c4d25eef88/polars-1.9.0-cp38-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:f753c8941a3b3249d59262d68a856714a96a7d4e16977aefbb196be0c192e151", size = 29764698 }, + { url = "https://files.pythonhosted.org/packages/a6/cf/f9170a3ac20e0efb9d3c1cdacc677e35b711ffd5ec48a6d5f3da7b7d8663/polars-1.9.0-cp38-abi3-win_amd64.whl", hash = "sha256:95de07066cd797dd940fa2783708a7bef93c827a57be0f4dfad3575a6144212b", size = 32819142 }, +] + +[[package]] +name = "polyfactory" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/74/25adfbef58c43bdd3b0afb12a98438fabdd3dcdb41c1babaca6455eed047/polyfactory-2.17.0.tar.gz", hash = "sha256:099d86f7c79c51a2caaf7c8598cc56e7b0a57c11b5918ddf699e82380735b6b7", size = 183719 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/ca/7c9b5e6270a911dd48165dd82c2e129b30dd5b8f0bf4da5379c94745c68c/polyfactory-2.17.0-py3-none-any.whl", hash = "sha256:71b677c17bb7cebad9a5631b1aca7718280bdcedc1c25278253717882d1ac294", size = 58820 }, +] + +[[package]] +name = "pre-commit" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 }, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 }, +] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 }, + { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 }, + { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 }, + { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 }, + { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 }, + { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 }, + { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 }, + { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, + { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, + { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/27/0bed9dd26b93328b60a1402febc780e7be72b42847fa8b5c94b7d0aeb6d1/pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0", size = 70938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/8d/29e82e333f32d9e2051c10764b906c2a6cd140992910b5f49762790911ba/pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", size = 26864 }, +] + +[[package]] +name = "pyright" +version = "1.1.384" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/00/a23114619f9d005f4b0f35e037c76cee029174d090a6f73a355749c74f4a/pyright-1.1.384.tar.gz", hash = "sha256:25e54d61f55cbb45f1195ff89c488832d7a45d59f3e132f178fdf9ef6cafc706", size = 21956 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/4a/e7f4d71d194ba675f3577d11eebe4e17a592c4d1c3f9986d4b321ba3c809/pyright-1.1.384-py3-none-any.whl", hash = "sha256:f0b6f4db2da38f27aeb7035c26192f034587875f751b847e9ad42ed0c704ac9e", size = 18578 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "ruff" +version = "0.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526 }, + { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612 }, + { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197 }, + { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855 }, + { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889 }, + { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678 }, + { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682 }, + { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446 }, + { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048 }, + { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855 }, + { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007 }, + { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594 }, + { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024 }, + { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085 }, + { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088 }, + { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275 }, + { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "virtualenv" +version = "20.26.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 }, +]