diff --git a/codado/hotedit.py b/codado/hotedit.py new file mode 100644 index 0000000..2514591 --- /dev/null +++ b/codado/hotedit.py @@ -0,0 +1,103 @@ +""" +Invoke user's editor on a temp file and present the edited contents back to the invoking code +""" +from __future__ import print_function + +import os +import pipes +import shlex +import subprocess +import tempfile + + +TEMP_EXT = '.hotedit' +EDITOR_FALLBACK = "vi" + + +class HoteditException(Exception): + """ + Base for any hotedit exception + """ + + +class EditingException(HoteditException): + """ + Raised to signal that an edited file was left unchanged + """ + + +class Unchanged(HoteditException): + """ + Raised to signal that an edited file was left unchanged + """ + + +def determine_editor(fallback=EDITOR_FALLBACK): + """ + Figure out which of many possible options is the editor, starting with: + -- git config core.editor + -- EDITOR + -- VISUAL + + If none of these is a usable editor, return the value of `fallback'. + + If fallback=None, and none of these is a usable editor, raise OSError + """ + try: + ret = subprocess.check_output(shlex.split('git config core.editor')) + return ret.strip() + except subprocess.CalledProcessError: + "git config core.editor didn't work, falling back to environment" + + if os.environ.get('EDITOR'): + return os.environ['EDITOR'] + + if os.environ.get('VISUAL'): + return os.environ['VISUAL'] + + if fallback: + return fallback + + raise OSError("No editor found (checked git, $EDITOR and $VISUAL)") + + +_remove = os.remove + + +def hotedit(initial=None, validate_unchanged=False, delete_temp=True, find_editor=determine_editor): + """ + Edit `initial' string as the contents of a temp file in a local editor + + With `validate_unchanged' == True, raise the Unchanged exception when the file is closed with no changes + + With `delete_temp' == False, leave the temp file alone after editing, otherwise remove it by default + + `find_editor' is a zero-argument callable that returns an editor string, using hotedit.determine_editor by default + + Exceptions while invoking the editor are re-raised + + @returns the string after editing. + """ + path = None + try: + handle, path = tempfile.mkstemp(TEMP_EXT) + with os.fdopen(handle, 'w') as f: + f.write(initial) + + cmd = shlex.split(find_editor()) + [path] + rc = subprocess.call(cmd) + if rc != 0: + quoted = " ".join([pipes.quote(x) for x in cmd]) + raise EditingException("Command '%s' returned non-zero exit status %s" % (quoted, rc)) + + with open(path, 'r') as tmp: + edited = tmp.read() + + if validate_unchanged and edited.strip() == initial.strip(): + raise Unchanged("No changes since editing started") + + return edited + + finally: + if delete_temp and path and os.path.exists(path): + _remove(path) diff --git a/codado/test/test_hotedit.py b/codado/test/test_hotedit.py new file mode 100644 index 0000000..e7f6c44 --- /dev/null +++ b/codado/test/test_hotedit.py @@ -0,0 +1,78 @@ +""" +Test hotedit, the tool for invoking a local editor on a temp file +""" +import os +import subprocess + +from mock import ANY, patch + +from pytest import raises + +from codado import hotedit + + +def test_determine_editor(): + """ + Do I figure out the right editor based on the running environment? + """ + with patch.object(subprocess, 'check_output', return_value='giteditor'): + assert hotedit.determine_editor() == 'giteditor' + + pCheckOutput = patch.object(subprocess, 'check_output', + side_effect=subprocess.CalledProcessError('ohh', 'noo')) + env = os.environ.copy() + pEnviron = patch.object(os, 'environ', env) + with pCheckOutput, pEnviron: + env['VISUAL'] = 'visual' + assert hotedit.determine_editor() == 'visual' + + env['EDITOR'] = 'editor' + assert hotedit.determine_editor() == 'editor' + + del env['VISUAL'], env['EDITOR'] + + assert hotedit.determine_editor() == 'vi' + + with raises(OSError): + hotedit.determine_editor(fallback=None) + + +def test_hotedit(): + """ + What happens if hotedit is called with an editor we can't find? + """ + find_editor = lambda: '---asdfasdfwsdf---' + + input_ = "asdfasdfa" + + # 1. cmd does not exist -> OSError + with raises(OSError): + hotedit.hotedit(input_, find_editor=find_editor) + + # 2. error while editing -> EditingException + pSubprocessCall = patch.object(subprocess, 'call', return_value=19) + with pSubprocessCall, raises(hotedit.EditingException): + hotedit.hotedit(input_, find_editor=find_editor) + + pRemove = patch.object(hotedit, "_remove", autospec=True) + + # 3. unchanged while editing + validate_unchanged -> Unchanged + # 5. any exception -> temp is removed + pSubprocessCall = patch.object(subprocess, 'call', return_value=0) + with pRemove as mRemove, pSubprocessCall, raises(hotedit.Unchanged): + hotedit.hotedit(input_, find_editor=find_editor, validate_unchanged=True) + mRemove.assert_called_once_with(ANY) + + # 4. unchanged while editing + !validate_unchanged -> string + # 6. happy path -> return edited string, temp is removed + pSubprocessCall = patch.object(subprocess, 'call', return_value=0) + with pRemove as mRemove, pSubprocessCall: + ret = hotedit.hotedit(input_, find_editor=find_editor, validate_unchanged=False) + assert ret == input_ + mRemove.assert_called_once_with(ANY) + + # 7. happy path + !delete_temp -> return edited string, temp is left + with pRemove as mRemove, pSubprocessCall: + ret = hotedit.hotedit(input_, find_editor=find_editor, validate_unchanged=False, delete_temp=False) + assert ret == input_ + assert mRemove.call_count == 0