Два проекта вызывают одну Python-функцию, чьи зависимости установлены в отдельном окружении
Два проекта вызывают одну Python‑функцию, чьи зависимости установлены в отдельном окружении

Полезная внутренняя функция редко остаётся функцией без зависимостей. Сегодня ей понадобился python-dateutil, завтра — конкретная версия NumPy. Когда тот же код нужен во втором проекте, приходится либо менять его lockfile, либо собирать отдельный контейнер, либо превращать десять строк в сервис.

splime предлагает ещё один вариант: опубликовать доверенную Python‑функцию вместе с точными версиями пакетов и вызывать её по имени. Локальный демон сам собирает окружение и хранит его отдельно от проекта, который будет вызывать функцию. Общая модель была в первой статье, а упаковка функции — в предыдущей части. Этот текст можно читать отдельно.

Сначала запустим функцию, чья зависимость вообще не установлена у вызывающего кода. Затем посмотрим, откуда берётся venv, почему он кэшируется и когда одной ноде стоит назначить native, venv-subprocess или Docker. Материал рассчитан на macOS и Linux с Python 3.13+; в splime 0.4.9 сборка локальных окружений требует POSIX.

Пример: зависимость есть у функции, но не у проекта

Создадим чистое окружение, поставим только splime и запустим демон. Для установки splime и первой сборки venv нужен доступ к PyPI. Отдельный daemon home не обязателен, но он не даст примеру смешаться с вашими настоящими объектами, если вы уже используете splime. --auto-port пригодится, если стандартный порт 8765 уже занят.

python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install "splime==0.4.9"

export SPL_DAEMON_HOME="$PWD/.splime-article-daemon"
spl-daemon serve --auto-port

Оставим демон работать. В соседнем терминале перейдём в ту же директорию и активируем то же клиентское окружение:

. .venv/bin/activate
export SPL_DAEMON_HOME="$PWD/.splime-article-daemon"

Сохраним следующий файл как demo.py. Он считает срок оплаты счёта через dateutil.parser.isoparse, но python-dateutil объявлен только в описании функции — в .venv его нет.

import importlib.util

from spl import SPLClient


assert importlib.util.find_spec("dateutil") is None

INVOICE_YAML = """\
- !DFunction
  name: days_until_due
  inputs:
  - name: issued_at
    type: str
    default: null
  - name: due_at
    type: str
    default: null
  outputs:
  - name: default
    type: int
  body: |-
    from dateutil.parser import isoparse
    return (isoparse(due_at) - isoparse(issued_at)).days
- !DDistribution
  package: python-dateutil
  version: 2.9.0.post0
"""

client = SPLClient()
client.register_env()
client.publish_yaml(
    INVOICE_YAML,
    name="days_until_due",
    entrypoint="days_until_due",
)

result = client.call(
    "days_until_due",
    kwargs={"issued_at": "2026-09-01", "due_at": "2026-09-30"},
    timeout_seconds=120,
)

build = next(
    item
    for item in client.environment_builds()
    if item["distributions"] == [
        {"package": "python-dateutil", "version": "2.9.0.post0"}
    ]
)

print("dateutil in caller:", importlib.util.find_spec("dateutil"))
print("result:", result.output)
print("environment:", build["status"], build["distributions"])

Запускаем:

python demo.py

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

dateutil in caller: None
result: 29
environment: ready [{'package': 'python-dateutil', 'version': '2.9.0.post0'}]

Вот тот самый полезный эффект: клиент не импортирует и не устанавливает python-dateutil, а функция получает ровно объявленную версию. Следующий вызов использует уже готовое окружение.

Что произошло между publish и call

В примере есть три разных места, которые легко мысленно склеить ведино:

  1. .venv — окружение скрипта demo.py. В нём установлен splime, но нет python-dateutil.

  2. Каталог демона (daemon home) — реестр объектов, запусков и собранных окружений.

  3. venv функции — отдельный каталог внутри него. Именно туда установлен python-dateutil==2.9.0.post0.

Строка !DDistribution — часть переносимого контракта функции. Демон не копирует текущее окружение целиком, а собирает новое из точного списка зависимостей.

Если публиковать живую функцию через client.publish(), splime сумеет найти глобально импортированные модули: он обходит внешние имена в байткоде, сопоставляет имя модуля с установленным дистрибутивом через importlib.metadata.packages_distributions() и записывает его версию. Стандартная библиотека отбрасывается.

У этого механизма есть свое ограничение. Импорт внутри тела функции не попадает в список глобалов. В days_until_due он именно такой, поэтому зависимость объявлена вручную. Если сторонний глобальный модуль нельзя связать с установленным дистрибутивом, публикация завершается ошибкой, а не создаёт заведомо неполный объект. Реализация находится в distribution.py.

Это не значит, что зависимости обычно приходится записывать в YAML руками, как в примере выше. Если вы импортируете пакет на уровне модуля, например, import dateutil.parser as date_parser — и публикует живую функцию через client.publish(), splime сам добавит python-dateutil и его установленную версию. Ручное описание понадобилось только в этом примере: один и тот же чистый venv играет роль вызывающего проекта, поэтому dateutil в нём намеренно отсутствует. В обычной схеме пакет установлен у автора при публикации, но не нужен проекту, который затем вызывает функцию.

Почему окружение кэшируется по хешу

Демон строит спецификацию из локально выбранного интерпретатора, версии Python, списка дистрибутивов, рантайм‑пакетов и способа сборки. Из неё считается spec_hash.

Одинаковая спецификация получает тот же каталог и готовый venv. Если изменить версию python-dateutil, появится другой хеш и новое окружение; старое не переписывается. При прочих равных два объекта с одинаковыми зависимостями делят кэш, а несовместимые наборы пакетов не смешиваются.

Если в PATH есть uv, splime создаёт переносимый venv командой uv venv --relocatable и ставит зависимости через uv pip install --strict. Без uv используются стандартный venv и pip. Выбранный сборщик тоже входит в хеш: каталоги у этих окружений устроены по‑разному и не должны случайно делить кэш.

Путь от описания функции и зависимостей к venv и трём рантаймам
Путь от описания функции и зависимостей к venv и трём рантаймам

Внутренняя схема: спецификация даёт spec_hash, по нему находится или строится venv.

У каждой сборки остаются install.log и запись со статусом. По умолчанию демон начинает сборку сразу после регистрации, не дожидаясь вызова. Процесс асинхронный, поэтому немедленный первый call() всё равно нужно немного подождать — именно это произошло в примере. С ключом --no-auto-build-envs на уровне локального демона сборка откладывается до первого запуска.

Лог сборки окружения через uv
Лог сборки окружения через uv

В install.log видны реальный интерпретатор, команды сборщика и установленные версии.

Путь Python, записанный автором, не стоит слепо повторять на другой машине. Для объекта, пришедшего с сервера, демон сначала ищет локально зарегистрированное окружение с тем же именем, затем default, затем собственный интерпретатор. Если выбор отличается от авторского, в запуске появляется interpreter_substitution, а spl-daemon doctor показывает несовпадение минорной версии. env_python здесь — сведения о происхождении объекта, а не переносимый абсолютный путь.

Venv и runtime — не одно и то же

Окружение отвечает на вопрос «какие пакеты и Python нужны объекту». Рантайм отвечает на другой: «как запустить конкретную функцию (в том числе внутри пайплайна)».

Рантайм ноды

Что происходит

Когда подходит

native

Функция выполняется в процессе, где вызывается client.call(). Это дефолт.

Доверенный код, где важны минимальные накладные расходы.

venv-subprocess

Для функции запускается отдельный процесс с подготовленным Python и splime‑free раннером.

Нужны отдельный процесс и изоляция зависимостей.

docker

Для функции запускается отдельный контейнер.

Нужны системные пакеты или более жёсткая граница исполнения.

Виртуальное окружение не является песочницей безопасности. native и venv-subprocess работают от имени того же пользователя ОС, что и вызывающий процесс; второй вариант отделяет процесс и зависимости, но не запрещает читать доступные этому пользователю файлы. Для недоверенного кода нужен Docker с проверенными mount‑ами либо отдельная учётная запись ОС.

При этом рантайм можно назначить конкретной ноде, не отправляя в него весь пайплайн:

from spl import Deployment, lift


def seed_json() -> int:
    return 21


def double_json(value: int) -> int:
    return value * 2


pipeline = (
    lift(double_json)
    .bind(value=lift(seed_json).alias("seed"))
    .alias("consumer")
    .render("runtime_demo")
    .with_node_runtime("consumer", "venv-subprocess")
)

with Deployment(pipeline).run(keep=True) as run:
    print(run.value("consumer"))
42

В манифесте seed получит native с источником default, а consumer — venv-subprocess с источником node-tag. При необходимости один запуск можно переопределить без изменения пайплайна:

result = Deployment(pipeline).run(
    output="consumer",
    runtimes={"consumer": "native"},
)
print(result)
42
Два рантайма в манифесте одного пайплайна
Два рантайма в манифесте одного пайплайна

seed остался в native, а consumer ушёл в отдельный процесс; источник выбора хранится рядом.

Для локального Deployment.run() Docker‑образ нужно указать явно в runtime_config. При запуске через демон он может быть подготовлен из окружения объекта. В режимах network="auto" и network="none" контейнер стартует с --network none; сеть включается только явным network="enabled".

Есть и ограничение актуальной версии splime 0.4.9: входы venv-subprocess и per‑node Docker должны быть JSON‑native. Внутренний файловый транспорт пользовательских типов к этим рантаймам ещё не подключён. Если между нодами идёт numpy.ndarray, понадобится native, явная нода‑конвертер или другой способ передать JSON‑подобное значение.

Где искать причину, если окружение не собралось

Первая команда — не ручной просмотр пяти каталогов, а:

spl-daemon doctor

Она проверяет Python, venv/ensurepip, наличие uv, daemon home и свободное место, доступность демона, кэш окружений, подмены интерпретатора и Docker. Записи сборок можно посмотреть через spl-daemon env-build-list, а одну конкретную — через spl-daemon env-build-show <spec-hash>. Полный вывод установщика лежит в указанном там install.log.

Результат spl-daemon doctor в чистом окружении
Результат spl‑daemon doctor в чистом окружении

В проверенном окружении 0.4.9 все 12 проверок завершились без предупреждений.

Практическая деталь: auto‑build запускается в фоне. Статус creating сразу после publish() не означает зависание: первый вызов дождётся уже начатой сборки. Файл build.lock защищает кэш, если несколько процессов попытаются собирать одно окружение одновременно. Если статус стал failed, ответ обычно уже лежит в install.log — например, пакет не существует для выбранной версии Python.

Где такой подход уместен, а где нет

Если у вас десяток лёгких функций с одинаковыми зависимостями, обычный Python‑пакет проще и быстрее. Если нужен долгоживущий API для разных языков и клиентов, честнее сделать сервис. Airflow, Prefect и Temporal отвечают за расписания и оркестрацию, а Ray — за распределённые вычисления; splime их не заменяет.

Отдельное окружение полезно, когда функция уже имеет собственный вес: модель, парсер документов, обработчик табличных данных, код с редкой или конфликтующей зависимостью. Тогда сборка venv окупается переиспользованием и кэшем.

И ещё раз про доверие: splime запускает код, который вы сами решили опубликовать. Отдельный venv защищает проекты от конфликтов пакетов, но не машину от функции.

Попробовать и поспорить

Пример days_until_due выше — полный локальный сценарий: нужны Python 3.13+ и доступ к PyPI. Исходники и тесты лежат на GitHub, замечания можно оставить в issues.

Интересно, как вы сегодня решаете подобные задачи: общий пакет, контейнер, внутренний сервис или собственный runner? И где для вас проходит граница, после которой отдельный venv на функцию оправдывает сложность?

В следующей статье — что делать, когда пайплайн упал после долгого шага: сохранить манифест, исправить причину и продолжить с нужной ноды, не пересчитывая апстрим.

splime пока в alpha; публичный API может меняться между релизами.