summaryrefslogtreecommitdiffstats
path: root/plugins/python/python.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/python/python.py')
-rw-r--r--plugins/python/python.py554
1 files changed, 554 insertions, 0 deletions
diff --git a/plugins/python/python.py b/plugins/python/python.py
new file mode 100644
index 00000000..30694802
--- /dev/null
+++ b/plugins/python/python.py
@@ -0,0 +1,554 @@
+from __future__ import print_function
+
+import importlib
+import os
+import pydoc
+import signal
+import sys
+import traceback
+import weakref
+from contextlib import contextmanager
+
+from _hexchat_embedded import ffi, lib
+
+if sys.version_info < (3, 0):
+ from io import BytesIO as HelpEater
+else:
+ from io import StringIO as HelpEater
+
+if not hasattr(sys, 'argv'):
+ sys.argv = ['<hexchat>']
+
+VERSION = b'2.0' # Sync with hexchat.__version__
+PLUGIN_NAME = ffi.new('char[]', b'Python')
+PLUGIN_DESC = ffi.new('char[]', b'Python %d.%d scripting interface' % (sys.version_info[0], sys.version_info[1]))
+PLUGIN_VERSION = ffi.new('char[]', VERSION)
+
+# TODO: Constants should be screaming snake case
+hexchat = None
+local_interp = None
+hexchat_stdout = None
+plugins = set()
+
+
+@contextmanager
+def redirected_stdout():
+ sys.stdout = sys.__stdout__
+ sys.stderr = sys.__stderr__
+ yield
+ sys.stdout = hexchat_stdout
+ sys.stderr = hexchat_stdout
+
+
+if os.getenv('HEXCHAT_LOG_PYTHON'):
+ def log(*args):
+ with redirected_stdout():
+ print(*args)
+
+else:
+ def log(*args):
+ pass
+
+
+class Stdout:
+ def __init__(self):
+ self.buffer = bytearray()
+
+ def write(self, string):
+ string = string.encode()
+ idx = string.rfind(b'\n')
+ if idx != -1:
+ self.buffer += string[:idx]
+ lib.hexchat_print(lib.ph, bytes(self.buffer))
+ self.buffer = bytearray(string[idx + 1:])
+ else:
+ self.buffer += string
+
+ def isatty(self):
+ return False
+
+
+class Attribute:
+ def __init__(self):
+ self.time = 0
+
+ def __repr__(self):
+ return '<Attribute object at {}>'.format(id(self))
+
+
+class Hook:
+ def __init__(self, plugin, callback, userdata, is_unload):
+ self.is_unload = is_unload
+ self.plugin = weakref.proxy(plugin)
+ self.callback = callback
+ self.userdata = userdata
+ self.hexchat_hook = None
+ self.handle = ffi.new_handle(weakref.proxy(self))
+
+ def __del__(self):
+ log('Removing hook', id(self))
+ if self.is_unload is False:
+ assert self.hexchat_hook is not None
+ lib.hexchat_unhook(lib.ph, self.hexchat_hook)
+
+
+if sys.version_info[0] == 2:
+ def compile_file(data, filename):
+ return compile(data, filename, 'exec', dont_inherit=True)
+
+
+ def compile_line(string):
+ try:
+ return compile(string, '<string>', 'eval', dont_inherit=True)
+
+ except SyntaxError:
+ # For some reason `print` is invalid for eval
+ # This will hide any return value though
+ return compile(string, '<string>', 'exec', dont_inherit=True)
+else:
+ def compile_file(data, filename):
+ return compile(data, filename, 'exec', optimize=2, dont_inherit=True)
+
+
+ def compile_line(string):
+ # newline appended to solve unexpected EOF issues
+ return compile(string + '\n', '<string>', 'single', optimize=2, dont_inherit=True)
+
+
+class Plugin:
+ def __init__(self):
+ self.ph = None
+ self.name = ''
+ self.filename = ''
+ self.version = ''
+ self.description = ''
+ self.hooks = set()
+ self.globals = {
+ '__plugin': weakref.proxy(self),
+ '__name__': '__main__',
+ }
+
+ def add_hook(self, callback, userdata, is_unload=False):
+ hook = Hook(self, callback, userdata, is_unload=is_unload)
+ self.hooks.add(hook)
+ return hook
+
+ def remove_hook(self, hook):
+ for h in self.hooks:
+ if id(h) == hook:
+ ud = h.userdata
+ self.hooks.remove(h)
+ return ud
+
+ log('Hook not found')
+ return None
+
+ def loadfile(self, filename):
+ try:
+ self.filename = filename
+ with open(filename) as f:
+ data = f.read()
+ compiled = compile_file(data, filename)
+ exec(compiled, self.globals)
+
+ try:
+ self.name = self.globals['__module_name__']
+
+ except KeyError:
+ lib.hexchat_print(lib.ph, b'Failed to load module: __module_name__ must be set')
+
+ return False
+
+ self.version = self.globals.get('__module_version__', '')
+ self.description = self.globals.get('__module_description__', '')
+ self.ph = lib.hexchat_plugingui_add(lib.ph, filename.encode(), self.name.encode(),
+ self.description.encode(), self.version.encode(), ffi.NULL)
+
+ except Exception as e:
+ lib.hexchat_print(lib.ph, 'Failed to load module: {}'.format(e).encode())
+ traceback.print_exc()
+ return False
+
+ return True
+
+ def __del__(self):
+ log('unloading', self.filename)
+ for hook in self.hooks:
+ if hook.is_unload is True:
+ try:
+ hook.callback(hook.userdata)
+
+ except Exception as e:
+ log('Failed to run hook:', e)
+ traceback.print_exc()
+
+ del self.hooks
+ if self.ph is not None:
+ lib.hexchat_plugingui_remove(lib.ph, self.ph)
+
+
+if sys.version_info[0] == 2:
+ def __decode(string):
+ return string
+
+else:
+ def __decode(string):
+ return string.decode()
+
+
+# There can be empty entries between non-empty ones so find the actual last value
+def wordlist_len(words):
+ for i in range(31, 1, -1):
+ if ffi.string(words[i]):
+ return i
+
+ return 0
+
+
+def create_wordlist(words):
+ size = wordlist_len(words)
+ return [__decode(ffi.string(words[i])) for i in range(1, size + 1)]
+
+
+# This function only exists for compat reasons with the C plugin
+# It turns the word list from print hooks into a word_eol list
+# This makes no sense to do...
+def create_wordeollist(words):
+ words = reversed(words)
+ accum = None
+ ret = []
+ for word in words:
+ if accum is None:
+ accum = word
+
+ elif word:
+ last = accum
+ accum = ' '.join((word, last))
+
+ ret.insert(0, accum)
+
+ return ret
+
+
+def to_cb_ret(value):
+ if value is None:
+ return 0
+
+ return int(value)
+
+
+@ffi.def_extern()
+def _on_command_hook(word, word_eol, userdata):
+ hook = ffi.from_handle(userdata)
+ word = create_wordlist(word)
+ word_eol = create_wordlist(word_eol)
+ return to_cb_ret(hook.callback(word, word_eol, hook.userdata))
+
+
+@ffi.def_extern()
+def _on_print_hook(word, userdata):
+ hook = ffi.from_handle(userdata)
+ word = create_wordlist(word)
+ word_eol = create_wordeollist(word)
+ return to_cb_ret(hook.callback(word, word_eol, hook.userdata))
+
+
+@ffi.def_extern()
+def _on_print_attrs_hook(word, attrs, userdata):
+ hook = ffi.from_handle(userdata)
+ word = create_wordlist(word)
+ word_eol = create_wordeollist(word)
+ attr = Attribute()
+ attr.time = attrs.server_time_utc
+ return to_cb_ret(hook.callback(word, word_eol, hook.userdata, attr))
+
+
+@ffi.def_extern()
+def _on_server_hook(word, word_eol, userdata):
+ hook = ffi.from_handle(userdata)
+ word = create_wordlist(word)
+ word_eol = create_wordlist(word_eol)
+ return to_cb_ret(hook.callback(word, word_eol, hook.userdata))
+
+
+@ffi.def_extern()
+def _on_server_attrs_hook(word, word_eol, attrs, userdata):
+ hook = ffi.from_handle(userdata)
+ word = create_wordlist(word)
+ word_eol = create_wordlist(word_eol)
+ attr = Attribute()
+ attr.time = attrs.server_time_utc
+ return to_cb_ret(hook.callback(word, word_eol, hook.userdata, attr))
+
+
+@ffi.def_extern()
+def _on_timer_hook(userdata):
+ hook = ffi.from_handle(userdata)
+ if hook.callback(hook.userdata) is True:
+ return 1
+
+ hook.is_unload = True # Don't unhook
+ for h in hook.plugin.hooks:
+ if h == hook:
+ hook.plugin.hooks.remove(h)
+ break
+
+ return 0
+
+
+@ffi.def_extern(error=3)
+def _on_say_command(word, word_eol, userdata):
+ channel = ffi.string(lib.hexchat_get_info(lib.ph, b'channel'))
+ if channel == b'>>python<<':
+ python = ffi.string(word_eol[1])
+ lib.hexchat_print(lib.ph, b'>>> ' + python)
+ exec_in_interp(__decode(python))
+ return 1
+
+ return 0
+
+
+def load_filename(filename):
+ filename = os.path.expanduser(filename)
+ if not os.path.isabs(filename):
+ configdir = __decode(ffi.string(lib.hexchat_get_info(lib.ph, b'configdir')))
+
+ filename = os.path.join(configdir, 'addons', filename)
+
+ if filename and not any(plugin.filename == filename for plugin in plugins):
+ plugin = Plugin()
+ if plugin.loadfile(filename):
+ plugins.add(plugin)
+ return True
+
+ return False
+
+
+def unload_name(name):
+ if name:
+ for plugin in plugins:
+ if name in (plugin.name, plugin.filename, os.path.basename(plugin.filename)):
+ plugins.remove(plugin)
+ return True
+
+ return False
+
+
+def reload_name(name):
+ if name:
+ for plugin in plugins:
+ if name in (plugin.name, plugin.filename, os.path.basename(plugin.filename)):
+ filename = plugin.filename
+ plugins.remove(plugin)
+ return load_filename(filename)
+
+ return False
+
+
+@contextmanager
+def change_cwd(path):
+ old_cwd = os.getcwd()
+ os.chdir(path)
+ yield
+ os.chdir(old_cwd)
+
+
+def autoload():
+ configdir = __decode(ffi.string(lib.hexchat_get_info(lib.ph, b'configdir')))
+ addondir = os.path.join(configdir, 'addons')
+ try:
+ with change_cwd(addondir): # Maintaining old behavior
+ for f in os.listdir(addondir):
+ if f.endswith('.py'):
+ log('Autoloading', f)
+ # TODO: Set cwd
+ load_filename(os.path.join(addondir, f))
+
+ except FileNotFoundError as e:
+ log('Autoload failed', e)
+
+
+def list_plugins():
+ if not plugins:
+ lib.hexchat_print(lib.ph, b'No python modules loaded')
+ return
+
+ tbl_headers = [b'Name', b'Version', b'Filename', b'Description']
+ tbl = [
+ tbl_headers,
+ [(b'-' * len(s)) for s in tbl_headers]
+ ]
+
+ for plugin in plugins:
+ basename = os.path.basename(plugin.filename).encode()
+ name = plugin.name.encode()
+ version = plugin.version.encode() if plugin.version else b'<none>'
+ description = plugin.description.encode() if plugin.description else b'<none>'
+ tbl.append((name, version, basename, description))
+
+ column_sizes = [
+ max(len(item) for item in column)
+ for column in zip(*tbl)
+ ]
+
+ for row in tbl:
+ lib.hexchat_print(lib.ph, b' '.join(item.ljust(column_sizes[i])
+ for i, item in enumerate(row)))
+ lib.hexchat_print(lib.ph, b'')
+
+
+def exec_in_interp(python):
+ global local_interp
+
+ if not python:
+ return
+
+ if local_interp is None:
+ local_interp = Plugin()
+ local_interp.locals = {}
+ local_interp.globals['hexchat'] = hexchat
+
+ code = compile_line(python)
+ try:
+ ret = eval(code, local_interp.globals, local_interp.locals)
+ if ret is not None:
+ lib.hexchat_print(lib.ph, '{}'.format(ret).encode())
+
+ except Exception as e:
+ traceback.print_exc(file=hexchat_stdout)
+
+
+@ffi.def_extern()
+def _on_load_command(word, word_eol, userdata):
+ filename = ffi.string(word[2])
+ if filename.endswith(b'.py'):
+ load_filename(__decode(filename))
+ return 3
+
+ return 0
+
+
+@ffi.def_extern()
+def _on_unload_command(word, word_eol, userdata):
+ filename = ffi.string(word[2])
+ if filename.endswith(b'.py'):
+ unload_name(__decode(filename))
+ return 3
+
+ return 0
+
+
+@ffi.def_extern()
+def _on_reload_command(word, word_eol, userdata):
+ filename = ffi.string(word[2])
+ if filename.endswith(b'.py'):
+ reload_name(__decode(filename))
+ return 3
+
+ return 0
+
+
+@ffi.def_extern(error=3)
+def _on_py_command(word, word_eol, userdata):
+ subcmd = __decode(ffi.string(word[2])).lower()
+
+ if subcmd == 'exec':
+ python = __decode(ffi.string(word_eol[3]))
+ exec_in_interp(python)
+
+ elif subcmd == 'load':
+ filename = __decode(ffi.string(word[3]))
+ load_filename(filename)
+
+ elif subcmd == 'unload':
+ name = __decode(ffi.string(word[3]))
+ if not unload_name(name):
+ lib.hexchat_print(lib.ph, b'Can\'t find a python plugin with that name')
+
+ elif subcmd == 'reload':
+ name = __decode(ffi.string(word[3]))
+ if not reload_name(name):
+ lib.hexchat_print(lib.ph, b'Can\'t find a python plugin with that name')
+
+ elif subcmd == 'console':
+ lib.hexchat_command(lib.ph, b'QUERY >>python<<')
+
+ elif subcmd == 'list':
+ list_plugins()
+
+ elif subcmd == 'about':
+ lib.hexchat_print(lib.ph, b'HexChat Python interface version ' + VERSION)
+
+ else:
+ lib.hexchat_command(lib.ph, b'HELP PY')
+
+ return 3
+
+
+@ffi.def_extern()
+def _on_plugin_init(plugin_name, plugin_desc, plugin_version, arg, libdir):
+ global hexchat
+ global hexchat_stdout
+
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+
+ plugin_name[0] = PLUGIN_NAME
+ plugin_desc[0] = PLUGIN_DESC
+ plugin_version[0] = PLUGIN_VERSION
+
+ try:
+ libdir = __decode(ffi.string(libdir))
+ modpath = os.path.join(libdir, '..', 'python')
+ sys.path.append(os.path.abspath(modpath))
+ hexchat = importlib.import_module('hexchat')
+
+ except (UnicodeDecodeError, ImportError) as e:
+ lib.hexchat_print(lib.ph, b'Failed to import module: ' + repr(e).encode())
+
+ return 0
+
+ hexchat_stdout = Stdout()
+ sys.stdout = hexchat_stdout
+ sys.stderr = hexchat_stdout
+ pydoc.help = pydoc.Helper(HelpEater(), HelpEater())
+
+ lib.hexchat_hook_command(lib.ph, b'', 0, lib._on_say_command, ffi.NULL, ffi.NULL)
+ lib.hexchat_hook_command(lib.ph, b'LOAD', 0, lib._on_load_command, ffi.NULL, ffi.NULL)
+ lib.hexchat_hook_command(lib.ph, b'UNLOAD', 0, lib._on_unload_command, ffi.NULL, ffi.NULL)
+ lib.hexchat_hook_command(lib.ph, b'RELOAD', 0, lib._on_reload_command, ffi.NULL, ffi.NULL)
+ lib.hexchat_hook_command(lib.ph, b'PY', 0, lib._on_py_command, b'''Usage: /PY LOAD <filename>
+ UNLOAD <filename|name>
+ RELOAD <filename|name>
+ LIST
+ EXEC <command>
+ CONSOLE
+ ABOUT''', ffi.NULL)
+
+ lib.hexchat_print(lib.ph, b'Python interface loaded')
+ autoload()
+ return 1
+
+
+@ffi.def_extern()
+def _on_plugin_deinit():
+ global local_interp
+ global hexchat
+ global hexchat_stdout
+ global plugins
+
+ plugins = set()
+ local_interp = None
+ hexchat = None
+ hexchat_stdout = None
+ sys.stdout = sys.__stdout__
+ sys.stderr = sys.__stderr__
+ pydoc.help = pydoc.Helper()
+
+ for mod in ('_hexchat', 'hexchat', 'xchat', '_hexchat_embedded'):
+ try:
+ del sys.modules[mod]
+
+ except KeyError:
+ pass
+
+ return 1