diff --git a/fimfarchive/utils.py b/fimfarchive/utils.py index e9d5e3b..763f8d7 100644 --- a/fimfarchive/utils.py +++ b/fimfarchive/utils.py @@ -27,7 +27,7 @@ import os import shutil from functools import partial from importlib_resources import read_binary, read_text -from typing import Any, Dict, Optional, Type, TypeVar, Union +from typing import Any, Dict, Iterator, Optional, Type, TypeVar, Union from tqdm import tqdm @@ -124,6 +124,42 @@ class PersistedDict(Dict[str, Any]): os.remove(self.temp) +class JayWalker: + """ + Walker for JSON objects. + """ + + def walk(self, data) -> None: + """ + Walks the attributes of a JSON object. + + Args: + data: The object to walk. + """ + iterator: Iterator + + if isinstance(data, dict): + iterator = iter(data.items()) + elif isinstance(data, list): + iterator = enumerate(data) + else: + return + + for key, value in iterator: + self.handle(data, key, value) + + def handle(self, data, key, value) -> None: + """ + Handles a single JSON entry. + + Args: + data: The current object. + key: The key of the entry. + value: The value of the entry. + """ + self.walk(value) + + def find_flavor(story: Story, flavor: Type[F]) -> Optional[F]: """ Searches for a flavor of a specific type. diff --git a/tests/test_utils.py b/tests/test_utils.py index c03baa7..2fe3b88 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -24,11 +24,12 @@ Utility tests. import json import os +from unittest.mock import call, patch import pytest from fimfarchive.flavors import DataFormat, MetaFormat, MetaPurity -from fimfarchive.utils import find_flavor, Empty, PersistedDict +from fimfarchive.utils import find_flavor, Empty, JayWalker, PersistedDict class TestEmpty: @@ -134,7 +135,7 @@ class TestPersistedDict: """ Tests data is replaced on load. """ - extra = {object(): object()} + extra = {'key': object()} data = PersistedDict(tmpfile) data.update(extra) data.load() @@ -192,6 +193,76 @@ class TestPersistedDict: assert dict(data) == sample +class TestJayWalker: + """ + JayWalker tests. + """ + + @pytest.fixture + def walker(self): + """ + Returns a walker instance. + """ + walker = JayWalker() + + with patch.object(walker, 'handle', wraps=walker.handle): + yield walker + + @pytest.mark.parametrize('obj', [None, 'alpaca', 42]) + def test_ignored_walk(self, walker, obj): + """ + Tests walker ignores plain values. + """ + walker.walk(obj) + walker.handle.assert_not_called() + + def test_list_walk(self, walker): + """ + Tests walker can walk lists. + """ + data = ['a', 'b', 'c'] + walker.walk(data) + + assert walker.handle.mock_calls == [ + call(data, 0, 'a'), + call(data, 1, 'b'), + call(data, 2, 'c'), + ] + + def test_dict_walk(self, walker): + """ + Tests walker can walk dicts. + """ + data = {0: 'a', 1: 'b', 2: 'c'} + walker.walk(data) + + assert walker.handle.mock_calls == [ + call(data, 0, 'a'), + call(data, 1, 'b'), + call(data, 2, 'c'), + ] + + def test_nested_walk(self, walker): + """ + Tests walker can walk nested objects. + """ + data = { + 'a': ['b', 'c'], + 'd': {'e': 'f'}, + } + + walker.walk([data]) + + assert walker.handle.mock_calls == [ + call([data], 0, data), + call(data, 'a', data['a']), + call(data['a'], 0, 'b'), + call(data['a'], 1, 'c'), + call(data, 'd', data['d']), + call(data['d'], 'e', 'f'), + ] + + class TestFindFlavor: """ find_flavor tests.