feat: add redis cache
This commit is contained in:
19
docker-compose.yaml
Normal file
19
docker-compose.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
container_name: gallery-redis
|
||||||
|
image: redis:alpine
|
||||||
|
stop_grace_period: 3s
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
|
||||||
|
app:
|
||||||
|
container_name: gallery-app
|
||||||
|
build: .
|
||||||
|
# image: shmyga/gallery
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
ports:
|
||||||
|
- 8000:80
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from os import environ
|
||||||
|
|
||||||
|
from aiocache import caches
|
||||||
|
|
||||||
|
REDIS_HOST = environ.get("REDIS_HOST", "0.0.0.0")
|
||||||
|
REDIS_PORT = int(environ.get("REDIS_PORT", 6379))
|
||||||
|
REDIS_DB = int(environ.get("REDIS_DB", 1))
|
||||||
|
|
||||||
|
caches.set_config(
|
||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"cache": "aiocache.SimpleMemoryCache",
|
||||||
|
"serializer": {"class": "aiocache.serializers.StringSerializer"},
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"cache": "aiocache.RedisCache",
|
||||||
|
"endpoint": REDIS_HOST,
|
||||||
|
"port": REDIS_PORT,
|
||||||
|
"db": REDIS_DB,
|
||||||
|
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import uvicorn
|
|||||||
from gallery.easel import build_app
|
from gallery.easel import build_app
|
||||||
from gallery.painting.gismeteo.api import GismeteoApi
|
from gallery.painting.gismeteo.api import GismeteoApi
|
||||||
from gallery.painting.matchtv.api import MatchTvApi
|
from gallery.painting.matchtv.api import MatchTvApi
|
||||||
|
from gallery.sketch.schedule.cached import CachedScheduleApi
|
||||||
|
from gallery.sketch.weather.cached import CachedWeatherApi
|
||||||
|
|
||||||
app = build_app(GismeteoApi(), MatchTvApi())
|
weather_api = CachedWeatherApi(GismeteoApi())
|
||||||
|
schedule_api = CachedScheduleApi(MatchTvApi())
|
||||||
|
app = build_app(weather_api, schedule_api)
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
@@ -18,3 +22,7 @@ def run():
|
|||||||
log_config=str(Path(__file__).parent / "logging.yaml"),
|
log_config=str(Path(__file__).parent / "logging.yaml"),
|
||||||
reload="DEBUG" in environ,
|
reload="DEBUG" in environ,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from aiocache import cached
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
@@ -17,6 +16,7 @@ logger = logging.getLogger("gismeteo")
|
|||||||
|
|
||||||
|
|
||||||
class GismeteoApi(WeatherApi):
|
class GismeteoApi(WeatherApi):
|
||||||
|
PROVIDER = "gismeteo"
|
||||||
SOURCE = ApiSource(
|
SOURCE = ApiSource(
|
||||||
"https://www.gismeteo.ru",
|
"https://www.gismeteo.ru",
|
||||||
cookies={
|
cookies={
|
||||||
@@ -32,7 +32,6 @@ class GismeteoApi(WeatherApi):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
CACHE_TTL = 10 * 60
|
|
||||||
|
|
||||||
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
||||||
result: List[Dict[str, Any]] = []
|
result: List[Dict[str, Any]] = []
|
||||||
@@ -76,12 +75,10 @@ class GismeteoApi(WeatherApi):
|
|||||||
LocationId.ZMIYEVKA,
|
LocationId.ZMIYEVKA,
|
||||||
]
|
]
|
||||||
|
|
||||||
@cached(ttl=CACHE_TTL)
|
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
||||||
return self._parse_oneday(date, data)
|
return self._parse_oneday(date, data)
|
||||||
|
|
||||||
@cached(ttl=CACHE_TTL)
|
|
||||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
|
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
|
||||||
return self._parse_manydays(data)
|
return self._parse_manydays(data)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiocache import cached
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
@@ -13,8 +12,8 @@ logger = logging.getLogger("matchtv")
|
|||||||
|
|
||||||
|
|
||||||
class MatchTvApi(ScheduleApi):
|
class MatchTvApi(ScheduleApi):
|
||||||
|
PROVIDER = "matchtv"
|
||||||
SOURCE = ApiSource("https://matchtv.ru")
|
SOURCE = ApiSource("https://matchtv.ru")
|
||||||
CACHE_TTL = 30 * 60
|
|
||||||
|
|
||||||
async def get_channels(self) -> list[str]:
|
async def get_channels(self) -> list[str]:
|
||||||
return [
|
return [
|
||||||
@@ -27,7 +26,6 @@ class MatchTvApi(ScheduleApi):
|
|||||||
ChannelId.MATCH_STRANA,
|
ChannelId.MATCH_STRANA,
|
||||||
]
|
]
|
||||||
|
|
||||||
@cached(ttl=CACHE_TTL)
|
|
||||||
async def get_channel_schedule(
|
async def get_channel_schedule(
|
||||||
self, channel_id: str, date: datetime.date
|
self, channel_id: str, date: datetime.date
|
||||||
) -> Schedule:
|
) -> Schedule:
|
||||||
|
|||||||
0
gallery/sketch/__init__.py
Normal file
0
gallery/sketch/__init__.py
Normal file
6
gallery/sketch/api.py
Normal file
6
gallery/sketch/api.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class Api:
|
||||||
|
PROVIDER: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> str:
|
||||||
|
return self.PROVIDER
|
||||||
20
gallery/sketch/cached.py
Normal file
20
gallery/sketch/cached.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
|
from .api import Api
|
||||||
|
|
||||||
|
API = TypeVar("API", bound=Api)
|
||||||
|
|
||||||
|
|
||||||
|
class CachedApi(Api, Generic[API]):
|
||||||
|
CACHE_TTL: int = TimeUnit.HOUR
|
||||||
|
CACHE_ALIAS: str = "redis"
|
||||||
|
CACHE_KEY: str
|
||||||
|
|
||||||
|
def __init__(self, api: API):
|
||||||
|
self._api = api
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> str:
|
||||||
|
return self._api.provider
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from ..api import Api
|
||||||
from .model import Schedule
|
from .model import Schedule
|
||||||
|
|
||||||
|
|
||||||
class ScheduleApi:
|
class ScheduleApi(Api):
|
||||||
async def get_channels(self) -> list[str]:
|
async def get_channels(self) -> list[str]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
32
gallery/sketch/schedule/cached.py
Normal file
32
gallery/sketch/schedule/cached.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from aiocache import cached
|
||||||
|
|
||||||
|
from gallery.sketch.cached import CachedApi
|
||||||
|
|
||||||
|
from .api import ScheduleApi
|
||||||
|
from .model import Schedule
|
||||||
|
|
||||||
|
|
||||||
|
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||||
|
CACHE_KEY = "schedule"
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
||||||
|
alias=CachedApi.CACHE_ALIAS,
|
||||||
|
ttl=CachedApi.CACHE_TTL,
|
||||||
|
)
|
||||||
|
async def get_channels(self) -> list[str]:
|
||||||
|
return await self._api.get_channels()
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, channel_id, date: (
|
||||||
|
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
|
||||||
|
),
|
||||||
|
alias=CachedApi.CACHE_ALIAS,
|
||||||
|
ttl=CachedApi.CACHE_TTL,
|
||||||
|
)
|
||||||
|
async def get_channel_schedule(
|
||||||
|
self, channel_id: str, date: datetime.date
|
||||||
|
) -> Schedule:
|
||||||
|
return await self._api.get_channel_schedule(channel_id, date)
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from ..api import Api
|
||||||
from .model import WeatherResponse
|
from .model import WeatherResponse
|
||||||
|
|
||||||
|
|
||||||
class WeatherApi:
|
class WeatherApi(Api):
|
||||||
|
|
||||||
async def get_locations(self) -> list[str]:
|
async def get_locations(self) -> list[str]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
40
gallery/sketch/weather/cached.py
Normal file
40
gallery/sketch/weather/cached.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from aiocache import cached
|
||||||
|
|
||||||
|
from gallery.sketch.cached import CachedApi
|
||||||
|
|
||||||
|
from .api import WeatherApi
|
||||||
|
from .model import WeatherResponse
|
||||||
|
|
||||||
|
|
||||||
|
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||||
|
CACHE_KEY = "weather"
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations",
|
||||||
|
alias=CachedApi.CACHE_ALIAS,
|
||||||
|
ttl=CachedApi.CACHE_TTL,
|
||||||
|
)
|
||||||
|
async def get_locations(self) -> list[str]:
|
||||||
|
return await self._api.get_locations()
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id, date: (
|
||||||
|
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||||
|
),
|
||||||
|
alias=CachedApi.CACHE_ALIAS,
|
||||||
|
ttl=CachedApi.CACHE_TTL,
|
||||||
|
)
|
||||||
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
|
return await self._api.get_day(location_id, date)
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id, date: (
|
||||||
|
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||||
|
),
|
||||||
|
alias=CachedApi.CACHE_ALIAS,
|
||||||
|
ttl=CachedApi.CACHE_TTL,
|
||||||
|
)
|
||||||
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
|
return await self._api.get_days(location_id, days)
|
||||||
5
gallery/util.py
Normal file
5
gallery/util.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class TimeUnit:
|
||||||
|
SECOND = 1
|
||||||
|
MINUTE = 60 * SECOND
|
||||||
|
HOUR = 60 * MINUTE
|
||||||
|
DAY = 24 * HOUR
|
||||||
20
poetry.lock
generated
20
poetry.lock
generated
@@ -11,6 +11,9 @@ files = [
|
|||||||
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"},
|
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
redis = {version = ">=4.2.0", optional = true, markers = "extra == \"redis\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
memcached = ["aiomcache (>=0.5.2)"]
|
memcached = ["aiomcache (>=0.5.2)"]
|
||||||
msgpack = ["msgpack (>=0.5.5)"]
|
msgpack = ["msgpack (>=0.5.5)"]
|
||||||
@@ -1234,6 +1237,21 @@ files = [
|
|||||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redis"
|
||||||
|
version = "5.0.8"
|
||||||
|
description = "Python client for Redis database and key-value store"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
files = [
|
||||||
|
{file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"},
|
||||||
|
{file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
hiredis = ["hiredis (>1.0.0)"]
|
||||||
|
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "2024.5.15"
|
version = "2024.5.15"
|
||||||
@@ -1811,4 +1829,4 @@ multidict = ">=4.0"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "61e9ac2e623a1f5f543705c252f34f8e15b58e164b0a8c14330b12d814a3c778"
|
content-hash = "7295e9ec7f7492017c5bbda489026f19bbf155f0ea82402d348b0aa4c03beaca"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ aiohttp = "^3.9.5"
|
|||||||
beautifulsoup4 = "^4.12.3"
|
beautifulsoup4 = "^4.12.3"
|
||||||
dateparser = "^1.2.0"
|
dateparser = "^1.2.0"
|
||||||
pydantic = "^2.8.2"
|
pydantic = "^2.8.2"
|
||||||
aiocache = "^0.12.2"
|
aiocache = {extras = ["redis"], version = "^0.12.2"}
|
||||||
|
|
||||||
[tool.poetry.group.app.dependencies]
|
[tool.poetry.group.app.dependencies]
|
||||||
fastapi = "^0.111.1"
|
fastapi = "^0.111.1"
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
cd "$(dirname $(dirname "$0"))" || exit
|
||||||
|
|
||||||
docker run --rm -p 8000:80 shmyga/gallery
|
# docker run --rm -p 8000:80 shmyga/gallery
|
||||||
|
docker compose up --build
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ from gallery.painting.gismeteo.mock import GISMETEO_MOCK_DATA
|
|||||||
|
|
||||||
@pytest.fixture(name="gismeteo_api", scope="module")
|
@pytest.fixture(name="gismeteo_api", scope="module")
|
||||||
def gismeteo_api_fixture() -> GismeteoApi:
|
def gismeteo_api_fixture() -> GismeteoApi:
|
||||||
api = GismeteoApi()
|
class MockSource:
|
||||||
|
async def request(self, endpoint: str):
|
||||||
async def _request(endpoint: str) -> str:
|
|
||||||
return GISMETEO_MOCK_DATA.get_html(endpoint.split("/")[-1])
|
return GISMETEO_MOCK_DATA.get_html(endpoint.split("/")[-1])
|
||||||
|
|
||||||
api._request = _request
|
api = GismeteoApi()
|
||||||
|
api.SOURCE = MockSource()
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ from gallery.painting.matchtv.mock import MATCHTV_MOCK_DATA
|
|||||||
|
|
||||||
@pytest.fixture(name="matchtv_api", scope="module")
|
@pytest.fixture(name="matchtv_api", scope="module")
|
||||||
def matchtv_api_fixture() -> MatchTvApi:
|
def matchtv_api_fixture() -> MatchTvApi:
|
||||||
api = MatchTvApi()
|
class MockSource:
|
||||||
|
async def request(self, endpoint: str):
|
||||||
async def _request(endpoint: str) -> str:
|
|
||||||
return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1])
|
return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1])
|
||||||
|
|
||||||
api._request = _request
|
api = MatchTvApi()
|
||||||
|
api.SOURCE = MockSource()
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
|
||||||
async def test_channel(matchtv_api: MatchTvApi):
|
async def test_channel(matchtv_api: MatchTvApi):
|
||||||
result = await matchtv_api.get_channel_schedule("matchtv", datetime.date.today())
|
result = await matchtv_api.get_channel_schedule("test", datetime.date.today())
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.values) > 0
|
assert len(result.values) > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user