Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editor metadata #203

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
.PHONY: clean publish test docs

PYTHON ?= python
PYTEST ?= pytest
PY_TEST ?= py.test

dist :
python setup.py sdist --formats=gztar,zip
python setup.py bdist_wheel --python-tag=py3
$(PYTHON) setup.py sdist --formats=gztar,zip
$(PYTHON) setup.py bdist_wheel --python-tag=py3

deb_dist:
python setup.py --command-packages=stdeb.command bdist_deb
$(PYTHON) setup.py --command-packages=stdeb.command bdist_deb

publish :
twine upload dist/*.tar.gz dist/*.whl

test:
pytest -v
$(PYTEST) -v

coverage:
py.test --cov=toot --cov-report html tests/
$(PY_TEST) --cov=toot --cov-report html tests/

clean :
find . -name "*pyc" | xargs rm -rf $1
Expand Down
20 changes: 13 additions & 7 deletions toot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from toot.exceptions import ConsoleError, NotFoundError
from toot.output import (print_out, print_instance, print_account,
print_search_results, print_timeline, print_notifications)
from toot.utils import assert_domain_exists, editor_input, multiline_input, EOF_KEY
from toot.utils import assert_domain_exists, parse_editor_input, multiline_input, EOF_KEY


def get_timeline_generator(app, user, args):
Expand Down Expand Up @@ -76,9 +76,17 @@ def thread(app, user, args):


def post(app, user, args):
# TODO: this might be achievable, explore options
if args.editor and not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
if args.editor:
# TODO: this might be achievable, explore options
if not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
prev_using = args.using
args = parse_editor_input(args)
# The user may have changed the account
if args.using != prev_using:
user, app = config.get_user_app(parsed_args.using)
# no need to check, this was done in parse_editor_input()


if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")
Expand All @@ -102,9 +110,7 @@ def post(app, user, args):
if uploaded_media and not args.text:
args.text = "\n".join(m['text_url'] for m in uploaded_media)

if args.editor:
args.text = editor_input(args.editor, args.text)
elif not args.text:
if not args.editor and not args.text:
print_out("Write or paste your toot. Press <yellow>{}</yellow> to post it.".format(EOF_KEY))
args.text = multiline_input()

Expand Down
24 changes: 2 additions & 22 deletions toot/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,7 @@
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err

VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']


def language(value):
"""Validates the language parameter"""
if len(value) != 3:
raise ArgumentTypeError(
"Invalid language specified: '{}'. Expected a 3 letter "
"abbreviation according to ISO 639-2 standard.".format(value)
)

return value


def visibility(value):
"""Validates the visibility parameter"""
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")

return value
from toot.utils import language, visibility, visibility_choices


def timeline_count(value):
Expand Down Expand Up @@ -306,7 +286,7 @@ def editor(value):
(["-v", "--visibility"], {
"type": visibility,
"default": "public",
"help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES),
"help": 'post visibility, one of: %s' % visibility_choices(),
}),
(["-s", "--sensitive"], {
"action": 'store_true',
Expand Down
166 changes: 151 additions & 15 deletions toot/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@

from toot.exceptions import ConsoleError

from argparse import FileType

VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']

def language(value):
"""Validates the language parameter"""
if len(value) != 3:
raise ArgumentTypeError(
"Invalid language specified: '{}'. Expected a 3 letter "
"abbreviation according to ISO 639-2 standard.".format(value)
)

return value

def visibility(value):
"""Validates the visibility parameter"""
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")

return value

def visibility_choices():
return ", ".join(VISIBILITY_CHOICES)

def str_bool(b):
"""Convert boolean to string, in the way expected by the API."""
Expand Down Expand Up @@ -92,25 +115,138 @@ def multiline_input():
return "\n".join(lines).strip()


EDITOR_INPUT_INSTRUCTIONS = """
# Please enter your toot. Lines starting with '#' will be ignored, and an empty
# message aborts the post.
EDITOR_INPUT_INSTRUCTIONS = """--
help: Please enter your toot. An empty message aborts the post.
help: This initial block between -- will be ignored.
help: Metadata can be added in this block in key: value form.
help: Supported keys are: from, media, description, lang, spoiler, reply-to, visibility, sensitive
"""

def parse_editor_meta(line, args):
"""Parse a metadata line in the editor box."""
key, sep, val = line.partition(':')
key = key.strip().lower()
val = val.strip()
if key == 'help' or sep == '' or val == '':
return True
if key in ['from', 'using']:
args.using = val
elif key == 'media':
args.media.append(FileType('rb')(val))
elif key == 'description':
args.description.append(val)
elif key in ['lang', 'language']:
args.language = language(val)
elif key in ['spoiler', 'spoiler-text', 'spoiler_text']:
args.spoiler_text = val
elif key in ['reply-to', 'reply_to', 'replyto', 'in-reply-to', 'in_reply_to']:
args.reply_to = val
elif key == 'visibility':
args.visibility = visibility(val)
elif key in ['sensitive', 'nsfw']:
# 0, f, false, n, no will count as not-sensitive, any other value will be taken as a yes
args.sensitive = val.lower() not in [ '0', 'f', 'false', 'n', 'no' ]
else:
print_out("Ignoring unsupported metadata <red>{}</red> with value <yellow>{}</yellow>.".format(key, val))
return False
return True

def editor_input(editor, initial_text):
def parse_editor_input(args):
"""Lets user input text using an editor."""
initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS

editor = args.editor
# initialize metacomments from the args field, and reset them in args so that
# they get reset if removed from the metacomments
meta = ""
if args.using:
meta += "from: " + args.using + "\n"
if args.reply_to:
meta += "reply_to: " + args.reply_to + "\n"
args.reply_to = None
if args.visibility:
meta += "visibility: " + args.visibility + "\n"
args.visibility = 'public' # TODO can we take the default from the command definition?
if args.language:
meta += "lang: " + args.language + "\n"
args.language = None
if args.spoiler_text:
meta += "spoiler: " + args.spoiler_text + "\n"
args.spoiler_text = None
if args.sensitive:
meta += "sensitive: " + args.sensitive + "\n"
args.sensitive = False

media = args.media or []
descriptions = args.description or []
if len(media) > 0:
for idx, file in enumerate(media):
meta += "media: " + file.name + "\n"
# encourage the user to add descriptions, always present the meta field
desc = descriptions[idx].strip() if idx < len(descriptions) else ''
meta += "description: " + desc + "\n"
if len(descriptions) > len(media):
for idx, desc in enumerate(descriptions):
if idx < len(media):
continue
meta += "description: "+ descriptions[idx].strip() + "\n"

args.media = []
args.description = []
text = "{}{}--\n{}".format(EDITOR_INPUT_INSTRUCTIONS, meta, (args.text or ""))

prev_using = args.using

# loop to avoid losing text if something goes wrong (e.g. wrong visibility setting)
with tempfile.NamedTemporaryFile() as f:
f.write(initial_text.encode())
f.flush()

subprocess.run([editor, f.name])

f.seek(0)
text = f.read().decode()
while True:
try:
f.seek(0)
f.write(text.encode())
f.flush()

subprocess.run([editor, f.name])

f.seek(0)
text = f.read().decode()

chunks = text.split("--\n", 3)
pre = ''
meta = ''
toot = ''

try:
pre, meta, toot = chunks
except ValueError:
toot = text

# pre should be empty
if pre:
raise ValueError("metadata block offset")

for l in meta.splitlines():
parse_editor_meta(l, args)
args.text = toot.strip()

# sanity check: this wll be checked by post() too, but we want to catch this early so that
# the user can still fix things in the editor
if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")

# if the user specified a different account, we check if it is valid here,
# so that the message is not lost in case of errors
# TODO FIXME this way of doing things is leading to a lot of duplicate code,
# the whole system should be refactored so that this error handling (with the retry)
# is handled at a higher level
if args.using != prev_using:
user, app = config.get_user_app(parsed_args.using)
if not user or not app:
raise ConsoleError("User '{}' not found".format(parsed_args.using))
except Exception as e:
if len(text) < 3:
text = "--\nerror: {}\n--\n{}".format(str(e), text)
else:
text = text[:3] + "error: {}\n".format(str(e)) + text[3:]
continue
break

lines = text.strip().splitlines()
lines = (l for l in lines if not l.startswith("#"))
return "\n".join(lines)
return args