Skip to content

Commit fc726b1

Browse files
author
Hana
committed
fix(setup): wizard install — voice template packaged + relocatable nell launcher
Two bugs surfaced during Hana's wizard validation against the Phase 7 bundled .app: 1. install_voice_template tried to read docs/voice-drafts/nell-voice.md from a path resolved relative to brain/setup.py — which is correct in source-installs but FAILS in any wheel install (the wheel ships only the brain/ package, not docs/). The nell-example voice template threw FileNotFoundError on every install_only path including the Phase 7 .app. 2. The pip-generated nell entry point at python-runtime/bin/nell had its python interpreter shebang baked to an absolute path from the build machine. The .app shipped a script that tried to exec /Users/<builder>/.../python-runtime/bin/python3 — a path that exists on the build machine and fails everywhere else. Hana's traceback shows the source-tree's brain/cli.py getting imported instead of the bundled one because the shebang resolved to the build host's python. Fix #1 — package the template: * docs/voice-drafts/nell-voice.md moved to brain/voice_templates/nell-voice.md * brain/voice_templates/__init__.py added so it's a real package (hatchling auto-includes everything under brain/) * install_voice_template reads via importlib.resources files('brain.voice_templates').joinpath('nell-voice.md'); no repo_root parameter, no path resolution * verified: wheel ships brain/voice_templates/nell-voice.md; nell init --voice-template nell-example writes the file correctly from a tmp NELLBRAIN_HOME Fix #2 — relocatable nell launcher: * build_python_runtime.sh post-install step replaces bin/nell with a 6-line /bin/sh wrapper that resolves $SCRIPT_DIR via cd "$(dirname "$0")" then execs "$SCRIPT_DIR/python3" with a -c 'from brain.cli import main; sys.exit(main())' payload. Whatever directory the python-runtime tree gets copied to, bin/nell finds its own python next door * Windows: pip's Scripts/nell.exe is already a relative-path launcher binary, so the script keeps it untouched * verified: bundled bin/nell runs --version from /tmp, init --voice-template nell-example writes voice.md Test updates: * test_install_voice_template_nell_example_copies_packaged_file replaces the old fake-repo-root test; asserts the canonical nell voice opens with '## 1. Who you are' and is > 1000 bytes * test_install_voice_template_nell_example_missing_file_raises removed (the importlib.resources path can't fail this way once the file ships in the wheel; ValueError on unknown template still tested) 1470 → 1469 (one removed test). Ruff clean. macOS arm64 .app re-bundled at ~/wizard-validation/ with codesign verify pass.
1 parent 1cf3a6e commit fc726b1

5 files changed

Lines changed: 72 additions & 40 deletions

File tree

app/build_python_runtime.sh

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,47 @@ echo "[build] installing brain + deps into bundled python"
125125
"$PY_BIN" -m pip install --quiet --upgrade pip
126126
"$PY_BIN" -m pip install --quiet "$WHEEL"
127127

128-
# 5. Verify the brain entry point + import work in the bundled python
128+
# 5. Replace the pip-generated nell entry point with a relocatable
129+
# wrapper. pip bakes the absolute path of *this build's* python into
130+
# the script's shebang, which means the bundled nell.app will try to
131+
# exec the build host's python-runtime/python3 on a USER'S
132+
# machine where that path does not exist (or worse, exists from a
133+
# stale build). The wrapper resolves the bundled python by relative
134+
# path so the runtime tree is fully relocatable.
135+
if [ "$HOST_OS" != "windows" ]; then
136+
echo "[build] writing relocatable nell wrapper (Unix)"
137+
cat > "$NELL_BIN" <<'NELL_WRAPPER'
138+
#!/bin/sh
139+
# Relocatable nell launcher — runs whichever python3 lives next door.
140+
# Generated by app/build_python_runtime.sh; do not pip-reinstall over.
141+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
142+
exec "$SCRIPT_DIR/python3" -c '
143+
import sys
144+
from brain.cli import main
145+
sys.exit(main())
146+
' "$@"
147+
NELL_WRAPPER
148+
chmod +x "$NELL_BIN"
149+
else
150+
# On Windows pip generates Scripts/nell.exe, a small launcher binary
151+
# that already resolves python via relative path lookup. No wrapper
152+
# rewrite needed. (If a future Windows setuptools ever bakes an
153+
# absolute path, mirror the Unix wrapper using a .bat file.)
154+
echo "[build] Windows: keeping pip-generated Scripts/nell.exe (relative-path launcher)"
155+
fi
156+
157+
# 6a. Verify the brain entry point + import work in the bundled python
129158
echo "[build] verify brain import + entry point"
130159
"$PY_BIN" -c "import brain; import brain.cli; print(' brain:', brain.__file__)"
131160
"$NELL_BIN" --version
132-
133-
# 6. Strip pyc cache + tests dirs to shrink the bundle a bit
161+
echo "[build] verify nell can resolve brain.voice_templates"
162+
"$PY_BIN" -c "
163+
from importlib.resources import files
164+
content = files('brain.voice_templates').joinpath('nell-voice.md').read_text(encoding='utf-8')
165+
print(' nell-voice.md:', len(content), 'bytes')
166+
"
167+
168+
# 6b. Strip pyc cache + tests dirs to shrink the bundle a bit
134169
echo "[build] strip __pycache__ + tests from site-packages"
135170
find "$RUNTIME_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true
136171
find "$SITE_PACKAGES" -type d -name "tests" -prune -exec rm -rf {} + 2>/dev/null || true

brain/setup.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
),
5050
}
5151

52-
_NELL_VOICE_REPO_PATH = Path("docs/voice-drafts/nell-voice.md")
5352
_PERSONA_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
5453

5554

@@ -102,8 +101,6 @@ def write_persona_config(
102101
def install_voice_template(
103102
persona_dir: Path,
104103
template: str,
105-
*,
106-
repo_root: Path | None = None,
107104
) -> Path | None:
108105
"""Drop a starter voice.md into the persona dir based on `template`.
109106
@@ -115,10 +112,12 @@ def install_voice_template(
115112
template: One of VOICE_TEMPLATES keys ("default" / "skip" leave
116113
no file; "nell-example" copies the canonical Nell voice.md
117114
as a starting point — the user is expected to edit it).
118-
repo_root: Path to the companion-emergence repo (for locating
119-
the example voice.md). Defaults to the parent of this file's
120-
grandparent (which is the package root in dev installs).
121-
Pass an explicit path in tests.
115+
116+
The Nell example template is packaged inside the brain wheel at
117+
``brain/voice_templates/nell-voice.md`` and read via
118+
:mod:`importlib.resources`, so this works whether the framework
119+
is installed from source, from a wheel, or from inside the
120+
Phase 7 bundled NellFace.app.
122121
"""
123122
if template not in VOICE_TEMPLATES:
124123
raise ValueError(
@@ -133,17 +132,20 @@ def install_voice_template(
133132
return None
134133

135134
if template == VOICE_TEMPLATE_NELL_EXAMPLE:
136-
if repo_root is None:
137-
# Default: this module lives at brain/setup.py inside the repo
138-
repo_root = Path(__file__).resolve().parent.parent
139-
src = repo_root / _NELL_VOICE_REPO_PATH
140-
if not src.exists():
141-
raise FileNotFoundError(
142-
f"Nell voice example not found at {src}. The repo may "
143-
f"have been installed without docs/. Author your voice.md "
144-
f"by hand or use template='default'."
135+
from importlib.resources import files
136+
137+
try:
138+
content = (
139+
files("brain.voice_templates")
140+
.joinpath("nell-voice.md")
141+
.read_text(encoding="utf-8")
145142
)
146-
voice_path.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
143+
except (FileNotFoundError, ModuleNotFoundError) as exc:
144+
raise FileNotFoundError(
145+
"Nell voice example not packaged with this install of brain. "
146+
"Reinstall from a recent wheel, or use template='default'."
147+
) from exc
148+
voice_path.write_text(content, encoding="utf-8")
147149
return voice_path
148150

149151
# Defensive — the membership check above should make this unreachable

brain/voice_templates/__init__.py

Whitespace-only changes.
File renamed without changes.

tests/unit/brain/test_setup.py

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -89,28 +89,23 @@ def test_install_voice_template_default_writes_no_file(tmp_path: Path) -> None:
8989
assert not (persona_dir / "voice.md").exists()
9090

9191

92-
def test_install_voice_template_nell_example_copies_file(tmp_path: Path) -> None:
93-
"""nell-example template copies docs/voice-drafts/nell-voice.md verbatim."""
94-
# Build a fake repo root with the example file present
95-
repo_root = tmp_path / "repo"
96-
(repo_root / "docs" / "voice-drafts").mkdir(parents=True)
97-
sample = "# fake voice\n\nfor testing\n"
98-
(repo_root / "docs" / "voice-drafts" / "nell-voice.md").write_text(sample)
99-
92+
def test_install_voice_template_nell_example_copies_packaged_file(
93+
tmp_path: Path,
94+
) -> None:
95+
"""nell-example writes the packaged brain/voice_templates/nell-voice.md.
96+
97+
The template ships inside the brain wheel so it's available whether
98+
the framework is installed from source, from a wheel, or from inside
99+
the Phase 7 bundled NellFace.app — no `repo_root` lookup needed.
100+
"""
100101
persona_dir = tmp_path / "persona"
101-
result = install_voice_template(persona_dir, "nell-example", repo_root=repo_root)
102+
result = install_voice_template(persona_dir, "nell-example")
102103
assert result == persona_dir / "voice.md"
103-
assert result.read_text() == sample
104-
105-
106-
def test_install_voice_template_nell_example_missing_file_raises(tmp_path: Path) -> None:
107-
"""If the repo doesn't have the example (e.g. installed without docs/),
108-
fail loudly — better than writing nothing silently."""
109-
repo_root = tmp_path / "empty_repo"
110-
repo_root.mkdir()
111-
persona_dir = tmp_path / "persona"
112-
with pytest.raises(FileNotFoundError, match="not found"):
113-
install_voice_template(persona_dir, "nell-example", repo_root=repo_root)
104+
content = result.read_text(encoding="utf-8")
105+
# The shipped Nell voice draft is the canonical one — opens with
106+
# the section-1 header.
107+
assert "## 1. Who you are" in content
108+
assert len(content) > 1000 # not an empty file
114109

115110

116111
def test_install_voice_template_unknown_raises(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)