first commit
|
@ -0,0 +1,5 @@
|
|||
[run]
|
||||
source = tvr
|
||||
omit =
|
||||
*/__main__.py
|
||||
*/packages/praw/*
|
|
@ -0,0 +1 @@
|
|||
tests/cassettes/* binary
|
|
@ -0,0 +1,14 @@
|
|||
.*
|
||||
!.travis.yml
|
||||
!.pylintrc
|
||||
!.gitignore
|
||||
!.gitattributes
|
||||
!.coveragerc
|
||||
*~
|
||||
*.pyc
|
||||
*.log
|
||||
build
|
||||
dist
|
||||
rtv.egg-info
|
||||
tests/refresh-token
|
||||
venv/
|
|
@ -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
|
|
@ -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>`_
|
|
@ -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.
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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/*
|
|
@ -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.
|
||||
|
|
@ -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.
|
|
@ -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
|
After Width: | Height: | Size: 609 KiB |
After Width: | Height: | Size: 111 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 2.4 MiB |
After Width: | Height: | Size: 947 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 78 KiB |
After Width: | Height: | Size: 87 KiB |
After Width: | Height: | Size: 80 KiB |
After Width: | Height: | Size: 85 KiB |
After Width: | Height: | Size: 168 KiB |
|
@ -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()
|
|
@ -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()
|
|
@ -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"
|
|
@ -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())
|
||||
|
|
@ -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
|
|
@ -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')
|
|
@ -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 {} +
|
|
@ -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}
|
|
@ -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()
|
|
@ -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',
|
||||
],
|
||||
)
|
|
@ -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)
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
[console_scripts]
|
||||
ttrv = ttrv.__main__:main
|
||||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
ttrv
|
|
@ -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'
|
|
@ -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())
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__version__ = '1.27.3'
|
|
@ -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))
|
|
@ -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)
|
|
@ -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
|
||||
"""
|
|
@ -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"
|
|
@ -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)
|
|
@ -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]
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
||||
))
|
|
@ -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!')
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -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>
|
|
@ -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
|
|
@ -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>
|
|
@ -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)
|
|
@ -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 = - - -
|
|
@ -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 = - - -
|
|
@ -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 = - - -
|
|
@ -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 = - - -
|
|
@ -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 = - - -
|
|
@ -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 = - - -
|
|
@ -0,0 +1 @@
|
|||
ttrv/__version__.py
|