在Python中嵌入低性能脚本语言

24 投票
8 回答
4058 浏览
提问于 2025-04-16 12:26

我有一个网页应用程序。在这个应用中,我希望用户能够编写(或者复制粘贴)非常简单的脚本来处理他们的数据。

这些脚本可以非常简单,性能问题并不是最重要的。举个例子,我想要的脚本可能像这样:

ratio = 1.2345678
minimum = 10

def convert(money)
    return money * ratio
end

if price < minimum
    cost = convert(minimum)
else
    cost = convert(price)
end

其中,价格和成本是全局变量(我可以把它们放入环境中,并在计算后访问)。

不过,我需要确保一些事情。

  1. 任何运行的脚本都不能访问Python的环境。它们不能导入模块,调用我没有明确提供的方法,读写文件,创建线程等等。我需要完全封锁。

  2. 我需要能够对脚本运行的“周期”数量设置一个硬性限制。这里的周期是个通用术语。可能是虚拟机指令,如果语言是字节编译的;也可能是Eval/Apply循环的调用次数;或者只是一些中央处理循环的迭代,这个循环负责运行脚本。具体细节不是最重要的,我需要的是能在短时间后停止某个脚本的运行,并给脚本的拥有者发个邮件,告诉他们“你的脚本似乎不仅仅是在加几个数字 - 请处理一下。”

  3. 它必须在原版的、没有打补丁的CPython上运行。

到目前为止,我一直在为这个任务编写自己的领域特定语言(DSL)。我能做到这一点。但我在想,是否可以借鉴一些已有的优秀成果。有没有适合Python的迷你语言可以做到这一点?

有很多黑客风格的Lisp变种(甚至还有我在Github上写的一个),但我更希望能找到一些语法更简单的东西(比如更像C或Pascal)。因为我在考虑把这个作为自己编写的替代方案,所以我希望能找到一些更成熟的东西。

有什么想法吗?

8 个回答

5

试试Lua吧。你提到的语法跟Lua的几乎一模一样。可以看看这个链接:我该如何把Lua嵌入到Python 3.x中?

8

Jispy 是个很合适的选择!

  • 它是一个用Python写的JavaScript解释器,主要是为了在Python中嵌入JS代码。

  • 特别的是,它对递归和循环进行了检查和限制,正好满足需求。

  • 它可以很方便地让你在JavaScript代码中使用Python函数。

  • 默认情况下,它不会暴露主机的文件系统或其他敏感信息。

完全透明:

  • Jispy是我的项目,我当然会偏向它。
  • 不过,在这里,它确实看起来是个很合适的选择。

附言:

  • 这个回答是在问题提出大约3年后写的。
  • 这么晚才回答的原因很简单:
    考虑到Jispy与这个问题的相关性,未来有类似需求的读者应该能从中受益。
18

这是我对这个问题的看法。要求用户的脚本在普通的CPython环境中运行,意味着你要么需要为你的迷你语言写一个解释器,要么将其编译成Python字节码(或者直接使用Python作为源语言),然后在执行之前对字节码进行“清理”。

我这里给出了一个快速的例子,假设用户可以用Python编写他们的脚本,并且通过某种方式过滤掉不安全的语法,或者从字节码中移除不安全的操作码,来确保源代码和字节码是安全的。

解决方案的第二部分要求用户脚本的字节码定期被一个监控任务中断,这样可以确保用户脚本不会超过某个操作码的限制,并且所有这些都要在普通的CPython上运行。

我尝试的总结,主要集中在问题的第二部分。

  • 用户脚本用Python编写。
  • 使用byteplay来过滤和修改字节码。
  • 在用户的字节码中插入一个操作码计数器,并调用一个函数来切换到监控任务。
  • 使用greenlet来执行用户的字节码,通过切换在用户脚本和监控协程之间进行。
  • 监控任务强制执行一个预设的操作码执行限制,超过这个限制就会抛出错误。

希望这至少朝着正确的方向前进。我很想听听你在找到解决方案后更多的想法。

以下是lowperf.py的源代码:

# std
import ast
import dis
import sys
from pprint import pprint

# vendor
import byteplay
import greenlet

# bytecode snippet to increment our global opcode counter
INCREMENT = [
    (byteplay.LOAD_GLOBAL, '__op_counter'),
    (byteplay.LOAD_CONST, 1),
    (byteplay.INPLACE_ADD, None),
    (byteplay.STORE_GLOBAL, '__op_counter')
    ]

# bytecode snippet to perform a yield to our watchdog tasklet.
YIELD = [
    (byteplay.LOAD_GLOBAL, '__yield'),
    (byteplay.LOAD_GLOBAL, '__op_counter'),
    (byteplay.CALL_FUNCTION, 1),
    (byteplay.POP_TOP, None)
    ]

def instrument(orig):
    """
    Instrument bytecode.  We place a call to our yield function before
    jumps and returns.  You could choose alternate places depending on 
    your use case.
    """
    line_count = 0
    res = []
    for op, arg in orig.code:
        line_count += 1

        # NOTE: you could put an advanced bytecode filter here.

        # whenever a code block is loaded we must instrument it
        if op == byteplay.LOAD_CONST and isinstance(arg, byteplay.Code):
            code = instrument(arg)
            res.append((op, code))
            continue

        # 'setlineno' opcode is a safe place to increment our global 
        # opcode counter.
        if op == byteplay.SetLineno:
            res += INCREMENT
            line_count += 1

        # append the opcode and its argument
        res.append((op, arg))

        # if we're at a jump or return, or we've processed 10 lines of
        # source code, insert a call to our yield function.  you could 
        # choose other places to yield more appropriate for your app.
        if op in (byteplay.JUMP_ABSOLUTE, byteplay.RETURN_VALUE) \
                or line_count > 10:
            res += YIELD
            line_count = 0

    # finally, build and return new code object
    return byteplay.Code(res, orig.freevars, orig.args, orig.varargs,
        orig.varkwargs, orig.newlocals, orig.name, orig.filename,
        orig.firstlineno, orig.docstring)

def transform(path):
    """
    Transform the Python source into a form safe to execute and return
    the bytecode.
    """
    # NOTE: you could call ast.parse(data, path) here to get an
    # abstract syntax tree, then filter that tree down before compiling
    # it into bytecode.  i've skipped that step as it is pretty verbose.
    data = open(path, 'rb').read()
    suite = compile(data, path, 'exec')
    orig = byteplay.Code.from_code(suite)
    return instrument(orig)

def execute(path, limit = 40):
    """
    This transforms the user's source code into bytecode, instrumenting
    it, then kicks off the watchdog and user script tasklets.
    """
    code = transform(path)
    target = greenlet.greenlet(run_task)

    def watcher_task(op_count):
        """
        Task which is yielded to by the user script, making sure it doesn't
        use too many resources.
        """
        while 1:
            if op_count > limit:
                raise RuntimeError("script used too many resources")
            op_count = target.switch()

    watcher = greenlet.greenlet(watcher_task)
    target.switch(code, watcher.switch)

def run_task(code, yield_func):
    "This is the greenlet task which runs our user's script."
    globals_ = {'__yield': yield_func, '__op_counter': 0}
    eval(code.to_code(), globals_, globals_)

execute(sys.argv[1])

这是一个示例用户脚本user.py

def otherfunc(b):
    return b * 7

def myfunc(a):
    for i in range(0, 20):
        print i, otherfunc(i + a + 3)

myfunc(2)

这是一个示例运行结果:

% python lowperf.py user.py

0 35
1 42
2 49
3 56
4 63
5 70
6 77
7 84
8 91
9 98
10 105
11 112
Traceback (most recent call last):
  File "lowperf.py", line 114, in <module>
    execute(sys.argv[1])
  File "lowperf.py", line 105, in execute
    target.switch(code, watcher.switch)
  File "lowperf.py", line 101, in watcher_task
    raise RuntimeError("script used too many resources")
RuntimeError: script used too many resources

撰写回答