Skip to content

commands/thread/open-attachment: add command #1505

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

Open
wants to merge 2 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
10 changes: 9 additions & 1 deletion alot/commands/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ class ExternalCommand(Command):
repeatable = True

def __init__(self, cmd, stdin=None, shell=False, spawn=False,
refocus=True, thread=False, on_success=None, **kwargs):
refocus=True, thread=False, on_success=None, on_exit=None,
**kwargs):
"""
:param cmd: the command to call
:type cmd: list or str
Expand All @@ -206,6 +207,8 @@ def __init__(self, cmd, stdin=None, shell=False, spawn=False,
:type refocus: bool
:param on_success: code to execute after command successfully exited
:type on_success: callable
:param on_exit: code to execute after command exited
:type on_exit: callable
"""
logging.debug({'spawn': spawn})
# make sure cmd is a list of str
Expand Down Expand Up @@ -238,6 +241,7 @@ def __init__(self, cmd, stdin=None, shell=False, spawn=False,
self.refocus = refocus
self.in_thread = thread
self.on_success = on_success
self.on_exit = on_exit
Command.__init__(self, **kwargs)

async def apply(self, ui):
Expand Down Expand Up @@ -308,6 +312,10 @@ async def apply(self, ui):
proc.returncode,
ret or "No stderr output")
ui.notify(msg, priority='error')

if self.on_exit is not None:
self.on_exit()

if self.refocus and callerbuffer in ui.buffers:
logging.info('refocussing')
ui.buffer_focus(callerbuffer)
Expand Down
163 changes: 110 additions & 53 deletions alot/commands/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,72 +934,130 @@ async def apply(self, ui):
raise CommandCanceled()


@registerCommand(MODE, 'open-attachment', arguments=[
(['cmd'], {
'help': '''Shell command to use to open the attachment. \
The path to the attachment file will be passed as an argument. \
If absent, mailcap is used to select the command \
based on the attachment's MIME type.''',
'nargs': '*',
}),
(['--thread'], {
'action': 'store_true',
'help': 'run in separate thread',
}),
(['--spawn'], {
'action': 'store_true',
'help': 'run in a new terminal window',
}),
])
class OpenAttachmentCommand(Command):
"""opens an attachment with a given shell command
or according to mailcap"""

"""displays an attachment according to mailcap"""
def __init__(self, attachment, **kwargs):
def __init__(self, cmd=None, thread=False, spawn=False, **kwargs):
"""
:param attachment: attachment to open
:type attachment: :class:`~alot.db.attachment.Attachment`
:param cmd: shell command to use to open the attachment
:type cmd: list of str
:param thread: whether to run in a separate thread
:type thread: bool
:param spawn: whether to run in a new terminal window
:type spawn: bool
"""
Command.__init__(self, **kwargs)
self.attachment = attachment
self.cmd = cmd
self.thread = thread
self.spawn = spawn

async def apply(self, ui):
logging.info('open attachment')
mimetype = self.attachment.get_content_type()
try:
logging.info('open attachment')
attachment = self._get_attachment(ui)
external_handler = \
self._get_ext_cmd_handler(attachment) if self.cmd \
else self._get_mailcap_cmd_handler(attachment)

await ui.apply_command(external_handler)

except RuntimeError as error:
ui.notify(str(error), priority='error')

def _get_ext_cmd_handler(self, attachment):
temp_file_name, destructor = self._make_temp_file(attachment)
return ExternalCommand(self.cmd + [temp_file_name],
on_exit=destructor,
thread=self.thread,
spawn=self.spawn)

def _get_mailcap_cmd_handler(self, attachment):
mimetype = attachment.get_content_type()

# returns pair of preliminary command string and entry dict containing
# more info. We only use the dict and construct the command ourselves
_, entry = settings.mailcap_find_match(mimetype)
if entry:
afterwards = None # callback, will rm tempfile if used
handler_stdin = None
tempfile_name = None
handler_raw_commandstring = entry['view']
# read parameter
part = self.attachment.get_mime_representation()
parms = tuple('='.join(p) for p in part.get_params())

# in case the mailcap defined command contains no '%s',
# we pipe the files content to the handling command via stdin
if '%s' in handler_raw_commandstring:
nametemplate = entry.get('nametemplate', '%s')
prefix, suffix = parse_mailcap_nametemplate(nametemplate)

fn_hook = settings.get_hook('sanitize_attachment_filename')
if fn_hook:
# get filename
filename = self.attachment.get_filename()
prefix, suffix = fn_hook(filename, prefix, suffix)

with tempfile.NamedTemporaryFile(delete=False, prefix=prefix,
suffix=suffix) as tmpfile:
tempfile_name = tmpfile.name
self.attachment.write(tmpfile)

def afterwards():
os.unlink(tempfile_name)
else:
handler_stdin = BytesIO()
self.attachment.write(handler_stdin)
if not entry:
raise RuntimeError(
f'no mailcap handler found for MIME type {mimetype}')

afterwards = None # callback, will rm tempfile if used
handler_stdin = None
tempfile_name = None
handler_raw_commandstring = entry['view']
# read parameter
part = attachment.get_mime_representation()
parms = tuple('='.join(p) for p in part.get_params())

# in case the mailcap defined command contains no '%s',
# we pipe the files content to the handling command via stdin
if '%s' in handler_raw_commandstring:
nametemplate = entry.get('nametemplate', '%s')
prefix, suffix = parse_mailcap_nametemplate(nametemplate)
tempfile_name, afterwards = \
self._make_temp_file(attachment, prefix, suffix)
else:
handler_stdin = BytesIO()
attachment.write(handler_stdin)

# create handler command list
handler_cmd = mailcap.subst(handler_raw_commandstring, mimetype,
filename=tempfile_name, plist=parms)
# create handler command list
handler_cmd = mailcap.subst(handler_raw_commandstring, mimetype,
filename=tempfile_name, plist=parms)

handler_cmdlist = split_commandstring(handler_cmd)
handler_cmdlist = split_commandstring(handler_cmd)

# 'needsterminal' makes handler overtake the terminal
# XXX: could this be repalced with "'needsterminal' not in entry"?
overtakes = entry.get('needsterminal') is None
# 'needsterminal' makes handler overtake the terminal
# XXX: could this be replaced with "'needsterminal' not in entry"?
overtakes = entry.get('needsterminal') is None

await ui.apply_command(ExternalCommand(handler_cmdlist,
stdin=handler_stdin,
on_success=afterwards,
thread=overtakes))
return ExternalCommand(handler_cmdlist,
stdin=handler_stdin,
on_exit=afterwards,
thread=overtakes,
spawn=self.spawn)

@staticmethod
def _get_attachment(ui):
focus = ui.get_deep_focus()
if isinstance(focus, AttachmentWidget):
return focus.get_attachment()
elif (getattr(focus, 'mimepart', False) and
isinstance(focus.mimepart, Attachment)):
return focus.mimepart
else:
ui.notify('unknown mime type')
raise RuntimeError('not focused on an attachment')

@staticmethod
def _make_temp_file(attachment, prefix='', suffix=''):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems overcomplicated to me. Why not just extend tempfile.NamedTemporaryFile and return a contextmanager?

Copy link
Contributor Author

@pacien pacien May 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ExternalCommand in charge of executing the command using the temporary file does not accept context managers. It's not either possible to use a "with" block on top of it because it can spawn an independent thread.

filename = attachment.get_filename()
sanitize_hook = settings.get_hook('sanitize_attachment_filename')
prefix, suffix = \
sanitize_hook(filename, prefix, suffix) \
if sanitize_hook else '', ''

with tempfile.NamedTemporaryFile(delete=False, prefix=prefix,
suffix=suffix) as tmpfile:
logging.info(f'created temp file {tmpfile.name}')
attachment.write(tmpfile)
return tmpfile.name, lambda: os.unlink(tmpfile.name)


@registerCommand(
Expand Down Expand Up @@ -1061,11 +1119,10 @@ class ThreadSelectCommand(Command):
async def apply(self, ui):
focus = ui.get_deep_focus()
if isinstance(focus, AttachmentWidget):
logging.info('open attachment')
await ui.apply_command(OpenAttachmentCommand(focus.get_attachment()))
await ui.apply_command(OpenAttachmentCommand())
elif getattr(focus, 'mimepart', False):
if isinstance(focus.mimepart, Attachment):
await ui.apply_command(OpenAttachmentCommand(focus.mimepart))
await ui.apply_command(OpenAttachmentCommand())
else:
await ui.apply_command(ChangeDisplaymodeCommand(
mimepart=True, mimetree='toggle'))
Expand Down
14 changes: 14 additions & 0 deletions docs/source/usage/modes/thread.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ The following commands are available in thread mode:
up, down, [half]page up, [half]page down, first, last, parent, first reply, last reply, next sibling, previous sibling, next, previous, next unfolded, previous unfolded, next NOTMUCH_QUERY, previous NOTMUCH_QUERY


.. _cmd.thread.open-attachment:

.. describe:: open-attachment

opens an attachment with a given shell command
or according to mailcap

argument
Shell command to use to open the attachment. The path to the attachment file will be passed as an argument. If absent, mailcap is used to select the command based on the attachment's MIME type.

optional arguments
:---thread: run in separate thread
:---spawn: run in a new terminal window

.. _cmd.thread.pipeto:

.. describe:: pipeto
Expand Down