Add dedicated fetch command instead of always checking

Use Protocol instead of ABCMeta
This commit is contained in:
Pavle Portic 2021-11-19 19:44:46 +01:00
parent f410f7d302
commit bd7fcb35bd
Signed by: TheEdgeOfRage
GPG Key ID: F2AB38285780DE3D
17 changed files with 186 additions and 125 deletions

View File

@ -4,4 +4,7 @@ ignore_missing_imports=true
[mypy-feedparser.*] [mypy-feedparser.*]
ignore_missing_imports=true ignore_missing_imports=true
[mypy-pytest_mock.*]
ignore_missing_imports=true
[mypy] [mypy]

View File

@ -4,6 +4,9 @@ This is a simple CLI to manage YouTube subscriptions through RSS feeds
and watch new videos using `mpv`. It keeps track of watched videos in a local and watch new videos using `mpv`. It keeps track of watched videos in a local
sqlite database. sqlite database.
**This tool is still in early development and breaking changes across minor
versions are expected.**
## Configuration ## Configuration
It looks for a list of RSS URLs in `$XDG_CONFIG_HOME/ytrssil/feeds` It looks for a list of RSS URLs in `$XDG_CONFIG_HOME/ytrssil/feeds`

View File

@ -1,7 +1,7 @@
aioresponses==0.7.* aioresponses==0.7.*
flake8==3.* flake8==3.*
jedi==0.*
mypy==0.* mypy==0.*
pytest==6.2.* pytest==6.2.*
pytest-cov==3.0.0
pytest-mock==3.6.* pytest-mock==3.6.*
wheel==0.37.* wheel==0.37.*

View File

@ -1,7 +1,6 @@
from datetime import datetime from datetime import datetime
from ytrssil.types import ChannelData, VideoData from ytrssil.datatypes import ChannelData, VideoData
FEED_XML: str = ''' FEED_XML: str = '''
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@ -30,5 +29,6 @@ TEST_VIDEO_DATA: VideoData = {
TEST_CHANNEL_DATA: ChannelData = { TEST_CHANNEL_DATA: ChannelData = {
'channel_id': 'channel_id', 'channel_id': 'channel_id',
'name': 'channel_name', 'name': 'channel_name',
'new_videos': {} 'new_videos': {},
'watched_videos': {},
} }

View File

@ -1,8 +1,9 @@
from ytrssil.bindings import setup_dependencies from ytrssil.bindings import setup_dependencies
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.fetch import Fetcher, AioHttpFetcher from ytrssil.fetch import AioHttpFetcher
from ytrssil.parse import Parser, FeedparserParser from ytrssil.parse import FeedparserParser
from ytrssil.repository import ChannelRepository, SqliteChannelRepository from ytrssil.protocols import ChannelRepository, Fetcher, Parser
from ytrssil.repository import SqliteChannelRepository
def test_setup_dependencies() -> None: def test_setup_dependencies() -> None:

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from tests.constants import TEST_CHANNEL_DATA, TEST_VIDEO_DATA from tests.constants import TEST_CHANNEL_DATA, TEST_VIDEO_DATA
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, ChannelData, Video
def test_video_str() -> None: def test_video_str() -> None:
@ -11,8 +11,8 @@ def test_video_str() -> None:
def test_channel_str() -> None: def test_channel_str() -> None:
channel = Channel.from_dict({ channel_data: ChannelData = TEST_CHANNEL_DATA.copy()
**TEST_CHANNEL_DATA, channel_data.update({
'new_videos': { 'new_videos': {
'video_id': Video( 'video_id': Video(
video_id='video_id', video_id='video_id',
@ -25,13 +25,14 @@ def test_channel_str() -> None:
), ),
}, },
}) })
string = str(channel) channel_string = str(Channel.from_dict(channel_data))
assert string == 'channel_name - 1' assert channel_string == 'channel_name - 1'
def test_channel_add_new_video() -> None: def test_channel_add_new_video() -> None:
channel = Channel.from_dict(TEST_CHANNEL_DATA) channel_data: ChannelData = TEST_CHANNEL_DATA.copy()
channel = Channel.from_dict(channel_data)
added_video = channel.add_video(Video( added_video = channel.add_video(Video(
video_id='video_id', video_id='video_id',
name='video_name', name='video_name',
@ -47,8 +48,8 @@ def test_channel_add_new_video() -> None:
def test_channel_add_existing_video() -> None: def test_channel_add_existing_video() -> None:
channel = Channel.from_dict({ channel_data: ChannelData = TEST_CHANNEL_DATA.copy()
**TEST_CHANNEL_DATA, channel_data.update({
'new_videos': { 'new_videos': {
'video_id': Video( 'video_id': Video(
video_id='video_id', video_id='video_id',
@ -61,6 +62,7 @@ def test_channel_add_existing_video() -> None:
), ),
}, },
}) })
channel = Channel.from_dict(channel_data)
added_video = channel.add_video(Video( added_video = channel.add_video(Video(
video_id='video_id', video_id='video_id',
name='video_name', name='video_name',
@ -85,10 +87,10 @@ def test_channel_mark_video_as_watched() -> None:
timestamp=datetime.fromisoformat('1970-01-01T00:00:00+00:00'), timestamp=datetime.fromisoformat('1970-01-01T00:00:00+00:00'),
watch_timestamp=None, watch_timestamp=None,
) )
channel = Channel.from_dict({
**TEST_CHANNEL_DATA, channel_data: ChannelData = TEST_CHANNEL_DATA.copy()
'new_videos': {'video_id': video}, channel_data.update({'new_videos': {'video_id': video}})
}) channel = Channel.from_dict(channel_data)
channel.mark_video_as_watched(video) channel.mark_video_as_watched(video)

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from aioresponses import aioresponses from aioresponses import aioresponses
@ -6,11 +7,11 @@ from aioresponses import aioresponses
from tests.constants import FEED_XML, TEST_CHANNEL_DATA, TEST_VIDEO_DATA from tests.constants import FEED_XML, TEST_CHANNEL_DATA, TEST_VIDEO_DATA
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, Video
from ytrssil.fetch import AioHttpFetcher, Fetcher from ytrssil.fetch import AioHttpFetcher, FetcherBase
def test_fetch_new_videos(): def test_fetch_new_videos() -> None:
class MockFetcher(Fetcher): class MockFetcher(FetcherBase):
def fetch_feeds(self, urls: Iterable[str]) -> Iterable[str]: def fetch_feeds(self, urls: Iterable[str]) -> Iterable[str]:
return [FEED_XML] return [FEED_XML]
@ -30,7 +31,7 @@ def test_fetch_new_videos():
assert new_videos[TEST_VIDEO_DATA['video_id']] == video assert new_videos[TEST_VIDEO_DATA['video_id']] == video
def test_aiohttpfetcher_fetch_feeds(): def test_aiohttpfetcher_fetch_feeds() -> None:
feed_url = 'test_url' feed_url = 'test_url'
with aioresponses() as mocked: with aioresponses() as mocked:
mocked.get( mocked.get(

View File

@ -7,8 +7,8 @@ from tests.constants import FEED_XML, TEST_CHANNEL_DATA, TEST_VIDEO_DATA
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, Video
from ytrssil.exceptions import ChannelNotFound from ytrssil.exceptions import ChannelNotFound
from ytrssil.parse import create_feed_parser, FeedparserParser from ytrssil.parse import FeedparserParser, create_feed_parser
from ytrssil.repository import ChannelRepository from ytrssil.protocols import ChannelRepository
def test_feedparser_channel_exists() -> None: def test_feedparser_channel_exists() -> None:

View File

@ -2,8 +2,7 @@ from inject import autoparams
from ytrssil.bindings import setup_dependencies from ytrssil.bindings import setup_dependencies
from ytrssil.datatypes import Video from ytrssil.datatypes import Video
from ytrssil.fetch import Fetcher from ytrssil.protocols import ChannelRepository, Fetcher
from ytrssil.repository import ChannelRepository
def get_new_videos() -> list[Video]: def get_new_videos() -> list[Video]:

View File

@ -1,9 +1,10 @@
from inject import Binder, Injector, clear_and_configure, get_injector_or_die from inject import Binder, Injector, clear_and_configure, get_injector_or_die
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.fetch import Fetcher, create_fetcher from ytrssil.fetch import create_fetcher
from ytrssil.parse import Parser, create_feed_parser from ytrssil.parse import create_feed_parser
from ytrssil.repository import ChannelRepository, create_channel_repository from ytrssil.protocols import ChannelRepository, Fetcher, Parser
from ytrssil.repository import create_channel_repository
def dependency_configuration(binder: Binder) -> None: def dependency_configuration(binder: Binder) -> None:

View File

@ -10,8 +10,7 @@ from inject import autoparams
from ytrssil.bindings import setup_dependencies from ytrssil.bindings import setup_dependencies
from ytrssil.constants import mpv_options from ytrssil.constants import mpv_options
from ytrssil.datatypes import Video from ytrssil.datatypes import Video
from ytrssil.fetch import Fetcher from ytrssil.protocols import ChannelRepository, Fetcher
from ytrssil.repository import ChannelRepository
def user_query(videos: dict[str, Video], reverse: bool = False) -> list[Video]: def user_query(videos: dict[str, Video], reverse: bool = False) -> list[Video]:
@ -41,13 +40,27 @@ def user_query(videos: dict[str, Video], reverse: bool = False) -> list[Video]:
return ret return ret
@autoparams()
def fetch_new_videos(
repository_manager: ChannelRepository,
fetcher: Fetcher,
) -> int:
with repository_manager as _:
_, new_videos = fetcher.fetch_new_videos()
if not new_videos:
print('No new videos', file=stderr)
return 1
return 0
@autoparams() @autoparams()
def watch_videos( def watch_videos(
repository_manager: ChannelRepository, repository_manager: ChannelRepository,
fetcher: Fetcher, fetcher: Fetcher,
) -> int: ) -> int:
with repository_manager as repository: with repository_manager as repository:
channels, new_videos = fetcher.fetch_new_videos() new_videos = repository.get_new_videos()
if not new_videos: if not new_videos:
print('No new videos', file=stderr) print('No new videos', file=stderr)
return 1 return 1
@ -63,7 +76,7 @@ def watch_videos(
execv(cmd[0], cmd) execv(cmd[0], cmd)
for video in selected_videos: for video in selected_videos:
selected_channel = channels[video.channel_id] selected_channel = repository.get_channel(video.channel_id)
selected_channel.mark_video_as_watched(video) selected_channel.mark_video_as_watched(video)
watch_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc) watch_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc)
repository.update_video(video, watch_timestamp) repository.update_video(video, watch_timestamp)
@ -77,7 +90,7 @@ def print_url(
fetcher: Fetcher, fetcher: Fetcher,
) -> int: ) -> int:
with repository_manager as repository: with repository_manager as repository:
channels, new_videos = fetcher.fetch_new_videos() new_videos = repository.get_new_videos()
if not new_videos: if not new_videos:
print('No new videos', file=stderr) print('No new videos', file=stderr)
return 1 return 1
@ -88,7 +101,7 @@ def print_url(
return 2 return 2
for video in selected_videos: for video in selected_videos:
selected_channel = channels[video.channel_id] selected_channel = repository.get_channel(video.channel_id)
selected_channel.mark_video_as_watched(video) selected_channel.mark_video_as_watched(video)
watch_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc) watch_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc)
repository.update_video(video, watch_timestamp) repository.update_video(video, watch_timestamp)
@ -145,7 +158,9 @@ def main(args: list[str] = argv) -> int:
except IndexError: except IndexError:
command = 'watch' command = 'watch'
if command == 'watch': if command == 'fetch':
return fetch_new_videos()
elif command == 'watch':
return watch_videos() return watch_videos()
elif command == 'print': elif command == 'print':
return print_url() return print_url()
@ -157,3 +172,5 @@ def main(args: list[str] = argv) -> int:
else: else:
print(f'Unknown command "{command}"', file=stderr) print(f'Unknown command "{command}"', file=stderr)
return 1 return 1
return 0

View File

@ -1,9 +1,25 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Any, Optional, TypedDict
from ytrssil.types import ChannelData, VideoData
class VideoData(TypedDict):
video_id: str
name: str
url: str
channel_id: str
channel_name: str
timestamp: datetime
watch_timestamp: Optional[datetime]
class ChannelData(TypedDict):
channel_id: str
name: str
new_videos: dict[str, Any]
watched_videos: dict[str, Any]
@dataclass @dataclass

View File

@ -1,4 +1,3 @@
from abc import ABCMeta, abstractmethod
from asyncio import gather, run from asyncio import gather, run
from collections.abc import Iterable from collections.abc import Iterable
@ -7,16 +6,15 @@ from inject import autoparams
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, Video
from ytrssil.parse import Parser from ytrssil.protocols import Fetcher, Parser
class Fetcher(metaclass=ABCMeta): class FetcherBase:
@abstractmethod
def fetch_feeds( def fetch_feeds(
self, self,
urls: Iterable[str], urls: Iterable[str],
) -> Iterable[str]: # pragma: no cover ) -> Iterable[str]: # pragma: no cover
pass raise NotImplementedError
@autoparams() @autoparams()
def fetch_new_videos( def fetch_new_videos(
@ -35,8 +33,12 @@ class Fetcher(metaclass=ABCMeta):
return channels, new_videos return channels, new_videos
class AioHttpFetcher(Fetcher): class AioHttpFetcher(FetcherBase):
async def request(self, session: ClientSession, url: str) -> ClientResponse: async def request(
self,
session: ClientSession,
url: str,
) -> ClientResponse:
return await session.get(url=url) return await session.get(url=url)
async def async_fetch_feeds(self, urls: Iterable[str]) -> Iterable[str]: async def async_fetch_feeds(self, urls: Iterable[str]) -> Iterable[str]:

View File

@ -1,4 +1,3 @@
from abc import ABCMeta, abstractmethod
from datetime import datetime from datetime import datetime
import feedparser import feedparser
@ -7,20 +6,16 @@ from inject import autoparams
from ytrssil.config import Configuration from ytrssil.config import Configuration
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, Video
from ytrssil.exceptions import ChannelNotFound from ytrssil.exceptions import ChannelNotFound
from ytrssil.repository import ChannelRepository from ytrssil.protocols import ChannelRepository, Parser
class Parser(metaclass=ABCMeta): class ParserBase:
@autoparams('channel_repository') @autoparams('channel_repository')
def __init__(self, channel_repository: ChannelRepository) -> None: def __init__(self, channel_repository: ChannelRepository) -> None:
self.repository = channel_repository self.repository = channel_repository
@abstractmethod
def __call__(self, feed_content: str) -> Channel: # pragma: no cover
pass
class FeedparserParser(ParserBase):
class FeedparserParser(Parser):
def __call__(self, feed_content: str) -> Channel: def __call__(self, feed_content: str) -> Channel:
d = feedparser.parse(feed_content) d = feedparser.parse(feed_content)
channel_id: str = d['feed']['yt_channelid'] channel_id: str = d['feed']['yt_channelid']

70
ytrssil/protocols.py Normal file
View File

@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from types import TracebackType
from typing import Iterable, Optional, Protocol, Type
from ytrssil.config import Configuration
from ytrssil.datatypes import Channel, Video
class ChannelRepository(Protocol):
def __enter__(self) -> ChannelRepository: # pragma: no cover
...
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None: # pragma: no cover
...
def get_channel(self, channel_id: str) -> Channel: # pragma: no cover
...
def get_all_channels(self) -> list[Channel]: # pragma: no cover
...
def get_watched_videos(self) -> dict[str, Video]: # pragma: no cover
...
def get_new_videos(self) -> dict[str, Video]: # pragma: no cover
...
def create_channel(self, channel: Channel) -> None: # pragma: no cover
...
def add_new_video(
self,
channel: Channel,
video: Video,
) -> None: # pragma: no cover
...
def update_video(
self,
video: Video,
watch_timestamp: datetime,
) -> None: # pragma: no cover
...
class Fetcher(Protocol):
def fetch_feeds(
self,
urls: Iterable[str],
) -> Iterable[str]: # pragma: no cover
...
def fetch_new_videos(
self,
config: Optional[Configuration] = None,
parser: Optional[Parser] = None,
) -> tuple[dict[str, Channel], dict[str, Video]]: # pragma: no cover
...
class Parser(Protocol):
def __call__(self, feed_content: str) -> Channel: # pragma: no cover
...

View File

@ -1,7 +1,4 @@
from __future__ import annotations
import os import os
from abc import ABCMeta, abstractmethod
from datetime import datetime from datetime import datetime
from sqlite3 import connect from sqlite3 import connect
from types import TracebackType from types import TracebackType
@ -13,56 +10,10 @@ from ytrssil.config import Configuration
from ytrssil.constants import config_dir from ytrssil.constants import config_dir
from ytrssil.datatypes import Channel, Video from ytrssil.datatypes import Channel, Video
from ytrssil.exceptions import ChannelNotFound from ytrssil.exceptions import ChannelNotFound
from ytrssil.protocols import ChannelRepository
class ChannelRepository(metaclass=ABCMeta): class SqliteChannelRepository:
@abstractmethod
def __enter__(self) -> ChannelRepository: # pragma: no cover
pass
@abstractmethod
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None: # pragma: no cover
pass
@abstractmethod
def get_channel(self, channel_id: str) -> Channel: # pragma: no cover
pass
@abstractmethod
def get_all_channels(self) -> list[Channel]: # pragma: no cover
pass
@abstractmethod
def get_watched_videos(self) -> dict[str, Video]: # pragma: no cover
pass
@abstractmethod
def create_channel(self, channel: Channel) -> None: # pragma: no cover
pass
@abstractmethod
def add_new_video(
self,
channel: Channel,
video: Video,
) -> None: # pragma: no cover
pass
@abstractmethod
def update_video(
self,
video: Video,
watch_timestamp: datetime,
) -> None: # pragma: no cover
pass
class SqliteChannelRepository(ChannelRepository):
def __init__(self) -> None: def __init__(self) -> None:
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
self.file_path: str = os.path.join(config_dir, 'channels.db') self.file_path: str = os.path.join(config_dir, 'channels.db')
@ -202,8 +153,26 @@ class SqliteChannelRepository(ChannelRepository):
cursor.execute( cursor.execute(
'SELECT video_id, videos.name, url, timestamp, ' 'SELECT video_id, videos.name, url, timestamp, '
'watch_timestamp, channels.channel_id, channels.name FROM videos ' 'watch_timestamp, channels.channel_id, channels.name FROM videos '
'LEFT JOIN channels ON channels.channel_id=videos.channel_id WHERE ' 'LEFT JOIN channels ON channels.channel_id=videos.channel_id '
'watch_timestamp IS NOT NULL ORDER BY timestamp' 'WHERE watch_timestamp IS NOT NULL ORDER BY timestamp'
)
return {
video_data[0]: self.video_data_to_video(
video_data=video_data,
channel_id=video_data[5],
channel_name=video_data[6],
)
for video_data in cursor
}
def get_new_videos(self) -> dict[str, Video]:
cursor = self.connection.cursor()
cursor.execute(
'SELECT video_id, videos.name, url, timestamp, '
'watch_timestamp, channels.channel_id, channels.name FROM videos '
'LEFT JOIN channels ON channels.channel_id=videos.channel_id '
'WHERE watch_timestamp IS NULL ORDER BY timestamp'
) )
return { return {

View File

@ -1,18 +0,0 @@
from datetime import datetime
from typing import Optional, TypedDict
class VideoData(TypedDict):
video_id: str
name: str
url: str
channel_id: str
channel_name: str
timestamp: datetime
watch_timestamp: Optional[datetime]
class ChannelData(TypedDict):
channel_id: str
name: str
new_videos: dict