Полезная внутренняя функция редко остаётся функцией без зависимостей. Сегодня ей понадобился 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/activatepython -m pip install "splime==0.4.9"export SPL_DAEMON_HOME="$PWD/.splime-article-daemon"spl-daemon serve --auto-port
Оставим демон работать. В соседнем терминале перейдём в ту же директорию и активируем то же клиентское окружение:
. .venv/bin/activateexport SPL_DAEMON_HOME="$PWD/.splime-article-daemon"
Сохраним следующий файл как demo.py. Он считает срок оплаты счёта через dateutil.parser.isoparse, но python-dateutil объявлен только в описании функции — в .venv его нет.
import importlib.utilfrom spl import SPLClientassert importlib.util.find_spec("dateutil") is NoneINVOICE_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: Noneresult: 29environment: ready [{'package': 'python-dateutil', 'version': '2.9.0.post0'}]
Вот тот самый полезный эффект: клиент не импортирует и не устанавливает python-dateutil, а функция получает ровно объявленную версию. Следующий вызов использует уже готовое окружение.
Что произошло между publish и call
В примере есть три разных места, которые легко мысленно склеить ведино:
-
.venv— окружение скриптаdemo.py. В нём установлен splime, но нетpython-dateutil. -
Каталог демона (
daemon home) — реестр объектов, запусков и собранных окружений. -
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. Выбранный сборщик тоже входит в хеш: каталоги у этих окружений устроены по-разному и не должны случайно делить кэш.
Внутренняя схема: спецификация даёт spec_hash, по нему находится или строится venv.
У каждой сборки остаются install.log и запись со статусом. По умолчанию демон начинает сборку сразу после регистрации, не дожидаясь вызова. Процесс асинхронный, поэтому немедленный первый call() всё равно нужно немного подождать — именно это произошло в примере. С ключом --no-auto-build-envs на уровне локального демона сборка откладывается до первого запуска.
В install.log видны реальный интерпретатор, команды сборщика и установленные версии.
Путь Python, записанный автором, не стоит слепо повторять на другой машине. Для объекта, пришедшего с сервера, демон сначала ищет локально зарегистрированное окружение с тем же именем, затем default, затем собственный интерпретатор. Если выбор отличается от авторского, в запуске появляется interpreter_substitution, а spl-daemon doctor показывает несовпадение минорной версии. env_python здесь — сведения о происхождении объекта, а не переносимый абсолютный путь.
Venv и runtime — не одно и то же
Окружение отвечает на вопрос «какие пакеты и Python нужны объекту». Рантайм отвечает на другой: «как запустить конкретную функцию (в том числе внутри пайплайна)».
|
Рантайм ноды |
Что происходит |
Когда подходит |
|---|---|---|
|
|
Функция выполняется в процессе, где вызывается |
Доверенный код, где важны минимальные накладные расходы. |
|
|
Для функции запускается отдельный процесс с подготовленным Python и splime-free раннером. |
Нужны отдельный процесс и изоляция зависимостей. |
|
|
Для функции запускается отдельный контейнер. |
Нужны системные пакеты или более жёсткая граница исполнения. |
Виртуальное окружение не является песочницей безопасности. native и venv-subprocess работают от имени того же пользователя ОС, что и вызывающий процесс; второй вариант отделяет процесс и зависимости, но не запрещает читать доступные этому пользователю файлы. Для недоверенного кода нужен Docker с проверенными mount-ами либо отдельная учётная запись ОС.
При этом рантайм можно назначить конкретной ноде, не отправляя в него весь пайплайн:
from spl import Deployment, liftdef seed_json() -> int: return 21def double_json(value: int) -> int: return value * 2pipeline = ( 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.
В проверенном окружении 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, документация и текущий статус — на splime.io, замечания можно оставить в issues.
Интересно, как вы сегодня решаете подобные задачи: общий пакет, контейнер, внутренний сервис или собственный runner? И где для вас проходит граница, после которой отдельный venv на функцию оправдывает сложность?
В следующей статье — что делать, когда пайплайн упал после долгого шага: сохранить манифест, исправить причину и продолжить с нужной ноды, не пересчитывая апстрим.
splime пока в alpha; публичный API может меняться между релизами.
ссылка на оригинал статьи https://habr.com/ru/articles/1077882/