Писать программы на питоне быстро, просто, удобно, и приятно, но поставлять их конечному пользователю не очень легко. Тут мы сталкиваемся по меньшей мере с двумя проблемами:

Во-первых поставляя исходный скрипт нужно позаботится о присутствии интерпретатора нужной версии у пользователя да еще и нужных модулей. 

Во-вторых отдавая пользователю текстовый скрипт он получает возможность изменять его, что для некоторых приложений крайне недопустимо. Этот пункт касается не только python а и других динамических языков с интроспекцией. Безусловно, любой исполняемый файл написанный на компилируемом языке можно дезасемблировать, но это требует от злоумышленника большего опыта и больший усилий. А вот получить даже из байт-кода в .pyc файле текстовый .py может любой кто умеет пользоваться google.

В этой статье мы попробуем разобраться с решением обоих проблем. 

Будем создавать 32-х битный файл для винды, собрать 64-бит версию вы сможете по аналогии, но х32 версия обязательна так как может работать как на 32-битных виндовсах так и на 64-х. Эта инструкция в принципе должена работать и под linux с небольшими изменениями.

Установка cython

У меня уже установлен python версии 3.4.2 (32 бит):

Python 3.4.2 (v3.4.2:ab2c023a9432, Oct  6 2014, 22:15:05) [MSC v.1600 32 bit (Intel)] on win32 

Разработку скрипта я веду в виртуальном окружении, то есть начиная новый проект я выполняю:

c:\Python34\python.exe -m venv project_env

При этом в текущей папке создается папка project_env с копией интерпретатора и туда мы можем ставить любые модули не засоряя системный python. Правда перед началом работы нужно активировать виртуальное окружение выполнив:

project_env\Scripts\activate.bat

При этом в приглашении в командной строке должно быть видно "(project_env)". Если вы используете среду разработке (например eclipse или PyCharm), то в ней нужно задать интерпретатор из папки project_env.

Для компиляции исходных кодов .py в архитектурный код мы должны установить модуль cython. Он позволит собирать наши скрипты в объектный код путем преобразования программ на питоне в язык си. Но для этого нам нужно настроить компилятор С. Тут у нас есть два решения: либо использовать Microsoft Visual C, либо открытый gcc из mingw, совместимый с MVC. Конечно же мы выбираем gcc.

Устанавливая Mingw нужно выбрать пакеты:

mingw_gcc_packssel.jpg

Установив компилятор мы должны прописать к нему путь в PATH добавив туда: C:\MinGW\bin;C:\MinGW\msys\1.0\bin . После этого нужно перезапустить cmd в которой запущено виртуальное окружение и затем проверить прописался ли path, выполнив echo %PATH%. Потом повторно активировать окружение.

Осталось указать компилятор установщику pip. Для этого создаем файл c:\Python34\Lib\distutils\distutils.cfg и в него добавляем:

[build]
compiler=mingw32

Примечание: идеологически файл distutils.cfg мы должны создавать в виртуальном окружении а не в папке Python. Но в силу непонятных причин pip отказался его подхватывать из project_env\Lib\distutils\distutils.cfg либо из project_env\pydistutils.cfg. Если у кого-то получится - пишите в комментариях.

Наконец запускаем в нашем окружении project_env:

pip install cython

В конечном итоге все должно закончится фразой типа:

Successfully installed cython-0.22

Кроме cython нам опционально понадобится пакет pywin32. Он понадобится для включения в exe-файл информации о версии. Для python 2.7 его можно установить через pip, однако для 3.4 пока поддерживается только отдельный инсталлятор, который можна найти тут: http://sourceforge.net/projects/pywin32/files/pywin32/. Так как мы используем venv, устанавливать нужно при помощи easy_install -N, например:

easy_install -N "Downloads\pywin32-219.win32-py3.4.exe"

Работа над проектом

Наконец можем приступать к компиляции рабочего проекта. Смысл состоит в том чтобы вынести весь код который мы хотим скрыть в пакеты и оставить в рабочем каталоге только основной файл, например main.py, в котором будет минимум вашего кода. Выбор имени главного модуля повлияет на название исполняемого файла (например main.exe), так что выбирайте его согласно названию вашей программы. Пакетом в питоне как известно считается папка в которой присутствует файл __init__.py (желательно что бы он был пустой, так как он не будет компилироваться).

Например мы можем разместить файлы проекта в такую структуру:

каталог_проекта
  |
  |-- gui
  |      |
  |      |-- __init__.py
  |      |-- login_window.py
  |      |-- main_window.py
  |
  |-- logic
  |      |
  |      |-- __init__.py
  |      |-- program_logic.py
  |
  |-- defs
  |      |
  |      |-- __init__.py
  |      |-- program_logic.py
  |
  |-- res
  |      |
  |      |--appicon.ico
  |      |--applogo.png
  |
  |-- main.py

В проекте у меня три пакета: 

  1. gui - пакет с файлами определения графических интерфейсов. Например я использую кросс-платформенный открытый PySide основный на Qt.
  2. logic - пакет со всякими модулями определяющими логику работы программы
  3. defs - пакет с константами и определениями.

Кроме того у меня есть папка res для всяких ресурсов (иконок картинок и т.д.), которые нужно будет поставлять вместе с программой.

Для сборки релиза я предлагаю создать ряд скриптов, которые позволят автоматизировать процесс.

Генерация файла версии

Начнем со скрипта генерирующего версию программы gen_version.py. Его нужно создать в каталоге проекта(рядом с main.py) Этот скрипт не есть обязательным но очень полезным с точки зрения сопровождения программы. Скрипт достает номер ревизии и хеш коммита из репозитория системы контроля версий git, находящегося в папке проекта. Если вы используете другую СКВ, например svn, вам не составит труда адаптировать скрипт. Если вы не используете системы контроля версий вообще, но все же хотите генерировать файл версии, то простым решением будет обычный инкримент номера (до открытия файла на записать нужно открыть его для  чтения вычитать текущий номер, если удалось сделать +1, иначе присвоить ноль). Кроме этого gen_version.py подсчитывает CRC32 всех исходников .py и записывает время запуска этого скрипта (типа время и дата сборки). Вот его код:

# -*- coding: utf-8 -*- 
import sys
import time
import os
import subprocess
import zlib
import re
"""
Find git in possible path
"""
def git_detect():
    git_path_vars = ["git", 
                    "c:\\cygwin\\git", 
                    "c:\\cygwin64\\bin\\git", 
                    "c:\\Program Files (x86)\\Git\\bin\\git",
                    "c:\\Program Files\\Git\\bin\\git"]
    git_path = None
    for g in git_path_vars:
        try:
            subprocess.call([g, '--version'])
            git_path = g
            print("Found git at: {}".format(git_path))
            break
        except OSError:
            pass
    if not git_path:
        sys.exit("Git not found. Please install it (http://git-scm.com/ or cygwin: \"apt-cyg install git\" or in other way)")
    return git_path
""
def count_sources_crc(file_types, ignore_lines):
    crc = 0
    for root, _, files in os.walk('.'):
        for file in files:
            ext = os.path.splitext(file)[1].lower()
            if ext in file_types:
                full_path = os.path.join(root, file)
                #print("Include in crc {}".format(full_path))
                for line in open(full_path).readlines():
                    ignore = False
                    for pat in ignore_lines:
                        if re.match(pat, line):
                            ignore = True
                            #print("Ignore line: {}".format(line))
                            break
                    if not ignore:                         
                        crc = zlib.crc32(bytes(line, 'UTF-8'), crc)
    return (crc & 0xFFFFFFFF)
def create_version_file(filename):
    git_path = git_detect()
    crc = count_sources_crc(['.py',], 
                            ['COMMIT_REVISION = .+', 
                             'COMMIT_HASH = .+',
                             'SOURCES_CRC = .+',
                             'BUILD_TIME = .+'])
    f = open(filename, 'w+')
    repo_rev = os.popen("\"{}\" rev-list --count HEAD".format(git_path)).read()[:8].rstrip()
    f.write("COMMIT_REVISION = {}\n\n".format(repo_rev))
    repo_hash = os.popen("\"{}\" rev-parse HEAD".format(git_path)).read()[:8].rstrip()
    f.write("COMMIT_HASH = 0x{}\n\n".format(repo_hash))
    f.write("SOURCES_CRC = 0x{:08x}\n\n".format(crc))
    f.write("BUILD_TIME = {}\n\n".format(time.time()))
    f.close()

Выполнив функцию create_version_file(путь_к_файлу_версии), мы получаем файл вроде такого:

COMMIT_REVISION = 3
COMMIT_HASH = 0x11b38900
SOURCES_CRC = 0xac699a53
BUILD_TIME = 1425129933.589303

COMMIT_REVISION можно использовать как версию программы (он будет увеличиваться от коммита к комиту при условии что мы будем делать релизы из одной ветки), COMMIT_HASH поможет нам найти коммит, из которого создавалась версия, например мы сможем на него вернутся и протестировать багу. SOURCES_CRC покажит реальное сотояние исходников, что позволит определить был ли реально сделан коммит или нет, а BUILD_TIME содержит timestamp указывающий на дату и время сборки.

Компиляция пакетов при помощи cython

Создаем файл compile.py в каталоге проекта.

# -*- coding: utf-8 -*- 
from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext
from os import walk
from os.path import splitext, join, sep
"""
find all .py files in subfolders
"""
def cython_file_list():
    files_for_cython = []
    for root, _, files in walk('.'):
        for file in files:
            if splitext(file)[1].lower() == '.py' and file != '__init__.py' and root != '.':
                files_for_cython.append(join(root, file))
    return files_for_cython
"""
Determine file path in "package" format. 
For example '.\generic\vesion.py' will have path 'generic.version'
"""
def ext_pack_name(filename):
    return filename[2:].replace('.py','').replace(sep, '.')
files = cython_file_list()
ext_modules = []
for f in files:
    print("Add to compile list {}".format(f))
    ext_modules.append(Extension(ext_pack_name(f), [f[2:]]))
setup(
    name = 'Название программы',
    cmdclass = {'build_ext': build_ext},
    ext_modules = ext_modules
)

Создание исполняемого файла

Создаем в папке проекта скрипт makebin.py

from cx_Freeze import setup, Executable   
import sys
from defs.version import COMMIT_REVISION
build_exe_options = {"icon": "res/appicon.ico", 
                     "packages": ["gui", "logic", "defs"],
                     "optimize": 2,
                     "compressed": True,
                     "include_files": "res",
                     "include_msvcr": False}
# GUI applications require a different base on Windows (the default is for a
# console application).
base = None
if sys.platform == "win32":
    base = "Win32GUI"
setup(   
    name = "имя_программы",   
    version = str(COMMIT_REVISION / 10),   
    description = "Короткое описание программы",   
    options = {"build_exe": build_exe_options},
    executables = [Executable("main.py", base=base)]
)

Если вы не используете генерацию версии замените строку COMMIT_REVISION на свое значение. Также укажите свои "имя_программы", "Короткое описание программы", путь к иконке res/appicon.ico. Также обратите внимание что в packages нужно перечислить используемые в импортах пакеты, в том числе внешние, например "os", "Crypto" и т.д.

Скрипт сборки

Ну и последнее что осталось сделать, это создать наконец окончательный скрипт который будет вызывать другие скрипты. В папке проекта создаем  build_release.py:

import os
from gen_version import create_version_file
from shutil import copytree, rmtree
from os.path import splitext, join
from os import walk
print(">> Generating version")
create_version_file('defs'+os.sep+'version.py')
WORKING_DIR = 'building'
print(">> Preparing working dir: {}".format(WORKING_DIR))
if os.path.exists(WORKING_DIR):
    rmtree(WORKING_DIR) 
# Copy current dir to working
copytree('.', WORKING_DIR)
os.chdir(WORKING_DIR)
print(">> Compiling py to cython")
os.system('python compile.py build_ext  --inplace')
for root, _, files in walk('.'):
    for file in files:
        if (splitext(file)[1].lower() == '.py' and file != '__init__.py' and root != '.')\
                or splitext(file)[1].lower() == '.c'\
                or splitext(file)[1].lower() == '.pyc':
            print("Removing src file: {}".format(join(root, file)))
            os.remove(join(root, file))
print(">> Building release with cxFreeze")
os.system('python makebin.py build')

Тут мы выполняем генерацию версии в файл defs/version.py а затем копируем рекурсивно все содержимое папки проекта в под-папку building, переходя в нее мы и начинаем сборку. Это позволяет нам не изменять основную папку и не создавать в ней всякого мусор. Папка building при каждой новой сборке полностью удаляется что позволяет избавиться от конфликтующих или просто ненужных файлов в процессе сборки. 

Затем мы выполняем компиляцию модулей в пакете, и после этого удаляем все .py, .c, .pyc файлы в пакетах (кроме пустых __init__.py, которые по прежнему определят пакет). Так мы гарантируем, что исходных файлов не осталось в пакетах, а остались только скомпилированные cython-ом.

Ну и напоследок мы собираем все в .exe файл, и копируем папку с релизом, в которую войдут папка ресурсов res и необходимые библиотеки. Все это будет находится в building\build\exe.win32-3.4\. Эту папку можно завернуть в SFX архив, или в какой-нибудь инсталлятор (например InnoSetup).

Запуск скрипта нужно выполнять в виртуальном окружении при помощи:

python build_release.py

Бонус: создание инсталлятора для Windows при помощи Inno Setup

Скачать Inno Setup, тут: http://www.jrsoftware.org/isdl.php.

В папке проекта создаем папку inno_setup, в нее помещаем два bmp-файла:

  • WizModernImage-IS.bmp
  • WizModernSmallImage-IS.bmp

Скопировать их можно из "c:\Program Files (x86)\Inno Setup 5\"  (или без x86) и по желанию подредактировать под свои нужды, например используя GIMP (Формат bmp должен быть 24 разряда на цвет пикселя).

Также создаем скрипт формирования иснталлятора proj.iss (кодировка ANSI cp1251):

#define ApplicationName 'Proj'
#define ApplicationGroup 'Proj Group In Start Menu'
#define ApplicationFolder 'Proj'
#define ExeName 'main.exe'
#define BundlePath '..\build\exe.win32-3.4\'
#define ApplicationVersion GetFileVersion(BundlePath+ExeName)
#define SetupBaseName   "Install"+ApplicationName+"_v"
#define VersReplDots   Copy(ApplicationVersion, 1, Pos(".", ApplicationVersion) - 1) + Copy(ApplicationVersion, Pos(".", ApplicationVersion) + 1)     
#define VersReplDots2  Copy(VersReplDots, 1, Pos(".", VersReplDots) - 1)
[Setup]
OutputBaseFilename={#SetupBaseName + VersReplDots2}
AppName={#ApplicationName}
AppId={#ApplicationGroup+ApplicationName}
AppVerName={#ApplicationName} {#ApplicationVersion}
VersionInfoVersion={#ApplicationVersion}
DefaultDirName={pf}\{#ApplicationFolder}
DefaultGroupName={#ApplicationGroup}
UninstallDisplayIcon={app}\{#ExeName}
Compression=lzma2
SolidCompression=yes
OutputDir=installer
WizardImageFile=WizModernImage-IS.bmp
WizardSmallImageFile=WizModernSmallImage-IS.bmp
ShowLanguageDialog=auto
[Languages]
Name: "en"; MessagesFile: "compiler:Default.isl"
Name: "ru"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "ua"; MessagesFile: "compiler:Languages\Ukrainian.isl"
Name: "de"; MessagesFile: "compiler:Languages\German.isl"
Name: "es"; MessagesFile: "compiler:Languages\Spanish.isl"
Name: "fr"; MessagesFile: "compiler:Languages\French.isl"
Name: "it"; MessagesFile: "compiler:Languages\Italian.isl"
Name: "nl"; MessagesFile: "compiler:Languages\Dutch.isl"
Name: "no"; MessagesFile: "compiler:Languages\Norwegian.isl"
Name: "pl"; MessagesFile: "compiler:Languages\Polish.isl"
[Files]
Source: {#BundlePath+"*"}; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\{#ApplicationName}"; Filename: "{app}\{#ExeName}"
Name: "{group}\Uninstall {#ApplicationName}"; Filename: "{uninstallexe}"
Name: "{commondesktop}\{#ApplicationName}"; Filename: "{app}\{#ExeName}"; WorkingDir: "{app}" 
[Run]
Filename: "{app}\{#ExeName}"; Description: "Запустить {#ApplicationName}"; Flags: postinstall nowait skipifsilent
[Code]
/////////////////////////////////////////////////////////////////////
function GetUninstallString(): String;
var
  sUnInstPath: String;
  sUnInstallString: String;
begin
  sUnInstPath := ExpandConstant('Software\Microsoft\Windows\CurrentVersion\Uninstall\{#emit SetupSetting("AppId")}_is1');
  sUnInstallString := '';
  if not RegQueryStringValue(HKLM, sUnInstPath, 'UninstallString', sUnInstallString) then
    RegQueryStringValue(HKCU, sUnInstPath, 'UninstallString', sUnInstallString);
  Result := sUnInstallString;
end;
/////////////////////////////////////////////////////////////////////
function IsUpgrade(): Boolean;
begin
  Result := (GetUninstallString() <> '');
end;
/////////////////////////////////////////////////////////////////////
function UnInstallOldVersion(): Integer;
var
  sUnInstallString: String;
  iResultCode: Integer;
begin
// Return Values:
// 1 - uninstall string is empty
// 2 - error executing the UnInstallString
// 3 - successfully executed the UnInstallString
  // default return value
  Result := 0;
  // get the uninstall string of the old app
  sUnInstallString := GetUninstallString();
  if sUnInstallString <> '' then begin
    sUnInstallString := RemoveQuotes(sUnInstallString);
    if Exec(sUnInstallString, '/SILENT /NORESTART /SUPPRESSMSGBOXES','', SW_HIDE, ewWaitUntilTerminated, iResultCode) then
      Result := 3
    else
      Result := 2;
  end else
    Result := 1;
end;
/////////////////////////////////////////////////////////////////////
procedure CurStepChanged(CurStep: TSetupStep);
begin
  if (CurStep=ssInstall) then
  begin
    if (IsUpgrade()) then
    begin
      UnInstallOldVersion();
    end;
  end;
end;

Первые 4-5 срок скрипта измените под свой проект.

Теперь в build_release.py можем добавить:

print(">> Building installer")
os.system('"c:\Program Files (x86)\Inno Setup 5\ISCC.exe" inno_setup\proj.iss')

После запуска "python build_release" в папке \building\inno_setup\installer забирайте свежий инсталлятор!