#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-editor. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE DOES, AND THEREFORE WHAT IS MEASURED. `editor' resolves a program name and then RUNS IT on a file: `get_editor()' picks the name, `get_editor_args()' decides what flags go with it, and `edit()' writes the seed bytes, spawns the program, waits, and reads the file back. So unlike the path-composition libraries next door this one is I/O and `execve(2)', and every interesting case needs a program to actually run. NO HUMAN, AND NO TERMINAL. Every editor this probe resolves to is a stub it writes itself: a two-line `/bin/sh' script that rewrites the file it is handed, records its own argv, and exits. So `edit()' runs to completion under the probe's own bound with nothing attached to it. `stub_execs' is measured FIRST and emitted, so that a platform that could not exec a `#!' script at all shows up as one named fact rather than as a dozen unexplained reds. THE FIXTURES ARE BUILT HERE, NOT SHIPPED. Everything the cases need is a file this probe creates under $TMPDIR -- stubs, PATH directories, seed files -- which is what keeps the suite deterministic and lets it state its expectations as literals. The tree is removed at the end and `workdir_gone' says so. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line, and bytes are emitted as hex. And no value may depend on the encoding stdout happens to be in: every one is ASCII. """ import os import shutil import stat import subprocess import sys import tempfile emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def call(fn, *a, **kw): """Return fn(*a) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message: the message is CPython's and has moved between releases, while the name is the contract a caller has to code against.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ def hexb(b): if not isinstance(b, bytes): return 'not-bytes:' + type(b).__name__ return b.hex() try: import editor except Exception as exc: print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ============================================================== identity ===== emit('module_file', getattr(editor, '__file__', '')) emit('is_package', 'yes' if hasattr(editor, '__path__') else 'no') emit('version_attr', getattr(editor, '__version__', '')) # `__all__' EXISTS HERE and names three things, so a star-import leaks nothing # -- the opposite of appdirs next door, and the reason this suite has no # "star-import rebinds os" case. emit('has_dunder_all', 'yes' if hasattr(editor, '__all__') else 'no') emit('dunder_all', ','.join(sorted(getattr(editor, '__all__', [])))) _ns = {} exec('from editor import *', _ns) emit('star_names', ','.join(sorted(k for k in _ns if not k.startswith('__')))) # The ticket asked for `get_tmp_dir()'. 1.0.4 has no such function; the # temporary file is made inline in `edit()' by tempfile.NamedTemporaryFile. emit('has_get_tmp_dir', 'yes' if hasattr(editor, 'get_tmp_dir') else 'no') emit('has_get_editor', 'yes' if hasattr(editor, 'get_editor') else 'no') emit('has_edit', 'yes' if hasattr(editor, 'edit') else 'no') emit('has_get_editor_args', 'yes' if hasattr(editor, 'get_editor_args') else 'no') emit('has_get_default_editors', 'yes' if hasattr(editor, 'get_default_editors') else 'no') emit('has_get_tty_filename', 'yes' if hasattr(editor, 'get_tty_filename') else 'no') emit('editor_error_is_runtimeerror', 'yes' if issubclass(editor.EditorError, RuntimeError) else 'no') emit('editor_error_is_exception', 'yes' if issubclass(editor.EditorError, Exception) else 'no') emit('default_editors', ','.join(editor.get_default_editors())) emit('tty_filename', editor.get_tty_filename()) emit('sys_platform', sys.platform) # ====================================================== the distutils shim === # THE WHOLE PORT TURNS ON THIS. `editor.py' does `from distutils.spawn import # find_executable' at module scope, and distutils was REMOVED from the standard # library in Python 3.12 -- which is this image's interpreter. The import # succeeds only because setuptools ships `distutils-precedence.pth', which # `site' executes at startup and which installs a finder mapping the name # `distutils' onto `setuptools._distutils'. That is why the recipe's RDEPENDS # on python3-setuptools is load-bearing rather than decorative, and it is what # these four cases measure. import distutils.spawn as _dspawn emit('distutils_spawn_file', _dspawn.__file__) emit('distutils_is_setuptools_shim', 'yes' if os.sep + 'setuptools' + os.sep + '_distutils' + os.sep in _dspawn.__file__ else 'no') emit('find_executable_module', editor.find_executable.__module__) emit('distutils_hack_loaded', 'yes' if '_distutils_hack' in sys.modules else 'no') # ========================================================= the environment === W = tempfile.mkdtemp(prefix='bst-editor.') ARGV = os.path.join(W, 'argv.log') EMPTYDIR = os.path.join(W, 'empty') os.mkdir(EMPTYDIR) # THE PROBE RUNS FROM AN EMPTY DIRECTORY, deliberately. find_executable tests # the bare name against the CURRENT DIRECTORY before it looks at PATH, so a # probe that ran from wherever the runner happened to start would have every # fallback case decided by that directory's contents. `ge_cwd_injection' below # is the one case that changes this, and it changes it on purpose. _saved_cwd = os.getcwd() os.chdir(EMPTYDIR) def stub_text(rc, rewrite=True): """A `/bin/sh' editor stub: record argv, optionally rewrite, exit `rc'. `$*' rather than `"$@"' because the recorded line is compared as one string and every path this probe builds is space-free. The rewrite targets the LAST argument, which is where edit() puts the filename -- after whatever flags get_editor_args chose -- so one stub serves the flag cases too.""" lines = ['#!/bin/sh', 'printf "%s\\n" "$*" >> "' + ARGV + '"'] if rewrite: lines += ['eval _last=\\${$#}', 'printf "EDITED\\n" > "$_last"'] lines += ['exit %d' % rc, ''] return '\n'.join(lines) def write_stub(path, text, mode=0o755): with open(path, 'w') as f: f.write(text) os.chmod(path, mode) return path def nw(value): """Replace the random workdir with `$W', so a value is a LITERAL. tempfile.mkdtemp picks the suffix, so any path this probe reports would otherwise differ on every run and could only be asserted by shape.""" return str(value).replace(W, '$W') def last_argv(): """The argv line the most recent stub run recorded, or '' if none.""" try: with open(ARGV) as f: lines = [l.rstrip('\n') for l in f if l.strip()] except IOError: return '' return lines[-1] if lines else '' def reset_argv(): try: os.unlink(ARGV) except OSError: pass # The rewriting stub, under a name `get_editor_args' does NOT special-case, so # `edit()' passes it nothing but the filename. ED0 = write_stub(os.path.join(W, 'ed0'), stub_text(0)) # The same stub, exiting non-zero. EDFAIL = write_stub(os.path.join(W, 'edfail'), stub_text(3)) # A stub that touches nothing, for the cases about what `edit()' itself wrote. EDNOOP = write_stub(os.path.join(W, 'ednoop'), stub_text(0, rewrite=False)) # MEASURED BEFORE ANYTHING DEPENDS ON IT: can this image exec a `#!' script at # all? Every `edit()' case below spawns one, so a `no' here explains all of # them at once and a red anywhere else is then not about `editor'. _p = os.path.join(W, 'probe-target') with open(_p, 'w') as f: f.write('seed') try: _rc = subprocess.Popen([ED0, _p]).wait() with open(_p) as f: _got = f.read() emit('stub_execs', 'yes' if _rc == 0 and _got == 'EDITED\n' else 'rc=%s content=%r' % (_rc, _got)) except Exception as exc: emit('stub_execs', '!' + type(exc).__name__) reset_argv() emit('stdin_isatty', 'yes' if sys.stdin.isatty() else 'no') emit('stdout_isatty', 'yes' if sys.stdout.isatty() else 'no') emit('use_tty_default', 'yes' if (sys.stdin.isatty() and not sys.stdout.isatty()) else 'no') def with_env(**kw): """Set VISUAL/EDITOR/PATH to exactly `kw' (None = unset) and restore after. None is not the same as the empty string, and this package turns on that difference: `os.environ.get('VISUAL') or os.environ.get('EDITOR')' treats a variable that is SET AND EMPTY exactly as an absent one, because the test is truthiness rather than presence.""" names = ('VISUAL', 'EDITOR', 'PATH') saved = {n: os.environ.get(n) for n in names} class _Ctx(object): def __enter__(self): for n in names: os.environ.pop(n, None) for k, v in kw.items(): if v is not None: os.environ[k] = v return self def __exit__(self, *a): for n in names: os.environ.pop(n, None) if saved[n] is not None: os.environ[n] = saved[n] return False return _Ctx() # ======================================== get_editor(): VISUAL, then EDITOR === with with_env(VISUAL='/x/visual', PATH=EMPTYDIR): emit('ge_visual_only', call(editor.get_editor)) with with_env(EDITOR='/x/editor', PATH=EMPTYDIR): emit('ge_editor_only', call(editor.get_editor)) with with_env(VISUAL='/x/visual', EDITOR='/x/editor', PATH=EMPTYDIR): emit('ge_both', call(editor.get_editor)) # SET-AND-EMPTY IS TREATED AS ABSENT, on both variables, because the test is # `or' over the values rather than `in os.environ'. with with_env(VISUAL='', EDITOR='/x/editor', PATH=EMPTYDIR): emit('ge_visual_empty', call(editor.get_editor)) with with_env(VISUAL='', EDITOR='', PATH=EMPTYDIR): emit('ge_both_empty', call(editor.get_editor)) # ...but a variable set to a SPACE is truthy and comes back untouched: nothing # strips, splits or validates what the environment said. with with_env(EDITOR=' ', PATH=EMPTYDIR): emit('ge_editor_space', repr(call(editor.get_editor))) with with_env(EDITOR='vim -f -o', PATH=EMPTYDIR): emit('ge_editor_with_args', call(editor.get_editor)) # ============================ get_editor(): the PATH fallback, and its order == # The fallback list is `editor, vim, emacs, nano' and it is searched IN THAT # ORDER, so what a user gets when they have set nothing is decided here. PD = os.path.join(W, 'pathdir') os.mkdir(PD) write_stub(os.path.join(PD, 'nano'), stub_text(0)) with with_env(PATH=PD): emit('ge_path_nano', nw(call(editor.get_editor))) write_stub(os.path.join(PD, 'emacs'), stub_text(0)) with with_env(PATH=PD): emit('ge_path_emacs_beats_nano', nw(call(editor.get_editor))) write_stub(os.path.join(PD, 'vim'), stub_text(0)) with with_env(PATH=PD): emit('ge_path_vim_beats_emacs', nw(call(editor.get_editor))) write_stub(os.path.join(PD, 'editor'), stub_text(0)) with with_env(PATH=PD): emit('ge_path_editor_wins', nw(call(editor.get_editor))) # NOTHING SET AND NOTHING ON PATH IS THE ONLY WAY EditorError IS RAISED. Its # message is upstream's, and upstream concatenated two string literals without a # separator, so it reads `...system.Please consider...' with no space. Asserted # verbatim, because it is the one message this package produces. with with_env(PATH=EMPTYDIR): emit('ge_none_found', call(editor.get_editor)) try: editor.get_editor() emit('ge_none_found_message', '') except editor.EditorError as exc: emit('ge_none_found_message', str(exc)) except Exception as exc: emit('ge_none_found_message', '!' + type(exc).__name__) # AN EMPTY PATH IS NOT A SEARCH OF THE CURRENT DIRECTORY. find_executable # returns None outright for `PATH=', rather than splitting it into one empty # component. with with_env(PATH=''): emit('ge_path_empty', call(editor.get_editor)) # THE CURRENT DIRECTORY IS SEARCHED FIRST, AND IT IS NOT ON PATH. # find_executable tests `os.path.isfile(executable)' -- the BARE NAME, resolved # against the process's cwd -- before it looks at PATH at all. So a file named # `nano' in whatever directory the process happens to be in is picked up ahead # of every real editor, and comes back as the RELATIVE name. A caller who runs # this in a directory somebody else can write has handed them the program. CWDINJ = os.path.join(W, 'cwdinj') os.mkdir(CWDINJ) write_stub(os.path.join(CWDINJ, 'nano'), stub_text(0)) os.chdir(CWDINJ) with with_env(PATH=EMPTYDIR): emit('ge_cwd_injection', call(editor.get_editor)) emit('ge_cwd_injection_is_relative', 'yes' if not os.path.isabs(str(call(editor.get_editor))) else 'no') os.chdir(EMPTYDIR) # A FILE THAT IS NOT EXECUTABLE IS STILL "FOUND". find_executable checks # isfile, never os.access(X_OK), so a non-executable `vim' on PATH is returned # by get_editor() and the failure is deferred to the exec inside edit(). NOEXEC = os.path.join(W, 'noexec') os.mkdir(NOEXEC) write_stub(os.path.join(NOEXEC, 'vim'), stub_text(0), mode=0o644) with with_env(PATH=NOEXEC): emit('ge_nonexecutable_found', nw(call(editor.get_editor))) emit('ge_nonexecutable_edit', call(editor.edit, filename=_p, use_tty=False)) # ========================================================= get_editor_args === # A NAME TABLE, NOT A PATH TABLE. Every arm matches a BARE program name, so a # caller who passes what get_editor() returned from $EDITOR -- routinely an # absolute path -- silently gets no flags. edit() itself does not have this # problem because it passes basename(realpath(...)); see `args_' below. for _name in ('vim', 'gvim', 'vim.basic', 'vim.tiny', 'emacs', 'gedit', 'nano', 'vi', 'code', '', '/usr/bin/vim', 'VIM'): emit('gea_' + (_name.replace('/', '_').replace('.', '_') or 'empty'), ' '.join(editor.get_editor_args(_name)) or '') # ==================================================================== edit === SEED = os.path.join(W, 'seed.txt') def seed(text=b'seed'): with open(SEED, 'wb') as f: f.write(text) return SEED # edit(filename=..., contents=...) writes the seed, runs the editor on THAT # file, and returns what the editor left there. reset_argv() with with_env(EDITOR=ED0, PATH=EMPTYDIR): emit('edit_filename_contents', hexb(call(editor.edit, filename=seed(), contents=b'hello', use_tty=False))) emit('edit_filename_argv', nw(last_argv())) with with_env(EDITOR=ED0, PATH=EMPTYDIR): emit('edit_return_type', type(call(editor.edit, filename=seed(), contents=b'q', use_tty=False)).__name__) with open(SEED, 'rb') as f: emit('edit_filename_inplace', hexb(f.read())) # contents=None does NOT write, so the editor sees the file exactly as it was. # The stub overwrites regardless, so what this measures is the argv: no # truncation happened before the spawn. reset_argv() with with_env(EDITOR=EDNOOP, PATH=EMPTYDIR): emit('edit_no_contents_preserves', hexb(call(editor.edit, filename=seed(b'keepme'), use_tty=False))) # A str is encoded with the DEFAULT encoding, not the locale's -- the module # calls `contents.encode()' with no argument. reset_argv() with with_env(EDITOR=EDNOOP, PATH=EMPTYDIR): emit('edit_str_contents', hexb(call(editor.edit, filename=seed(), contents='h\u00e9llo', use_tty=False))) emit('default_encoding', sys.getdefaultencoding()) # An EMPTY contents is not None, so it DOES write -- and truncates the file. reset_argv() with with_env(EDITOR=EDNOOP, PATH=EMPTYDIR): emit('edit_empty_contents', hexb(call(editor.edit, filename=seed(b'gone'), contents=b'', use_tty=False))) # NO FILENAME: a NamedTemporaryFile is made, edited, read back, and DELETED when # the local goes out of scope at return. The stub records the path, so the # suite can assert on the temporary file that no longer exists. reset_argv() with with_env(EDITOR=ED0, PATH=EMPTYDIR): emit('edit_tmp_result', hexb(call(editor.edit, contents=b'x', use_tty=False))) _tmp_path = last_argv() emit('edit_tmp_path_absolute', 'yes' if os.path.isabs(_tmp_path) else 'no') emit('edit_tmp_removed', 'yes' if _tmp_path and not os.path.exists(_tmp_path) else 'no') # `suffix' reaches NamedTemporaryFile, so it is the temporary file's extension. reset_argv() with with_env(EDITOR=ED0, PATH=EMPTYDIR): call(editor.edit, contents=b'x', suffix='.md', use_tty=False) _tmp_suf = last_argv() emit('edit_suffix_applied', 'yes' if _tmp_suf.endswith('.md') else 'no') emit('edit_suffix_removed', 'yes' if _tmp_suf and not os.path.exists(_tmp_suf) else 'no') # THE EDITOR'S EXIT STATUS IS DISCARDED. `proc.communicate()' is called and its # result thrown away, and `returncode' is never read -- so an editor that failed # is indistinguishable from one that saved. The ticket expected the status to # be surfaced; it is not, and a caller who needs to know a user aborted cannot # learn it from this API. reset_argv() with with_env(EDITOR=EDFAIL, PATH=EMPTYDIR): emit('edit_nonzero_exit_result', hexb(call(editor.edit, filename=seed(), contents=b'x', use_tty=False))) # AN EDITOR THAT DOES NOT EXIST RAISES FileNotFoundError, NOT EditorError. # get_editor() returns whatever $EDITOR said without checking it exists, and the # failure surfaces out of subprocess.Popen. EditorError is reachable ONLY # through the PATH-fallback arm above. with with_env(EDITOR=os.path.join(W, 'no-such-editor'), PATH=EMPTYDIR): emit('edit_missing_editor', call(editor.edit, filename=seed(), use_tty=False)) # AN $EDITOR CARRYING ARGUMENTS IS NOT SPLIT. The whole string is argv[0], so # the documented-everywhere `EDITOR="vim -f"' does not work with this library. with with_env(EDITOR=ED0 + ' -x', PATH=EMPTYDIR): emit('edit_editor_with_args', call(editor.edit, filename=seed(), use_tty=False)) # A DIRECTORY named as the editor gets as far as the exec. with with_env(EDITOR=EMPTYDIR, PATH=EMPTYDIR): emit('edit_editor_is_dir', call(editor.edit, filename=seed(), use_tty=False)) # A filename that does not exist and contents=None: nothing creates it, so the # read-back at the end of edit() is what fails. with with_env(EDITOR=EDNOOP, PATH=EMPTYDIR): emit('edit_missing_file', call(editor.edit, filename=os.path.join(W, 'not-there'), use_tty=False)) # THE FLAGS ARE CHOSEN FROM basename(realpath(editor)), NOT FROM WHAT THE USER # TYPED. Two consequences, and both are surprises: # * an editor reached through an ABSOLUTE PATH still gets its flags, which is # what makes `gea_/usr/bin/vim' above harmless in practice; # * a SYMLINK is resolved first, so the flags follow the TARGET's name. A # link called `myeditor' pointing at `vim' is invoked with vim's `-f -o', # and a link called `vim' pointing at something else gets none. VIMDIR = os.path.join(W, 'vimdir') os.mkdir(VIMDIR) VIMSTUB = write_stub(os.path.join(VIMDIR, 'vim'), stub_text(0)) NANOSTUB = write_stub(os.path.join(VIMDIR, 'nano'), stub_text(0)) reset_argv() with with_env(EDITOR=VIMSTUB, PATH=EMPTYDIR): call(editor.edit, filename=seed(), use_tty=False) emit('args_vim_by_abspath', nw(last_argv())) reset_argv() with with_env(EDITOR=NANOSTUB, PATH=EMPTYDIR): call(editor.edit, filename=seed(), use_tty=False) emit('args_nano_by_abspath', nw(last_argv())) LINK_TO_VIM = os.path.join(W, 'myeditor') os.symlink(VIMSTUB, LINK_TO_VIM) reset_argv() with with_env(EDITOR=LINK_TO_VIM, PATH=EMPTYDIR): call(editor.edit, filename=seed(), use_tty=False) emit('args_symlink_takes_target_name', nw(last_argv())) LINK_NAMED_VIM = os.path.join(W, 'vim') os.symlink(ED0, LINK_NAMED_VIM) reset_argv() with with_env(EDITOR=LINK_NAMED_VIM, PATH=EMPTYDIR): call(editor.edit, filename=seed(), use_tty=False) emit('args_link_named_vim_gets_none', nw(last_argv())) # THE DEFAULT use_tty PATH, taken rather than forced. Under the runner neither # stream is a terminal, so `sys.stdin.isatty() and not sys.stdout.isatty()' is # False and nothing opens /dev/tty -- which is what makes this package usable # non-interactively here at all. reset_argv() with with_env(EDITOR=ED0, PATH=EMPTYDIR): emit('edit_default_use_tty', hexb(call(editor.edit, filename=seed(), contents=b'x'))) # edit() RESOLVES THE EDITOR THROUGH get_editor(), so the PATH fallback drives # it too -- no $EDITOR set anywhere in this case. reset_argv() with with_env(PATH=VIMDIR): emit('edit_via_path_fallback', hexb(call(editor.edit, filename=seed(), contents=b'x', use_tty=False))) emit('edit_via_path_fallback_argv', nw(last_argv())) # ===================================================================== CLI === # `editor.py' HAS AN argparse MAIN, AND THE PACKAGE INSTALLS NO ENTRY POINT FOR # IT. setup.py declares neither `scripts' nor `entry_points', so the only way # to reach it is `python3 -m editor' -- which is why the suite asserts no # executable was installed and the CLI is exercised HERE, through runpy, rather # than by spending an interpreter start per invocation. import contextlib import io import runpy def run_main(argv): """Run editor.py as __main__ with `argv', returning its stdout or !Exc.""" saved = sys.argv buf = io.StringIO() try: sys.argv = ['editor'] + argv with contextlib.redirect_stdout(buf): runpy.run_module('editor', run_name='__main__') except SystemExit as exc: return 'SystemExit:%s' % exc.code except Exception as exc: return '!' + type(exc).__name__ finally: sys.argv = saved return buf.getvalue().strip() with with_env(EDITOR='/x/from-env', PATH=EMPTYDIR): emit('cli_get_editor', run_main(['get-editor'])) # A BARE `python3 -m editor' CRASHES. argparse subparsers are optional on # Python 3, so with no subcommand `ns.cmd' is never set and the last line of the # module raises AttributeError -- a traceback, not a usage message. Upstream # targets 2.7 as well, where a missing subcommand was an error argparse itself # reported; the module never caught up. emit('cli_no_subcommand', run_main([])) # `edit' PRINTS THE repr OF THE BYTES, not the bytes -- so the CLI's output # carries the `b'...'' wrapper. It also encodes with locale.getpreferredencoding() # rather than the default encoding edit() itself uses. reset_argv() with with_env(EDITOR=ED0, PATH=EMPTYDIR): emit('cli_edit', run_main(['edit', seed(), '--contents', 'hi'])) emit('preferred_encoding', __import__('locale').getpreferredencoding()) # ================================================================= cleanup === os.chdir(_saved_cwd) shutil.rmtree(W, ignore_errors=True) emit('workdir_gone', 'yes' if not os.path.exists(W) else 'no') emit('cwd_restored', 'yes' if os.getcwd() == _saved_cwd else 'no') emit('emitted_count', len(emitted)) print('probe_done=yes')