chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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 npm-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.
|
||||
|
||||
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 a system ``npm`` but have no fast registry
|
||||
mirror configured for the lint/test shards, so ``npm
|
||||
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 missing ``npm`` or a
|
||||
failing ``npm install`` / ``npm run build`` aborts the
|
||||
install with an actionable error. Omnigent needs Node +
|
||||
npm at runtime anyway (the Claude / Codex / Pi harness
|
||||
CLIs are npm packages), 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".
|
||||
|
||||
:raises SystemExit: If ``npm`` is not on PATH 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
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
raise SystemExit(
|
||||
"omnigent build: npm not found on PATH, so the web UI "
|
||||
"cannot be built. Omnigent requires Node.js 22 LTS or "
|
||||
"newer with npm (the Claude / Codex / Pi harness CLIs are "
|
||||
"npm packages). Install it from "
|
||||
"https://nodejs.org/en/download and rerun the install. "
|
||||
"To deliberately install without the web UI (API-only "
|
||||
"server), set OMNIGENT_SKIP_WEB_UI=true."
|
||||
)
|
||||
try:
|
||||
subprocess.run([npm, "install"], cwd=web_src, check=True, timeout=600)
|
||||
subprocess.run([npm, "run", "build"], cwd=web_src, check=True, timeout=600)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise SystemExit(
|
||||
f"omnigent build: web UI build failed ({exc}). Fix the "
|
||||
"failure above (it usually means Node.js is older than the "
|
||||
"required 22 LTS, or `npm install` could not reach the npm "
|
||||
"registry) and rerun the install. 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.
|
||||
"""
|
||||
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 _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})
|
||||
Reference in New Issue
Block a user