forked from omnigent-ai/omnigent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.py
More file actions
343 lines (305 loc) · 14.9 KB
/
Copy pathsetup.py
File metadata and controls
343 lines (305 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""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()
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 missing Node.js 22+,
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".
:raises SystemExit: If Node.js < 22 (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
# Enforce the Node.js 22 LTS floor up front. The web UI's
# toolchain (pnpm@11, Vite 8, oxlint) and the runtime harness
# CLIs all require Node 22; building on an older Node fails
# deep inside the toolchain with an opaque error, so we fail
# fast here with a single actionable message instead.
_require_node_22()
# 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."
)
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``.
subprocess.run(
[*pnpm_cmd, "install", "--frozen-lockfile", "--filter", "web"],
cwd=root,
check=True,
timeout=600,
)
subprocess.run(
[*pnpm_cmd, "--filter", "web", "run", "build"],
cwd=root,
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, pnpm is missing, or `pnpm 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.
"""
# 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_node_22() -> None:
"""Abort the build unless Node.js >= 22 is on PATH.
The web UI toolchain (pnpm 11, Vite 8, oxlint) and the runtime
harness CLIs (Claude / Codex / Pi, all npm packages) require Node 22
LTS. Building on an older Node fails deep inside the toolchain with
an opaque error; this check fails fast with a single actionable
message instead.
:raises SystemExit: If ``node`` is missing or reports a major
version below 22.
"""
import shutil
node = shutil.which("node")
if node is None:
raise SystemExit(
"omnigent build: Node.js not found on PATH, so the web UI "
"cannot be built. Omnigent requires Node.js 22 LTS or newer "
"(the web UI toolchain — pnpm 11 / Vite 8 / oxlint — and the "
"Claude / Codex / Pi harness CLIs all need it). Install it "
"from https://nodejs.org/en/download (the 22 LTS line or "
"newer) and rerun the install. To deliberately install "
"without the web UI (API-only server), set "
"OMNIGENT_SKIP_WEB_UI=true."
)
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}). Omnigent requires "
"Node.js 22 LTS or newer. Ensure Node 22+ is installed and "
"on PATH, then rerun the install. To deliberately install "
"without the web UI (API-only server), set "
"OMNIGENT_SKIP_WEB_UI=true."
) from exc
# ``node --version`` prints ``v22.14.0``; split off the leading ``v``
# and read the major. A non-numeric result is treated as too old.
version_str = result.stdout.strip().lstrip("v")
try:
major = int(version_str.split(".")[0])
except ValueError:
major = -1
if major < 22:
raise SystemExit(
f"omnigent build: Node.js {version_str or 'unknown'} is "
f"installed, but Omnigent requires Node.js 22 LTS or newer "
"(the web UI toolchain — pnpm 11 / Vite 8 / oxlint — and "
"the Claude / Codex / Pi harness CLIs all need Node 22+). "
"Upgrade from https://nodejs.org/en/download (pick the 22 "
"LTS line or newer) and rerun the install. To deliberately "
"install without the web UI (API-only server), set "
"OMNIGENT_SKIP_WEB_UI=true."
)
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})