setup.py
Rama main · réplica de lectura del repositorio original.
"""Custom setuptools build for omnigent.
Generates ``omnigent/_build_info.py`` at wheel build time so the
CLI's update-check (``omnigent/update_check.py``) can tell the user
when their installed build is stale without having to consult
``git`` or hit a remote endpoint at startup.
All other build configuration lives in ``pyproject.toml``; this
file exists solely to register the cmdclass override that runs the
generator before ``build_py`` copies sources into the wheel.
The generated file is gitignored — it is recreated on every build
and only meaningful at install time, where it travels inside the
wheel alongside the rest of the package.
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from setuptools import setup
from setuptools.command.build_py import build_py
_MINIMUM_NODE_VERSION = (22, 13, 0)
_MINIMUM_NODE_VERSION_TEXT = ".".join(str(part) for part in _MINIMUM_NODE_VERSION)
class _GenerateBuildInfo(build_py):
"""Subclass of ``build_py`` that writes ``_build_info.py``.
The override is the smallest possible intervention: run the
generator, then defer to the stock ``build_py`` to copy sources
(including the freshly-written ``_build_info.py``) into the
wheel's build directory. No other behavior of the build is
changed.
"""
def run(self) -> None:
"""Build the web UI, generate ``_build_info.py``, then run build_py."""
self._build_web_ui()
self._write_build_info()
super().run()
self._bundle_examples()
self._bundle_scripts()
def _bundle_scripts(self) -> None:
"""Copy top-level maintenance scripts into package resources."""
import shutil
root = Path(__file__).resolve().parent
src = root / "scripts" / "uninstall_oss.sh"
if not src.is_file():
return
dest = Path(self.build_lib) / "omnigent" / "resources" / "scripts" / src.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
def _bundle_examples(self) -> None:
"""Copy bundled example agents into the wheel as real directories.
``omnigent/resources/examples/{polly,debby}`` may exist as symlinks
into the top-level ``examples/`` tree (or not at all) depending on
the checkout, and setuptools' ``package-data`` never materializes
symlinks into the built wheel — a directory symlink is not walked.
A plain ``pip install`` / ``uv tool install`` would then ship a
package whose ``omnigent.resources.examples`` has no ``polly`` /
``debby`` subdir, and bare ``omnigent`` (first-run default → polly)
dies with "Agent path not found".
Fix: after ``build_py`` has populated ``build_lib``, copy the real
example trees from the top-level ``examples/`` dir (present in every
checkout) into
``build_lib/omnigent/resources/examples/<name>`` so every wheel is
self-contained. This honors the contract documented in cli.py's
``_bundled_polly_path``: a symlink in a checkout, a real directory in
an installed wheel. Editable installs (``uv sync``) resolve the
in-checkout symlink directly and don't need this.
"""
import shutil
root = Path(__file__).resolve().parent
dest_root = Path(self.build_lib) / "omnigent" / "resources" / "examples"
for name in ("debby", "polly"):
src = root / "examples" / name
if not src.is_dir():
continue
dst = dest_root / name
if dst.is_symlink() or dst.is_file():
dst.unlink()
elif dst.is_dir():
shutil.rmtree(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
def _build_web_ui(self) -> None:
"""Build the web SPA into ``omnigent/server/static/web-ui/``.
The server mounts that directory at ``/`` when present
(``omnigent/server/app.py``); when absent it serves an
API-only JSON landing page and the web UI is unreachable.
The bundle is Vite build output, not tracked in git, so a
plain ``pip install .`` / ``uv tool install`` from a checkout
would otherwise ship no UI — the single most common "the web
UI doesn't load" report.
``web/`` is a package in a pnpm workspace (``pnpm-workspace.yaml``
and ``pnpm-lock.yaml`` at the repo root, ``packageManager:
pnpm@11.15.1`` in the root ``package.json``), so the install and
build run against the **workspace root** with ``--filter web``,
matching ``deploy/databricks/build.sh`` and the CI workflows.
Running ``pnpm install`` from inside ``web/`` would miss the
committed lockfile and resolve against ``package.json`` alone —
the legacy npm path that hit peer-dependency conflicts.
Build policy, chosen to fix that case without slowing the
backend-only dev loop or breaking node-less CI:
- Skip if ``web/`` is absent (sdists that don't vendor it).
- Skip if ``OMNIGENT_SKIP_WEB_UI=true``. The hardened CI
runners ship pnpm but have no fast registry mirror
configured for the lint/test shards, so ``pnpm install``
crawls against the public registry and hits the 600s
timeout — 10 wasted minutes per ``uv sync`` for a bundle
those jobs never serve. They set this env var to opt out.
- Skip if the bundle already exists, UNLESS
``OMNIGENT_BUILD_WEB_UI=1`` forces a rebuild. This keeps
repeat ``uv sync`` fast for backend devs (build once, reuse)
while letting release builds force a fresh bundle.
- Otherwise the build MUST succeed: a Node.js older than 22.13
(or absent), a missing pnpm, or a failing ``pnpm install`` /
``pnpm --filter web run build`` aborts the install with an
actionable error. Omnigent needs Node 22 LTS + pnpm at
runtime anyway (the Claude / Codex / Pi harness CLIs are
npm packages, and the web UI is a pnpm workspace), so a
node-less machine would get a broken install either way —
failing here, with a message that says how to fix it, beats
a silent API-only install that surfaces later as "the web
UI doesn't load".
The pnpm commands run with ``COREPACK_ENABLE_DOWNLOAD_PROMPT=0``
and no stdin: corepack (whether reached via its ``pnpm`` shim or
as ``corepack pnpm``) otherwise blocks on an interactive
confirmation before downloading the pinned pnpm, which a
non-interactive install can never answer.
:raises SystemExit: If the on-PATH Node.js is older than 22.13
(or absent), pnpm is not on PATH (and corepack can't supply
it), or the web UI build fails, and no skip condition applies.
"""
import os
import shutil
root = Path(__file__).resolve().parent
web_src = root / "web"
bundle = root / "omnigent" / "server" / "static" / "web-ui" / "index.html"
if not (web_src / "package.json").is_file():
return
# CI opt-out: exact "true" only — this is set by our own
# workflows, not user-facing config.
if os.environ.get("OMNIGENT_SKIP_WEB_UI") == "true":
return
force_raw = os.environ.get("OMNIGENT_BUILD_WEB_UI")
force = force_raw is not None and force_raw.strip().lower() in (
"1",
"true",
"yes",
)
if bundle.is_file() and not force:
return
# pnpm 11.15.1 requires Node 22.13 and is stricter than Vite 8 and
# oxlint. Enforce the effective toolchain floor before invoking pnpm.
node_version = _require_supported_node()
# pnpm first; fall back to corepack (bundled with Node 22+),
# which downloads the pnpm version pinned by the root
# package.json's ``packageManager`` field on first use.
pnpm = shutil.which("pnpm")
if pnpm is not None:
pnpm_cmd = [pnpm]
else:
corepack = shutil.which("corepack")
if corepack is not None:
pnpm_cmd = [corepack, "pnpm"]
else:
pnpm_cmd = None
if pnpm_cmd is None:
raise SystemExit(
"omnigent build: pnpm not found on PATH, so the web UI "
"cannot be built. Omnigent requires Node.js 22 LTS or "
"newer with pnpm (the web UI is a pnpm workspace; the "
"Claude / Codex / Pi harness CLIs are npm packages). "
"Install Node from https://nodejs.org/en/download and "
"enable pnpm with `corepack enable` (or `npm install -g "
"pnpm`), then rerun the install. To deliberately install "
"without the web UI (API-only server), set "
"OMNIGENT_SKIP_WEB_UI=true."
)
# A ``pnpm`` on PATH is often a corepack shim, which asks "Do you
# want to continue? [Y/n]" before fetching the pinned pnpm. That
# prompt is invisible under a build backend, so the install looks
# hung; "0" downloads without asking (shims default it to "1").
env = {**os.environ, "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"}
try:
# Workspace root, not ``web/``: the lockfile and workspace
# manifest live at the repo root. ``--frozen-lockfile``
# matches CI and guarantees the build is reproducible from
# the committed ``pnpm-lock.yaml``. No stdin, so nothing in
# the toolchain can block on input we can never deliver.
subprocess.run(
[*pnpm_cmd, "install", "--frozen-lockfile", "--filter", "web"],
cwd=root,
check=True,
timeout=600,
stdin=subprocess.DEVNULL,
env=env,
)
subprocess.run(
[*pnpm_cmd, "--filter", "web", "run", "build"],
cwd=root,
check=True,
timeout=600,
stdin=subprocess.DEVNULL,
env=env,
)
except (subprocess.SubprocessError, OSError) as exc:
raise SystemExit(
f"omnigent build: web UI build failed on Node.js {node_version} "
f"({_subprocess_failure_details(exc)}). Fix the failure above "
"and rerun the install. If "
"this Node.js release is incompatible with pnpm or Vite, "
f"upgrade to Node.js {_MINIMUM_NODE_VERSION_TEXT} or newer and "
"retry. To deliberately install "
"without the web UI (API-only server), set "
"OMNIGENT_SKIP_WEB_UI=true."
) from exc
def _write_build_info(self) -> None:
"""Write ``omnigent/_build_info.py`` into the source tree.
Writing to the source tree (rather than directly into the
build dir) means editable installs (``pip install -e .``,
``uv sync``) also get the file — they're a single
``build_py`` invocation against an in-place package — and
any later non-build code path that does ``from omnigent
import _build_info`` works without re-running the build.
"""
# Keep generated names and types aligned with omnigent/_build_info.pyi.
target = Path(__file__).resolve().parent / "omnigent" / "_build_info.py"
commit = _git_sha()
# Use repr() for the SHA so quoting is always correct, even
# for an empty fallback. The format is deliberately minimal
# — anything more elaborate (version strings, branch names)
# belongs in pyproject.toml or git tags, not here.
target.write_text(
'"""Auto-generated at wheel build time; do not edit.\n\n'
"This module is created by ``setup.py`` immediately before\n"
"``build_py`` packages the wheel, and is gitignored so it\n"
"is recreated on every build. Consumers should import it\n"
"defensively (``try: from omnigent import _build_info``)\n"
"because source checkouts that have never been built will\n"
"not have it on disk.\n"
'"""\n'
"from __future__ import annotations\n\n"
f"BUILD_TIME_EPOCH: int = {int(time.time())}\n"
f"COMMIT_SHA: str = {commit!r}\n"
)
def _require_supported_node() -> str:
"""Return the Node.js version or abort when it is older than 22.13.
The pinned pnpm 11.15.1 requires Node 22.13. Newer Node releases
remain valid.
:returns: The normalized Node.js version string.
:raises SystemExit: If ``node`` is missing, ``node --version`` fails,
or the reported version is older than 22.13.
"""
import shutil
node = shutil.which("node")
if node is None:
raise SystemExit(
"omnigent build: Node.js not found on PATH, so the web UI "
f"cannot be built. Node.js {_MINIMUM_NODE_VERSION_TEXT} or newer "
"is required. Install it from https://nodejs.org/en/download and "
"retry, or run with OMNIGENT_SKIP_WEB_UI=true to skip the web UI "
"build."
)
try:
result = subprocess.run(
[node, "--version"],
check=True,
capture_output=True,
text=True,
timeout=10,
)
except (subprocess.SubprocessError, OSError) as exc:
raise SystemExit(
f"omnigent build: could not determine the Node.js version "
f"(`node --version` failed: {exc}). Node.js "
f"{_MINIMUM_NODE_VERSION_TEXT} or newer is required. Install it "
"from https://nodejs.org/en/download and retry, or run with "
"OMNIGENT_SKIP_WEB_UI=true to skip the web UI build."
) from exc
# ``node --version`` prints ``v22.14.0``.
version_str = result.stdout.strip().lstrip("v")
try:
version_parts = tuple(int(part) for part in version_str.split(".")[:3])
if len(version_parts) != 3:
raise ValueError
except ValueError:
raise SystemExit(
f"omnigent build: could not parse Node.js version "
f"{version_str or 'unknown'}. Node.js "
f"{_MINIMUM_NODE_VERSION_TEXT} or newer is required. Install it "
"from https://nodejs.org/en/download and retry, or run with "
"OMNIGENT_SKIP_WEB_UI=true to skip the web UI build."
) from None
if version_parts < _MINIMUM_NODE_VERSION:
raise SystemExit(
f"omnigent build: Node.js {_MINIMUM_NODE_VERSION_TEXT} or newer "
f"is required but found Node.js {version_str}. Upgrade from "
"https://nodejs.org/en/download and retry, or run with "
"OMNIGENT_SKIP_WEB_UI=true to skip the web UI build."
)
return version_str
def _subprocess_failure_details(exc: BaseException) -> str:
"""Return an actionable subprocess failure summary, including stderr."""
details = str(exc)
stderr = getattr(exc, "stderr", None)
if isinstance(stderr, bytes):
stderr = stderr.decode(errors="replace")
if isinstance(stderr, str):
stderr = stderr.strip()
if stderr and stderr not in details:
details = f"{details}; stderr: {stderr}"
return details
def _git_sha() -> str:
"""Return the current Git HEAD SHA, or empty string on failure.
Empty-string fallback is intentional: when this is run inside a
Docker build context with no ``git`` binary, or when the build
happens from an sdist that has no ``.git/`` directory, the field
must still be populated with a stable string so the generated
module remains importable. The CLI update-check treats an empty
SHA as "no commit info available" and silently falls back to
timestamp-only nag logic.
:returns: 40-character full hex SHA, or ``""`` on any failure.
"""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
return ""
return result.stdout.strip()
setup(cmdclass={"build_py": _GenerateBuildInfo})