diff --git a/app/config.py b/app/config.py index 1ec3c0d..7ab1dea 100644 --- a/app/config.py +++ b/app/config.py @@ -10,7 +10,6 @@ class Config(object): DISPLAY_SQL = False ERRORS_FROM = ['noreply@midnighthax.com'] ERRORS_TO = ['kae@midnighthax.com'] - FOLLOWED_COLOUR = '#8ae234' LOG_LEVEL_STDERR = logging.ERROR LOG_LEVEL_SYSLOG = logging.DEBUG LOG_NAME = "urma" @@ -19,6 +18,6 @@ class Config(object): MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com" MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None - MAX_CONTENT_LENGTH = 4096 - MAX_POSTS_TO_FETCH = 2000 - NORMAL_COLOUR = "#f6f5f4" + MAX_DAYS_TO_FETCH = 10 + POINTS_BOOSTED = 1 + POINTS_FAVOURITED = 1 diff --git a/app/helpers.py b/app/helpers.py index 409ce60..5166a44 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -10,31 +10,6 @@ from log import log from typing import Any, List -from PyQt5.QtWidgets import QMessageBox - - -def ask_yes_no(title: str, question: str) -> bool: - """Ask question; return True for yes, False for no""" - - button_reply = QMessageBox.question(None, title, question) - - return button_reply == QMessageBox.Yes - - -def format_display_name(account) -> str: - """ - Format account display name according to whether we follow that account - or not. - """ - - username = account.display_name - if account.followed: - colour = Config.FOLLOWED_COLOUR - else: - colour = Config.NORMAL_COLOUR - - return '' + username + '' - def index_ojects_by_parameter(object_list: List, param: Any): """ @@ -74,15 +49,3 @@ def send_mail(to_addr, from_addr, subj, body): print(e) finally: s.quit() - - -def show_OK(title: str, msg: str) -> None: - """Display a message to user""" - - QMessageBox.information(None, title, msg, buttons=QMessageBox.Ok) - - -def show_warning(title: str, msg: str) -> None: - """Display a warning to user""" - - QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel) diff --git a/app/models.py b/app/models.py index 3486397..d0fec68 100644 --- a/app/models.py +++ b/app/models.py @@ -44,7 +44,6 @@ class Accounts(Base): bot = Column(Boolean, index=False, nullable=False, default=False) url = Column(String(256), index=False) followed = Column(Boolean, index=False, nullable=False, default=False) - posts = relationship("Posts", back_populates="account") def __repr__(self) -> str: return ( @@ -57,7 +56,7 @@ class Accounts(Base): self.account_id = account_id session.add(self) - session.commit() + session.flush() @classmethod def get_followed(cls, session: Session) -> List["Accounts"]: @@ -93,54 +92,6 @@ class Accounts(Base): return rec -class Attachments(Base): - __tablename__ = 'attachments' - - id = Column(Integer, primary_key=True, autoincrement=True) - media_id = Column(String(32), index=True, nullable=False) - url = Column(String(256), index=False) - preview_url = Column(String(256), index=False) - description = Column(String(2048), index=False) - post_id = Column(Integer, ForeignKey("posts.id")) - type = Column(String(256), index=False) - - def __repr__(self) -> str: - return ( - f"" - ) - - def __init__(self, session: Session, media_id: str, post_id: int) -> None: - - self.media_id = media_id - self.post_id = post_id - - session.add(self) - session.commit() - - @classmethod - def get_or_create(cls, session: Session, media_id: str, - post_id: int) -> "Attachments": - """ - Return any existing Attachment with this id or create a new one - """ - - try: - rec = ( - session.execute( - select(cls) - .where( - cls.media_id == media_id, - cls.post_id == post_id - ) - ).scalar_one() - ) - except NoResultFound: - rec = Attachments(session, media_id, post_id) - - return rec - - class Hashtags(Base): __tablename__ = 'hashtags' @@ -164,7 +115,21 @@ class Hashtags(Base): self.url = url session.add(self) - session.commit() + session.flush() + + @classmethod + def get_all(cls, session: Session) -> List["Hashtags"]: + """ + Return a list of all hashtags + """ + + records = ( + session.execute( + select(cls) + ).scalars().all() + ) + + return records @classmethod def get_followed(cls, session: Session) -> List["Hashtags"]: @@ -206,105 +171,32 @@ class Posts(Base): id = Column(Integer, primary_key=True, autoincrement=True) post_id = Column(String(32), index=True, nullable=False) + + account_id = Column(Integer, ForeignKey('accounts.id'), nullable=True) + account = relationship("Accounts", foreign_keys=[account_id]) + + boosted_by_id = Column(Integer, ForeignKey('accounts.id'), nullable=True) + boosted_by = relationship("Accounts", foreign_keys=[boosted_by_id]) + created_at = Column(DateTime, index=True, default=None) uri = Column(String(256), index=False) - url = Column(String(256), index=False) - content = Column(String(Config.MAX_CONTENT_LENGTH), index=False, - default="") - account_id = Column(Integer, ForeignKey('accounts.id'), nullable=True) - account = relationship("Accounts", back_populates="posts") - - reblogged_by_post = relationship("Posts") - boosted_post_id = Column(Integer, ForeignKey("posts.id")) - - media_attachments = relationship("Attachments") posts_to_tags = relationship("PostTags", back_populates="post") hashtags = association_proxy("posts_to_tags", "hashtag") - rating = Column(Integer, index=True, default=None) + favourited = Column(Boolean, index=True, nullable=False, default=False) + boosted = Column(Boolean, index=True, nullable=False, default=False) + bookmarked = Column(Boolean, index=True, nullable=False, default=False) def __repr__(self) -> str: - return f"" + return f"" def __init__(self, session: Session, post_id) -> None: self.post_id = post_id session.add(self) - session.commit() - - @classmethod - def get_unrated_after(cls, session: Session, - post_id: int) -> Optional["Posts"]: - """ - Return earliest unrated Posts object after passed post_id, or None - if there isn't one. - """ - - return ( - session.scalars( - select(cls) - .where( - (cls.rating.is_(None)), - (cls.post_id > post_id) - ) - .order_by(cls.post_id.asc()) - .limit(1) - ).first() - ) - - @classmethod - def get_unrated_before(cls, session: Session, - post_id: int) -> Optional["Posts"]: - """ - Return latest unrated Posts object before passed post_id, or None - if there isn't one. - """ - - return ( - session.scalars( - select(cls) - .where( - (cls.rating.is_(None)), - (cls.post_id < post_id) - ) - .order_by(cls.post_id.desc()) - .limit(1) - ).first() - ) - - @classmethod - def get_unrated_newest(cls, session: Session) -> Optional["Posts"]: - """ - Return most recent Posts object that has not been rated and which - is not a boosted post, or None if there isn't one. - """ - - return ( - session.scalars( - select(cls) - .where(cls.rating.is_(None)) - .order_by(cls.post_id.desc()) - .limit(1) - ).first() - ) - - @classmethod - def get_unrated_oldest(cls, session: Session) -> Optional["Posts"]: - """ - Return oldest Posts object that has not been rated and which - is not a boosted post, or None if there isn't one. - """ - - return ( - session.scalars( - select(cls) - .where(cls.rating.is_(None)) - .order_by(cls.post_id.asc()) - .limit(1) - ).first() - ) + session.flush() @classmethod def get_by_post_id(cls, session: Session, post_id: str) -> "Posts": diff --git a/app/urma.py b/app/urma.py index 550b9c3..3d99906 100755 --- a/app/urma.py +++ b/app/urma.py @@ -10,42 +10,29 @@ import stackprinter import sys from config import Config -from dbconfig import engine, Session, scoped_session +from dbconfig import ( + engine, + Session, + scoped_session, +) from helpers import ( - format_display_name, index_ojects_by_parameter, send_mail, ) -from helpers import show_OK from log import log from mastodon import Mastodon from models import ( Accounts, - Attachments, Base, Hashtags, Posts, PostTags, ) -from typing import List, Optional - -from PyQt5.QtCore import Qt -from PyQt5.QtGui import ( - QImage, - QPixmap, -) -from PyQt5.QtWidgets import ( - QApplication, - QLabel, - QMainWindow, - QPushButton, -) - -from ui.main_window_ui import Ui_MainWindow # type: ignore - -TESTDATA = "/home/kae/git/urma/hometl.pickle" +from typing import List, Optional, Union +# TESTDATA = "/home/kae/git/urma/hometl.pickle" +# # Mastodon.create_app( # 'urma', # api_base_url='mastodon.org.uk', @@ -77,6 +64,63 @@ class MastodonAPI: return self.mastodon.fetch_remaining(page1) + def get_bookmarked(self, since: int) -> List[dict]: + """ + Return posts bookmarked since id 'since' + """ + + results = [] + data = self.mastodon.bookmarks() + while data: + # Add in new data + results.extend(data) + # Have we reached minimum id? + if min([a.id for a in data]) < since: + break + # Get more data + data = self.mastodon.fetch_next(data) + + return results + + def get_boosted(self, since: int) -> List[dict]: + """ + Return posts boosted since id 'since' + """ + + results = [] + data = self.mastodon.account_statuses(self.me.id) + while data: + for datum in data: + # Have we reached minimum id? + if datum.id < since: + break + # Is this our post that we boosted? + if datum.account.id == self.me.id and datum.reblog: + # Add in new data + results.append(datum) + # Get more data + data = self.mastodon.fetch_next(data) + + return results + + def get_favourited(self, since: Union[int, List[dict]]) -> List[dict]: + """ + Return posts favourite since id 'since' + """ + + results = [] + data = self.mastodon.favourites() + while data: + # Add in new data + results.extend(data) + # Have we reached minimum id? + if min([a.id for a in data]) < since: + break + # Get more data + data = self.mastodon.fetch_next(data) + + return results + def get_hashtag_following(self): """ Return a list of hashtag_dicts that we are following @@ -86,391 +130,204 @@ class MastodonAPI: return self.mastodon.fetch_remaining(page1) - -class Window(QMainWindow, Ui_MainWindow): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setupUi(self) - - self.mastapi = MastodonAPI(Config.ACCESS_TOKEN) - self.update_db() - - self.current_post_id = None - self.next_post = self.next - - self.btnDislike.clicked.connect(self.dislike) - self.btnFirst.clicked.connect(self.first) - self.btnLast.clicked.connect(self.last) - self.btnLike.clicked.connect(self.like) - self.btnNext.clicked.connect(self.next) - self.btnPrev.clicked.connect(self.prev) - self.btnUnsure.clicked.connect(self.unsure) - - # Show first record - self.next() - - def display(self, session: Session, post: Posts) -> None: + def unbookmark(self, post_id: int) -> None: """ - Prepare to display post + Remove bookmark on passed post ID """ - boosted_by = None - if post.boosted_post_id: - boosted_by = post.account - while post.boosted_post_id: - post = session.get(Posts, post.boosted_post_id) - self._display(session, post, boosted_by) + log.debug(f"unbookmark({post_id=})") - def _display(self, session: Session, post: int, - boosted_by: Optional[Accounts] = None) -> None: - """ - Display passed post - """ + _ = self.mastodon.status_unbookmark(post_id) - if post is None: - return - # Boosted - if boosted_by: - self.txtBoosted.setText( - "Boosted by: " + format_display_name(boosted_by)) - self.txtBoosted.show() - else: - self.txtBoosted.hide() +def main() -> None: + """ + Main loop + """ - # Username - self.txtUsername.setText(format_display_name(post.account)) + mastapi = MastodonAPI(Config.ACCESS_TOKEN) - # Debug - self.lblDebug.setText(str(post.id)) + with Session() as session: + since = get_since_id(session) - # Account - self.lblAcct.setText(post.account.acct) + update_followed_accounts(session, mastapi) + update_followed_hashtags(session, mastapi) - # Hashtags - unfollowed_hashtags = [ - '#' + a.name for a in post.hashtags if not a.followed] - followed_hashtags = [ - '#' + a.name for a in post.hashtags if a.followed] - hashtag_text = ( - '' + - '
'.join(followed_hashtags) + - '

' + - '' + - '
'.join(unfollowed_hashtags) + - '
' - ) - self.txtHashtags.setText(hashtag_text) + favourited = mastapi.get_favourited(since) + process_favourited_posts(session, favourited) - # Post - self.txtPost.setHtml(post.content) + boosted = mastapi.get_boosted(since) + process_boosted_posts(session, boosted) - # Image - if post.media_attachments: - # TODO: handle multiple images, not just [0] - url_image = post.media_attachments[0].preview_url - pixmap = QPixmap() - pixmap.loadFromData(requests.get(url_image).content) - s_pixmap = pixmap.scaled(self.lblPicture.size(), - Qt.KeepAspectRatio) - self.lblPicture.show() - self.lblPicture.setPixmap(s_pixmap) - else: - self.lblPicture.hide() + bookmarked = mastapi.get_bookmarked(since) + process_bookmarked_posts(session, mastapi, bookmarked) - def dislike(self): - """ - Mark a post as rated negatively - """ - self.rate_post(rating=-1) +def get_since_id(session: Session) -> int: + """ + Return id to use as 'min_id' when fetching posts. - def first(self): - """ - actions - """ + We don't want to fetch anything older than MAX_DAYS_TO_FETCH. + """ - pass + # Build psuedo id for MAX_DAYS_TO_FETCH time ago + now = datetime.datetime.now() + max_days_ago_dt = now - datetime.timedelta(days=Config.MAX_DAYS_TO_FETCH) + # From mastodon.py package, use code from internals.py:__unpack_id + max_days_ago_id = (int(max_days_ago_dt.timestamp()) << 16) * 1000 + return max_days_ago_id - def last(self): - """ - actions - """ + # Get newest ID from database + newest_db_id = Posts.max_post_id(session) - pass + if not newest_db_id: + return max_days_ago_id + else: + return max(max_days_ago_id, newest_db_id) - def like(self): - """ - Mark a post as rated positively - """ - self.rate_post(rating=1) +def process_bookmarked_posts(session, mastapi, posts) -> None: + """ + Process bookmarked posts + """ - def next(self) -> None: - """ - Display next post. We work BACKWARDS through posts, starting with the - most recent, so "next" is actually one older. + for post in posts: + record = _process_post(session, post) + # Posts that are favourited and bookmarked are genuine bookmark + # posts: ignore. + if record.favourited: + continue + record.bookmarked = True + return + # TODO: mastapi.unbookmark(int(post.id)) - If we are called with self.current_post_id set to None, retrieve and - display newest unrated post. - """ - # Remember whether we're going forward or backwards through - # posts - self.next_post = self.next +def process_boosted_posts(session, posts) -> None: + """ + Process boosted posts + """ - # Get post to display - with Session() as session: - if self.current_post_id is None: - post = Posts.get_unrated_newest(session) - else: - post = Posts.get_unrated_before(session, self.current_post_id) - # Don't process posts that are boosted as they will be - # processed by the boosting post - while post and post.reblogged_by_post: - post = Posts.get_unrated_before(session, post.post_id) - if not post: - self.current_post_id = None - show_OK("All done", "No more posts to process") - return + for post in posts: + record = _process_post(session, post) + record.boosted = True - self.current_post_id = post.post_id - self.display(session, post) - def prev(self): - """ - Display previous post. We work BACKWARDS through posts so - "previous" is actually one newer. +def process_favourited_posts(session, posts) -> None: + """ + Process favourited posts + """ - If we are called with self.current_post_id set to None, retrieve and - display oldest unrated post. - """ + for post in posts: + record = _process_post(session, post) + record.favourited = True - # Remember whether we're going forward or backwards through - # posts - self.next_post = self.prev - # Get post to display, but don't process posts that are boosted - # as they will be processed by the boosting post - with Session() as session: - if self.current_post_id is None: - post = Posts.get_unrated_oldest(session) - else: - post = Posts.get_unrated_after(session, self.current_post_id) - # Don't process posts that are boosted as they will be - # processed by the boosting post - while post and post.reblogged_by_post: - post = Posts.get_unrated_after(session, post.post_id) - if not post: - self.current_post_id = None - show_OK("All done", "No more posts to process") - return - - self.current_post_id = post.post_id - self.display(session, post) - - def rate_post(self, rating: int) -> None: - """ - Add rating to current post - """ - - with Session() as session: - post = Posts.get_by_post_id(session, self.current_post_id) - post.rating = rating - self.next_post() - - def unsure(self): - """ - Mark a post as rated neutrally - """ - - self.rate_post(rating=0) - - def update_db(self) -> None: - """ - Update database from Mastodon - - Save a copy of downloaded data for debugging - """ - - with Session() as session: - minimum_post_id = Posts.max_post_id(session) - if not minimum_post_id: - minimum_post_id = "1" - posts_to_get = Config.MAX_POSTS_TO_FETCH - reached_minimum = False - hometl = [] - - while True: - - # Create a filename to save data - now = datetime.datetime.now() - seq = 0 - while True: - fname = ( - "testdata/" + - now.strftime("%Y-%m-%d_%H:%M:%S_") + - f"{seq:02d}.pickle" - ) - if not os.path.isfile(fname): - print(f"{fname=}") - break - seq += 1 - print(f"{seq=}") - - # Fetch data - if not hometl: - print("Fetching first data...") - hometl = self.mastapi.mastodon.timeline() - else: - print("Fetching next data...") - hometl = self.mastapi.mastodon.fetch_next(hometl) - print(f"Fetched additional {len(hometl)} posts") - with open(fname, "wb") as f: - pickle.dump(hometl, f) - - for post in hometl: - if str(post.id) <= minimum_post_id: - reached_minimum = True - break - print(f"Processing {post.id=}") - self._process_post(session, post) - - posts_to_get -= len(hometl) - print(f"{posts_to_get=}") - if posts_to_get <= 0 or reached_minimum or not hometl: - break - - def _process_post(self, session: Session, post) -> Posts: - """ - Add passsed post to database - """ - - log.debug(f"{post.id=} processing") - rec = Posts.get_or_create(session, str(post.id)) - if rec.account_id is not None: - # We already have this post - log.debug(f"{post.id=} already in db") - return rec - - # Create account record if needed - log.debug(f"{post.id=} processing {post.account.id=}") - account_rec = Accounts.get_or_create(session, str(post.account.id)) - if account_rec.username is None: - log.debug(f"{post.id=} populating new account {post.account.id=}") - account_rec.username = post.account.username - account_rec.acct = post.account.acct - account_rec.display_name = post.account.display_name - account_rec.bot = post.account.bot - account_rec.url = post.account.url - rec.account_id = account_rec.id - - # Create hashtag records as needed - for tag in post.tags: - log.debug(f"{post.id=} processing {tag.name=}") - hashtag = Hashtags.get_or_create(session, tag.name, tag.url) - rec.hashtags.append(hashtag) - - # Handle media - if post.media_attachments: - for media in post.media_attachments: - log.debug(f"{post.id=} processing {media.id=}") - media_rec = Attachments.get_or_create( - session, str(media.id), rec.id) - if not media_rec.type: - log.debug(f"{post.id=} {media.id=} new record") - media_rec.type = media.type - media_rec.url = media.url - media_rec.preview_url = media.preview_url - media_rec.description = media.description - else: - log.debug(f"{post.id=} {media.id=} already exists") - else: - log.debug(f"{post.id=} No media attachments") - - rec.account_id = account_rec.id - rec.created_at = post.created_at - rec.uri = post.uri - rec.url = post.url - rec.content = post.content[:Config.MAX_CONTENT_LENGTH] - log.debug(f"{post.id=} {post.content=}") - - if post.reblog: - log.debug(f"{post.id=} {post.reblog.id=}") - rec.boosted_post_id = self._process_post( - session, post.reblog).id - log.debug(f"{post.id=} {rec.boosted_post_id=}") +def _process_post(session: Session, post) -> Posts: + """ + Add passsed post to database + """ + log.debug(f"{post.id=} processing") + rec = Posts.get_or_create(session, str(post.id)) + if rec.account_id is not None: + # We already have this post + log.debug(f"{post.id=} already in db") return rec - def update_followed_accounts(self, session: Session) -> None: - """ - Retrieve list of followed accounts and update accounts - in database to match - """ + # Create account record if needed + log.debug(f"{post.id=} processing {post.account.id=}") + account_rec = Accounts.get_or_create(session, str(post.account.id)) + if account_rec.username is None: + log.debug(f"{post.id=} populating new account {post.account.id=}") + account_rec.username = post.account.username + account_rec.acct = post.account.acct + account_rec.display_name = post.account.display_name + account_rec.bot = post.account.bot + account_rec.url = post.account.url + rec.account_id = account_rec.id - mast_followed_accounts = self.mastapi.get_account_following() - mast_followed_accounts_d = index_ojects_by_parameter( - mast_followed_accounts, "username") + # Create hashtag records as needed + for tag in post.tags: + log.debug(f"{post.id=} processing {tag.name=}") + hashtag = Hashtags.get_or_create(session, tag.name, tag.url) + rec.hashtags.append(hashtag) - our_followed_accounts = Accounts.get_followed(session) - our_followed_accounts_d = index_ojects_by_parameter( - our_followed_accounts, "username") + rec.created_at = post.created_at + rec.uri = post.uri - # Add those we are missing - for username in ( - set(mast_followed_accounts_d.keys()) - - set(our_followed_accounts_d.keys()) - ): - account = Accounts.get_or_create( - session, str(mast_followed_accounts_d[username].id) - ) - account.followed = True + if post.reblog: + log.debug(f"{post.id=} {post.reblog.id=}") + boosted_rec = _process_post(session, post.reblog) + rec.boosted_by_id = boosted_rec.account_id - # Remove any we no longer follow - for username in ( - set(our_followed_accounts_d.keys()) - - set(mast_followed_accounts_d.keys()) - ): - account = Accounts.get_or_create( - session, str(our_followed_accounts_d[username].account_id) - ) - account.followed = False - - def update_followed_hashtags(self, session: Session) -> None: - """ - Retrieve list of followed hashtags and update hashtags - """ - - mast_followed_hashtags = self.mastapi.get_hashtag_following() - mast_followed_hashtags_d = index_ojects_by_parameter( - mast_followed_hashtags, "name") - - our_followed_hashtags = Hashtags.get_followed(session) - our_followed_hashtags_d = index_ojects_by_parameter( - our_followed_hashtags, "name") - - # Add those we are missing - for name in ( - set(mast_followed_hashtags_d.keys()) - - set(our_followed_hashtags_d.keys()) - ): - hashtag = Hashtags.get_or_create( - session, name, mast_followed_hashtags_d[name].url) - hashtag.followed = True - - # Remove any we no longer follow - for name in ( - set(our_followed_hashtags_d.keys()) - - set(mast_followed_hashtags_d.keys()) - ): - hashtag = hashtags.get_or_create( - session, name, our_followed_hashtags_d[username].name) - hashtag.followed = False + return rec -# class HoldingPot: -# def process_post(post): +def update_followed_accounts(session: Session, mastapi: MastodonAPI) -> None: + """ + Retrieve list of followed accounts and update accounts + in database to match + """ + + mast_followed_accounts = mastapi.get_account_following() + mast_followed_accounts_d = index_ojects_by_parameter( + mast_followed_accounts, "username") + + our_followed_accounts = Accounts.get_followed(session) + our_followed_accounts_d = index_ojects_by_parameter( + our_followed_accounts, "username") + + # Add those we are missing + for username in ( + set(mast_followed_accounts_d.keys()) - + set(our_followed_accounts_d.keys()) + ): + account = Accounts.get_or_create( + session, str(mast_followed_accounts_d[username].id) + ) + account.followed = True + + # Remove any we no longer follow + for username in ( + set(our_followed_accounts_d.keys()) - + set(mast_followed_accounts_d.keys()) + ): + account = Accounts.get_or_create( + session, str(our_followed_accounts_d[username].account_id) + ) + account.followed = False + + +def update_followed_hashtags(session: Session, mastapi: MastodonAPI) -> None: + """ + Retrieve list of followed hashtags and update hashtags + """ + + mast_followed_hashtags = mastapi.get_hashtag_following() + mast_followed_hashtags_d = index_ojects_by_parameter( + mast_followed_hashtags, "name") + + our_followed_hashtags = Hashtags.get_followed(session) + our_followed_hashtags_d = index_ojects_by_parameter( + our_followed_hashtags, "name") + + # Add those we are missing + for name in ( + set(mast_followed_hashtags_d.keys()) - + set(our_followed_hashtags_d.keys()) + ): + hashtag = Hashtags.get_or_create( + session, name, mast_followed_hashtags_d[name].url) + hashtag.followed = True + + # Remove any we no longer follow + for name in ( + set(our_followed_hashtags_d.keys()) - + set(mast_followed_hashtags_d.keys()) + ): + hashtag = hashtags.get_or_create( + session, name, our_followed_hashtags_d[username].name) + hashtag.followed = False if __name__ == "__main__": @@ -481,10 +338,7 @@ if __name__ == "__main__": try: Base.metadata.create_all(engine) - app = QApplication(sys.argv) - win = Window() - win.show() - sys.exit(app.exec()) + sys.exit(main()) except Exception as exc: if os.environ["URMA_ENV"] != "DEVELOPMENT": @@ -495,11 +349,3 @@ if __name__ == "__main__": print("\033[1;31;47mUnhandled exception starts") stackprinter.show(style="darkbg") print("Unhandled exception ends\033[1;37;40m") - -# # Data for development -# with open(TESTDATA, "rb") as inp: -# hometl = pickle.load(inp) -# -# with Session() as session: -# for post in hometl: -# process_post(post) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..72fa72f --- /dev/null +++ b/conftest.py @@ -0,0 +1,46 @@ +# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629 + +import pytest +import sys +sys.path.append("app") +import models # noqa E402 (import not at top of file) + +from sqlalchemy import create_engine # noqa E402 +from sqlalchemy.orm import ( # noqa E402 + scoped_session, + Session, + sessionmaker, +) + + +@pytest.fixture(scope="session") +def engine(): + return create_engine( + "mysql+mysqldb://dev_urma_testing:dev_urma_testing@" + "localhost/dev_urma_testing", + encoding='utf-8', + pool_pre_ping=True, + future=True + ) + + +@pytest.fixture(scope="session") +def setup_database(engine): + """ + Made scope=function (the default) to ensure any committed objects + are removed + """ + + from app.models import Base # noqa E402 + Base.metadata.create_all(engine) + # seed_database() + yield + Base.metadata.drop_all(engine) + + +@pytest.fixture +def session(setup_database, engine): + session = scoped_session(sessionmaker(autoflush=False, bind=engine)) + session.begin() + yield session + session.rollback() diff --git a/hometl.pickle b/hometl.pickle deleted file mode 100644 index 6a34b79..0000000 Binary files a/hometl.pickle and /dev/null differ diff --git a/poetry.lock b/poetry.lock index c4141ea..5753a34 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "alembic" -version = "1.9.1" +version = "1.9.2" description = "A database migration tool for SQLAlchemy." category = "main" optional = false @@ -35,14 +35,6 @@ six = "*" [package.extras] test = ["astroid", "pytest"] -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" version = "22.2.0" @@ -88,14 +80,11 @@ python-versions = ">=3.6" [[package]] name = "charset-normalizer" -version = "2.1.1" +version = "3.0.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=3.6.0" - -[package.extras] -unicode_backport = ["unicodedata2"] +python-versions = "*" [[package]] name = "colorama" @@ -113,6 +102,17 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "1.2.0" @@ -124,6 +124,18 @@ python-versions = "*" [package.extras] tests = ["asttokens", "pytest", "littleutils", "rich"] +[[package]] +name = "fancycompleter" +version = "0.9.1" +description = "colorful TAB completion for Python prompt" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pyreadline = {version = "*", markers = "platform_system == \"Windows\""} +pyrepl = ">=0.8.2" + [[package]] name = "greenlet" version = "2.0.1" @@ -144,6 +156,14 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "ipdb" version = "0.13.11" @@ -159,7 +179,7 @@ tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < [[package]] name = "ipython" -version = "8.7.0" +version = "8.8.0" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -264,14 +284,6 @@ python-versions = ">=3.5" [package.dependencies] traitlets = "*" -[[package]] -name = "more-itertools" -version = "9.0.0" -description = "More routines for operating on iterables, beyond itertools" -category = "dev" -optional = false -python-versions = ">=3.7" - [[package]] name = "mysqlclient" version = "2.1.1" @@ -282,7 +294,7 @@ python-versions = ">=3.5" [[package]] name = "packaging" -version = "22.0" +version = "23.0" description = "Core utilities for Python packages" category = "dev" optional = false @@ -300,6 +312,23 @@ python-versions = ">=3.6" qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] testing = ["docopt", "pytest (<6.0.0)"] +[[package]] +name = "pdbpp" +version = "0.10.3" +description = "pdb++, a drop-in replacement for pdb" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +fancycompleter = ">=0.8" +pygments = "*" +wmctrl = "*" + +[package.extras] +funcsigs = ["funcsigs"] +testing = ["funcsigs", "pytest"] + [[package]] name = "pexpect" version = "4.8.0" @@ -321,14 +350,15 @@ python-versions = "*" [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "prompt-toolkit" @@ -360,14 +390,6 @@ python-versions = "*" [package.extras] tests = ["pytest"] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - [[package]] name = "pygments" version = "2.14.0" @@ -380,65 +402,54 @@ python-versions = ">=3.6" plugins = ["importlib-metadata"] [[package]] -name = "pyqt5" -version = "5.15.7" -description = "Python bindings for the Qt cross platform application toolkit" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -PyQt5-Qt5 = ">=5.15.0" -PyQt5-sip = ">=12.11,<13" - -[[package]] -name = "pyqt5-qt5" -version = "5.15.2" -description = "The subset of a Qt installation needed by PyQt5." -category = "main" +name = "pyreadline" +version = "2.1" +description = "A python implmementation of GNU readline." +category = "dev" optional = false python-versions = "*" [[package]] -name = "pyqt5-sip" -version = "12.11.0" -description = "The sip module support for PyQt5" -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "pyqt5-stubs" -version = "5.15.6.0" -description = "PEP561 stub files for the PyQt5 framework" +name = "pyrepl" +version = "0.9.0" +description = "A library for building flexible command line interfaces" category = "dev" optional = false -python-versions = ">= 3.5" - -[package.extras] -dev = ["mypy (==0.930)", "pytest", "pytest-xvfb"] +python-versions = "*" [[package]] name = "pytest" -version = "5.4.3" +version = "7.2.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=17.4.0" +attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -more-itertools = ">=4.0.0" +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -checkqa-mypy = ["mypy (==v0.761)"] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-env" +version = "0.8.1" +description = "py.test plugin that allows you to add environment variables." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=7.1.3" + +[package.extras] +test = ["coverage (>=6.5)", "pytest-mock (>=3.10)"] [[package]] name = "python-dateutil" @@ -461,7 +472,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "requests" -version = "2.28.1" +version = "2.28.2" description = "Python HTTP for Humans." category = "main" optional = false @@ -469,7 +480,7 @@ python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset-normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" @@ -487,7 +498,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "sqlalchemy" -version = "1.4.45" +version = "1.4.46" description = "Database Abstraction Library" category = "main" optional = false @@ -551,7 +562,7 @@ python-versions = ">=3.7" [[package]] name = "traitlets" -version = "5.8.0" +version = "5.8.1" description = "Traitlets Python configuration system" category = "dev" optional = false @@ -563,7 +574,7 @@ test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] [[package]] name = "urllib3" -version = "1.26.13" +version = "1.26.14" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -582,16 +593,23 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "wmctrl" +version = "0.4" +description = "A tool to programmatically control windows inside X" +category = "dev" +optional = false +python-versions = "*" + [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "d3bb7fb1fedd37ef4df5b2b2c8097a57f268e54041c9aa28fe230824db299a5a" +content-hash = "6595ea3da23f353d916879141650fbb0b213e1393466925fa7be5e2e5bb6d5a3" [metadata.files] alembic = [] appnope = [] asttokens = [] -atomicwrites = [] attrs = [] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, @@ -605,9 +623,15 @@ decorator = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +exceptiongroup = [] executing = [] +fancycompleter = [ + {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, + {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, +] greenlet = [] idna = [] +iniconfig = [] ipdb = [] ipython = [] jedi = [] @@ -615,13 +639,16 @@ mako = [] markupsafe = [] "mastodon.py" = [] matplotlib-inline = [] -more-itertools = [] mysqlclient = [] packaging = [] parso = [ {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, ] +pdbpp = [ + {file = "pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1"}, + {file = "pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5"}, +] pexpect = [ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, @@ -631,8 +658,8 @@ pickleshare = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] prompt-toolkit = [] ptyprocess = [ @@ -643,24 +670,17 @@ pure-eval = [ {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, ] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] pygments = [] -pyqt5 = [] -pyqt5-qt5 = [ - {file = "PyQt5_Qt5-5.15.2-py3-none-macosx_10_13_intel.whl", hash = "sha256:76980cd3d7ae87e3c7a33bfebfaee84448fd650bad6840471d6cae199b56e154"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:1988f364ec8caf87a6ee5d5a3a5210d57539988bf8e84714c7d60972692e2f4a"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9cc7a768b1921f4b982ebc00a318ccb38578e44e45316c7a4a850e953e1dd327"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, +pyreadline = [ + {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, + {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, + {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, ] -pyqt5-sip = [] -pyqt5-stubs = [] -pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, +pyrepl = [ + {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, ] +pytest = [] +pytest-env = [] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -684,3 +704,6 @@ wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] +wmctrl = [ + {file = "wmctrl-0.4.tar.gz", hash = "sha256:66cbff72b0ca06a22ec3883ac3a4d7c41078bdae4fb7310f52951769b10e14e0"}, +] diff --git a/pyproject.toml b/pyproject.toml index 73e4e05..5779d10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,13 +11,11 @@ stackprinter = "^0.2.10" SQLAlchemy = "^1.4.45" mysqlclient = "^2.1.1" alembic = "^1.9.1" -PyQt5 = "^5.15.7" -PyQt5-sip = "^12.11.0" [tool.poetry.dev-dependencies] -pytest = "^5.2" ipdb = "^0.13.11" -PyQt5-stubs = "^5.15.6" +pytest-env = "^0.8.1" +pdbpp = "^0.10.3" [build-system] requires = ["poetry-core>=1.0.0"] @@ -27,6 +25,9 @@ build-backend = "poetry.core.masonry.api" mypy_path = "/home/kae/.cache/pypoetry/virtualenvs/urma-e3I_sS5U-py3.9:/home/kae/git/urma/app" plugins = "sqlalchemy.ext.mypy.plugin" +[tool.pytest.ini_options] +addopts = "-xls --pdb" + [tool.vulture] exclude = ["migrations"] paths = ["app"] diff --git a/tests/test_kaemasto.py b/tests/test_kaemasto.py deleted file mode 100644 index 9f918ec..0000000 --- a/tests/test_kaemasto.py +++ /dev/null @@ -1,5 +0,0 @@ -from urma import __version__ - - -def test_version(): - assert __version__ == '0.1.0' diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8b3e9d9 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,117 @@ +from app.models import ( + Accounts, + Hashtags, + Posts, +) + + +def test_accounts_creation(session): + """Account creation""" + + account_id = "109568725613662482" + + acct = Accounts(session, account_id) + assert acct + assert acct.account_id == account_id + assert acct.username is None + + +def test_create_or_add_account(session): + """Check we can retrieve existing account""" + + account_id = "109568725613662482" + + acct = Accounts.get_or_create(session, account_id) + acct2 = Accounts.get_or_create(session, account_id) + assert acct is acct2 + + +def test_get_followed_accounts(session): + """Test retrieval of followed accounts""" + + account1_id = "109568725613662482" + account2_id = "109568725613662483" + + acct1 = Accounts.get_or_create(session, account1_id) + acct2 = Accounts.get_or_create(session, account2_id) + + acct2.followed = True + session.flush() + + accts_followed = Accounts.get_followed(session) + assert acct1 not in accts_followed + assert acct2 in accts_followed + + +def test_hashtags_access(session): + """Test we can access hashtags table""" + + result = Hashtags.get_all(session) + assert result == [] + + +def test_create_hashtag(session): + """Create a hashtag""" + + h_name = "MyHashtag" + h_url = "https://example.com" + + ht = Hashtags.get_or_create(session, h_name, h_url) + assert ht + assert ht.name == h_name + assert ht.url == h_url + + +def test_create_or_add_hashtag(session): + """Check we can retrieve existing hashtag""" + + h_name = "MyHashtag" + h_url = "https://example.com" + + ht = Hashtags.get_or_create(session, h_name, h_url) + ht2 = Hashtags.get_or_create(session, h_name, h_url) + assert ht is ht2 + + +def test_get_followed_hashtags(session): + """Test retrieval of followed hashtags""" + + ht1 = "HashTagOne" + ht1_url = "https://one.example.com" + ht2 = "HashTagTwo" + ht2_url = "https://two.example.com" + + hashtag1 = Hashtags.get_or_create(session, ht1, ht1_url) + hashtag2 = Hashtags.get_or_create(session, ht2, ht2_url) + + hashtag2.followed = True + session.flush() + + hashtags_followed = Hashtags.get_followed(session) + assert hashtag1 not in hashtags_followed + assert hashtag2 in hashtags_followed + + +def test_create_posts(session): + """Test we can create posts""" + + post_id = "109666763623624320" + + post = Posts(session, post_id) + assert post.post_id == post_id + assert post.account_id is None + + +def test_get_by_post_id(session): + """Retrieve by post ID""" + + post1_id = "109666763623624320" + post2_id = "109666763623624321" + + post1 = Posts(session, post1_id) + post2 = Posts(session, post2_id) + + post = Posts.get_by_post_id(session, post1_id) + + assert post is post1 + assert post is not post2