F = TypeVar('F', bound=Flavor)
AUTHOR_BLACKLIST: Set[int] = {
    33094,
    135140,
    242007,
}
STORY_BLACKLIST: Set[int] = {
    316169,
    318201,
}
STORY_WHITELIST: Set[int] = set()
class EmptyMeta(type):
class Empty(metaclass=EmptyMeta):
class PersistedDict(Dict[str, Any]):
class JayWalker:
class ResourceLoader:
    def __bool__(cls):
    def __bool__(self):
    def __init__(self, path, default=dict()):
    def load(self):
    def save(self):
    def walk(self, data) -> None:
    def handle(self, data, key, value) -> None:
def find_compressor() -> Tuple[ByteFunc, ByteFunc]:
def find_flavor(story: Story, flavor: Type[F]) -> Optional[F]:
def get_path(source: Union[None, Path, str]) -> Optional[Path]:
def is_blacklisted(story: Story) -> bool:
    def __init__(self, package: str, binary: bool = False) -> None:
    def __call__(self, name: str, binary: bool = None) -> Union[str, bytes]: class DataFormat(Flavor):
    """
    Indicates the file format of story data.
    """
    EPUB = ()
    FPUB = ()
    HTML = ()
    JSON = () class MetaFormat(Flavor):
    """
    Indicates the general structure of story meta.
    """
    ALPHA = ()
    BETA = () class MetaPurity(Flavor):
    """
    Indicates if story meta has been sanitized.
    """
    CLEAN = ()
    DIRTY = () def find_flavor(story: Story, flavor: Type[F]) -> Optional[F]:
    """
    Searches for a flavor of a specific type.

    Args:
        story: The story to search in.
        flavor: The flavor type to find.

    Returns:
        A flavor of the desired type, or None.
    """
    for current in story.flavors:
        if isinstance(current, flavor):
            return current

    return None def get_path(source: Union[None, Path, str]) -> Optional[Path]:
    """
    Creates a path from an object, if one is supplied.

    Args:
        source: Object to create a path from.

    Returns:
        A resolved path instance, or None.
    """
    if source is None:
        return None

    return Path(source).resolve() def is_blacklisted(story: Story) -> bool:
    """
    Checks if a story has been blacklisted.

    Args:
        story: Instance to check.

    Returns:
        True if a story has been blacklisted.
    """
    story_id = int(story.meta['id'])
    author_id = int(story.meta['author']['id'])

    if story_id in STORY_WHITELIST:
        return False
    elif story_id in STORY_BLACKLIST:
        return True
    elif author_id in AUTHOR_BLACKLIST:
        return True
    else:
        return False class Empty(metaclass=EmptyMeta):
    """
    Unique placeholder similar to `None`.
    """

    def __bool__(self):
        return False 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) class PersistedDict(Dict[str, Any]):
    """
    Dictionary for simple persistance.
    """

    def __init__(self, path, default=dict()):
        """
        Constructor.

        Args:
            path: Location of the persistence file.
            default: Initial values for entries.
        """
        super().__init__()
        self.path = path
        self.temp = path + '~'
        self.default = default
        self.load()

    def load(self):
        """
        Loads data from file as JSON.
        """
        self.clear()
        self.update(self.default)

        if os.path.exists(self.path):
            with open(self.path, 'rt') as fobj:
                self.update(json.load(fobj))

    def save(self):
        """
        Saves data to file as JSON.
        """
        content = json.dumps(
            self,
            indent=4,
            ensure_ascii=False,
            sort_keys=True,
        )

        if os.path.exists(self.path):
            shutil.copy(self.path, self.temp)

        with open(self.path, 'wt') as fobj:
            fobj.write(content)

        if os.path.exists(self.temp):
            os.remove(self.temp)
import json
import os
import pytest
from pathlib import Path
from unittest.mock import call, patch
from fimfarchive import utils
from fimfarchive.flavors import DataFormat, MetaFormat, MetaPurity
from fimfarchive.utils import (
    find_flavor, get_path, is_blacklisted,
    Empty, JayWalker, PersistedDict,
)

        assert dict(data) == {**sample, **extra}

    def test_default_in_empty(self, tmppath, sample):
        """
        Tests defaults are inserted when data is empty.
        """
        data = PersistedDict(tmppath, default=sample)

        assert dict(data) == sample

    def test_default_in_mixed(self, tmpfile, sample):
        """
        Tests defaults are inserted alongside loaded data.
        """
        extra = {object(): object()}
        data = PersistedDict(tmpfile, default=extra)

        assert dict(data) == {**sample, **extra}

    def test_default_does_not_override(self, tmpfile, sample):
        """
        Tests defaults do not override loaded data.
        """
        extra = {k: object() for k in sample.keys()}
        data = PersistedDict(tmpfile, default=extra)

        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.
    """

    @pytest.fixture
    def story(self, story):
        """
        Returns a meta-flavored story.
        """
        return story.merge(flavors=[
            MetaFormat.BETA,
            MetaPurity.CLEAN,
        ])

    def test_present_flavor(self, story):
        """
        Tests flavor is returned when present.
        """
        found = find_flavor(story, MetaFormat)

        assert found is MetaFormat.BETA