From 66dd4c5bd4065dc5009958052dcbc79e8f28ce4f Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Fri, 30 Jan 2015 23:11:11 -0800 Subject: [PATCH] Moved files around, tweaked content generators. Things are in a broken state atm. --- rtv/content.py | 266 ++++++++++++-------- rtv/main.py | 15 +- rtv/{viewer.py => page.py} | 2 +- rtv/{submission_viewer.py => submission.py} | 13 +- rtv/{subreddit_viewer.py => subreddit.py} | 22 +- rtv/utils.py | 143 ++++------- 6 files changed, 230 insertions(+), 231 deletions(-) rename rtv/{viewer.py => page.py} (99%) rename rtv/{submission_viewer.py => submission.py} (92%) rename rtv/{subreddit_viewer.py => subreddit.py} (83%) diff --git a/rtv/content.py b/rtv/content.py index 819fade..c704c6c 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -1,9 +1,79 @@ import textwrap +from datetime import datetime +from contextlib import contextmanager + import praw -from utils import clean, strip_subreddit_url, humanize_timestamp +def clean(unicode_string): + """ + Convert unicode string into ascii-safe characters. + """ -class ContainerBase(object): + return unicode_string.encode('ascii', 'replace').replace('\\', '') + + +def strip_subreddit_url(permalink): + """ + Grab the subreddit from the permalink because submission.subreddit.url + makes a seperate call to the API. + """ + + subreddit = clean(permalink).split('/')[4] + return '/r/{}'.format(subreddit) + + +def humanize_timestamp(utc_timestamp, verbose=False): + """ + Convert a utc timestamp into a human readable relative-time. + """ + + timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) + + seconds = int(timedelta.total_seconds()) + if seconds < 60: + return 'moments ago' if verbose else '0min' + minutes = seconds / 60 + if minutes < 60: + return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes) + hours = minutes / 60 + if hours < 24: + return ('%d hours ago' % hours) if verbose else ('%dhr' % hours) + days = hours / 24 + if days < 30: + return ('%d days ago' % days) if verbose else ('%dday' % days) + months = days / 30.4 + if months < 12: + return ('%d months ago' % months) if verbose else ('%dmonth' % months) + years = months / 12 + return ('%d years ago' % years) if verbose else ('%dyr' % years) + +@contextmanager +def default_loader(self): + yield + +class BaseContent(object): + + @staticmethod + def flatten_comments(comments, root_level=0): + """ + Flatten a PRAW comment tree while preserving the nested level of each + comment via the `nested_level` attribute. + """ + + stack = comments[:] + for item in stack: + item.nested_level = root_level + + retval = [] + while stack: + item = stack.pop(0) + nested = getattr(item, 'replies', None) + if nested: + for n in nested: + n.nested_level = item.nested_level + 1 + stack[0:0] = nested + retval.append(item) + return retval @staticmethod def strip_praw_comment(comment): @@ -55,97 +125,43 @@ class ContainerBase(object): return data -class SubredditContainer(ContainerBase): + +class SubmissionContent(BaseContent): """ - Grabs a subreddit from PRAW and lazily stores submissions to an internal + Grab a submission from PRAW and lazily store comments to an internal list for repeat access. """ - def __init__(self, reddit_session, subreddit='front'): - """ - params: - session (praw.Reddit): Active reddit connection - subreddit (str): Subreddit to connect to, defaults to front page. - """ - self.r = reddit_session - self.r.config.decode_html_entities = True + def __init__( + self, + submission, + loader=default_loader(), + indent_size=2, + max_indent_level=4): - self.subreddit = None - self.display_name = None - self._submissions = None - self._submission_data = None - - self.reset(subreddit=subreddit) - - def get(self, index, n_cols=70): - """ - Grab the `i`th submission, with the title field formatted to fit inside - of a window of width `n` - """ - - if index < 0: - raise IndexError - - while index >= len(self._submission_data): - - try: - submission = self._submissions.next() - except StopIteration: - raise IndexError - else: - data = self.strip_praw_submission(submission) - self._submission_data.append(data) - - # Modifies the original dict, faster than copying - data = self._submission_data[index] - data['split_title'] = textwrap.wrap(data['title'], width=n_cols) - data['n_rows'] = len(data['split_title']) + 3 - data['offset'] = 0 - - return data - - def iterate(self, index, step, n_cols): - - while True: - yield self.get(index, n_cols) - index += step - - def reset(self, subreddit=None): - """ - Clear the internal list and fetch a new submission generator. Switch - to the specified subreddit if one is given. - """ - - # Fall back to the internal value if nothing is passed in. - self.subreddit = subreddit or self.subreddit - self._submission_data = [] - - if self.subreddit == 'front': - self._submissions = self.r.get_front_page(limit=None) - self.display_name = 'Front Page' - else: - sub = self.r.get_subreddit(self.subreddit) - self._submissions = sub.get_hot() - self.display_name = '/r/' + self.subreddit - - -class SubmissionContainer(ContainerBase): - """ - Grabs a submission from PRAW and lazily store comments to an internal - list for repeat access and to allow expanding and hiding comments. - """ - - def __init__(self, submission, indent_size=2, max_indent_level=4): - - self.submission = submission self.indent_size = indent_size self.max_indent_level = max_indent_level + self._loader = loader - self.display_name = None - self._submission_data = None - self._comment_data = None + self._submission_data = self.strip_praw_submission(submission) + self.name = self._submission_data['permalink'] + with loader: + comments = self.flatten_comments(submission.comments) + self._comment_data = [self.strip_praw_comment(c) for c in comments] - self.reset() + @classmethod + def from_url( + cls, + r, + url, + loader=default_loader(), + indent_size=2, + max_indent_level=4): + + with loader: + submission = r.get_submission(url) + + return cls(submission, loader, indent_size, max_indent_level) def get(self, index, n_cols=70): """ @@ -211,7 +227,8 @@ class SubmissionContainer(ContainerBase): elif data['type'] == 'MoreComments': - comments = data['object'].comments() + with self._loader: + comments = data['object'].comments() comments = self.flatten_comments(comments, root_level=data['level']) comment_data = [self.strip_praw_comment(c) for c in comments] self._comment_data[index:index+1] = comment_data @@ -225,36 +242,63 @@ class SubmissionContainer(ContainerBase): yield self.get(index, n_cols=n_cols) index += step - def reset(self): + +class SubredditContent(BaseContent): + """ + Grabs a subreddit from PRAW and lazily stores submissions to an internal + list for repeat access. + """ + + def __init__(self, name, submission_generator, loader=default_loader()): + + self.name = name + self._loader = loader + self._submissions = submission_generator + self._submission_data = [] + + @classmethod + def from_name(cls, r, name, loader=default_loader()): + + if name == 'front': + return cls('Front Page', r.get_front_page(limit=None), loader) + + if name == 'all': + sub = r.get_subreddit(name) + else: + sub = r.get_subreddit(name, fetch=True) + + return cls('/r/'+sub.display_name, sub.get_hot(limit=None), loader) + + def get(self, index, n_cols=70): """ - Fetch changes to the submission from PRAW and clear the internal list. + Grab the `i`th submission, with the title field formatted to fit inside + of a window of width `n` """ - self.submission.refresh() - self._submission_data = self.strip_praw_submission(self.submission) + if index < 0: + raise IndexError - self.display_name = self._submission_data['permalink'] - comments = self.flatten_comments(self.submission.comments) - self._comment_data = [self.strip_praw_comment(c) for c in comments] + while index >= len(self._submission_data): - @staticmethod - def flatten_comments(comments, root_level=0): - """ - Flatten a PRAW comment tree while preserving the nested level of each - comment via the `nested_level` attribute. - """ + try: + with self._loader: + submission = self._submissions.next() + except StopIteration: + raise IndexError + else: + data = self.strip_praw_submission(submission) + self._submission_data.append(data) - stack = comments[:] - for item in stack: - item.nested_level = root_level + # Modifies the original dict, faster than copying + data = self._submission_data[index] + data['split_title'] = textwrap.wrap(data['title'], width=n_cols) + data['n_rows'] = len(data['split_title']) + 3 + data['offset'] = 0 - retval = [] - while stack: - item = stack.pop(0) - nested = getattr(item, 'replies', None) - if nested: - for n in nested: - n.nested_level = item.nested_level + 1 - stack[0:0] = nested - retval.append(item) - return retval \ No newline at end of file + return data + + def iterate(self, index, step, n_cols): + + while True: + yield self.get(index, n_cols) + index += step \ No newline at end of file diff --git a/rtv/main.py b/rtv/main.py index 6c296d1..ac26b97 100644 --- a/rtv/main.py +++ b/rtv/main.py @@ -1,8 +1,8 @@ import argparse import praw -from utils import curses_session -from content import SubredditContainer -from subreddit_viewer import SubredditViewer +from utils import curses_session, LoadScreen +from content import SubredditContent +from subreddit import SubredditPage parser = argparse.ArgumentParser(description='Reddit Terminal Viewer') parser.add_argument('-s', dest='subreddit', default='front', help='subreddit name') @@ -14,14 +14,17 @@ group.add_argument('-p', dest='password', help='reddit password') def main(args): r = praw.Reddit(user_agent='reddit terminal viewer v0.0') + r.config.decode_html_entities = True + if args.username and args.password: r.login(args.username, args.password) with curses_session() as stdscr: - content = SubredditContainer(r, subreddit=args.subreddit) - viewer = SubredditViewer(stdscr, content) - viewer.loop() + loader = LoadScreen(stdscr) + content = SubredditContent(r, subreddit=args.subreddit, loader=loader) + page = SubredditPage(stdscr, content) + page.loop() if __name__ == '__main__': diff --git a/rtv/viewer.py b/rtv/page.py similarity index 99% rename from rtv/viewer.py rename to rtv/page.py index 9ef9b48..47d7db0 100644 --- a/rtv/viewer.py +++ b/rtv/page.py @@ -70,7 +70,7 @@ class Navigator(object): return True -class BaseViewer(object): +class BasePage(object): """ Base terminal viewer incorperates a cursor to navigate content """ diff --git a/rtv/submission_viewer.py b/rtv/submission.py similarity index 92% rename from rtv/submission_viewer.py rename to rtv/submission.py index a6e8c15..4864956 100644 --- a/rtv/submission_viewer.py +++ b/rtv/submission.py @@ -2,16 +2,15 @@ import praw import curses import sys -from content import SubmissionContainer, SubredditContainer -from utils import curses_session, LoadScreen -from viewer import BaseViewer +from page import BasePage +from utils import curses_session -class SubmissionViewer(BaseViewer): +class SubmissionPage(BasePage): def __init__(self, stdscr, content): page_index, cursor_index = -1, 1 - super(SubmissionViewer, self).__init__( + super(SubmissionPage, self).__init__( stdscr, content, page_index=page_index, cursor_index=cursor_index) @@ -58,9 +57,7 @@ class SubmissionViewer(BaseViewer): def refresh_content(self): - self.add_loading() - with LoadScreen(self.stdscr): - self.content.reset() + self.content.reset() self.stdscr.clear() self.draw() diff --git a/rtv/subreddit_viewer.py b/rtv/subreddit.py similarity index 83% rename from rtv/subreddit_viewer.py rename to rtv/subreddit.py index 70c7e18..e5ea044 100644 --- a/rtv/subreddit_viewer.py +++ b/rtv/subreddit.py @@ -3,12 +3,12 @@ import textwrap import curses import sys -from content import SubredditContainer, SubmissionContainer -from submission_viewer import SubmissionViewer -from viewer import BaseViewer -from utils import curses_session, text_input, LoadScreen +from page import BasePage +from submission import SubmissionPage +from content import SubmissionContent +from utils import curses_session, text_input -class SubredditViewer(BaseViewer): +class SubredditPage(BasePage): def loop(self): @@ -51,8 +51,7 @@ class SubredditViewer(BaseViewer): self.nav.page_index, self.nav.cursor_index = 0, 0 self.nav.inverted = False - with LoadScreen(self.stdscr): - self.content.reset(subreddit=subreddit) + self.content.reset(subreddit=subreddit) self.stdscr.clear() self.draw() @@ -73,11 +72,10 @@ class SubredditViewer(BaseViewer): def open_submission(self): "Select the current submission to view posts" - with LoadScreen(self.stdscr): - submission = self.content.get(self.nav.absolute_index)['object'] - content = SubmissionContainer(submission) - viewer = SubmissionViewer(self.stdscr, content) - viewer.loop() + submission = self.content.get(self.nav.absolute_index)['object'] + content = SubmissionContent(submission, loader=self.content.loader) + page = SubmissionPage(self.stdscr, content) + page.loop() def draw(self): diff --git a/rtv/utils.py b/rtv/utils.py index 1211bab..86d2cfe 100644 --- a/rtv/utils.py +++ b/rtv/utils.py @@ -3,12 +3,42 @@ import curses import time import threading from curses import textpad -from datetime import datetime, timedelta from contextlib import contextmanager class EscapePressed(Exception): pass +def text_input(window): + """ + Transform a window into a text box that will accept user input and loop + until an escape sequence is entered. + + If enter is pressed, return the input text as a string. + If escape is pressed, return None. + """ + + window.clear() + curses.curs_set(2) + textbox = textpad.Textbox(window, insert_mode=True) + + def validate(ch): + "Filters characters for special key sequences" + if ch == 27: + raise EscapePressed + return ch + + # Wrapping in an exception block so that we can distinguish when the user + # hits the return character from when the user tries to back out of the + # input. + try: + out = textbox.edit(validate=validate) + out = out.strip() + except EscapePressed: + out = None + + curses.curs_set(0) + return out + class LoadScreen(object): @@ -26,18 +56,14 @@ class LoadScreen(object): self.interval=interval self.trail = trail - message_len = len(self.message) + len(self.trail) - n_rows, n_cols = stdscr.getmaxyx() - s_row = (n_rows - 2) / 2 - s_col = (n_cols - message_len - 1) / 2 - self.window = stdscr.derwin(3, message_len+2, s_row, s_col) - - self._animator = threading.Thread(target=self.animate) - self._animator.daemon = True + self._animator = None self._is_running = None def __enter__(self): + self._animator = threading.Thread(target=self.animate) + self._animator.daemon = True + self._is_running = True self._animator.start() @@ -46,106 +72,37 @@ class LoadScreen(object): self._is_running = False self._animator.join() - del self.window - self._stdscr.refresh() - def animate(self): - # Delay before popping up the animation to avoid flashing - # the screen if the load time is effectively zero + # Delay before starting animation to avoid wasting resources if the + # wait time is very short start = time.time() while (time.time() - start) < self.delay: if not self._is_running: return + message_len = len(self.message) + len(self.trail) + n_rows, n_cols = self._stdscr.getmaxyx() + s_row = (n_rows - 2) / 2 + s_col = (n_cols - message_len - 1) / 2 + window = self._stdscr.derwin(3, message_len+2, s_row, s_col) + while True: for i in xrange(len(self.trail)+1): if not self._is_running: + # TODO: figure out why this isn't removing the screen + del window + self._stdscr.refresh() return - self.window.erase() - self.window.border() - self.window.addstr(1, 1, self.message + self.trail[:i]) - self.window.refresh() + window.erase() + window.border() + window.addstr(1, 1, self.message + self.trail[:i]) + window.refresh() time.sleep(self.interval) -def clean(unicode_string): - """ - Convert unicode string into ascii-safe characters. - """ - - return unicode_string.encode('ascii', 'replace').replace('\\', '') - - -def strip_subreddit_url(permalink): - """ - Grab the subreddit from the permalink because submission.subreddit.url - makes a seperate call to the API. - """ - - subreddit = clean(permalink).split('/')[4] - return '/r/{}'.format(subreddit) - - -def humanize_timestamp(utc_timestamp, verbose=False): - """ - Convert a utc timestamp into a human readable relative-time. - """ - - timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) - - seconds = int(timedelta.total_seconds()) - if seconds < 60: - return 'moments ago' if verbose else '0min' - minutes = seconds / 60 - if minutes < 60: - return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes) - hours = minutes / 60 - if hours < 24: - return ('%d hours ago' % hours) if verbose else ('%dhr' % hours) - days = hours / 24 - if days < 30: - return ('%d days ago' % days) if verbose else ('%dday' % days) - months = days / 30.4 - if months < 12: - return ('%d months ago' % months) if verbose else ('%dmonth' % months) - years = months / 12 - return ('%d years ago' % years) if verbose else ('%dyr' % years) - - -def validate(ch): - "Filters characters for special key sequences" - if ch == 27: - raise EscapePressed - return ch - -def text_input(window): - """ - Transform a window into a text box that will accept user input and loop - until an escape sequence is entered. - - If enter is pressed, return the input text as a string. - If escape is pressed, return None. - """ - - window.clear() - curses.curs_set(2) - textbox = textpad.Textbox(window, insert_mode=True) - - # Wrapping in an exception block so that we can distinguish when the user - # hits the return character from when the user tries to back out of the - # input. - try: - out = textbox.edit(validate=validate) - out = out.strip() - except EscapePressed: - out = None - - curses.curs_set(0) - return out - @contextmanager def curses_session():