Moved files around, tweaked content generators. Things are in a broken state atm.

This commit is contained in:
Michael Lazar
2015-01-30 23:11:11 -08:00
parent 6ef1abadc3
commit 66dd4c5bd4
6 changed files with 230 additions and 231 deletions

View File

@@ -1,9 +1,79 @@
import textwrap import textwrap
from datetime import datetime
from contextlib import contextmanager
import praw 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 @staticmethod
def strip_praw_comment(comment): def strip_praw_comment(comment):
@@ -55,97 +125,43 @@ class ContainerBase(object):
return data 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. list for repeat access.
""" """
def __init__(self, reddit_session, subreddit='front'): def __init__(
""" self,
params: submission,
session (praw.Reddit): Active reddit connection loader=default_loader(),
subreddit (str): Subreddit to connect to, defaults to front page. indent_size=2,
""" max_indent_level=4):
self.r = reddit_session
self.r.config.decode_html_entities = True
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.indent_size = indent_size
self.max_indent_level = max_indent_level self.max_indent_level = max_indent_level
self._loader = loader
self.display_name = None self._submission_data = self.strip_praw_submission(submission)
self._submission_data = None self.name = self._submission_data['permalink']
self._comment_data = None 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): def get(self, index, n_cols=70):
""" """
@@ -211,7 +227,8 @@ class SubmissionContainer(ContainerBase):
elif data['type'] == 'MoreComments': elif data['type'] == 'MoreComments':
comments = data['object'].comments() with self._loader:
comments = data['object'].comments()
comments = self.flatten_comments(comments, root_level=data['level']) comments = self.flatten_comments(comments, root_level=data['level'])
comment_data = [self.strip_praw_comment(c) for c in comments] comment_data = [self.strip_praw_comment(c) for c in comments]
self._comment_data[index:index+1] = comment_data self._comment_data[index:index+1] = comment_data
@@ -225,36 +242,63 @@ class SubmissionContainer(ContainerBase):
yield self.get(index, n_cols=n_cols) yield self.get(index, n_cols=n_cols)
index += step 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() if index < 0:
self._submission_data = self.strip_praw_submission(self.submission) raise IndexError
self.display_name = self._submission_data['permalink'] while index >= len(self._submission_data):
comments = self.flatten_comments(self.submission.comments)
self._comment_data = [self.strip_praw_comment(c) for c in comments]
@staticmethod try:
def flatten_comments(comments, root_level=0): with self._loader:
""" submission = self._submissions.next()
Flatten a PRAW comment tree while preserving the nested level of each except StopIteration:
comment via the `nested_level` attribute. raise IndexError
""" else:
data = self.strip_praw_submission(submission)
self._submission_data.append(data)
stack = comments[:] # Modifies the original dict, faster than copying
for item in stack: data = self._submission_data[index]
item.nested_level = root_level data['split_title'] = textwrap.wrap(data['title'], width=n_cols)
data['n_rows'] = len(data['split_title']) + 3
data['offset'] = 0
retval = [] return data
while stack:
item = stack.pop(0) def iterate(self, index, step, n_cols):
nested = getattr(item, 'replies', None)
if nested: while True:
for n in nested: yield self.get(index, n_cols)
n.nested_level = item.nested_level + 1 index += step
stack[0:0] = nested
retval.append(item)
return retval

View File

@@ -1,8 +1,8 @@
import argparse import argparse
import praw import praw
from utils import curses_session from utils import curses_session, LoadScreen
from content import SubredditContainer from content import SubredditContent
from subreddit_viewer import SubredditViewer from subreddit import SubredditPage
parser = argparse.ArgumentParser(description='Reddit Terminal Viewer') parser = argparse.ArgumentParser(description='Reddit Terminal Viewer')
parser.add_argument('-s', dest='subreddit', default='front', help='subreddit name') 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): def main(args):
r = praw.Reddit(user_agent='reddit terminal viewer v0.0') r = praw.Reddit(user_agent='reddit terminal viewer v0.0')
r.config.decode_html_entities = True
if args.username and args.password: if args.username and args.password:
r.login(args.username, args.password) r.login(args.username, args.password)
with curses_session() as stdscr: with curses_session() as stdscr:
content = SubredditContainer(r, subreddit=args.subreddit) loader = LoadScreen(stdscr)
viewer = SubredditViewer(stdscr, content) content = SubredditContent(r, subreddit=args.subreddit, loader=loader)
viewer.loop() page = SubredditPage(stdscr, content)
page.loop()
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -70,7 +70,7 @@ class Navigator(object):
return True return True
class BaseViewer(object): class BasePage(object):
""" """
Base terminal viewer incorperates a cursor to navigate content Base terminal viewer incorperates a cursor to navigate content
""" """

View File

@@ -2,16 +2,15 @@ import praw
import curses import curses
import sys import sys
from content import SubmissionContainer, SubredditContainer from page import BasePage
from utils import curses_session, LoadScreen from utils import curses_session
from viewer import BaseViewer
class SubmissionViewer(BaseViewer): class SubmissionPage(BasePage):
def __init__(self, stdscr, content): def __init__(self, stdscr, content):
page_index, cursor_index = -1, 1 page_index, cursor_index = -1, 1
super(SubmissionViewer, self).__init__( super(SubmissionPage, self).__init__(
stdscr, content, stdscr, content,
page_index=page_index, cursor_index=cursor_index) page_index=page_index, cursor_index=cursor_index)
@@ -58,9 +57,7 @@ class SubmissionViewer(BaseViewer):
def refresh_content(self): def refresh_content(self):
self.add_loading() self.content.reset()
with LoadScreen(self.stdscr):
self.content.reset()
self.stdscr.clear() self.stdscr.clear()
self.draw() self.draw()

View File

@@ -3,12 +3,12 @@ import textwrap
import curses import curses
import sys import sys
from content import SubredditContainer, SubmissionContainer from page import BasePage
from submission_viewer import SubmissionViewer from submission import SubmissionPage
from viewer import BaseViewer from content import SubmissionContent
from utils import curses_session, text_input, LoadScreen from utils import curses_session, text_input
class SubredditViewer(BaseViewer): class SubredditPage(BasePage):
def loop(self): def loop(self):
@@ -51,8 +51,7 @@ class SubredditViewer(BaseViewer):
self.nav.page_index, self.nav.cursor_index = 0, 0 self.nav.page_index, self.nav.cursor_index = 0, 0
self.nav.inverted = False self.nav.inverted = False
with LoadScreen(self.stdscr): self.content.reset(subreddit=subreddit)
self.content.reset(subreddit=subreddit)
self.stdscr.clear() self.stdscr.clear()
self.draw() self.draw()
@@ -73,11 +72,10 @@ class SubredditViewer(BaseViewer):
def open_submission(self): def open_submission(self):
"Select the current submission to view posts" "Select the current submission to view posts"
with LoadScreen(self.stdscr): submission = self.content.get(self.nav.absolute_index)['object']
submission = self.content.get(self.nav.absolute_index)['object'] content = SubmissionContent(submission, loader=self.content.loader)
content = SubmissionContainer(submission) page = SubmissionPage(self.stdscr, content)
viewer = SubmissionViewer(self.stdscr, content) page.loop()
viewer.loop()
def draw(self): def draw(self):

View File

@@ -3,12 +3,42 @@ import curses
import time import time
import threading import threading
from curses import textpad from curses import textpad
from datetime import datetime, timedelta
from contextlib import contextmanager from contextlib import contextmanager
class EscapePressed(Exception): class EscapePressed(Exception):
pass 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): class LoadScreen(object):
@@ -26,18 +56,14 @@ class LoadScreen(object):
self.interval=interval self.interval=interval
self.trail = trail self.trail = trail
message_len = len(self.message) + len(self.trail) self._animator = None
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._is_running = None self._is_running = None
def __enter__(self): def __enter__(self):
self._animator = threading.Thread(target=self.animate)
self._animator.daemon = True
self._is_running = True self._is_running = True
self._animator.start() self._animator.start()
@@ -46,106 +72,37 @@ class LoadScreen(object):
self._is_running = False self._is_running = False
self._animator.join() self._animator.join()
del self.window
self._stdscr.refresh()
def animate(self): def animate(self):
# Delay before popping up the animation to avoid flashing # Delay before starting animation to avoid wasting resources if the
# the screen if the load time is effectively zero # wait time is very short
start = time.time() start = time.time()
while (time.time() - start) < self.delay: while (time.time() - start) < self.delay:
if not self._is_running: if not self._is_running:
return 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: while True:
for i in xrange(len(self.trail)+1): for i in xrange(len(self.trail)+1):
if not self._is_running: if not self._is_running:
# TODO: figure out why this isn't removing the screen
del window
self._stdscr.refresh()
return return
self.window.erase() window.erase()
self.window.border() window.border()
self.window.addstr(1, 1, self.message + self.trail[:i]) window.addstr(1, 1, self.message + self.trail[:i])
self.window.refresh() window.refresh()
time.sleep(self.interval) 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 @contextmanager
def curses_session(): def curses_session():