first commit

This commit is contained in:
deepend 2024-03-08 16:51:35 +00:00
commit 26fe54f20c
90 changed files with 24232 additions and 0 deletions

5
.coveragerc Normal file
View File

@ -0,0 +1,5 @@
[run]
source = tvr
omit =
*/__main__.py
*/packages/praw/*

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
tests/cassettes/* binary

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.*
!.travis.yml
!.pylintrc
!.gitignore
!.gitattributes
!.coveragerc
*~
*.pyc
*.log
build
dist
rtv.egg-info
tests/refresh-token
venv/

378
.pylintrc Normal file
View File

@ -0,0 +1,378 @@
[MASTER]
# Specify a configuration file.
#rcfile=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=praw
# Pickle collected data for later comparisons.
persistent=yes
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
# Use multiple processes to speed up Pylint.
jobs=1
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code
extension-pkg-whitelist=
# Allow optimization of some AST trees. This will activate a peephole AST
# optimizer, which will apply various small optimizations. For instance, it can
# be used to obtain the result of joining multiple strings with the addition
# operator. Joining a lot of strings can lead to a maximum recursion error in
# Pylint and this flag can prevent that. It has one side effect, the resulting
# AST will be different than the one from reality.
optimize-ast=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
confidence=
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time. See also the "--disable" option for examples.
#enable=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=execfile-builtin,zip-builtin-not-iterating,range-builtin-not-iterating,hex-method,old-division,file-builtin,long-builtin,input-builtin,no-absolute-import,invalid-name,delslice-method,suppressed-message,coerce-builtin,buffer-builtin,import-star-module-level,round-builtin,old-ne-operator,apply-builtin,missing-final-newline,basestring-builtin,xrange-builtin,getslice-method,filter-builtin-not-iterating,map-builtin-not-iterating,raw_input-builtin,indexing-exception,dict-iter-method,metaclass-assignment,setslice-method,next-method-called,intern-builtin,using-cmp-argument,missing-docstring,oct-method,backtick,print-statement,reload-builtin,long-suffix,old-raise-syntax,unicode-builtin,nonzero-method,old-octal-literal,cmp-method,useless-suppression,dict-view-method,parameter-unpacking,unpacking-in-except,coerce-method,unichr-builtin,raising-string,cmp-builtin,reduce-builtin,standarderror-builtin,no-else-return,too-many-locals,too-many-statements,too-few-public-methods,too-many-public-methods,too-many-instance-attributes
[REPORTS]
# Set the output format. Available formats are text, parseable, colorized, msvs
# (visual studio) and html. You can also give a reporter class, eg
# mypackage.mymodule.MyReporterClass.
output-format=text
# Put messages in a separate file for each module / package specified on the
# command line instead of printing them on stdout. Reports (if any) will be
# written in a file name "pylint_global.[txt|html]".
files-output=no
# Tells whether to display a full report or only the messages
reports=yes
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details
#msg-template=
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=5
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,XXX,TODO
[VARIABLES]
# Tells whether we should check for unused import in __init__ files.
init-import=no
# A regular expression matching the name of dummy variables (i.e. expectedly
# not used).
dummy-variables-rgx=_$|dummy
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid to define new builtins when possible.
additional-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,_cb
[LOGGING]
# Logging modules to check that the string format arguments are in logging
# function parameter format
logging-modules=logging
[SPELLING]
# Spelling dictionary name. Available dictionaries: none. To make it working
# install python-enchant package.
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
spelling-store-unknown-words=no
[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set). This supports can work
# with qualified names.
ignored-classes=SQLObject
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=REQUEST,acl_users,aq_parent
[FORMAT]
# Maximum number of characters on a single line.
max-line-length=100
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,dict-separator
# Maximum number of lines in a module
max-module-lines=1000
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
[BASIC]
# List of builtins function names that should not be used, separated by a comma
bad-functions=map,filter
# Good variable names which should always be accepted, separated by a comma
good-names=i,j,k,ex,Run,_
# Bad variable names which should always be refused, separated by a comma
bad-names=foo,bar,baz,toto,tutu,tata
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Include a hint for the correct naming format with invalid-name
include-naming-hint=no
# Regular expression matching correct module names
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Naming hint for module names
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression matching correct variable names
variable-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for variable names
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Naming hint for class names
class-name-hint=[A-Z_][a-zA-Z0-9]+$
# Regular expression matching correct attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for attribute names
attr-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct constant names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Naming hint for constant names
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Regular expression matching correct class attribute names
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Naming hint for class attribute names
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Regular expression matching correct argument names
argument-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for argument names
argument-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct function names
function-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for function names
function-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct method names
method-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for method names
method-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct inline iteration names
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
# Naming hint for inline iteration names
inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=__.*__
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
[ELIF]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
[IMPORTS]
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=stringprep,optparse
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,__new__,setUp
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make
[DESIGN]
# Maximum number of arguments for function / method
max-args=7
# Argument names that match this expression will be ignored. Default to name
# with leading underscore
ignored-argument-names=_.*
# Maximum number of locals for function / method body
max-locals=15
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of statements in function / method body
max-statements=50
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of boolean expressions in a if statement
max-bool-expr=5
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=Exception

68
AUTHORS.rst Normal file
View File

@ -0,0 +1,68 @@
================
TTRV Contributors
================
* `deepend <https://github.com/deepend-tildeclub>`_
================
RTV Contributors
================
Thanks to the following people for their contributions to this project.
* `Michael Lazar <https://github.com/michael-lazar>`_
* `Tobin Brown <https://github.com/Brobin>`_
* `woorst <https://github.com/woorst>`_
* `Théo Piboubès <https://github.com/ThePib>`_
* `Yusuke Sakamoto <https://github.com/yskmt>`_
* `Johnathan Jenkins <https://github.com/shaggytwodope>`_
* `tyjak <https://github.com/tyjak>`_
* `Edridge D'Souza <https://github.com/edridgedsouza>`_
* `Josue Ortega <https://github.com/noahfx>`_
* `mekhami <https://github.com/mekhami>`_
* `Nemanja Nedeljković <https://github.com/nemanjan00>`_
* `obosob <https://github.com/obosob>`_
* `codesoap <https://github.com/codesoap>`_
* `Toby Hughes <https://github.com/tobywhughes>`_
* `Noah Morrison <https://github.com/noahmorrison>`_
* `Mardigon Toler <https://github.com/mardigontoler>`_
* `5225225 <https://github.com/5225225>`_
* `Shawn Hind <https://github.com/shawnhind>`_
* `Antoine Nguyen <https://github.com/anhtuann>`_
* `JuanPablo <https://github.com/juanpabloaj>`_
* `Pablo Arias <https://github.com/pabloariasal>`_
* `Robert Greener <https://github.com/ragreener1>`_
* `mac1202 <https://github.com/mac1202>`_
* `Iqbal Singh <https://github.com/nagracks>`_
* `Lorenz Leitner <https://github.com/LoLei>`_
* `Markus Pettersson <https://github.com/MarkusPettersson98>`_
* `Reshef Elisha <https://github.com/ReshefElisha>`_
* `Ryan Reno <https://github.com/rreno>`_
* `Sam Tebbs <https://github.com/SamTebbs33>`_
* `Justin Partain <https://github.com/jupart>`_
* `afloofloo <https://github.com/afloofloo>`_
* `0xflotus <https://github.com/0xflotus>`_
* `Caleb Perkins <https://github.com/calebperkins>`_
* `Charles Saracco <https://github.com/crsaracco>`_
* `Corey McCandless <https://github.com/cmccandless>`_
* `Crestwave <https://github.com/Crestwave>`_
* `Danilo G. Baio <https://github.com/dbaio>`_
* `Donovan Glover <https://github.com/GloverDonovan>`_
* `Fabio Alessandro Locati <https://github.com/Fale>`_
* `Gabriel Le Breton <https://github.com/GabLeRoux>`_
* `Hans Roman <https://github.com/snahor>`_
* `micronn <https://github.com/micronn>`_
* `Ivan Klishch <https://github.com/klivan>`_
* `Joe MacDonald <https://github.com/joeythesaint>`_
* `Marc Abramowitz <https://github.com/msabramo>`_
* `Matt <https://github.com/mehandes>`_
* `Matthew Smith <https://github.com/msmith491>`_
* `Michael Kwon <https://github.com/mskwon>`_
* `Michael Wei <https://github.com/no2chem>`_
* `Ram-Z <https://github.com/Ram-Z>`_
* `Vivek Anand <https://github.com/vivekanand1101>`_
* `Wieland Hoffmann <https://github.com/mineo>`_
* `Adam Talsma <https://github.com/a-tal>`_
* `geheimnisse <https://github.com/geheimnisse>`_
* `Alexander Terry <https://github.com/mralext20>`_
* `peterpans01 <https://github.com/peterpans01>`_

783
CHANGELOG.rst Normal file
View File

@ -0,0 +1,783 @@
=============
TTVR Changelog
=============
.. _1.27.0: https://github.com/tildeclub/ttrv/releases/tag/v1.27.0
.. _1.26.0: https://github.com/tildeclub/ttrv/releases/tag/v1.26.0
.. _1.25.1: https://github.com/tildeclub/ttrv/releases/tag/v1.25.1
.. _1.25.0: https://github.com/tildeclub/ttrv/releases/tag/v1.25.0
.. _1.24.0: https://github.com/tildeclub/ttrv/releases/tag/v1.24.0
.. _1.23.0: https://github.com/tildeclub/ttrv/releases/tag/v1.23.0
.. _1.22.1: https://github.com/tildeclub/ttrv/releases/tag/v1.22.1
.. _1.22.0: https://github.com/tildeclub/ttrv/releases/tag/v1.22.0
.. _1.21.0: https://github.com/tildeclub/ttrv/releases/tag/v1.21.0
.. _1.20.0: https://github.com/tildeclub/ttrv/releases/tag/v1.20.0
.. _1.19.0: https://github.com/tildeclub/ttrv/releases/tag/v1.19.0
.. _1.18.0: https://github.com/tildeclub/ttrv/releases/tag/v1.18.0
.. _1.17.1: https://github.com/tildeclub/ttrv/releases/tag/v1.17.1
.. _1.17.0: https://github.com/tildeclub/ttrv/releases/tag/v1.17.0
.. _1.16.0: https://github.com/tildeclub/ttrv/releases/tag/v1.16.0
.. _1.15.1: https://github.com/tildeclub/ttrv/releases/tag/v1.15.1
.. _1.15.0: https://github.com/tildeclub/ttrv/releases/tag/v1.15.0
.. _1.14.1: https://github.com/tildeclub/ttrv/releases/tag/v1.14.1
.. _1.13.0: https://github.com/tildeclub/ttrv/releases/tag/v1.13.0
.. _1.12.1: https://github.com/tildeclub/ttrv/releases/tag/v1.12.1
.. _1.12.0: https://github.com/tildeclub/ttrv/releases/tag/v1.12.0
.. _1.11.0: https://github.com/tildeclub/ttrv/releases/tag/v1.11.0
.. _1.10.0: https://github.com/tildeclub/ttrv/releases/tag/v1.10.0
.. _1.9.1: https://github.com/tildeclub/ttrv/releases/tag/v1.9.1
.. _1.9.0: https://github.com/tildeclub/ttrv/releases/tag/v1.9.0
.. _1.8.1: https://github.com/tildeclub/ttrv/releases/tag/v1.8.1
.. _1.8.0: https://github.com/tildeclub/ttrv/releases/tag/v1.8.0
.. _1.7.0: https://github.com/tildeclub/ttrv/releases/tag/v1.7.0
.. _1.6.1: https://github.com/tildeclub/ttrv/releases/tag/v1.6.1
.. _1.6: https://github.com/tildeclub/ttrv/releases/tag/v1.6
.. _1.5: https://github.com/tildeclub/ttrv/releases/tag/v1.5
.. _1.4.2: https://github.com/tildeclub/ttrv/releases/tag/v1.4.2
.. _1.4.1: https://github.com/tildeclub/ttrv/releases/tag/v1.4.1
.. _1.4: https://github.com/tildeclub/ttrv/releases/tag/v1.4
.. _1.3: https://github.com/tildeclub/ttrv/releases/tag/v1.3
.. _1.2.2: https://github.com/tildeclub/ttrv/releases/tag/v1.2.2
.. _1.2.1: https://github.com/tildeclub/ttrv/releases/tag/v1.2.1
.. _1.2: https://github.com/tildeclub/ttrv/releases/tag/v1.2
--------------------
1.27.0_ (2019-06-02)
--------------------
This is the final release of RTV. See here for more information:
https://github.com/michael-lazar/rtv/issues/696
Features
* Added a configuration option to toggle whether to open web browser links in a
new tab or a new window.
Documentation
* Improved the mailcap example for the ``feh`` command.
* Fixed the the descriptions for the ``j`` & ``k`` keys (they were swapped).
--------------------
1.26.0_ (2019-03-03)
--------------------
Features
* Added a brand new inbox page for viewing private messages and comment replies.
The inbox is accessible with the ``i`` key. Supported actions include viewing
message chains and replying to messages, marking messages as read/unread, and
opening the context of a comment.
* Added the ability to compose new private messages with the ``C`` key.
* Updated the inline help ``?`` document to contain a more comprehensive list
of commands.
* Opening a link from the command line is now faster at startup because the
default subreddit will not be loaded beforehand.
* Added a new ``--debug-info`` command to display useful system information.
Bugfixes
* Fixed opening comments with the prompt ``/`` from the subscription window.
* The subscription and multireddit ``s``/``S`` keys now work from all pages.
* Relative time strings are now correctly pluralized.
* Fixed an unclosed file handler when opening the web browser.
* Fixed the application not starting if the user has an empty front page.
Configuration Changes
* Renamed the following keybindings to better represent their usage:
* ``SORT_HOT`` -> ``SORT_1``
* ``SORT_TOP`` -> ``SORT_2``
* ``SORT_RISING`` -> ``SORT_3``
* ``SORT_NEW`` -> ``SORT_4``
* ``SORT_CONTROVERSIAL`` -> ``SORT_5``
* ``SORT_GILDED`` -> ``SORT_6``
* ``SUBREDDIT_OPEN_SUBSCRIPTIONS`` -> ``SUBSCRIPTIONS``
* ``SUBREDDIT_OPEN_MULTIREDDITS`` -> ``MULTIREDDITS``
* Added new keybindings to support the inbox page:
* ``SORT_7``
* ``PRIVATE_MESSAGE``
* ``INBOX_VIEW_CONTEXT``
* ``INBOX_OPEN_SUBMISSION``
* ``INBOX_REPLY``
* ``INBOX_MARK_READ``
* ``INBOX_EXIT``
* Added new theme elements to support the inbox page:
* <New>
* <Distinguished>
* <MessageSubject>
* <MessageLink>
* <MessageAuthor>
* <MessageSubreddit>
* <MessageText>
--------------------
1.25.1_ (2019-02-13)
--------------------
Bugfixes
* Fixed a bug that was causing newlines to be stripped when posting comments
and submissions.
--------------------
1.25.0_ (2019-02-03)
--------------------
Features
* You can now open HTML links that are embedded inside of comments and
submissions by pressing the ``ENTER`` key and selecting a link from the list.
This also works when copying links to the clipboard using ``Y``.
* Added the ``--no-autologin`` command line argument to disable automatically
logging in at startup.
* Added the ``max_pager_cols`` configuration option to limit the text width
when sending text to the system ``PAGER``.
* Additional filtering options have been added when viewing user pages.
* The gilded flair now displays the number of times a submission has been
gilded.
* Submissions/comments now display the time that they were most recently edited.
Bugfixes
* Fixed the MIME parser for gfycat, and gfycat videos are now downloaded as mp4.
* Fixed formatting when composing posts with leading whitespace.
* Fixed crash when attempting to display a long terminal title.
Documentation
* RTV has been moved to the Arch Community Repository and installation
instructions for Arch have been updated accordingly.
--------------------
1.24.0_ (2018-08-12)
--------------------
Features
* Python 3.7 is now officially supported.
* Lines that start with the hash symbol (#) are no longer ignored when
composing posts in your editor. This allows # to be used with Reddit's
markdown parser to denote headers.
* Added a new *dark colorblind* theme.
* Added support for the ``$RTV_PAGER`` environment variable, which can be
used to set a unique PAGER for rtv.
* Added the ability to sort submissions by **guilded**.
Bugfixes
* Fixed a crash when setting the ``$BROWSER`` with python 3.7.
* Improved the error message when attempting to vote on an archived post.
* Cleaned up several outdated MIME parsers. Removed the vidme, twitch,
oddshot, and imgtc parsers. Fixed the liveleak and reddit video parsers.
--------------------
1.23.0_ (2018-06-24)
--------------------
Features
* Submissions can now be marked as *[hidden]* using the ``space`` key. Hidden
submissions will be removed from the feed when the page is reloaded.
* New MIME parsers have been added for vimeo.com and streamja.com.
* Added support for opening links with **qutebrowser**.
Bugfixes
* Fixed unhandled OAuth server log messages being dumped to stdout.
* Fixed the application crashing when performing rate-limited requests.
* Fixed crash when displaying posts that contain null byte characters.
Documentation
* Added README badge for *saythanks.io*.
* Updated the mailcap template to support *v.redd.it* links.
--------------------
1.22.1_ (2018-03-11)
--------------------
I forgot to check in a commit before publishing the 1.22.0 release (whoops!)
Bugfixes
* Updated the ``__version__.py`` file to report the current version.
* Added the missing v1.22.0 entry to the CHANGELOG.
--------------------
1.22.0_ (2018-03-07)
--------------------
Features
* Added the ``--no-flash`` option to disable terminal flashing.
Bugfixes
* Fixed automatically exiting on launch when trying to open an invalid
subreddit with the ``-s`` flag.
* Fixed error handling for HTTP request timeouts when checking for new
messages in the inbox.
* Fixed a typo in the sample theme config.
Documentation
* Added the FreeBSD package to the README.
--------------------
1.21.0_ (2017-12-30)
--------------------
Features
* Full support for customizable themes has been added. For more information,
see the new section on themes in the README, and the ``THEMES.md`` file.
Bugfixes
* Fixed incorrect URL strings being sent to the **opera** web browser.
* Fixed timeout messages for the **surf** and **vimb** web browsers.
* Switched to using ``XDG_DATA_HOME`` to store the rtv browser history and
credentials file.
--------------------
1.20.0_ (2017-12-05)
--------------------
Features
* Text piped to the ``$PAGER`` will now wrap on word / sentence breaks.
* New MIME parsers have been added for liveleak.com and worldstarhiphop.com.
Bugfixes
* Fixed a regression where text from the web browser's stdout/stderr was
being sent to the terminal window.
* Fixed crashing on startup when the terminal doesn't support colors.
* Fixed broken text formatting when running inside of emacs ``term``.
Codebase
* Dropped support for python 3.3 because it's no longer supported upstream
by **pytest**. The application will still install through pip but will no
longer be tested.
* Added a text logo to the README.
--------------------
1.19.0_ (2017-10-24)
--------------------
Features
* Greatly improved loading times by using smarter rate limiting and page caching.
* The logout prompt is now visible as a popup notification.
* New MIME parsers have been added for gifs.com, giphy.com, imgtc.com,
imgflip.com, livememe.com, makeameme.org and flickr.com
* Improved mailcap examples for parsing video links with mpv.
Bugfixes
* Patched a backwards-incompatible Reddit API change with the comment
permalink now being returned in the response JSON.
* Fixed crashing when a comment contained exotic unicode characters like emojis.
* Removed the option to select custom sorting ranges for controversial and
top comments.
* Fixed MIME parsing for single image Imgur galleries.
Codebase
* Preliminary refactoring for the upcoming theme support.
* Created some utility scripts for project maintenance.
* Created a release checklist document.
* Updated the README gif images and document layout.
--------------------
1.18.0_ (2017-09-06)
--------------------
Features
* The ``rtv -l`` flag has been deprecated and replaced with a positional
argument, in order to match the syntax of other command line web browsers.
* NSFW content is now filtered according to the user's reddit profile
settings.
* ``$RTV_BROWSER`` has been added as a way to set the preferred web browser.
* Sorting options for **relevance** and **comments** are now displayed on
the search results page.
* An **[S]** badge is now displayed next to the submission author.
* The gfycat MIME parser has been expanded to support more URLs.
* New MIME parsers have been added for oddshot.tv, clips.twitch.tv,
clippituser.tv, and Reddit's beta hosted videos.
Bugfixes
* Users can now use the prompt to navigate to "/comments/..." pages from
inside of a submission.
* Users can now navigate to multireddits using the "/u/me/" prefix.
* Fixed the ``$BROWSER`` behavior on macOS to support the **chrome**,
**firefox**, **safari**, and **default** keywords.
Codebase
* Travis CI tests have been moved to the trusty environment.
* Added more detailed logging of the environment and settings at startup.
--------------------
1.17.1_ (2017-08-06)
--------------------
Bugfixes
* ``J``/``K`` commands are now restricted to the submission page.
--------------------
1.17.0_ (2017-08-03)
--------------------
Features
* Added the ``J`` command to jump to the next sibling comment.
* Added the ``K`` command to jump to the parent comment.
* Search results can now be sorted, and the title bar has been updated
to display the current search query.
* Imgur URLs are now resolved via the Imgur API.
This enables the loading of large albums with over 10 images.
An ``imgur_client_id`` option has been added to the RTV configuration.
* A MIME parser has been added for www.liveleak.com.
* RTV now respects the ``$VISUAL`` environment variable.
Bugfixes
* Fixed a screen refresh bug on urxvt terminals.
* New key bindings will now attempt to fallback to their default key if not
defined in the user's configuration file.
Documentation
* Added additional mailcap examples for framebuffer videos and iTerm2.
* Python version information is now captured in the log at startup.
--------------------
1.16.0_ (2017-06-08)
--------------------
Features
* Added the ability to copy links to the OS clipboad with ``y`` and ``Y``.
* Both submissions and comments can now be viewed on **/user/** pages.
* A MIME parser has been added for www.streamable.com.
* A MIME parser has been added for www.vidme.com.
* Submission URLs can now be opened while viewing the comments page.
Bugfixes
* More graceful handling for the invalid LOCALE error on MacOS.
* A fatal error is now raised when trying to run on Windows without curses.
* Fixed an error when trying to view saved comments.
* Invalid refresh-tokens are now automatically deleted.
* Users who are signed up for Reddit's beta profiles can now launch RTV.
--------------------
1.15.1_ (2017-04-09)
--------------------
Codebase
* Removed the mailcap-fix dependency for python versions >= 3.6.0.
* Enabled installing test dependencies with ``pip install rtv[test]``.
--------------------
1.15.0_ (2017-03-30)
--------------------
Features
* Added the ability to open comment threads using the submission's
permalink. E.g. **/comments/30rwj2**
Bugfixes
* Updated ``requests`` requirement to fix a bug in version 2.3.0.
* Fixed an edge case where comment trees were unfolding out of order.
Codebase
* Removed dependency on the PyPI ``praw`` package. A version of PRAW 3
is now bundled with rtv. This should make installation easier because
users are no longer required to maintain a legacy version of praw in
their python dependencies.
* Removed ``update-checker`` dependency.
--------------------
1.14.1_ (2017-01-12)
--------------------
Features
* The order-by option menu now triggers after a single '2' or '5' keystroke
instead of needing to double press.
Bugfixes
* Mailcap now handles multi-part shell commands correctly, e.g. "emacs -nw"
* OS X no longer relies on $DISPLAY to check if there is a display available.
* Added error handling for terminals that don't support hiding the cursor.
* Fixed a bug on tmux that prevented scrolling when $TERM was set to
"xterm-256color" instead of screen.
Documentation
* Added section to FAQ about garbled characters output by curses.
--------------------
1.13.0_ (2016-10-17)
--------------------
Features
* Pressing `2` or `5` twice now opens a menu to select the time frame.
* Added the `hide_username` config option.
* Added the `max_comment_cols` config option.
Bugfixes
* Fixed the terminal title from displaying b'' in py3.
* Flipped j and k in the documentation.
* Fixed bug when selecting post order for the front page.
* Added more descriptive error messages for invalid subreddits.
--------------------
1.12.1_ (2016-09-27)
--------------------
Bugfixes
* Fixed security vulnerability where malicious URLs could inject python code.
* No longer hangs when using mpv on long videos.
* Now falls back to ascii mode when the system locale is not utf-8.
--------------------
1.12.0_ (2016-08-25)
--------------------
Features
* Added a help banner with common key bindings.
* Added `gg` and `G` bindings to jump to the top and bottom the the page.
* Updated help screen now opens with the system PAGER.
* The `/` prompt now works from inside of submissions.
* Added an Instagram parser to extract images and videos from urls.
Bugixes
* Shortened reddit links (https://redd.it/) will now work with ``-s``.
Codebase
* Removed the Tornado dependency from the project.
* Added a requirements.txt file.
* Fixed a bunch of tests where cassettes were not being generated.
* Added compatability for pytest-xdist.
--------------------
1.11.0_ (2016-08-02)
--------------------
Features
* Added the ability to open image and video urls with the user's mailcap file.
* New ``--enable-media`` and ``copy-mailcap`` commands to support mailcap.
* New command `w` to save submissions and comments.
* New command `p` to toggle between the front page and the last visited subreddit.
* New command `S` to view subscribed multireddits.
* Extended ``/`` prompt to work with users, multireddits, and domains.
* New page ``/u/saved`` to view saved submissions.
* You can now specify the sort period by appending **-(period)**,
E.g. **/r/python/top-week**.
Bugfixes
* Terminal title is now only set when $DISPLAY is present.
* Urlview now works on the submission as well as comments.
* Fixed text encoding when using urlview.
* Removed `futures` dependency from the python 3 wheel.
* Unhandled resource warnings on exit are now ignored.
Documentation
* Various README updates.
* Updated asciinema demo video.
* Added script to update the AUTHORS.rst file.
--------------------
1.10.0_ (2016-07-11)
--------------------
Features
* New command, `b` extracts urls from comments using urlviewer.
* Comment files will no longer be destroyed if RTV encounters an error while posting.
* The terminal title now displays the subreddit name/url.
Bugfixes
* Fixed crash when entering empty or invalid subreddit name.
* Fixed crash when opening x-posts linked to subreddits.
* Fixed a bug where the terminal title wasn't getting set.
* **/r/me** is now displayed as *My Submissions* in the header.
-------------------
1.9.1_ (2016-06-13)
-------------------
Features
* Better support for */r/random*.
* Added a ``monochrome`` config setting to disable all color.
* Improved cursor positioning when expanding/hiding comments.
* Show ``(not enough space)`` when comments are too large.
Bugfixes
* Fixed permissions when copying the config file.
* Fixed bug where submission indicies were duplicated when paging.
* Specify praw v3.4.0 to avoid installing praw 4.
Documentation
* Added section to the readme on Arch Linux installation.
* Updated a few argument descriptions.
* Added a proper ascii logo.
-------------------
1.9.0_ (2016-04-05)
-------------------
Features
* You can now open long posts/comments with the $PAGER by pressing `l`.
* Changed a couple of visual separators.
Documentation
* Added testing instructions to the FAQ.
-------------------
1.8.1_ (2016-03-01)
-------------------
Features
* All keys are now rebindable through the config.
* New bindings - ctrl-d and ctrl-u for page up / page down.
* Added tag for stickied posts and comments.
* Added bullet between timestamp and comment count.
Bugfixes
* Links starting with np.reddit.com no longer return `Forbidden`.
Documentation
* Updated README.
-------------------
1.8.0_ (2015-12-20)
-------------------
Features
* A banner on the top of the page now displays the selected page sort order.
* Hidden scores now show up as "- pts".
* Oauth settings are now accesible through the config file.
* New argument `--config` specifies the config file to use.
* New argument `--copy-config` generates a default config file.
Documentation
* Added a keyboard reference from keyboardlayouteditor.com
* Added a link to an asciinema demo video
-------------------
1.7.0_ (2015-12-08)
-------------------
**Note**
This version comes with a large change in the internal structure of the project,
but does not break backwards compatibility. This includes adding a new test
suite that will hopefully improve the stability of future releases.
Continuous Integration additions
* Travis-CI https://travis-ci.org/michael-lazar/rtv
* Coveralls https://coveralls.io/github/michael-lazar/rtv
* Gitter (chat) https://gitter.im/michael-lazar/rtv
* Added a tox config for local testing
* Added a pylint config for static code and style analysis
* The project now uses VCR.py to record HTTP interactions for testing.
Features
* Added a wider utilization of the loading screen for functions that make
reddit API calls.
* In-progress loading screens can now be cancelled by pressing the `Esc` key.
Bugfixes
* OSX users should now be able to login using OAuth.
* Comments now return the correct nested level when loading "More Comments".
* Several unicode fixes, the project is now much more consistent in the way
that unicode is handled.
* Several undocumented bug fixes as a result of the code restructure.
-------------------
1.6.1_ (2015-10-19)
-------------------
Bugfixes
* Fixed authentication checking for */r/me*.
* Added force quit option with the `Q` key.
* Removed option to sort subscriptions.
* Fixed crash with pressing `i` when not logged in.
* Removed futures requirement from the python 3 distribution.
Documentation
* Updated screenshot in README.
* Added section to the FAQ on installation.
-----------------
1.6_ (2015-10-14)
-----------------
Features
* Switched all authentication to OAuth.
* Can now list the version with `rtv --version`.
* Added a man page.
* Added confirmation prompt when quitting.
* Submissions now display the index in front of their title.
Bugfixes
* Streamlined error logging.
Documentation
* Added missing docs for the `i` key.
* New documentation for OAuth.
* New FAQ section.
-----------------
1.5_ (2015-08-26)
-----------------
Features
* New page to view and open subscribed subreddits with `s`.
* Sorting method can now be toggled with the `1` - `5` keys.
* Links to x-posts are now opened inside of RTV.
Bugfixes
* Added */r/* to subreddit names in the subreddit view.
-------------------
1.4.2_ (2015-08-01)
-------------------
Features
* Pressing the `o` key now opens selfposts directly inside of rtv.
Bugfixes
* Fixed invalid subreddits from throwing unexpected errors.
-------------------
1.4.1_ (2015-07-11)
-------------------
Features
* Added the ability to check for unread messages with the `i` key.
* Upped required PRAW version to 3.
Bugfixes
* Fixed crash caused by downvoting.
* Missing flairs now display properly.
* Fixed ResourceWarning on Python 3.2+.
-----------------
1.4_ (2015-05-16)
-----------------
Features
* Unicode support has been vastly improved and is now turned on by default.
Ascii only mode can be toggled with the `--ascii` command line flag.
* Added pageup and pagedown with the `m` and `n` keys.
* Support for terminal based webbrowsers such as links and w3m.
* Browsing history is now persistent and stored in `$XDG_CACHE_HOME`.
Bugfixes
* Several improvements for handling unicode.
* Fixed crash caused by resizing the window and exiting a submission.
-----------------
1.3_ (2015-04-22)
-----------------
Features
* Added edit `e` and delete `d` for comments and submissions.
* Added *nsfw* tags.
Bugfixes
* Upvote/downvote icon now displays in the submission selfpost.
* Loading large *MoreComment* blocks no longer hangs the program.
* Improved logging and error handling with praw interactions.
-------------------
1.2.2_ (2015-04-07)
-------------------
Bugfixes
* Fixed default subreddit not being set.
Documentation
* Added changelog and contributor links to the README.
-------------------
1.2.1_ (2015-04-06)
-------------------
Bugfixes
* Fixed crashing on invalid subreddit names
-----------------
1.2_ (2015-04-06)
-----------------
Features
* Added user login / logout with the `u` key.
* Added subreddit searching with the `f` key.
* Added submission posting with the `p` key.
* Added viewing of user submissions with `/r/me`.
* Program title now displays in the terminal window.
* Gold symbols now display on guilded comments and posts.
* Moved default config location to XDG_CONFIG_HOME.
Bugfixes
* Improved error handling for submission / comment posts.
* Fixed handling of unicode flairs.
* Improved displaying of the help message and selfposts on small terminal windows.
* The author's name now correctly highlights in submissions
* Corrected user agent formatting.
* Various minor bugfixes.
------------------
1.1.1 (2015-03-30)
------------------
* Post comments using your text editor.

108
CONTRIBUTING.rst Normal file
View File

@ -0,0 +1,108 @@
----------------------
Contributor Guidelines
----------------------
Before you start
================
- Post an issue on the `tracker <https://github.com/tildeclub/ttrv/issues>`_ describing the bug or feature you would like to add
- If an issue already exists, leave a comment to let others know that you intend to work on it
Considerations
==============
- One of the project's goals is to maintain compatibility with as many terminal emulators as possible.
Please be mindful of this when designing a new feature
- Is it compatible with both Linux and OS X?
- Is it compatible with both Python 2 and Python 3
- Will it work over ssh (without X11)?
- What about terminals that don't support color? Or in those with limited (8/256) colors?
- Will it work in tmux/screen?
- Will is fail gracefully if unicode is not supported?
- If you're adding a new feature, try to include a few test cases.
See the section below on setting up your test environment
- If you tried, but you can't get the tests running in your environment, it's ok
- If you are unsure about anything, ask!
Submitting a pull request
=========================
- Reference the issue # that the pull request is related to
- Make sure you have merged in the latest changes from the ``master`` branch
- After you submit, make sure that the Travis-CI build passes
- Be prepared to have your code reviewed.
For non-trivial additions, it's normal for this process to take a few iterations
Style guide
===========
- All code should follow `PEP 8 <https://www.python.org/dev/peps/pep-0008/>`_
- Try to keep lines under 80 characters, but don't sacrifice readability to do it!
**Ugly**
.. code-block:: python
text = ''.join(
line for line in fp2 if not line.startswith('#'))
**Better**
.. code-block:: python
text = ''.join(line for line in fp2 if not line.startswith('#'))
- Use the existing codebase as a reference when writing docstrings (adopted from the `Google Style Guide <https://google.github.io/styleguide/pyguide.html#Comments>`_)
- Add an encoding header ``# -*- coding: utf-8 -*-`` to all new files
- **Please don't submit pull requests for style-only code changes**
Running the tests
=================
This project uses `pytest <http://pytest.org/>`_ and `VCR.py <https://vcrpy.readthedocs.org/>`_
VCR is a tool that records HTTP requests made during the test run and stores them in *tests/cassettes* for subsequent runs.
This both speeds up the tests and helps to maintain consistency across runs.
1. Install the test dependencies
.. code-block:: bash
$ pip install ttrv[test]
2. Set your ``$PYTHONPATH`` to point to the directory of your ttrv repository.
.. code-block:: bash
$ export PYTHONPATH=~/code/ttrv/
3. Run the tests using the existing cassettes
.. code-block:: bash
$ python -m pytest ~/code/ttrv/tests/
================================ test session starts ================================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: ~/code/ttrv/, inifile:
plugins: xdist-1.14, cov-2.2.0
collected 113 items
4. By default, the cassettes will act as read-only.
If you have written a new test and would like to record a cassette, you must provide your own refresh token.
The easiest thing to do is to use the token generated by ttrv when you log in.
This is usually stored as *~/.local/share/ttrv/refresh-token*.
.. code-block:: bash
$ python -m pytest ~/code/ttrv/tests/ --record-mode once --refresh-token ~/.local/share/ttrv/refresh-token
================================ test session starts ================================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: ~/code/ttrv/, inifile:
plugins: xdist-1.14, cov-2.2.0
collected 113 items
Note that all sensitive information will automatically be stripped from the cassette when it's saved.
5. Once you have generated a new cassette, go ahead and commit it to your branch along with your test case

103
CONTROLS.md Normal file
View File

@ -0,0 +1,103 @@
# Controls
## Basic Commands
- <kbd>j</kbd> or <kbd></kbd> - Move the cursor down
- <kbd>k</kbd> or <kbd></kbd> - Move the cursor up
- <kbd>l</kbd> or <kbd></kbd> - View the currently selected item
- <kbd>h</kbd> or <kbd></kbd> - Return to the previous view
- <kbd>m</kbd> or <kbd>PgUp</kbd> - Move the cursor up one page
- <kbd>n</kbd> or <kbd>PgDn</kbd> - Move the cursor down one page
- <kbd>gg</kbd> - Jump to the top of the page
- <kbd>G</kbd> - Jump to the bottom of the page
- <kbd>1</kbd> to <kbd>7</kbd> - Sort submissions by category
- <kbd>r</kbd> or <kbd>F5</kbd> - Refresh the content on the current page
- <kbd>u</kbd> - Login to your reddit account
- <kbd>q</kbd> - Quit
- <kbd>Q</kbd> - Force quit
- <kbd>y</kbd> - Copy submission permalink to clipboard
- <kbd>Y</kbd> - Copy submission link to clipboard
- <kbd>F2</kbd> - Cycle to the previous color theme
- <kbd>F3</kbd> - Cycle to the next color theme
- <kbd>?</kbd> - Show the help screen
- <kbd>/</kbd> - Open a prompt to select a subreddit
The <kbd>/</kbd> key opens a text prompt at the bottom of the screen. You can use
this to type in the name of the subreddit that you want to open. The following text
formats are recognized:
- ``/python`` - Open a subreddit, shorthand
- ``/r/python`` - Open a subreddit
- ``/r/python/new`` - Open a subreddit, sorted by category
- ``/r/python/controversial-year`` - Open a subreddit, sorted by category and time
- ``/r/python+linux+commandline`` - Open multiple subreddits merged together
- ``/comments/30rwj2`` - Open a submission, shorthand
- ``/r/python/comments/30rwj2`` - Open a submission
- ``/r/front`` - Open your front page
- ``/u/me`` - View your submissions
- ``/u/me/saved`` - View your saved content
- ``/u/me/hidden`` - View your hidden content
- ``/u/me/upvoted`` - View your upvoted content
- ``/u/me/downvoted`` - View your downvoted content
- ``/u/spez`` - View a user's submissions and comments
- ``/u/spez/submitted`` - View a user's submissions
- ``/u/spez/comments`` - View a user's comments
- ``/u/multi-mod/m/android`` - Open a user's curated multireddit
- ``/domain/python.org`` - Search for links for the given domain
## Authenticated Commands
Some actions require that you be logged in to your reddit account. You can login
by pressing the <kbd>u</kbd> key. Once you are logged in, your username will
appear in the top-right corner of the screen.
- <kbd>a</kbd> - Upvote
- <kbd>z</kbd> - Downvote
- <kbd>c</kbd> - Compose a new submission or comment
- <kbd>C</kbd> - Compose a new private message
- <kbd>e</kbd> - Edit the selected submission or comment
- <kbd>d</kbd> - Delete the selected submission or comment
- <kbd>i</kbd> - View your inbox (see [inbox mode](#inbox-mode))
- <kbd>s</kbd> - View your subscribed subreddits (see [subscription mode](#subscription-mode))
- <kbd>S</kbd> - View your subscribed multireddits (see [subscription mode](#subscription-mode))
- <kbd>u</kbd> - Logout of your reddit account
- <kbd>w</kbd> - Save the selected submission or comment
## Subreddit Mode
The following actions can be performed when viewing a subreddit:
- <kbd>l</kbd> or <kbd></kbd> - View the comments for the selected submission (see [submission mode](#submission-mode))
- <kbd>o</kbd> or <kbd>ENTER</kbd> - Open the selected submission link using your web browser or ``.mailcap`` config
- <kbd>SPACE</kbd> - Mark the selected submission as *hidden*
- <kbd>p</kbd> - Toggle between the currently viewed subreddit and ``/r/front``
- <kbd>f</kbd> - Open a prompt to search the current subreddit for a text string
## Submission Mode
The following actions can be performed when viewing a submission:
- <kbd>h</kbd> or <kbd></kbd> - Close the submission and return to the previous page
- <kbd>l</kbd> or <kbd></kbd> - View the selected comment using the system's pager
- <kbd>o</kbd> or <kbd>ENTER</kbd> - Open a link in the comment using your web browser or ``.mailcap`` config
- <kbd>SPACE</kbd> - Fold or expand the selected comment and its children
- <kbd>b</kbd> - Send the comment text to the system's urlviewer application
- <kbd>J</kbd> - Move the cursor down the the next comment at the same indentation
- <kbd>K</kbd> - Move the cursor up to the parent comment
## Subscription Mode
The following actions can be performed when viewing your subscriptions or multireddits:
- <kbd>h</kbd> or <kbd></kbd> - Close your subscriptions and return to the previous page
- <kbd>l</kbd> or <kbd></kbd> - Open the selected subreddit or multireddit
## Inbox Mode
The following actions can be performed when viewing your inbox:
- <kbd>h</kbd> or <kbd></kbd> - Close your inbox and return to the previous page
- <kbd>l</kbd> or <kbd></kbd> - View the context of the selected comment
- <kbd>o</kbd> or <kbd>Enter</kbd> - Open the submission of the selected comment
- <kbd>c</kbd> - Reply to the selected comment or message
- <kbd>w</kbd> - Mark the selected comment or message as seen

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015 michael-lazar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

8
MANIFEST.in Normal file
View File

@ -0,0 +1,8 @@
include version.py
include CHANGELOG.rst
include AUTHORS.rst
include README.md
include LICENSE
include ttrv.1
include ttrv/templates/*
include ttrv/themes/*

228
README.md Normal file
View File

@ -0,0 +1,228 @@
<h1 align="center">Tilde Terminal Reddit Viewer (TTRV)</h1>
<p>Forked from Original source/development at: <a href="https://github.com/michael-lazar/rtv">RTV</a></p>
<p align="center">
A text-based interface (TUI) to view and interact with Reddit from your terminal.<br>
</p>
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/title_image.png"/>
</p>
<p align="center">
</p>
## Table of Contents
* [Demo](#demo)
* [Installation](#installation)
* [Usage](#usage)
* [Settings](#settings)
* [Themes](#themes)
* [FAQ](#faq)
* [Contributing](#contributing)
* [License](#license)
## Demo
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/demo.gif"/>
</p>
## Installation
### PyPI package
TTRV is available on [PyPI](https://pypi.python.org/pypi/ttrv/) and can be installed with pip:
```bash
$ pip install ttrv
```
### From source
```bash
$ git clone https://github.com/tildeclub/ttrv.git
$ cd ttrv/
$ python setup.py install
```
### Windows
TTRV is not supported on Windows but you can enable Windows subsystem for Linux, download your preferred Linux distribution from Microsoft Store and access it from there.
To open links on Edge, paste the line below to ``{HOME}/.bashrc``
```
export BROWSER='/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
```
## Usage
To run the program, type:
```bash
$ ttrv --help
```
### Controls
Move the cursor using either the arrow keys or *Vim* style movement:
- Press <kbd></kbd> and <kbd></kbd> to scroll through submissions
- Press <kbd></kbd> to view the selected submission and <kbd></kbd> to return
- Press <kbd>space-bar</kbd> to expand/collapse comments
- Press <kbd>u</kbd> to login (this requires a web browser for [OAuth](https://github.com/reddit-archive/reddit/wiki/oauth2))
- Press <kbd>?</kbd> to open the help screen
Press <kbd>/</kbd> to open the navigation prompt, where you can type things like:
- ``/front``
- ``/r/commandprompt+linuxmasterrace``
- ``/r/programming/controversial``
- ``/u/me``
- ``/u/multi-mod/m/art``
- ``/domain/github.com``
See [CONTROLS](CONTROLS.md) for the full list of commands.
## Settings
### Configuration File
Configuration files are stored in the ``{HOME}/.config/ttrv/`` directory.
Check out [ttrv.cfg](ttrv/templates/ttrv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
```bash
$ ttrv --copy-config
```
### Viewing Media Links
You can use [mailcap](https://en.wikipedia.org/wiki/Media_type#Mailcap) to configure how TTRV will open different types of links.
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/mailcap.gif"/>
</p>
A mailcap file allows you to associate different MIME media types, like ``image/jpeg`` or ``video/mp4``, with shell commands. This feature is disabled by default because it takes a few extra steps to configure. To get started, copy the default mailcap template to your home directory.
```bash
$ ttrv --copy-mailcap
```
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](ttrv/templates/mailcap) listed inside.
Once you've setup your mailcap file, enable it by launching ttrv with the ``ttrv --enable-media`` flag (or set it in your **ttrv.cfg**)
### Environment Variables
The default programs that TTRV interacts with can be configured through environment variables:
<table>
<tr>
<td><strong>$TTRV_EDITOR</strong></td>
<td>A program used to compose text submissions and comments, e.g. <strong>vim</strong>, <strong>emacs</strong>, <strong>gedit</strong>
<br/> <em>If not specified, will fallback to $VISUAL and $EDITOR in that order.</em></td>
</tr>
<tr>
<td><strong>$TTRV_BROWSER</strong></td>
<td>A program used to open links to external websites, e.g. <strong>firefox</strong>, <strong>google-chrome</strong>, <strong>w3m</strong>, <strong>lynx</strong>
<br/> <em>If not specified, will fallback to $BROWSER, or your system's default browser.</em></td>
</tr>
<tr>
<td><strong>$TTRV_URLVIEWER</strong></td>
<td>A tool used to extract hyperlinks from blocks of text, e.g. <a href=https://github.com/sigpipe/urlview>urlview</a>, <a href=https://github.com/firecat53/urlscan>urlscan</a>
<br/> <em>If not specified, will fallback to urlview if it is installed.</em></td>
</tr>
</table>
### Clipboard
TTRV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
## Themes
Themes can be used to customize the look and feel of TTRV
<table>
<tr>
<td align="center">
<p><strong>Solarized Dark</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_dark.png"></img>
</td>
<td align="center">
<p><strong>Solarized Light</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_light.png"></img>
</td>
</tr>
<tr>
<td align="center">
<p><strong>Papercolor</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_papercolor.png"></img>
</td>
<td align="center">
<p><strong>Molokai</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_molokai.png"></img>
</td>
</tr>
</table>
You can list all installed themes with the ``--list-themes`` command, and select one with ``--theme``. You can save your choice permanently in your [ttrv.cfg](ttrv/templates/ttrv.cfg) file. You can also use the <kbd>F2</kbd> & <kbd>F3</kbd> keys inside of TTRV to cycle through all available themes.
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
## FAQ
<details>
<summary>Why am I getting an error during installation/when launching ttrv?</summary>
> If your distro ships with an older version of python 2.7 or python-requests,
> you may experience SSL errors or other package incompatibilities. The
> easiest way to fix this is to install ttrv using python 3. If you
> don't already have pip3, see http://stackoverflow.com/a/6587528 for setup
> instructions. Then do
>
> ```bash
> $ sudo pip uninstall ttrv
> $ sudo pip3 install -U ttrv
> ```
</details>
<details>
<summary>Why do I see garbled text like <em>M-b~@M-"</em> or <em>^@</em>?</summary>
> This type of text usually shows up when python is unable to render
> unicode properly.
>
> 1. Try starting TTRV in ascii-only mode with ``ttrv --ascii``
> 2. Make sure that the terminal/font that you're using supports unicode
> 3. Try [setting the LOCALE to utf-8](https://perlgeek.de/en/article/set-up-a-clean-utf8-environment)
> 4. Your python may have been built against the wrong curses library,
> see [here](stackoverflow.com/questions/19373027) and
> [here](https://bugs.python.org/issue4787) for more information
</details>
<details>
<summary>How do I run the code directly from the repository?</summary>
> This project is structured to be run as a python *module*. This means that
> you need to launch it using python's ``-m`` flag. See the example below, which
> assumes that you have cloned the repository into the directory **~/ttrv_project**.
>
> ```bash
> $ cd ~/ttrv_project
> $ python3 -m ttrv
> ```
</details>
## Contributing
All feedback and suggestions are welcome, just post an issue!
Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
## License
This project is distributed under the [MIT](LICENSE) license.

215
THEMES.md Normal file
View File

@ -0,0 +1,215 @@
# Themes
## Installing Themes
You can install custom themes by copying them into your **~/.config/ttrv/themes/**
directory. The name of the theme will match the name of the file.
```
$ cp my-custom-theme.cfg ~/.config/ttrv/themes/
$ ttrv --theme my-custom-theme
```
If you've created a cool theme and would like to share it with the community,
please submit a pull request!
## A quick primer on ANSI colors
Color support on modern terminals can be split into 4 categories:
1. No support for colors
2. 8 system colors - Black, Red, Green, Yellow, Blue, Magenta,
Cyan, and White
3. 16 system colors - Everything above + bright variations
4. 256 extended colors - Everything above + 6x6x6 color palette + 24 greyscale colors
<p align="center">
<img alt="terminal colors" src="resources/terminal_colors.png"/>
<br><i>The 256 terminal color codes, image from <a href=https://github.com/eikenb/terminal-colors>https://github.com/eikenb/terminal-colors</a></i>
</p>
The 16 system colors, along with the default foreground and background,
can usually be customized through your terminal's profile settings. The
6x6x6 color palette and grayscale colors are constant RGB values across
all terminals. TTRV's default theme only uses the 8 primary system colors,
which is why it matches the "look and feel" of the terminal that you're
running it in.
<p align="center">
<img alt="iTerm preferences" src="resources/iterm_preferences.png"/>
<br><i>Setting the 16 system colors in iTerm preferences</i>
</p>
The curses library determines your terminal's color support by reading your
environment's ``$TERM`` variable, and looking up your terminal's
capabilities in the [terminfo](https://linux.die.net/man/5/terminfo)
database. You can emulate this behavior by using the ``tput`` command:
```
bash$ export TERM=xterm
bash$ tput colors
8
bash$ export TERM=xterm-256color
bash$ tput colors
256
bash$ export TERM=vt220
bash$ tput colors
-1
```
In general you should not be setting your ``$TERM`` variable manually,
it will be set automatically by you terminal. Often, problems with
terminal colors can be traced back to somebody hardcoding
``TERM=xterm-256color`` in their .bashrc file.
## Understanding TTRV Themes
Here's an example of what an TTRV theme file looks like:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = default default normal
Selected = default default normal
SelectedCursor = default default reverse
TitleBar = cyan - bold+reverse
OrderBar = yellow - bold
OrderBarHighlight = yellow - bold+reverse
HelpBar = cyan - bold+reverse
Prompt = cyan - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = magenta - -
CursorBar2 = cyan - -
CursorBar3 = green - -
CursorBar4 = yellow - -
CommentAuthor = blue - bold
CommentAuthorSelf = green - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = red - bold
Gold = yellow - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = yellow - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = red - bold+reverse
Saved = green - -
Score = - - -
Separator = - - bold
Stickied = green - -
SubscriptionName = yellow - bold
SubscriptionText = - - -
SubmissionAuthor = green - bold
SubmissionFlair = red - -
SubmissionSubreddit = yellow - -
SubmissionText = - - -
SubmissionTitle = - - bold
Upvote = green - bold
Link = blue - underline
LinkSeen = magenta - underline
UserFlair = yellow - bold
```
Every piece of text drawn on the screen is assigned to an ``<element>``,
which has three properties:
- ``<foreground>``: The text color
- ``<background>``: The background color
- ``<attributes>``: Additional text attributes, like bold or underlined
### Colors
The ``<foreground>`` and ``<background>`` properties can be set to any the following values:
- ``default``, which means use the terminal's default foreground or background color.
- The 16 system colors:
<p>
<table>
<tr><td>black</td><td>dark_gray</td></tr>
<tr><td>red</td></td><td>bright_red</td></tr>
<tr><td>green</td></td><td>bright_green</td></tr>
<tr><td>yellow</td></td><td>bright_yellow</td></tr>
<tr><td>blue</td></td><td>bright_blue</td></tr>
<tr><td>magenta</td></td><td>bright_magenta</td></tr>
<tr><td>cyan</td></td><td>bright_cyan</td></tr>
<tr><td>light_gray</td></td><td>white</td></tr>
</table>
</p>
- ``ansi_{n}``, where n is between 0 and 255. These will map to their
corresponding ANSI colors (see the figure above).
- Hex RGB codes, like ``#0F0F0F``, which will be converted to their nearest
ANSI color. This is generally not recommended because the conversion process
downscales the color resolution and the resulting colors will look "off".
### Attributes
The ``<attributes>`` property can be set to any of the following values:
- ``normal``, ``bold``, ``underline``, or ``standout``.
- ``reverse`` will swap the foreground and background colors.
Attributes can be mixed together using the + symbol. For example,
``bold+underline`` will make the text bold and underlined.
### Modifiers
TTRV themes use special "modifer" elements to define the default
application style. This allows you to do things like set the default
background color without needing to set ``<background>`` on every
single element. The three modifier elements are:
- ``Normal`` - The default modifier that applies to all text elements.
- ``Selected`` - Applies to text elements that are highlighted on the page.
- ``SelectedCursor`` - Like ``Selected``, but only applies to ``CursorBlock``
and ``CursorBar{n}`` elements.
When an element is marked with a ``-`` token, it means inherit the
attribute value from the relevant modifier. This is best explained
through an example:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
Link = ansi_33 - underline
```
<p align="center">
<img src="resources/theme_modifiers.png"/>
<br><i>The default solarized-light theme</i>
</p>
In the snippet above, the ``Link`` element has its background color set
to the ``-`` token. This means that it will inherit it's background
from either the ``Normal`` (light yellow, ansi_230) or the ``Selected`` (light grey, ansi_254)
element, depending on if it's selected or not.
Compare this with what happens when the ``Link`` background is hard-coded to ``ansi_230``:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
Link = ansi_33 ansi_230 underline
```
<p align="center">
<img src="resources/theme_modifiers_2.png"/>
<br><i>The Link element hard-coded to ansi_230</i>
</p>
In this case, the ``Link`` background stays yellow (ansi_230) even when the link is
selected by the cursor.

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
beautifulsoup4==4.5.1
decorator==4.0.10
kitchen==1.2.4
mailcap-fix==0.1.3
requests==2.20.0
six==1.10.0
pytest==3.2.3
vcrpy==1.10.5
pylint==1.6.5
pytest-xdist==1.22.5

BIN
resources/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
resources/keyboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
resources/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
resources/logo_black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
resources/mailcap.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
resources/retro_term.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
resources/theme_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
resources/theme_molokai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
resources/title_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

59
scripts/build_authors.py Executable file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scrape the project contributors list from Github and update AUTHORS.rst
"""
from __future__ import unicode_literals
import os
import time
import logging
import requests
_filepath = os.path.dirname(os.path.relpath(__file__))
FILENAME = os.path.abspath(os.path.join(_filepath, '..', 'AUTHORS.rst'))
URL = "https://api.github.com/repos/tildeclub/ttrv/contributors?per_page=1000"
HEADER = """\
================
TTRV Contributors
================
Thanks to the following people for their contributions to this project.
"""
def main():
logging.captureWarnings(True)
# Request the list of contributors
print('GET {}'.format(URL))
resp = requests.get(URL)
contributors = resp.json()
lines = []
for contributor in contributors:
time.sleep(1.0)
# Request each contributor individually to get the full name
print('GET {}'.format(contributor['url']))
resp = requests.get(contributor['url'])
user = resp.json()
name = user.get('name') or contributor['login']
url = user['html_url']
lines.append('* `{} <{}>`_'.format(name, url))
print('Writing to {}'.format(FILENAME))
text = HEADER + '\n'.join(lines)
text = text.encode('utf-8')
with open(FILENAME, 'wb') as fp:
fp.write(text)
if __name__ == '__main__':
main()

84
scripts/build_manpage.py Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python
"""
Internal tool used to automatically generate an up-to-date version of the tvr
man page. Currently this script should be manually ran after each version bump.
In the future, it would be nice to have this functionality built into setup.py.
Usage:
$ python scripts/build_manpage.py
"""
import os
import sys
from datetime import datetime
_filepath = os.path.dirname(os.path.relpath(__file__))
ROOT = os.path.abspath(os.path.join(_filepath, '..'))
sys.path.insert(0, ROOT)
import tvr
from tvr import config
def main():
parser = config.build_parser()
help_text = parser.format_help()
help_sections = help_text.split('\n\n')
del help_sections[1]
data = {}
print('Fetching version')
data['version'] = tvr.__version__
print('Fetching release date')
data['release_date'] = datetime.utcnow().strftime('%B %d, %Y')
print('Fetching synopsis')
synopsis = help_sections[0].replace('usage: ', '')
synopsis = ' '.join(line.strip() for line in synopsis.split('\n'))
data['synopsis'] = synopsis
print('Fetching description')
data['description'] = help_sections[1]
# Build the options section for each argument from the help section
# Example Before:
# -h, --help show this help message and exit
# Example After
# .TP
# \fB-h\fR, \fB--help\fR
# show this help message and exit
options = ''
lines = help_sections[2].split('\n')[1:] # positional arguments
lines.extend(help_sections[3].split('\n')[1:]) # optional arguments
lines = [line.strip() for line in lines]
arguments = []
for line in lines:
if line.startswith('-'):
arguments.append(line)
elif line.startswith('URL'):
# Special case for URL which is a positional argument
arguments.append(line)
else:
arguments[-1] = arguments[-1] + ' ' + line
for argument in arguments:
flag, description = (col.strip() for col in argument.split(' ', 1))
flag = ', '.join(r'\fB'+f+r'\fR' for f in flag.split(', '))
options += '\n'.join(('.TP', flag, description, '\n'))
data['options'] = options
print('Fetching license')
data['license'] = tvr.__license__
print('Fetching copyright')
data['copyright'] = tvr.__copyright__
# Escape dashes is all of the sections
data = {k: v.replace('-', r'\-') for k, v in data.items()}
print('Reading from %s/scripts/tvr.1.template' % ROOT)
with open(os.path.join(ROOT, 'scripts/tvr.1.template')) as fp:
template = fp.read()
print('Populating template')
out = template.format(**data)
print('Writing to %s/tvr.1' % ROOT)
with open(os.path.join(ROOT, 'tvr.1'), 'w') as fp:
fp.write(out)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

15
scripts/count_lines.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
ROOT="$(dirname "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )")"
cd ${ROOT}
echo -e "\nTests: "
echo "$(wc -l tests/*.py)"
echo -e "\nScripts: "
echo "$(wc -l scripts/*)"
echo -e "\nTemplates: "
echo "$(wc -l ttrv/templates/*)"
echo -e "\nCode: "
echo "$(wc -l ttrv/*.py)"
echo -e "\nCombined: "
echo "$(cat tests/*.py scripts/* ttrv/templates/* ttrv/*.py | wc -l) total lines"

283
scripts/demo_theme.py Executable file
View File

@ -0,0 +1,283 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import print_function
import os
import sys
import time
import curses
import locale
import threading
from types import MethodType
from collections import Counter
from vcr import VCR
from six.moves.urllib.parse import urlparse, parse_qs
from ttrv.theme import Theme, ThemeList
from ttrv.config import Config
from ttrv.packages import praw
from ttrv.oauth import OAuthHelper
from ttrv.terminal import Terminal
from ttrv.objects import curses_session
from ttrv.subreddit_page import SubredditPage
from ttrv.submission_page import SubmissionPage
from ttrv.subscription_page import SubscriptionPage
try:
from unittest import mock
except ImportError:
import mock
def initialize_vcr():
def auth_matcher(r1, r2):
return (r1.headers.get('authorization') ==
r2.headers.get('authorization'))
def uri_with_query_matcher(r1, r2):
p1, p2 = urlparse(r1.uri), urlparse(r2.uri)
return (p1[:3] == p2[:3] and
parse_qs(p1.query, True) == parse_qs(p2.query, True))
cassette_dir = os.path.join(os.path.dirname(__file__), 'cassettes')
if not os.path.exists(cassette_dir):
os.makedirs(cassette_dir)
filename = os.path.join(cassette_dir, 'demo_theme.yaml')
if os.path.exists(filename):
record_mode = 'none'
else:
record_mode = 'once'
vcr = VCR(
record_mode=record_mode,
filter_headers=[('Authorization', '**********')],
filter_post_data_parameters=[('refresh_token', '**********')],
match_on=['method', 'uri_with_query', 'auth', 'body'],
cassette_library_dir=cassette_dir)
vcr.register_matcher('auth', auth_matcher)
vcr.register_matcher('uri_with_query', uri_with_query_matcher)
return vcr
# Patch the getch method so we can display multiple notifications or
# other elements that require a keyboard input on the screen at the
# same time without blocking the main thread.
def notification_getch(self):
if self.pause_getch:
return -1
return 0
def prompt_getch(self):
while self.pause_getch:
time.sleep(1)
return 0
def draw_screen(stdscr, reddit, config, theme, oauth):
threads = []
max_y, max_x = stdscr.getmaxyx()
mid_x = int(max_x / 2)
tall_y, short_y = int(max_y / 3 * 2), int(max_y / 3)
stdscr.clear()
stdscr.refresh()
# ===================================================================
# Submission Page
# ===================================================================
win1 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, 0)
term = Terminal(win1, config)
term.set_theme(theme)
oauth.term = term
url = 'https://www.reddit.com/r/Python/comments/4dy7xr'
with term.loader('Loading'):
page = SubmissionPage(reddit, term, config, oauth, url=url)
# Tweak the data in order to demonstrate the full range of settings
data = page.content.get(-1)
data['object'].link_flair_text = 'flair'
data['object'].gilded = 1
data['object'].over_18 = True
data['object'].saved = True
data.update(page.content.strip_praw_submission(data['object']))
data = page.content.get(0)
data['object'].author.name = 'kafoozalum'
data['object'].stickied = True
data['object'].author_flair_text = 'flair'
data['object'].likes = True
data.update(page.content.strip_praw_comment(data['object']))
data = page.content.get(1)
data['object'].saved = True
data['object'].likes = False
data['object'].score_hidden = True
data['object'].gilded = 1
data.update(page.content.strip_praw_comment(data['object']))
data = page.content.get(2)
data['object'].author.name = 'kafoozalum'
data['object'].body = data['object'].body[:100]
data.update(page.content.strip_praw_comment(data['object']))
page.content.toggle(9)
page.content.toggle(5)
page.draw()
# ===================================================================
# Subreddit Page
# ===================================================================
win2 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, mid_x + 1)
term = Terminal(win2, config)
term.set_theme(theme)
oauth.term = term
with term.loader('Loading'):
page = SubredditPage(reddit, term, config, oauth, '/u/saved')
# Tweak the data in order to demonstrate the full range of settings
data = page.content.get(3)
data['object'].hide_score = True
data['object'].author = None
data['object'].saved = False
data.update(page.content.strip_praw_submission(data['object']))
page.content.order = 'rising'
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Success',),
kwargs={'style': 'Success'})
thread.start()
threads.append((thread, term))
# ===================================================================
# Subscription Page
# ===================================================================
win3 = stdscr.derwin(short_y, mid_x - 1, tall_y, 0)
term = Terminal(win3, config)
term.set_theme(theme)
oauth.term = term
with term.loader('Loading'):
page = SubscriptionPage(reddit, term, config, oauth, 'popular')
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Error',),
kwargs={'style': 'Error'})
thread.start()
threads.append((thread, term))
# ===================================================================
# Multireddit Page
# ===================================================================
win4 = stdscr.derwin(short_y, mid_x - 1, tall_y, mid_x + 1)
term = Terminal(win4, config)
term.set_theme(theme)
oauth.term = term
with term.loader('Loading'):
page = SubscriptionPage(reddit, term, config, oauth, 'multireddit')
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Info',),
kwargs={'style': 'Info'})
thread.start()
threads.append((thread, term))
term = Terminal(win4, config)
term.set_theme(theme)
term.pause_getch = True
term.getch = MethodType(prompt_getch, term)
thread = threading.Thread(target=term.prompt_y_or_n, args=('Prompt: ',))
thread.start()
threads.append((thread, term))
time.sleep(0.5)
curses.curs_set(0)
return threads
def main():
locale.setlocale(locale.LC_ALL, '')
if len(sys.argv) > 1:
theme = Theme.from_name(sys.argv[1])
else:
theme = Theme()
vcr = initialize_vcr()
with vcr.use_cassette('demo_theme.yaml') as cassette, \
curses_session() as stdscr:
config = Config()
if vcr.record_mode == 'once':
config.load_refresh_token()
else:
config.refresh_token = 'mock_refresh_token'
reddit = praw.Reddit(user_agent='TTRV Theme Demo',
decode_html_entities=False,
disable_update_check=True)
reddit.config.api_request_delay = 0
config.history.add('https://api.reddit.com/comments/6llvsl/_/djutc3s')
config.history.add('http://i.imgur.com/Z9iGKWv.gifv')
config.history.add('https://www.reddit.com/r/Python/comments/6302cj/rpython_official_job_board/')
term = Terminal(stdscr, config)
term.set_theme()
oauth = OAuthHelper(reddit, term, config)
oauth.authorize()
theme_list = ThemeList()
while True:
term = Terminal(stdscr, config)
term.set_theme(theme)
threads = draw_screen(stdscr, reddit, config, theme, oauth)
try:
ch = term.show_notification(theme.display_string)
except KeyboardInterrupt:
ch = Terminal.ESCAPE
for thread, term in threads:
term.pause_getch = False
thread.join()
if vcr.record_mode == 'once':
break
else:
cassette.play_counts = Counter()
theme_list.reload()
if ch == curses.KEY_RIGHT:
theme = theme_list.next(theme)
elif ch == curses.KEY_LEFT:
theme = theme_list.previous(theme)
elif ch == Terminal.ESCAPE:
break
else:
# Force the theme to reload
theme = theme_list.next(theme)
theme = theme_list.previous(theme)
sys.exit(main())

View File

@ -0,0 +1,30 @@
"""
Initialize an authenticated instance of PRAW to interact with.
$ python -i initialize_session.py
"""
from ttrv.docs import AGENT
from ttrv.packages import praw
from ttrv.content import RequestHeaderRateLimiter
from ttrv.config import Config
config = Config()
config.load_refresh_token()
reddit = praw.Reddit(
user_agent=AGENT.format(version='test_session'),
decode_html_entities=False,
disable_update_check=True,
timeout=10, # 10 second request timeout
handler=RequestHeaderRateLimiter())
reddit.set_oauth_app_info(
config['oauth_client_id'],
config['oauth_client_secret'],
config['oauth_redirect_uri'])
reddit.refresh_access_information(config.refresh_token)
inbox = reddit.get_inbox()
items = [next(inbox) for _ in range(20)]
pass

31
scripts/inspect_webbrowser.py Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env python
"""
Utility script used to examine the python webbrowser module with different OSs.
"""
import os
os.environ['BROWSER'] = 'firefox'
# If we want to override the $BROWSER variable that the python webbrowser
# references, it needs to be done before the webbrowser module is imported
# for the first time.
TTRV_BROWSER, BROWSER = os.environ.get('TTRV_BROWSER'), os.environ.get('BROWSER')
if TTRV_BROWSER:
os.environ['BROWSER'] = TTRV_BROWSER
print('TTRV_BROWSER=%s' % TTRV_BROWSER)
print('BROWSER=%s' % BROWSER)
import webbrowser
print('webbrowser._browsers:')
for key, val in webbrowser._browsers.items():
print(' %s: %s' % (key, val))
print('webbrowser._tryorder:')
for name in webbrowser._tryorder:
print(' %s' % name)
webbrowser.open_new_tab('https://www.python.org')

9
scripts/pip_clean.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Removes any lingering build/release files from the project directory
find . -type f -name '*.pyc' -delete
find . -type f -name '*.pyo' -delete
find . -type d -name '__pycache__' -exec rm -rv {} +
find . -type d -name 'build' -exec rm -rv {} +
find . -type d -name 'dist' -exec rm -rv {} +
find . -type d -name '*.egg-info' -exec rm -rv {} +

47
scripts/ttrv.1.template Normal file
View File

@ -0,0 +1,47 @@
.TH "TTRV" "1" "{release_date}" "Version {version}" "Usage and Commands"
.SH NAME
TTRV - Reddit Terminal Viewer
.SH SYNOPSIS
{synopsis}
.SH DESCRIPTION
{description}
.SH OPTIONS
{options}
.SH CONTROLS
Move the cursor using the arrow keys or vim style movement.
.br
Press \fBup\fR and \fBdown\fR to scroll through submissions.
.br
Press \fBright\fR to view the selected submission and \fBleft\fR to return.
.br
Press \fB?\fR to open the help screen.
.SH FILES
.TP
.BR $XDG_CONFIG_HOME/ttrv/ttrv.cfg
The configuration file can be used to customize default program settings.
.TP
.BR $XDG_DATA_HOME/ttrv/refresh-token
After you login to reddit, your most recent OAuth refresh token will be stored
for future sessions.
.TP
.BR $XDG_DATA_HOME/ttrv/history.log
This file stores URLs that have been recently opened in order to
visually highlight them as "seen".
.SH ENVIRONMENT
.TP
.BR TTRV_EDITOR
Text editor to use when editing comments and submissions. Will fallback to
\fI$EDITOR\fR.
.TP
.BR TTRV_URLVIEWER
Url viewer to use to extract links from comments. Requires a compatible
program to be installed.
.TP
.BR TTRV_BROWSER
Web browser to use when opening links. Will fallback to \fI$BROWSER\fR.
.SH AUTHOR
Michael Lazar <lazar.michael22@gmail.com> (2017).
.SH BUGS
Report bugs to \fIhttps://github.com/tildeclub/ttrv/issues\fR
.SH LICENSE
{license}

49
scripts/update_packages.py Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Update the project's bundled dependencies by downloading the git repository and
copying over the most recent commit.
"""
import os
import shutil
import subprocess
import tempfile
_filepath = os.path.dirname(os.path.relpath(__file__))
ROOT = os.path.abspath(os.path.join(_filepath, '..'))
PRAW_REPO = 'https://github.com/michael-lazar/praw3.git'
def main():
tmpdir = tempfile.mkdtemp()
subprocess.check_call(['git', 'clone', PRAW_REPO, tmpdir])
# Update the commit hash reference
os.chdir(tmpdir)
p = subprocess.Popen(['git', 'rev-parse', 'HEAD'], stdout=subprocess.PIPE)
p.wait()
commit = p.stdout.read().strip()
print('Found commit %s' % commit)
regex = 's/^__praw_hash__ =.*$/__praw_hash__ = \'%s\'/g' % commit
packages_root = os.path.join(ROOT, 'ttrv', 'packages', '__init__.py')
print('Updating commit hash in %s' % packages_root)
subprocess.check_call(['sed', '-i', '', regex, packages_root])
# Overwrite the project files
src = os.path.join(tmpdir, 'praw')
dest = os.path.join(ROOT, 'ttrv', 'packages', 'praw')
print('Copying package files to %s' % dest)
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(src, dest)
# Cleanup
print('Removing directory %s' % tmpdir)
shutil.rmtree(tmpdir)
if __name__ == '__main__':
main()

2
setup.cfg Normal file
View File

@ -0,0 +1,2 @@
[wheel]
universal = 1

86
setup.py Normal file
View File

@ -0,0 +1,86 @@
import sys
import codecs
import setuptools
from version import __version__ as version
install_requires = [
'beautifulsoup4',
'decorator',
'kitchen',
'requests >=2.4.0', # https://github.com/tildeclub/ttrv/issues/325
'six',
]
tests_require = [
'coveralls',
'pytest>=3.1.0', # Pinned for the ``pytest.param`` method
'coverage',
'mock',
'pylint',
'vcrpy',
]
extras_require = {
'test': tests_require
}
# https://hynek.me/articles/conditional-python-dependencies/
if int(setuptools.__version__.split(".", 1)[0]) < 18:
assert "bdist_wheel" not in sys.argv
if sys.version_info[0:2] < (3, 6):
install_requires.append("mailcap-fix")
else:
# Building the bdist_wheel with conditional environment dependencies
# requires setuptools version > 18. For older setuptools versions this
# will raise an error.
extras_require.update({":python_version<'3.6'": ["mailcap-fix"]})
def long_description():
with codecs.open('README.md', encoding='utf8') as f:
return f.read()
setuptools.setup(
name='ttrv',
version=version,
description='Tilde Terminal Reddit Viewer',
long_description=long_description(),
long_description_content_type='text/markdown',
url='https://github.com/tildeclub/ttrv',
author='deepend (forked from RTV)',
author_email='deepend@tilde.club',
license='MIT',
keywords='reddit terminal praw curses',
packages=[
'ttrv',
'ttrv.packages',
'ttrv.packages.praw'
],
package_data={
'ttrv': ['templates/*', 'themes/*'],
'ttrv.packages.praw': ['praw.ini']
},
data_files=[("share/man/man1", ["ttrv.1"])],
install_requires=install_requires,
tests_require=tests_require,
extras_require=extras_require,
entry_points={'console_scripts': ['ttrv=ttrv.__main__:main']},
classifiers=[
'Intended Audience :: End Users/Desktop',
'Environment :: Console :: Curses',
'Operating System :: MacOS :: MacOS X',
'Operating System :: POSIX',
'Natural Language :: English',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Topic :: Terminals',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary',
],
)

1
test Normal file
View File

@ -0,0 +1 @@
test

119
ttrv.1 Normal file
View File

@ -0,0 +1,119 @@
.TH "TTRV" "1" "June 03, 2019" "Version 1.27.0" "Usage and Commands"
.SH NAME
TTRV - Tilde Terminal Reddit Viewer
.SH SYNOPSIS
ttrv [URL] [\-s SUBREDDIT]
.SH DESCRIPTION
TTRV (Tilde Terminal Reddit Viewer) is a terminal interface to view and interact with reddit.
.SH OPTIONS
.TP
\fBURL\fR
[optional] Full URL of a submission to open
.TP
\fB\-h\fR, \fB\-\-help\fR
show this help message and exit
.TP
\fB\-s SUBREDDIT\fR
Name of the subreddit that will be loaded on start
.TP
\fB\-\-log FILE\fR
Log HTTP requests to the given file
.TP
\fB\-\-config FILE\fR
Load configuration settings from the given file
.TP
\fB\-\-ascii\fR
Enable ascii\-only mode
.TP
\fB\-\-monochrome\fR
Disable color
.TP
\fB\-\-theme FILE\fR
Color theme to use, see \-\-list\-themes for valid options
.TP
\fB\-\-list\-themes\fR
List all of the available color themes
.TP
\fB\-\-non\-persistent\fR
Forget the authenticated user when the program exits
.TP
\fB\-\-no\-autologin\fR
Do not authenticate automatically on startup
.TP
\fB\-\-clear\-auth\fR
Remove any saved user data before launching
.TP
\fB\-\-copy\-config\fR
Copy the default configuration to {HOME}/.config/ttrv/ttrv.cfg
.TP
\fB\-\-copy\-mailcap\fR
Copy an example mailcap configuration to {HOME}/.mailcap
.TP
\fB\-\-enable\-media\fR
Open external links using programs defined in the mailcap config
.TP
\fB\-V\fR, \fB\-\-version\fR
show program's version number and exit
.TP
\fB\-\-no\-flash\fR
Disable screen flashing
.TP
\fB\-\-debug\-info\fR
Show system and environment information and exit
.SH CONTROLS
Move the cursor using the arrow keys or vim style movement.
.br
Press \fBup\fR and \fBdown\fR to scroll through submissions.
.br
Press \fBright\fR to view the selected submission and \fBleft\fR to return.
.br
Press \fB?\fR to open the help screen.
.SH FILES
.TP
.BR $XDG_CONFIG_HOME/ttrv/ttrv.cfg
The configuration file can be used to customize default program settings.
.TP
.BR $XDG_DATA_HOME/ttrv/refresh-token
After you login to reddit, your most recent OAuth refresh token will be stored
for future sessions.
.TP
.BR $XDG_DATA_HOME/ttrv/history.log
This file stores URLs that have been recently opened in order to
visually highlight them as "seen".
.SH ENVIRONMENT
.TP
.BR TTRV_EDITOR
Text editor to use when editing comments and submissions. Will fallback to
\fI$EDITOR\fR.
.TP
.BR TTRV_URLVIEWER
Url viewer to use to extract links from comments. Requires a compatible
program to be installed.
.TP
.BR TTRV_BROWSER
Web browser to use when opening links. Will fallback to \fI$BROWSER\fR.
.SH AUTHOR
deepend <root@tilde.club> (2017).
.SH BUGS
Report bugs to \fIhttps://github.com/tildeclub/ttrv/issues\fR
.SH LICENSE
The MIT License (MIT)

258
ttrv.egg-info/PKG-INFO Normal file
View File

@ -0,0 +1,258 @@
Metadata-Version: 2.1
Name: ttrv
Version: 1.27.3
Summary: Tilde Terminal Reddit Viewer
Home-page: https://github.com/tildeclub/ttrv
Author: deepend (forked from RTV)
Author-email: deepend@tilde.club
License: MIT
Keywords: reddit terminal praw curses
Platform: UNKNOWN
Classifier: Intended Audience :: End Users/Desktop
Classifier: Environment :: Console :: Curses
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Topic :: Terminals
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
Description-Content-Type: text/markdown
Provides-Extra: test
License-File: LICENSE
License-File: AUTHORS.rst
<h1 align="center">Tilde Terminal Reddit Viewer (TTRV)</h1>
<p>Forked from Original source/development at: <a href="https://github.com/michael-lazar/rtv">RTV</a></p>
<p align="center">
A text-based interface (TUI) to view and interact with Reddit from your terminal.<br>
</p>
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/title_image.png"/>
</p>
<p align="center">
</p>
## Table of Contents
* [Demo](#demo)
* [Installation](#installation)
* [Usage](#usage)
* [Settings](#settings)
* [Themes](#themes)
* [FAQ](#faq)
* [Contributing](#contributing)
* [License](#license)
## Demo
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/demo.gif"/>
</p>
## Installation
### PyPI package
TTRV is available on [PyPI](https://pypi.python.org/pypi/ttrv/) and can be installed with pip:
```bash
$ pip install ttrv
```
### From source
```bash
$ git clone https://github.com/tildeclub/ttrv.git
$ cd ttrv/
$ python setup.py install
```
### Windows
TTRV is not supported on Windows but you can enable Windows subsystem for Linux, download your preferred Linux distribution from Microsoft Store and access it from there.
To open links on Edge, paste the line below to ``{HOME}/.bashrc``
```
export BROWSER='/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
```
## Usage
To run the program, type:
```bash
$ ttrv --help
```
### Controls
Move the cursor using either the arrow keys or *Vim* style movement:
- Press <kbd>▲</kbd> and <kbd>▼</kbd> to scroll through submissions
- Press <kbd>▶</kbd> to view the selected submission and <kbd>◀</kbd> to return
- Press <kbd>space-bar</kbd> to expand/collapse comments
- Press <kbd>u</kbd> to login (this requires a web browser for [OAuth](https://github.com/reddit-archive/reddit/wiki/oauth2))
- Press <kbd>?</kbd> to open the help screen
Press <kbd>/</kbd> to open the navigation prompt, where you can type things like:
- ``/front``
- ``/r/commandprompt+linuxmasterrace``
- ``/r/programming/controversial``
- ``/u/me``
- ``/u/multi-mod/m/art``
- ``/domain/github.com``
See [CONTROLS](CONTROLS.md) for the full list of commands.
## Settings
### Configuration File
Configuration files are stored in the ``{HOME}/.config/ttrv/`` directory.
Check out [ttrv.cfg](ttrv/templates/ttrv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
```bash
$ ttrv --copy-config
```
### Viewing Media Links
You can use [mailcap](https://en.wikipedia.org/wiki/Media_type#Mailcap) to configure how TTRV will open different types of links.
<p align="center">
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/mailcap.gif"/>
</p>
A mailcap file allows you to associate different MIME media types, like ``image/jpeg`` or ``video/mp4``, with shell commands. This feature is disabled by default because it takes a few extra steps to configure. To get started, copy the default mailcap template to your home directory.
```bash
$ ttrv --copy-mailcap
```
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](ttrv/templates/mailcap) listed inside.
Once you've setup your mailcap file, enable it by launching ttrv with the ``ttrv --enable-media`` flag (or set it in your **ttrv.cfg**)
### Environment Variables
The default programs that TTRV interacts with can be configured through environment variables:
<table>
<tr>
<td><strong>$TTRV_EDITOR</strong></td>
<td>A program used to compose text submissions and comments, e.g. <strong>vim</strong>, <strong>emacs</strong>, <strong>gedit</strong>
<br/> <em>If not specified, will fallback to $VISUAL and $EDITOR in that order.</em></td>
</tr>
<tr>
<td><strong>$TTRV_BROWSER</strong></td>
<td>A program used to open links to external websites, e.g. <strong>firefox</strong>, <strong>google-chrome</strong>, <strong>w3m</strong>, <strong>lynx</strong>
<br/> <em>If not specified, will fallback to $BROWSER, or your system's default browser.</em></td>
</tr>
<tr>
<td><strong>$TTRV_URLVIEWER</strong></td>
<td>A tool used to extract hyperlinks from blocks of text, e.g. <a href=https://github.com/sigpipe/urlview>urlview</a>, <a href=https://github.com/firecat53/urlscan>urlscan</a>
<br/> <em>If not specified, will fallback to urlview if it is installed.</em></td>
</tr>
</table>
### Clipboard
TTRV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
## Themes
Themes can be used to customize the look and feel of TTRV
<table>
<tr>
<td align="center">
<p><strong>Solarized Dark</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_dark.png"></img>
</td>
<td align="center">
<p><strong>Solarized Light</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_light.png"></img>
</td>
</tr>
<tr>
<td align="center">
<p><strong>Papercolor</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_papercolor.png"></img>
</td>
<td align="center">
<p><strong>Molokai</strong></p>
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_molokai.png"></img>
</td>
</tr>
</table>
You can list all installed themes with the ``--list-themes`` command, and select one with ``--theme``. You can save your choice permanently in your [ttrv.cfg](ttrv/templates/ttrv.cfg) file. You can also use the <kbd>F2</kbd> & <kbd>F3</kbd> keys inside of TTRV to cycle through all available themes.
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
## FAQ
<details>
<summary>Why am I getting an error during installation/when launching ttrv?</summary>
> If your distro ships with an older version of python 2.7 or python-requests,
> you may experience SSL errors or other package incompatibilities. The
> easiest way to fix this is to install ttrv using python 3. If you
> don't already have pip3, see http://stackoverflow.com/a/6587528 for setup
> instructions. Then do
>
> ```bash
> $ sudo pip uninstall ttrv
> $ sudo pip3 install -U ttrv
> ```
</details>
<details>
<summary>Why do I see garbled text like <em>M-b~@M-"</em> or <em>^@</em>?</summary>
> This type of text usually shows up when python is unable to render
> unicode properly.
>
> 1. Try starting TTRV in ascii-only mode with ``ttrv --ascii``
> 2. Make sure that the terminal/font that you're using supports unicode
> 3. Try [setting the LOCALE to utf-8](https://perlgeek.de/en/article/set-up-a-clean-utf8-environment)
> 4. Your python may have been built against the wrong curses library,
> see [here](stackoverflow.com/questions/19373027) and
> [here](https://bugs.python.org/issue4787) for more information
</details>
<details>
<summary>How do I run the code directly from the repository?</summary>
> This project is structured to be run as a python *module*. This means that
> you need to launch it using python's ``-m`` flag. See the example below, which
> assumes that you have cloned the repository into the directory **~/ttrv_project**.
>
> ```bash
> $ cd ~/ttrv_project
> $ python3 -m ttrv
> ```
</details>
## Contributing
All feedback and suggestions are welcome, just post an issue!
Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
## License
This project is distributed under the [MIT](LICENSE) license.

54
ttrv.egg-info/SOURCES.txt Normal file
View File

@ -0,0 +1,54 @@
AUTHORS.rst
CHANGELOG.rst
LICENSE
MANIFEST.in
README.md
setup.cfg
setup.py
ttrv.1
version.py
ttrv/__init__.py
ttrv/__main__.py
ttrv/__version__.py
ttrv/clipboard.py
ttrv/config.py
ttrv/content.py
ttrv/docs.py
ttrv/exceptions.py
ttrv/inbox_page.py
ttrv/mime_parsers.py
ttrv/oauth.py
ttrv/objects.py
ttrv/page.py
ttrv/submission_page.py
ttrv/subreddit_page.py
ttrv/subscription_page.py
ttrv/terminal.py
ttrv/theme.py
ttrv.egg-info/PKG-INFO
ttrv.egg-info/SOURCES.txt
ttrv.egg-info/dependency_links.txt
ttrv.egg-info/entry_points.txt
ttrv.egg-info/requires.txt
ttrv.egg-info/top_level.txt
ttrv/packages/__init__.py
ttrv/packages/praw/__init__.py
ttrv/packages/praw/decorator_helpers.py
ttrv/packages/praw/decorators.py
ttrv/packages/praw/errors.py
ttrv/packages/praw/handlers.py
ttrv/packages/praw/helpers.py
ttrv/packages/praw/internal.py
ttrv/packages/praw/multiprocess.py
ttrv/packages/praw/objects.py
ttrv/packages/praw/praw.ini
ttrv/packages/praw/settings.py
ttrv/templates/index.html
ttrv/templates/mailcap
ttrv/templates/ttrv.cfg
ttrv/themes/colorblind-dark.cfg
ttrv/themes/default.cfg.example
ttrv/themes/molokai.cfg
ttrv/themes/papercolor.cfg
ttrv/themes/solarized-dark.cfg
ttrv/themes/solarized-light.cfg

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,3 @@
[console_scripts]
ttrv = ttrv.__main__:main

View File

@ -0,0 +1,16 @@
beautifulsoup4
decorator
kitchen
requests>=2.4.0
six
[:python_version<'3.6']
mailcap-fix
[test]
coveralls
pytest>=3.1.0
coverage
mock
pylint
vcrpy

View File

@ -0,0 +1 @@
ttrv

33
ttrv/__init__.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
r"""
________ _____ ______
___ __/__________________ ______(_)____________ ___ /
__ / _ _ \_ ___/_ __ `__ \_ /__ __ \ __ `/_ /
_ / / __/ / _ / / / / / / _ / / / /_/ /_ /
/_/ \___//_/ /_/ /_/ /_//_/ /_/ /_/\__,_/ /_/
________ __________________________
___ __ \__________ /_____ /__(_)_ /_
__ /_/ / _ \ __ /_ __ /__ /_ __/
_ _, _// __/ /_/ / / /_/ / _ / / /_
/_/ |_| \___/\__,_/ \__,_/ /_/ \__/
___ ______
__ | / /__(_)_______ ______________
__ | / /__ /_ _ \_ | /| / / _ \_ ___/
__ |/ / _ / / __/_ |/ |/ // __/ /
_____/ /_/ \___/____/|__/ \___//_/
(TTVR)
"""
from __future__ import unicode_literals
from .__version__ import __version__
__title__ = 'Tilde Terminal Reddit Viewer'
__author__ = 'tildeclub'
__license__ = 'The MIT License (MIT)'
__copyright__ = '(c) 2019 tilde.club'

280
ttrv/__main__.py Executable file
View File

@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-position
from __future__ import unicode_literals
from __future__ import print_function
import os
import sys
import locale
import logging
import warnings
import six
import requests
# Need to check for curses compatibility before performing the trv imports
try:
import curses
except ImportError:
if sys.platform == 'win32':
sys.exit('Fatal Error: This program is not compatible with Windows '
'Operating Systems.')
else:
sys.exit('Fatal Error: Your python distribution appears to be missing '
'_curses.so.\nWas it compiled without support for curses?')
# If we want to override the $BROWSER variable that the python webbrowser
# references, it needs to be done before the webbrowser module is imported
# for the first time.
webbrowser_import_warning = ('webbrowser' in sys.modules)
TTRV_BROWSER, BROWSER = os.environ.get('TTRV_BROWSER'), os.environ.get('BROWSER')
if TTRV_BROWSER:
os.environ['BROWSER'] = TTRV_BROWSER
from . import docs
from . import packages
from .packages import praw
from .config import Config, copy_default_config, copy_default_mailcap
from .theme import Theme
from .oauth import OAuthHelper
from .terminal import Terminal
from .content import RequestHeaderRateLimiter
from .objects import curses_session, patch_webbrowser
from .subreddit_page import SubredditPage
from .submission_page import SubmissionPage
from .exceptions import ConfigError, SubredditError, SubmissionError
from .__version__ import __version__
_logger = logging.getLogger(__name__)
# Pycharm debugging note:
# You can use pycharm to debug a curses application by launching ttrv in a
# console window (python -m ttrv) and using pycharm to attach to the remote
# process. On Ubuntu, you may need to allow ptrace permissions by setting
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
def main():
"""Main entry point"""
# Squelch SSL warnings
logging.captureWarnings(True)
if six.PY3:
# These ones get triggered even when capturing warnings is turned on
warnings.simplefilter('ignore', ResourceWarning) # pylint:disable=E0602
# Set the terminal title
if os.getenv('DISPLAY'):
title = 'ttrv {0}'.format(__version__)
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
sys.stdout.flush()
args = Config.get_args()
fargs, bindings = Config.get_file(args.get('config'))
# Apply the file config first, then overwrite with any command line args
config = Config()
config.update(**fargs)
config.update(**args)
# If key bindings are supplied in the config file, overwrite the defaults
if bindings:
config.keymap.set_bindings(bindings)
if config['copy_config']:
return copy_default_config()
if config['copy_mailcap']:
return copy_default_mailcap()
if config['list_themes']:
return Theme.print_themes()
# Load the browsing history from previous sessions
config.load_history()
# Load any previously saved auth session token
config.load_refresh_token()
if config['clear_auth']:
config.delete_refresh_token()
if config['log']:
# Log request headers to the file (print hack only works on python 3.x)
# from http import client
# _http_logger = logging.getLogger('http.client')
# client.HTTPConnection.debuglevel = 2
# def print_to_file(*args, **_):
# if args[0] != "header:":
# _http_logger.info(' '.join(args))
# client.print = print_to_file
logging.basicConfig(
level=logging.DEBUG,
filename=config['log'],
format='%(asctime)s:%(levelname)s:%(filename)s:%(lineno)d:%(message)s')
else:
# Add an empty handler so the logger doesn't complain
logging.root.addHandler(logging.NullHandler())
# Make sure the locale is UTF-8 for unicode support
default_locale = locale.setlocale(locale.LC_ALL, '')
try:
encoding = locale.getlocale()[1] or locale.getdefaultlocale()[1]
except ValueError:
# http://stackoverflow.com/a/19961403
# OS X on some terminals will set the LC_CTYPE to "UTF-8"
# (as opposed to something like "en_US.UTF-8") and python
# doesn't know how to handle it.
_logger.warning('Error parsing system locale: `%s`,'
' falling back to utf-8', default_locale)
encoding = 'UTF-8'
if not encoding or encoding.lower() != 'utf-8':
text = ('System encoding was detected as (%s) instead of UTF-8'
', falling back to ascii only mode' % encoding)
warnings.warn(text)
config['ascii'] = True
if packages.__praw_bundled__:
praw_info = 'packaged, commit {}'.format(packages.__praw_hash__[:12])
else:
praw_info = 'system installed v{}'.format(praw.__version__)
# Update the webbrowser module's default behavior
patch_webbrowser()
if webbrowser_import_warning:
_logger.warning('webbrowser module was unexpectedly imported before'
'$BROWSER could be overwritten')
# Construct the reddit user agent
user_agent = docs.AGENT.format(version=__version__)
debug_info = [
'ttrv version: ttrv {}'.format(__version__),
'ttrv module path: {}'.format(os.path.abspath(__file__)),
'python version: {}'.format(sys.version.replace('\n', ' ')),
'python executable: {}'.format(sys.executable),
'praw version: {}'.format(praw_info),
'locale, encoding: {}, {}'.format(default_locale, encoding),
'Environment Variables']
for name, value in [
('BROWSER', BROWSER),
('DISPLAY', os.getenv('DISPLAY')),
('EDITOR', os.getenv('EDITOR')),
('LANG', os.getenv('LANG')),
('PAGER', os.getenv('PAGER')),
('TTRV_BROWSER', TTRV_BROWSER),
('TTRV_EDITOR', os.getenv('TTRV_EDITOR')),
('TTRV_PAGER', os.getenv('TTRV_PAGER')),
('TTRV_URLVIEWER', os.getenv('TTRV_URLVIEWER')),
('TERM', os.getenv('TERM')),
('VISUAL', os.getenv('VISUAL')),
('XDG_CONFIG_HOME', os.getenv('XDG_CONFIG_HOME')),
('XDG_DATA_HOME', os.getenv('XDG_DATA_HOME')),
]:
debug_info.append(' {:<16}: {}'.format(name, value or ''))
debug_info.append('')
debug_text = '\n'.join(debug_info)
_logger.info(debug_text)
if config['debug_info']:
print(debug_text)
return
try:
with curses_session() as stdscr:
term = Terminal(stdscr, config)
if config['monochrome'] or config['theme'] == 'monochrome':
_logger.info('Using monochrome theme')
theme = Theme(use_color=False)
elif config['theme'] and config['theme'] != 'default':
_logger.info('Loading theme: %s', config['theme'])
theme = Theme.from_name(config['theme'])
else:
# Set to None to let the terminal figure out which theme
# to use depending on if colors are supported or not
theme = None
term.set_theme(theme)
with term.loader('Initializing', catch_exception=False):
reddit = praw.Reddit(user_agent=user_agent,
decode_html_entities=False,
disable_update_check=True,
timeout=10, # 10 second request timeout
handler=RequestHeaderRateLimiter())
# Dial the request cache up from 30 seconds to 5 minutes
# I'm trying this out to make navigation back and forth
# between pages quicker, it may still need to be fine tuned.
reddit.config.api_request_delay = 300
# Authorize on launch if the refresh token is present
oauth = OAuthHelper(reddit, term, config)
if config['autologin'] and config.refresh_token:
oauth.authorize(autologin=True)
# Open the supplied submission link before opening the subreddit
if config['link']:
# Expand shortened urls like https://redd.it/
# Praw won't accept the shortened versions, add the reddit
# headers to avoid a 429 response from reddit.com
url = requests.head(
config['link'],
headers=reddit.http.headers,
allow_redirects=True
).url
page = None
with term.loader('Loading submission'):
try:
page = SubmissionPage(reddit, term, config, oauth, url)
except Exception as e:
_logger.exception(e)
raise SubmissionError('Unable to load {0}'.format(url))
while page:
page = page.loop()
page = None
name = config['subreddit']
with term.loader('Loading subreddit'):
try:
page = SubredditPage(reddit, term, config, oauth, name)
except Exception as e:
# If we can't load the subreddit that was requested, try
# to load the "popular" page instead so at least the
# application still launches. This used to use the user's
# front page, but some users have an empty front page.
_logger.exception(e)
page = SubredditPage(reddit, term, config, oauth, 'popular')
raise SubredditError('Unable to load {0}'.format(name))
# Launch the subreddit page
while page:
page = page.loop()
except ConfigError as e:
_logger.exception(e)
print(e)
except Exception as e:
_logger.exception(e)
import traceback
exit_message = '\n'.join([
debug_text,
traceback.format_exc(),
'ttrv has crashed. Please report this traceback at:',
'https://github.com/tildeclub/ttrv/issues\n'])
sys.stderr.write(exit_message)
return 1 # General error exception code
except KeyboardInterrupt:
pass
finally:
# Try to save the browsing history
config.save_history()
# Ensure sockets are closed to prevent a ResourceWarning
if 'reddit' in locals():
reddit.handler.http.close()
sys.exit(main())

4
ttrv/__version__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
__version__ = '1.27.3'

51
ttrv/clipboard.py Normal file
View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import subprocess
from .exceptions import ProgramError
def _subprocess_copy(text, args_list):
p = subprocess.Popen(args_list, stdin=subprocess.PIPE, close_fds=True)
p.communicate(input=text.encode('utf-8'))
def copy(text):
"""
Copy text to OS clipboard.
"""
if sys.platform == 'darwin':
copy_osx(text)
else:
# For Linux, BSD, cygwin, etc.
copy_linux(text)
def copy_osx(text):
_subprocess_copy(text, ['pbcopy', 'w'])
def copy_linux(text):
def get_command_name():
# Checks for the installation of xsel or xclip
for cmd in ['xsel', 'xclip']:
cmd_exists = subprocess.call(
['which', cmd],
stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
if cmd_exists:
return cmd
return None
cmd_args = {
'xsel': ['xsel', '-b', '-i'],
'xclip': ['xclip', '-selection', 'c']}
cmd_name = get_command_name()
if cmd_name is None:
raise ProgramError("External copy application not found")
_subprocess_copy(text, cmd_args.get(cmd_name))

303
ttrv/config.py Normal file
View File

@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import codecs
import shutil
import argparse
from functools import partial
import six
from six.moves import configparser
from . import docs, __version__
from .objects import KeyMap
PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~')
TEMPLATES = os.path.join(PACKAGE, 'templates')
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'ttrv.cfg')
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
XDG_DATA_HOME = os.getenv('XDG_DATA_HOME', os.path.join(HOME, '.local', 'share'))
CONFIG = os.path.join(XDG_CONFIG_HOME, 'ttrv', 'ttrv.cfg')
MAILCAP = os.path.join(HOME, '.mailcap')
TOKEN = os.path.join(XDG_DATA_HOME, 'ttrv', 'refresh-token')
HISTORY = os.path.join(XDG_DATA_HOME, 'ttrv', 'history.log')
THEMES = os.path.join(XDG_CONFIG_HOME, 'ttrv', 'themes')
def build_parser():
parser = argparse.ArgumentParser(
prog='ttrv', description=docs.SUMMARY,
epilog=docs.CONTROLS,
usage=docs.USAGE,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'link', metavar='URL', nargs='?',
help='[optional] Full URL of a submission to open')
parser.add_argument(
'-s', dest='subreddit',
help='Name of the subreddit that will be loaded on start')
parser.add_argument(
'-l', dest='link_deprecated',
help=argparse.SUPPRESS) # Deprecated, use the positional arg instead
parser.add_argument(
'--log', metavar='FILE', action='store',
help='Log HTTP requests to the given file')
parser.add_argument(
'--config', metavar='FILE', action='store',
help='Load configuration settings from the given file')
parser.add_argument(
'--ascii', action='store_const', const=True,
help='Enable ascii-only mode')
parser.add_argument(
'--monochrome', action='store_const', const=True,
help='Disable color')
parser.add_argument(
'--theme', metavar='FILE', action='store',
help='Color theme to use, see --list-themes for valid options')
parser.add_argument(
'--list-themes', metavar='FILE', action='store_const', const=True,
help='List all of the available color themes')
parser.add_argument(
'--non-persistent', dest='persistent', action='store_const', const=False,
help='Forget the authenticated user when the program exits')
parser.add_argument(
'--no-autologin', dest='autologin', action='store_const', const=False,
help='Do not authenticate automatically on startup')
parser.add_argument(
'--clear-auth', dest='clear_auth', action='store_const', const=True,
help='Remove any saved user data before launching')
parser.add_argument(
'--copy-config', dest='copy_config', action='store_const', const=True,
help='Copy the default configuration to {HOME}/.config/ttrv/ttrv.cfg')
parser.add_argument(
'--copy-mailcap', dest='copy_mailcap', action='store_const', const=True,
help='Copy an example mailcap configuration to {HOME}/.mailcap')
parser.add_argument(
'--enable-media', dest='enable_media', action='store_const', const=True,
help='Open external links using programs defined in the mailcap config')
parser.add_argument(
'-V', '--version', action='version', version='ttrv ' + __version__)
parser.add_argument(
'--no-flash', dest='flash', action='store_const', const=False,
help='Disable screen flashing')
parser.add_argument(
'--debug-info', dest='debug_info', action='store_const', const=True,
help='Show system and environment information and exit')
return parser
def copy_default_mailcap(filename=MAILCAP):
"""
Copy the example mailcap configuration to the specified file.
"""
return _copy_settings_file(DEFAULT_MAILCAP, filename, 'mailcap')
def copy_default_config(filename=CONFIG):
"""
Copy the default ttrv user configuration to the specified file.
"""
return _copy_settings_file(DEFAULT_CONFIG, filename, 'config')
def _copy_settings_file(source, destination, name):
"""
Copy a file from the repo to the user's home directory.
"""
if os.path.exists(destination):
try:
ch = six.moves.input(
'File %s already exists, overwrite? y/[n]):' % destination)
if ch not in ('Y', 'y'):
return
except KeyboardInterrupt:
return
filepath = os.path.dirname(destination)
if not os.path.exists(filepath):
os.makedirs(filepath)
print('Copying default %s to %s' % (name, destination))
shutil.copy(source, destination)
os.chmod(destination, 0o664)
class OrderedSet(object):
"""
A simple implementation of an ordered set. A set is used to check
for membership, and a list is used to maintain ordering.
"""
def __init__(self, elements=None):
elements = elements or []
self._set = set(elements)
self._list = elements
def __contains__(self, item):
return item in self._set
def __len__(self):
return len(self._list)
def __getitem__(self, item):
return self._list[item]
def add(self, item):
self._set.add(item)
self._list.append(item)
class Config(object):
"""
This class manages the loading and saving of configs and other files.
"""
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
self.history_file = history_file
self.token_file = token_file
self.config = kwargs
default, bindings = self.get_file(DEFAULT_CONFIG)
self.default = default
self.keymap = KeyMap(bindings)
# `refresh_token` and `history` are saved/loaded at separate locations,
# so they are treated differently from the rest of the config options.
self.refresh_token = None
self.history = OrderedSet()
def __getitem__(self, item):
if item in self.config:
return self.config[item]
else:
return self.default.get(item, None)
def __setitem__(self, key, value):
self.config[key] = value
def __delitem__(self, key):
self.config.pop(key, None)
def update(self, **kwargs):
self.config.update(kwargs)
def load_refresh_token(self):
if os.path.exists(self.token_file):
with open(self.token_file) as fp:
self.refresh_token = fp.read().strip()
else:
self.refresh_token = None
def save_refresh_token(self):
self._ensure_filepath(self.token_file)
with open(self.token_file, 'w+') as fp:
fp.write(self.refresh_token)
def delete_refresh_token(self):
if os.path.exists(self.token_file):
os.remove(self.token_file)
self.refresh_token = None
def load_history(self):
if os.path.exists(self.history_file):
with codecs.open(self.history_file, encoding='utf-8') as fp:
self.history = OrderedSet([line.strip() for line in fp])
else:
self.history = OrderedSet()
def save_history(self):
self._ensure_filepath(self.history_file)
with codecs.open(self.history_file, 'w+', encoding='utf-8') as fp:
fp.writelines('\n'.join(self.history[-self['history_size']:]))
def delete_history(self):
if os.path.exists(self.history_file):
os.remove(self.history_file)
self.history = OrderedSet()
@staticmethod
def get_args():
"""
Load settings from the command line.
"""
parser = build_parser()
args = vars(parser.parse_args())
# Overwrite the deprecated "-l" option into the link variable
if args['link_deprecated'] and args['link'] is None:
args['link'] = args['link_deprecated']
args.pop('link_deprecated', None)
# Filter out argument values that weren't supplied
return {key: val for key, val in args.items() if val is not None}
@classmethod
def get_file(cls, filename=None):
"""
Load settings from an ttrv configuration file.
"""
if filename is None:
filename = CONFIG
config = configparser.ConfigParser()
if os.path.exists(filename):
with codecs.open(filename, encoding='utf-8') as fp:
config.readfp(fp)
return cls._parse_ttrv_file(config)
@staticmethod
def _parse_ttrv_file(config):
ttrv = {}
if config.has_section('ttrv'):
ttrv = dict(config.items('ttrv'))
# convert non-string params to their typed representation
params = {
'ascii': partial(config.getboolean, 'ttrv'),
'monochrome': partial(config.getboolean, 'ttrv'),
'persistent': partial(config.getboolean, 'ttrv'),
'autologin': partial(config.getboolean, 'ttrv'),
'clear_auth': partial(config.getboolean, 'ttrv'),
'enable_media': partial(config.getboolean, 'ttrv'),
'history_size': partial(config.getint, 'ttrv'),
'oauth_redirect_port': partial(config.getint, 'ttrv'),
'oauth_scope': lambda x: ttrv[x].split(','),
'max_comment_cols': partial(config.getint, 'ttrv'),
'max_pager_cols': partial(config.getint, 'ttrv'),
'hide_username': partial(config.getboolean, 'ttrv'),
'flash': partial(config.getboolean, 'ttrv'),
'force_new_browser_window': partial(config.getboolean, 'ttrv')
}
for key, func in params.items():
if key in ttrv:
ttrv[key] = func(key)
bindings = {}
if config.has_section('bindings'):
bindings = dict(config.items('bindings'))
for name, keys in bindings.items():
bindings[name] = [key.strip() for key in keys.split(',')]
return ttrv, bindings
@staticmethod
def _ensure_filepath(filename):
"""
Ensure that the directory exists before trying to write to the file.
"""
filepath = os.path.dirname(filename)
if not os.path.exists(filepath):
os.makedirs(filepath)

1189
ttrv/content.py Normal file

File diff suppressed because it is too large Load Diff

229
ttrv/docs.py Normal file
View File

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
AGENT = """\
desktop:https://github.com/tildeclub/ttrv:{version}\
(by /u/civilization_phaze_3)\
"""
SUMMARY = """
TVR (Terminal Viewer Reddit) is a terminal interface to view and interact with reddit.
"""
USAGE = """\
ttrv [URL] [-s SUBREDDIT]
$ ttrv https://www.reddit.com/r/programming/comments/7h9l31
$ ttrv -s linux
"""
CONTROLS = """
Move the cursor using the arrow keys or vim style movement.
Press `?` to open the help screen.
"""
HELP = """\
====================================
Reddit Terminal Viewer
https://github.com/tildeclub/ttrv
====================================
[Basic Commands]
j : Move the cursor down
k : Move the cursor up
l : View the currently selected item
h : Return to the previous view
m : Move the cursor up one page
n : Move the cursor down one page
gg : Jump to the top of the page
G : Jump to the bottom of the page
1-7 : Sort submissions by category
r : Refresh the content on the current page
u : Login to your reddit account
q : Quit
Q : Force quit
y : Copy submission permalink to clipboard
Y : Copy submission link to clipboard
F2 : Cycle to the previous color theme
F3 : Cycle to the next color theme
? : Show the help screen
/ : Open a prompt to select a subreddit
[Authenticated Commands]
a : Upvote
z : Downvote
c : Compose a new submission or comment
C : Compose a new private message
e : Edit the selected submission or comment
d : Delete the selected submission or comment
i : View your inbox (see Inbox Mode)
s : View your subscribed subreddits (see Subscription Mode)
S : View your subscribed multireddits (see Subscription Mode)
u : Logout of your reddit account
w : Save the selected submission or comment
[Subreddit Mode]
l : View the comments for the selected submission (see Submission Mode)
o : Open the selected submission link using your web browser
SPACE : Mark the selected submission as hidden
p : Toggle between the currently viewed subreddit and /r/front
f : Open a prompt to search the current subreddit for a text string
[Submission Mode]
h : Close the submission and return to the previous page
l : View the selected comment using the system's pager
o : Open a link in the comment using your web browser
SPACE : Fold or expand the selected comment and its children
b : Send the comment text to the system's urlviewer application
J : Move the cursor down the the next comment at the same indentation
K : Move the cursor up to the parent comment
[Subscription Mode]
h : Close your subscriptions and return to the previous page
l : Open the selected subreddit or multireddit
[Inbox Mode]
h : Close your inbox and return to the previous page
l : View the context of the selected comment
o : Open the submission of the selected comment
c : Reply to the selected comment or message
w : Mark the selected comment or message as seen
[Prompt]
The / key opens a text prompt at the bottom of the screen. You can use this
to type in the name of the subreddit that you want to open. The following
text formats are recognized:
/python - Open a subreddit, shorthand
/r/python - Open a subreddit
/r/python/new - Open a subreddit, sorted by category
/r/python/controversial-year - Open a subreddit, sorted by category and time
/r/python+linux+commandline - Open multiple subreddits merged together
/comments/30rwj2 - Open a submission, shorthand
/r/python/comments/30rwj2 - Open a submission
/r/front - Open your front page
/u/me - View your submissions
/u/me/saved - View your saved content
/u/me/hidden - View your hidden content
/u/me/upvoted - View your upvoted content
/u/me/downvoted - View your downvoted content
/u/spez - View a user's submissions and comments
/u/spez/submitted - View a user's submissions
/u/spez/comments - View a user's comments
/u/multi-mod/m/android - Open a user's curated multireddit
/domain/python.org - Search for links for the given domain
"""
BANNER_SUBREDDIT = """
[1]hot [2]top [3]rising [4]new [5]controversial [6]gilded
"""
BANNER_SUBMISSION = """
[1]hot [2]top [3]rising [4]new [5]controversial
"""
BANNER_SEARCH = """
[1]relevance [2]top [3]comments [4]new
"""
BANNER_INBOX = """
[1]all [2]unread [3]messages [4]comments [5]posts [6]mentions [7]sent
"""
FOOTER_SUBREDDIT = """
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote [r]Refresh
"""
FOOTER_SUBMISSION = """
[?]Help [q]Quit [h]Return [space]Fold/Expand [o]Open [c]Comment [a/z]Vote [r]Refresh
"""
FOOTER_SUBSCRIPTION = """
[?]Help [q]Quit [h]Return [l]Select Subreddit [r]Refresh
"""
FOOTER_INBOX = """
[?]Help [l]View Context [o]Open Submission [c]Reply [w]Mark Read [r]Refresh
"""
TOKEN = "INSTRUCTIONS"
REPLY_FILE = """<!--{token}
Replying to {{author}}'s {{type}}:
{{content}}
Enter your reply below this instruction block,
an empty message will abort the comment.
{token}-->
""".format(token=TOKEN)
COMMENT_EDIT_FILE = """<!--{token}
Editing comment #{{id}}.
The comment is shown below, update it and save the file.
{token}-->
{{content}}
""".format(token=TOKEN)
SUBMISSION_FILE = """<!--{token}
Submitting a selfpost to {{name}}.
Enter your submission below this instruction block:
- The first line will be interpreted as the title
- The following lines will be interpreted as the body
- An empty message will abort the submission
{token}-->
""".format(token=TOKEN)
SUBMISSION_EDIT_FILE = """<!--{token}
Editing submission #{{id}}.
The submission is shown below, update it and save the file.
{token}-->
{{content}}
""".format(token=TOKEN)
MESSAGE_FILE = """<!--{token}
Compose a new private message
Enter your message below this instruction block:
- The first line should contain the recipient's reddit name
- The second line should contain the message subject
- Subsequent lines will be interpreted as the message body
{token}-->
""".format(token=TOKEN)
OAUTH_ACCESS_DENIED = """\
<h1 style="color: red">Access Denied</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was
denied access and will continue to operate in unauthenticated mode,
you can close this window.</p>
"""
OAUTH_ERROR = """\
<h1 style="color: red">Error</h1><hr>
<p>{error}</p>
"""
OAUTH_INVALID = """\
<h1>Wait...</h1><hr>
<p>This page is supposed to be a Reddit OAuth callback.
You can't just come here hands in your pocket!</p>
"""
OAUTH_SUCCESS = """\
<h1 style="color: green">Access Granted</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span>
will now log in, you can close this window.</p>
"""
TIME_ORDER_MENU = """
Links from:
[1] Past hour
[2] Past 24 hours
[3] Past week
[4] Past month
[5] Past year
[6] All time
"""

63
ttrv/exceptions.py Normal file
View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed"
class ConfigError(Exception):
"There was a problem with the configuration"
class TTRVError(Exception):
"Base TTRV error class"
class AccountError(TTRVError):
"Could not access user account"
class SubmissionError(TTRVError):
"Submission could not be loaded"
class SubredditError(TTRVError):
"Subreddit could not be loaded"
class NoSubmissionsError(TTRVError):
"No submissions for the given page"
def __init__(self, name):
self.name = name
message = '`{0}` has no submissions'.format(name)
super(NoSubmissionsError, self).__init__(message)
class SubscriptionError(TTRVError):
"Content could not be fetched"
class InboxError(TTRVError):
"Content could not be fetched"
class ProgramError(TTRVError):
"Problem executing an external program"
class BrowserError(TTRVError):
"Could not open a web browser tab"
class TemporaryFileError(TTRVError):
"Indicates that an error has occurred and the file should not be deleted"
class MailcapEntryNotFound(TTRVError):
"A valid mailcap entry could not be coerced from the given url"
class InvalidRefreshToken(TTRVError):
"The refresh token is corrupt and cannot be used to login"

204
ttrv/inbox_page.py Normal file
View File

@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import InboxContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class InboxController(PageController):
character_map = {}
class InboxPage(Page):
BANNER = docs.BANNER_INBOX
FOOTER = docs.FOOTER_INBOX
name = 'inbox'
def __init__(self, reddit, term, config, oauth, content_type='all'):
super(InboxPage, self).__init__(reddit, term, config, oauth)
self.controller = InboxController(self, keymap=config.keymap)
self.content = InboxContent.from_user(reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
def handle_selected_page(self):
"""
Open the subscription and submission pages subwindows, but close the
current page if any other type of page is selected.
"""
if not self.selected_page:
pass
if self.selected_page.name in ('subscription', 'submission'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
@logged_in
def refresh_content(self, order=None, name=None):
"""
Re-download all inbox content and reset the page index
"""
self.content_type = order or self.content_type
with self.term.loader():
self.content = InboxContent.from_user(
self.reddit, self.term.loader, self.content_type)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@InboxController.register(Command('SORT_1'))
def load_content_inbox(self):
self.refresh_content(order='all')
@InboxController.register(Command('SORT_2'))
def load_content_unread_messages(self):
self.refresh_content(order='unread')
@InboxController.register(Command('SORT_3'))
def load_content_messages(self):
self.refresh_content(order='messages')
@InboxController.register(Command('SORT_4'))
def load_content_comment_replies(self):
self.refresh_content(order='comments')
@InboxController.register(Command('SORT_5'))
def load_content_post_replies(self):
self.refresh_content(order='posts')
@InboxController.register(Command('SORT_6'))
def load_content_username_mentions(self):
self.refresh_content(order='mentions')
@InboxController.register(Command('SORT_7'))
def load_content_sent_messages(self):
self.refresh_content(order='sent')
@InboxController.register(Command('INBOX_MARK_READ'))
@logged_in
def mark_seen(self):
"""
Mark the selected message or comment as seen.
"""
data = self.get_selected_item()
if data['is_new']:
with self.term.loader('Marking as read'):
data['object'].mark_as_read()
if not self.term.loader.exception:
data['is_new'] = False
else:
with self.term.loader('Marking as unread'):
data['object'].mark_as_unread()
if not self.term.loader.exception:
data['is_new'] = True
@InboxController.register(Command('INBOX_REPLY'))
@logged_in
def inbox_reply(self):
"""
Reply to the selected private message or comment from the inbox.
"""
self.reply()
@InboxController.register(Command('INBOX_EXIT'))
def close_inbox(self):
"""
Close inbox and return to the previous page.
"""
self.active = False
@InboxController.register(Command('INBOX_VIEW_CONTEXT'))
@logged_in
def view_context(self):
"""
View the context surrounding the selected comment.
"""
url = self.get_selected_item().get('context')
if url:
self.selected_page = self.open_submission_page(url)
@InboxController.register(Command('INBOX_OPEN_SUBMISSION'))
@logged_in
def open_submission(self):
"""
Open the full submission and comment tree for the selected comment.
"""
url = self.get_selected_item().get('submission_permalink')
if url:
self.selected_page = self.open_submission_page(url)
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
row = offset
if row in valid_rows:
if data['is_new']:
attr = self.term.attr('New')
self.term.add_line(win, '[new]', row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), attr=attr)
self.term.add_space(win)
else:
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), row, 1, attr)
self.term.add_space(win)
if data['link_title']:
attr = self.term.attr('MessageLink')
self.term.add_line(win, '{link_title}'.format(**data), attr=attr)
row = offset + 1
if row in valid_rows:
# reddit.user might be ``None`` if the user logs out while viewing
# this page
if data['author'] == getattr(self.reddit.user, 'name', None):
self.term.add_line(win, 'to ', row, 1)
text = '{recipient}'.format(**data)
else:
self.term.add_line(win, 'from ', row, 1)
text = '{author}'.format(**data)
attr = self.term.attr('MessageAuthor')
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['distinguished']:
attr = self.term.attr('Distinguished')
text = '[{distinguished}]'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('Created')
text = 'sent {created_long}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['subreddit_name']:
attr = self.term.attr('MessageSubreddit')
text = 'via {subreddit_name}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('MessageText')
for row, text in enumerate(data['split_body'], start=offset + 2):
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

505
ttrv/mime_parsers.py Normal file
View File

@ -0,0 +1,505 @@
import re
import logging
import mimetypes
import json
import requests
from bs4 import BeautifulSoup
_logger = logging.getLogger(__name__)
class BaseMIMEParser(object):
"""
BaseMIMEParser can be sub-classed to define custom handlers for determining
the MIME type of external urls.
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
"""
Guess based on the file extension.
Args:
url (text): Web url that was linked to by a reddit submission.
Returns:
modified_url (text): The url (or filename) that will be used when
constructing the command to run.
content_type (text): The mime-type that will be used when
constructing the command to run. If the mime-type is unknown,
return None and the program will fallback to using the web
browser.
"""
filename = url.split('?')[0]
filename = filename.split('#')[0]
content_type, _ = mimetypes.guess_type(filename)
return url, content_type
class OpenGraphMIMEParser(BaseMIMEParser):
"""
Open graph protocol is used on many web pages.
<meta property="og:image" content="https://xxxx.jpg?ig_cache_key=xxxxx" />
<meta property="og:video:secure_url" content="https://xxxxx.mp4" />
If the page is a video page both of the above tags will be present and
priority is given to video content.
see http://ogp.me
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
for og_type in ['video', 'image']:
prop = 'og:' + og_type + ':secure_url'
tag = soup.find('meta', attrs={'property': prop})
if not tag:
prop = 'og:' + og_type
tag = soup.find('meta', attrs={'property': prop})
if tag:
return BaseMIMEParser.get_mimetype(tag.get('content'))
return url, None
class VideoTagMIMEParser(BaseMIMEParser):
"""
<video width="320" height="240" controls>
<source src="movie.mp4" res="HD" type="video/mp4">
<source src="movie.mp4" res="SD" type="video/mp4">
<source src="movie.ogg" type="video/ogg">
</video>
"""
pattern = re.compile(r'.*$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
# TODO: Handle pages with multiple videos
video = soup.find('video')
source = None
if video:
source = video.find('source', attr={'res': 'HD'})
source = source or video.find('source', attr={'type': 'video/mp4'})
source = source or video.find('source')
if source:
return source.get('src'), source.get('type')
else:
return url, None
class GfycatMIMEParser(BaseMIMEParser):
"""
Gfycat provides a primitive json api to generate image links. URLs can be
downloaded as either gif, mp4, webm, or mjpg. Mp4 was selected because it's
fast and works with VLC.
https://gfycat.com/api
https://gfycat.com/UntidyAcidicIberianemeraldlizard -->
https://giant.gfycat.com/UntidyAcidicIberianemeraldlizard.webm
"""
pattern = re.compile(r'https?://(www\.)?gfycat\.com/[^.]+$')
@staticmethod
def get_mimetype(url):
identifier = url.split('/')[-1]
api_url = 'https://api.gfycat.com/v1/gfycats/{}'.format(identifier)
resp = requests.get(api_url)
image_url = resp.json()['gfyItem']['mp4Url']
return image_url, 'video/mp4'
class YoutubeMIMEParser(BaseMIMEParser):
"""
Youtube videos can be streamed with vlc or downloaded with youtube-dl.
Assign a custom mime-type so they can be referenced in mailcap.
"""
pattern = re.compile(
r'(?:https?://)?(m\.)?(?:youtu\.be/|(?:www\.)?youtube\.com/watch'
r'(?:\.php)?\'?.*v=)([a-zA-Z0-9\-_]+)')
@staticmethod
def get_mimetype(url):
return url, 'video/x-youtube'
class VimeoMIMEParser(BaseMIMEParser):
"""
Vimeo videos can be streamed with vlc or downloaded with youtube-dl.
Assign a custom mime-type so they can be referenced in mailcap.
"""
pattern = re.compile(r'https?://(www\.)?vimeo\.com/\d+$')
@staticmethod
def get_mimetype(url):
return url, 'video/x-youtube'
class GifvMIMEParser(BaseMIMEParser):
"""
Special case for .gifv, which is a custom video format for imgur serves
as html with a special <video> frame. Note that attempting for download as
.webm also returns this html page. However, .mp4 appears to return the raw
video file.
"""
pattern = re.compile(r'.*[.]gifv$')
@staticmethod
def get_mimetype(url):
modified_url = url[:-4] + 'mp4'
return modified_url, 'video/mp4'
class RedditUploadsMIMEParser(BaseMIMEParser):
"""
Reddit uploads do not have a file extension, but we can grab the mime-type
from the page header.
"""
pattern = re.compile(r'https://i\.reddituploads\.com/.+$')
@staticmethod
def get_mimetype(url):
page = requests.head(url)
content_type = page.headers.get('Content-Type', '')
content_type = content_type.split(';')[0] # Strip out the encoding
return url, content_type
class RedditVideoMIMEParser(BaseMIMEParser):
"""
Reddit hosted videos/gifs.
Media uses MPEG-DASH format (.mpd)
"""
pattern = re.compile(r'https://v\.redd\.it/.+$')
@staticmethod
def get_mimetype(url):
request_url = url + '/DASHPlaylist.mpd'
page = requests.get(request_url)
soup = BeautifulSoup(page.content, 'html.parser')
if not soup.find('representation', attrs={'mimetype': 'audio/mp4'}):
reps = soup.find_all('representation', attrs={'mimetype': 'video/mp4'})
reps = sorted(reps, reverse=True, key=lambda t: int(t.get('bandwidth')))
if reps:
url_suffix = reps[0].find('baseurl')
if url_suffix:
return url + '/' + url_suffix.text, 'video/mp4'
return request_url, 'video/x-youtube'
class ImgurApiMIMEParser(BaseMIMEParser):
"""
Imgur now provides a json API exposing its entire infrastructure. Each Imgur
page has an associated hash and can either contain an album, a gallery,
or single image.
The default client token for TTRV is shared among users and allows a maximum
global number of requests per day of 12,500. If we find that this limit is
not sufficient for all of ttrv's traffic, this method will be revisited.
Reference:
https://apidocs.imgur.com
"""
CLIENT_ID = None
pattern = re.compile(
r'https?://(w+\.)?(m\.)?imgur\.com/'
r'((?P<domain>a|album|gallery)/)?(?P<hash>[a-zA-Z0-9]+)$')
@classmethod
def get_mimetype(cls, url):
endpoint = 'https://api.imgur.com/3/{domain}/{page_hash}'
headers = {'authorization': 'Client-ID {0}'.format(cls.CLIENT_ID)}
m = cls.pattern.match(url)
page_hash = m.group('hash')
if m.group('domain') in ('a', 'album'):
domain = 'album'
else:
# This could be a gallery or a single image, but there doesn't
# seem to be a way to reliably distinguish between the two.
# Assume a gallery, which appears to be more common, and fallback
# to an image request upon failure.
domain = 'gallery'
if not cls.CLIENT_ID:
return cls.fallback(url, domain)
api_url = endpoint.format(domain=domain, page_hash=page_hash)
r = requests.get(api_url, headers=headers)
if domain == 'gallery' and r.status_code != 200:
# Not a gallery, try to download using the image endpoint
api_url = endpoint.format(domain='image', page_hash=page_hash)
r = requests.get(api_url, headers=headers)
if r.status_code != 200:
_logger.warning('Imgur API failure, status %s', r.status_code)
return cls.fallback(url, domain)
data = r.json().get('data')
if not data:
_logger.warning('Imgur API failure, resp %s', r.json())
return cls.fallback(url, domain)
if 'images' in data and len(data['images']) > 1:
# TODO: handle imgur albums with mixed content, i.e. jpeg and gifv
link = ' '.join([d['link'] for d in data['images'] if not d['animated']])
mime = 'image/x-imgur-album'
else:
data = data['images'][0] if 'images' in data else data
# this handles single image galleries
link = data['mp4'] if data['animated'] else data['link']
mime = 'video/mp4' if data['animated'] else data['type']
link = link.replace('http://', 'https://')
return link, mime
@classmethod
def fallback(cls, url, domain):
"""
Attempt to use one of the scrapers if the API doesn't work
"""
if domain == 'album':
# The old Imgur album scraper has stopped working and I haven't
# put in the effort to figure out why
return url, None
else:
return ImgurScrapeMIMEParser.get_mimetype(url)
class ImgurScrapeMIMEParser(BaseMIMEParser):
"""
The majority of imgur links don't point directly to the image, so we need
to open the provided url and scrape the page for the link.
Scrape the actual image url from an imgur landing page. Imgur intentionally
obscures this on most reddit links in order to draw more traffic for their
advertisements.
There are a couple of <meta> tags that supply the relevant info:
<meta name="twitter:image" content="https://i.imgur.com/xrqQ4LEh.jpg">
<meta property="og:image" content="http://i.imgur.com/xrqQ4LE.jpg?fb">
<link rel="image_src" href="http://i.imgur.com/xrqQ4LE.jpg">
"""
pattern = re.compile(r'https?://(w+\.)?(m\.)?imgur\.com/[^.]+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
tag = soup.find('meta', attrs={'name': 'twitter:image'})
if tag:
url = tag.get('content')
if GifvMIMEParser.pattern.match(url):
return GifvMIMEParser.get_mimetype(url)
return BaseMIMEParser.get_mimetype(url)
class InstagramMIMEParser(OpenGraphMIMEParser):
"""
Instagram uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?instagr((am\.com)|\.am)/p/[^.]+$')
class StreamableMIMEParser(OpenGraphMIMEParser):
"""
Streamable uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?streamable\.com/[^.]+$')
class LiveleakMIMEParser(BaseMIMEParser):
"""
https://www.liveleak.com/view?i=12c_3456789
<video>
<source src="https://cdn.liveleak.com/..mp4" res="HD" type="video/mp4">
<source src="https://cdn.liveleak.com/..mp4" res="SD" type="video/mp4">
</video>
Sometimes only one video source is available
"""
pattern = re.compile(r'https?://((www|m)\.)?liveleak\.com/view\?i=\w+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
urls = []
videos = soup.find_all('video')
for vid in videos:
source = vid.find('source', attr={'res': 'HD'})
source = source or vid.find('source')
if source:
urls.append((source.get('src'), source.get('type')))
# TODO: Handle pages with multiple videos
if urls:
return urls[0]
def filter_iframe(t):
return t.name == 'iframe' and 'youtube.com' in t['src']
iframe = soup.find_all(filter_iframe)
if iframe:
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'].strip('/'))
return url, None
class ClippitUserMIMEParser(BaseMIMEParser):
"""
Clippit uses a video player container
"""
pattern = re.compile(r'https?://(www\.)?clippituser\.tv/c/.+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
tag = soup.find(id='player-container')
if tag:
quality = ['data-{}-file'.format(_) for _ in ['hd', 'sd']]
new_url = tag.get(quality[0])
if new_url:
return new_url, 'video/mp4'
return url, None
class GifsMIMEParser(OpenGraphMIMEParser):
"""
Gifs.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?gifs\.com/gif/.+$')
class GiphyMIMEParser(OpenGraphMIMEParser):
"""
Giphy.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?giphy\.com/gifs/.+$')
class ImgflipMIMEParser(OpenGraphMIMEParser):
"""
imgflip.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?imgflip\.com/i/.+$')
class LivememeMIMEParser(OpenGraphMIMEParser):
"""
livememe.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?livememe\.com/[^.]+$')
class MakeamemeMIMEParser(OpenGraphMIMEParser):
"""
makeameme.com uses the Open Graph protocol
"""
pattern = re.compile(r'https?://(www\.)?makeameme\.org/meme/.+$')
class FlickrMIMEParser(OpenGraphMIMEParser):
"""
Flickr uses the Open Graph protocol
"""
# TODO: handle albums/photosets (https://www.flickr.com/services/api)
pattern = re.compile(r'https?://(www\.)?flickr\.com/photos/[^/]+/[^/]+/?$')
class StreamjaMIMEParser(VideoTagMIMEParser):
"""
Embedded HTML5 video element
"""
pattern = re.compile(r'https?://(www\.)?streamja\.com/[^/]+/?$')
class WorldStarHipHopMIMEParser(BaseMIMEParser):
"""
<video>
<source src="https://hw-mobile.worldstarhiphop.com/..mp4" type="video/mp4">
<source src="" type="video/mp4">
</video>
Sometimes only one video source is available
"""
pattern = re.compile(r'https?://((www|m)\.)?worldstarhiphop\.com/videos/video.php\?v=\w+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
def filter_source(t):
return t.name == 'source' and t['src'] and t['type'] == 'video/mp4'
source = soup.find_all(filter_source)
if source:
return source[0]['src'], 'video/mp4'
def filter_iframe(t):
return t.name == 'iframe' and 'youtube.com' in t['src']
iframe = soup.find_all(filter_iframe)
if iframe:
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'])
return url, None
class RedGifsParser(BaseMIMEParser):
"""
Extract from application/ld+json
"""
pattern = re.compile(r'https?://(www\.)?redgifs\.com/watch/.+$')
@staticmethod
def get_mimetype(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
tag = soup.find('script', attrs={'type': 'application/ld+json'})
if tag:
ld_json = json.loads(tag.text)
return ld_json['video']['contentUrl'], 'video/mp4'
return url, None
# Parsers should be listed in the order they will be checked
parsers = [
StreamjaMIMEParser,
ClippitUserMIMEParser,
StreamableMIMEParser,
InstagramMIMEParser,
GfycatMIMEParser,
ImgurApiMIMEParser,
RedditUploadsMIMEParser,
RedditVideoMIMEParser,
YoutubeMIMEParser,
VimeoMIMEParser,
LiveleakMIMEParser,
FlickrMIMEParser,
GifsMIMEParser,
GiphyMIMEParser,
ImgflipMIMEParser,
LivememeMIMEParser,
MakeamemeMIMEParser,
WorldStarHipHopMIMEParser,
GifvMIMEParser,
RedGifsParser,
BaseMIMEParser]

248
ttrv/oauth.py Normal file
View File

@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import time
import uuid
import string
import codecs
import logging
import threading
# pylint: disable=import-error
from six.moves.urllib.parse import urlparse, parse_qs
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from . import docs
from .config import TEMPLATES
from .exceptions import InvalidRefreshToken
from .packages.praw.errors import HTTPException, OAuthException
_logger = logging.getLogger(__name__)
INDEX = os.path.join(TEMPLATES, 'index.html')
class OAuthHTTPServer(HTTPServer):
def handle_error(self, request, client_address):
"""
The default HTTPServer's error handler prints the request traceback
to stdout, which breaks the curses display.
Override it to log to a file instead.
"""
_logger.exception('Error processing request in OAuth HTTP Server')
class OAuthHandler(BaseHTTPRequestHandler):
# params are stored as a global because we don't have control over what
# gets passed into the handler __init__. These will be accessed by the
# OAuthHelper class.
params = {'state': None, 'code': None, 'error': None}
shutdown_on_request = True
def do_GET(self):
"""
Accepts GET requests to http://localhost:6500/, and stores the query
params in the global dict. If shutdown_on_request is true, stop the
server after the first successful request.
The http request may contain the following query params:
- state : unique identifier, should match what we passed to reddit
- code : code that can be exchanged for a refresh token
- error : if provided, the OAuth error that occurred
"""
parsed_path = urlparse(self.path)
if parsed_path.path != '/':
self.send_error(404)
qs = parse_qs(parsed_path.query)
self.params['state'] = qs['state'][0] if 'state' in qs else None
self.params['code'] = qs['code'][0] if 'code' in qs else None
self.params['error'] = qs['error'][0] if 'error' in qs else None
body = self.build_body()
# send_response also sets the Server and Date headers
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=UTF-8')
self.send_header('Content-Length', len(body))
self.end_headers()
self.wfile.write(body)
if self.shutdown_on_request:
# Shutdown the server after serving the request
# http://stackoverflow.com/a/22533929
thread = threading.Thread(target=self.server.shutdown)
thread.daemon = True
thread.start()
def log_message(self, fmt, *args):
"""
Redirect logging to our own handler instead of stdout
"""
_logger.debug(fmt, *args)
def build_body(self, template_file=INDEX):
"""
Params:
template_file (text): Path to an index.html template
Returns:
body (bytes): THe utf-8 encoded document body
"""
if self.params['error'] == 'access_denied':
message = docs.OAUTH_ACCESS_DENIED
elif self.params['error'] is not None:
message = docs.OAUTH_ERROR.format(error=self.params['error'])
elif self.params['state'] is None or self.params['code'] is None:
message = docs.OAUTH_INVALID
else:
message = docs.OAUTH_SUCCESS
with codecs.open(template_file, 'r', 'utf-8') as fp:
index_text = fp.read()
body = string.Template(index_text).substitute(message=message)
body = codecs.encode(body, 'utf-8')
return body
class OAuthHelper(object):
params = OAuthHandler.params
def __init__(self, reddit, term, config):
self.term = term
self.reddit = reddit
self.config = config
# Wait to initialize the server, we don't want to reserve the port
# unless we know that the server needs to be used.
self.server = None
self.reddit.set_oauth_app_info(
self.config['oauth_client_id'],
self.config['oauth_client_secret'],
self.config['oauth_redirect_uri'])
# Reddit's mobile website works better on terminal browsers
if not self.term.display:
if '.compact' not in self.reddit.config.API_PATHS['authorize']:
self.reddit.config.API_PATHS['authorize'] += '.compact'
def authorize(self, autologin=False):
self.params.update(state=None, code=None, error=None)
# If we already have a token, request new access credentials
if self.config.refresh_token:
with self.term.loader('Logging in'):
try:
self.reddit.refresh_access_information(
self.config.refresh_token)
except (HTTPException, OAuthException) as e:
# Reddit didn't accept the refresh-token
# This appears to throw a generic 400 error instead of the
# more specific invalid_token message that it used to send
if isinstance(e, HTTPException):
if e._raw.status_code != 400:
# No special handling if the error is something
# temporary like a 5XX.
raise e
# Otherwise we know the token is bad, so we can remove it.
_logger.exception(e)
self.clear_oauth_data()
raise InvalidRefreshToken(
' Invalid user credentials!\n'
'The cached refresh token has been removed')
else:
if not autologin:
# Only show the welcome message if explicitly logging
# in, not when TTRV first launches.
message = 'Welcome {}!'.format(self.reddit.user.name)
self.term.show_notification(message)
return
state = uuid.uuid4().hex
authorize_url = self.reddit.get_authorize_url(
state, scope=self.config['oauth_scope'], refreshable=True)
if self.server is None:
address = ('', self.config['oauth_redirect_port'])
self.server = OAuthHTTPServer(address, OAuthHandler)
if self.term.display:
# Open a background browser (e.g. firefox) which is non-blocking.
# The server will block until it responds to its first request,
# at which point we can check the callback params.
OAuthHandler.shutdown_on_request = True
with self.term.loader('Opening browser for authorization'):
self.term.open_browser(authorize_url)
self.server.serve_forever()
if self.term.loader.exception:
# Don't need to call server.shutdown() because serve_forever()
# is wrapped in a try-finally that doees it for us.
return
else:
# Open the terminal webbrowser in a background thread and wait
# while for the user to close the process. Once the process is
# closed, the iloop is stopped and we can check if the user has
# hit the callback URL.
OAuthHandler.shutdown_on_request = False
with self.term.loader('Redirecting to reddit', delay=0):
# This load message exists to provide user feedback
time.sleep(1)
thread = threading.Thread(target=self.server.serve_forever)
thread.daemon = True
thread.start()
try:
self.term.open_browser(authorize_url)
except Exception as e:
# If an exception is raised it will be seen by the thread
# so we don't need to explicitly shutdown() the server
_logger.exception(e)
self.term.show_notification('Browser Error', style='Error')
else:
self.server.shutdown()
finally:
thread.join()
if self.params['error'] == 'access_denied':
self.term.show_notification('Denied access', style='Error')
return
elif self.params['error']:
self.term.show_notification('Authentication error', style='Error')
return
elif self.params['state'] is None:
# Something went wrong but it's not clear what happened
return
elif self.params['state'] != state:
self.term.show_notification('UUID mismatch', style='Error')
return
with self.term.loader('Logging in'):
info = self.reddit.get_access_information(self.params['code'])
if self.term.loader.exception:
return
message = 'Welcome {}!'.format(self.reddit.user.name)
self.term.show_notification(message)
self.config.refresh_token = info['refresh_token']
if self.config['persistent']:
self.config.save_refresh_token()
def clear_oauth_data(self):
self.reddit.clear_authentication()
self.config.delete_refresh_token()

709
ttrv/objects.py Normal file
View File

@ -0,0 +1,709 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import sys
import time
import signal
import inspect
import weakref
import logging
import threading
import webbrowser
import curses
import curses.ascii
from contextlib import contextmanager
import six
import requests
from . import exceptions
from .packages import praw
_logger = logging.getLogger(__name__)
def patch_webbrowser():
"""
Some custom patches on top of the python webbrowser module to fix
user reported bugs and limitations of the module.
"""
# https://bugs.python.org/issue31014
# https://github.com/michael-lazar/ttrv/issues/588
def register_patch(name, klass, instance=None, update_tryorder=None, preferred=False):
"""
Wrapper around webbrowser.register() that detects if the function was
invoked with the legacy function signature. If so, the signature is
fixed before passing it along to the underlying function.
Examples:
register(name, klass, instance, -1)
register(name, klass, instance, update_tryorder=-1)
register(name, klass, instance, preferred=True)
"""
if update_tryorder is not None:
preferred = (update_tryorder == -1)
return webbrowser._register(name, klass, instance, preferred=preferred)
if sys.version_info[:2] >= (3, 7):
webbrowser._register = webbrowser.register
webbrowser.register = register_patch
# Add support for browsers that aren't defined in the python standard library
webbrowser.register('surf', None, webbrowser.BackgroundBrowser('surf'))
webbrowser.register('vimb', None, webbrowser.BackgroundBrowser('vimb'))
webbrowser.register('qutebrowser', None, webbrowser.BackgroundBrowser('qutebrowser'))
# Fix the opera browser, see https://github.com/michael-lazar/ttrv/issues/476.
# By default, opera will open a new tab in the current window, which is
# what we want to do anyway.
webbrowser.register('opera', None, webbrowser.BackgroundBrowser('opera'))
# https://bugs.python.org/issue31348
# Use MacOS actionscript when opening the program defined in by $BROWSER
if sys.platform == 'darwin' and 'BROWSER' in os.environ:
_userchoices = os.environ["BROWSER"].split(os.pathsep)
for cmdline in reversed(_userchoices):
if cmdline in ('safari', 'firefox', 'chrome', 'default'):
browser = webbrowser.MacOSXOSAScript(cmdline)
webbrowser.register(cmdline, None, browser, update_tryorder=-1)
@contextmanager
def curses_session():
"""
Setup terminal and initialize curses. Most of this copied from
curses.wrapper in order to convert the wrapper into a context manager.
"""
try:
# Curses must wait for some time after the Escape key is pressed to
# check if it is the beginning of an escape sequence indicating a
# special key. The default wait time is 1 second, which means that
# http://stackoverflow.com/questions/27372068
os.environ['ESCDELAY'] = '25'
# Initialize curses
stdscr = curses.initscr()
# Turn off echoing of keys, and enter cbreak mode, where no buffering
# is performed on keyboard input
curses.noecho()
curses.cbreak()
# In keypad mode, escape sequences for special keys (like the cursor
# keys) will be interpreted and a special value like curses.KEY_LEFT
# will be returned
stdscr.keypad(1)
# Start color, too. Harmless if the terminal doesn't have color; user
# can test with has_color() later on. The try/catch works around a
# minor bit of over-conscientiousness in the curses module -- the error
# return from C start_color() is ignorable.
try:
curses.start_color()
curses.use_default_colors()
except:
_logger.warning('Curses failed to initialize color support')
# Hide the blinking cursor
try:
curses.curs_set(0)
except:
_logger.warning('Curses failed to initialize the cursor mode')
yield stdscr
finally:
if 'stdscr' in locals():
stdscr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
class LoadScreen(object):
"""
Display a loading dialog while waiting for a blocking action to complete.
This class spins off a separate thread to animate the loading screen in the
background. The loading thread also takes control of stdscr.getch(). If
an exception occurs in the main thread while the loader is active, the
exception will be caught, attached to the loader object, and displayed as
a notification. The attached exception can be used to trigger context
sensitive actions. For example, if the connection hangs while opening a
submission, the user may press ctrl-c to raise a KeyboardInterrupt. In this
case we would *not* want to refresh the current page.
>>> with self.terminal.loader(...) as loader:
>>> # Perform a blocking request to load content
>>> blocking_request(...)
>>>
>>> if loader.exception is None:
>>> # Only run this if the load was successful
>>> self.refresh_content()
When a loader is nested inside of itself, the outermost loader takes
priority and all of the nested loaders become no-ops. Call arguments given
to nested loaders will be ignored, and errors will propagate to the parent.
>>> with self.terminal.loader(...) as loader:
>>>
>>> # Additional loaders will be ignored
>>> with self.terminal.loader(...):
>>> raise KeyboardInterrupt()
>>>
>>> # This code will not be executed because the inner loader doesn't
>>> # catch the exception
>>> assert False
>>>
>>> # The exception is finally caught by the outer loader
>>> assert isinstance(terminal.loader.exception, KeyboardInterrupt)
"""
EXCEPTION_MESSAGES = [
(exceptions.TTRVError, '{0}'),
(praw.errors.OAuthException, 'OAuth Error'),
(praw.errors.OAuthScopeRequired, 'Not logged in'),
(praw.errors.LoginRequired, 'Not logged in'),
(praw.errors.InvalidCaptcha, 'Error, captcha required'),
(praw.errors.InvalidSubreddit, '{0.args[0]}'),
(praw.errors.PRAWException, '{0.__class__.__name__}'),
(requests.exceptions.Timeout, 'HTTP request timed out'),
(requests.exceptions.RequestException, '{0.__class__.__name__}'),
]
def __init__(self, terminal):
self.exception = None
self.catch_exception = None
self.depth = 0
self._terminal = weakref.proxy(terminal)
self._args = None
self._animator = None
self._is_running = None
def __call__(
self,
message='Downloading',
trail='...',
delay=0.5,
interval=0.4,
catch_exception=True):
"""
Params:
delay (float): Length of time that the loader will wait before
printing on the screen. Used to prevent flicker on pages that
load very fast.
interval (float): Length of time between each animation frame.
message (str): Message to display
trail (str): Trail of characters that will be animated by the
loading screen.
catch_exception (bool): If an exception occurs while the loader is
active, this flag determines whether it is caught or allowed to
bubble up.
"""
if self.depth > 0:
return self
self.exception = None
self.catch_exception = catch_exception
self._args = (delay, interval, message, trail)
return self
def __enter__(self):
self.depth += 1
if self.depth > 1:
return self
self._animator = threading.Thread(target=self.animate, args=self._args)
self._animator.daemon = True
self._is_running = True
self._animator.start()
return self
def __exit__(self, exc_type, e, exc_tb):
self.depth -= 1
if self.depth > 0:
return
self._is_running = False
self._animator.join()
if e is None or not self.catch_exception:
# Skip exception handling
return
self.exception = e
exc_name = type(e).__name__
_logger.info('Loader caught: %s - %s', exc_name, e)
if isinstance(e, KeyboardInterrupt):
# Don't need to print anything for this one, just swallow it
return True
for e_type, message in self.EXCEPTION_MESSAGES:
# Some exceptions we want to swallow and display a notification
if isinstance(e, e_type):
msg = message.format(e)
self._terminal.show_notification(msg, style='Error')
return True
def animate(self, delay, interval, message, trail):
# The animation starts with a configurable delay before drawing on the
# screen. This is to prevent very short loading sections from
# flickering on the screen before immediately disappearing.
with self._terminal.no_delay():
start = time.time()
while (time.time() - start) < delay:
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
if not self._is_running:
return
time.sleep(0.01)
# Build the notification window. Note that we need to use
# curses.newwin() instead of stdscr.derwin() so the text below the
# notification window does not got erased when we cover it up.
message_len = len(message) + len(trail)
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
v_offset, h_offset = self._terminal.stdscr.getbegyx()
s_row = (n_rows - 3) // 2 + v_offset
s_col = (n_cols - message_len - 1) // 2 + h_offset
window = curses.newwin(3, message_len + 2, s_row, s_col)
window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
# Animate the loading prompt until the stopping condition is triggered
# when the context manager exits.
with self._terminal.no_delay():
while True:
for i in range(len(trail) + 1):
if not self._is_running:
window.erase()
del window
self._terminal.stdscr.touchwin()
self._terminal.stdscr.refresh()
return
window.erase()
window.border()
self._terminal.add_line(window, message + trail[:i], 1, 1)
window.refresh()
# Break up the designated sleep interval into smaller
# chunks so we can more responsively check for interrupts.
for _ in range(int(interval / 0.01)):
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
break
time.sleep(0.01)
class Navigator(object):
"""
Handles the math behind cursor movement and screen paging.
This class determines how cursor movements effect the currently displayed
page. For example, if scrolling down the page, items are drawn from the
bottom up. This ensures that the item at the very bottom of the screen
(the one selected by cursor) will be fully drawn and not cut off. Likewise,
when scrolling up the page, items are drawn from the top down. If the
cursor is moved around without hitting the top or bottom of the screen, the
current mode is preserved.
"""
def __init__(
self,
valid_page_cb,
page_index=0,
cursor_index=0,
inverted=False,
top_item_height=None):
"""
Params:
valid_page_callback (func): This function, usually `Content.get`,
takes a page index and raises an IndexError if that index falls
out of bounds. This is used to determine the upper and lower
bounds of the page, i.e. when to stop scrolling.
page_index (int): Initial page index.
cursor_index (int): Initial cursor index, relative to the page.
inverted (bool): Whether the page scrolling is reversed of not.
normal - The page is drawn from the top of the screen,
starting with the page index, down to the bottom of
the screen.
inverted - The page is drawn from the bottom of the screen,
starting with the page index, up to the top of the
screen.
top_item_height (int): If this is set to a non-null value
The number of columns that the top-most item
should utilize if non-inverted. This is used for a special mode
where all items are drawn non-inverted except for the top one.
"""
self.page_index = page_index
self.cursor_index = cursor_index
self.inverted = inverted
self.top_item_height = top_item_height
self._page_cb = valid_page_cb
@property
def step(self):
return 1 if not self.inverted else -1
@property
def position(self):
return self.page_index, self.cursor_index, self.inverted
@property
def absolute_index(self):
"""
Return the index of the currently selected item.
"""
return self.page_index + (self.step * self.cursor_index)
def move(self, direction, n_windows):
"""
Move the cursor up or down by the given increment.
Params:
direction (int): `1` will move the cursor down one item and `-1`
will move the cursor up one item.
n_windows (int): The number of items that are currently being drawn
on the screen.
Returns:
valid (bool): Indicates whether or not the attempted cursor move is
allowed. E.g. When the cursor is on the last comment,
attempting to scroll down any further would not be valid.
redraw (bool): Indicates whether or not the screen needs to be
redrawn.
"""
assert direction in (-1, 1)
valid, redraw = True, False
forward = ((direction * self.step) > 0)
if forward:
if self.page_index < 0:
if self._is_valid(0):
# Special case - advance the page index if less than zero
self.page_index = 0
self.cursor_index = 0
redraw = True
else:
valid = False
else:
self.cursor_index += 1
if not self._is_valid(self.absolute_index):
# Move would take us out of bounds
self.cursor_index -= 1
valid = False
elif self.cursor_index >= (n_windows - 1):
# Flip the orientation and reset the cursor
self.flip(self.cursor_index)
self.cursor_index = 0
self.top_item_height = None
redraw = True
else:
if self.cursor_index > 0:
self.cursor_index -= 1
if self.top_item_height and self.cursor_index == 0:
# Selecting the partially displayed item
self.top_item_height = None
redraw = True
else:
self.page_index -= self.step
if self._is_valid(self.absolute_index):
# We have reached the beginning of the page - move the
# index
self.top_item_height = None
redraw = True
else:
self.page_index += self.step
valid = False # Revert
return valid, redraw
def move_page(self, direction, n_windows):
"""
Move the page down (positive direction) or up (negative direction).
Paging down:
The post on the bottom of the page becomes the post at the top of
the page and the cursor is moved to the top.
Paging up:
The post at the top of the page becomes the post at the bottom of
the page and the cursor is moved to the bottom.
"""
assert direction in (-1, 1)
assert n_windows >= 0
# top of subreddit/submission page or only one
# submission/reply on the screen: act as normal move
if (self.absolute_index < 0) | (n_windows == 0):
valid, redraw = self.move(direction, n_windows)
else:
# first page
if self.absolute_index < n_windows and direction < 0:
self.page_index = -1
self.cursor_index = 0
self.inverted = False
# not submission mode: starting index is 0
if not self._is_valid(self.absolute_index):
self.page_index = 0
valid = True
else:
# flip to the direction of movement
if ((direction > 0) & (self.inverted is True)) \
| ((direction < 0) & (self.inverted is False)):
self.page_index += (self.step * (n_windows - 1))
self.inverted = not self.inverted
self.cursor_index \
= (n_windows - (direction < 0)) - self.cursor_index
valid = False
adj = 0
# check if reached the bottom
while not valid:
n_move = n_windows - adj
if n_move == 0:
break
self.page_index += n_move * direction
valid = self._is_valid(self.absolute_index)
if not valid:
self.page_index -= n_move * direction
adj += 1
redraw = True
return valid, redraw
def flip(self, n_windows):
"""
Flip the orientation of the page.
"""
assert n_windows >= 0
self.page_index += (self.step * n_windows)
self.cursor_index = n_windows
self.inverted = not self.inverted
self.top_item_height = None
def _is_valid(self, page_index):
"""
Check if a page index will cause entries to fall outside valid range.
"""
try:
self._page_cb(page_index)
except IndexError:
return False
else:
return True
class Controller(object):
"""
Event handler for triggering functions with curses keypresses.
Register a keystroke to a class method using the @register decorator.
>>> @Controller.register('a', 'A')
>>> def func(self, *args)
>>> ...
Register a KeyBinding that can be defined later by the config file
>>> @Controller.register(Command("UPVOTE"))
>>> def upvote(self, *args)
>> ...
Bind the controller to a class instance and trigger a key. Additional
arguments will be passed to the function.
>>> controller = Controller(self)
>>> controller.trigger('a', *args)
"""
character_map = {}
def __init__(self, instance, keymap=None):
self.instance = instance
# Build a list of parent controllers that follow the object's MRO
# to check if any parent controllers have registered the keypress
self.parents = inspect.getmro(type(self))[:-1]
# Keep track of last key press for doubles like `gg`
self.last_char = None
if not keymap:
return
# Go through the controller and all of it's parents and look for
# Command objects in the character map. Use the keymap the lookup the
# keys associated with those command objects and add them to the
# character map.
for controller in self.parents:
for command, func in controller.character_map.copy().items():
if isinstance(command, Command):
for key in keymap.get(command):
val = keymap.parse(key)
# If a double key press is defined, the first half
# must be unbound
if isinstance(val, tuple):
if controller.character_map.get(val[0]) is not None:
raise exceptions.ConfigError(
"Invalid configuration! `%s` is bound to "
"duplicate commands in the "
"%s" % (key, controller.__name__))
# Mark the first half of the double with None so
# that no other command can use it
controller.character_map[val[0]] = None
# Check if the key is already programmed to trigger a
# different function.
if controller.character_map.get(val, func) != func:
raise exceptions.ConfigError(
"Invalid configuration! `%s` is bound to "
"duplicate commands in the "
"%s" % (key, controller.__name__))
controller.character_map[val] = func
def trigger(self, char, *args, **kwargs):
if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)
func = None
# Check if the controller (or any of the controller's parents) have
# registered a function to the given key
for controller in self.parents:
func = controller.character_map.get((self.last_char, char))
if func:
break
func = controller.character_map.get(char)
if func:
break
if func:
self.last_char = None
return func(self.instance, *args, **kwargs)
else:
self.last_char = char
return None
@classmethod
def register(cls, *chars):
def inner(f):
for char in chars:
if isinstance(char, six.string_types) and len(char) == 1:
cls.character_map[ord(char)] = f
else:
cls.character_map[char] = f
return f
return inner
class Command(object):
"""
Minimal class that should be used to wrap abstract commands that may be
implemented as one or more physical keystrokes.
E.g. Command("REFRESH") can be represented by the KeyMap to be triggered
by either `r` or `F5`
"""
def __init__(self, val):
self.val = val.upper()
def __repr__(self):
return 'Command(%s)' % self.val
def __eq__(self, other):
return repr(self) == repr(other)
def __ne__(self, other):
return not self == other
def __hash__(self):
return hash(repr(self))
class KeyMap(object):
"""
Mapping between commands and the keys that they represent.
"""
def __init__(self, bindings):
self._keymap = None
self.set_bindings(bindings)
def set_bindings(self, bindings):
new_keymap = {}
for command, keys in bindings.items():
if not isinstance(command, Command):
command = Command(command)
new_keymap[command] = keys
if not self._keymap:
self._keymap = new_keymap
else:
self._keymap.update(new_keymap)
def get(self, command):
if not isinstance(command, Command):
command = Command(command)
try:
return self._keymap[command]
except KeyError:
raise exceptions.ConfigError('Invalid configuration! `%s` key is '
'undefined' % command.val)
@classmethod
def parse(cls, key):
"""
Parse a key represented by a string and return its character code.
"""
try:
if isinstance(key, int):
return key
elif re.match('[<]KEY_.*[>]', key):
# Curses control character
return getattr(curses, key[1:-1])
elif re.match('[<].*[>]', key):
# Ascii control character
return getattr(curses.ascii, key[1:-1])
elif key.startswith('0x'):
# Ascii hex code
return int(key, 16)
elif len(key) == 2:
# Double presses
return tuple(cls.parse(k) for k in key)
else:
# Ascii character
code = ord(key)
if 0 <= code <= 255:
return code
# Python 3.3 has a curses.get_wch() function that we can use
# for unicode keys, but Python 2.7 is limited to ascii.
raise exceptions.ConfigError('Invalid configuration! `%s` is '
'not in the ascii range' % key)
except (AttributeError, ValueError, TypeError):
raise exceptions.ConfigError('Invalid configuration! "%s" is not a '
'valid key' % key)

26
ttrv/packages/__init__.py Normal file
View File

@ -0,0 +1,26 @@
"""
This stub allows the user to fallback to their system installation of
praw if the bundled package is missing. This technique was inspired by the
requests library and how it handles dependencies.
Reference:
https://github.com/kennethreitz/requests/blob/master/requests/packages/__init__.py
"""
from __future__ import absolute_import
import sys
__praw_hash__ = '1656ec224e574eed9cda4efcb497825d54b4d926'
__praw_bundled__ = True
try:
from . import praw
except ImportError:
import praw
if not praw.__version__.startswith('3.'):
raise RuntimeError('Invalid PRAW version ({0}) detected, '
'ttrv requires PRAW version 3'.format(praw.__version__))
sys.modules['%s.praw' % __name__] = praw
__praw_bundled__ = False

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
"""Internal helper functions used by praw.decorators."""
import inspect
from requests.compat import urljoin
import six
import sys
def _get_captcha(reddit_session, captcha_id):
"""Prompt user for captcha solution and return a prepared result."""
url = urljoin(reddit_session.config['captcha'],
captcha_id + '.png')
sys.stdout.write('Captcha URL: {0}\nCaptcha: '.format(url))
sys.stdout.flush()
raw = sys.stdin.readline()
if not raw: # stdin has reached the end of file
# Trigger exception raising next time through. The request is
# cached so this will not require and extra request and delay.
sys.stdin.close()
return None
return {'iden': captcha_id, 'captcha': raw.strip()}
def _is_mod_of_all(user, subreddit):
mod_subs = user.get_cached_moderated_reddits()
subs = six.text_type(subreddit).lower().split('+')
return all(sub in mod_subs for sub in subs)
def _make_func_args(function):
if six.PY3 and not hasattr(sys, 'pypy_version_info'):
# CPython3 uses inspect.signature(), not inspect.getargspec()
# see #551 and #541 for more info
func_items = inspect.signature(function).parameters.items()
func_args = [name for name, param in func_items
if param.kind == param.POSITIONAL_OR_KEYWORD]
else:
func_args = inspect.getargspec(function).args
return func_args

View File

@ -0,0 +1,294 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Decorators.
They mainly do two things: ensure API guidelines are followed and
prevent unnecessary failed API requests by testing that the call can be made
first. Also, they can limit the length of output strings and parse json
response for certain errors.
"""
from __future__ import print_function, unicode_literals
import decorator
import six
import sys
from functools import wraps
from .decorator_helpers import (
_get_captcha,
_is_mod_of_all,
_make_func_args
)
from . import errors
from warnings import filterwarnings, warn
# Enable deprecation warnings from this module
filterwarnings('default', category=DeprecationWarning,
module='^praw\.decorators$')
def alias_function(function, class_name):
"""Create a RedditContentObject function mapped to a BaseReddit function.
The BaseReddit classes define the majority of the API's functions. The
first argument for many of these functions is the RedditContentObject that
they operate on. This factory returns functions appropriate to be called on
a RedditContent object that maps to the corresponding BaseReddit function.
"""
@wraps(function)
def wrapped(self, *args, **kwargs):
func_args = _make_func_args(function)
if 'subreddit' in func_args and func_args.index('subreddit') != 1:
# Only happens for search
kwargs['subreddit'] = self
return function(self.reddit_session, *args, **kwargs)
else:
return function(self.reddit_session, self, *args, **kwargs)
# Only grab the short-line doc and add a link to the complete doc
if wrapped.__doc__ is not None:
wrapped.__doc__ = wrapped.__doc__.split('\n', 1)[0]
wrapped.__doc__ += ('\n\nSee :meth:`.{0}.{1}` for complete usage. '
'Note that you should exclude the subreddit '
'parameter when calling this convenience method.'
.format(class_name, function.__name__))
# Don't hide from sphinx as this is a parameter modifying decorator
return wrapped
def deprecated(msg=''):
"""Deprecate decorated method."""
@decorator.decorator
def wrap(function, *args, **kwargs):
if not kwargs.pop('disable_warning', False):
warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return wrap
@decorator.decorator
def limit_chars(function, *args, **kwargs):
"""Truncate the string returned from a function and return the result."""
output_chars_limit = args[0].reddit_session.config.output_chars_limit
output_string = function(*args, **kwargs)
if -1 < output_chars_limit < len(output_string):
output_string = output_string[:output_chars_limit - 3] + '...'
return output_string
@decorator.decorator
def oauth_generator(function, *args, **kwargs):
"""Set the _use_oauth keyword argument to True when appropriate.
This is needed because generator functions may be called at anytime, and
PRAW relies on the Reddit._use_oauth value at original call time to know
when to make OAuth requests.
Returned data is not modified.
"""
if getattr(args[0], '_use_oauth', False):
kwargs['_use_oauth'] = True
return function(*args, **kwargs)
@decorator.decorator
def raise_api_exceptions(function, *args, **kwargs):
"""Raise client side exception(s) when present in the API response.
Returned data is not modified.
"""
try:
return_value = function(*args, **kwargs)
except errors.HTTPException as exc:
if exc._raw.status_code != 400: # pylint: disable=W0212
raise # Unhandled HTTPErrors
try: # Attempt to convert v1 errors into older format (for now)
data = exc._raw.json() # pylint: disable=W0212
assert len(data) == 2
return_value = {'errors': [(data['reason'],
data['explanation'], '')]}
except Exception:
raise exc
if isinstance(return_value, dict):
if return_value.get('error') == 304: # Not modified exception
raise errors.NotModified(return_value)
elif return_value.get('errors'):
error_list = []
for error_type, msg, value in return_value['errors']:
if error_type in errors.ERROR_MAPPING:
if error_type == 'RATELIMIT':
args[0].evict(args[1])
error_class = errors.ERROR_MAPPING[error_type]
else:
error_class = errors.APIException
error_list.append(error_class(error_type, msg, value,
return_value))
if len(error_list) == 1:
raise error_list[0]
else:
raise errors.ExceptionList(error_list)
return return_value
@decorator.decorator
def require_captcha(function, *args, **kwargs):
"""Return a decorator for methods that require captchas."""
raise_captcha_exception = kwargs.pop('raise_captcha_exception', False)
captcha_id = None
# Get a handle to the reddit session
if hasattr(args[0], 'reddit_session'):
reddit_session = args[0].reddit_session
else:
reddit_session = args[0]
while True:
try:
if captcha_id:
captcha_answer = _get_captcha(reddit_session, captcha_id)
# When the method is being decorated, all of its default
# parameters become part of this *args tuple. This means that
# *args currently contains a None where the captcha answer
# needs to go. If we put the captcha in the **kwargs,
# we get a TypeError for having two values of the same param.
func_args = _make_func_args(function)
if 'captcha' in func_args:
captcha_index = func_args.index('captcha')
args = list(args)
args[captcha_index] = captcha_answer
else:
kwargs['captcha'] = captcha_answer
return function(*args, **kwargs)
except errors.InvalidCaptcha as exception:
if raise_captcha_exception or \
not hasattr(sys.stdin, 'closed') or sys.stdin.closed:
raise
captcha_id = exception.response['captcha']
def restrict_access(scope, mod=None, login=None, oauth_only=False,
generator_called=False):
"""Restrict function access unless the user has the necessary permissions.
Raises one of the following exceptions when appropriate:
* LoginRequired
* LoginOrOAuthRequired
* the scope attribute will provide the necessary scope name
* ModeratorRequired
* ModeratorOrOAuthRequired
* the scope attribute will provide the necessary scope name
:param scope: Indicate the scope that is required for the API call. None or
False must be passed to indicate that no scope handles the API call.
All scopes save for `read` imply login=True. Scopes with 'mod' in their
name imply mod=True.
:param mod: Indicate that a moderator is required. Implies login=True.
:param login: Indicate that a login is required.
:param oauth_only: Indicate that only OAuth is supported for the function.
:param generator_called: Indicate that the function consists solely of
exhausting one or more oauth_generator wrapped generators. This is
because the oauth_generator itself will determine whether or not to
use the oauth domain.
Returned data is not modified.
This decorator assumes that all mod required functions fit one of these
categories:
* have the subreddit as the first argument (Reddit instance functions) or
have a subreddit keyword argument
* are called upon a subreddit object (Subreddit RedditContentObject)
* are called upon a RedditContent object with attribute subreddit
"""
if not scope and oauth_only:
raise TypeError('`scope` must be set when `oauth_only` is set')
mod = mod is not False and (mod or scope and 'mod' in scope)
login = login is not False and (login or mod or scope and scope != 'read')
@decorator.decorator
def wrap(function, *args, **kwargs):
if args[0] is None: # Occurs with (un)friend
assert login
raise errors.LoginRequired(function.__name__)
# This segment of code uses hasattr to determine what instance type
# the function was called on. We could use isinstance if we wanted
# to import the types at runtime (decorators is used by all the
# types).
if mod:
if hasattr(args[0], 'reddit_session'):
# Defer access until necessary for RedditContentObject.
# This is because scoped sessions may not require this
# attribute to exist, thus it might not be set.
from .objects import Subreddit
subreddit = args[0] if isinstance(args[0], Subreddit) \
else False
else:
subreddit = kwargs.get(
'subreddit', args[1] if len(args) > 1 else None)
if subreddit is None: # Try the default value
defaults = six.get_function_defaults(function)
subreddit = defaults[0] if defaults else None
else:
subreddit = None
obj = getattr(args[0], 'reddit_session', args[0])
# This function sets _use_oauth for one time use only.
# Verify that statement is actually true.
assert not obj._use_oauth # pylint: disable=W0212
if scope and obj.has_scope(scope):
obj._use_oauth = not generator_called # pylint: disable=W0212
elif oauth_only:
raise errors.OAuthScopeRequired(function.__name__, scope)
elif login and obj.is_logged_in():
if subreddit is False:
# Now fetch the subreddit attribute. There is no good
# reason for it to not be set during a logged in session.
subreddit = args[0].subreddit
if mod and not _is_mod_of_all(obj.user, subreddit):
if scope:
raise errors.ModeratorOrScopeRequired(
function.__name__, scope)
raise errors.ModeratorRequired(function.__name__)
elif login:
if scope:
raise errors.LoginOrScopeRequired(function.__name__, scope)
raise errors.LoginRequired(function.__name__)
try:
return function(*args, **kwargs)
finally:
obj._use_oauth = False # pylint: disable=W0212
return wrap
@decorator.decorator
def require_oauth(function, *args, **kwargs):
"""Verify that the OAuth functions can be used prior to use.
Returned data is not modified.
"""
if not args[0].has_oauth_app_info:
err_msg = ("The OAuth app config parameters client_id, client_secret "
"and redirect_url must be specified to use this function.")
raise errors.OAuthAppRequired(err_msg)
return function(*args, **kwargs)

View File

@ -0,0 +1,475 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Error classes.
Includes two main exceptions: ClientException, when something goes
wrong on our end, and APIExeception for when something goes wrong on the
server side. A number of classes extend these two main exceptions for more
specific exceptions.
"""
from __future__ import print_function, unicode_literals
import inspect
import six
import sys
class PRAWException(Exception):
"""The base PRAW Exception class.
Ideally, this can be caught to handle any exception from PRAW.
"""
class ClientException(PRAWException):
"""Base exception class for errors that don't involve the remote API."""
def __init__(self, message=None):
"""Construct a ClientException.
:param message: The error message to display.
"""
if not message:
message = 'Clientside error'
super(ClientException, self).__init__()
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class OAuthScopeRequired(ClientException):
"""Indicates that an OAuth2 scope is required to make the function call.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope, message=None):
"""Contruct an OAuthScopeRequiredClientException.
:param function: The function that requires a scope.
:param scope: The scope required for the function.
:param message: A custom message to associate with the
exception. Default: `function` requires the OAuth2 scope `scope`
"""
if not message:
message = '`{0}` requires the OAuth2 scope `{1}`'.format(function,
scope)
super(OAuthScopeRequired, self).__init__(message)
self.scope = scope
class LoginRequired(ClientException):
"""Indicates that a logged in session is required.
This exception is raised on a preemptive basis, whereas NotLoggedIn occurs
in response to a lack of credentials on a privileged API call.
"""
def __init__(self, function, message=None):
"""Construct a LoginRequired exception.
:param function: The function that requires login-based authentication.
:param message: A custom message to associate with the exception.
Default: `function` requires a logged in session
"""
if not message:
message = '`{0}` requires a logged in session'.format(function)
super(LoginRequired, self).__init__(message)
class LoginOrScopeRequired(OAuthScopeRequired, LoginRequired):
"""Indicates that either a logged in session or OAuth2 scope is required.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope, message=None):
"""Construct a LoginOrScopeRequired exception.
:param function: The function that requires authentication.
:param scope: The scope that is required if not logged in.
:param message: A custom message to associate with the exception.
Default: `function` requires a logged in session or the OAuth2
scope `scope`
"""
if not message:
message = ('`{0}` requires a logged in session or the '
'OAuth2 scope `{1}`').format(function, scope)
super(LoginOrScopeRequired, self).__init__(function, scope, message)
class ModeratorRequired(LoginRequired):
"""Indicates that a moderator of the subreddit is required."""
def __init__(self, function):
"""Construct a ModeratorRequired exception.
:param function: The function that requires moderator access.
"""
message = ('`{0}` requires a moderator '
'of the subreddit').format(function)
super(ModeratorRequired, self).__init__(message)
class ModeratorOrScopeRequired(LoginOrScopeRequired, ModeratorRequired):
"""Indicates that a moderator of the sub or OAuth2 scope is required.
The attribute `scope` will contain the name of the necessary scope.
"""
def __init__(self, function, scope):
"""Construct a ModeratorOrScopeRequired exception.
:param function: The function that requires moderator authentication or
a moderator scope..
:param scope: The scope that is required if not logged in with
moderator access..
"""
message = ('`{0}` requires a moderator of the subreddit or the '
'OAuth2 scope `{1}`').format(function, scope)
super(ModeratorOrScopeRequired, self).__init__(function, scope,
message)
class OAuthAppRequired(ClientException):
"""Raised when an OAuth client cannot be initialized.
This occurs when any one of the OAuth config values are not set.
"""
class HTTPException(PRAWException):
"""Base class for HTTP related exceptions."""
def __init__(self, _raw, message=None):
"""Construct a HTTPException.
:params _raw: The internal request library response object. This object
is mapped to attribute `_raw` whose format may change at any time.
"""
if not message:
message = 'HTTP error'
super(HTTPException, self).__init__()
self._raw = _raw
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class Forbidden(HTTPException):
"""Raised when the user does not have permission to the entity."""
class NotFound(HTTPException):
"""Raised when the requested entity is not found."""
class InvalidComment(PRAWException):
"""Indicate that the comment is no longer available on reddit."""
ERROR_TYPE = 'DELETED_COMMENT'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class InvalidSubmission(PRAWException):
"""Indicates that the submission is no longer available on reddit."""
ERROR_TYPE = 'DELETED_LINK'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class InvalidSubreddit(PRAWException):
"""Indicates that an invalid subreddit name was supplied."""
ERROR_TYPE = 'SUBREDDIT_NOEXIST'
def __str__(self):
"""Return the message of the error."""
return self.ERROR_TYPE
class RedirectException(PRAWException):
"""Raised when a redirect response occurs that is not expected."""
def __init__(self, request_url, response_url, message=None):
"""Construct a RedirectException.
:param request_url: The url requested.
:param response_url: The url being redirected to.
:param message: A custom message to associate with the exception.
"""
if not message:
message = ('Unexpected redirect '
'from {0} to {1}').format(request_url, response_url)
super(RedirectException, self).__init__()
self.request_url = request_url
self.response_url = response_url
self.message = message
def __str__(self):
"""Return the message of the error."""
return self.message
class OAuthException(PRAWException):
"""Base exception class for OAuth API calls.
Attribute `message` contains the error message.
Attribute `url` contains the url that resulted in the error.
"""
def __init__(self, message, url):
"""Construct a OAuthException.
:param message: The message associated with the exception.
:param url: The url that resulted in error.
"""
super(OAuthException, self).__init__()
self.message = message
self.url = url
def __str__(self):
"""Return the message along with the url."""
return self.message + " on url {0}".format(self.url)
class OAuthInsufficientScope(OAuthException):
"""Raised when the current OAuth scope is not sufficient for the action.
This indicates the access token is valid, but not for the desired action.
"""
class OAuthInvalidGrant(OAuthException):
"""Raised when the code to retrieve access information is not valid."""
class OAuthInvalidToken(OAuthException):
"""Raised when the current OAuth access token is not valid."""
class APIException(PRAWException):
"""Base exception class for the reddit API error message exceptions.
All exceptions of this type should have their own subclass.
"""
def __init__(self, error_type, message, field='', response=None):
"""Construct an APIException.
:param error_type: The error type set on reddit's end.
:param message: The associated message for the error.
:param field: The input field associated with the error, or ''.
:param response: The HTTP response that resulted in the exception.
"""
super(APIException, self).__init__()
self.error_type = error_type
self.message = message
self.field = field
self.response = response
def __str__(self):
"""Return a string containing the error message and field."""
if hasattr(self, 'ERROR_TYPE'):
return '`{0}` on field `{1}`'.format(self.message, self.field)
else:
return '({0}) `{1}` on field `{2}`'.format(self.error_type,
self.message,
self.field)
class ExceptionList(APIException):
"""Raised when more than one exception occurred."""
def __init__(self, errors):
"""Construct an ExceptionList.
:param errors: The list of errors.
"""
super(ExceptionList, self).__init__(None, None)
self.errors = errors
def __str__(self):
"""Return a string representation for all the errors."""
ret = '\n'
for i, error in enumerate(self.errors):
ret += '\tError {0}) {1}\n'.format(i, six.text_type(error))
return ret
class AlreadySubmitted(APIException):
"""An exception to indicate that a URL was previously submitted."""
ERROR_TYPE = 'ALREADY_SUB'
class AlreadyModerator(APIException):
"""Used to indicate that a user is already a moderator of a subreddit."""
ERROR_TYPE = 'ALREADY_MODERATOR'
class BadCSS(APIException):
"""An exception to indicate bad CSS (such as invalid) was used."""
ERROR_TYPE = 'BAD_CSS'
class BadCSSName(APIException):
"""An exception to indicate a bad CSS name (such as invalid) was used."""
ERROR_TYPE = 'BAD_CSS_NAME'
class BadUsername(APIException):
"""An exception to indicate an invalid username was used."""
ERROR_TYPE = 'BAD_USERNAME'
class InvalidCaptcha(APIException):
"""An exception for when an incorrect captcha error is returned."""
ERROR_TYPE = 'BAD_CAPTCHA'
class InvalidEmails(APIException):
"""An exception for when invalid emails are provided."""
ERROR_TYPE = 'BAD_EMAILS'
class InvalidFlairTarget(APIException):
"""An exception raised when an invalid user is passed as a flair target."""
ERROR_TYPE = 'BAD_FLAIR_TARGET'
class InvalidInvite(APIException):
"""Raised when attempting to accept a nonexistent moderator invite."""
ERROR_TYPE = 'NO_INVITE_FOUND'
class InvalidUser(APIException):
"""An exception for when a user doesn't exist."""
ERROR_TYPE = 'USER_DOESNT_EXIST'
class InvalidUserPass(APIException):
"""An exception for failed logins."""
ERROR_TYPE = 'WRONG_PASSWORD'
class InsufficientCreddits(APIException):
"""Raised when there are not enough creddits to complete the action."""
ERROR_TYPE = 'INSUFFICIENT_CREDDITS'
class NotLoggedIn(APIException):
"""An exception for when a Reddit user isn't logged in."""
ERROR_TYPE = 'USER_REQUIRED'
class NotModified(APIException):
"""An exception raised when reddit returns {'error': 304}.
This error indicates that the requested content was not modified and is
being requested too frequently. Such an error usually occurs when multiple
instances of PRAW are running concurrently or in rapid succession.
"""
def __init__(self, response):
"""Construct an instance of the NotModified exception.
This error does not have an error_type, message, nor field.
"""
super(NotModified, self).__init__(None, None, response=response)
def __str__(self):
"""Return: That page has not been modified."""
return 'That page has not been modified.'
class RateLimitExceeded(APIException):
"""An exception for when something has happened too frequently.
Contains a `sleep_time` attribute for the number of seconds that must
transpire prior to the next request.
"""
ERROR_TYPE = 'RATELIMIT'
class SubredditExists(APIException):
"""An exception to indicate that a subreddit name is not available."""
ERROR_TYPE = 'SUBREDDIT_EXISTS'
class UsernameExists(APIException):
"""An exception to indicate that a username is not available."""
ERROR_TYPE = 'USERNAME_TAKEN'
def _build_error_mapping():
def predicate(obj):
return inspect.isclass(obj) and hasattr(obj, 'ERROR_TYPE')
tmp = {}
for _, obj in inspect.getmembers(sys.modules[__name__], predicate):
tmp[obj.ERROR_TYPE] = obj
return tmp
ERROR_MAPPING = _build_error_mapping()

View File

@ -0,0 +1,243 @@
"""Provides classes that handle request dispatching."""
from __future__ import print_function, unicode_literals
import socket
import sys
import time
from functools import wraps
from .errors import ClientException
from .helpers import normalize_url
from requests import Session
from six import text_type
from six.moves import cPickle # pylint: disable=F0401
from threading import Lock
from timeit import default_timer as timer
class RateLimitHandler(object):
"""The base handler that provides thread-safe rate limiting enforcement.
While this handler is threadsafe, PRAW is not thread safe when the same
`Reddit` instance is being utilized from multiple threads.
"""
last_call = {} # Stores a two-item list: [lock, previous_call_time]
rl_lock = Lock() # lock used for adding items to last_call
@staticmethod
def rate_limit(function):
"""Return a decorator that enforces API request limit guidelines.
We are allowed to make a API request every api_request_delay seconds as
specified in praw.ini. This value may differ from reddit to reddit. For
reddit.com it is 2. Any function decorated with this will be forced to
delay _rate_delay seconds from the calling of the last function
decorated with this before executing.
This decorator must be applied to a RateLimitHandler class method or
instance method as it assumes `rl_lock` and `last_call` are available.
"""
@wraps(function)
def wrapped(cls, _rate_domain, _rate_delay, **kwargs):
cls.rl_lock.acquire()
lock_last = cls.last_call.setdefault(_rate_domain, [Lock(), 0])
with lock_last[0]: # Obtain the domain specific lock
cls.rl_lock.release()
# Sleep if necessary, then perform the request
now = timer()
delay = lock_last[1] + _rate_delay - now
if delay > 0:
now += delay
time.sleep(delay)
lock_last[1] = now
return function(cls, **kwargs)
return wrapped
@classmethod
def evict(cls, urls): # pylint: disable=W0613
"""Method utilized to evict entries for the given urls.
:param urls: An iterable containing normalized urls.
:returns: The number of items removed from the cache.
By default this method returns False as a cache need not be present.
"""
return 0
def __del__(self):
"""Cleanup the HTTP session."""
if self.http:
try:
self.http.close()
except: # Never fail pylint: disable=W0702
pass
def __init__(self):
"""Establish the HTTP session."""
self.http = Session() # Each instance should have its own session
def request(self, request, proxies, timeout, verify, **_):
"""Responsible for dispatching the request and returning the result.
Network level exceptions should be raised and only
``requests.Response`` should be returned.
:param request: A ``requests.PreparedRequest`` object containing all
the data necessary to perform the request.
:param proxies: A dictionary of proxy settings to be utilized for the
request.
:param timeout: Specifies the maximum time that the actual HTTP request
can take.
:param verify: Specifies if SSL certificates should be validated.
``**_`` should be added to the method call to ignore the extra
arguments intended for the cache handler.
"""
settings = self.http.merge_environment_settings(
request.url, proxies, False, verify, None
)
return self.http.send(request, timeout=timeout, allow_redirects=False,
**settings)
RateLimitHandler.request = RateLimitHandler.rate_limit(
RateLimitHandler.request)
class DefaultHandler(RateLimitHandler):
"""Extends the RateLimitHandler to add thread-safe caching support."""
ca_lock = Lock()
cache = {}
cache_hit_callback = None
timeouts = {}
@staticmethod
def with_cache(function):
"""Return a decorator that interacts with a handler's cache.
This decorator must be applied to a DefaultHandler class method or
instance method as it assumes `cache`, `ca_lock` and `timeouts` are
available.
"""
@wraps(function)
def wrapped(cls, _cache_key, _cache_ignore, _cache_timeout, **kwargs):
def clear_timeouts():
"""Clear the cache of timed out results."""
for key in list(cls.timeouts):
if timer() - cls.timeouts[key] > _cache_timeout:
del cls.timeouts[key]
del cls.cache[key]
if _cache_ignore:
return function(cls, **kwargs)
with cls.ca_lock:
clear_timeouts()
if _cache_key in cls.cache:
if cls.cache_hit_callback:
cls.cache_hit_callback(_cache_key)
return cls.cache[_cache_key]
# Releasing the lock before actually making the request allows for
# the possibility of more than one thread making the same request
# to get through. Without having domain-specific caching (under the
# assumption only one request to a domain can be made at a
# time), there isn't a better way to handle this.
result = function(cls, **kwargs)
# The handlers don't call `raise_for_status` so we need to ignore
# status codes that will result in an exception that should not be
# cached.
if result.status_code not in (200, 302):
return result
with cls.ca_lock:
cls.timeouts[_cache_key] = timer()
cls.cache[_cache_key] = result
return result
return wrapped
@classmethod
def clear_cache(cls):
"""Remove all items from the cache."""
with cls.ca_lock:
cls.cache = {}
cls.timeouts = {}
@classmethod
def evict(cls, urls):
"""Remove items from cache matching URLs.
Return the number of items removed.
"""
if isinstance(urls, text_type):
urls = [urls]
urls = set(normalize_url(url) for url in urls)
retval = 0
with cls.ca_lock:
for key in list(cls.cache):
if key[0] in urls:
retval += 1
del cls.cache[key]
del cls.timeouts[key]
return retval
DefaultHandler.request = DefaultHandler.with_cache(RateLimitHandler.request)
class MultiprocessHandler(object):
"""A PRAW handler to interact with the PRAW multi-process server."""
def __init__(self, host='localhost', port=10101):
"""Construct an instance of the MultiprocessHandler."""
self.host = host
self.port = port
def _relay(self, **kwargs):
"""Send the request through the server and return the HTTP response."""
retval = None
delay_time = 2 # For connection retries
read_attempts = 0 # For reading from socket
while retval is None: # Evict can return False
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock_fp = sock.makefile('rwb') # Used for pickle
try:
sock.connect((self.host, self.port))
cPickle.dump(kwargs, sock_fp, cPickle.HIGHEST_PROTOCOL)
sock_fp.flush()
retval = cPickle.load(sock_fp)
except: # pylint: disable=W0702
exc_type, exc, _ = sys.exc_info()
socket_error = exc_type is socket.error
if socket_error and exc.errno == 111: # Connection refused
sys.stderr.write('Cannot connect to multiprocess server. I'
's it running? Retrying in {0} seconds.\n'
.format(delay_time))
time.sleep(delay_time)
delay_time = min(64, delay_time * 2)
elif exc_type is EOFError or socket_error and exc.errno == 104:
# Failure during socket READ
if read_attempts >= 3:
raise ClientException('Successive failures reading '
'from the multiprocess server.')
sys.stderr.write('Lost connection with multiprocess server'
' during read. Trying again.\n')
read_attempts += 1
else:
raise
finally:
sock_fp.close()
sock.close()
if isinstance(retval, Exception):
raise retval # pylint: disable=E0702
return retval
def evict(self, urls):
"""Forward the eviction to the server and return its response."""
return self._relay(method='evict', urls=urls)
def request(self, **kwargs):
"""Forward the request to the server and return its HTTP response."""
return self._relay(method='request', **kwargs)

View File

@ -0,0 +1,481 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Helper functions.
The functions here provide functionality that is often needed by programs using
PRAW, but which isn't part of reddit's API.
"""
from __future__ import unicode_literals
import six
import sys
import time
from collections import deque
from functools import partial
from timeit import default_timer as timer
from .errors import HTTPException, PRAWException
from operator import attrgetter
BACKOFF_START = 4 # Minimum number of seconds to sleep during errors
KEEP_ITEMS = 128 # On each iteration only remember the first # items
# for conversion between broken reddit timestamps and unix timestamps
REDDIT_TIMESTAMP_OFFSET = 28800
def comment_stream(reddit_session, subreddit, limit=None, verbosity=1):
"""Indefinitely yield new comments from the provided subreddit.
Comments are yielded from oldest to newest.
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the comment stream for all comments made to
reddit.
:param limit: The maximum number of comments to fetch in a single
iteration. When None, fetch all available comments (reddit limits this
to 1000 (or multiple of 1000 for multi-subreddits). If this number is
too small, comments may be missed.
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of comments
processed and provide the short-term number of comments processed per
second; >= 2: output when additional delays are added in order to avoid
subsequent unexpected http errors. >= 3: output debugging information
regarding the comment stream. (Default: 1)
"""
get_function = partial(reddit_session.get_comments,
six.text_type(subreddit))
return _stream_generator(get_function, limit, verbosity)
def submission_stream(reddit_session, subreddit, limit=None, verbosity=1):
"""Indefinitely yield new submissions from the provided subreddit.
Submissions are yielded from oldest to newest.
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the submissions stream for all submissions
made to reddit.
:param limit: The maximum number of submissions to fetch in a single
iteration. When None, fetch all available submissions (reddit limits
this to 1000 (or multiple of 1000 for multi-subreddits). If this number
is too small, submissions may be missed. Since there isn't a limit to
the number of submissions that can be retrieved from r/all, the limit
will be set to 1000 when limit is None.
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of submissions
processed and provide the short-term number of submissions processed
per second; >= 2: output when additional delays are added in order to
avoid subsequent unexpected http errors. >= 3: output debugging
information regarding the submission stream. (Default: 1)
"""
if six.text_type(subreddit).lower() == "all":
if limit is None:
limit = 1000
if not hasattr(subreddit, 'reddit_session'):
subreddit = reddit_session.get_subreddit(subreddit)
return _stream_generator(subreddit.get_new, limit, verbosity)
def valid_redditors(redditors, sub):
"""Return a verified list of valid Redditor instances.
:param redditors: A list comprised of Redditor instances and/or strings
that are to be verified as actual redditor accounts.
:param sub: A Subreddit instance that the authenticated account has
flair changing permission on.
Note: Flair will be unset for all valid redditors in `redditors` on the
subreddit `sub`. A valid redditor is defined as a redditor that is
registered on reddit.
"""
simplified = list(set(six.text_type(x).lower() for x in redditors))
return [sub.reddit_session.get_redditor(simplified[i], fetch=False)
for (i, resp) in enumerate(sub.set_flair_csv(
({'user': x, 'flair_text': x} for x in simplified)))
if resp['ok']]
def submissions_between(reddit_session,
subreddit,
lowest_timestamp=None,
highest_timestamp=None,
newest_first=True,
extra_cloudsearch_fields=None,
verbosity=1):
"""Yield submissions between two timestamps.
If both ``highest_timestamp`` and ``lowest_timestamp`` are unspecified,
yields all submissions in the ``subreddit``.
Submissions are yielded from newest to oldest(like in the "new" queue).
:param reddit_session: The reddit_session to make requests from. In all the
examples this is assigned to the variable ``r``.
:param subreddit: Either a subreddit object, or the name of a
subreddit. Use `all` to get the submissions stream for all submissions
made to reddit.
:param lowest_timestamp: The lower bound for ``created_utc`` atributed of
submissions.
(Default: subreddit's created_utc or 0 when subreddit == "all").
:param highest_timestamp: The upper bound for ``created_utc`` attribute
of submissions. (Default: current unix time)
NOTE: both highest_timestamp and lowest_timestamp are proper
unix timestamps(just like ``created_utc`` attributes)
:param newest_first: If set to true, yields submissions
from newest to oldest. Otherwise yields submissions
from oldest to newest
:param extra_cloudsearch_fields: Allows extra filtering of results by
parameters like author, self. Full list is available here:
https://www.reddit.com/wiki/search
:param verbosity: A number that controls the amount of output produced to
stderr. <= 0: no output; >= 1: output the total number of submissions
processed; >= 2: output debugging information regarding
the search queries. (Default: 1)
"""
def debug(msg, level):
if verbosity >= level:
sys.stderr.write(msg + '\n')
def format_query_field(k, v):
if k in ["nsfw", "self"]:
# even though documentation lists "no" and "yes"
# as possible values, in reality they don't work
if v not in [0, 1, "0", "1"]:
raise PRAWException("Invalid value for the extra"
"field {}. Only '0' and '1' are"
"valid values.".format(k))
return "{}:{}".format(k, v)
return "{}:'{}'".format(k, v)
if extra_cloudsearch_fields is None:
extra_cloudsearch_fields = {}
extra_query_part = " ".join(
[format_query_field(k, v) for (k, v)
in sorted(extra_cloudsearch_fields.items())]
)
if highest_timestamp is None:
highest_timestamp = int(time.time()) + REDDIT_TIMESTAMP_OFFSET
else:
highest_timestamp = int(highest_timestamp) + REDDIT_TIMESTAMP_OFFSET
if lowest_timestamp is not None:
lowest_timestamp = int(lowest_timestamp) + REDDIT_TIMESTAMP_OFFSET
elif not isinstance(subreddit, six.string_types):
lowest_timestamp = int(subreddit.created)
elif subreddit not in ("all", "contrib", "mod", "friend"):
lowest_timestamp = int(reddit_session.get_subreddit(subreddit).created)
else:
lowest_timestamp = 0
original_highest_timestamp = highest_timestamp
original_lowest_timestamp = lowest_timestamp
# When making timestamp:X..Y queries, reddit misses submissions
# inside X..Y range, but they can be found inside Y..Z range
# It is not clear what is the value of Z should be, but it seems
# like the difference is usually about ~1 hour or less
# To be sure, let's set the workaround offset to 2 hours
out_of_order_submissions_workaround_offset = 7200
highest_timestamp += out_of_order_submissions_workaround_offset
lowest_timestamp -= out_of_order_submissions_workaround_offset
# Those parameters work ok, but there may be a better set of parameters
window_size = 60 * 60
search_limit = 100
min_search_results_in_window = 50
window_adjustment_ratio = 1.25
backoff = BACKOFF_START
processed_submissions = 0
prev_win_increased = False
prev_win_decreased = False
while highest_timestamp >= lowest_timestamp:
try:
if newest_first:
t1 = max(highest_timestamp - window_size, lowest_timestamp)
t2 = highest_timestamp
else:
t1 = lowest_timestamp
t2 = min(lowest_timestamp + window_size, highest_timestamp)
search_query = 'timestamp:{}..{}'.format(t1, t2)
if extra_query_part:
search_query = "(and {} {})".format(search_query,
extra_query_part)
debug(search_query, 3)
search_results = list(reddit_session.search(search_query,
subreddit=subreddit,
limit=search_limit,
syntax='cloudsearch',
sort='new'))
debug("Received {0} search results for query {1}"
.format(len(search_results), search_query),
2)
backoff = BACKOFF_START
except HTTPException as exc:
debug("{0}. Sleeping for {1} seconds".format(exc, backoff), 2)
time.sleep(backoff)
backoff *= 2
continue
if len(search_results) >= search_limit:
power = 2 if prev_win_decreased else 1
window_size = int(window_size / window_adjustment_ratio**power)
prev_win_decreased = True
debug("Decreasing window size to {0} seconds".format(window_size),
2)
# Since it is possible that there are more submissions
# in the current window, we have to re-do the request
# with reduced window
continue
else:
prev_win_decreased = False
search_results = [s for s in search_results
if original_lowest_timestamp <= s.created and
s.created <= original_highest_timestamp]
for submission in sorted(search_results,
key=attrgetter('created_utc', 'id'),
reverse=newest_first):
yield submission
processed_submissions += len(search_results)
debug('Total processed submissions: {}'
.format(processed_submissions), 1)
if newest_first:
highest_timestamp -= (window_size + 1)
else:
lowest_timestamp += (window_size + 1)
if len(search_results) < min_search_results_in_window:
power = 2 if prev_win_increased else 1
window_size = int(window_size * window_adjustment_ratio**power)
prev_win_increased = True
debug("Increasing window size to {0} seconds"
.format(window_size), 2)
else:
prev_win_increased = False
def _stream_generator(get_function, limit=None, verbosity=1):
def debug(msg, level):
if verbosity >= level:
sys.stderr.write(msg + '\n')
def b36_id(item):
return int(item.id, 36)
seen = BoundedSet(KEEP_ITEMS * 16)
before = None
count = 0 # Count is incremented to bypass the cache
processed = 0
backoff = BACKOFF_START
while True:
items = []
sleep = None
start = timer()
try:
i = None
params = {'uniq': count}
count = (count + 1) % 100
if before:
params['before'] = before
gen = enumerate(get_function(limit=limit, params=params))
for i, item in gen:
if b36_id(item) in seen:
if i == 0:
if before is not None:
# reddit sent us out of order data -- log it
debug('(INFO) {0} already seen with before of {1}'
.format(item.fullname, before), 3)
before = None
break
if i == 0: # Always the first item in the generator
before = item.fullname
if b36_id(item) not in seen:
items.append(item)
processed += 1
if verbosity >= 1 and processed % 100 == 0:
sys.stderr.write(' Items: {0} \r'
.format(processed))
sys.stderr.flush()
if i < KEEP_ITEMS:
seen.add(b36_id(item))
else: # Generator exhausted
if i is None: # Generator yielded no items
assert before is not None
# Try again without before as the before item may be too
# old or no longer exist.
before = None
backoff = BACKOFF_START
except HTTPException as exc:
sleep = (backoff, '{0}. Sleeping for {{0}} seconds.'.format(exc),
2)
backoff *= 2
# Provide rate limit
if verbosity >= 1:
rate = len(items) / (timer() - start)
sys.stderr.write(' Items: {0} ({1:.2f} ips) \r'
.format(processed, rate))
sys.stderr.flush()
# Yield items from oldest to newest
for item in items[::-1]:
yield item
# Sleep if necessary
if sleep:
sleep_time, msg, msg_level = sleep # pylint: disable=W0633
debug(msg.format(sleep_time), msg_level)
time.sleep(sleep_time)
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
"""Given a sequence, divide it into sequences of length `chunk_length`.
:param allow_incomplete: If True, allow final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
"""
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def convert_id36_to_numeric_id(id36):
"""Convert strings representing base36 numbers into an integer."""
if not isinstance(id36, six.string_types) or id36.count("_") > 0:
raise ValueError("must supply base36 string, not fullname (e.g. use "
"xxxxx, not t3_xxxxx)")
return int(id36, 36)
def convert_numeric_id_to_id36(numeric_id):
"""Convert an integer into its base36 string representation.
This method has been cleaned up slightly to improve readability. For more
info see:
https://github.com/reddit/reddit/blob/master/r2/r2/lib/utils/_utils.pyx
https://www.reddit.com/r/redditdev/comments/n624n/submission_ids_question/
https://en.wikipedia.org/wiki/Base36
"""
# base36 allows negative numbers, but reddit does not
if not isinstance(numeric_id, six.integer_types) or numeric_id < 0:
raise ValueError("must supply a positive int/long")
# Alphabet used for base 36 conversion
alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
alphabet_len = len(alphabet)
# Temp assign
current_number = numeric_id
base36 = []
# Current_number must be greater than alphabet length to while/divmod
if 0 <= current_number < alphabet_len:
return alphabet[current_number]
# Break up into chunks
while current_number != 0:
current_number, rem = divmod(current_number, alphabet_len)
base36.append(alphabet[rem])
# String is built in reverse order
return ''.join(reversed(base36))
def flatten_tree(tree, nested_attr='replies', depth_first=False):
"""Return a flattened version of the passed in tree.
:param nested_attr: The attribute name that contains the nested items.
Defaults to ``replies`` which is suitable for comments.
:param depth_first: When true, add to the list in a depth-first manner
rather than the default breadth-first manner.
"""
stack = deque(tree)
extend = stack.extend if depth_first else stack.extendleft
retval = []
while stack:
item = stack.popleft()
nested = getattr(item, nested_attr, None)
if nested:
extend(nested)
retval.append(item)
return retval
def normalize_url(url):
"""Return url after stripping trailing .json and trailing slashes."""
if url.endswith('.json'):
url = url[:-5]
if url.endswith('/'):
url = url[:-1]
return url
class BoundedSet(object):
"""A set with a maximum size that evicts the oldest items when necessary.
This class does not implement the complete set interface.
"""
def __init__(self, max_items):
"""Construct an instance of the BoundedSet."""
self.max_items = max_items
self._fifo = []
self._set = set()
def __contains__(self, item):
"""Test if the BoundedSet contains item."""
return item in self._set
def add(self, item):
"""Add an item to the set discarding the oldest item if necessary."""
if item in self._set:
self._fifo.remove(item)
elif len(self._set) == self.max_items:
self._set.remove(self._fifo.pop(0))
self._fifo.append(item)
self._set.add(item)

View File

@ -0,0 +1,271 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""Internal helper functions.
The functions in this module are not to be relied upon by third-parties.
"""
from __future__ import print_function, unicode_literals
import os
import re
import six
import sys
from requests import Request, codes, exceptions
from requests.compat import urljoin
from .decorators import restrict_access
from .errors import (ClientException, HTTPException, Forbidden, NotFound,
InvalidSubreddit, OAuthException,
OAuthInsufficientScope, OAuthInvalidToken,
RedirectException)
from warnings import warn
try:
from OpenSSL import __version__ as _opensslversion
_opensslversionlist = [int(minor) if minor.isdigit() else minor
for minor in _opensslversion.split('.')]
except ImportError:
_opensslversionlist = [0, 15]
MIN_PNG_SIZE = 67
MIN_JPEG_SIZE = 128
MAX_IMAGE_SIZE = 512000
JPEG_HEADER = b'\xff\xd8\xff'
PNG_HEADER = b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a'
RE_REDIRECT = re.compile('(rand(om|nsfw))|about/sticky')
def _get_redditor_listing(subpath=''):
"""Return function to generate Redditor listings."""
def _listing(self, sort='new', time='all', *args, **kwargs):
"""Return a get_content generator for some RedditContentObject type.
:param sort: Specify the sort order of the results if applicable
(one of ``'hot'``, ``'new'``, ``'top'``, ``'controversial'``).
:param time: Specify the time-period to return submissions if
applicable (one of ``'hour'``, ``'day'``, ``'week'``,
``'month'``, ``'year'``, ``'all'``).
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
"""
kwargs.setdefault('params', {})
kwargs['params'].setdefault('sort', sort)
kwargs['params'].setdefault('t', time)
url = urljoin(self._url, subpath) # pylint: disable=W0212
return self.reddit_session.get_content(url, *args, **kwargs)
return _listing
def _get_sorter(subpath='', **defaults):
"""Return function to generate specific subreddit Submission listings."""
@restrict_access(scope='read')
def _sorted(self, *args, **kwargs):
"""Return a get_content generator for some RedditContentObject type.
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
"""
if not kwargs.get('params'):
kwargs['params'] = {}
for key, value in six.iteritems(defaults):
kwargs['params'].setdefault(key, value)
url = urljoin(self._url, subpath) # pylint: disable=W0212
return self.reddit_session.get_content(url, *args, **kwargs)
return _sorted
def _image_type(image):
size = os.path.getsize(image.name)
if size < MIN_PNG_SIZE:
raise ClientException('png image is too small.')
if size > MAX_IMAGE_SIZE:
raise ClientException('`image` is too big. Max: {0} bytes'
.format(MAX_IMAGE_SIZE))
first_bytes = image.read(MIN_PNG_SIZE)
image.seek(0)
if first_bytes.startswith(PNG_HEADER):
return 'png'
elif first_bytes.startswith(JPEG_HEADER):
if size < MIN_JPEG_SIZE:
raise ClientException('jpeg image is too small.')
return 'jpg'
raise ClientException('`image` must be either jpg or png.')
def _modify_relationship(relationship, unlink=False, is_sub=False):
"""Return a function for relationship modification.
Used to support friending (user-to-user), as well as moderating,
contributor creating, and banning (user-to-subreddit).
"""
# The API uses friend and unfriend to manage all of these relationships.
url_key = 'unfriend' if unlink else 'friend'
if relationship == 'friend':
access = {'scope': None, 'login': True}
elif relationship == 'moderator':
access = {'scope': 'modothers'}
elif relationship in ['banned', 'contributor', 'muted']:
access = {'scope': 'modcontributors'}
elif relationship in ['wikibanned', 'wikicontributor']:
access = {'scope': ['modcontributors', 'modwiki']}
else:
access = {'scope': None, 'mod': True}
@restrict_access(**access)
def do_relationship(thing, user, **kwargs):
data = {'name': six.text_type(user),
'type': relationship}
data.update(kwargs)
if is_sub:
data['r'] = six.text_type(thing)
else:
data['container'] = thing.fullname
session = thing.reddit_session
if relationship == 'moderator':
session.evict(session.config['moderators'].format(
subreddit=six.text_type(thing)))
url = session.config[url_key]
return session.request_json(url, data=data)
return do_relationship
def _prepare_request(reddit_session, url, params, data, auth, files,
method=None):
"""Return a requests Request object that can be "prepared"."""
# Requests using OAuth for authorization must switch to using the oauth
# domain.
if getattr(reddit_session, '_use_oauth', False):
bearer = 'bearer {0}'.format(reddit_session.access_token)
headers = {'Authorization': bearer}
config = reddit_session.config
for prefix in (config.api_url, config.permalink_url):
if url.startswith(prefix):
if config.log_requests >= 1:
msg = 'substituting {0} for {1} in url\n'.format(
config.oauth_url, prefix)
sys.stderr.write(msg)
url = config.oauth_url + url[len(prefix):]
break
else:
headers = {}
headers.update(reddit_session.http.headers)
if method:
pass
elif data or files:
method = 'POST'
else:
method = 'GET'
# Log the request if logging is enabled
if reddit_session.config.log_requests >= 1:
sys.stderr.write('{0}: {1}\n'.format(method, url))
if reddit_session.config.log_requests >= 2:
if params:
sys.stderr.write('params: {0}\n'.format(params))
if data:
sys.stderr.write('data: {0}\n'.format(data))
if auth:
sys.stderr.write('auth: {0}\n'.format(auth))
# Prepare request
request = Request(method=method, url=url, headers=headers, params=params,
auth=auth, cookies=reddit_session.http.cookies)
if method == 'GET':
return request
# Most POST requests require adding `api_type` and `uh` to the data.
if data is True:
data = {}
if isinstance(data, dict):
if not auth:
data.setdefault('api_type', 'json')
if reddit_session.modhash:
data.setdefault('uh', reddit_session.modhash)
else:
request.headers.setdefault('Content-Type', 'application/json')
request.data = data
request.files = files
return request
def _raise_redirect_exceptions(response):
"""Return the new url or None if there are no redirects.
Raise exceptions if appropriate.
"""
if response.status_code not in [301, 302, 307]:
return None
new_url = urljoin(response.url, response.headers['location'])
if 'reddits/search' in new_url: # Handle non-existent subreddit
subreddit = new_url.rsplit('=', 1)[1]
raise InvalidSubreddit('`{0}` is not a valid subreddit'
.format(subreddit))
elif not RE_REDIRECT.search(response.url):
raise RedirectException(response.url, new_url)
return new_url
def _raise_response_exceptions(response):
"""Raise specific errors on some status codes."""
if not response.ok and 'www-authenticate' in response.headers:
msg = response.headers['www-authenticate']
if 'insufficient_scope' in msg:
raise OAuthInsufficientScope('insufficient_scope', response.url)
elif 'invalid_token' in msg:
raise OAuthInvalidToken('invalid_token', response.url)
else:
raise OAuthException(msg, response.url)
if response.status_code == codes.forbidden: # pylint: disable=E1101
raise Forbidden(_raw=response)
elif response.status_code == codes.not_found: # pylint: disable=E1101
raise NotFound(_raw=response)
else:
try:
response.raise_for_status() # These should all be directly mapped
except exceptions.HTTPError as exc:
raise HTTPException(_raw=exc.response)
def _to_reddit_list(arg):
"""Return an argument converted to a reddit-formatted list.
The returned format is a comma deliminated list. Each element is a string
representation of an object. Either given as a string or as an object that
is then converted to its string representation.
"""
if (isinstance(arg, six.string_types) or not (
hasattr(arg, "__getitem__") or hasattr(arg, "__iter__"))):
return six.text_type(arg)
else:
return ','.join(six.text_type(a) for a in arg)
def _warn_pyopenssl():
"""Warn the user against faulty versions of pyOpenSSL."""
if _opensslversionlist < [0, 15]: # versions >= 0.15 are fine
warn(RuntimeWarning(
"pyOpenSSL {0} may be incompatible with praw if validating"
"ssl certificates, which is on by default.\nSee https://"
"github.com/praw/pull/625 for more information".format(
_opensslversion)
))

View File

@ -0,0 +1,102 @@
"""Provides a request server to be used with the multiprocess handler."""
from __future__ import print_function, unicode_literals
import socket
import sys
from optparse import OptionParser
from . import __version__
from .handlers import DefaultHandler
from requests import Session
from six.moves import cPickle, socketserver # pylint: disable=F0401
from threading import Lock
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
# pylint: disable=R0903,W0232
"""A TCP server that creates new threads per connection."""
allow_reuse_address = True
@staticmethod
def handle_error(_, client_addr):
"""Mute tracebacks of common errors."""
exc_type, exc_value, _ = sys.exc_info()
if exc_type is socket.error and exc_value[0] == 32:
pass
elif exc_type is cPickle.UnpicklingError:
sys.stderr.write('Invalid connection from {0}\n'
.format(client_addr[0]))
else:
raise
class RequestHandler(socketserver.StreamRequestHandler):
# pylint: disable=W0232
"""A class that handles incoming requests.
Requests to the same domain are cached and rate-limited.
"""
ca_lock = Lock() # lock around cache and timeouts
cache = {} # caches requests
http = Session() # used to make requests
last_call = {} # Stores a two-item list: [lock, previous_call_time]
rl_lock = Lock() # lock used for adding items to last_call
timeouts = {} # store the time items in cache were entered
do_evict = DefaultHandler.evict # Add in the evict method
@staticmethod
def cache_hit_callback(key):
"""Output when a cache hit occurs."""
print('HIT {0} {1}'.format('POST' if key[1][1] else 'GET', key[0]))
@DefaultHandler.with_cache
@DefaultHandler.rate_limit
def do_request(self, request, proxies, timeout, **_):
"""Dispatch the actual request and return the result."""
print('{0} {1}'.format(request.method, request.url))
response = self.http.send(request, proxies=proxies, timeout=timeout,
allow_redirects=False)
response.raw = None # Make pickleable
return response
def handle(self):
"""Parse the RPC, make the call, and pickle up the return value."""
data = cPickle.load(self.rfile) # pylint: disable=E1101
method = data.pop('method')
try:
retval = getattr(self, 'do_{0}'.format(method))(**data)
except Exception as e:
# All exceptions should be passed to the client
retval = e
cPickle.dump(retval, self.wfile, # pylint: disable=E1101
cPickle.HIGHEST_PROTOCOL)
def run():
"""The entry point from the praw-multiprocess utility."""
parser = OptionParser(version='%prog {0}'.format(__version__))
parser.add_option('-a', '--addr', default='localhost',
help=('The address or host to listen on. Specify -a '
'0.0.0.0 to listen on all addresses. '
'Default: localhost'))
parser.add_option('-p', '--port', type='int', default='10101',
help=('The port to listen for requests on. '
'Default: 10101'))
options, _ = parser.parse_args()
try:
server = ThreadingTCPServer((options.addr, options.port),
RequestHandler)
except (socket.error, socket.gaierror) as exc: # Handle bind errors
print(exc)
sys.exit(1)
print('Listening on {0} port {1}'.format(options.addr, options.port))
try:
server.serve_forever() # pylint: disable=E1101
except KeyboardInterrupt:
server.socket.close() # pylint: disable=E1101
RequestHandler.http.close()
print('Goodbye!')

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
[DEFAULT]
# The domain name PRAW will use to interact with the reddit site via its API.
api_domain: api.reddit.com
# Time, a float, in seconds, required between calls. See:
# http://code.reddit.com/wiki/API
api_request_delay: 2.0
# A boolean to indicate whether or not to check for package updates.
check_for_updates: True
# Time, a float, in seconds, to save the results of a get/post request.
cache_timeout: 30
# Log the API calls
# 0: no logging
# 1: log only the request URIs
# 2: log the request URIs as well as any POST data
log_requests: 0
# The domain name PRAW will use for oauth-related requests.
oauth_domain: oauth.reddit.com
# Whether or not to use HTTPS for oauth connections. This should only be
# changed for development environments.
oauth_https: True
# OAuth grant type: either `authorization_code` or `password`
oauth_grant_type: authorization_code
# The maximum length of unicode representations of Comment, Message and
# Submission objects. This is mainly used to fit them within a terminal window
# line. A negative value means no limit.
output_chars_limit: 80
# The domain name PRAW will use when permalinks are requested.
permalink_domain: www.reddit.com
# The domain name to use for short urls.
short_domain: redd.it
# A boolean to indicate if json_dict, which contains the original API response,
# should be stored on every object in the json_dict attribute. Default is
# False as memory usage will double if enabled.
store_json_result: False
# Maximum time, a float, in seconds, before a single HTTP request times
# out. urllib2.URLError is raised upon timeout.
timeout: 45
# A boolean to indicate if SSL certificats should be validated. The
# default is True.
validate_certs: True
# Object to kind mappings
comment_kind: t1
message_kind: t4
redditor_kind: t2
submission_kind: t3
subreddit_kind: t5
[reddit]
# Uses the default settings
[reddit_oauth_test]
oauth_client_id: stJlUSUbPQe5lQ
oauth_client_secret: iU-LsOzyJH7BDVoq-qOWNEq2zuI
oauth_redirect_uri: https://127.0.0.1:65010/authorize_callback
[local_example]
api_domain: reddit.local
api_request_delay: 0
log_requests: 0
message_kind: t7
permalink_domain: reddit.local
short_domain:
submission_kind: t6
subreddit_kind: t5

View File

@ -0,0 +1,45 @@
# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""Provides the code to load PRAW's configuration file `praw.ini`."""
from __future__ import print_function, unicode_literals
import os
import sys
from six.moves import configparser
def _load_configuration():
"""Attempt to load settings from various praw.ini files."""
config = configparser.RawConfigParser()
module_dir = os.path.dirname(sys.modules[__name__].__file__)
if 'APPDATA' in os.environ: # Windows
os_config_path = os.environ['APPDATA']
elif 'XDG_CONFIG_HOME' in os.environ: # Modern Linux
os_config_path = os.environ['XDG_CONFIG_HOME']
elif 'HOME' in os.environ: # Legacy Linux
os_config_path = os.path.join(os.environ['HOME'], '.config')
else:
os_config_path = None
locations = [os.path.join(module_dir, 'praw.ini'), 'praw.ini']
if os_config_path is not None:
locations.insert(1, os.path.join(os_config_path, 'praw.ini'))
if not config.read(locations):
raise Exception('Could not find config file in any of: {0}'
.format(locations))
return config
CONFIG = _load_configuration()
del _load_configuration

913
ttrv/page.py Normal file
View File

@ -0,0 +1,913 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import sys
import time
import logging
from functools import wraps
import six
from kitchen.text.display import textual_width
from . import docs
from .clipboard import copy as clipboard_copy
from .objects import Controller, Command
from .exceptions import TemporaryFileError, ProgramError
from .__version__ import __version__
_logger = logging.getLogger(__name__)
def logged_in(f):
"""
Decorator for Page methods that require the user to be authenticated.
"""
@wraps(f)
def wrapped_method(self, *args, **kwargs):
if not self.reddit.is_oauth_session():
self.term.show_notification('Not logged in')
return None
return f(self, *args, **kwargs)
return wrapped_method
class PageController(Controller):
character_map = {}
class Page(object):
BANNER = None
FOOTER = None
def __init__(self, reddit, term, config, oauth):
self.reddit = reddit
self.term = term
self.config = config
self.oauth = oauth
self.content = None
self.nav = None
self.controller = None
self.active = True
self.selected_page = None
self._row = 0
self._subwindows = None
def refresh_content(self, order=None, name=None):
raise NotImplementedError
def _draw_item(self, win, data, inverted):
raise NotImplementedError
def get_selected_item(self):
"""
Return the content dictionary that is currently selected by the cursor.
"""
return self.content.get(self.nav.absolute_index)
def loop(self):
"""
Main control loop runs the following steps:
1. Re-draw the screen
2. Wait for user to press a key (includes terminal resizing)
3. Trigger the method registered to the input key
4. Check if there are any nested pages that need to be looped over
The loop will run until self.active is set to False from within one of
the methods.
"""
self.active = True
# This needs to be called once before the main loop, in case a subpage
# was pre-selected before the loop started. This happens in __main__.py
# with ``page.open_submission(url=url)``
while self.selected_page and self.active:
self.handle_selected_page()
while self.active:
self.draw()
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
while self.selected_page and self.active:
self.handle_selected_page()
return self.selected_page
def handle_selected_page(self):
"""
Some commands will result in an action that causes a new page to open.
Examples include selecting a submission, viewing subscribed subreddits,
or opening the user's inbox. With these commands, the newly selected
page will be pre-loaded and stored in ``self.selected_page`` variable.
It's up to each page type to determine what to do when another page is
selected.
- It can start a nested page.loop(). This would allow the user to
return to their previous screen after exiting the sub-page. For
example, this is what happens when opening an individual submission
from within a subreddit page. When the submission is closed, the
user resumes the subreddit that they were previously viewing.
- It can close the current self.loop() and bubble the selected page up
one level in the loop stack. For example, this is what happens when
the user opens their subscriptions and selects a subreddit. The
subscription page loop is closed and the selected subreddit is
bubbled up to the root level loop.
Care should be taken to ensure the user can never enter an infinite
nested loop, as this could lead to memory leaks and recursion errors.
# Example of an unsafe nested loop
subreddit_page.loop()
-> submission_page.loop()
-> subreddit_page.loop()
-> submission_page.loop()
...
"""
raise NotImplementedError
@PageController.register(Command('REFRESH'))
def reload_page(self):
"""
Clear the PRAW cache to force the page the re-fetch content from reddit.
"""
self.reddit.handler.clear_cache()
self.refresh_content()
@PageController.register(Command('EXIT'))
def exit(self):
"""
Prompt and exit the application.
"""
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
@PageController.register(Command('FORCE_EXIT'))
def force_exit(self):
"""
Immediately exit the application.
"""
sys.exit()
@PageController.register(Command('PREVIOUS_THEME'))
def previous_theme(self):
"""
Cycle to preview the previous theme from the internal list of themes.
"""
theme = self.term.theme_list.previous(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.previous(theme)
self.term.set_theme(theme)
self.draw()
message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('NEXT_THEME'))
def next_theme(self):
"""
Cycle to preview the next theme from the internal list of themes.
"""
theme = self.term.theme_list.next(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.next(theme)
self.term.set_theme(theme)
self.draw()
message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('HELP'))
def show_help(self):
"""
Open the help documentation in the system pager.
"""
self.term.open_pager(docs.HELP.strip())
@PageController.register(Command('MOVE_UP'))
def move_cursor_up(self):
"""
Move the cursor up one selection.
"""
self._move_cursor(-1)
self.clear_input_queue()
@PageController.register(Command('MOVE_DOWN'))
def move_cursor_down(self):
"""
Move the cursor down one selection.
"""
self._move_cursor(1)
self.clear_input_queue()
@PageController.register(Command('MOVE_NEXT_UNREAD'))
def move_next_unread(self):
"""
Move the cursor to the next unread url.
"""
self._move_cursor_to_unread(1)
self.clear_input_queue()
@PageController.register(Command('MOVE_PREV_UNREAD'))
def move_prev_unread(self):
"""
Move the cursor to the previous unread url.
"""
self._move_cursor_to_unread(-1)
self.clear_input_queue()
@PageController.register(Command('PAGE_UP'))
def move_page_up(self):
"""
Move the cursor up approximately the number of entries on the page.
"""
self._move_page(-1)
self.clear_input_queue()
@PageController.register(Command('PAGE_DOWN'))
def move_page_down(self):
"""
Move the cursor down approximately the number of entries on the page.
"""
self._move_page(1)
self.clear_input_queue()
@PageController.register(Command('PAGE_TOP'))
def move_page_top(self):
"""
Move the cursor to the first item on the page.
"""
self.nav.page_index = self.content.range[0]
self.nav.cursor_index = 0
self.nav.inverted = False
@PageController.register(Command('PAGE_BOTTOM'))
def move_page_bottom(self):
"""
Move the cursor to the last item on the page.
"""
self.nav.page_index = self.content.range[1]
self.nav.cursor_index = 0
self.nav.inverted = True
@PageController.register(Command('UPVOTE'))
@logged_in
def upvote(self):
"""
Upvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
elif getattr(data['object'], 'archived'):
self.term.show_notification("Voting disabled for archived post", style='Error')
elif data['likes']:
with self.term.loader('Clearing vote'):
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
else:
with self.term.loader('Voting'):
data['object'].upvote()
if not self.term.loader.exception:
data['likes'] = True
@PageController.register(Command('DOWNVOTE'))
@logged_in
def downvote(self):
"""
Downvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
elif getattr(data['object'], 'archived'):
self.term.show_notification("Voting disabled for archived post", style='Error')
elif data['likes'] or data['likes'] is None:
with self.term.loader('Voting'):
data['object'].downvote()
if not self.term.loader.exception:
data['likes'] = False
else:
with self.term.loader('Clearing vote'):
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
@PageController.register(Command('SAVE'))
@logged_in
def save(self):
"""
Mark the currently selected item as saved through the reddit API.
"""
data = self.get_selected_item()
if 'saved' not in data:
self.term.flash()
elif not data['saved']:
with self.term.loader('Saving'):
data['object'].save()
if not self.term.loader.exception:
data['saved'] = True
else:
with self.term.loader('Unsaving'):
data['object'].unsave()
if not self.term.loader.exception:
data['saved'] = False
@PageController.register(Command('LOGIN'))
def login(self):
"""
Prompt to log into the user's account, or log out of the current
account.
"""
if self.reddit.is_oauth_session():
ch = self.term.show_notification('Log out? (y/n)')
if ch in (ord('y'), ord('Y')):
self.oauth.clear_oauth_data()
self.term.show_notification('Logged out')
else:
self.oauth.authorize()
def reply(self):
"""
Reply to the selected item. This is a utility method and should not
be bound to a key directly.
Item type:
Submission - add a top level comment
Comment - add a comment reply
Message - reply to a private message
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
body = data['text']
description = 'submission'
reply = data['object'].add_comment
elif data['type'] in ('Comment', 'InboxComment'):
body = data['body']
description = 'comment'
reply = data['object'].reply
elif data['type'] == 'Message':
body = data['body']
description = 'private message'
reply = data['object'].reply
else:
self.term.flash()
return
# Construct the text that will be displayed in the editor file.
# The post body will be commented out and added for reference
lines = [' |' + line for line in body.split('\n')]
content = '\n'.join(lines)
comment_info = docs.REPLY_FILE.format(
author=data['author'],
type=description,
content=content)
with self.term.open_editor(comment_info) as comment:
if not comment:
self.term.show_notification('Canceled')
return
with self.term.loader('Posting {}'.format(description), delay=0):
reply(comment)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
@PageController.register(Command('DELETE'))
@logged_in
def delete_item(self):
"""
Delete a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
return
prompt = 'Are you sure you want to delete this? (y/n): '
if not self.term.prompt_y_or_n(prompt):
self.term.show_notification('Canceled')
return
with self.term.loader('Deleting', delay=0):
data['object'].delete()
# Give reddit time to process the request
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
@PageController.register(Command('EDIT'))
@logged_in
def edit(self):
"""
Edit a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
return
if data['type'] == 'Submission':
content = data['text']
info = docs.SUBMISSION_EDIT_FILE.format(
content=content, id=data['object'].id)
elif data['type'] == 'Comment':
content = data['body']
info = docs.COMMENT_EDIT_FILE.format(
content=content, id=data['object'].id)
else:
self.term.flash()
return
with self.term.open_editor(info) as text:
if not text or text == content:
self.term.show_notification('Canceled')
return
with self.term.loader('Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
@PageController.register(Command('PRIVATE_MESSAGE'))
@logged_in
def send_private_message(self):
"""
Send a new private message to another user.
"""
message_info = docs.MESSAGE_FILE
with self.term.open_editor(message_info) as text:
if not text:
self.term.show_notification('Canceled')
return
parts = text.split('\n', 2)
if len(parts) == 1:
self.term.show_notification('Missing message subject')
return
elif len(parts) == 2:
self.term.show_notification('Missing message body')
return
recipient, subject, message = parts
recipient = recipient.strip()
subject = subject.strip()
message = message.rstrip()
if not recipient:
self.term.show_notification('Missing recipient')
return
elif not subject:
self.term.show_notification('Missing message subject')
return
elif not message:
self.term.show_notification('Missing message body')
return
with self.term.loader('Sending message', delay=0):
self.reddit.send_message(
recipient, subject, message, raise_captcha_exception=True)
# Give reddit time to process the message
time.sleep(2.0)
if self.term.loader.exception:
raise TemporaryFileError()
else:
self.term.show_notification('Message sent!')
self.selected_page = self.open_inbox_page('sent')
def prompt_and_select_link(self):
"""
Prompt the user to select a link from a list to open.
Return the link that was selected, or ``None`` if no link was selected.
"""
data = self.get_selected_item()
url_full = data.get('url_full')
permalink = data.get('permalink')
if url_full and url_full != permalink:
# The item is a link-only submission that won't contain text
link = url_full
else:
html = data.get('html')
if html:
extracted_links = self.content.extract_links(html)
if not extracted_links:
# Only one selection to choose from, so just pick it
link = permalink
else:
# Let the user decide which link to open
links = []
if permalink:
links += [{'text': 'Permalink', 'href': permalink}]
links += extracted_links
link = self.term.prompt_user_to_select_link(links)
else:
# Some items like hidden comments don't have any HTML to parse
link = permalink
return link
@PageController.register(Command('COPY_PERMALINK'))
def copy_permalink(self):
"""
Copy the submission permalink to OS clipboard
"""
url = self.get_selected_item().get('permalink')
self.copy_to_clipboard(url)
@PageController.register(Command('COPY_URL'))
def copy_url(self):
"""
Copy a link to OS clipboard
"""
url = self.prompt_and_select_link()
self.copy_to_clipboard(url)
def copy_to_clipboard(self, url):
"""
Attempt to copy the selected URL to the user's clipboard
"""
if url is None:
self.term.flash()
return
try:
clipboard_copy(url)
except (ProgramError, OSError) as e:
_logger.exception(e)
self.term.show_notification(
'Failed to copy url: {0}'.format(e))
else:
self.term.show_notification(
['Copied to clipboard:', url], timeout=1)
@PageController.register(Command('SUBSCRIPTIONS'))
@logged_in
def subscriptions(self):
"""
View a list of the user's subscribed subreddits
"""
self.selected_page = self.open_subscription_page('subreddit')
@PageController.register(Command('MULTIREDDITS'))
@logged_in
def multireddits(self):
"""
View a list of the user's subscribed multireddits
"""
self.selected_page = self.open_subscription_page('multireddit')
@PageController.register(Command('PROMPT'))
def prompt(self):
"""
Open a prompt to navigate to a different subreddit or comment"
"""
name = self.term.prompt_input('Enter page: /')
if name:
# Check if opening a submission url or a subreddit url
# Example patterns for submissions:
# comments/571dw3
# /comments/571dw3
# /r/pics/comments/571dw3/
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
match = submission_pattern.search(name)
if match:
url = 'https://www.reddit.com/comments/{0}'.format(match.group('id'))
self.selected_page = self.open_submission_page(url)
else:
self.selected_page = self.open_subreddit_page(name)
@PageController.register(Command('INBOX'))
@logged_in
def inbox(self):
"""
View the user's inbox.
"""
self.selected_page = self.open_inbox_page('all')
def open_inbox_page(self, content_type):
"""
Open an instance of the inbox page for the logged in user.
"""
from .inbox_page import InboxPage
with self.term.loader('Loading inbox'):
page = InboxPage(self.reddit, self.term, self.config, self.oauth,
content_type=content_type)
if not self.term.loader.exception:
return page
def open_subscription_page(self, content_type):
"""
Open an instance of the subscriptions page with the selected content.
"""
from .subscription_page import SubscriptionPage
with self.term.loader('Loading {0}s'.format(content_type)):
page = SubscriptionPage(self.reddit, self.term, self.config,
self.oauth, content_type=content_type)
if not self.term.loader.exception:
return page
def open_submission_page(self, url=None, submission=None):
"""
Open an instance of the submission page for the given submission URL.
"""
from .submission_page import SubmissionPage
with self.term.loader('Loading submission'):
page = SubmissionPage(self.reddit, self.term, self.config,
self.oauth, url=url, submission=submission)
if not self.term.loader.exception:
return page
def open_subreddit_page(self, name):
"""
Open an instance of the subreddit page for the given subreddit name.
"""
from .subreddit_page import SubredditPage
with self.term.loader('Loading subreddit'):
page = SubredditPage(self.reddit, self.term, self.config,
self.oauth, name)
if not self.term.loader.exception:
return page
def clear_input_queue(self):
"""
Clear excessive input caused by the scroll wheel or holding down a key
"""
with self.term.no_delay():
while self.term.getch() != -1:
continue
def draw(self):
"""
Clear the terminal screen and redraw all of the sub-windows
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
# TODO: Will crash when you try to navigate if the terminal is too
# small at startup because self._subwindows will never be populated
return
self._row = 0
self._draw_header()
self._draw_banner()
self._draw_content()
self._draw_footer()
self.term.clear_screen()
self.term.stdscr.refresh()
def _draw_header(self):
"""
Draw the title bar at the top of the screen
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
# curses.bkgd expects bytes in py2 and unicode in py3
window.bkgd(str(' '), self.term.attr('TitleBar'))
sub_name = self.content.name
sub_name = sub_name.replace('/r/front', 'Front Page')
parts = sub_name.split('/')
if len(parts) == 1:
pass
elif '/m/' in sub_name:
_, _, user, _, multi = parts
sub_name = '{} Curated by {}'.format(multi, user)
elif parts[1] == 'u':
noun = 'My' if parts[2] == 'me' else parts[2] + "'s"
user_room = parts[3] if len(parts) == 4 else 'overview'
title_lookup = {
'overview': 'Overview',
'submitted': 'Submissions',
'comments': 'Comments',
'saved': 'Saved Content',
'hidden': 'Hidden Content',
'upvoted': 'Upvoted Content',
'downvoted': 'Downvoted Content'
}
sub_name = "{} {}".format(noun, title_lookup[user_room])
query = self.content.query
if query:
sub_name = 'Searching {0}: {1}'.format(sub_name, query)
self.term.add_line(window, sub_name, 0, 0)
# Set the terminal title
if len(sub_name) > 50:
title = sub_name.strip('/')
title = title.replace('_', ' ')
try:
title = title.rsplit('/', 1)[1]
except IndexError:
pass
else:
title = sub_name
# Setting the terminal title will break emacs or systems without
# X window.
if os.getenv('DISPLAY') and not os.getenv('INSIDE_EMACS'):
title += ' - ttrv {0}'.format(__version__)
title = self.term.clean(title)
if six.PY3:
# In py3 you can't write bytes to stdout
title = title.decode('utf-8')
title = '\x1b]2;{0}\x07'.format(title)
else:
title = b'\x1b]2;{0}\x07'.format(title)
sys.stdout.write(title)
sys.stdout.flush()
if self.reddit and self.reddit.user is not None:
# The starting position of the name depends on if we're converting
# to ascii or not
width = len if self.config['ascii'] else textual_width
if self.config['hide_username']:
username = "Logged in"
else:
username = self.reddit.user.name
s_col = (n_cols - width(username) - 1)
# Only print username if it fits in the empty space on the right
if (s_col - 1) >= width(sub_name):
self.term.add_line(window, username, 0, s_col)
self._row += 1
def _draw_banner(self):
"""
Draw the banner with sorting options at the top of the page
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
window.bkgd(str(' '), self.term.attr('OrderBar'))
banner = docs.BANNER_SEARCH if self.content.query else self.BANNER
items = banner.strip().split(' ')
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
spacing = max(1, int(distance)) * ' '
text = spacing.join(items)
self.term.add_line(window, text, 0, 0)
if self.content.order is not None:
order = self.content.order.split('-')[0]
col = text.find(order) - 3
attr = self.term.attr('OrderBarHighlight')
window.chgat(0, col, 3, attr)
self._row += 1
def _draw_content(self):
"""
Loop through submissions and fill up the content page.
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0)
window.erase()
win_n_rows, win_n_cols = window.getmaxyx()
self._subwindows = []
page_index, cursor_index, inverted = self.nav.position
step = self.nav.step
# If not inverted, align the first submission with the top and draw
# downwards. If inverted, align the first submission with the bottom
# and draw upwards.
cancel_inverted = True
current_row = (win_n_rows - 1) if inverted else 0
available_rows = win_n_rows
top_item_height = None if inverted else self.nav.top_item_height
for data in self.content.iterate(page_index, step, win_n_cols - 2):
subwin_n_rows = min(available_rows, data['n_rows'])
subwin_inverted = inverted
if top_item_height is not None:
# Special case: draw the page as non-inverted, except for the
# top element. This element will be drawn as inverted with a
# restricted height
subwin_n_rows = min(subwin_n_rows, top_item_height)
subwin_inverted = True
top_item_height = None
subwin_n_cols = win_n_cols - data['h_offset']
start = current_row - subwin_n_rows + 1 if inverted else current_row
subwindow = window.derwin(subwin_n_rows, subwin_n_cols, start, data['h_offset'])
self._subwindows.append((subwindow, data, subwin_inverted))
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
current_row += step * (subwin_n_rows + 1)
if available_rows <= 0:
# Indicate the page is full and we can keep the inverted screen.
cancel_inverted = False
break
if len(self._subwindows) == 1:
# Never draw inverted if only one subwindow. The top of the
# subwindow should always be aligned with the top of the screen.
cancel_inverted = True
if cancel_inverted and self.nav.inverted:
# In some cases we need to make sure that the screen is NOT
# inverted. Unfortunately, this currently means drawing the whole
# page over again. Could not think of a better way to pre-determine
# if the content will fill up the page, given that it is dependent
# on the size of the terminal.
self.nav.flip((len(self._subwindows) - 1))
self._draw_content()
return
if self.nav.cursor_index >= len(self._subwindows):
# Don't allow the cursor to go over the number of subwindows
# This could happen if the window is resized and the cursor index is
# pushed out of bounds
self.nav.cursor_index = len(self._subwindows) - 1
# Now that the windows are setup, we can take a second pass through
# to draw the text onto each subwindow
for index, (win, data, inverted) in enumerate(self._subwindows):
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
win.bkgd(str(' '), self.term.attr('Selected'))
with self.term.theme.turn_on_selected():
self._draw_item(win, data, inverted)
else:
win.bkgd(str(' '), self.term.attr('Normal'))
self._draw_item(win, data, inverted)
self._row += win_n_rows
def _draw_footer(self):
"""
Draw the key binds help bar at the bottom of the screen
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
window.bkgd(str(' '), self.term.attr('HelpBar'))
text = self.FOOTER.strip()
self.term.add_line(window, text, 0, 0)
self._row += 1
def _move_cursor(self, direction):
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
# redraw flag and opt to always redraw
valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid:
self.term.flash()
def _move_cursor_to_unread(self, direction):
url_in_history = True
valid = True
while valid and url_in_history:
valid, redraw = self.nav.move(direction, len(self._subwindows))
if valid:
data = self.get_selected_item()
url_in_history = data['url_full'] in self.config.history
if not valid:
self.term.flash()
def _move_page(self, direction):
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
if not valid:
self.term.flash()
def _prompt_period(self, order):
choices = {
'\n': order,
'1': '{0}-hour'.format(order),
'2': '{0}-day'.format(order),
'3': '{0}-week'.format(order),
'4': '{0}-month'.format(order),
'5': '{0}-year'.format(order),
'6': '{0}-all'.format(order)}
message = docs.TIME_ORDER_MENU.strip().splitlines()
ch = self.term.show_notification(message)
ch = six.unichr(ch)
return choices.get(ch)

415
ttrv/submission_page.py Normal file
View File

@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import SubmissionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class SubmissionController(PageController):
character_map = {}
class SubmissionPage(Page):
BANNER = docs.BANNER_SUBMISSION
FOOTER = docs.FOOTER_SUBMISSION
name = 'submission'
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
self.controller = SubmissionController(self, keymap=config.keymap)
if url:
self.content = SubmissionContent.from_url(
reddit, url, term.loader,
max_comment_cols=config['max_comment_cols'])
else:
self.content = SubmissionContent(
submission, term.loader,
max_comment_cols=config['max_comment_cols'])
# Start at the submission post, which is indexed as -1
self.nav = Navigator(self.content.get, page_index=-1)
def handle_selected_page(self):
"""
Open the subscription page in a subwindow, but close the current page
if any other type of page is selected.
"""
if not self.selected_page:
pass
elif self.selected_page.name == 'subscription':
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'submission', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download comments and reset the page index
"""
order = order or self.content.order
url = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubmissionContent.from_url(
self.reddit, url, self.term.loader, order=order,
max_comment_cols=self.config['max_comment_cols'])
if not self.term.loader.exception:
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(Command('SORT_1'))
def sort_content_hot(self):
self.refresh_content(order='hot')
@SubmissionController.register(Command('SORT_2'))
def sort_content_top(self):
self.refresh_content(order='top')
@SubmissionController.register(Command('SORT_3'))
def sort_content_rising(self):
self.refresh_content(order='rising')
@SubmissionController.register(Command('SORT_4'))
def sort_content_new(self):
self.refresh_content(order='new')
@SubmissionController.register(Command('SORT_5'))
def sort_content_controversial(self):
self.refresh_content(order='controversial')
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
def toggle_comment(self):
"""
Toggle the selected comment tree between visible and hidden
"""
current_index = self.nav.absolute_index
self.content.toggle(current_index)
# This logic handles a display edge case after a comment toggle. We
# want to make sure that when we re-draw the page, the cursor stays at
# its current absolute position on the screen. In order to do this,
# apply a fixed offset if, while inverted, we either try to hide the
# bottom comment or toggle any of the middle comments.
if self.nav.inverted:
data = self.content.get(current_index)
if data['hidden'] or self.nav.cursor_index != 0:
window = self._subwindows[-1][0]
n_rows, _ = window.getmaxyx()
self.nav.flip(len(self._subwindows) - 1)
self.nav.top_item_height = n_rows
@SubmissionController.register(Command('SUBMISSION_EXIT'))
def exit_submission(self):
"""
Close the submission and return to the subreddit page
"""
self.active = False
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
def open_link(self):
"""
Open the link contained in the selected item.
If there is more than one link contained in the item, prompt the user
to choose which link to open.
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
link = self.prompt_and_select_link()
if link:
self.config.history.add(link)
self.term.open_link(link)
elif data['type'] == 'Comment':
link = self.prompt_and_select_link()
if link:
self.term.open_link(link)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
def open_pager(self):
"""
Open the selected item with the system's pager
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
if self.config['max_pager_cols'] is not None:
n_cols = min(n_cols, self.config['max_pager_cols'])
data = self.get_selected_item()
if data['type'] == 'Submission':
text = '\n\n'.join((data['permalink'], data['text']))
self.term.open_pager(text, wrap=n_cols)
elif data['type'] == 'Comment':
text = '\n\n'.join((data['permalink'], data['body']))
self.term.open_pager(text, wrap=n_cols)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_POST'))
@logged_in
def add_comment(self):
"""
Submit a reply to the selected item.
"""
self.reply()
@SubmissionController.register(Command('DELETE'))
@logged_in
def delete_comment(self):
"""
Delete the selected comment
"""
if self.get_selected_item()['type'] == 'Comment':
self.delete_item()
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
def comment_urlview(self):
"""
Open the selected comment with the URL viewer
"""
data = self.get_selected_item()
comment = data.get('body') or data.get('text') or data.get('url_full')
if comment:
self.term.open_urlview(comment)
else:
self.term.flash()
@SubmissionController.register(Command('SUBMISSION_GOTO_PARENT'))
def move_parent_up(self):
"""
Move the cursor up to the comment's parent. If the comment is
top-level, jump to the previous top-level comment.
"""
cursor = self.nav.absolute_index
if cursor > 0:
level = max(self.content.get(cursor)['level'], 1)
while self.content.get(cursor - 1)['level'] >= level:
self._move_cursor(-1)
cursor -= 1
self._move_cursor(-1)
else:
self.term.flash()
self.clear_input_queue()
@SubmissionController.register(Command('SUBMISSION_GOTO_SIBLING'))
def move_sibling_next(self):
"""
Jump to the next comment that's at the same level as the selected
comment and shares the same parent.
"""
cursor = self.nav.absolute_index
if cursor >= 0:
level = self.content.get(cursor)['level']
try:
move = 1
while self.content.get(cursor + move)['level'] > level:
move += 1
except IndexError:
self.term.flash()
else:
if self.content.get(cursor + move)['level'] == level:
for _ in range(move):
self._move_cursor(1)
else:
self.term.flash()
else:
self.term.flash()
self.clear_input_queue()
def _draw_item(self, win, data, inverted):
if data['type'] == 'MoreComments':
return self._draw_more_comments(win, data)
elif data['type'] == 'HiddenComment':
return self._draw_more_comments(win, data)
elif data['type'] == 'Comment':
return self._draw_comment(win, data, inverted)
else:
return self._draw_submission(win, data)
def _draw_comment(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
# Handle the case where the window is not large enough to fit the text.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
# If there isn't enough space to fit the comment body on the screen,
# replace the last line with a notification.
split_body = data['split_body']
if data['n_rows'] > n_rows:
# Only when there is a single comment on the page and not inverted
if not inverted and len(self._subwindows) == 1:
cutoff = data['n_rows'] - n_rows + 1
split_body = split_body[:-cutoff]
split_body.append('(Not enough space to display)')
row = offset
if row in valid_rows:
if data['is_author']:
attr = self.term.attr('CommentAuthorSelf')
text = '{author} [S]'.format(**data)
else:
attr = self.term.attr('CommentAuthor')
text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, attr)
if data['flair']:
attr = self.term.attr('UserFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('Score')
self.term.add_space(win)
self.term.add_line(win, '{score}'.format(**data), attr=attr)
attr = self.term.attr('Created')
self.term.add_space(win)
self.term.add_line(win, '{created}{edited}'.format(**data),
attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['stickied']:
attr = self.term.attr('Stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
for row, text in enumerate(split_body, start=offset + 1):
attr = self.term.attr('CommentText')
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr)
# curses.vline() doesn't support custom colors so need to build the
# cursor bar on the left of the comment one character at a time
index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
for y in range(n_rows):
self.term.addch(win, y, 0, self.term.vline, attr)
def _draw_more_comments(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
attr = self.term.attr('HiddenCommentText')
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
attr = self.term.attr('HiddenCommentExpand')
self.term.add_space(win)
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
self.term.addch(win, 0, 0, self.term.vline, attr)
def _draw_submission(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset
attr = self.term.attr('SubmissionTitle')
for row, text in enumerate(data['split_title'], start=1):
self.term.add_line(win, text, row, 1, attr)
row = len(data['split_title']) + 1
attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
if data['flair']:
attr = self.term.attr('SubmissionFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('SubmissionSubreddit')
self.term.add_space(win)
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
attr = self.term.attr('Created')
self.term.add_space(win)
self.term.add_line(win, '{created_long}{edited_long}'.format(**data),
attr=attr)
row = len(data['split_title']) + 2
if data['url_full'] in self.config.history:
attr = self.term.attr('LinkSeen')
else:
attr = self.term.attr('Link')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
offset = len(data['split_title']) + 3
# Cut off text if there is not enough room to display the whole post
split_text = data['split_text']
if data['n_rows'] > n_rows:
cutoff = data['n_rows'] - n_rows + 1
split_text = split_text[:-cutoff]
split_text.append('(Not enough space to display)')
attr = self.term.attr('SubmissionText')
for row, text in enumerate(split_text, start=offset):
self.term.add_line(win, text, row, 1, attr=attr)
row = len(data['split_title']) + len(split_text) + 3
attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('CommentCount')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
attr = self.term.attr('NSFW')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
win.border()

329
ttrv/subreddit_page.py Normal file
View File

@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import time
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
from .exceptions import TemporaryFileError
class SubredditController(PageController):
character_map = {}
class SubredditPage(Page):
BANNER = docs.BANNER_SUBREDDIT
FOOTER = docs.FOOTER_SUBREDDIT
name = 'subreddit'
def __init__(self, reddit, term, config, oauth, name):
"""
Params:
name (string): Name of subreddit to open
"""
super(SubredditPage, self).__init__(reddit, term, config, oauth)
self.controller = SubredditController(self, keymap=config.keymap)
self.content = SubredditContent.from_name(reddit, name, term.loader)
self.nav = Navigator(self.content.get)
self.toggled_subreddit = None
def handle_selected_page(self):
"""
Open all selected pages in subwindows except other subreddit pages.
"""
if not self.selected_page:
pass
elif self.selected_page.name in ('subscription', 'submission', 'inbox'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name == 'subreddit':
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download all submissions and reset the page index
"""
order = order or self.content.order
# Preserve the query if staying on the current page
if name is None:
query = self.content.query
else:
query = None
name = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubredditContent.from_name(
self.reddit, name, self.term.loader, order=order, query=query)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('SORT_1'))
def sort_content_hot(self):
if self.content.query:
self.refresh_content(order='relevance')
else:
self.refresh_content(order='hot')
@SubredditController.register(Command('SORT_2'))
def sort_content_top(self):
order = self._prompt_period('top')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
@SubredditController.register(Command('SORT_3'))
def sort_content_rising(self):
if self.content.query:
order = self._prompt_period('comments')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
else:
self.refresh_content(order='rising')
@SubredditController.register(Command('SORT_4'))
def sort_content_new(self):
self.refresh_content(order='new')
@SubredditController.register(Command('SORT_5'))
def sort_content_controversial(self):
if self.content.query:
self.term.flash()
else:
order = self._prompt_period('controversial')
if order is None:
self.term.show_notification('Invalid option')
else:
self.refresh_content(order=order)
@SubredditController.register(Command('SORT_6'))
def sort_content_gilded(self):
if self.content.query:
self.term.flash()
else:
self.refresh_content(order='gilded')
@SubredditController.register(Command('SUBREDDIT_SEARCH'))
def search_subreddit(self, name=None):
"""
Open a prompt to search the given subreddit
"""
name = name or self.content.name
query = self.term.prompt_input('Search {0}: '.format(name))
if not query:
return
with self.term.loader('Searching'):
self.content = SubredditContent.from_name(
self.reddit, name, self.term.loader, query=query)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('SUBREDDIT_FRONTPAGE'))
def show_frontpage(self):
"""
If on a subreddit, remember it and head back to the front page.
If this was pressed on the front page, go back to the last subreddit.
"""
if self.content.name != '/r/front':
target = '/r/front'
self.toggled_subreddit = self.content.name
else:
target = self.toggled_subreddit
# target still may be empty string if this command hasn't yet been used
if target is not None:
self.refresh_content(order='ignore', name=target)
@SubredditController.register(Command('SUBREDDIT_OPEN'))
def open_submission(self, url=None):
"""
Select the current submission to view posts.
"""
if url is None:
data = self.get_selected_item()
url = data['permalink']
if data.get('url_type') == 'selfpost':
self.config.history.add(data['url_full'])
self.selected_page = self.open_submission_page(url)
@SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER'))
def open_link(self):
"""
Open a link with the webbrowser
"""
data = self.get_selected_item()
if data['url_type'] == 'selfpost':
self.open_submission()
elif data['url_type'] == 'x-post subreddit':
self.refresh_content(order='ignore', name=data['xpost_subreddit'])
elif data['url_type'] == 'x-post submission':
self.open_submission(url=data['url_full'])
self.config.history.add(data['url_full'])
else:
self.term.open_link(data['url_full'])
self.config.history.add(data['url_full'])
@SubredditController.register(Command('SUBREDDIT_POST'))
@logged_in
def post_submission(self):
"""
Post a new submission to the given subreddit.
"""
# Check that the subreddit can be submitted to
name = self.content.name
if '+' in name or name in ('/r/all', '/r/front', '/r/me', '/u/saved'):
self.term.show_notification("Can't post to {0}".format(name))
return
submission_info = docs.SUBMISSION_FILE.format(name=name)
with self.term.open_editor(submission_info) as text:
if not text:
self.term.show_notification('Canceled')
return
elif '\n' not in text:
self.term.show_notification('Missing body')
return
title, content = text.split('\n', 1)
with self.term.loader('Posting', delay=0):
submission = self.reddit.submit(name, title, text=content,
raise_captcha_exception=True)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception:
raise TemporaryFileError()
if not self.term.loader.exception:
# Open the newly created submission
self.selected_page = self.open_submission_page(submission=submission)
@SubredditController.register(Command('SUBREDDIT_HIDE'))
@logged_in
def hide(self):
data = self.get_selected_item()
if not hasattr(data["object"], 'hide'):
self.term.flash()
elif data['hidden']:
with self.term.loader('Unhiding'):
data['object'].unhide()
data['hidden'] = False
else:
with self.term.loader('Hiding'):
data['object'].hide()
data['hidden'] = True
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
n_title = len(data['split_title'])
if data['url_full'] in self.config.history:
attr = self.term.attr('SubmissionTitleSeen')
else:
attr = self.term.attr('SubmissionTitle')
for row, text in enumerate(data['split_title'], start=offset):
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr)
row = n_title + offset
if data['url_full'] in self.config.history:
attr = self.term.attr('LinkSeen')
else:
attr = self.term.attr('Link')
if row in valid_rows:
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
row = n_title + offset + 1
if row in valid_rows:
attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
self.term.add_space(win)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, arrow, attr=attr)
self.term.add_space(win)
attr = self.term.attr('Created')
self.term.add_line(win, '{created}{edited}'.format(**data), attr=attr)
if data['comments'] is not None:
attr = self.term.attr('Separator')
self.term.add_space(win)
self.term.add_line(win, '-', attr=attr)
attr = self.term.attr('CommentCount')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['saved']:
attr = self.term.attr('Saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
if data['hidden']:
attr = self.term.attr('Hidden')
self.term.add_space(win)
self.term.add_line(win, '[hidden]', attr=attr)
if data['stickied']:
attr = self.term.attr('Stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['gold']:
attr = self.term.attr('Gold')
self.term.add_space(win)
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
text = self.term.gilded + count
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
attr = self.term.attr('NSFW')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
row = n_title + offset + 2
if row in valid_rows:
attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('SubmissionSubreddit')
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
if data['flair']:
attr = self.term.attr('SubmissionFlair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

97
ttrv/subscription_page.py Normal file
View File

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import SubscriptionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class SubscriptionController(PageController):
character_map = {}
class SubscriptionPage(Page):
BANNER = None
FOOTER = docs.FOOTER_SUBSCRIPTION
name = 'subscription'
def __init__(self, reddit, term, config, oauth, content_type='subreddit'):
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
self.controller = SubscriptionController(self, keymap=config.keymap)
self.content = SubscriptionContent.from_user(
reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
def handle_selected_page(self):
"""
Always close the current page when another page is selected.
"""
if self.selected_page:
self.active = False
def refresh_content(self, order=None, name=None):
"""
Re-download all subscriptions and reset the page index
"""
# reddit.get_my_subreddits() does not support sorting by order
if order:
self.term.flash()
return
with self.term.loader():
self.content = SubscriptionContent.from_user(
self.reddit, self.term.loader, self.content_type)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
def select_subreddit(self):
"""
Store the selected subreddit and return to the subreddit page
"""
name = self.get_selected_item()['name']
self.selected_page = self.open_subreddit_page(name)
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
def close_subscriptions(self):
"""
Close subscriptions and return to the subreddit page
"""
self.active = False
def _draw_banner(self):
# Subscriptions can't be sorted, so disable showing the order menu
pass
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
row = offset
if row in valid_rows:
if data['type'] == 'Multireddit':
attr = self.term.attr('MultiredditName')
else:
attr = self.term.attr('SubscriptionName')
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
row = offset + 1
for row, text in enumerate(data['split_title'], start=row):
if row in valid_rows:
if data['type'] == 'Multireddit':
attr = self.term.attr('MultiredditText')
else:
attr = self.term.attr('SubscriptionText')
self.term.add_line(win, text, row, 1, attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

31
ttrv/templates/index.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>RTV OAuth2 Helper</title>
<!-- style borrowed from http://bettermotherfuckingwebsite.com/ -->
<style type="text/css">
body {
margin:40px auto;
max-width:650px;
line-height:1.6;
font-size:18px;
font-family:Arial, Helvetica, sans-serif;
color:#444;
padding:0 10px;
}
h1, h2, h3 {
line-height:1.2
}
#footer {
position: absolute;
bottom: 0px;
width: 100%;
font-size:14px;
}
</style>
</head>
<body>
${message}
<div id="footer">View the <a href="https://github.com/tildeclub/ttrv">Documentation</a></div>
</body>
</html>

70
ttrv/templates/mailcap Normal file
View File

@ -0,0 +1,70 @@
# Example mailcap file for Terminal Viewer for Reddit
# https://github.com/tildeclub/ttrv/
#
# Copy the contents of this file to {HOME}/.mailcap, or point to it using $MAILCAPS
# Then launch TTRV using the --enable-media flag. All shell commands defined in
# this file depend on external programs that must be installed on your system.
#
# HELP REQUESTED! If you come up with your own commands (especially for OS X)
# and would like to share, please post an issue on the GitHub tracker and we
# can get them added to this file as references.
#
#
# Mailcap 101
# - The first entry with a matching MIME type will be executed, * is a wildcard
# - %s will be replaced with the image or video url
# - Add ``test=test -n "$DISPLAY"`` if your command opens a new window
# - Add ``needsterminal`` for commands that use the terminal
# - Add ``copiousoutput`` for commands that dump text to stdout
###############################################################################
# Commands below this point will open media in a separate window without
# pausing execution of TTRV.
###############################################################################
# Feh is a simple and effective image viewer
# Note that ttrv returns a list of urls for imgur albums, so we don't put quotes
# around the `%s`
image/x-imgur-album; feh -g 640x480 -. %s; test=test -n "$DISPLAY"
image/gif; mpv '%s' --autofit=640x480 --loop=inf; test=test -n "$DISPLAY"
image/*; feh -g 640x480 -. '%s'; test=test -n "$DISPLAY"
# Youtube videos are assigned a custom mime-type, which can be streamed with
# vlc or youtube-dl.
video/x-youtube; vlc '%s' --width 640 --height 480; test=test -n "$DISPLAY"
video/x-youtube; mpv --ytdl-format=bestvideo+bestaudio/best '%s' --autofit=640x480; test=test -n "$DISPLAY"
# Mpv is a simple and effective video streamer
video/*; mpv '%s' --autofit=640x480 --loop=inf; test=test -n "$DISPLAY"
###############################################################################
# Commands below this point will attempt to display media directly in the
# terminal when a desktop is not available (e.g. inside of an SSH session)
###############################################################################
# View images directly in your terminal with iTerm2
# curl -L https://iterm2.com/misc/install_shell_integration_and_utilities.sh | bash
# image/*; bash -c '[[ "%s" == http* ]] && (curl -s %s | ~/.iterm2/imgcat) || ~/.iterm2/imgcat %s' && read -n 1; needsterminal
# View true images in the terminal, supported by rxvt-unicode, xterm and st
# Requires the w3m-img package
# image/*; w3m -o 'ext_image_viewer=off' '%s'; needsterminal
# Don't have a solution for albums yet
image/x-imgur-album; echo
# 256 color images using half-width unicode characters
# Much higher quality that img2txt, but must be built from source
# https://github.com/rossy/img2xterm
image/*; curl -s '%s' | convert -resize 80x80 - jpg:/tmp/ttrv.jpg && img2xterm /tmp/ttrv.jpg; needsterminal; copiousoutput
# Display images in classic ascii using img2txt and lib-caca
image/*; curl -s '%s' | convert - jpg:/tmp/ttrv.jpg && img2txt -f utf8 /tmp/ttrv.jpg; needsterminal; copiousoutput
# Full motion videos - requires a framebuffer to view
video/x-youtube; mpv -vo drm -quiet '%s'; needsterminal
video/*; mpv -vo drm -quiet '%s'; needsterminal
# Ascii videos
# video/x-youtube; youtube-dl -q -o - '%s' | mplayer -cache 8192 -vo caca -quiet -; needsterminal
# video/*; wget '%s' -O - | mplayer -cache 8192 -vo caca -quiet -; needsterminal

182
ttrv/templates/ttrv.cfg Normal file
View File

@ -0,0 +1,182 @@
; Tilde Terminal Reddit Viewer Configuration File
; https://github.com/tildeclub/ttrv
;
; This file should be placed in $XDG_CONFIG/ttrv/ttrv.cfg
; If $XDG_CONFIG is not set, use ~/.config/ttrv/ttrv.cfg
[ttrv]
##################
# General Settings
##################
; Turn on ascii-only mode to disable all unicode characters.
; This may be necessary for compatibility with some terminal browsers.
ascii = False
; Turn on monochrome mode to disable color.
monochrome = False
; Flash when an invalid action is executed.
flash = True
; Enable debugging by logging all HTTP requests and errors to the given file.
;log = /tmp/ttrv.log
; Default subreddit that will be opened when the program launches.
subreddit = front
;subreddit = python
;subreddit = python+linux+programming
;subreddit = all
; Allow ttrv to store reddit authentication credentials between sessions.
persistent = True
; Automatically log in on startup, if credentials are available.
autologin = True
; Clear any stored credentials when the program starts.
clear_auth = False
; Maximum number of opened links that will be saved in the history file.
history_size = 200
; Open external links using programs defined in the mailcap config.
enable_media = False
; Maximum number of columns for a comment
max_comment_cols = 120
; Maximum number of columns for pager
;max_pager_cols = 70
; Hide username if logged in, display "Logged in" instead
hide_username = False
; Color theme, use "ttrv --list-themes" to view a list of valid options.
; This can be an absolute filepath, or the name of a theme file that has
; been installed into either the custom of default theme paths.
;theme = molokai
; Open a new browser window instead of a new tab in existing instance
force_new_browser_window = False
################
# OAuth Settings
################
; This sections defines the paramaters that will be used during the OAuth
; authentication process. ttrv is registered as an "installed app",
; see https://github.com/reddit/reddit/wiki/OAuth2 for more information.
; These settings are defined at https://www.reddit.com/prefs/apps and should
; not be altered unless you are defining your own developer application.
oauth_client_id = E2oEtRQfdfAfNQ
oauth_client_secret = praw_gapfill
oauth_redirect_uri = http://127.0.0.1:65000/
; Port that the ttrv webserver will listen on. This should match the redirect
; uri defined above.
oauth_redirect_port = 65000
; Access permissions that will be requested.
oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote
; This is a separate token for the imgur api. It's used to extract images
; from imgur links and albums so they can be opened with mailcap.
; See https://imgur.com/account/settings/apps to generate your own key.
imgur_client_id = 93396265f59dec9
[bindings]
##############
# Key Bindings
##############
; If you would like to define custom bindings, copy this section into your
; config file with the [bindings] heading. All commands must be bound to at
; least one key for the config to be valid.
;
; 1.) Plain keys can be represented by either uppercase/lowercase characters
; or the hexadecimal numbers referring their ascii codes. For reference, see
; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart
; e.g. Q, q, 1, ?
; e.g. 0x20 (space), 0x3c (less-than sign)
;
; 2.) Special ascii control codes should be surrounded with <>. For reference,
; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart
; e.g. <LF> (enter), <ESC> (escape)
;
; 3.) Other special keys are defined by curses, they should be surrounded by <>
; and prefixed with KEY_. For reference, see
; https://docs.python.org/2/library/curses.html#constants
; e.g. <KEY_LEFT> (left arrow), <KEY_F5>, <KEY_NPAGE> (page down)
;
; Notes:
; - Curses <KEY_ENTER> is unreliable and should always be used in conjunction
; with <LF>.
; - Use 0x20 for the space key.
; - A subset of Ctrl modifiers are available through the ascii control codes.
; For example, Ctrl-D will trigger an <EOT> signal. See the table above for
; a complete reference.
; Base page
EXIT = q
FORCE_EXIT = Q
HELP = ?
SORT_1 = 1
SORT_2 = 2
SORT_3 = 3
SORT_4 = 4
SORT_5 = 5
SORT_6 = 6
SORT_7 = 7
MOVE_UP = k, <KEY_UP>
MOVE_DOWN = j, <KEY_DOWN>
MOVE_NEXT_UNREAD = J
MOVE_PREV_UNREAD = K
PREVIOUS_THEME = <KEY_F2>
NEXT_THEME = <KEY_F3>
PAGE_UP = m, <KEY_PPAGE>, <NAK>
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
PAGE_TOP = gg
PAGE_BOTTOM = G
UPVOTE = a
DOWNVOTE = z
LOGIN = u
DELETE = d
EDIT = e
INBOX = i
REFRESH = r, <KEY_F5>
PROMPT = /
SAVE = w
COPY_PERMALINK = y
COPY_URL = Y
PRIVATE_MESSAGE = C
SUBSCRIPTIONS = s
MULTIREDDITS = S
; Submission page
SUBMISSION_TOGGLE_COMMENT = 0x20
SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBMISSION_POST = c
SUBMISSION_EXIT = h, <KEY_LEFT>
SUBMISSION_OPEN_IN_PAGER = l, <KEY_RIGHT>
SUBMISSION_OPEN_IN_URLVIEWER = b
SUBMISSION_GOTO_PARENT = K
SUBMISSION_GOTO_SIBLING = J
; Subreddit page
SUBREDDIT_SEARCH = f
SUBREDDIT_POST = c
SUBREDDIT_OPEN = l, <KEY_RIGHT>
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBREDDIT_FRONTPAGE = p
SUBREDDIT_HIDE = 0x20
; Subscription page
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>
SUBSCRIPTION_EXIT = h, s, S, <ESC>, <KEY_LEFT>
; Inbox page
INBOX_VIEW_CONTEXT = l, <KEY_RIGHT>
INBOX_OPEN_SUBMISSION = o, <LF>, <KEY_ENTER>
INBOX_REPLY = c
INBOX_MARK_READ = w
INBOX_EXIT = h, <ESC>, <KEY_LEFT>

1015
ttrv/terminal.py Normal file

File diff suppressed because it is too large Load Diff

567
ttrv/theme.py Normal file
View File

@ -0,0 +1,567 @@
# pylint: disable=bad-whitespace
import os
import codecs
import curses
import logging
from collections import OrderedDict
from contextlib import contextmanager
import six
from six.moves import configparser
from .config import THEMES, DEFAULT_THEMES
from .exceptions import ConfigError
_logger = logging.getLogger(__name__)
class Theme(object):
ATTRIBUTE_CODES = {
'-': None,
'': None,
'normal': curses.A_NORMAL,
'bold': curses.A_BOLD,
'reverse': curses.A_REVERSE,
'underline': curses.A_UNDERLINE,
'standout': curses.A_STANDOUT
}
COLOR_CODES = {
'-': None,
'default': -1,
'black': curses.COLOR_BLACK,
'red': curses.COLOR_RED,
'green': curses.COLOR_GREEN,
'yellow': curses.COLOR_YELLOW,
'blue': curses.COLOR_BLUE,
'magenta': curses.COLOR_MAGENTA,
'cyan': curses.COLOR_CYAN,
'light_gray': curses.COLOR_WHITE,
'dark_gray': 8,
'bright_red': 9,
'bright_green': 10,
'bright_yellow': 11,
'bright_blue': 12,
'bright_magenta': 13,
'bright_cyan': 14,
'white': 15,
}
for i in range(256):
COLOR_CODES['ansi_{0}'.format(i)] = i
# For compatibility with as many terminals as possible, the default theme
# can only use the 8 basic colors with the default color as the background
DEFAULT_THEME = {
'modifiers': {
'Normal': (-1, -1, curses.A_NORMAL),
'Selected': (-1, -1, curses.A_NORMAL),
'SelectedCursor': (-1, -1, curses.A_REVERSE),
},
'page': {
'TitleBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'OrderBar': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'OrderBarHighlight': (curses.COLOR_YELLOW, None, curses.A_BOLD | curses.A_REVERSE),
'HelpBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'Prompt': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'NoticeInfo': (None, None, curses.A_BOLD),
'NoticeLoading': (None, None, curses.A_BOLD),
'NoticeError': (None, None, curses.A_BOLD),
'NoticeSuccess': (None, None, curses.A_BOLD),
},
# Fields that might be highlighted by the "SelectedCursor" element
'cursor': {
'CursorBlock': (None, None, None),
'CursorBar1': (curses.COLOR_MAGENTA, None, None),
'CursorBar2': (curses.COLOR_CYAN, None, None),
'CursorBar3': (curses.COLOR_GREEN, None, None),
'CursorBar4': (curses.COLOR_YELLOW, None, None),
},
# Fields that might be highlighted by the "Selected" element
'normal': {
'CommentAuthor': (curses.COLOR_BLUE, None, curses.A_BOLD),
'CommentAuthorSelf': (curses.COLOR_GREEN, None, curses.A_BOLD),
'CommentCount': (None, None, None),
'CommentText': (None, None, None),
'Created': (None, None, None),
'Downvote': (curses.COLOR_RED, None, curses.A_BOLD),
'Gold': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'HiddenCommentExpand': (None, None, curses.A_BOLD),
'HiddenCommentText': (None, None, None),
'MultiredditName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'MultiredditText': (None, None, None),
'NeutralVote': (None, None, curses.A_BOLD),
'NSFW': (curses.COLOR_RED, None, curses.A_BOLD | curses.A_REVERSE),
'Saved': (curses.COLOR_GREEN, None, None),
'Hidden': (curses.COLOR_YELLOW, None, None),
'Score': (None, None, None),
'Separator': (None, None, curses.A_BOLD),
'Stickied': (curses.COLOR_GREEN, None, None),
'SubscriptionName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'SubscriptionText': (None, None, None),
'SubmissionAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
'SubmissionFlair': (curses.COLOR_RED, None, None),
'SubmissionSubreddit': (curses.COLOR_YELLOW, None, None),
'SubmissionText': (None, None, None),
'SubmissionTitle': (None, None, curses.A_BOLD),
'SubmissionTitleSeen': (None, None, None),
'Upvote': (curses.COLOR_GREEN, None, curses.A_BOLD),
'Link': (curses.COLOR_BLUE, None, curses.A_UNDERLINE),
'LinkSeen': (curses.COLOR_MAGENTA, None, curses.A_UNDERLINE),
'UserFlair': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'New': (curses.COLOR_RED, None, curses.A_BOLD),
'Distinguished': (curses.COLOR_RED, None, curses.A_BOLD),
'MessageSubject': (curses.COLOR_BLUE, None, curses.A_BOLD),
'MessageLink': (curses.COLOR_MAGENTA, None, curses.A_BOLD),
'MessageAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
'MessageSubreddit': (curses.COLOR_YELLOW, None, None),
'MessageText': (None, None, None),
}
}
DEFAULT_ELEMENTS = {k: v for group in DEFAULT_THEME.values()
for k, v in group.items()}
# The SubmissionPage uses this to determine which color bar to use
CURSOR_BARS = ['CursorBar1', 'CursorBar2', 'CursorBar3', 'CursorBar4']
def __init__(self, name=None, source=None, elements=None, use_color=True):
"""
Params:
name (str): A unique string that describes the theme
source (str): A string that describes the source of the theme:
built-in - Should only be used when Theme() is called directly
preset - Themes packaged with ttrv
installed - Themes in ~/.config/ttrv/themes/
custom - When a filepath is explicitly provided, e.g.
``ttrv --theme=/path/to/theme_file.cfg``
elements (dict): The theme's element map, should be in the same
format as Theme.DEFAULT_THEME.
"""
if source not in (None, 'built-in', 'preset', 'installed', 'custom'):
raise ValueError('Invalid source')
if name is None and source is None:
name = 'default' if use_color else 'monochrome'
source = 'built-in'
elif name is None or source is None:
raise ValueError('Must specify both `name` and `source`, or neither one')
self.name = name
self.source = source
self.use_color = use_color
self._color_pair_map = None
self._attribute_map = None
self._selected = None
self.required_color_pairs = 0
self.required_colors = 0
if elements is None:
elements = self.DEFAULT_ELEMENTS.copy()
# Set any elements that weren't defined by the config to fallback to
# the default color and attributes
for key in self.DEFAULT_ELEMENTS.keys():
if key not in elements:
elements[key] = (None, None, None)
self._set_fallback(elements, 'Normal', (-1, -1, curses.A_NORMAL))
self._set_fallback(elements, 'Selected', 'Normal')
self._set_fallback(elements, 'SelectedCursor', 'Normal')
# Create the "Selected" versions of elements, which are prefixed with
# the @ symbol. For example, "@CommentText" represents how comment
# text is formatted when it is highlighted by the cursor.
for key in self.DEFAULT_THEME['normal']:
dest = '@{0}'.format(key)
self._set_fallback(elements, key, 'Selected', dest)
for key in self.DEFAULT_THEME['cursor']:
dest = '@{0}'.format(key)
self._set_fallback(elements, key, 'SelectedCursor', dest)
# Fill in the ``None`` values for all of the elements with normal text
for key in self.DEFAULT_THEME['normal']:
self._set_fallback(elements, key, 'Normal')
for key in self.DEFAULT_THEME['cursor']:
self._set_fallback(elements, key, 'Normal')
for key in self.DEFAULT_THEME['page']:
self._set_fallback(elements, key, 'Normal')
self.elements = elements
if self.use_color:
# Pre-calculate how many colors / color pairs the theme will need
colors, color_pairs = set(), set()
for fg, bg, _ in self.elements.values():
colors.add(fg)
colors.add(bg)
color_pairs.add((fg, bg))
# Don't count the default (-1, -1) as a color pair because it
# doesn't need to be initialized by curses.init_pair().
color_pairs.discard((-1, -1))
self.required_color_pairs = len(color_pairs)
# Determine how many colors the terminal needs to support in order
# to be able to use the theme. This uses the common breakpoints
# that 99% of terminals follow and doesn't take into account
# 88 color themes.
self.required_colors = None
for marker in [0, 8, 16, 256]:
if max(colors) < marker:
self.required_colors = marker
break
@property
def display_string(self):
return '{0} ({1})'.format(self.name, self.source)
def bind_curses(self):
"""
Bind the theme's colors to curses's internal color pair map.
This method must be called once (after curses has been initialized)
before any element attributes can be accessed. Color codes and other
special attributes will be mixed bitwise into a single value that
can be passed into curses draw functions.
"""
self._color_pair_map = {}
self._attribute_map = {}
for element, item in self.elements.items():
fg, bg, attrs = item
color_pair = (fg, bg)
if self.use_color and color_pair != (-1, -1):
# Curses limits the number of available color pairs, so we
# need to reuse them if there are multiple elements with the
# same foreground and background.
if color_pair not in self._color_pair_map:
# Index 0 is reserved by curses for the default color
index = len(self._color_pair_map) + 1
curses.init_pair(index, color_pair[0], color_pair[1])
self._color_pair_map[color_pair] = curses.color_pair(index)
attrs |= self._color_pair_map[color_pair]
self._attribute_map[element] = attrs
def get(self, element, selected=False):
"""
Returns the curses attribute code for the given element.
"""
if self._attribute_map is None:
raise RuntimeError('Attempted to access theme attribute before '
'calling initialize_curses_theme()')
if selected or self._selected:
element = '@{0}'.format(element)
return self._attribute_map[element]
@contextmanager
def turn_on_selected(self):
"""
Sets the selected modifier inside of context block.
For example:
>>> with theme.turn_on_selected():
>>> attr = theme.get('CursorBlock')
Is the same as:
>>> attr = theme.get('CursorBlock', selected=True)
Is also the same as:
>>> attr = theme.get('@CursorBlock')
"""
# This context manager should never be nested
assert self._selected is None
self._selected = True
try:
yield
finally:
self._selected = None
@classmethod
def list_themes(cls, path=THEMES):
"""
Compile all of the themes configuration files in the search path.
"""
themes, errors = [], OrderedDict()
def load_themes(path, source):
"""
Load all themes in the given path.
"""
if os.path.isdir(path):
for filename in sorted(os.listdir(path)):
if not filename.endswith('.cfg'):
continue
filepath = os.path.join(path, filename)
name = filename[:-4]
try:
# Make sure the theme is valid
theme = cls.from_file(filepath, source)
except Exception as e:
errors[(source, name)] = e
else:
themes.append(theme)
themes.extend([Theme(use_color=True), Theme(use_color=False)])
load_themes(DEFAULT_THEMES, 'preset')
load_themes(path, 'installed')
return themes, errors
@classmethod
def print_themes(cls, path=THEMES):
"""
Prints a human-readable summary of the installed themes to stdout.
This is intended to be used as a command-line utility, outside of the
main curses display loop.
"""
themes, errors = cls.list_themes(path=path + '/')
print('\nInstalled ({0}):'.format(path))
installed = [t for t in themes if t.source == 'installed']
if installed:
for theme in installed:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
else:
print(' (empty)')
print('\nPresets:')
preset = [t for t in themes if t.source == 'preset']
for theme in preset:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
print('\nBuilt-in:')
built_in = [t for t in themes if t.source == 'built-in']
for theme in built_in:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
if errors:
print('\nWARNING: Some files encountered errors:')
for (source, name), error in errors.items():
theme_info = '({0}) {1}'.format(source, name)
# Align multi-line error messages with the right column
err_message = six.text_type(error).replace('\n', '\n' + ' ' * 20)
print(' {0:<20}{1}'.format(theme_info, err_message))
print('')
@classmethod
def from_name(cls, name, path=THEMES):
"""
Search for the given theme on the filesystem and attempt to load it.
Directories will be checked in a pre-determined order. If the name is
provided as an absolute file path, it will be loaded directly.
"""
if os.path.isfile(name):
return cls.from_file(name, 'custom')
filename = os.path.join(path, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'installed')
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'preset')
raise ConfigError('Could not find theme named "{0}"'.format(name))
@classmethod
def from_file(cls, filename, source):
"""
Load a theme from the specified configuration file.
Parameters:
filename: The name of the filename to load.
source: A description of where the theme was loaded from.
"""
_logger.info('Loading theme %s', filename)
try:
config = configparser.ConfigParser()
config.optionxform = six.text_type # Preserve case
with codecs.open(filename, encoding='utf-8') as fp:
config.readfp(fp)
except configparser.ParsingError as e:
raise ConfigError(e.message)
if not config.has_section('theme'):
raise ConfigError(
'Error loading {0}:\n'
' missing [theme] section'.format(filename))
theme_name = os.path.basename(filename)
theme_name, _ = os.path.splitext(theme_name)
elements = {}
for element, line in config.items('theme'):
if element not in cls.DEFAULT_ELEMENTS:
# Could happen if using a new config with an older version
# of the software
_logger.info('Skipping element %s', element)
continue
elements[element] = cls._parse_line(element, line, filename)
return cls(name=theme_name, source=source, elements=elements)
@classmethod
def _parse_line(cls, element, line, filename=None):
"""
Parse a single line from a theme file.
Format:
<element>: <foreground> <background> <attributes>
"""
items = line.split()
if len(items) == 2:
fg, bg, attrs = items[0], items[1], ''
elif len(items) == 3:
fg, bg, attrs = items
else:
raise ConfigError(
'Error loading {0}, invalid line:\n'
' {1} = {2}'.format(filename, element, line))
if fg.startswith('#'):
fg = cls.rgb_to_ansi(fg)
if bg.startswith('#'):
bg = cls.rgb_to_ansi(bg)
if fg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <foreground>:\n'
' {1} = {2}'.format(filename, element, line))
fg_code = cls.COLOR_CODES[fg]
if bg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <background>:\n'
' {1} = {2}'.format(filename, element, line))
bg_code = cls.COLOR_CODES[bg]
attrs_code = curses.A_NORMAL
for attr in attrs.split('+'):
if attr not in cls.ATTRIBUTE_CODES:
raise ConfigError(
'Error loading {0}, invalid <attributes>:\n'
' {1} = {2}'.format(filename, element, line))
attr_code = cls.ATTRIBUTE_CODES[attr]
if attr_code is None:
attrs_code = None
break
else:
attrs_code |= attr_code
return fg_code, bg_code, attrs_code
@staticmethod
def _set_fallback(elements, src_field, fallback, dest_field=None):
"""
Helper function used to set the fallback attributes of an element when
they are defined by the configuration as "None" or "-".
"""
if dest_field is None:
dest_field = src_field
if isinstance(fallback, six.string_types):
fallback = elements[fallback]
attrs = elements[src_field]
elements[dest_field] = (
attrs[0] if attrs[0] is not None else fallback[0],
attrs[1] if attrs[1] is not None else fallback[1],
attrs[2] if attrs[2] is not None else fallback[2])
@staticmethod
def rgb_to_ansi(color):
"""
Converts hex RGB to the 6x6x6 xterm color space
Args:
color (str): RGB color string in the format "#RRGGBB"
Returns:
str: ansi color string in the format "ansi_n", where n
is between 16 and 230
Reference:
https://github.com/chadj2/bash-ui/blob/master/COLORS.md
"""
if color[0] != '#' or len(color) != 7:
return None
try:
r = round(int(color[1:3], 16) / 51.0) # Normalize between 0-5
g = round(int(color[3:5], 16) / 51.0)
b = round(int(color[5:7], 16) / 51.0)
n = int(36 * r + 6 * g + b + 16)
return 'ansi_{0:d}'.format(n)
except ValueError:
return None
class ThemeList(object):
"""
This is a small container around Theme.list_themes() that can be used
to cycle through all of the available themes.
"""
def __init__(self):
self.themes = None
self.errors = None
def reload(self):
"""
This acts as a lazy load, it won't read all of the theme files from
disk until the first time somebody tries to access the theme list.
"""
self.themes, self.errors = Theme.list_themes()
def _step(self, theme, direction):
"""
Traverse the list in the given direction and return the next theme
"""
if not self.themes:
self.reload()
# Try to find the starting index
key = (theme.source, theme.name)
for i, val in enumerate(self.themes):
if (val.source, val.name) == key:
index = i
break
else:
# If the theme was set from a custom source it might
# not be a part of the list returned by list_themes().
self.themes.insert(0, theme)
index = 0
index = (index + direction) % len(self.themes)
new_theme = self.themes[index]
return new_theme
def next(self, theme):
return self._step(theme, 1)
def previous(self, theme):
return self._step(theme, -1)

View File

@ -0,0 +1,70 @@
# Black ansi_235
# White ansi_253
# Sky Blue ansi_81
# Bluish Green ansi_36
# Yellow ansi_227
# Blue ansi_32
# Vermillion ansi_202
# Reddish Purple ansi_175
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_253 ansi_235 normal
Selected = - ansi_236 normal
SelectedCursor = - - reverse
TitleBar = ansi_81 - bold+reverse
OrderBar = ansi_227 - bold
OrderBarHighlight = ansi_227 - bold+reverse
HelpBar = ansi_81 - bold+reverse
Prompt = ansi_81 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = ansi_175 - -
CursorBar2 = ansi_81 - -
CursorBar3 = ansi_36 - -
CursorBar4 = ansi_227 - -
CommentAuthor = ansi_32 - bold
CommentAuthorSelf = ansi_36 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_202 - bold
Gold = ansi_227 - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = ansi_227 - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = ansi_202 - bold+reverse
Saved = ansi_36 - -
Hidden = ansi_227 - -
Score = - - -
Separator = - - bold
Stickied = ansi_36 - -
SubscriptionName = ansi_227 - bold
SubscriptionText = - - -
SubmissionAuthor = ansi_36 - bold
SubmissionFlair = ansi_202 - -
SubmissionSubreddit = ansi_227 - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_36 - bold
Link = ansi_32 - underline
LinkSeen = ansi_175 - underline
UserFlair = ansi_227 - bold
New = ansi_227 - bold
Distinguished = ansi_202 - bold
MessageSubject = ansi_32 - bold
MessageLink = ansi_175 - bold
MessageAuthor = ansi_36 - bold
MessageSubreddit = ansi_227 - -
MessageText = - - -

View File

@ -0,0 +1,59 @@
[theme]
;<element> = <foreground> <background> <attributes>
Normal = default default normal
Selected = default default normal
SelectedCursor = default default reverse
TitleBar = cyan - bold+reverse
OrderBar = yellow - bold
OrderBarHighlight = yellow - bold+reverse
HelpBar = cyan - bold+reverse
Prompt = cyan - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = magenta - -
CursorBar2 = cyan - -
CursorBar3 = green - -
CursorBar4 = yellow - -
CommentAuthor = blue - bold
CommentAuthorSelf = green - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = red - bold
Gold = yellow - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = yellow - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = red - bold+reverse
Saved = green - -
Hidden = yellow - -
Score = - - -
Separator = - - bold
Stickied = green - -
SubscriptionName = yellow - bold
SubscriptionText = - - -
SubmissionAuthor = green - bold
SubmissionFlair = red - -
SubmissionSubreddit = yellow - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = green - bold
Link = blue - underline
LinkSeen = magenta - underline
UserFlair = yellow - bold
New = red - bold
Distinguished = red - bold
MessageSubject = blue - bold
MessageLink = magenta - bold
MessageAuthor = green - bold
MessageSubreddit = yellow - -
MessageText = - - -

82
ttrv/themes/molokai.cfg Normal file
View File

@ -0,0 +1,82 @@
# https://github.com/tomasr/molokai
# normal ansi_252, ansi_234
# line number ansi_239, ansi_235
# cursor ansi_252, ansi_236
# pmenusel ansi_255, ansi_242
# text - normal ansi_252
# text - dim ansi_244
# text - ultra dim ansi_241
# purple ansi_141
# green ansi_154
# magenta ansi_199, ansi_16
# gold ansi_222, ansi_233
# red ansi_197
# red - dim ansi_203
# orange ansi_208
# blue ansi_81
# blue - dim ansi_67, ansi_16
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_252 ansi_234 normal
Selected = ansi_252 ansi_236 normal
SelectedCursor = ansi_252 ansi_234 bold+reverse
TitleBar = ansi_81 - bold+reverse
OrderBar = ansi_244 ansi_235 -
OrderBarHighlight = ansi_244 ansi_235 bold+reverse
HelpBar = ansi_81 - bold+reverse
Prompt = ansi_208 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_199 - bold
NoticeSuccess = ansi_154 - bold
CursorBlock = ansi_252 - -
CursorBar1 = ansi_141 - -
CursorBar2 = ansi_197 - -
CursorBar3 = ansi_154 - -
CursorBar4 = ansi_208 - -
CommentAuthor = ansi_81 - -
CommentAuthorSelf = ansi_154 - -
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_197 - bold
Gold = ansi_222 - bold
HiddenCommentExpand = ansi_244 - bold
HiddenCommentText = ansi_244 - -
MultiredditName = - - bold
MultiredditText = ansi_244 - -
NeutralVote = - - bold
NSFW = ansi_197 - bold+reverse
Saved = ansi_199 - -
Hidden = ansi_208 - -
Score = - - bold
Separator = ansi_241 - bold
Stickied = ansi_208 - -
SubscriptionName = - - bold
SubscriptionText = ansi_244 - -
SubmissionAuthor = ansi_154 - -
SubmissionFlair = ansi_197 - -
SubmissionSubreddit = ansi_222 - -
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_154 - bold
Link = ansi_67 - underline
LinkSeen = ansi_141 - underline
UserFlair = ansi_222 - bold
New = ansi_208 - bold
Distinguished = ansi_197 - bold
MessageSubject = ansi_81 - bold
MessageLink = ansi_199 - bold
MessageAuthor = ansi_154 - bold
MessageSubreddit = ansi_222 - -
MessageText = - - -

View File

@ -0,0 +1,80 @@
# https://github.com/NLKNguyen/papercolor-theme
# background ansi_255
# negative ansi_124
# positive ansi_28
# olive ansi_64
# neutral ansi_31
# comment ansi_102
# navy ansi_24
# foreground ansi_238
# nontext ansi_250
# red ansi_160
# pink ansi_162
# purple ansi_91
# accent ansi_166
# orange ansi_166
# blue ansi_25
# highlight ansi_24
# aqua ansi_31
# green ansi_28
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_238 ansi_255 normal
Selected = ansi_238 ansi_254 normal
SelectedCursor = ansi_238 ansi_255 bold+reverse
TitleBar = ansi_24 - bold+reverse
OrderBar = ansi_25 - bold
OrderBarHighlight = ansi_25 - bold+reverse
HelpBar = ansi_24 - bold+reverse
Prompt = ansi_31 - bold+reverse
NoticeInfo = ansi_238 ansi_252 bold
NoticeLoading = ansi_238 ansi_252 bold
NoticeError = ansi_124 ansi_225 bold
NoticeSuccess = ansi_28 ansi_157 bold
CursorBlock = ansi_102 - -
CursorBar1 = ansi_162 - -
CursorBar2 = ansi_166 - -
CursorBar3 = ansi_25 - -
CursorBar4 = ansi_91 - -
CommentAuthor = ansi_25 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_124 - bold
Gold = ansi_166 - bold
HiddenCommentExpand = ansi_102 - bold
HiddenCommentText = ansi_102 - -
MultiredditName = - - bold
MultiredditText = ansi_102 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_31 - bold
Hidden = ansi_166 - bold
Score = - - bold
Separator = - - bold
Stickied = ansi_166 - bold
SubscriptionName = - - bold
SubscriptionText = ansi_102 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_162 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = - - bold
SubmissionTitleSeen = - - -
Upvote = ansi_28 - bold
Link = ansi_24 - underline
LinkSeen = ansi_91 - underline
UserFlair = ansi_162 - bold
New = ansi_162 - bold
Distinguished = ansi_160 - bold
MessageSubject = ansi_91 - bold
MessageLink = ansi_162 - bold
MessageAuthor = ansi_28 - bold
MessageSubreddit = ansi_166 - bold
MessageText = - - -

View File

@ -0,0 +1,78 @@
# http://ethanschoonover.com/solarized
# base3 ansi_230
# base2 ansi_254
# base1 ansi_245 (optional emphasized content)
# base0 ansi_244 (body text / primary content)
# base00 ansi_241
# base01 ansi_240 (comments / secondary content)
# base02 ansi_235 (background highlights)
# base03 ansi_234 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_244 ansi_234 normal
Selected = ansi_244 ansi_235 normal
SelectedCursor = ansi_244 ansi_235 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_240 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_240 - bold
HiddenCommentText = ansi_240 - -
MultiredditName = ansi_245 - bold
MultiredditText = ansi_240 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - -
Hidden = ansi_136 - -
Score = - - -
Separator = - - bold
Stickied = ansi_136 - -
SubscriptionName = ansi_245 - bold
SubscriptionText = ansi_240 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - -
SubmissionSubreddit = ansi_166 - -
SubmissionText = - - -
SubmissionTitle = ansi_245 - bold
SubmissionTitleSeen = - - -
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold
New = ansi_136 - bold
Distinguished = ansi_160 - bold
MessageSubject = ansi_37 - bold
MessageLink = ansi_125 - bold
MessageAuthor = ansi_64 - bold
MessageSubreddit = ansi_136 - -
MessageText = - - -

View File

@ -0,0 +1,78 @@
# http://ethanschoonover.com/solarized
# base03 ansi_234
# base02 ansi_235
# base01 ansi_240 (optional emphasized content)
# base00 ansi_241 (body text / primary content)
# base0 ansi_244
# base1 ansi_245 (comments / secondary content)
# base2 ansi_254 (background highlights)
# base3 ansi_230 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
SelectedCursor = ansi_241 ansi_254 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_245 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_245 - bold
HiddenCommentText = ansi_245 - -
MultiredditName = ansi_240 - bold
MultiredditText = ansi_245 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - bold
Hidden = ansi_136 - bold
Score = - - -
Separator = - - bold
Stickied = ansi_136 - bold
SubscriptionName = ansi_240 - bold
SubscriptionText = ansi_245 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = ansi_240 - bold
SubmissionTitleSeen = - - -
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold
New = ansi_227 - bold
Distinguished = ansi_202 - bold
MessageSubject = ansi_32 - bold
MessageLink = ansi_175 - bold
MessageAuthor = ansi_36 - bold
MessageSubreddit = ansi_166 - -
MessageText = - - -

1
version.py Symbolic link
View File

@ -0,0 +1 @@
ttrv/__version__.py