Files
zoitechat/plugins/python/python.py

615 lines
16 KiB
Python
Raw Normal View History

2017-09-02 17:52:25 -04:00
from __future__ import print_function
2018-12-26 20:46:31 +02:00
import importlib
2017-09-02 17:52:25 -04:00
import os
import pydoc
2017-09-02 17:52:25 -04:00
import signal
2018-12-26 20:46:31 +02:00
import sys
2017-09-02 17:52:25 -04:00
import traceback
import weakref
2018-12-26 20:46:31 +02:00
from contextlib import contextmanager
2026-01-05 23:12:38 -07:00
from _zoitechat_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'):
2026-01-05 23:12:38 -07:00
sys.argv = ['<zoitechat>']
2026-02-24 19:30:54 -07:00
VERSION = b'2.18.0~pre2' # Sync with zoitechat.__version__
2017-09-02 17:52:25 -04:00
PLUGIN_NAME = ffi.new('char[]', b'Python')
2018-12-26 20:46:31 +02:00
PLUGIN_DESC = ffi.new('char[]', b'Python %d.%d scripting interface' % (sys.version_info[0], sys.version_info[1]))
2017-09-02 17:52:25 -04:00
PLUGIN_VERSION = ffi.new('char[]', VERSION)
2018-12-26 20:46:31 +02:00
# TODO: Constants should be screaming snake case
2026-01-05 23:12:38 -07:00
zoitechat = None
2017-09-02 17:52:25 -04:00
local_interp = None
2026-01-05 23:12:38 -07:00
zoitechat_stdout = None
2017-09-02 17:52:25 -04:00
plugins = set()
@contextmanager
def redirected_stdout():
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
yield
2026-01-05 23:12:38 -07:00
sys.stdout = zoitechat_stdout
sys.stderr = zoitechat_stdout
2017-09-02 17:52:25 -04:00
2026-01-25 16:13:47 -07:00
if os.getenv('ZOITECHAT_LOG_PYTHON'):
2017-09-02 17:52:25 -04:00
def log(*args):
with redirected_stdout():
print(*args)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
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')
2018-12-26 20:46:31 +02:00
if idx != -1:
2017-09-02 17:52:25 -04:00
self.buffer += string[:idx]
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, bytes(self.buffer))
2017-09-02 17:52:25 -04:00
self.buffer = bytearray(string[idx + 1:])
else:
self.buffer += string
def flush(self):
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, bytes(self.buffer))
self.buffer = bytearray()
2017-09-02 17:52:25 -04:00
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
2026-01-05 23:12:38 -07:00
self.zoitechat_hook = None
2017-09-02 17:52:25 -04:00
self.handle = ffi.new_handle(weakref.proxy(self))
def __del__(self):
log('Removing hook', id(self))
if self.is_unload is False:
2026-01-05 23:12:38 -07:00
assert self.zoitechat_hook is not None
lib.zoitechat_unhook(lib.ph, self.zoitechat_hook)
2017-09-02 17:52:25 -04:00
2018-12-26 20:46:31 +02:00
if sys.version_info[0] == 2:
2017-09-02 17:52:25 -04:00
def compile_file(data, filename):
return compile(data, filename, 'exec', dont_inherit=True)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
def compile_line(string):
try:
return compile(string, '<string>', 'eval', dont_inherit=True)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
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)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
def compile_line(string):
# newline appended to solve unexpected EOF issues
return compile(string + '\n', '<string>', 'single', optimize=2, dont_inherit=True)
2017-09-02 17:52:25 -04:00
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
2017-09-02 17:52:25 -04:00
self.hooks.remove(h)
return ud
2018-12-26 20:46:31 +02:00
log('Hook not found')
return None
2017-09-02 17:52:25 -04:00
def loadfile(self, filename):
try:
self.filename = filename
2022-05-07 19:16:11 +03:00
with open(filename, 'rb') as f:
data = f.read().decode('utf-8')
2017-09-02 17:52:25 -04:00
compiled = compile_file(data, filename)
exec(compiled, self.globals)
try:
self.name = self.globals['__module_name__']
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except KeyError:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'Failed to load module: __module_name__ must be set')
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return False
self.version = self.globals.get('__module_version__', '')
self.description = self.globals.get('__module_description__', '')
2026-01-05 23:12:38 -07:00
self.ph = lib.zoitechat_plugingui_add(lib.ph, filename.encode(), self.name.encode(),
2018-12-26 20:46:31 +02:00
self.description.encode(), self.version.encode(), ffi.NULL)
2017-09-02 17:52:25 -04:00
except Exception as e:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, 'Failed to load module: {}'.format(e).encode())
2017-09-02 17:52:25 -04:00
traceback.print_exc()
return False
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
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)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except Exception as e:
log('Failed to run hook:', e)
traceback.print_exc()
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
del self.hooks
if self.ph is not None:
2026-01-05 23:12:38 -07:00
lib.zoitechat_plugingui_remove(lib.ph, self.ph)
2017-09-02 17:52:25 -04:00
2018-12-26 20:46:31 +02:00
if sys.version_info[0] == 2:
2017-09-02 17:52:25 -04:00
def __decode(string):
return string
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
else:
def __decode(string):
return string.decode()
# There can be empty entries between non-empty ones so find the actual last value
2026-01-07 20:33:55 -07:00
def _cstr(ptr):
"""Safely convert a C char* (possibly NULL) to bytes."""
if ptr == ffi.NULL:
return b''
try:
return ffi.string(ptr)
except Exception:
return b''
2017-09-02 17:52:25 -04:00
def wordlist_len(words):
2026-01-07 20:33:55 -07:00
# ZoiteChat passes a fixed-size array (typically 32) where unused entries may be NULL.
for i in range(31, 0, -1):
2026-01-07 20:33:55 -07:00
if _cstr(words[i]):
2017-09-02 17:52:25 -04:00
return i
return 0
def create_wordlist(words):
size = wordlist_len(words)
2026-01-07 20:33:55 -07:00
return [__decode(_cstr(words[i])) for i in range(1, size + 1)]
2017-09-02 17:52:25 -04:00
def create_wordeollist(words):
words = reversed(words)
accum = None
ret = []
for word in words:
if accum is None:
accum = word
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif word:
last = accum
accum = ' '.join((word, last))
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
ret.insert(0, accum)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return ret
def to_cb_ret(value):
if value is None:
return 0
2018-12-26 20:46:31 +02:00
return int(value)
2017-09-02 17:52:25 -04:00
@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) == True:
2017-09-02 17:52:25 -04:00
return 1
2018-12-26 20:46:31 +02:00
try:
2026-01-05 23:12:38 -07:00
# Avoid calling zoitechat_unhook twice if unnecessary
hook.is_unload = True
except ReferenceError:
# hook is a weak reference, it might have been destroyed by the callback
# in which case it has already been removed from hook.plugin.hooks and
# we wouldn't be able to test it with h == hook anyway.
return 0
2018-12-26 20:46:31 +02:00
for h in hook.plugin.hooks:
if h == hook:
hook.plugin.hooks.remove(h)
break
return 0
2017-09-02 17:52:25 -04:00
2026-01-07 20:33:55 -07:00
@ffi.def_extern()
2017-09-02 17:52:25 -04:00
def _on_say_command(word, word_eol, userdata):
2026-01-07 20:33:55 -07:00
"""Handle input in the special >>python<< tab.
This callback is wired via hook_command(b''), so it may be invoked for a wide range
of internal commands. It must never throw, and must default to EAT_NONE.
"""
try:
channel = _cstr(lib.zoitechat_get_info(lib.ph, b'channel'))
except Exception:
return 0
if channel != b'>>python<<':
return 0
try:
python = _cstr(word_eol[1])
except Exception:
python = b''
if not python:
return 1
2018-12-26 20:46:31 +02:00
2026-01-07 20:33:55 -07:00
# Dont let exceptions here swallow core commands or wedge the UI.
try:
exec_in_interp(python)
except Exception:
# Best effort: surface the traceback in the python tab.
exc = traceback.format_exc().encode('utf-8', errors='replace')
lib.zoitechat_print(lib.ph, exc)
return 1
2017-09-02 17:52:25 -04:00
def load_filename(filename):
filename = os.path.expanduser(filename)
if not os.path.isabs(filename):
2026-01-07 20:33:55 -07:00
configdir = __decode(_cstr(lib.zoitechat_get_info(lib.ph, b'configdir')))
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
filename = os.path.join(configdir, 'addons', filename)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
if filename and not any(plugin.filename == filename for plugin in plugins):
plugin = Plugin()
if plugin.loadfile(filename):
plugins.add(plugin)
return True
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return False
def unload_name(name):
if name:
for plugin in plugins:
2018-12-26 20:46:31 +02:00
if name in (plugin.name, plugin.filename, os.path.basename(plugin.filename)):
2017-09-02 17:52:25 -04:00
plugins.remove(plugin)
return True
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return False
def reload_name(name):
if name:
for plugin in plugins:
2018-12-26 20:46:31 +02:00
if name in (plugin.name, plugin.filename, os.path.basename(plugin.filename)):
2017-09-02 17:52:25 -04:00
filename = plugin.filename
plugins.remove(plugin)
return load_filename(filename)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return False
@contextmanager
def change_cwd(path):
old_cwd = os.getcwd()
os.chdir(path)
yield
os.chdir(old_cwd)
def autoload():
2026-01-07 20:33:55 -07:00
configdir = __decode(_cstr(lib.zoitechat_get_info(lib.ph, b'configdir')))
2017-09-02 17:52:25 -04:00
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))
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except FileNotFoundError as e:
log('Autoload failed', e)
def list_plugins():
if not plugins:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'No python modules loaded')
2017-09-02 17:52:25 -04:00
return
tbl_headers = [b'Name', b'Version', b'Filename', b'Description']
tbl = [
tbl_headers,
[(b'-' * len(s)) for s in tbl_headers]
]
2017-09-02 17:52:25 -04:00
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:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b' '.join(item.ljust(column_sizes[i])
for i, item in enumerate(row)))
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'')
2017-09-02 17:52:25 -04:00
def exec_in_interp(python):
global local_interp
if not python:
return
if local_interp is None:
local_interp = Plugin()
local_interp.locals = {}
2026-01-05 23:12:38 -07:00
local_interp.globals['zoitechat'] = zoitechat
2017-09-02 17:52:25 -04:00
code = compile_line(python)
try:
ret = eval(code, local_interp.globals, local_interp.locals)
if ret is not None:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, '{}'.format(ret).encode())
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except Exception as e:
2026-01-05 23:12:38 -07:00
traceback.print_exc(file=zoitechat_stdout)
2017-09-02 17:52:25 -04:00
@ffi.def_extern()
def _on_load_command(word, word_eol, userdata):
2026-01-07 20:33:55 -07:00
filename = _cstr(word[2])
2017-09-02 17:52:25 -04:00
if filename.endswith(b'.py'):
load_filename(__decode(filename))
return 3
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return 0
@ffi.def_extern()
def _on_unload_command(word, word_eol, userdata):
2026-01-07 20:33:55 -07:00
filename = _cstr(word[2])
2017-09-02 17:52:25 -04:00
if filename.endswith(b'.py'):
unload_name(__decode(filename))
return 3
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return 0
@ffi.def_extern()
def _on_reload_command(word, word_eol, userdata):
2026-01-07 20:33:55 -07:00
filename = _cstr(word[2])
2017-09-02 17:52:25 -04:00
if filename.endswith(b'.py'):
reload_name(__decode(filename))
return 3
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
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)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'load':
filename = __decode(ffi.string(word[3]))
load_filename(filename)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'unload':
name = __decode(ffi.string(word[3]))
if not unload_name(name):
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'Can\'t find a python plugin with that name')
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'reload':
name = __decode(ffi.string(word[3]))
if not reload_name(name):
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'Can\'t find a python plugin with that name')
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'console':
2026-01-05 23:12:38 -07:00
lib.zoitechat_command(lib.ph, b'QUERY >>python<<')
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'list':
list_plugins()
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
elif subcmd == 'about':
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'ZoiteChat Python interface version ' + VERSION)
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
else:
2026-01-05 23:12:38 -07:00
lib.zoitechat_command(lib.ph, b'HELP PY')
2017-09-02 17:52:25 -04:00
return 3
@ffi.def_extern()
def _on_plugin_init(plugin_name, plugin_desc, plugin_version, arg, libdir):
2026-01-05 23:12:38 -07:00
global zoitechat
global zoitechat_stdout
2017-09-02 17:52:25 -04:00
signal.signal(signal.SIGINT, signal.SIG_DFL)
plugin_name[0] = PLUGIN_NAME
plugin_desc[0] = PLUGIN_DESC
plugin_version[0] = PLUGIN_VERSION
try:
2026-01-07 20:33:55 -07:00
libdir = __decode(_cstr(libdir))
modpaths = [
os.path.abspath(os.path.join(libdir, '..', 'python')),
os.path.abspath(os.path.join(libdir, 'python')),
]
appdir = os.getenv('APPDIR')
if appdir:
modpaths.extend([
os.path.join(appdir, 'usr', 'lib', 'zoitechat', 'python'),
os.path.join(appdir, 'usr', 'lib', 'x86_64-linux-gnu', 'zoitechat', 'python'),
])
if os.getenv('FLATPAK_ID'):
modpaths.extend([
'/app/lib/zoitechat/python',
'/app/lib/x86_64-linux-gnu/zoitechat/python',
])
for modpath in modpaths:
if os.path.isdir(modpath) and modpath not in sys.path:
sys.path.append(modpath)
2026-01-05 23:12:38 -07:00
zoitechat = importlib.import_module('zoitechat')
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except (UnicodeDecodeError, ImportError) as e:
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'Failed to import module: ' + repr(e).encode())
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
return 0
2026-01-05 23:12:38 -07:00
zoitechat_stdout = Stdout()
sys.stdout = zoitechat_stdout
sys.stderr = zoitechat_stdout
pydoc.help = pydoc.Helper(HelpEater(), HelpEater())
2017-09-02 17:52:25 -04:00
2026-01-05 23:12:38 -07:00
lib.zoitechat_hook_command(lib.ph, b'', 0, lib._on_say_command, ffi.NULL, ffi.NULL)
lib.zoitechat_hook_command(lib.ph, b'LOAD', 0, lib._on_load_command, ffi.NULL, ffi.NULL)
lib.zoitechat_hook_command(lib.ph, b'UNLOAD', 0, lib._on_unload_command, ffi.NULL, ffi.NULL)
lib.zoitechat_hook_command(lib.ph, b'RELOAD', 0, lib._on_reload_command, ffi.NULL, ffi.NULL)
lib.zoitechat_hook_command(lib.ph, b'PY', 0, lib._on_py_command, b'''Usage: /PY LOAD <filename>
2017-09-02 17:52:25 -04:00
UNLOAD <filename|name>
RELOAD <filename|name>
LIST
EXEC <command>
CONSOLE
ABOUT''', ffi.NULL)
2026-01-05 23:12:38 -07:00
lib.zoitechat_print(lib.ph, b'Python interface loaded')
2017-09-02 17:52:25 -04:00
autoload()
return 1
@ffi.def_extern()
def _on_plugin_deinit():
global local_interp
2026-01-05 23:12:38 -07:00
global zoitechat
global zoitechat_stdout
2017-09-02 17:52:25 -04:00
global plugins
plugins = set()
local_interp = None
2026-01-05 23:12:38 -07:00
zoitechat = None
zoitechat_stdout = None
2017-09-02 17:52:25 -04:00
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
pydoc.help = pydoc.Helper()
2017-09-02 17:52:25 -04:00
2026-01-05 23:12:38 -07:00
for mod in ('_zoitechat', 'zoitechat', 'xchat', '_zoitechat_embedded'):
2017-09-02 17:52:25 -04:00
try:
del sys.modules[mod]
2018-12-26 20:46:31 +02:00
2017-09-02 17:52:25 -04:00
except KeyError:
pass
return 1