Parcourir la source

:building_construction: add s3 support for lily

Jeremy Zheng il y a 2 ans
Parent
commit
06d83836a8

+ 4 - 3
rpc/lily/.gitignore

@@ -1,4 +1,5 @@
-__pycache__/
-/tmp/
-/config.toml
 /python/
+/config.toml
+
+*.tar
+*.md5

+ 14 - 14
rpc/lily/Dockerfile

@@ -5,6 +5,12 @@ ENV DEBIAN_FRONTEND noninteractive
 
 RUN apt update
 RUN apt -y upgrade
+
+RUN apt install -y software-properties-common
+ENV PYTHON_VERSION "3.12"
+# https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa
+RUN add-apt-repository -y ppa:deadsnakes/ppa
+RUN apt update
 RUN apt -y install build-essential \
     imagemagick ffmpeg fonts-dejavu-extra texlive-full pandoc \
     fonts-arphic-ukai fonts-arphic-uming \
@@ -14,20 +20,13 @@ RUN apt -y install build-essential \
     fonts-cns11643-kai fonts-cns11643-sung \
     fonts-moe-standard-kai fonts-moe-standard-song \
     fonts-ipafont-nonfree-jisx0208 \
-    python3-full python3-dev 
+    python${PYTHON_VERSION}-full python${PYTHON_VERSION}-dev \
+    libpq5
+RUN apt -y clean
 
-# https://getcomposer.org/download/
-RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/76a7060ccb93902cd7576b67264ad91c8a2700e2/web/installer -O - -q | php -- --quiet --install-dir=/usr/local/bin --filename=composer
-
-RUN useradd -s /bin/bash -m deploy
-RUN passwd -l deploy
-RUN echo 'deploy ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/101-deploy
 RUN mkdir /opt/lily
-RUN chown deploy:deploy /opt/lily
-USER deploy
-
 # https://pip.pypa.io/en/stable/installation/
-RUN bash -c "python3 -m venv $HOME/python3 \
+RUN bash -c "python${PYTHON_VERSION} -m venv $HOME/python3 \
     && . $HOME/python3/bin/activate \
     && pip install --upgrade pip \
     && pip install cmake \
@@ -38,9 +37,10 @@ RUN bash -c "python3 -m venv $HOME/python3 \
 RUN echo 'source $HOME/python3/bin/activate' >> $HOME/.bashrc
 
 COPY lily /opt/lily/
+COPY config.toml /etc/lily.toml
 
-RUN echo "$(date -u +%4Y%m%d%H%M%S)" | sudo tee /VERSION
+RUN echo "$(date -u +%4Y%m%d%H%M%S)" | tee /VERSION
 
-WORKDIR /opt/morus
+WORKDIR /opt/lily
 
-CMD ["/bin/bash"]
+CMD ["/bin/bash", "-l"]

+ 10 - 0
rpc/lily/README.md

@@ -0,0 +1,10 @@
+# Usage
+
+```bash
+# show help
+./start.sh -h
+# start a tex2pdf worker
+./start.sh -c /etc/lily.toml --worker
+# start a rpc server
+./start.sh -c /etc/lily.toml
+```

+ 2 - 2
rpc/lily/build.sh

@@ -3,12 +3,12 @@
 set -e
 
 export VERSION=$(date "+%4Y%m%d%H%M%S")
-export CODE="mint-lily"
+export CODE="palm-lily"
 
 buildah pull ubuntu:latest
 buildah bud --layers -t $CODE .
 podman save --format=oci-archive -o $CODE-$VERSION.tar $CODE
-md5sum $CODE-$VERSION.tar > md5.txt
+md5sum $CODE-$VERSION.tar > $CODE-$VERSION.md5
 
 echo "done($CODE-$VERSION.tar)."
 

+ 0 - 69
rpc/lily/client.php

@@ -1,69 +0,0 @@
-<?php
-
-require dirname(__FILE__) . '/vendor/autoload.php';
-
-function tex2pdf($host, $request)
-{
-    $client = new \Palm\Lily\V1\TexClient($host, [
-        'credentials' => Grpc\ChannelCredentials::createInsecure(),
-    ]);
-
-    list($response, $status) = $client->ToPdf($request)->wait();
-    if ($status->code !== Grpc\STATUS_OK) {
-        echo "ERROR: " . $status->code . ", " . $status->details . PHP_EOL;
-        exit(1);
-    }
-    echo $response->getContentType() . '(' . strlen($response->getPayload()) . ' bytes)' . PHP_EOL;
-}
-
-$request = new \Palm\Lily\V1\TexToRequest();
-
-$request->getFiles()['main.tex'] = <<<'EOF'
-% 导言区
-\documentclass[a4paper, 12pt, fontset=ubuntu]{article} % book, report, letter
-\usepackage{ctex} % Use chinese package
-
-\title{\heiti 一级标题}
-\author{\kaishu 半闲}
-\date{\today}
-
-% 正文区
-
-\begin{document}
-    \maketitle % 头部信息在正文显示
-    \tableofcontents % 显示索引列
-
-    \include{section-1.tex}
-    \include{section-2.tex}
-
-\end{document}
-
-EOF;
-
-$request->getFiles()['section-1.tex'] = <<<'EOF'
-\section{章节1 标题}
-章节1 正文
-\subsection{子章节1.1 标题}
-子章节1-1 正文
-
-
-\newline This is another \verb|\newline| .
-
-\par This is a new paragraph.
-
-\newpage This is a new page.
-
-\subsection{子章节1.2 标题}
-子章节1-2 正文
-EOF;
-
-$request->getFiles()['section-2.tex'] = <<<'EOF'
-\section{章节2 标题}
-章节2 正文
-\subsection{子章节2.1 标题}
-子章节2-1 正文
-\subsection{子章节2.2 标题}
-子章节2-2 正文
-EOF;
-
-tex2pdf('192.168.43.100:9000', $request);

+ 18 - 1
rpc/lily/lily.sh

@@ -1,5 +1,22 @@
-#!/bin/sh
+#!/bin/bash
+
+set -e
+
+export PYTHON_HOME=$PWD/python
+
+if [ ! -d $PYTHON_HOME ]
+then
+    python -m venv $PYTHON_HOME
+fi
 
 source $PWD/python/bin/activate
 
+if [ ! -f $PYTHON_HOME/bin/ttx ]
+then
+    pip install psycopg minio redis[hiredis] \
+        pika msgpack matplotlib ebooklib \
+        grpcio protobuf grpcio-health-checking \
+        pandas openpyxl xlrd pyxlsb
+fi
+
 python lily $*

+ 3 - 0
rpc/lily/lily/.gitignore

@@ -0,0 +1,3 @@
+__pycache__/
+/tmp/
+*.toml

+ 7 - 13
rpc/lily/lily/README.md

@@ -5,9 +5,9 @@
 - Install [Python3.11+](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa)
 
   ```bash
-  $ sudo add-apt-repository ppa:deadsnakes/ppa
-  $ sudo apt update
-  $ sudo apt install python3.12-full python3.12-dev
+  sudo add-apt-repository ppa:deadsnakes/ppa
+  sudo apt update
+  sudo apt install python3.12-full python3.12-dev
   ```
 
 - Install libraries
@@ -16,22 +16,15 @@
   $ sudo apt install imagemagick ffmpeg fonts-dejavu-extra texlive-full
   $ python3.12 -m venv $HOME/local/python3
   $ source $HOME/local/python3/bin/activate
-  $ pip install psycopg pika matplotlib ebooklib \
+  $ pip install psycopg minio redis[hiredis] \
+    pika msgpack matplotlib ebooklib \
     grpcio protobuf grpcio-health-checking \
     pandas openpyxl xlrd pyxlsb
   ```
 
 ## Start
 
-- create config.toml
-
-  ```toml
-  [rpc]
-  port = 9999
-  workers = 8
-  ```
-
-- run `python lily -d`
+- run `python lily -d -c config.toml`
 
 ## Documents
 
@@ -39,3 +32,4 @@
 - [https://graphviz.org/](Graphviz)
 - [EbookLib](https://github.com/aerkalov/ebooklib)
 - [Excel files](https://pandas.pydata.org/docs/user_guide/io.html#excel-files)
+- [Data types used by Excel](https://learn.microsoft.com/en-us/office/client-developer/excel/data-types-used-by-excel)

+ 26 - 5
rpc/lily/lily/__main__.py

@@ -2,16 +2,18 @@ import logging
 import argparse
 import sys
 import tomllib
-import pika
 
-from palm import VERSION, start_server
+
+from palm import VERSION,  RedisClient, MinioClient, RabbitMqClient, is_stopped
+from palm.tex import TEX2PDF_QUEUE, create_tex2pdf_queue_callback
+from palm.server import Rpc as RpcServer
 
 NAME = 'lily'
 
 if __name__ == '__main__':
     parser = argparse.ArgumentParser(
         prog=NAME,
-        description='Background worker for palm',
+        description='A tex to pdf/word converter',
         epilog='https://github.com/saturn-xiv/palm')
     parser.add_argument('-c', '--config',
                         type=argparse.FileType(mode='rb'),
@@ -20,6 +22,8 @@ if __name__ == '__main__':
     parser.add_argument('-d', '--debug',
                         action='store_true',
                         help='run on debug mode')
+    parser.add_argument('-w', '--worker',
+                        help='run queue worker %s' % (TEX2PDF_QUEUE))
     parser.add_argument('-v', '--version',
                         action='store_true',
                         help=('print %s version' % NAME))
@@ -30,8 +34,25 @@ if __name__ == '__main__':
     logging.basicConfig(level=(logging.DEBUG if args.debug else logging.INFO))
     if args.debug:
         logging.debug('run on debug mode with %s', args)
+
+    if is_stopped():
+        logging.error('.stop file existed, quit...')
+        sys.exit(1)
+
     logging.info('load configuration from %s', args.config.name)
 
     config = tomllib.load(args.config)
-    rpc_params = config['rpc']
-    start_server('0.0.0.0:%d' % (rpc_params['port']), rpc_params['workers'])
+    redis_client = RedisClient(config['redis'])
+    minio_client = MinioClient(config['minio'])
+    rabbitmq_client = RabbitMqClient(config['rabbitmq'])
+    if args.worker:
+        if args.worker == TEX2PDF_QUEUE:
+            callback = create_tex2pdf_queue_callback(minio_client)
+            rabbitmq_client.start_consuming(TEX2PDF_QUEUE, callback)
+        else:
+            logging.error('unimplemented queue %s', args.worker)
+            sys.exit(1)
+        sys.exit()
+    rpc_server = RpcServer(
+        config['rpc'], minio_client, redis_client, rabbitmq_client)
+    rpc_server.start()

+ 140 - 46
rpc/lily/lily/palm/__init__.py

@@ -1,68 +1,162 @@
-
 import logging
-from time import sleep
-from concurrent import futures
-import threading
+import json
+import uuid
+import os.path
+
+
+from datetime import timedelta
+from datetime import datetime
+
 
 import psycopg
 import pika
-import grpc
-from grpc_health.v1 import health_pb2, health, health_pb2_grpc
+from redis import Redis
+from redis.cluster import RedisCluster
+from minio import Minio
+from minio.versioningconfig import VersioningConfig as MinioVersioningConfig
+from minio.commonconfig import ENABLED as MinioEnabled, Tags as MinioTags
+
+
+VERSION = '2023.11.10'
+
+
+def is_stopped():
+    return os.path.isfile('.stop')
+
+
+class RedisClient:
+    def __init__(self, config):
+        self.namespace = config['namespace']
+        if config["db"]:
+            self.connection = self.connection = Redis(
+                host=config['host'], port=config['port'], db=config['db'])
+            logging.info('connect redis single node tcp://%s:%d/%d with namespace(%s)',
+                         config['host'], config['port'], config['db'], self.namespace)
+        else:
+            self.connection = RedisCluster(
+                host=config['host'], port=config['port'])
+            logging.info('connect redis cluster nodes with namespace(%s) in %s', self.namespace, list(
+                map(lambda x: '%s:%d(%s)' % (x.host, x.port, x.server_type), self.connection.get_nodes())))
+        self.connection.ping()
+
+    def set(self, key, val, ttl=0):
+        self.connection.setex(self._key(key), timedelta(seconds=ttl), val)
 
-from . import lily_pb2_grpc, excel, tex
+    def get(self, key):
+        return self.connection.get(self._key(key))
 
+    def _key(self, k):
+        return '%s://%s' % (self.namespace, k)
 
-VERSION = '2023.9.29'
 
+class MinioClient:
+    def __init__(self, config):
+        logging.debug("connect to minio %s", config['endpoint'])
+        self.namespace = config['namespace']
+        self.connection = Minio(
+            config['endpoint'],
+            access_key=config['access-key'],
+            secret_key=config['secret-key'],
+            secure=config['secure'])
+        logging.debug('found buckets: %s', self.list_buckets())
 
-def _health_checker(servicer, name):
-    while True:
-        servicer.set(name, health_pb2.HealthCheckResponse.SERVING)
-        sleep(5)
+    def put_object(self, bucket, name, data, length, content_type):
+        logging.debug("try to upload(%s, %s, %s) with %d bytes",
+                      bucket, name, content_type, length)
+        result = self.connection.put_object(
+            bucket, name, data, length, content_type=content_type)
+        logging.info("uploaded %s, etag: %s, version-id: %s",
+                     result.object_name, result.etag, result.version_id)
 
+    def get_object_url(self, bucket, name, ttl=60*60*24*7):
+        return self.connection.presigned_get_object(bucket, name, expires=timedelta(seconds=ttl))
 
-def _setup_health_thread(server):
-    servicer = health.HealthServicer(
-        experimental_non_blocking=True,
-        experimental_thread_pool=futures.ThreadPoolExecutor(max_workers=2)
-    )
-    health_pb2_grpc.add_HealthServicer_to_server(servicer, server)
-    health_checker_thread = threading.Thread(
-        target=_health_checker,
-        args=(servicer, 'palm.lily'),
-        daemon=True
-    )
-    health_checker_thread.start()
+    def set_object_tags(self, bucket, name, tags):
+        tmp = MinioTags.new_object_tags()
+        for k, v in tags:
+            tmp[k] = v
+        self.connection.set_object_tags(bucket, name, tmp)
 
+    def bucket_exists(self, bucket, published=False):
+        ok = self.connection.bucket_exists(bucket)
+        if not ok:
+            logging.warning("bucket %s isn't existed, try to create it")
+            self.connection.make_bucket(bucket)
+            self.connection.set_bucket_versioning(
+                bucket, MinioVersioningConfig(MinioEnabled))
 
-def start_server(addr, workers):
-    server = grpc.server(futures.ThreadPoolExecutor(max_workers=workers))
-    lily_pb2_grpc.add_ExcelServicer_to_server(excel.Service(), server)
-    lily_pb2_grpc.add_TexServicer_to_server(tex.Service(), server)
-    _setup_health_thread(server)
-    server.add_insecure_port(addr)
-    server.start()
-    logging.info(
-        "Lily gRPC server started, listening on %s with %d threads", addr, workers)
-    try:
-        server.wait_for_termination()
-    except KeyboardInterrupt:
-        logging.warn('exited...')
-        server.stop(0)
+        if published:
+            policy = {
+                "Version": "2023-10-06",
+                "Statement": [
+                    {
+                        "Effect": "Allow",
+                        "Principal": {"AWS": "*"},
+                        "Action": [
+                            "s3:GetObject"
+                        ],
+                        "Resource": "arn:aws:s3:::%s/*" % bucket,
+                    },
+                ],
+            }
+            self.connection.set_bucket_policy(bucket, json.dumps(policy))
+
+    def list_buckets(self):
+        return list(map(lambda x: x.name, self.connection.list_buckets()))
+
+    def current_bucket(self, published):
+        return '-' .join([self.namespace, datetime.now().strftime("%Y"), ('o' if published else 'p')])
+
+    def random_filename(ext=''):
+        return str(uuid.uuid4())+ext
 
 
 # https://pika.readthedocs.io/en/stable/modules/parameters.html
-def rabbitmq_parameters(config):
-    credentials = pika.PlainCredentials(config['user'], config['password'])
-    parameters = pika.ConnectionParameters(
-        config['host'],
-        config['port'],
-        config['virtual-host'],
-        credentials)
-    return parameters
+class RabbitMqClient:
+    def __init__(self, config):
+        credentials = pika.PlainCredentials(config['user'], config['password'])
+        self.parameters = pika.ConnectionParameters(
+            config['host'],
+            config['port'],
+            config['virtual-host'],
+            credentials)
+
+    def produce(self, queue, id, message):
+        logging.info("publish message(%s) to (%s) with %d bytes",
+                     id, queue, len(message))
+        with pika.BlockingConnection(self.parameters) as con:
+            ch = con.channel()
+            ch.queue_declare(queue=queue, durable=True)
+            ch.basic_publish(exchange='', routing_key=queue,
+                             body=message, properties=pika.BasicProperties(message_id=id, delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE))
+
+    def start_consuming(self, queue, callback):
+        logging.info("start consumer for %s", queue)
+
+        def handler(ch, method, properties, body):
+            callback(ch, method, properties, body)
+            if is_stopped():
+                logging.warning("stop consumer")
+                ch.stop_consuming()
+
+        with pika.BlockingConnection(self.parameters) as con:
+            ch = con.channel()
+            ch.queue_declare(queue=queue, durable=True)
+            ch.basic_qos(prefetch_count=1)
+            ch.basic_consume(queue=queue, on_message_callback=handler)
+            try:
+                ch.start_consuming()
+            except KeyboardInterrupt:
+                logging.warning("quit consumer...")
+                ch.stop_consuming()
+
+
+# -----------------------------------------------------------------------------
 
 
 # https://www.postgresql.org/docs/current/libpq-connect.html
+
+
 def postgresql_url(config):
     logging.debug('open postgresql://%s@%s:%d/%s',
                   config['user'], config['host'], config['port'], config['name'])

+ 13 - 0
rpc/lily/lily/palm/s3.py

@@ -0,0 +1,13 @@
+from . import lily_pb2, lily_pb2_grpc
+
+
+class Service(lily_pb2_grpc.S3Servicer):
+    def __init__(self, s3):
+        super().__init__()
+        self.s3 = s3
+
+    def GetFile(self, request, context):
+        response = lily_pb2.S3GetFileResponse()
+        response.url = self.s3.get_object_url(
+            request.bucket, request.name, request.ttl.seconds)
+        return response

+ 55 - 0
rpc/lily/lily/palm/server.py

@@ -0,0 +1,55 @@
+import threading
+import logging
+
+from time import sleep
+from concurrent import futures
+
+import grpc
+from grpc_health.v1 import health_pb2, health, health_pb2_grpc
+
+from . import lily_pb2_grpc, excel, tex, s3
+
+
+class Rpc:
+    def __init__(self, config, s3, cache, queue):
+        self.addr = '0.0.0.0:%d' % (config['port'])
+        self.max_workers = config['max-workers']
+        self.s3 = s3
+        self.cache = cache
+        self.queue = queue
+
+    def start(self):
+        server = grpc.server(futures.ThreadPoolExecutor(
+            max_workers=self.max_workers))
+        lily_pb2_grpc.add_ExcelServicer_to_server(excel.Service(), server)
+        lily_pb2_grpc.add_TexServicer_to_server(
+            tex.Service(self.s3, self.cache, self.queue), server)
+        lily_pb2_grpc.add_S3Servicer_to_server(s3.Service(self.s3), server)
+        Rpc._rpc_setup_health_thread(server)
+        server.add_insecure_port(self.addr)
+        server.start()
+        logging.info(
+            "Lily gRPC server started, listening on %s with %d threads", self.addr, self.max_workers)
+        try:
+            server.wait_for_termination()
+        except KeyboardInterrupt:
+            logging.warning('exited...')
+            server.stop(0)
+
+    def _rpc_health_checker(servicer, name):
+        while True:
+            servicer.set(name, health_pb2.HealthCheckResponse.SERVING)
+            sleep(5)
+
+    def _rpc_setup_health_thread(server):
+        servicer = health.HealthServicer(
+            experimental_non_blocking=True,
+            experimental_thread_pool=futures.ThreadPoolExecutor(max_workers=2)
+        )
+        health_pb2_grpc.add_HealthServicer_to_server(servicer, server)
+        health_checker_thread = threading.Thread(
+            target=Rpc._rpc_health_checker,
+            args=(servicer, 'palm.lily'),
+            daemon=True
+        )
+        health_checker_thread.start()

+ 69 - 17
rpc/lily/lily/palm/tex.py

@@ -3,31 +3,83 @@ import tempfile
 import os.path
 import subprocess
 
+
+from io import BytesIO
+
+
+import msgpack
+
 from . import lily_pb2, lily_pb2_grpc
 
+from . import MinioClient
 
-def _tmp_root():
-    return os.path.join(tempfile.tempdir())
+TEX2PDF_QUEUE = 'palm.lily.tex-to-pdf'
 
 
 class Service(lily_pb2_grpc.TexServicer):
+    def __init__(self, s3, cache, queue):
+        super().__init__()
+        self.s3 = s3
+        self.cache = cache
+        self.queue = queue
+
     def ToPdf(self, request, context):
-        logging.info("convert tex to pdf(%d)" % len(request.files))
-        with tempfile.TemporaryDirectory(prefix='tex-') as root:
-            for name in request.files:
-                with open(os.path.join(root, name), mode='wb') as fd:
-                    logging.debug("generate file %s/%s", root, name)
-                    fd.write(request.files[name])
-            for x in range(2):
-                subprocess.run(
-                    ['xelatex', '-halt-on-error', 'main.tex'], cwd=root)
-            with open(os.path.join(root, 'main.pdf'), mode="rb") as fd:
-                response = lily_pb2.File()
-                response.content_type = 'application/pdf'
-                response.payload = fd.read()
-                return response
+        response = lily_pb2.S3File()
+        response.content_type = 'application/pdf'
+        response.name = MinioClient.random_filename('.pdf')
+        response.bucket = self.s3.current_bucket(request.published)
+
+        task = msgpack.packb(
+            [request.SerializeToString(), response.SerializeToString()], use_bin_type=True)
+        self.queue.produce(TEX2PDF_QUEUE, response.name, task)
+        return response
 
     def ToWord(self, request, context):
         logging.info("convert tex to word %s" % request.content_type)
-        response = lily_pb2.File()
+        response = lily_pb2.S3File()
+        # TODO
         return response
+
+
+def create_tex2pdf_queue_callback(s3):
+    def it(ch, method, properties, body):
+        logging.info("receive message %s", properties.message_id)
+        _handle_tex2pdf_message(body, s3)
+        ch.basic_ack(delivery_tag=method.delivery_tag)
+    return it
+
+
+def _handle_tex2pdf_message(message, s3):
+    (request_b, response_b) = msgpack.unpackb(
+        message, use_list=False, raw=False)
+    request = lily_pb2.TexToRequest()
+    request.ParseFromString(request_b)
+    response = lily_pb2.S3File()
+    response.ParseFromString(response_b)
+    logging.info("convert tex to pdf(%d) ", len(request.files))
+    with tempfile.TemporaryDirectory(prefix='tex-') as root:
+        for name in request.files:
+            with open(os.path.join(root, name), mode='wb') as fd:
+                logging.debug("generate file %s/%s", root, name)
+                fd.write(request.files[name])
+        for _ in range(2):
+            try:
+                subprocess.run(
+                    ['xelatex', '-halt-on-error', 'main.tex'],  check=True, cwd=root)
+            except subprocess.CalledProcessError as e:
+                logging.error("%s", e)
+                return
+
+        pdf_file = os.path.join(root, 'main.pdf')
+        pdf_size = os.path.getsize(pdf_file)
+        with open(pdf_file, mode="rb") as fd:
+            pdf_data = BytesIO(fd.read())
+            s3.bucket_exists(response.bucket, request.published)
+            s3.put_object(response.bucket, response.name,
+                          pdf_data, pdf_size, response.content_type)
+            tags = {'title': request.title}
+            if request.has_owner:
+                tags['owner'] = request.owner
+            if request.has_ttl:
+                tags['ttl'] = request.ttl.seconds
+            s3.set_object_tags(response.bucket, response.name, tags)

+ 0 - 0
rpc/lily/lily/palm/worker.py


+ 3 - 3
rpc/lily/start.sh

@@ -1,5 +1,5 @@
-#!/bin/sh
+#!/bin/bash
 
-export CODE="mint-lily"
+export CODE="palm-lily"
 
-podman run -it --rm --events-backend=file --hostname=palm --network host $CODE
+podman run --rm --events-backend=file --hostname=palm --network host $CODE /bin/bash -i -c "python . $*"