wthrcli 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wthrcli-1.0.1/.github/README.md +81 -0
- wthrcli-1.0.1/PKG-INFO +100 -0
- wthrcli-1.0.1/pyproject.toml +30 -0
- wthrcli-1.0.1/setup.cfg +4 -0
- wthrcli-1.0.1/src/wthr/__init__.py +0 -0
- wthrcli-1.0.1/src/wthr/api/__init__.py +6 -0
- wthrcli-1.0.1/src/wthr/api/api_client.py +39 -0
- wthrcli-1.0.1/src/wthr/api/weather_client.py +173 -0
- wthrcli-1.0.1/src/wthr/database/__init__.py +6 -0
- wthrcli-1.0.1/src/wthr/database/storage/__init__.py +7 -0
- wthrcli-1.0.1/src/wthr/database/storage/app_info.py +3 -0
- wthrcli-1.0.1/src/wthr/database/storage/cache_storage.py +71 -0
- wthrcli-1.0.1/src/wthr/database/storage/config_storage.py +33 -0
- wthrcli-1.0.1/src/wthr/database/storage/storage.py +58 -0
- wthrcli-1.0.1/src/wthr/main.py +228 -0
- wthrcli-1.0.1/src/wthr/models/__init__.py +24 -0
- wthrcli-1.0.1/src/wthr/models/forecast.py +89 -0
- wthrcli-1.0.1/src/wthr/models/location.py +22 -0
- wthrcli-1.0.1/src/wthr/models/storage.py +10 -0
- wthrcli-1.0.1/src/wthr/models/weather.py +26 -0
- wthrcli-1.0.1/src/wthr/utils/__init__.py +5 -0
- wthrcli-1.0.1/src/wthr/utils/formatter.py +296 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/PKG-INFO +100 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/SOURCES.txt +26 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/dependency_links.txt +1 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/entry_points.txt +2 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/requires.txt +12 -0
- wthrcli-1.0.1/src/wthrcli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ✨⛅ weather-cli
|
|
2
|
+
|
|
3
|
+
Командная утилита для получения текущей погоды в любом месте.
|
|
4
|
+
|
|
5
|
+
## 📋 Содержание
|
|
6
|
+
|
|
7
|
+
- [Технологический стек](#-технологический-стек)
|
|
8
|
+
- [Используемые API](#-используемые-api)
|
|
9
|
+
- [Использование](#использование)
|
|
10
|
+
- [Сборка](#сборка)
|
|
11
|
+
|
|
12
|
+
## 🛠 Технологический стек
|
|
13
|
+
|
|
14
|
+
- [Typer](https://typer.tiangolo.com/) — для создания удобного интерфейса командной строки (CLI). Позволяет легко определять аргументы и опции (например, название места и тип прогноза), автоматически генерирует подсказки и обрабатывает ввод пользователя.
|
|
15
|
+
- [Niquests](https://github.com/jawah/niquests/) — современный HTTP-клиент (форк `requests`). Используется для отправки запросов к Nominatim и Open-Meteo и получения данных о месте и погоде, соответственно.
|
|
16
|
+
- [Pydantic](https://docs.pydantic.dev/) — для валидации и парсинга данных. С его помощью мы определяем структуру ответа API и автоматически преобразуем JSON в удобные объекты Python с проверкой типов.
|
|
17
|
+
|
|
18
|
+
## 🌐 Используемые API
|
|
19
|
+
|
|
20
|
+
- [Nominatim](https://nominatim.org/) — геокодирование координат
|
|
21
|
+
- [Open-Meteo](https://open-meteo.com/) — получение прогноза по координатам
|
|
22
|
+
|
|
23
|
+
### ⚠️ Ограничения API
|
|
24
|
+
|
|
25
|
+
- Nominatim — Максимум 1 запрос в секунду
|
|
26
|
+
- Open-Meteo — Не более 10,000 запросов в день, 5,000 в час и 600 в минуту
|
|
27
|
+
|
|
28
|
+
## Использование
|
|
29
|
+
|
|
30
|
+
Возможные команды weather-cli можно увидеть прописав `--help`, так же можно использовать `--help` и у отдельных команд, чтобы увидеть их описание
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
wthr --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Доступные команды:
|
|
37
|
+
- weather – Показать прогноз погоды для указанного места.
|
|
38
|
+
- set – Сохранить место в конфиг, чтобы в дальнейшем автоматически использовать его для получения погоды
|
|
39
|
+
- get – Проверить текущее место в конфиге
|
|
40
|
+
- clear – Очистить конфиг и кеш
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Также доступно использование просто `wthr`
|
|
44
|
+
|
|
45
|
+
### Использование исполняемого файла на linux
|
|
46
|
+
|
|
47
|
+
Выбрать подходящую версию из [релизов](https://github.com/sanbobsan/weather-cli/releases)
|
|
48
|
+
|
|
49
|
+
- **wthr** — самодостаточный исполняемый файл
|
|
50
|
+
- **whtr.pyz** — исполняемый файл, который требует установленный python для запуска (требуется лишь интерпретатор, все зависимости он хранит в себе)
|
|
51
|
+
|
|
52
|
+
1. поместить исполняемый файл в _`/usr/local/bin`_
|
|
53
|
+
2. запускать командой `wthr` / `wthr.pyz`
|
|
54
|
+
|
|
55
|
+
### Использование python для запуска
|
|
56
|
+
|
|
57
|
+
## Сборка
|
|
58
|
+
|
|
59
|
+
### Сборка под linux
|
|
60
|
+
|
|
61
|
+
Создание и активация виртуального окружения python
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m venv .venv
|
|
65
|
+
source .venv/bin/activate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Установка зависимостей для сборки
|
|
69
|
+
|
|
70
|
+
```basn
|
|
71
|
+
pip install -r requirements-build.txt
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Сборка исполняемых файлов
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
sh build.sh # самодостаточный
|
|
78
|
+
sh build_shiv.sh # файл .pyz
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Результат сборки лежит в папке _`/dist`_
|
wthrcli-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wthrcli
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: A smart CLI weather tool: no coordinates needed, just name your location and go.
|
|
5
|
+
Author-email: sanbobsan <saintsanbob@gmail.com>
|
|
6
|
+
Project-URL: Repository, https://github.com/sanbobsan/weather-cli.git
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: typer>=0.24.0
|
|
10
|
+
Requires-Dist: niquests>=3.17.0
|
|
11
|
+
Requires-Dist: pydantic>=2.12.5
|
|
12
|
+
Requires-Dist: platformdirs>=4.9.2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pip-tools; extra == "dev"
|
|
15
|
+
Requires-Dist: mypy; extra == "dev"
|
|
16
|
+
Provides-Extra: build
|
|
17
|
+
Requires-Dist: pyinstaller; extra == "build"
|
|
18
|
+
Requires-Dist: shiv; extra == "build"
|
|
19
|
+
|
|
20
|
+
# ✨⛅ weather-cli
|
|
21
|
+
|
|
22
|
+
Командная утилита для получения текущей погоды в любом месте.
|
|
23
|
+
|
|
24
|
+
## 📋 Содержание
|
|
25
|
+
|
|
26
|
+
- [Технологический стек](#-технологический-стек)
|
|
27
|
+
- [Используемые API](#-используемые-api)
|
|
28
|
+
- [Использование](#использование)
|
|
29
|
+
- [Сборка](#сборка)
|
|
30
|
+
|
|
31
|
+
## 🛠 Технологический стек
|
|
32
|
+
|
|
33
|
+
- [Typer](https://typer.tiangolo.com/) — для создания удобного интерфейса командной строки (CLI). Позволяет легко определять аргументы и опции (например, название места и тип прогноза), автоматически генерирует подсказки и обрабатывает ввод пользователя.
|
|
34
|
+
- [Niquests](https://github.com/jawah/niquests/) — современный HTTP-клиент (форк `requests`). Используется для отправки запросов к Nominatim и Open-Meteo и получения данных о месте и погоде, соответственно.
|
|
35
|
+
- [Pydantic](https://docs.pydantic.dev/) — для валидации и парсинга данных. С его помощью мы определяем структуру ответа API и автоматически преобразуем JSON в удобные объекты Python с проверкой типов.
|
|
36
|
+
|
|
37
|
+
## 🌐 Используемые API
|
|
38
|
+
|
|
39
|
+
- [Nominatim](https://nominatim.org/) — геокодирование координат
|
|
40
|
+
- [Open-Meteo](https://open-meteo.com/) — получение прогноза по координатам
|
|
41
|
+
|
|
42
|
+
### ⚠️ Ограничения API
|
|
43
|
+
|
|
44
|
+
- Nominatim — Максимум 1 запрос в секунду
|
|
45
|
+
- Open-Meteo — Не более 10,000 запросов в день, 5,000 в час и 600 в минуту
|
|
46
|
+
|
|
47
|
+
## Использование
|
|
48
|
+
|
|
49
|
+
Возможные команды weather-cli можно увидеть прописав `--help`, так же можно использовать `--help` и у отдельных команд, чтобы увидеть их описание
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
wthr --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Доступные команды:
|
|
56
|
+
- weather – Показать прогноз погоды для указанного места.
|
|
57
|
+
- set – Сохранить место в конфиг, чтобы в дальнейшем автоматически использовать его для получения погоды
|
|
58
|
+
- get – Проверить текущее место в конфиге
|
|
59
|
+
- clear – Очистить конфиг и кеш
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Также доступно использование просто `wthr`
|
|
63
|
+
|
|
64
|
+
### Использование исполняемого файла на linux
|
|
65
|
+
|
|
66
|
+
Выбрать подходящую версию из [релизов](https://github.com/sanbobsan/weather-cli/releases)
|
|
67
|
+
|
|
68
|
+
- **wthr** — самодостаточный исполняемый файл
|
|
69
|
+
- **whtr.pyz** — исполняемый файл, который требует установленный python для запуска (требуется лишь интерпретатор, все зависимости он хранит в себе)
|
|
70
|
+
|
|
71
|
+
1. поместить исполняемый файл в _`/usr/local/bin`_
|
|
72
|
+
2. запускать командой `wthr` / `wthr.pyz`
|
|
73
|
+
|
|
74
|
+
### Использование python для запуска
|
|
75
|
+
|
|
76
|
+
## Сборка
|
|
77
|
+
|
|
78
|
+
### Сборка под linux
|
|
79
|
+
|
|
80
|
+
Создание и активация виртуального окружения python
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m venv .venv
|
|
84
|
+
source .venv/bin/activate
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Установка зависимостей для сборки
|
|
88
|
+
|
|
89
|
+
```basn
|
|
90
|
+
pip install -r requirements-build.txt
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Сборка исполняемых файлов
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
sh build.sh # самодостаточный
|
|
97
|
+
sh build_shiv.sh # файл .pyz
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Результат сборки лежит в папке _`/dist`_
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 82.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wthrcli"
|
|
7
|
+
authors = [{ name = "sanbobsan", email = "saintsanbob@gmail.com" }]
|
|
8
|
+
description = "A smart CLI weather tool: no coordinates needed, just name your location and go."
|
|
9
|
+
version = "1.0.1"
|
|
10
|
+
readme = ".github/README.md"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"typer>=0.24.0",
|
|
14
|
+
"niquests>=3.17.0",
|
|
15
|
+
"pydantic>=2.12.5",
|
|
16
|
+
"platformdirs>=4.9.2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pip-tools", "mypy"]
|
|
21
|
+
build = ["pyinstaller", "shiv"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/sanbobsan/weather-cli.git"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
wthr = "wthr.main:app"
|
wthrcli-1.0.1/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from niquests import Response, Session
|
|
2
|
+
from niquests.adapters import HTTPAdapter
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class APIClient:
|
|
6
|
+
def __init__(self) -> None:
|
|
7
|
+
self._session: Session = self._create_session()
|
|
8
|
+
self.timeout = (5, 10)
|
|
9
|
+
|
|
10
|
+
def _create_session(self) -> Session:
|
|
11
|
+
session = Session()
|
|
12
|
+
session.headers.update(
|
|
13
|
+
{
|
|
14
|
+
"User-Agent": "weather-cli/0.2.0",
|
|
15
|
+
"Accept": "application/json",
|
|
16
|
+
# "Accept-Encoding": "gzip, deflate, br",
|
|
17
|
+
"Connection": "keep-alive",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
adapter = HTTPAdapter(
|
|
21
|
+
pool_connections=5,
|
|
22
|
+
pool_maxsize=5,
|
|
23
|
+
max_retries=2,
|
|
24
|
+
pool_block=False,
|
|
25
|
+
)
|
|
26
|
+
session.mount("https://", adapter)
|
|
27
|
+
session.mount("http://", adapter)
|
|
28
|
+
return session
|
|
29
|
+
|
|
30
|
+
def _get(self, url: str, params: dict | None = None) -> dict:
|
|
31
|
+
r: Response = self._session.get(url=url, params=params, timeout=self.timeout)
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
return r.json()
|
|
34
|
+
|
|
35
|
+
def __enter__(self):
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
39
|
+
self._session.close()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from wthr.api.api_client import APIClient
|
|
4
|
+
from wthr.database import location_cache_storage
|
|
5
|
+
from wthr.models import (
|
|
6
|
+
CurrentForecast,
|
|
7
|
+
DailyForecast,
|
|
8
|
+
ForecastType,
|
|
9
|
+
HourlyForecast,
|
|
10
|
+
Location,
|
|
11
|
+
LocationDict,
|
|
12
|
+
Weather,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LocationNotFoundError(Exception):
|
|
17
|
+
"""Исключение, возникающее, когда локация не найдена с помощью геокодирования"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, msg: str = "Локация не найдена"):
|
|
20
|
+
super().__init__(msg)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WeatherAPIClient(APIClient):
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self._geocoder_url = "https://nominatim.openstreetmap.org/search"
|
|
27
|
+
self._weather_url = "https://api.open-meteo.com/v1/forecast"
|
|
28
|
+
|
|
29
|
+
def get_location(self, location_name: str) -> Location:
|
|
30
|
+
|
|
31
|
+
cache: LocationDict | None = location_cache_storage.get_location(location_name)
|
|
32
|
+
if cache:
|
|
33
|
+
location_: Location = Location.model_validate(cache)
|
|
34
|
+
return location_
|
|
35
|
+
|
|
36
|
+
params: dict = {
|
|
37
|
+
"q": location_name,
|
|
38
|
+
"format": "json",
|
|
39
|
+
"limit": 1,
|
|
40
|
+
}
|
|
41
|
+
raw_data: dict = self._get(url=self._geocoder_url, params=params)
|
|
42
|
+
|
|
43
|
+
if not raw_data:
|
|
44
|
+
raise LocationNotFoundError
|
|
45
|
+
|
|
46
|
+
location_data: LocationDict = {
|
|
47
|
+
"display_name": raw_data[0]["display_name"],
|
|
48
|
+
"latitude": round(float(raw_data[0]["lat"]), 2),
|
|
49
|
+
"longitude": round(float(raw_data[0]["lon"]), 2),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
location_cache_storage.save_location(
|
|
53
|
+
name=location_name,
|
|
54
|
+
display_name=location_data["display_name"],
|
|
55
|
+
latitude=location_data["latitude"],
|
|
56
|
+
longitude=location_data["longitude"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
location_ = Location.model_validate(location_data)
|
|
60
|
+
return location_
|
|
61
|
+
|
|
62
|
+
def get_weather(
|
|
63
|
+
self,
|
|
64
|
+
location: Location,
|
|
65
|
+
forecast_type: ForecastType = ForecastType.CURRENT,
|
|
66
|
+
days: int = 4,
|
|
67
|
+
hours: int = 12,
|
|
68
|
+
timezone: str = "auto",
|
|
69
|
+
temperature_unit: Literal["celsius", "fahrenheit"] | None = None,
|
|
70
|
+
wind_speed_unit: Literal["kmh", "ms", "mph", "knots"] | None = None,
|
|
71
|
+
) -> Weather:
|
|
72
|
+
"""Универсальный метод для получения погоды
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
location_data: данные локации
|
|
76
|
+
forecast_type: тип прогноза ("current", "daily", "hourly", "mixed")
|
|
77
|
+
days: количество дней для daily-прогноза (1-16)
|
|
78
|
+
hours: количество часов для hourly-прогноза (1-168)
|
|
79
|
+
timezone: временная зона
|
|
80
|
+
temperature_unit: "celsius" или "fahrenheit"
|
|
81
|
+
wind_speed_unit: "kmh", "ms", "mph", "knots"
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
WeatherData: Информация о погоде:
|
|
85
|
+
- для current: {"location": "...", "current": {...}}
|
|
86
|
+
- для daily: {"location": "...", "daily": [{...}, {...}]}
|
|
87
|
+
- для hourly: {"location": "...", "hourly": [{...}, {...}]}
|
|
88
|
+
- для mixed: комбинация всех выше
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
params: dict = {
|
|
92
|
+
"latitude": location.latitude,
|
|
93
|
+
"longitude": location.longitude,
|
|
94
|
+
"timezone": timezone,
|
|
95
|
+
"temperature_unit": temperature_unit,
|
|
96
|
+
"wind_speed_unit": wind_speed_unit,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if forecast_type in (ForecastType.CURRENT, ForecastType.MIXED):
|
|
100
|
+
params["current"] = CurrentForecast.get_request_fields()
|
|
101
|
+
|
|
102
|
+
if forecast_type in (ForecastType.DAILY, ForecastType.MIXED):
|
|
103
|
+
params["daily"] = DailyForecast.get_request_fields()
|
|
104
|
+
params["forecast_days"] = min(max(days, 1), 16) # от 1 до 16
|
|
105
|
+
|
|
106
|
+
if forecast_type in (ForecastType.HOURLY, ForecastType.MIXED):
|
|
107
|
+
params["hourly"] = HourlyForecast.get_request_fields()
|
|
108
|
+
params["forecast_hours"] = min(max(hours, 1), 168) # от 1 до 168
|
|
109
|
+
|
|
110
|
+
raw_data: dict = self._get(self._weather_url, params)
|
|
111
|
+
|
|
112
|
+
return self._parse_weather_response(
|
|
113
|
+
raw_data=raw_data,
|
|
114
|
+
forecast_type=forecast_type,
|
|
115
|
+
location_display_name=location.display_name,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _parse_weather_response(
|
|
119
|
+
self,
|
|
120
|
+
raw_data: dict,
|
|
121
|
+
forecast_type: ForecastType,
|
|
122
|
+
location_display_name: str,
|
|
123
|
+
) -> Weather:
|
|
124
|
+
"""Преобразует ответ API в удобный формат"""
|
|
125
|
+
result: Weather = Weather(location_display_name=location_display_name)
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
forecast_type in (ForecastType.CURRENT, ForecastType.MIXED)
|
|
129
|
+
and "current" in raw_data
|
|
130
|
+
):
|
|
131
|
+
result.current = CurrentForecast.model_validate(raw_data["current"])
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
forecast_type in (ForecastType.DAILY, ForecastType.MIXED)
|
|
135
|
+
and "daily" in raw_data
|
|
136
|
+
):
|
|
137
|
+
result.daily = [
|
|
138
|
+
DailyForecast.model_validate(item)
|
|
139
|
+
for item in self._normalize_timeseries(raw_data["daily"])
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
forecast_type in (ForecastType.HOURLY, ForecastType.MIXED)
|
|
144
|
+
and "hourly" in raw_data
|
|
145
|
+
):
|
|
146
|
+
result.hourly = [
|
|
147
|
+
HourlyForecast.model_validate(item)
|
|
148
|
+
for item in self._normalize_timeseries(raw_data["hourly"])
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
def _normalize_timeseries(self, data: dict) -> list[dict]:
|
|
154
|
+
"""
|
|
155
|
+
Преобразует почасовые или ежедневные данные в удобный формат
|
|
156
|
+
|
|
157
|
+
Было: {"time": [...], "weather_code": [...]}
|
|
158
|
+
Стало: [{"time": ..., "weather_code": ...}, ...]
|
|
159
|
+
"""
|
|
160
|
+
if not data or "time" not in data:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
count: int = len(data["time"])
|
|
164
|
+
result: list[dict] = []
|
|
165
|
+
|
|
166
|
+
for i in range(count):
|
|
167
|
+
item: dict = {}
|
|
168
|
+
for key, values in data.items():
|
|
169
|
+
if isinstance(values, list) and len(values) > i:
|
|
170
|
+
item[key] = values[i]
|
|
171
|
+
result.append(item)
|
|
172
|
+
|
|
173
|
+
return result
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Generic
|
|
3
|
+
|
|
4
|
+
from platformdirs import user_cache_path
|
|
5
|
+
|
|
6
|
+
from wthr.database.storage.app_info import APP_NAME, LOCATION_CACHE_FILE_NAME
|
|
7
|
+
from wthr.database.storage.storage import Storage, T
|
|
8
|
+
from wthr.models import (
|
|
9
|
+
LocationDict,
|
|
10
|
+
LocationDicts,
|
|
11
|
+
WeatherDicts,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheStorage(Storage[T], Generic[T]):
|
|
16
|
+
def __init__(self, app_name: str, file_name: str) -> None:
|
|
17
|
+
folder_path: Path = user_cache_path(app_name)
|
|
18
|
+
empty_data_example: dict = {}
|
|
19
|
+
super().__init__(
|
|
20
|
+
folder_path=folder_path,
|
|
21
|
+
file_name=file_name,
|
|
22
|
+
empty_data_example=empty_data_example, # type: ignore
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocationCacheStorage(CacheStorage[LocationDicts]):
|
|
27
|
+
def get_location(self, name: str) -> LocationDict | None:
|
|
28
|
+
"""Найти место в кеше
|
|
29
|
+
Args:
|
|
30
|
+
name (str): название, которое дал пользователь
|
|
31
|
+
:return: Место или None, если не найдено
|
|
32
|
+
:rtype: LocationDict | None
|
|
33
|
+
"""
|
|
34
|
+
name = name.lower()
|
|
35
|
+
data: LocationDicts = self.get()
|
|
36
|
+
if name in data:
|
|
37
|
+
return data[name]
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def save_location(
|
|
41
|
+
self,
|
|
42
|
+
name: str,
|
|
43
|
+
display_name: str,
|
|
44
|
+
latitude: float,
|
|
45
|
+
longitude: float,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Сохранить место в кеш
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
name (str): название, которое дал пользователь
|
|
51
|
+
display_name (str): полное название (информация), полученное после парсинга
|
|
52
|
+
latitude (float): широта, будет округлена до сотых
|
|
53
|
+
longitude (float): долгота, будет округлена до сотых
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name = name.lower()
|
|
57
|
+
latitude = round(latitude, 2)
|
|
58
|
+
longitude = round(longitude, 2)
|
|
59
|
+
|
|
60
|
+
with self.open_data() as data:
|
|
61
|
+
data[name] = {
|
|
62
|
+
"display_name": display_name,
|
|
63
|
+
"latitude": latitude,
|
|
64
|
+
"longitude": longitude,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WeatherCacheStorage(CacheStorage[WeatherDicts]): ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
location_cache_storage = LocationCacheStorage(APP_NAME, LOCATION_CACHE_FILE_NAME)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from wthr.database.storage.app_info import APP_NAME, CONFIG_FILE_NAME
|
|
6
|
+
from wthr.database.storage.storage import Storage
|
|
7
|
+
from wthr.models import (
|
|
8
|
+
ConfigDict,
|
|
9
|
+
get_empty_config_dict,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigStorage(Storage[ConfigDict]):
|
|
14
|
+
def __init__(self, app_name: str, file_name: str) -> None:
|
|
15
|
+
folder_path = Path(typer.get_app_dir(app_name))
|
|
16
|
+
empty_data_example: ConfigDict = get_empty_config_dict()
|
|
17
|
+
super().__init__(
|
|
18
|
+
folder_path=folder_path,
|
|
19
|
+
file_name=file_name,
|
|
20
|
+
empty_data_example=empty_data_example,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def set_default_location(self, default_location: str | None) -> None:
|
|
24
|
+
"""Сохранить место по умолчанию"""
|
|
25
|
+
with self.open_data() as config:
|
|
26
|
+
config["default"] = default_location
|
|
27
|
+
|
|
28
|
+
def get_default_location(self) -> str | None:
|
|
29
|
+
"""Получить место по умолчанию"""
|
|
30
|
+
return self.get()["default"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
config_storage = ConfigStorage(APP_NAME, CONFIG_FILE_NAME)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Generic, Iterator, TypeVar
|
|
6
|
+
|
|
7
|
+
from wthr.models import (
|
|
8
|
+
ConfigDict,
|
|
9
|
+
LocationDicts,
|
|
10
|
+
WeatherDicts,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=(dict | ConfigDict | LocationDicts | WeatherDicts))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Storage(ABC, Generic[T]):
|
|
17
|
+
def __init__(
|
|
18
|
+
self, folder_path: Path, file_name: str, empty_data_example: T
|
|
19
|
+
) -> None:
|
|
20
|
+
self._folder_path: Path = folder_path
|
|
21
|
+
self._file_path: Path = folder_path / file_name
|
|
22
|
+
self._empty_data_example: T = empty_data_example
|
|
23
|
+
|
|
24
|
+
def _create_folder(self) -> None:
|
|
25
|
+
self._folder_path.mkdir(exist_ok=True, parents=True)
|
|
26
|
+
|
|
27
|
+
def get(self) -> T:
|
|
28
|
+
"""Получить все данные из хранилища
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
T (dict): словарь в зависимости от типа хранилища
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
with open(self._file_path, "r") as file:
|
|
35
|
+
return json.load(file)
|
|
36
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
37
|
+
return self._empty_data_example
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def open_data(self) -> Iterator[T]:
|
|
41
|
+
"""Контекстный менеджер, который возвращает изменяемый объект, который будет сохранен в хранилище"""
|
|
42
|
+
self._create_folder()
|
|
43
|
+
try:
|
|
44
|
+
with open(self._file_path, "r") as file:
|
|
45
|
+
data: T = json.load(file)
|
|
46
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
47
|
+
data = self._empty_data_example
|
|
48
|
+
yield data
|
|
49
|
+
with open(self._file_path, "w") as file:
|
|
50
|
+
json.dump(data, file)
|
|
51
|
+
|
|
52
|
+
def clear(self) -> None:
|
|
53
|
+
"""Удаляет папку и файл, которые использовались, как хранилище"""
|
|
54
|
+
self._file_path.unlink(missing_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
self._folder_path.rmdir()
|
|
57
|
+
except (FileNotFoundError, OSError):
|
|
58
|
+
pass
|