feat(weather): add openweather api

This commit is contained in:
2024-08-25 23:28:49 +03:00
parent d3ef03a6a0
commit 3e80ccb0df
25 changed files with 1636 additions and 75 deletions

View File

@@ -2,32 +2,22 @@ import locale as _locale
from fastapi import FastAPI
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.bundle import ApiBundle
from .route import doc
from .route.api import schedule as schedule_api_route
from .route.api import weather as weather_api_route
from .route.view import common as common_view_route
from .route.view import schedule as schedule_view_route
from .route.view import weather as weather_view_route
from .route import api, doc, view
DEFAULT_LOCALE = "ru_RU.UTF-8"
def build_app(
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
) -> FastAPI:
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale)
app = FastAPI(
title="Gallery",
docs_url=None,
redoc_url=None,
)
app.state.weather_api = weather_api
app.state.schedule_api = schedule_api
app.state.api = api_bundle
doc.mount(app)
weather_api_route.mount(app)
schedule_api_route.mount(app)
common_view_route.mount(app)
weather_view_route.mount(app)
schedule_view_route.mount(app)
api.mount(app)
view.mount(app)
return app

15
gallery/easel/core.py Normal file
View File

@@ -0,0 +1,15 @@
from fastapi import Request
from gallery.sketch.bundle import ApiBundle
class State:
api: ApiBundle
class App:
state: State
class AppRequest(Request):
app: App

View File

@@ -0,0 +1,8 @@
from fastapi import FastAPI
from . import schedule, weather
def mount(app: FastAPI):
weather.mount(app)
schedule.mount(app)

View File

@@ -1,27 +1,27 @@
import datetime
from fastapi import FastAPI, Request
from fastapi import FastAPI
from gallery.sketch.weather.api import WeatherApi
from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse
def mount(app: FastAPI):
@app.get("/api/weather/locations")
async def get_api_weather_locations(request: Request) -> list[str]:
weather_api: WeatherApi = request.app.state.weather_api
async def get_api_weather_locations(request: AppRequest) -> list[str]:
weather_api = request.app.state.api.weather
return await weather_api.get_locations()
@app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day(
request: Request, location: str, date: datetime.date
request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api
weather_api = request.app.state.api.weather
return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}")
async def get_api_weather_days(
request: Request, location: str, days: int
request: AppRequest, location: str, days: int
) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api
weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days)

View File

@@ -0,0 +1,9 @@
from fastapi import FastAPI
from . import common, schedule, weather
def mount(app: FastAPI):
common.mount(app)
weather.mount(app)
schedule.mount(app)

View File

@@ -1,12 +1,12 @@
import datetime
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.sketch.schedule.api import ScheduleApi
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.catalog import BUNDLE
from gallery.version import __version__
@@ -21,8 +21,8 @@ def mount(app: FastAPI):
templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: Request):
schedule_api: ScheduleApi = request.app.state.schedule_api
async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse(
@@ -35,9 +35,9 @@ def mount(app: FastAPI):
)
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: Request, tag: str, live: bool = False):
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag)
schedule_api: ScheduleApi = request.app.state.schedule_api
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
responses = [
await schedule_api.get_channel_schedule(channel, tag_value.date)
@@ -62,9 +62,9 @@ def mount(app: FastAPI):
return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: Request, channel: str, tag: str):
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
schedule_api: ScheduleApi = request.app.state.schedule_api
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
else:

View File

@@ -1,12 +1,12 @@
import datetime
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.sketch.weather.api import WeatherApi
from gallery.easel.core import AppRequest
from gallery.sketch.weather.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse
@@ -23,7 +23,7 @@ def mount(app: FastAPI):
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: Request, response: WeatherResponse):
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
@@ -36,8 +36,8 @@ def mount(app: FastAPI):
)
@app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: Request):
weather_api: WeatherApi = request.app.state.weather_api
async def get_weather_list(request: AppRequest):
weather_api = request.app.state.api.weather
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations)
return templates.TemplateResponse(
@@ -54,31 +54,31 @@ def mount(app: FastAPI):
return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
async def get_weather_day_mock(request: Request):
async def get_weather_day_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
async def get_weather_days_mock(request: Request):
async def get_weather_days_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: Request, location: str, date: datetime.date):
weather_api: WeatherApi = request.app.state.weather_api
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: Request, location: str, days: int):
weather_api: WeatherApi = request.app.state.weather_api
async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: Request, location: str, tag: str):
async def get_weather_tag(request: AppRequest, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
weather_api: WeatherApi = request.app.state.weather_api
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:

View File

@@ -1,12 +1,19 @@
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
Sky,
WindDirection,
WindDirectionDeg,
)
def wind_direction_icon(wind_direction: WindDirection) -> str:
def wind_direction_icon(wind_direction_deg: float) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction
return {
WindDirection.N: "⬇️",
WindDirection.NO: "↙️",
WindDirection.O: "⬅️",
WindDirection.SO: "↖️",
WindDirection.NE: "↙️",
WindDirection.E: "⬅️",
WindDirection.SE: "↖️",
WindDirection.S: "⬆️",
WindDirection.SW: "↗️",
WindDirection.W: "➡️",

View File

@@ -6,12 +6,19 @@ import uvicorn
from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi
weather_api = CachedWeatherApi(GismeteoApi())
schedule_api = CachedScheduleApi(MatchTvApi())
app = build_app(weather_api, schedule_api)
api = ApiBundle(
[
CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
]
)
app = build_app(api)
def run():

View File

@@ -5,7 +5,13 @@ from typing import Iterable
import dateparser
from bs4 import Tag
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
Sky,
WindDirection,
WindDirectionDeg,
)
from .core import BaseWidgetParser, RowParser
@@ -126,21 +132,23 @@ class WindDirectionParser(RowParser[WindDirection]):
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"штиль": WindDirection.CALM,
"с": WindDirection.N,
"св": WindDirection.NO,
"в": WindDirection.O,
"юв": WindDirection.SO,
"св": WindDirection.NE,
"в": WindDirection.E,
"юв": WindDirection.SE,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
def parse_row(self, tag: Tag) -> Iterable[WindDirection]:
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(
".widget-row[data-row=wind-direction] > .row-item > .direction"
):
wind_direction_str = item.text.lower()
yield self.WIND_DIRECTION_MAP[wind_direction_str]
yield WindDirectionDeg.from_direction(
self.WIND_DIRECTION_MAP[wind_direction_str]
).value
class WindPrecipitationParser(RowParser[float]):

View File

View File

@@ -0,0 +1,70 @@
import datetime
import logging
from collections import defaultdict
from aiocache import cached
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE, LocationId
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from .openweather import Forecast, OpenWeather
from .parser import FORECAST_ITEM_PARSER
logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.DAY,
)
async def _get_location_forecast(self, location_id: str) -> Forecast:
location = BUNDLE.get_item(location_id)
return await self.SOURCE.get_forecast(location.lat, location.lon)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
values = []
for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date:
values.append(value)
location = BUNDLE.get_item(location_id)
return WeatherResponse(
location=location.name,
date=date,
period="day",
values=values,
)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item)
item_date = value.date.replace(hour=0, minute=0)
values_by_date[item_date].append(value)
values = [
merge_weather_values(date, values)
for date, values in values_by_date.items()
]
location = BUNDLE.get_item(location_id)
return WeatherResponse(
location=location.name,
date=datetime.date.today(),
period="days",
values=list(sorted(values, key=lambda item: item.date)),
)

View File

@@ -0,0 +1,5 @@
from pathlib import Path
from gallery.sketch.mock import MockData
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import json
from pydantic import BaseModel, Field
from gallery.sketch.source import ApiSource
class Model(BaseModel):
class Config:
use_enum_values = True
class Main(Model):
temp: float
feels_like: float
temp_min: float
temp_max: float
pressure: int
sea_level: int
grnd_level: int
humidity: int
temp_kf: float
class Weather(Model):
id: int
main: str
description: str
icon: str
class Clouds(Model):
all: int
class Wind(Model):
speed: float
deg: int
gust: float
class Rain(Model):
interval_3h: float = Field(..., alias="3h")
class Sys(Model):
pod: str
class ForecastItem(Model):
dt: int
main: Main
weather: list[Weather]
clouds: Clouds
wind: Wind
visibility: int
pop: float
rain: Rain | None = None
sys: Sys
dt_txt: str
class Forecast(Model):
cod: str
message: int
cnt: int
list: list[ForecastItem]
class OpenWeather:
BASE_URL = "https://api.openweathermap.org"
def __init__(self, api_key: str):
self._api_key = api_key
self._source = ApiSource(self.BASE_URL)
async def get_forecast(self, lat: float, lon: float) -> Forecast:
endpoint = (
f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
)
response = await self._source.request(endpoint)
response_data = json.loads(response)
return Forecast(**response_data)

View File

@@ -0,0 +1,52 @@
import datetime
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
from gallery.sketch.weather.util import build_weather_value
from .openweather import ForecastItem
class ForecastItemParser:
CLOUDNESS_MAP: dict[str, Cloudness] = {
"clear sky": Cloudness.CLEAR,
"few clouds": Cloudness.PARTLY_CLOUDY,
"scattered clouds": Cloudness.PARTLY_CLOUDY,
"broken clouds": Cloudness.CLOUDY,
"overcast clouds": Cloudness.MAINLY_CLOUDY,
"light rain": Cloudness.CLOUDY,
}
PRECIPITATION_MAP: dict[str, Precipitation] = {
"light rain": Precipitation.SMALL_RAIN,
"rain": Precipitation.RAIN,
"heavy rain": Precipitation.SHOWER,
}
def parse(self, item: ForecastItem) -> WeatherValue:
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
item_date = (
item_date.replace(tzinfo=datetime.timezone.utc)
.astimezone(tz=None)
.replace(tzinfo=None)
)
value = build_weather_value(item_date)
# TODO parse temperature interval flag
value.temperature = [round(item.main.temp)]
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
value.pressure = [round(item.main.pressure / 133.3 * 100)]
value.humidity = item.main.humidity
value.wind_speed = round(item.wind.speed)
value.wind_gust = round(item.wind.gust)
value.wind_direction = item.wind.deg
value.sky.cloudness = self.CLOUDNESS_MAP.get(
item.weather[0].description, Cloudness.CLEAR
)
value.sky.precipitation = self.PRECIPITATION_MAP.get(
item.weather[0].description, Precipitation.NO
)
if item.rain:
value.precipitation = round(item.rain.interval_3h, 1)
return value
FORECAST_ITEM_PARSER = ForecastItemParser()

View File

@@ -1,6 +1,12 @@
from typing import TypeVar
class Api:
PROVIDER: str
@property
def provider(self) -> str:
return self.PROVIDER
API = TypeVar("API", bound=Api)

30
gallery/sketch/bundle.py Normal file
View File

@@ -0,0 +1,30 @@
from typing import Type
from .api import API, Api
from .schedule.api import ScheduleApi
from .weather.api import WeatherApi
class ApiBundle(list[Api]):
def __init__(self, values: list[Api]) -> None:
super().__init__(values)
def get_api_by_provider(self, provider: str) -> Api:
for value in self:
if value.PROVIDER == provider:
return value
raise ValueError(provider)
def get_api_by_type(self, api_type: Type[API]) -> API:
for value in self:
if isinstance(value, api_type):
return value
raise ValueError(api_type)
@property
def weather(self) -> WeatherApi:
return self.get_api_by_type(WeatherApi)
@property
def schedule(self) -> ScheduleApi:
return self.get_api_by_type(ScheduleApi)

View File

@@ -1,10 +1,8 @@
from typing import Generic, TypeVar
from typing import Generic
from gallery.util import TimeUnit
from .api import Api
API = TypeVar("API", bound=Api)
from .api import API, Api
class CachedApi(Api, Generic[API]):

View File

@@ -7,5 +7,8 @@ class CatalogBundle(Generic[T]):
def __init__(self, items: list[T]) -> None:
self._items_by_id = {item.id: item for item in items}
def get_item(self, item_id: str) -> T:
return self._items_by_id[item_id]
def select_items(self, ids: list[str]) -> list[T]:
return [self._items_by_id[id_] for id_ in ids]

View File

@@ -6,9 +6,12 @@ class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_text(self, key: str) -> str:
return (self._data_dir / f"{key}").read_text()
def get_html(self, key: str) -> str:
return (self._data_dir / f"{key}.html").read_text()
return self.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads((self._data_dir / f"{key}.json").read_text())
data = json.loads(self.get_text(f"{key}.json"))
return data

View File

@@ -15,7 +15,17 @@ class LocationId(str, Enum):
BUNDLE = CatalogBundle(
[
Location(id=LocationId.OREL, name="Орёл"),
Location(id=LocationId.ZMIYEVKA, name="Змиёвка"),
Location(
id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

View File

@@ -12,6 +12,8 @@ class Model(BaseModel):
class Location(Model):
id: str
name: str
lat: float
lon: float
class Cloudness(str, Enum):
@@ -38,22 +40,69 @@ class Sky(Model):
class WindDirection(str, Enum):
CALM = "calm"
N = "N"
NO = "NO"
O = "O"
SO = "SO"
NE = "NE"
E = "E"
SE = "SE"
S = "S"
SW = "SW"
W = "W"
NW = "NW"
class WindDirectionDeg(float):
@property
def direction(self) -> WindDirection:
return self.to_direction()
@property
def value(self) -> float:
return self
# pylint:disable=too-many-return-statements
def to_direction(self) -> WindDirection:
if self > 337.5 or self <= 22.25:
return WindDirection.N
elif self <= 67.5:
return WindDirection.NE
elif self <= 112.5:
return WindDirection.E
elif self <= 157.5:
return WindDirection.SE
elif self <= 202.5:
return WindDirection.S
elif self <= 247.5:
return WindDirection.SW
elif self <= 292.5:
return WindDirection.W
elif self <= 337.5:
return WindDirection.NW
else:
return WindDirection.CALM
@classmethod
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
return cls(
{
WindDirection.CALM: -1,
WindDirection.N: 0,
WindDirection.NE: 45,
WindDirection.E: 90,
WindDirection.SE: 135,
WindDirection.S: 180,
WindDirection.SW: 225,
WindDirection.W: 270,
WindDirection.NW: 315,
}[direction]
)
class WeatherValue(Model):
date: datetime.datetime
sky: Sky
temperature: list[int]
wind_speed: int
wind_gust: int
wind_direction: WindDirection
wind_direction: float
precipitation: float
pressure: list[int]
humidity: int

View File

@@ -1,6 +1,7 @@
import datetime
import statistics
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
def build_weather_value(date: datetime.datetime) -> WeatherValue:
@@ -15,8 +16,49 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
temperature=[],
wind_speed=0,
wind_gust=0,
wind_direction=WindDirection.CALM,
wind_direction=WindDirectionDeg(-1),
precipitation=0,
pressure=[],
humidity=0,
)
def merge_weather_values(
date: datetime.datetime, values: list[WeatherValue]
) -> WeatherValue:
result = build_weather_value(date)
temperatures = []
pressures = []
humidities = []
wind_speeds = []
wind_gusts = []
wind_directions = []
cloudnesses = []
precipitations = []
precipitation = 0
for value in values:
temperatures += value.temperature
pressures += value.pressure
humidities.append(value.humidity)
wind_speeds.append(value.wind_speed)
wind_gusts.append(value.wind_gust)
wind_directions.append(value.wind_direction)
cloudnesses.append(value.sky.cloudness)
precipitations.append(value.sky.precipitation)
precipitation += value.precipitation
result.temperature = [max(temperatures), min(temperatures)]
result.pressure = [max(pressures), min(pressures)]
result.humidity = round(statistics.mean(humidities))
result.wind_speed = round(statistics.mean(wind_speeds))
result.wind_gust = round(statistics.mean(wind_gusts))
result.wind_direction = statistics.mean(wind_directions)
# TODO: merge cloudnesses
for item in cloudnesses:
if item != Cloudness.CLEAR:
result.sky.cloudness = item
# TODO: merge precipitations
for item in precipitations:
if item != Precipitation.NO:
result.sky.precipitation = item
result.precipitation = precipitation
return result

View File

@@ -0,0 +1,27 @@
import datetime
import pytest
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.openweather.mock import OPENWEATHER_MOCK_DATA
from gallery.painting.openweather.openweather import Forecast
@pytest.fixture(name="openweather_api", scope="module")
def openweather_api_fixture() -> OpenWeatherApi:
async def _get_location_forecast(location_id: str) -> Forecast:
return Forecast(**OPENWEATHER_MOCK_DATA.get_json("forecast"))
api = OpenWeatherApi()
api._get_location_forecast = _get_location_forecast
return api
async def test_day(openweather_api: OpenWeatherApi):
result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23))
assert len(result.values) == 8
async def test_days(openweather_api: OpenWeatherApi):
result = await openweather_api.get_days("orel-4432", 10)
assert len(result.values) == 6