Skip to content

Commit

Permalink
pokedex#16: "adds synchronous local storage" (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
withtwoemms committed Dec 31, 2023
1 parent 97b701d commit 4c9dd6f
Show file tree
Hide file tree
Showing 15 changed files with 265 additions and 34 deletions.
4 changes: 4 additions & 0 deletions pokedex/api/__init__.py
@@ -0,0 +1,4 @@
from typing import Any, Dict

PokeApiEndpoints = Dict[str, str]
Pokemon = Dict[str, Any]
11 changes: 7 additions & 4 deletions pokedex/api/__main__.py
@@ -1,8 +1,9 @@
import json
import sys
from argparse import ArgumentParser

from pokedex.api.client import get_pokemon_by_move, get_pokemon_by_type
from pokedex.db.client import persist_requests
from pokedex.db.models import Report


def go(args=sys.argv):
Expand All @@ -12,7 +13,7 @@ def go(args=sys.argv):

parser_by = subparsers.add_parser("by")
parser_by.add_argument("--type", type=str, help="e.g. water, grass, fire")
parser_by.add_argument("--move", type=str, help='e.g. "water gun", "razor leaf", ember')
parser_by.add_argument("--move", type=str, help='e.g. "water-gun", "razor-leaf", ember')

any_args_given = len(sys.argv) > 1

Expand All @@ -23,8 +24,10 @@ def go(args=sys.argv):
args = parser.parse_args()

if args.type:
print(json.dumps(list(get_pokemon_by_type(args.type)), indent=4))
results = dict(persist_requests(get_pokemon_by_type(args.type)))
print(Report(persisted=results))

if args.move:
move = str(args.move).replace(" ", "-")
print(json.dumps(list(get_pokemon_by_move(move)), indent=4))
results = dict(persist_requests(get_pokemon_by_move(move)))
print(Report(persisted=results))
25 changes: 11 additions & 14 deletions pokedex/api/client.py
@@ -1,22 +1,21 @@
from typing import Any, Dict, Generator, Iterable, List, Optional
from actionpack import Procedure
from typing import Generator, Iterable, List, Optional

import requests

from actionpack import Procedure
from actionpack.actions import Call
from actionpack.utils import Closure

from pokedex.api import PokeApiEndpoints, Pokemon
from pokedex.api.constants import BASE_URL
from pokedex.api.models import PokeApiRequest, PokeApiResource, PokeApiResourceRef, PokemonRef

PokeApiEndpoints = Dict[str, str]
Pokemon = Dict[str, Any]


def get_endpoints(endpoints_request: PokeApiRequest) -> PokeApiEndpoints:
response: requests.Response = endpoints_request()
response.raise_for_status() # TODO: handle error states
response.raise_for_status()
endpoints: PokeApiEndpoints = response.json()
if isinstance(endpoints, str):
raise TypeError()
return endpoints


Expand All @@ -36,7 +35,7 @@ def generate_pokemon_requests(api_request: PokeApiRequest, response_key: str) ->
resource_refs = response.json()[response_key]
for resource_ref in resource_refs:
if response_key == "pokemon":
model = PokemonRef(**resource_ref).to_api_resource_ref()
model = PokemonRef(**resource_ref).as_api_resource_ref()
else:
model = PokeApiResourceRef(**resource_ref)
yield model.as_request()
Expand Down Expand Up @@ -75,15 +74,13 @@ def fetch(endpoint_name: str) -> PokeApiResource:
return get_resource(endpoint)


def get_pokemon_by_move(pokemon_move: str) -> Generator[Pokemon, None, None]:
def get_pokemon_by_move(pokemon_move: str) -> Generator[PokeApiRequest, None, None]:
endpoint_request = search_endpoint("move", pokemon_move)
if endpoint_request:
pokemon_refs = generate_pokemon_requests(endpoint_request, "learned_by_pokemon")
yield from get_pokemon(pokemon_refs)
yield from generate_pokemon_requests(endpoint_request, "learned_by_pokemon")


def get_pokemon_by_type(pokemon_type: str):
def get_pokemon_by_type(pokemon_type: str) -> Generator[PokeApiRequest, None, None]:
endpoint_request = search_endpoint("type", pokemon_type)
if endpoint_request:
pokemon_refs = generate_pokemon_requests(endpoint_request, "pokemon")
yield from get_pokemon(pokemon_refs)
yield from generate_pokemon_requests(endpoint_request, "pokemon")
10 changes: 5 additions & 5 deletions pokedex/api/models.py
@@ -1,18 +1,18 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import List, Optional

import requests
from pydantic import BaseModel

ApiResponseType = Dict[str, Any]
from pokedex.db.client import cached_get


@dataclass(frozen=True)
class PokeApiRequest:
url: str

def __call__(self) -> ApiResponseType:
return requests.get(self.url)
def __call__(self) -> requests.Response:
return cached_get(self.url)

@property
def __name__(self):
Expand All @@ -31,7 +31,7 @@ class PokemonRef(BaseModel):
pokemon: PokeApiResourceRef
slot: int

def to_api_resource_ref(self) -> PokeApiRequest:
def as_api_resource_ref(self) -> PokeApiResourceRef:
return self.pokemon

def as_request(self) -> PokeApiRequest:
Expand Down
5 changes: 5 additions & 0 deletions pokedex/constants.py
@@ -0,0 +1,5 @@
from pathlib import Path

PROJECTROOT = Path(__file__).parent.parent.absolute()
DBROOT = PROJECTROOT / "pokedex" / "db"
CACHEPATH = DBROOT / "cache"
Empty file added pokedex/db/__init__.py
Empty file.
50 changes: 50 additions & 0 deletions pokedex/db/actions.py
@@ -0,0 +1,50 @@
import dbm
import json
from dataclasses import dataclass
from typing import AnyStr, Optional

from actionpack import Action

from pokedex.constants import CACHEPATH
from pokedex.db.models import DeferredRequest


@dataclass
class DbInsertRequestResult(Action):
key: AnyStr
value: DeferredRequest
db: Optional[str] = None

def __post_init__(self):
self.set(name=self.key)

def instruction(self) -> bool:
db = self.db or str(CACHEPATH)
response = self.value() # external call
with dbm.open(db, "c") as cache:
cache[self.key] = json.dumps(response.json())
return True


@dataclass
class DbInsert(Action):
key: AnyStr
value: AnyStr
db: Optional[str] = None

def instruction(self) -> str:
db = self.db or str(CACHEPATH)
with dbm.open(db, "c") as cache:
cache[self.key] = self.value
return self.key


@dataclass
class DbRead(Action[str, bytes]):
key: bytes
db: Optional[str] = None

def instruction(self) -> bytes:
db = self.db or str(CACHEPATH)
with dbm.open(db, "c") as cache: # create db if not exists
return cache[self.key]
28 changes: 28 additions & 0 deletions pokedex/db/client.py
@@ -0,0 +1,28 @@
import json
from typing import Iterable

import requests
from actionpack import KeyedProcedure

from pokedex.db.actions import DbInsert, DbInsertRequestResult, DbRead
from pokedex.db.models import DeferredRequest


def persist_requests(requests: Iterable[DeferredRequest]):
db_inserts = (DbInsertRequestResult(key=rq.url, value=rq) for rq in requests)
procedure = KeyedProcedure[str, dict](db_inserts).execute(should_raise=True)
for key, result in procedure:
yield key, result.value


def cached_get(url: str) -> requests.Response:
cache_result = DbRead(url.encode()).perform()
if cache_result.successful:
response = requests.Response()
response._content = cache_result.value
response.status_code = 200
else:
response = requests.get(url)
response.raise_for_status() # TODO: handle error states
DbInsert(key=url, value=json.dumps(response.json())).perform(should_raise=True)
return response
25 changes: 25 additions & 0 deletions pokedex/db/models.py
@@ -0,0 +1,25 @@
import json
from dataclasses import asdict, dataclass
from typing import Any, Dict, List, Type, Union

from requests import Response
from typing_extensions import Protocol


class DeferredRequest(Protocol):
url: str

def __call__(self) -> Response:
pass


JSON = Union[Dict[str, Any], List[Any], int, str, float, bool, Type[None]]


@dataclass
class Report:
persisted: dict

def __str__(self) -> JSON:
summary = dict(asdict(self), count=len(self.persisted))
return json.dumps(summary, indent=4)
2 changes: 2 additions & 0 deletions requirements.txt
@@ -0,0 +1,2 @@
poetry==1.5.1
typing_extensions==4.6.2
16 changes: 14 additions & 2 deletions tests/fixtures/__init__.py
@@ -1,6 +1,8 @@
from pathlib import Path
from typing import Dict
from typing import Dict, TypeVar
from unittest.mock import MagicMock

from actionpack.action import Result
from requests.models import Response

parentdir = Path(__file__).parent
Expand All @@ -17,11 +19,21 @@


def resource(name: str):
return resource_paths[name].open("rb").read()
return resource_paths[name].read_bytes()


def craft_response(contents: str, status_code: int):
response = Response()
response._content = contents
response.status_code = status_code
return response


T = TypeVar("T")


def craft_result(value: T, successful: bool) -> Result:
mock_result = MagicMock(spec=Result)
mock_result.successful = successful
mock_result.value = value
return mock_result
34 changes: 25 additions & 9 deletions tests/pokedex/api/test_client.py
Expand Up @@ -4,7 +4,7 @@

from requests.exceptions import HTTPError

from pokedex.api.client import get_pokemon_by_move, get_pokemon_by_type
from pokedex.api.client import get_pokemon, get_pokemon_by_move, get_pokemon_by_type
from pokedex.api.models import PokeApiRequest
from tests.fixtures import craft_response, resource

Expand All @@ -21,11 +21,12 @@ class TestClientCanGetPokemonByType(TestCase):
],
)
def test_entrypoint_happy_path(self, mock_requests):
fairies = get_pokemon_by_type("fairy")
assert isinstance(fairies, GeneratorType)
fairy_requests = get_pokemon_by_type("fairy")
assert isinstance(fairy_requests, GeneratorType)

pokemon = next(fairies)
assert pokemon["name"] == "jigglypuff"
pokemon_request = next(fairy_requests)
pokemon_response = pokemon_request()
assert pokemon_response.json()["name"] == "jigglypuff"

@patch.object(
PokeApiRequest,
Expand Down Expand Up @@ -55,11 +56,12 @@ class TestClientCanGetPokemonByMove(TestCase):
],
)
def test_entrypoint_happy_path(self, mock_requests):
pounders = get_pokemon_by_move("headbutt")
assert isinstance(pounders, GeneratorType)
pounder_requests = get_pokemon_by_move("headbutt")
assert isinstance(pounder_requests, GeneratorType)

pokemon = next(pounders)
assert pokemon["name"] == "jigglypuff"
pokemon_request = next(pounder_requests)
pokemon_response = pokemon_request()
assert pokemon_response.json()["name"] == "jigglypuff"

@patch.object(
PokeApiRequest,
Expand All @@ -75,3 +77,17 @@ def test_entrypoint_happy_path(self, mock_requests):
def test_entrypoint_initial_api_call_failure(self, mock_requests):
with self.assertRaises(HTTPError):
next(get_pokemon_by_move("double-slap"))


class TestClientCanNavigateExternalApi(TestCase):
@patch.object(
target=PokeApiRequest,
attribute="__call__",
side_effect=[
craft_response(resource("jigglypuff.response"), status_code=200),
],
)
def test_get_pokemon(self, mock_request):
mock_request.__name__ = PokeApiRequest.__name__
pokemon_response = next(get_pokemon((mock_request,)))
assert pokemon_response["name"] == "jigglypuff"
Empty file added tests/pokedex/db/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions tests/pokedex/db/test_actions.py
@@ -0,0 +1,17 @@
from unittest import TestCase
from unittest.mock import patch

from pokedex.db.actions import DbRead
from tests.fixtures import resource


class TestActions(TestCase):
@patch("dbm.open")
def test_can_read_cache(self, mock_kv_open):
key = "https://pokeapi.co/api/v2/pokemon/39/"
pokemon_data = resource("jigglypuff.response")
mock_kv_open.return_value.__enter__.return_value.__getitem__.return_value = pokemon_data
mock_kv_open.assert_not_called()
result = DbRead(key=key).perform(should_raise=True)
mock_kv_open.assert_called_once()
assert result.value == pokemon_data

0 comments on commit 4c9dd6f

Please sign in to comment.