From d8f0beec43dad70bcca85a2f995951097974d267 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 15 Jan 2023 20:50:57 +0000 Subject: [PATCH] Change to CLI; introduce tests --- app/config.py | 7 +- app/helpers.py | 37 --- app/models.py | 164 ++--------- app/urma.py | 606 +++++++++++++++-------------------------- conftest.py | 46 ++++ hometl.pickle | Bin 96252 -> 0 bytes poetry.lock | 223 ++++++++------- pyproject.toml | 9 +- tests/test_kaemasto.py | 5 - tests/test_models.py | 117 ++++++++ 10 files changed, 548 insertions(+), 666 deletions(-) create mode 100644 conftest.py delete mode 100644 hometl.pickle delete mode 100644 tests/test_kaemasto.py create mode 100644 tests/test_models.py 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 6a34b79bd1b3ae9fc8961f91599ad0230680baf4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96252 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 PNBBSW6Y8g`xzPUysggUA 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