From 848b6bd9bad7186bf1fcc99b07ca61e6b8f6380c Mon Sep 17 00:00:00 2001 From: shmyga Date: Fri, 26 Jul 2024 11:02:01 +0300 Subject: [PATCH] feat: split to weather and gismeteo modules --- .vscode/settings.json | 13 +++--- gismeteo/api.py | 40 +++++++++--------- gismeteo/app.py | 20 --------- gismeteo/core.py | 1 - gismeteo/{dateutil.py => datehelp.py} | 0 gismeteo/mock/__init__.py | 12 ++---- gismeteo/mock/data/weather.json | 2 +- gismeteo/parser.py | 16 ++++++- gismeteo/route/api.py | 12 ------ poetry.lock | 2 +- pyproject.toml | 7 +-- tests/{test_api.py => test_gismeteo_api.py} | 2 +- {gismeteo/route => weather}/__init__.py | 0 weather/api.py | 15 +++++++ weather/app.py | 27 ++++++++++++ weather/model.py | 22 ++++++++++ .../index.js => weather/route/__init__.py | 0 weather/route/api.py | 12 ++++++ {gismeteo => weather}/route/doc/__init__.py | 0 .../route/doc/static/redoc.standalone.js | 0 .../route/doc/static/swagger-ui-bundle.js | 0 .../route/doc/static/swagger-ui.css | 0 {gismeteo => weather}/route/view/__init__.py | 20 ++++----- {gismeteo => weather}/route/view/filters.py | 1 + .../route/view/static/favicon.ico | Bin weather/route/view/static/index.js | 0 .../route/view/static/style.css | 1 + .../route/view/templates/weather.html | 40 +++++++++--------- 28 files changed, 157 insertions(+), 108 deletions(-) delete mode 100644 gismeteo/app.py rename gismeteo/{dateutil.py => datehelp.py} (100%) delete mode 100644 gismeteo/route/api.py rename tests/{test_api.py => test_gismeteo_api.py} (93%) rename {gismeteo/route => weather}/__init__.py (100%) create mode 100644 weather/api.py create mode 100644 weather/app.py create mode 100644 weather/model.py rename gismeteo/route/view/static/index.js => weather/route/__init__.py (100%) create mode 100644 weather/route/api.py rename {gismeteo => weather}/route/doc/__init__.py (100%) rename {gismeteo => weather}/route/doc/static/redoc.standalone.js (100%) rename {gismeteo => weather}/route/doc/static/swagger-ui-bundle.js (100%) rename {gismeteo => weather}/route/doc/static/swagger-ui.css (100%) rename {gismeteo => weather}/route/view/__init__.py (68%) rename {gismeteo => weather}/route/view/filters.py (94%) rename {gismeteo => weather}/route/view/static/favicon.ico (100%) create mode 100644 weather/route/view/static/index.js rename {gismeteo => weather}/route/view/static/style.css (97%) rename {gismeteo => weather}/route/view/templates/weather.html (78%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1bdc1a0..d4d7b1e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { - "python.testing.pytestArgs": [ - "tests", "-s" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file + "python.testing.pytestArgs": ["tests", "-s"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "files.exclude": { + "**/__pycache__": true + } +} diff --git a/gismeteo/api.py b/gismeteo/api.py index 1261c43..28c13ee 100644 --- a/gismeteo/api.py +++ b/gismeteo/api.py @@ -1,27 +1,18 @@ import datetime -from typing import Any, Dict, List, NamedTuple +from typing import Any, Dict, List import aiohttp from bs4 import BeautifulSoup -from . import dateutil +from weather.api import WeatherApi +from weather.model import WeatherResponse, WeatherValue + +from . import datehelp from .location import LOCATION_BUNDLE -from .parser import ROW_PARSERS, ONE_DAY_PARSER +from .parser import LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS -class WeatherValue(NamedTuple): - date: datetime.datetime - cloudness: str - temperature: int - wind_speed: int - wind_gust: int - wind_direction: str - precipitation: float - pressure: int - humidity: int - - -class GismeteoApi: +class GismeteoApi(WeatherApi): BASE_URL = "https://www.gismeteo.ru" async def _request(self, endpoint: str) -> str: @@ -30,18 +21,25 @@ class GismeteoApi: async with session.request("GET", url) as response: return await response.text() - def _parse_oneday(self, data: str) -> List[WeatherValue]: + def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse: result: List[Dict[str, Any]] = [] soup = BeautifulSoup(data, features="html.parser") + location = LOCATION_PARSER.parse_location(data) widget = ONE_DAY_PARSER.parse_widget(soup) for parser in ROW_PARSERS: for index, value in enumerate(parser.parse_row(widget)): while len(result) < index + 1: result.append({}) result[index][parser.KEY] = value - return [WeatherValue(**item) for item in result] + values = [WeatherValue(**item) for item in result] + return WeatherResponse( + location=location or "n/a", + date=date, + period="day", + values=values, + ) - async def get_day(self, location_id: str, date: datetime.date) -> List[WeatherValue]: + async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: location = LOCATION_BUNDLE.parse(location_id) - data = await self._request(f"weather-{location}/{dateutil.dump(date)}") - return self._parse_oneday(data) + data = await self._request(f"weather-{location}/{datehelp.dump(date)}") + return self._parse_oneday(date, data) diff --git a/gismeteo/app.py b/gismeteo/app.py deleted file mode 100644 index 312c2ab..0000000 --- a/gismeteo/app.py +++ /dev/null @@ -1,20 +0,0 @@ -import locale -from os import environ - -import uvicorn -from fastapi import FastAPI - -from gismeteo.route import api, doc, view - -# locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") - -app = FastAPI(docs_url=None, redoc_url=None) -doc.mount(app) -api.mount(app) -view.mount(app) - - -def run(): - uvicorn.run( - "gismeteo.app:app", host="0.0.0.0", port=8000, reload="DEBUG" in environ - ) diff --git a/gismeteo/core.py b/gismeteo/core.py index b168e57..edde261 100644 --- a/gismeteo/core.py +++ b/gismeteo/core.py @@ -4,7 +4,6 @@ from bs4 import Tag T = TypeVar("T") - class WidgetParser: def parse_widget(self, tag: Tag) -> Tag: raise NotImplementedError diff --git a/gismeteo/dateutil.py b/gismeteo/datehelp.py similarity index 100% rename from gismeteo/dateutil.py rename to gismeteo/datehelp.py diff --git a/gismeteo/mock/__init__.py b/gismeteo/mock/__init__.py index edd05d8..cf53c87 100644 --- a/gismeteo/mock/__init__.py +++ b/gismeteo/mock/__init__.py @@ -1,10 +1,7 @@ import json from pathlib import Path -from typing import List -import dateparser - -from gismeteo.api import WeatherValue +from gismeteo.api import WeatherResponse class MockData: @@ -14,12 +11,9 @@ class MockData: return (Path(__file__).parent / "data/weather.html").read_text() @property - def values(self) -> List[WeatherValue]: + def response(self) -> WeatherResponse: data = json.loads((Path(__file__).parent / "data/weather.json").read_text()) - return [ - WeatherValue(**{**item, "date": dateparser.parse(item["date"])}) - for item in data - ] + return WeatherResponse(**data) MOCK_DATA = MockData() diff --git a/gismeteo/mock/data/weather.json b/gismeteo/mock/data/weather.json index 55c462e..ed4e1a8 100644 --- a/gismeteo/mock/data/weather.json +++ b/gismeteo/mock/data/weather.json @@ -1 +1 @@ -[{"date":"2024-07-25T00:00:00","cloudness":"Ясно","temperature":14,"wind_speed":1,"wind_gust":1,"wind_direction":"С","precipitation":0.0,"pressure":739,"humidity":85},{"date":"2024-07-25T03:00:00","cloudness":"Ясно","temperature":13,"wind_speed":1,"wind_gust":2,"wind_direction":"СЗ","precipitation":0.0,"pressure":739,"humidity":92},{"date":"2024-07-25T06:00:00","cloudness":"Малооблачно, без осадков","temperature":14,"wind_speed":1,"wind_gust":2,"wind_direction":"СЗ","precipitation":0.0,"pressure":738,"humidity":89},{"date":"2024-07-25T09:00:00","cloudness":"Малооблачно, без осадков","temperature":23,"wind_speed":3,"wind_gust":5,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":58},{"date":"2024-07-25T12:00:00","cloudness":"Малооблачно, без осадков","temperature":26,"wind_speed":3,"wind_gust":6,"wind_direction":"СЗ","precipitation":0.0,"pressure":738,"humidity":47},{"date":"2024-07-25T15:00:00","cloudness":"Облачно, без осадков","temperature":26,"wind_speed":2,"wind_gust":6,"wind_direction":"С","precipitation":0.0,"pressure":737,"humidity":46},{"date":"2024-07-25T18:00:00","cloudness":"Малооблачно, небольшой дождь","temperature":24,"wind_speed":3,"wind_gust":7,"wind_direction":"СВ","precipitation":0.3,"pressure":737,"humidity":54},{"date":"2024-07-25T21:00:00","cloudness":"Ясно","temperature":19,"wind_speed":1,"wind_gust":5,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":80}] \ No newline at end of file +{"location":"Змиевка","date":"2024-07-26","period":"day","values":[{"date":"2024-07-26T00:00:00","cloudness":"Облачно, без осадков","temperature":15,"wind_speed":1,"wind_gust":1,"wind_direction":"СВ","precipitation":0.0,"pressure":738,"humidity":96},{"date":"2024-07-26T03:00:00","cloudness":"Ясно","temperature":14,"wind_speed":0,"wind_gust":1,"wind_direction":"штиль","precipitation":0.0,"pressure":738,"humidity":98},{"date":"2024-07-26T06:00:00","cloudness":"Ясно","temperature":15,"wind_speed":1,"wind_gust":1,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":97},{"date":"2024-07-26T09:00:00","cloudness":"Ясно","temperature":22,"wind_speed":1,"wind_gust":3,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":66},{"date":"2024-07-26T12:00:00","cloudness":"Малооблачно, без осадков","temperature":24,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":47},{"date":"2024-07-26T15:00:00","cloudness":"Облачно, без осадков","temperature":25,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":40},{"date":"2024-07-26T18:00:00","cloudness":"Облачно, без осадков","temperature":25,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":740,"humidity":39},{"date":"2024-07-26T21:00:00","cloudness":"Ясно","temperature":18,"wind_speed":1,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":741,"humidity":62}]} \ No newline at end of file diff --git a/gismeteo/parser.py b/gismeteo/parser.py index 39ffefc..1622d7d 100644 --- a/gismeteo/parser.py +++ b/gismeteo/parser.py @@ -1,5 +1,6 @@ import datetime -from typing import Dict, Iterable, List +import re +from typing import Dict, Iterable, List, Optional import dateparser from bs4 import Tag @@ -9,6 +10,19 @@ from .core import BaseWidgetParser, RowParser ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items") +class LocationParser: + PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"') + + def parse_location(self, data: str) -> Optional[str]: + match = self.PATTERN.search(data) + if match: + return match.group(1) + return None + + +LOCATION_PARSER = LocationParser() + + class DateParser(RowParser[datetime.datetime]): KEY = "date" diff --git a/gismeteo/route/api.py b/gismeteo/route/api.py deleted file mode 100644 index ae291df..0000000 --- a/gismeteo/route/api.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import FastAPI - -from gismeteo import dateutil -from gismeteo.api import GismeteoApi - - -def mount(app: FastAPI): - @app.get("/api/weather/{location}/{date}") - async def get_weather(location: str, date: str): - api = GismeteoApi() - result = await api.get_day(location, dateutil.parse(date)) - return [item._asdict() for item in result] diff --git a/poetry.lock b/poetry.lock index 2239456..f1e7fa4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1795,4 +1795,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "6940ed3fa5467b63dde6410fcb427cbcf93c4366f967e07cf24601d51b43a28f" +content-hash = "bc04729da7680c2e078b4abd6e402186b54a938ef5cee388b0b2c462f6c167f1" diff --git a/pyproject.toml b/pyproject.toml index 0499746..8ecef46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,17 @@ [tool.poetry] -name = "gismeteo-api" +name = "weather" version = "0.1.0" description = "" authors = ["shmyga "] readme = "README.md" -packages = [{ include = "gismeteo" }] +packages = [{ include = "weather" }, { include = "gismeteo" }] [tool.poetry.dependencies] python = "^3.12" aiohttp = "^3.9.5" beautifulsoup4 = "^4.12.3" dateparser = "^1.2.0" +pydantic = "^2.8.2" [tool.poetry.group.app.dependencies] fastapi = "^0.111.1" @@ -30,7 +31,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -app = 'gismeteo.app:run' +app = 'weather.app:run' [tool.pytest.ini_options] addopts = "-p no:warnings" diff --git a/tests/test_api.py b/tests/test_gismeteo_api.py similarity index 93% rename from tests/test_api.py rename to tests/test_gismeteo_api.py index 08b6a79..266dbe8 100644 --- a/tests/test_api.py +++ b/tests/test_gismeteo_api.py @@ -21,4 +21,4 @@ async def test_api(gismeteo_api: GismeteoApi): result = await gismeteo_api.get_day( "zmiyevka", datetime.date.today() + datetime.timedelta(days=1) ) - assert len(result) == 8 + assert len(result.values) == 8 diff --git a/gismeteo/route/__init__.py b/weather/__init__.py similarity index 100% rename from gismeteo/route/__init__.py rename to weather/__init__.py diff --git a/weather/api.py b/weather/api.py new file mode 100644 index 0000000..c292975 --- /dev/null +++ b/weather/api.py @@ -0,0 +1,15 @@ +import datetime + +from .model import WeatherResponse + + +class WeatherApi: + async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: + raise NotImplementedError + + +DEFAULT_API: WeatherApi = None + + +def get_api() -> WeatherApi: + return DEFAULT_API diff --git a/weather/app.py b/weather/app.py new file mode 100644 index 0000000..893c2df --- /dev/null +++ b/weather/app.py @@ -0,0 +1,27 @@ +import locale +from os import environ + +import uvicorn +from fastapi import FastAPI + +from gismeteo.api import GismeteoApi + +from . import api as _api +from .route import api, doc, view + +_api.DEFAULT_API = GismeteoApi() + +locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") + +app = FastAPI( + title="Weather", + docs_url=None, + redoc_url=None, +) +doc.mount(app) +api.mount(app) +view.mount(app) + + +def run(): + uvicorn.run("weather.app:app", host="0.0.0.0", port=8000, reload="DEBUG" in environ) diff --git a/weather/model.py b/weather/model.py new file mode 100644 index 0000000..4476be4 --- /dev/null +++ b/weather/model.py @@ -0,0 +1,22 @@ +import datetime + +from pydantic import BaseModel + + +class WeatherValue(BaseModel): + date: datetime.datetime + cloudness: str + temperature: int + wind_speed: int + wind_gust: int + wind_direction: str + precipitation: float + pressure: int + humidity: int + + +class WeatherResponse(BaseModel): + location: str + date: datetime.date + period: str + values: list[WeatherValue] diff --git a/gismeteo/route/view/static/index.js b/weather/route/__init__.py similarity index 100% rename from gismeteo/route/view/static/index.js rename to weather/route/__init__.py diff --git a/weather/route/api.py b/weather/route/api.py new file mode 100644 index 0000000..355252d --- /dev/null +++ b/weather/route/api.py @@ -0,0 +1,12 @@ +import datetime + +from fastapi import FastAPI + +from weather.api import get_api +from weather.model import WeatherResponse + + +def mount(app: FastAPI): + @app.get("/api/weather/{location}/{date}") + async def get_weather(location: str, date: datetime.date) -> WeatherResponse: + return await get_api().get_day(location, date) diff --git a/gismeteo/route/doc/__init__.py b/weather/route/doc/__init__.py similarity index 100% rename from gismeteo/route/doc/__init__.py rename to weather/route/doc/__init__.py diff --git a/gismeteo/route/doc/static/redoc.standalone.js b/weather/route/doc/static/redoc.standalone.js similarity index 100% rename from gismeteo/route/doc/static/redoc.standalone.js rename to weather/route/doc/static/redoc.standalone.js diff --git a/gismeteo/route/doc/static/swagger-ui-bundle.js b/weather/route/doc/static/swagger-ui-bundle.js similarity index 100% rename from gismeteo/route/doc/static/swagger-ui-bundle.js rename to weather/route/doc/static/swagger-ui-bundle.js diff --git a/gismeteo/route/doc/static/swagger-ui.css b/weather/route/doc/static/swagger-ui.css similarity index 100% rename from gismeteo/route/doc/static/swagger-ui.css rename to weather/route/doc/static/swagger-ui.css diff --git a/gismeteo/route/view/__init__.py b/weather/route/view/__init__.py similarity index 68% rename from gismeteo/route/view/__init__.py rename to weather/route/view/__init__.py index a4bfa1b..400bfe3 100644 --- a/gismeteo/route/view/__init__.py +++ b/weather/route/view/__init__.py @@ -6,9 +6,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from gismeteo import dateutil -from gismeteo.api import GismeteoApi from gismeteo.mock import MOCK_DATA +from weather.api import get_api from .filters import cloudness_icon, wind_direction_icon @@ -20,24 +19,21 @@ def mount(app: FastAPI): templates.env.filters["wind_direction_icon"] = wind_direction_icon templates.env.filters["cloudness_icon"] = cloudness_icon - @app.get("/weather/{location}") - async def get_weather_base(location: str): - return RedirectResponse(f"{location}/today") + @app.get("/weather/{location}", response_class=RedirectResponse) + async def get_weather_default(location: str): + return RedirectResponse(f"{location}/{datetime.date.today()}") @app.get("/weather/{location}/{date}", response_class=HTMLResponse) - async def get_weather(request: Request, location: str, date: str): + async def get_weather(request: Request, location: str, date: datetime.date): if date == "mock": - values = MOCK_DATA.values + response = MOCK_DATA.response else: - api = GismeteoApi() - values = await api.get_day(location, dateutil.parse(date)) + response = await get_api().get_day(location, date) return templates.TemplateResponse( request=request, name="weather.html", context={ "datetime": datetime, - "location": location, - "date": dateutil.parse(date), - "values": values, + "response": response, }, ) diff --git a/gismeteo/route/view/filters.py b/weather/route/view/filters.py similarity index 94% rename from gismeteo/route/view/filters.py rename to weather/route/view/filters.py index 92f7e67..f36be69 100644 --- a/gismeteo/route/view/filters.py +++ b/weather/route/view/filters.py @@ -24,4 +24,5 @@ def cloudness_icon(cloudness: str) -> str: "Облачно, небольшой дождь, гроза": "⛈️", "Малооблачно, дождь": "🌦️", "Пасмурно, небольшой дождь": "🌧️", + "Облачно, дождь, гроза": "⛈️", }.get(cloudness, cloudness) diff --git a/gismeteo/route/view/static/favicon.ico b/weather/route/view/static/favicon.ico similarity index 100% rename from gismeteo/route/view/static/favicon.ico rename to weather/route/view/static/favicon.ico diff --git a/weather/route/view/static/index.js b/weather/route/view/static/index.js new file mode 100644 index 0000000..e69de29 diff --git a/gismeteo/route/view/static/style.css b/weather/route/view/static/style.css similarity index 97% rename from gismeteo/route/view/static/style.css rename to weather/route/view/static/style.css index 2062ca2..d90bd10 100644 --- a/gismeteo/route/view/static/style.css +++ b/weather/route/view/static/style.css @@ -37,6 +37,7 @@ td { .header { font-size: 1rem; text-align: left; + padding-top: 0.25rem; } .date { diff --git a/gismeteo/route/view/templates/weather.html b/weather/route/view/templates/weather.html similarity index 78% rename from gismeteo/route/view/templates/weather.html rename to weather/route/view/templates/weather.html index 8ec055c..d4b15e1 100644 --- a/gismeteo/route/view/templates/weather.html +++ b/weather/route/view/templates/weather.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1.0"> - Weather - {{date.strftime('%a, %d %B %Y')}} + Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}

- 🡨 - {{date.strftime('%a, %d %B %Y')}} + 🡨 + {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}} 🡪 + href="{{response.date + datetime.timedelta(days=1)}}">🡪

- {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %} @@ -50,13 +50,13 @@ - - {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %} - - {% for value in values %} + {% for value in response.values %}
{{value.date.strftime('%H:%M')}} @@ -36,13 +36,13 @@
Облачность
{{value.cloudness | cloudness_icon}}
Температура, °C
{{value.temperature}} @@ -65,13 +65,13 @@
Направление ветра
{{value.wind_direction | wind_direction_icon}} {{value.wind_direction}} @@ -80,13 +80,13 @@
Скорость ветра, м/с
{{value.wind_speed}} @@ -100,13 +100,13 @@
Осадки, мм
{{value.precipitation or ' '}} @@ -115,13 +115,13 @@
Давление, мм рт. ст.
{{value.pressure}} @@ -130,13 +130,13 @@
Влажность, %
{{value.humidity}}