class StorySourceError(FimfarchiveError):
    """
    Story source is unusable.
    """ class DataFormat(Flavor):
    """
    Indicates the file format of story data.
    """
    EPUB = ()
    FPUB = ()
    HTML = ()
    JSON = () class StorySource(Flavor):
    """
    Indicates from where a story was fetched.
    """
    FIMFICTION = ()
    FIMFARCHIVE = () class UpdateStatus(Flavor):
    """
    Indicates if and how a story has changed.
    """
    CREATED = ()
    REVIVED = ()
    UPDATED = ()
    DELETED = () class RefetchSelector(UpdateSelector):
    """
    Selects the new story if it is available.
    """

    def filter_unchanged(self, old, new):
        """
        Returns the new story.
        """
        return new class UpdateSelector(Selector):
    """
    Selects the new story if it needs to be updated.

    An `UpdateStatus` flavor is applied to all returned stories. The
    returned story will also be fully fetched. The selector will not
    attempt to fetch data from new stories unless they have changed.
    """

    def __init__(self, date_mapper=None):
        """
        Constructor.

        Args:
            date_mapper: Maps a `Story` to its modification date.
        """
        if date_mapper:
            self.date_mapper = date_mapper
        else:
            self.date_mapper = StoryDateMapper()

    def filter_empty(self, story):
        """
        Returns the story if it has chapters, otherwise None.
        """
        meta = getattr(story, 'meta', None)

        if meta and meta.get('chapters'):
            return story
        else:
            return None

    def filter_invalid(self, story):
        """
        Returns the story if it is valid, otherwise None.
        """
        try:
            story.meta
            story.data
        except InvalidStoryError:
            return None
        else:
            return story

    def filter_unchanged(self, old, new):
        """
        Returns the new story if it has changed, otherwise None.

        Raises:
            ValueError: If `date_mapper` returns `None`.
        """
        old_date = self.date_mapper(old)
        new_date = self.date_mapper(new)

        if old_date is None:
            raise ValueError("Missing old date.")
        elif new_date is None:
            raise ValueError("Missing new date.")
        elif old_date < new_date:
            return new
        else:
            return None

    def flavored(self, story, *flavors):
        """
        Returns the story after applying the specified flavors.
        """
        story.flavors.update(flavors)

        return story

    def __call__(self, old, new):
        old = self.filter_empty(old)
        new = self.filter_empty(new)
        deleted = old and not new

        if old:
            old = self.filter_invalid(old)

        if old and new:
            new = self.filter_unchanged(old, new)

        if new:
            new = self.filter_invalid(new)
            deleted = old and not new

        if not old and new:
            return self.flavored(new, UpdateStatus.CREATED)
        elif old and not new and not deleted:
            return self.flavored(old, UpdateStatus.REVIVED)
        elif old and new:
            return self.flavored(new, UpdateStatus.UPDATED)
        elif old and not new and deleted:
            return self.flavored(old, UpdateStatus.DELETED)
        else:
            return None class UpdateTask(SignalSender):
    """
    Updates Fimfarchive.
    """
    on_attempt = Signal('key', 'skips', 'retries')
    on_success = Signal('key', 'story')
    on_skipped = Signal('key', 'story')
    on_failure = Signal('key', 'error')

    state_file = 'state.json'
    state_vars = {'key': 0}

    def __init__(
            self,
            fimfarchive: Fetcher,
            fimfiction: Fetcher,
            selector: Selector = None,
            stamper: Stamper = None,
            workdir: str = DEFAULT_WORKDIR,
            retries: int = DEFAULT_RETRIES,
            skips: int = DEFAULT_SKIPS,
            ) -> None:
        """
        Constructor.

        Args:
            fimfarchive: Fetcher for the old release.
            fimfiction: Fetcher for the new release.
            selector: Selector for which story to save.
            stamper: Stamper for adding story information.
            workdir: Path for storage of state and stories.
            retries: Number of retries before giving up.
            skips: Number of skips before giving up.
        """
        super().__init__()

        if selector is None:
            selector = UpdateSelector()

        if stamper is None:
            stamper = UpdateStamper()

        self.fimfarchive = fimfarchive
        self.fimfiction = fimfiction
        self.select = selector
        self.stamp = stamper
        self.workdir = workdir
        self.retries = retries
        self.skips = skips

        os.makedirs(self.workdir, exist_ok=True)
        state_path = os.path.join(self.workdir, self.state_file)
        self.state = PersistedDict(state_path, self.state_vars)

        meta_mapper = self.get_mapper('meta')
        skip_mapper = self.get_mapper('skip')
        epub_mapper = self.get_mapper('epub')
        html_mapper = self.get_mapper('html')
        json_mapper = self.get_mapper('json')

        self.meta_writer = DirectoryWriter(meta_mapper)
        self.skip_writer = DirectoryWriter(skip_mapper)
        self.epub_writer = DirectoryWriter(meta_mapper, epub_mapper)
        self.html_writer = DirectoryWriter(meta_mapper, html_mapper)
        self.json_writer = DirectoryWriter(meta_mapper, json_mapper)

    def get_mapper(self, subdir: str) -> StoryPathMapper:
        """
        Creates a mapper to the specified subdirectory.

        Args:
            subdir: Subdirectory for the mapper.
        """
        directory = os.path.join(self.workdir, subdir)
        return StoryPathMapper(directory)

    def fetch(self, fetcher: Fetcher, key: int) -> Optional[Story]:
        """
        Fetches a story unless invalid.

        Args:
            fetcher: Source for the story.
            key: Primary key of the story.

        Raises:
            StorySourceError: If the fetcher fails.
        """
        try:
            return fetcher.fetch(key)
        except InvalidStoryError:
            return None

    def write(self, story: Story) -> None:
        """
        Passes the story to the appropriate writer.

        Args:
            story: Object to write.

        Raises:
            ValueError: If story flavor is unsupported.
        """
        if StorySource.FIMFARCHIVE in story.flavors:
            self.meta_writer.write(story)
        elif DataFormat.HTML in story.flavors:
            self.html_writer.write(story)
        elif DataFormat.JSON in story.flavors:
            self.json_writer.write(story)
        elif DataFormat.EPUB in story.flavors:
            self.epub_writer.write(story)
        else:
            raise ValueError("Unsupported story flavor.")

    def copy_archive_meta(
            self,
            old: Optional[Story],
            new: Optional[Story],
            ) -> None:
        """
        Copies archive meta from old story to new.

        Args:
            old: The story to copy from.
            new: The story to copy to.

        Raises:
            ValueError: If new story already contains archive meta.
        """
        if old is None or new is None:
            return

        try:
            if 'archive' in new.meta:
                raise ValueError("New story contains archive meta.")

            new.meta['archive'] = deepcopy(old.meta['archive'])
        except (InvalidStoryError, KeyError):
            return

    def update(self, key: int) -> Optional[Story]:
        """
        Updates the specified story.

        args:
            key: Primary key of the story to update.

        Raises:
            StorySourceError: If any fetcher fails.
        """
        old = self.fetch(self.fimfarchive, key)
        new = self.fetch(self.fimfiction, key)

        self.copy_archive_meta(old, new)
        selected = self.select(old, new)

        if selected and UpdateStatus.REVIVED in selected.flavors:
            assert new is not None
            selected = selected.merge(meta=new.meta)

        if selected:
            self.stamp(selected)
            self.write(selected)
        elif new:
            self.skip_writer.write(new)
        elif old:
            self.skip_writer.write(old)

        return selected

    def run(self) -> None:
        """
        Runs the updater task.
        """
        retried = 0
        skipped = 0

        while skipped < self.skips and retried < self.retries:
            key = self.state['key']

            self.on_attempt(key, skipped, retried)

            try:
                story = self.update(key)
            except Exception as e:
                retried += 1
                self.on_failure(key, e)
                time.sleep(FAILURE_DELAY)
            else:
                retried = 0
                self.state['key'] += 1
                self.state.save()

                if story:
                    skipped = 0
                    self.on_success(key, story)
                    time.sleep(SUCCESS_DELAY)
                else:
                    skipped += 1
                    self.on_skipped(key, story)
                    time.sleep(SKIPPED_DELAY) SUCCESS_DELAY = 8 SKIPPED_DELAY = 2 FAILURE_DELAY = 300 class DummyFetcher(Fetcher):
    """
    Fetcher with local instance storage.
    """

    def __init__(self):
        """
        Constructor.
        """
        self.stories: Dict[int, Story] = dict()

    def add(self, key, date, flavors=(), data=Empty):
        """
        Adds a story to the fetcher.
        """
        meta = {
            'id': key,
            'title': f't{key}',
            'date_modified': date,
            'conversions': 0,
            'author': {
                'id': key,
                'name': f'n{key}'
            },
            'chapters': [
                {'id': key},
            ],
        }

        if data is Empty:
            text = f'd{key}'
            data = text.encode()

        story = Story(
            key=key,
            fetcher=self,
            meta=meta,
            data=data,
            flavors=flavors,
        )

        self.stories[key] = story

        return story

    def fetch(self, key, prefetch_meta=None, prefetch_data=None):
        """
        Returns a previously stored story.
        """
        try:
            return self.stories[key]
        except KeyError:
            raise InvalidStoryError()

    def fetch_data(self, key):
        """
        Raises exception for missing data.
        """
        raise InvalidStoryError()

    def fetch_meta(self, key):
        """
        Raises exception for missing meta.
        """
        raise InvalidStoryError()

    def __iter__(self):
        """
        Yields all previously stored stories.
        """
        for key in sorted(self.stories.keys()):
            yield self.stories[key]
from unittest.mock import MagicMock, call, patch
from fimfarchive.exceptions import StorySourceError
from fimfarchive.flavors import DataFormat, StorySource, UpdateStatus
from fimfarchive.selectors import RefetchSelector, UpdateSelector
from fimfarchive.tasks.update import (
    UpdateTask, SUCCESS_DELAY, SKIPPED_DELAY, FAILURE_DELAY,
)
from .conftest import DummyFetcher
import pytest
"""
Update task tests.
"""


#
# Fimfarchive, preserves stories from Fimfiction.
# Copyright (C) 2020  Joakim Soderlund
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#







class TestUpdateTask:
    """
    Tests update task.
    """

    @pytest.fixture
    def fimfiction(self):
        """
        Returns a `Fetcher` simulating Fimfiction.
        """
        return DummyFetcher()

    @pytest.fixture
    def fimfarchive(self):
        """
        Returns a `Fetcher` simulating Fimfarchive.
        """

        return DummyFetcher()