jenkins-bot has submitted this change. ( https://gerrit.wikimedia.org/r/c/pywikibot/core/+/857001 )
Change subject: [IMPR] Improvements for editor ......................................................................
[IMPR] Improvements for editor
- Redefine config.editor setting: No longer call os.environment here. config.editor may be str, bool or None. str: use this as editor command True: use Tkinter False: use 'break' or 'true' as command which does nothing (mostly used for tests purpose) None: Use EDITOR environment value if present or on Windows detect editor from registry or fially ue Tkinter - Move editor detection functions from config.py to editor.py - copy EXTERNAL EDITOR SETTINGS section in generate_user_files.py - use pathlib.Path in TextEditor.edit()
Change-Id: I84b097c09ee2a4a582582878e08afb3cbb086264 --- M pywikibot/editor.py M pywikibot/scripts/generate_user_files.py M pywikibot/config.py 3 files changed, 151 insertions(+), 104 deletions(-)
Approvals: Xqt: Looks good to me, approved jenkins-bot: Verified
diff --git a/pywikibot/config.py b/pywikibot/config.py index 64cc696..1746a02 100644 --- a/pywikibot/config.py +++ b/pywikibot/config.py @@ -29,6 +29,9 @@
.. versionchanged:: 6.2 config2 was renamed to config +.. versionchanged:: 8.0 + Editor settings has been revised. *editor* variable is None by + default. Editor detection functions were moved to :mod:`editor`. """ # # (C) Pywikibot team, 2003-2022 @@ -71,8 +74,6 @@
OSWIN32 = (sys.platform == 'win32')
-if OSWIN32: - import winreg
# This frozen set should contain all imported modules/variables, so it must # occur directly after the imports. At that point globals() only contains the @@ -568,9 +569,14 @@ tkvertsize = 800
# ############# EXTERNAL EDITOR SETTINGS ############## -# The command for the editor you want to use. If set to None, a simple Tkinter -# editor will be used. -editor = os.environ.get('EDITOR') +# The command for the editor you want to use. If set to True, Tkinter +# editor will be used. If set to False, no editor will be used. In +# script tests to be a noop (like /bin/true) so the script continues. +# If set to None, the EDITOR environment variable will be used as +# command. If EDITOR is not set, on windows plattforms it tries to +# determine the default text editor from registry. Finally, Tkinter is +# used as fallback. +editor: Union[bool, str, None] = None
# Warning: DO NOT use an editor which doesn't support Unicode to edit pages! # You will BREAK non-ASCII symbols! @@ -925,51 +931,6 @@ return path
-def _win32_extension_command(extension: str) -> Optional[str]: - """Get the command from the Win32 registry for an extension.""" - fileexts_key = \ - r'Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts' - key_name = fileexts_key + r'.' + extension + r'\OpenWithProgids' - try: - key1 = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_name) - _prog_id = winreg.EnumValue(key1, 0)[0] - _key2 = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, - fr'{_prog_id}\shell\open\command') - _cmd = winreg.QueryValueEx(_key2, '')[0] - # See T102465 for issues relating to using this value. - cmd = _cmd - if cmd.find('%1'): - cmd = cmd[:cmd.find('%1')] - # Remove any trailing character, which should be a quote or space - # and then remove all whitespace. - return cmd[:-1].strip() - except OSError as e: - # Catch any key lookup errors - output('Unable to detect program for file extension "{}": {!r}' - .format(extension, e)) - return None - - -def _detect_win32_editor() -> Optional[str]: - """Detect the best Win32 editor.""" - # Notepad is even worse than our Tkinter editor. - unusable_exes = ['notepad.exe', - 'py.exe', - 'pyw.exe', - 'python.exe', - 'pythonw.exe'] - - for ext in ['py', 'txt']: - editor = _win32_extension_command(ext) - if editor: - for unusable in unusable_exes: - if unusable in editor.lower(): - break - else: - return editor - return None - - # System-level and User-level changes. # Store current variables and their types. _public_globals = { @@ -1107,18 +1068,6 @@ if console_encoding is None: console_encoding = 'utf-8'
-if OSWIN32 and editor is None: - editor = _detect_win32_editor() - -# single character string literals from -# https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-l... -# encode('unicode-escape') also changes Unicode characters -if OSWIN32 and editor and set(editor) & set('\a\b\f\n\r\t\v'): - warning( - 'The editor path contains probably invalid escaped characters. Make ' - 'sure to use a raw-string (r"..." or r'...'), forward slashes as a ' - 'path delimiter or to escape the normal path delimiter.') - if userinterface_lang is None: userinterface_lang = os.getenv('PYWIKIBOT_USERINTERFACE_LANG') \ or getlocale()[0] diff --git a/pywikibot/editor.py b/pywikibot/editor.py index e29b2de..29e4829 100644 --- a/pywikibot/editor.py +++ b/pywikibot/editor.py @@ -4,10 +4,10 @@ # # Distributed under the terms of the MIT license. # -import codecs import os import subprocess import tempfile +from pathlib import Path from sys import platform from textwrap import fill from typing import Optional @@ -24,14 +24,36 @@ GUI_ERROR = e
+OSWIN32 = platform == 'win32' +if OSWIN32: + import winreg + + class TextEditor:
- """Text editor.""" + """Text editor.
- @staticmethod - def _command(file_name: str, text: str, + .. versionchanged:: 8.0 + Editor detection functions were moved from :mod:`config`. + """ + + def __init__(self): + """Setup external Editor.""" + self.editor: str + if config.editor is True: + self.editor = '' + elif config.editor is False: + self.editor = 'break' if OSWIN32 else 'true' + elif config.editor is None: + self.editor = os.environ.get('EDITOR', '') + if OSWIN32 and not self.editor: + self.editor = self._detect_win32_editor() + else: + self.editor = config.editor + + def _command(self, file_name: str, text: str, jump_index: Optional[int] = None) -> List[str]: - """Return editor selected in user config file (user-config.py).""" + """Return command of editor selected in user config file.""" if jump_index: # Some editors make it possible to mark occurrences of substrings, # or to jump to the line of the first occurrence. @@ -41,28 +63,28 @@ column = jump_index - (text[:jump_index].rfind('\n') + 1) else: line = column = 0 + # Linux editors. We use startswith() because some users might use # parameters. - assert config.editor is not None - if config.editor.startswith('kate'): + if self.editor.startswith('kate'): command = ['-l', str(line + 1), '-c', str(column + 1)] - elif config.editor.startswith(('gedit', 'emacs')): + elif self.editor.startswith(('gedit', 'emacs')): command = [f'+{line + 1}'] # columns seem unsupported - elif config.editor.startswith('jedit'): + elif self.editor.startswith('jedit'): command = [f'+line:{line + 1}'] # columns seem unsupported - elif config.editor.startswith('vim'): + elif self.editor.startswith('vim'): command = [f'+{line + 1}'] # columns seem unsupported - elif config.editor.startswith('nano'): + elif self.editor.startswith('nano'): command = [f'+{line + 1},{column + 1}'] # Windows editors - elif config.editor.lower().endswith('notepad++.exe'): + elif self.editor.lower().endswith('notepad++.exe'): command = [f'-n{line + 1}'] # seems not to support columns else: command = []
- # See T102465 for problems relating to using config.editor unparsed. - command = [config.editor] + command + [file_name] - pywikibot.log(f'Running editor: {TextEditor._concat(command)}') + # See T102465 for problems relating to using self.editor unparsed. + command = [self.editor] + command + [file_name] + pywikibot.log(f'Running editor: {self._concat(command)}') return command
@staticmethod @@ -83,39 +105,93 @@ :return: the modified text, or None if the user didn't save the text file in his text editor """ - if config.editor: - handle, tempFilename = tempfile.mkstemp() - tempFilename = '{}.{}'.format(tempFilename, - config.editor_filename_extension) - try: - with codecs.open(tempFilename, 'w', - encoding=config.editor_encoding) as tempFile: - tempFile.write(text) - creationDate = os.stat(tempFilename).st_mtime - cmd = self._command(tempFilename, text, jumpIndex) - subprocess.run(cmd, shell=platform == 'win32', check=True) - lastChangeDate = os.stat(tempFilename).st_mtime - if lastChangeDate == creationDate: - # Nothing changed - return None + if self.editor: + handle, filename = tempfile.mkstemp( + suffix=f'.{config.editor_filename_extension}', text=True) + path = Path(filename)
- with codecs.open(tempFilename, 'r', - encoding=config.editor_encoding) as temp_file: - newcontent = temp_file.read() - return newcontent + try: + encoding = config.editor_encoding + path.write_text(text, encoding=encoding) + + creation_date = path.stat().st_mtime + cmd = self._command(filename, text, jumpIndex) + subprocess.run(cmd, shell=platform == 'win32', check=True) + last_change_date = path.stat().st_mtime + + if last_change_date == creation_date: + return None # Nothing changed + + return path.read_text(encoding=encoding) + finally: os.close(handle) - os.unlink(tempFilename) + os.unlink(path)
if GUI_ERROR: raise ImportError(fill( - 'Could not load GUI modules: {}. No editor available. ' - 'Set your favourite editor in user-config.py "editor", ' - 'or install python packages tkinter and idlelib, which ' - 'are typically part of Python but may be packaged separately ' - 'on your platform.'.format(GUI_ERROR)) + '\n') + f'Could not load GUI modules: {GUI_ERROR}. No editor' + ' available. Set your favourite editor in user-config.py' + ' "editor", or install python packages tkinter and idlelib,' + ' which are typically part of Python but may be packaged' + ' separately on your platform.') + '\n')
assert pywikibot.ui is not None - return pywikibot.ui.editText(text, jumpIndex=jumpIndex, highlight=highlight) + + @staticmethod + def _win32_extension_command(extension: str) -> Optional[str]: + """Get the command from the Win32 registry for an extension.""" + fileexts_key = \ + r'Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts' + key_name = fr'{fileexts_key}.{extension}\OpenWithProgids' + try: + key1 = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_name) + _prog_id = winreg.EnumValue(key1, 0)[0] + _key2 = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, + fr'{_prog_id}\shell\open\command') + _cmd = winreg.QueryValueEx(_key2, '')[0] + # See T102465 for issues relating to using this value. + cmd = _cmd + if cmd.find('%1'): + cmd = cmd[:cmd.find('%1')] + # Remove any trailing character, which should be a quote or + # space and then remove all whitespace. + return cmd[:-1].strip() + except OSError as e: + # Catch any key lookup errors + pywikibot.info(f'Unable to detect program for file extension ' + f'{extension!r}: {e!r}') + return None + + @staticmethod + def _detect_win32_editor() -> str: + """Detect the best Win32 editor.""" + # Notepad is even worse than our Tkinter editor. + unusable_exes = ['notepad.exe', + 'py.exe', + 'pyw.exe', + 'python.exe', + 'pythonw.exe'] + + for ext in ['py', 'txt']: + editor = TextEditor._win32_extension_command(ext) + if editor: + for unusable in unusable_exes: + if unusable in editor.lower(): + break + else: + if set(editor) & set('\a\b\f\n\r\t\v'): + # single character string literals from + # https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-l... + # encode('unicode-escape') also changes Unicode + # characters + pywikibot.warning(fill( + 'The editor path contains probably invalid ' + 'escaped characters. Make sure to use a ' + 'raw-string (r"..." or r'...'), forward slashes ' + 'as a path delimiter or to escape the normal path ' + 'delimiter.')) + return editor + return '' diff --git a/pywikibot/scripts/generate_user_files.py b/pywikibot/scripts/generate_user_files.py index 101a066..ab2005c 100755 --- a/pywikibot/scripts/generate_user_files.py +++ b/pywikibot/scripts/generate_user_files.py @@ -5,6 +5,7 @@ moved to pywikibot.scripts folder. .. versionchanged:: 8.0 let user the choice which section to be copied. +...Also EXTERNAL EDITOR SETTINGS section can be copied. """ # # (C) Pywikibot team, 2010-2022 @@ -30,7 +31,6 @@ # DISABLED_SECTIONS cannot be copied; variables must be set manually DISABLED_SECTIONS = { 'USER INTERFACE SETTINGS', # uses sys - 'EXTERNAL EDITOR SETTINGS', # uses os } OBSOLETE_SECTIONS = { 'ACCOUNT SETTINGS', # already set