Cythonを使ってPythonのコードを秘匿化する

# やりたいこと

Pythonはスクリプト言語でソースコードを読み込みながら実行します。したがってPythonで作ったプログラムを直接顧客に渡すとソースコードも一緒に渡さないといけません。PyArmorというツールを使うとPythonのソースコードを難読化することができますが、Python 3.11に未対応だったり、商用利用では有償です。そこで代替案としてCythonを使って自分が書いた部分全体を共有ライブラリ化して渡すという方法を試してみます。

# 環境

- OS: Ubuntu 22.04, Debian 11などのLinux
- Python: 3.11
- Cython: 3.0.0a11
- 依存管理: PDM 2.4以降 (PDMについてはこちら[^PDM]を参照)

[^PDM]: https://zenn.dev/lucidfrontier45/articles/a9601aa94b7c29

Cythonは安定板の0.29系統ではなく3系を使用します。0.29系と比べて多くの最新のPythonの構文にも対応しています。ただ、現状ではまだmatch文には対応していません。

# 手順

https://github.com/lucidfrontier45/cython_obfuscate_example

上記のレポジトリに必要なファイルをまとめてあります。通常の開発時にはそのままPythonのプロジェクトとしていつも通りに開発します。一通り開発が完了したら`pdm run python setup.py build`を実行します。これで`build/lib.linux-x86_64-cpython-311`以下に各`.py`ファイルに対応する`.so`ファイルができています。このプロジェクトは

```
src
└── app
   ├── __init__.py
   └── backend
      ├── __init__.py
      └── rand.py
```

のような構成でしたので

```
build/lib.linux-x86_64-cpython-311
└── app
   ├── __init__.cpython-311-x86_64-linux-gnu.so
   ├── backend
   │  ├── __init__.cpython-311-x86_64-linux-gnu.so
   │  └── rand.cpython-311-x86_64-linux-gnu.so
   └── version.cpython-311-x86_64-linux-gnu.so
```

のようになりました。


ちなみに`setup.py`は以下の通りです。こちら[^1]のほぼコピペです。

[^1]: https://medium.com/swlh/distributing-python-packages-protected-with-cython-40fc29d84caf

```py
# coding: utf-8
import os

from Cython.Build import cythonize
from setuptools import find_packages, setup

EXCLUDE_FILES = []


def get_ext_paths(root_dir: str, exclude_files: list[str]):
    """get filepaths for compilation"""
    paths = []

    for root, dirs, files in os.walk(root_dir):
        for filename in files:
            if os.path.splitext(filename)[1] != ".py":
                continue

            file_path = os.path.join(root, filename)
            if file_path in exclude_files:
                continue

            paths.append(file_path)
    return paths


setup(
    packages=find_packages(),
    ext_modules=cythonize(
        get_ext_paths("src", EXCLUDE_FILES),
        compiler_directives={"language_level": 3},
        build_dir="build",
    ),
)
```

あとは`build/lib.linux-x86_64-cpython-311`を`PYTHONPATH`にセットすれば別途用意したmainのスクリプトからimportすることができます。ユーザーに渡すときには`src`以下の内容をごっそり`build/lib.linux-x86_64-cpython-311`のものと置き換えてしまうという方法もあります。なお、PDMはルートの`__init__.py`を探すのでこのファイルだけはテキストの`.py`のままにしておく必要があります。参考としてDockerfileを載せておきます。

```Dockerfile
#----------cython-builder----------#
FROM python:3.11 as cython-builder
WORKDIR /project

RUN pip install -U pip setuptools wheel
RUN pip install "cython>=3.0.0a11"

COPY src /project/src
COPY setup.py /project/setup.py
RUN python setup.py build

RUN mv src raw
RUN mv build/lib.linux-x86_64-cpython-311 cython

#----------builder----------#
FROM python:3.11 as builder
WORKDIR /project
RUN pip install -U pip setuptools wheel
RUN pip install pdm

COPY pyproject.toml pdm.lock /project/
RUN pdm sync --prod --no-self

# cython: use cython to obfuscate code
# raw: no obfuscation 
ARG BUILD_TYPE="cython"

COPY --from=cython-builder /project/${BUILD_TYPE} /project/src
# root __init__.py needs to be a text file for PDM to read it
COPY --from=cython-builder /project/raw/app/__init__.py /project/src/app/__init__.py
RUN pdm sync --prod --no-editable

#----------runner----------#
FROM python:3.11-slim as runner
WORKDIR /project

COPY --from=builder /project/.venv /project/.venv
COPY main.py /project/main.py

ENV PATH /project/.venv/bin:$PATH

CMD ["python", "main.py"]
```