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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,6 @@
1
+ from .weather_client import LocationNotFoundError, WeatherAPIClient
2
+
3
+ __all__ = [
4
+ "LocationNotFoundError",
5
+ "WeatherAPIClient",
6
+ ]
@@ -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,6 @@
1
+ from .storage import config_storage, location_cache_storage
2
+
3
+ __all__ = [
4
+ "config_storage",
5
+ "location_cache_storage",
6
+ ]
@@ -0,0 +1,7 @@
1
+ from .cache_storage import location_cache_storage
2
+ from .config_storage import config_storage
3
+
4
+ __all__ = [
5
+ "location_cache_storage",
6
+ "config_storage",
7
+ ]
@@ -0,0 +1,3 @@
1
+ APP_NAME = "wthr"
2
+ CONFIG_FILE_NAME = "config.json"
3
+ LOCATION_CACHE_FILE_NAME = "location.json"
@@ -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