"""Text editor class for your favourite editor.
.. note:: This module uses :mod:`userinterfaces.gui` and has depedencies
from other partially external modules.
"""
#
# (C) Pywikibot team, 2004-2023
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
from sys import platform
from textwrap import fill
import pywikibot
from pywikibot import config
from pywikibot.backports import Sequence
try:
from pywikibot.userinterfaces import gui # noqa: F401
GUI_ERROR = None
except ImportError as e:
GUI_ERROR = e
OSWIN32 = platform == 'win32'
if OSWIN32:
import winreg
[docs]
class TextEditor:
"""Text editor.
.. 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: int | None = None) -> list[str]:
"""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.
# TODO: Find a better solution than hardcoding these, e.g. a config
# option.
line = text[:jump_index].count('\n')
column = jump_index - (text[:jump_index].rfind('\n') + 1)
else:
line = column = 0
# Linux editors. We use startswith() because some users might use
# parameters.
if self.editor.startswith('kate'):
command = ['-l', str(line + 1), '-c', str(column + 1)]
elif self.editor.startswith(('gedit', 'emacs')):
command = [f'+{line + 1}'] # columns seem unsupported
elif self.editor.startswith('jedit'):
command = [f'+line:{line + 1}'] # columns seem unsupported
elif self.editor.startswith('vim'):
command = [f'+{line + 1}'] # columns seem unsupported
elif self.editor.startswith('nano'):
command = [f'+{line + 1},{column + 1}']
# Windows editors
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 self.editor unparsed.
editor_cmd = [self.editor] if OSWIN32 else shlex.split(self.editor)
command = editor_cmd + command + [file_name]
pywikibot.log(f'Running editor: {self._concat(command)}')
return command
@staticmethod
def _concat(command: Sequence[str]) -> str:
return ' '.join(f'{part!r}' if ' ' in part else part
for part in command)
[docs]
def edit(self, text: str, jumpIndex: int | None = None,
highlight: str | None = None) -> str | None:
"""
Call the editor and thus allows the user to change the text.
Halts the thread's operation until the editor is closed.
:param text: the text to be edited
:param jumpIndex: position at which to put the caret
:param highlight: each occurrence of this substring will be highlighted
:return: the modified text, or None if the user didn't save the text
file in his text editor
"""
if self.editor:
handle, filename = tempfile.mkstemp(
suffix=f'.{config.editor_filename_extension}', text=True)
path = Path(filename)
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(path)
if GUI_ERROR:
raise ImportError(fill(
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) -> str | None:
"""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-literals
# 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 ''