diff --git a/Procfile b/Procfile index 2cdaa12f..23609265 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn app.main:APP --log-file=- --log-level info -k uvicorn.workers.UvicornWorker +web: gunicorn app.main:APP -k uvicorn.workers.UvicornWorker diff --git a/app/__init__.py b/app/__init__.py index c43ae7ac..57721529 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,4 +4,4 @@ API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak. """ # See PEP396. -__version__ = "2.0.1" +__version__ = "2.0.3" diff --git a/app/data/geonames_population_mappings.json b/app/data/geonames_population_mappings.json new file mode 100644 index 00000000..7b293caa --- /dev/null +++ b/app/data/geonames_population_mappings.json @@ -0,0 +1,252 @@ +{ + "AD": 77006, + "AE": 9630959, + "AF": 37172386, + "AG": 96286, + "AI": 13254, + "AL": 2866376, + "AM": 2951776, + "AO": 30809762, + "AQ": null, + "AR": 44494502, + "AS": 55465, + "AT": 8847037, + "AU": 24992369, + "AW": 105845, + "AX": 26711, + "AZ": 9942334, + "BA": 3323929, + "BB": 286641, + "BD": 161356039, + "BE": 11422068, + "BF": 19751535, + "BG": 7000039, + "BH": 1569439, + "BI": 11175378, + "BJ": 11485048, + "BL": 8450, + "BM": 63968, + "BN": 428962, + "BO": 11353142, + "BQ": 18012, + "BR": 209469333, + "BS": 385640, + "BT": 754394, + "BV": null, + "BW": 2254126, + "BY": 9485386, + "BZ": 383071, + "CA": 37058856, + "CC": 628, + "CD": 84068091, + "CF": 4666377, + "CG": 5244363, + "CH": 8516543, + "CI": 25069229, + "CK": 21388, + "CL": 18729160, + "CM": 25216237, + "CN": 1392730000, + "CO": 49648685, + "CR": 4999441, + "CU": 11338138, + "CV": 543767, + "CW": 159849, + "CX": 1500, + "CY": 1189265, + "CZ": 10625695, + "DE": 82927922, + "DJ": 958920, + "DK": 5797446, + "DM": 71625, + "DO": 10627165, + "DZ": 42228429, + "EC": 17084357, + "EE": 1320884, + "EG": 98423595, + "EH": 273008, + "ER": null, + "ES": 46723749, + "ET": 109224559, + "FI": 5518050, + "FJ": 883483, + "FK": 2638, + "FM": 112640, + "FO": 48497, + "FR": 66987244, + "GA": 2119275, + "GB": 66488991, + "GD": 111454, + "GE": 3731000, + "GF": 195506, + "GG": 65228, + "GH": 29767108, + "GI": 33718, + "GL": 56025, + "GM": 2280102, + "GN": 12414318, + "GP": 443000, + "GQ": 1308974, + "GR": 10727668, + "GS": 30, + "GT": 17247807, + "GU": 165768, + "GW": 1874309, + "GY": 779004, + "HK": 7451000, + "HM": null, + "HN": 9587522, + "HR": 4089400, + "HT": 11123176, + "HU": 9768785, + "ID": 267663435, + "IE": 4853506, + "IL": 8883800, + "IM": 84077, + "IN": 1352617328, + "IO": 4000, + "IQ": 38433600, + "IR": 81800269, + "IS": 353574, + "IT": 60431283, + "JE": 90812, + "JM": 2934855, + "JO": 9956011, + "JP": 126529100, + "KE": 51393010, + "KG": 6315800, + "KH": 16249798, + "KI": 115847, + "KM": 832322, + "KN": 52441, + "KP": 25549819, + "KR": 51635256, + "KW": 4137309, + "KY": 64174, + "KZ": 18276499, + "LA": 7061507, + "LB": 6848925, + "LC": 181889, + "LI": 37910, + "LK": 21670000, + "LR": 4818977, + "LS": 2108132, + "LT": 2789533, + "LU": 607728, + "LV": 1926542, + "LY": 6678567, + "MA": 36029138, + "MC": 38682, + "MD": 3545883, + "ME": 622345, + "MF": 37264, + "MG": 26262368, + "MH": 58413, + "MK": 2082958, + "ML": 19077690, + "MM": 53708395, + "MN": 3170208, + "MO": 631636, + "MP": 56882, + "MQ": 432900, + "MR": 4403319, + "MS": 9341, + "MT": 483530, + "MU": 1265303, + "MV": 515696, + "MW": 17563749, + "MX": 126190788, + "MY": 31528585, + "MZ": 29495962, + "NA": 2448255, + "NC": 284060, + "NE": 22442948, + "NF": 1828, + "NG": 195874740, + "NI": 6465513, + "NL": 17231017, + "NO": 5314336, + "NP": 28087871, + "NR": 12704, + "NU": 2166, + "NZ": 4885500, + "OM": 4829483, + "PA": 4176873, + "PE": 31989256, + "PF": 277679, + "PG": 8606316, + "PH": 106651922, + "PK": 212215030, + "PL": 37978548, + "PM": 7012, + "PN": 46, + "PR": 3195153, + "PS": 4569087, + "PT": 10281762, + "PW": 17907, + "PY": 6956071, + "QA": 2781677, + "RE": 776948, + "RO": 19473936, + "RS": 6982084, + "RU": 144478050, + "RW": 12301939, + "SA": 33699947, + "SB": 652858, + "SC": 96762, + "SD": 41801533, + "SE": 10183175, + "SG": 5638676, + "SH": 7460, + "SI": 2067372, + "SJ": 2550, + "SK": 5447011, + "SL": 7650154, + "SM": 33785, + "SN": 15854360, + "SO": 15008154, + "SR": 575991, + "SS": 8260490, + "ST": 197700, + "SV": 6420744, + "SX": 40654, + "SY": 16906283, + "SZ": 1136191, + "TC": 37665, + "TD": 15477751, + "TF": 140, + "TG": 7889094, + "TH": 69428524, + "TJ": 9100837, + "TK": 1466, + "TL": 1267972, + "TM": 5850908, + "TN": 11565204, + "TO": 103197, + "TR": 82319724, + "TT": 1389858, + "TV": 11508, + "TW": 22894384, + "TZ": 56318348, + "UA": 44622516, + "UG": 42723139, + "UM": null, + "US": 327167434, + "UY": 3449299, + "UZ": 32955400, + "VA": 921, + "VC": 110211, + "VE": 28870195, + "VG": 29802, + "VI": 106977, + "VN": 95540395, + "VU": 292680, + "WF": 16025, + "WS": 196130, + "XK": 1845300, + "YE": 28498687, + "YT": 159042, + "ZA": 57779622, + "ZM": 17351822, + "ZW": 14439018 +} \ No newline at end of file diff --git a/app/enums/sources.py b/app/enums/sources.py deleted file mode 100644 index 9fc00744..00000000 --- a/app/enums/sources.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -class Sources(str, Enum): - """ - A source available for retrieving data. - """ - - jhu = "jhu" - csbs = "csbs" - nyt = "nyt" diff --git a/app/io.py b/app/io.py new file mode 100644 index 00000000..8130c146 --- /dev/null +++ b/app/io.py @@ -0,0 +1,28 @@ +"""app.io.py""" +import json +import pathlib +from typing import Dict, Union + +HERE = pathlib.Path(__file__) +DATA = HERE.joinpath("..", "data").resolve() + + +def save( + name: str, content: Union[str, Dict], write_mode: str = "w", indent: int = 2, **json_dumps_kwargs +) -> pathlib.Path: + """Save content to a file. If content is a dictionary, use json.dumps().""" + path = DATA / name + if isinstance(content, dict): + content = json.dumps(content, indent=indent, **json_dumps_kwargs) + with open(DATA / name, mode=write_mode) as f_out: + f_out.write(content) + return path + + +def load(name: str, **json_kwargs) -> Union[str, Dict]: + """Loads content from a file. If file ends with '.json', call json.load() and return a Dictionary.""" + path = DATA / name + with open(path) as f_in: + if path.suffix == ".json": + return json.load(f_in, **json_kwargs) + return f_in.read() diff --git a/app/main.py b/app/main.py index 437b2395..0d7120f2 100644 --- a/app/main.py +++ b/app/main.py @@ -11,8 +11,7 @@ from fastapi.responses import JSONResponse from .data import data_source -from .router.v1 import V1 -from .router.v2 import V2 +from .routers import V1, V2 from .utils.httputils import setup_client_session, teardown_client_session # ############ @@ -26,7 +25,7 @@ "API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak." " Project page: https://github.com/ExpDev07/coronavirus-tracker-api." ), - version="2.0.2", + version="2.0.3", docs_url="/", redoc_url="/docs", on_startup=[setup_client_session], diff --git a/app/models/location.py b/app/models.py similarity index 56% rename from app/models/location.py rename to app/models.py index 48fa4d74..8875a92c 100644 --- a/app/models/location.py +++ b/app/models.py @@ -1,9 +1,44 @@ +"""app.models.py""" from typing import Dict, List from pydantic import BaseModel -from .latest import Latest -from .timeline import Timelines + +class Latest(BaseModel): + """ + Latest model. + """ + + confirmed: int + deaths: int + recovered: int + + +class LatestResponse(BaseModel): + """ + Response for latest. + """ + + latest: Latest + + +class Timeline(BaseModel): + """ + Timeline model. + """ + + latest: int + timeline: Dict[str, int] = {} + + +class Timelines(BaseModel): + """ + Timelines model. + """ + + confirmed: Timeline + deaths: Timeline + recovered: Timeline class Location(BaseModel): diff --git a/app/models/latest.py b/app/models/latest.py deleted file mode 100644 index 6dcfd517..00000000 --- a/app/models/latest.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import BaseModel - - -class Latest(BaseModel): - """ - Latest model. - """ - - confirmed: int - deaths: int - recovered: int - - -class LatestResponse(BaseModel): - """ - Response for latest. - """ - - latest: Latest diff --git a/app/models/timeline.py b/app/models/timeline.py deleted file mode 100644 index 453dfb14..00000000 --- a/app/models/timeline.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Dict - -from pydantic import BaseModel - - -class Timeline(BaseModel): - """ - Timeline model. - """ - - latest: int - timeline: Dict[str, int] = {} - - -class Timelines(BaseModel): - """ - Timelines model. - """ - - confirmed: Timeline - deaths: Timeline - recovered: Timeline diff --git a/app/router/__init__.py b/app/router/__init__.py deleted file mode 100644 index 4eda6c21..00000000 --- a/app/router/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""app.router""" -from fastapi import APIRouter - -# pylint: disable=redefined-builtin -from .v1 import all, confirmed, deaths, recovered - -# The routes. -from .v2 import latest, sources, locations # isort:skip diff --git a/app/router/v1/__init__.py b/app/router/v1/__init__.py deleted file mode 100644 index 839bd212..00000000 --- a/app/router/v1/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""app.router.v1""" -from fastapi import APIRouter - -V1 = APIRouter() diff --git a/app/router/v1/all.py b/app/router/v1/all.py deleted file mode 100644 index 91b9e826..00000000 --- a/app/router/v1/all.py +++ /dev/null @@ -1,20 +0,0 @@ -"""app.router.v1.all.py""" -from ...services.location.jhu import get_category -from . import V1 - - -@V1.get("/all") -async def all(): # pylint: disable=redefined-builtin - """Get all the categories.""" - confirmed = await get_category("confirmed") - deaths = await get_category("deaths") - recovered = await get_category("recovered") - - return { - # Data. - "confirmed": confirmed, - "deaths": deaths, - "recovered": recovered, - # Latest. - "latest": {"confirmed": confirmed["latest"], "deaths": deaths["latest"], "recovered": recovered["latest"],}, - } diff --git a/app/router/v1/confirmed.py b/app/router/v1/confirmed.py deleted file mode 100644 index 13365e32..00000000 --- a/app/router/v1/confirmed.py +++ /dev/null @@ -1,11 +0,0 @@ -"""app.router.v1.confirmed.py""" -from ...services.location.jhu import get_category -from . import V1 - - -@V1.get("/confirmed") -async def confirmed(): - """Confirmed cases.""" - confirmed_data = await get_category("confirmed") - - return confirmed_data diff --git a/app/router/v1/deaths.py b/app/router/v1/deaths.py deleted file mode 100644 index fb45498c..00000000 --- a/app/router/v1/deaths.py +++ /dev/null @@ -1,11 +0,0 @@ -"""app.router.v1.deaths.py""" -from ...services.location.jhu import get_category -from . import V1 - - -@V1.get("/deaths") -async def deaths(): - """Total deaths.""" - deaths_data = await get_category("deaths") - - return deaths_data diff --git a/app/router/v1/recovered.py b/app/router/v1/recovered.py deleted file mode 100644 index 3a3a85b7..00000000 --- a/app/router/v1/recovered.py +++ /dev/null @@ -1,11 +0,0 @@ -"""app.router.v1.recovered.py""" -from ...services.location.jhu import get_category -from . import V1 - - -@V1.get("/recovered") -async def recovered(): - """Recovered cases.""" - recovered_data = await get_category("recovered") - - return recovered_data diff --git a/app/router/v2/__init__.py b/app/router/v2/__init__.py deleted file mode 100644 index 62c31905..00000000 --- a/app/router/v2/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""app.router.v2""" -from fastapi import APIRouter - -V2 = APIRouter() diff --git a/app/router/v2/latest.py b/app/router/v2/latest.py deleted file mode 100644 index 105b16fe..00000000 --- a/app/router/v2/latest.py +++ /dev/null @@ -1,21 +0,0 @@ -"""app.router.v2.latest.py""" -from fastapi import Request - -from ...enums.sources import Sources -from ...models.latest import LatestResponse as Latest -from . import V2 - - -@V2.get("/latest", response_model=Latest) -async def get_latest(request: Request, source: Sources = "jhu"): # pylint: disable=unused-argument - """ - Getting latest amount of total confirmed cases, deaths, and recoveries. - """ - locations = await request.state.source.get_all() - return { - "latest": { - "confirmed": sum(map(lambda location: location.confirmed, locations)), - "deaths": sum(map(lambda location: location.deaths, locations)), - "recovered": sum(map(lambda location: location.recovered, locations)), - } - } diff --git a/app/router/v2/sources.py b/app/router/v2/sources.py deleted file mode 100644 index ad906e51..00000000 --- a/app/router/v2/sources.py +++ /dev/null @@ -1,11 +0,0 @@ -"""app.router.v2.sources.py""" -from ...data import DATA_SOURCES -from . import V2 - - -@V2.get("/sources") -async def sources(): - """ - Retrieves a list of data-sources that are availble to use. - """ - return {"sources": list(DATA_SOURCES.keys())} diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 00000000..2bdd5ee3 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,3 @@ +"""app.routers""" +from .v1 import V1 +from .v2 import V2 diff --git a/app/routers/v1.py b/app/routers/v1.py new file mode 100644 index 00000000..65bc4373 --- /dev/null +++ b/app/routers/v1.py @@ -0,0 +1,47 @@ +"""app.router.v1.py""" +from fastapi import APIRouter + +from ..services.location.jhu import get_category + +V1 = APIRouter() + + +@V1.get("/all") +async def all_categories(): + """Get all the categories.""" + confirmed = await get_category("confirmed") + deaths = await get_category("deaths") + recovered = await get_category("recovered") + + return { + # Data. + "confirmed": confirmed, + "deaths": deaths, + "recovered": recovered, + # Latest. + "latest": {"confirmed": confirmed["latest"], "deaths": deaths["latest"], "recovered": recovered["latest"],}, + } + + +@V1.get("/confirmed") +async def get_confirmed(): + """Confirmed cases.""" + confirmed_data = await get_category("confirmed") + + return confirmed_data + + +@V1.get("/deaths") +async def get_deaths(): + """Total deaths.""" + deaths_data = await get_category("deaths") + + return deaths_data + + +@V1.get("/recovered") +async def get_recovered(): + """Recovered cases.""" + recovered_data = await get_category("recovered") + + return recovered_data diff --git a/app/router/v2/locations.py b/app/routers/v2.py similarity index 59% rename from app/router/v2/locations.py rename to app/routers/v2.py index 649f9c9e..a495066b 100644 --- a/app/router/v2/locations.py +++ b/app/routers/v2.py @@ -1,14 +1,41 @@ -"""app.router.v2.locations.py""" -from fastapi import HTTPException, Request +"""app.router.v2""" +import enum -from ...enums.sources import Sources -from ...models.location import LocationResponse as Location -from ...models.location import LocationsResponse as Locations -from . import V2 +from fastapi import APIRouter, HTTPException, Request + +from ..data import DATA_SOURCES +from ..models import LatestResponse, LocationResponse, LocationsResponse + +V2 = APIRouter() + + +class Sources(str, enum.Enum): + """ + A source available for retrieving data. + """ + + jhu = "jhu" + csbs = "csbs" + nyt = "nyt" + + +@V2.get("/latest", response_model=LatestResponse) +async def get_latest(request: Request, source: Sources = "jhu"): # pylint: disable=unused-argument + """ + Getting latest amount of total confirmed cases, deaths, and recoveries. + """ + locations = await request.state.source.get_all() + return { + "latest": { + "confirmed": sum(map(lambda location: location.confirmed, locations)), + "deaths": sum(map(lambda location: location.deaths, locations)), + "recovered": sum(map(lambda location: location.recovered, locations)), + } + } # pylint: disable=unused-argument,too-many-arguments,redefined-builtin -@V2.get("/locations", response_model=Locations, response_model_exclude_unset=True) +@V2.get("/locations", response_model=LocationsResponse, response_model_exclude_unset=True) async def get_locations( request: Request, source: Sources = "jhu", @@ -56,10 +83,18 @@ async def get_locations( # pylint: disable=invalid-name -@V2.get("/locations/{id}", response_model=Location) +@V2.get("/locations/{id}", response_model=LocationResponse) async def get_location_by_id(request: Request, id: int, source: Sources = "jhu", timelines: bool = True): """ Getting specific location by id. """ location = await request.state.source.get(id) return {"location": location.serialize(timelines)} + + +@V2.get("/sources") +async def sources(): + """ + Retrieves a list of data-sources that are availble to use. + """ + return {"sources": list(DATA_SOURCES.keys())} diff --git a/app/services/location/csbs.py b/app/services/location/csbs.py index dbd8d82d..815d47b9 100644 --- a/app/services/location/csbs.py +++ b/app/services/location/csbs.py @@ -1,5 +1,6 @@ """app.services.location.csbs.py""" import csv +import logging from datetime import datetime from asyncache import cached @@ -10,6 +11,8 @@ from ...utils import httputils from . import LocationService +LOGGER = logging.getLogger("services.location.csbs") + class CSBSLocationService(LocationService): """ @@ -39,10 +42,14 @@ async def get_locations(): :returns: The locations. :rtype: dict """ + LOGGER.info("Requesting data...") async with httputils.CLIENT_SESSION.get(BASE_URL) as response: text = await response.text() + LOGGER.info("Data received") + data = list(csv.DictReader(text.splitlines())) + LOGGER.info("CSV parsed") locations = [] @@ -77,6 +84,7 @@ async def get_locations(): int(item["Death"] or 0), ) ) + LOGGER.info("Data normalized") # Return the locations. return locations diff --git a/app/services/location/jhu.py b/app/services/location/jhu.py index 316de367..e3a5da72 100644 --- a/app/services/location/jhu.py +++ b/app/services/location/jhu.py @@ -1,5 +1,6 @@ """app.services.location.jhu.py""" import csv +import logging from datetime import datetime from asyncache import cached @@ -13,6 +14,8 @@ from ...utils import httputils from . import LocationService +LOGGER = logging.getLogger("services.location.jhu") + class JhuLocationService(LocationService): """ @@ -55,11 +58,15 @@ async def get_category(category): url = BASE_URL + "time_series_covid19_%s_global.csv" % category # Request the data + LOGGER.info("Requesting data...") async with httputils.CLIENT_SESSION.get(url) as response: text = await response.text() + LOGGER.info("Data received") + # Parse the CSV. data = list(csv.DictReader(text.splitlines())) + LOGGER.info("CSV parsed") # The normalized locations. locations = [] @@ -92,6 +99,7 @@ async def get_category(category): "latest": int(latest or 0), } ) + LOGGER.info("Data normalized") # Latest total. latest = sum(map(lambda location: location["latest"], locations)) diff --git a/app/services/location/nyt.py b/app/services/location/nyt.py index 7f73c1de..fbce631d 100644 --- a/app/services/location/nyt.py +++ b/app/services/location/nyt.py @@ -1,5 +1,6 @@ """app.services.location.nyt.py""" import csv +import logging from datetime import datetime from asyncache import cached @@ -11,6 +12,8 @@ from ...utils import httputils from . import LocationService +LOGGER = logging.getLogger("services.location.nyt") + class NYTLocationService(LocationService): """ @@ -73,11 +76,15 @@ async def get_locations(): """ # Request the data. + LOGGER.info("Requesting data...") async with httputils.CLIENT_SESSION.get(BASE_URL) as response: text = await response.text() + LOGGER.info("Data received") + # Parse the CSV. data = list(csv.DictReader(text.splitlines())) + LOGGER.info("CSV parsed") # Group together locations (NYT data ordered by dates not location). grouped_locations = get_grouped_locations_dict(data) @@ -119,5 +126,6 @@ async def get_locations(): }, ) ) + LOGGER.info("Data normalized") return locations diff --git a/app/utils/populations.py b/app/utils/populations.py index 1d8bd843..cacf0ff3 100644 --- a/app/utils/populations.py +++ b/app/utils/populations.py @@ -1,9 +1,13 @@ """app.utils.populations.py""" +import json import logging import requests +import app.io + LOGGER = logging.getLogger(__name__) +GEONAMES_BACKUP_PATH = "geonames_population_mappings.json" # Fetching of the populations. def fetch_populations(): @@ -20,12 +24,20 @@ def fetch_populations(): mappings = {} # Fetch the countries. - countries = requests.get("http://api.geonames.org/countryInfoJSON?username=dperic").json()["geonames"] - - # Go through all the countries and perform the mapping. - for country in countries: - mappings.update({country["countryCode"]: int(country["population"]) or None}) - + try: + countries = requests.get( + "http://api.geonames.org/countryInfoJSON", params={"username": "dperic"}, timeout=2 + ).json()["geonames"] + # Go through all the countries and perform the mapping. + for country in countries: + mappings.update({country["countryCode"]: int(country["population"]) or None}) + + if mappings: + app.io.save(GEONAMES_BACKUP_PATH, mappings) + except (json.JSONDecodeError, KeyError, requests.exceptions.Timeout) as err: + LOGGER.warning(f"Error pulling population data. {err.__class__.__name__}: {err}") + mappings = app.io.load(GEONAMES_BACKUP_PATH) + LOGGER.info(f"Using backup data from {GEONAMES_BACKUP_PATH}") # Finally, return the mappings. return mappings diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 00000000..83639cc9 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,39 @@ +"""test.test_io.py""" +import string + +import pytest + +import app.io + + +@pytest.mark.parametrize( + "name, content, kwargs", + [ + ("test_file.txt", string.ascii_lowercase, {}), + ("test_json_file.json", {"a": 0, "b": 1, "c": 2}, {}), + ("test_custom_json.json", {"z": -1, "b": 1, "y": -2, "a": 0}, {"indent": 4, "sort_keys": True}), + ], +) +def test_save(tmp_path, name, content, kwargs): + test_path = tmp_path / name + assert not test_path.exists() + + result = app.io.save(test_path, content, **kwargs) + assert result == test_path + assert test_path.exists() + + +@pytest.mark.parametrize( + "name, content, kwargs", + [ + ("test_file.txt", string.ascii_lowercase, {}), + ("test_json_file.json", {"a": 0, "b": 1, "c": 2}, {}), + ("test_custom_json.json", {"z": -1, "b": 1, "y": -2, "a": 0}, {"indent": 4, "sort_keys": True}), + ], +) +def test_round_trip(tmp_path, name, content, kwargs): + test_path = tmp_path / name + assert not test_path.exists() + + app.io.save(test_path, content, **kwargs) + assert app.io.load(test_path) == content