Skip to content

Use Cython to Protect Source Code

When you use uv pip install to install your project into pyembed/python, your Python code is distributed as-is.

If you want to protect your source code, you can use Cython or Nuitka to compile your Python code into binary modules.

We recommend Cython because it integrates well with the setuptools used by PyTauri.

cythonized

Usage

First, ensure that you meet the prerequisites for Cython.

Then, add Cython as a build dependency:

src-tauri/pyproject.toml
[build-system]
requires = [
    # ...
    "Cython>=3",
]
build-backend = "setuptools.build_meta"

Add the following content to setup.py. Pay attention to the following parameters and their comments:

  • USE_CYTHON
  • SRC_PREFIX
  • INCLUDE_FILE_PATTERNS
  • EXCLUDE_FILE_PATTERNS
src-tauri/setup.py
"""See: <https://setuptools-rust.readthedocs.io/en/latest/setuppy_tutorial.html>"""

from collections.abc import Sequence
from importlib.util import cache_from_source
from logging import getLogger
from os import getenv
from os.path import abspath, normcase
from pathlib import Path

from Cython.Build import (  # pyright: ignore[reportMissingTypeStubs]
    cythonize,  # pyright: ignore[reportUnknownVariableType]
)
from setuptools import setup
from setuptools.command.install import install as _install
from setuptools_rust import RustExtension

########## Inputs ##########

PYTAURI_STANDALONE = getenv("PYTAURI_STANDALONE") == "1"
"""Instead of building pytauri as a extension module file, it will be loaded in memory through Rust's `append_ext_mod`"""

USE_CYTHON = getenv("USE_CYTHON") == "1"
"""Whether to use Cython to compile the Python code into C extension module to protect the source code."""


SRC_PREFIX = "python/"
# The glob pattern of the source files you want to protect,
# or you can set it to `"tauri_app/**/*.py"` to protect all Python files in the project.
INCLUDE_FILE_PATTERNS = ("tauri_app/private.py",)
# Usually we dont need to protect the `**/__init__.py` files.
# NOTE: you must exclude `**/__main__.py`: <https://groups.google.com/g/cython-users/c/V-i0a8r-x00>.
EXCLUDE_FILE_PATTERNS = ("tauri_app/**/__init__.py", "tauri_app/**/__main__.py")

##############################


_logger = getLogger(__name__)


class install(_install):  # noqa: N801
    """Subclass `setuptools.command.install` to exclude protected files in the Wheel.

    ref: <https://setuptools.pypa.io/en/latest/userguide/extension.html>
    """

    def run(self) -> None:
        """Remove protected files after installation and before writing into the Wheel,
        to prevent them from being packaged into the Wheel."""

        super().run()  # pyright: ignore[reportUnknownMemberType]

        # skip if `pip install -e`
        build_py_obj = self.distribution.get_command_obj("build_py")
        build_py_obj.ensure_finalized()
        if build_py_obj.editable_mode:
            return

        # ref: <https://github.com/pypa/setuptools/blob/6ead555c5fb29bc57fe6105b1bffc163f56fd558/setuptools/_distutils/command/install_lib.py#L115-L124>
        assert self.install_lib is not None
        install_lib = Path(self.install_lib)

        def norm_files_set(patterns: Sequence[str]) -> set[str]:
            """Normalized set of file paths"""
            files_set: set[str] = set()
            for pattern in patterns:
                for file in install_lib.glob(pattern):
                    files_set.add(normcase(abspath(file)))
            return files_set

        include_files_set = norm_files_set(INCLUDE_FILE_PATTERNS)
        exclude_files_set = norm_files_set(EXCLUDE_FILE_PATTERNS)

        for file in include_files_set.difference(exclude_files_set):
            protected_file = Path(file)
            _logger.info(f"Removing protected file: {protected_file}")

            # remove the protected files from the Wheel
            protected_file.unlink()
            # remove C files generated by Cython
            protected_file.with_suffix(".c").unlink(missing_ok=True)
            # why `.cpp`: <https://cython.readthedocs.io/en/latest/src/userguide/wrapping_CPlusPlus.html#specify-c-language-in-setup-py>
            protected_file.with_suffix(".cpp").unlink(missing_ok=True)
            # remove *potential* python bytecode files (e.g., `pip install --compile-bytecode`)
            # ref:
            #   - <https://docs.python.org/3/using/cmdline.html#cmdoption-O>
            #   - <https://peps.python.org/pep-0488/>
            #   - <https://docs.python.org/3/library/importlib.html#importlib.util.cache_from_source>
            for optimization in ("", 1, 2):
                bytecode_file = cache_from_source(
                    protected_file, optimization=optimization
                )
                Path(bytecode_file).unlink(missing_ok=True)


setup(
    ####################
    # pyo3 extension module
    ####################
    rust_extensions=[
        RustExtension(
            # set `target` the same as `[project.entry-points.pytauri.ext_mod]` in `pyproject.toml`
            target="tauri_app.ext_mod",
            # It is recommended to set other features in `Cargo.toml`, except following features:
            features=[
                # see: <https://pyo3.rs/v0.23.3/building-and-distribution.html#the-extension-module-feature>,
                # required to build the extension module
                "pyo3/extension-module",
                # This feature tells Tauri to use embedded frontend assets instead of using a frontend development server.
                # Usually this feature is enabled by `tauri-cli`, here we enable it manually.
                "tauri/custom-protocol",
            ],
        )
    ]
    if not PYTAURI_STANDALONE
    else [],
    ####################
    # Cython
    ####################
    cmdclass={"install": install} if USE_CYTHON else {},
    # See: <https://cython.readthedocs.io/en/latest/src/quickstart/build.html#building-a-cython-module-using-setuptools>
    ext_modules=cythonize(  # pyright: ignore[reportUnknownArgumentType]
        module_list=[SRC_PREFIX + pattern for pattern in INCLUDE_FILE_PATTERNS],
        exclude=[SRC_PREFIX + pattern for pattern in EXCLUDE_FILE_PATTERNS],
    )
    if USE_CYTHON
    else [],
)

Add *.c (generated by Cython) to .gitignore to avoid committing them if you don't want to.

When building a standalone binary (uv pip install), you only need to add the environment variable USE_CYTHON=1 to compile your Python code with Cython.

Check the following directories, and you will find that the *.py source files matching INCLUDE_FILE_PATTERNS have been compiled into *.so/*.pyd files. Additionally, the *.c files generated by Cython (which could also leak your source code) are not included:

  • Windows: src-tauri\pyembed\python\Lib\site-packages\{your_package}
  • Unix: src-tauri/pyembed/python/lib/python{version}/site-packages/{your_package}

Tip

During development, you can still use uv pip install -e to install your project in development mode. This means you can modify *.py files and see the changes immediately (without compiling with Cython). That's awesome 🎉!

FAQ

The Size of the Binary Modules

It is not recommended to set INCLUDE_FILE_PATTERNS to a wildcard like **/*.py. Compiling a binary module can increase your application size from a few kilobytes for *.py files to several hundred kilobytes. Of course, if you don't mind the size increase, this is not an issue.

If you do want to do this and still care about size, check out the Cython tutorial (NOTE: it is complex and requires manual steps): https://cython.readthedocs.io/en/latest/src/userguide/source_files_and_compilation.html#shared-utility-module

If possible, consider consolidating the code you want to compile into a single file. This not only reduces size but also allows Cython to better optimize and accelerate them through inlining: https://cython.readthedocs.io/en/latest/src/userguide/faq.html#what-is-better-a-single-big-module-or-multiple-separate-modules.

Why Hack the setuptools.command.install.install

Setuptools does not provide an API to exclude certain files from the Wheel: https://github.com/pypa/setuptools/issues/511.