fix: bundle all runtime files in aisbf/_share/ so wheel install is self-contained

pip's data_files extraction is unreliable for user installs with
--break-system-packages: files either land in the wrong prefix or are
silently dropped from the wheel entirely.

Fix: build_py hook in setup.py copies main.py, aisbf.sh, requirements.txt,
templates/, static/, and config/ into aisbf/_share/ at wheel-build time.
These are declared as package_data so pip always installs them to
site-packages/aisbf/_share/ regardless of install mode.

cli.py now:
- Checks all sysconfig-derived paths for a complete share directory
- Falls back to bootstrapping: copies the full aisbf/_share/ bundle to
  ~/.local/share/aisbf/ on first run if data_files were not installed
- Works for both wheel installs (uses _share/) and editable installs
  (uses the project root directly as the bundle source)
- Prints a clear error with checked paths and reinstall instructions if
  bootstrap also fails

aisbf/_share/ is gitignored (generated at build time).
parent b52af5a4
...@@ -168,3 +168,6 @@ Thumbs.db ...@@ -168,3 +168,6 @@ Thumbs.db
# Worktrees # Worktrees
.worktrees/ .worktrees/
docs/superpowers/ docs/superpowers/
# Generated at wheel-build time by the build_py hook in setup.py — do not commit
aisbf/_share/
...@@ -29,6 +29,12 @@ import shutil ...@@ -29,6 +29,12 @@ import shutil
from pathlib import Path from pathlib import Path
# Files and directories that must be present in the share directory for the
# server to start correctly.
_REQUIRED_FILES = ['aisbf.sh', 'main.py', 'requirements.txt']
_REQUIRED_DIRS = ['templates', 'static', 'config']
def _share_dir_candidates(): def _share_dir_candidates():
"""Return candidate share directories in priority order, deduplicated.""" """Return candidate share directories in priority order, deduplicated."""
seen = set() seen = set()
...@@ -41,7 +47,7 @@ def _share_dir_candidates(): ...@@ -41,7 +47,7 @@ def _share_dir_candidates():
result.append(Path(p)) result.append(Path(p))
# sysconfig paths for the running Python interpreter — covers venvs, # sysconfig paths for the running Python interpreter — covers venvs,
# system installs, and user installs regardless of scheme. # system installs, and user installs regardless of pip's install scheme.
add(Path(sysconfig.get_path('data')) / 'share' / 'aisbf') add(Path(sysconfig.get_path('data')) / 'share' / 'aisbf')
for scheme in ('posix_user', 'posix_prefix', 'posix_home'): for scheme in ('posix_user', 'posix_prefix', 'posix_home'):
...@@ -50,7 +56,7 @@ def _share_dir_candidates(): ...@@ -50,7 +56,7 @@ def _share_dir_candidates():
except Exception: except Exception:
pass pass
# Legacy hardcoded fallbacks (setup.py installs here for system-wide) # Legacy hardcoded fallbacks
add(Path('/usr/local/share/aisbf')) add(Path('/usr/local/share/aisbf'))
add(Path('/usr/share/aisbf')) add(Path('/usr/share/aisbf'))
add(Path.home() / '.local' / 'share' / 'aisbf') add(Path.home() / '.local' / 'share' / 'aisbf')
...@@ -58,34 +64,77 @@ def _share_dir_candidates(): ...@@ -58,34 +64,77 @@ def _share_dir_candidates():
return result return result
def _share_dir_is_complete(d):
"""Return True only if d contains every file/dir the server needs."""
return all((d / f).exists() for f in _REQUIRED_FILES + _REQUIRED_DIRS)
def _find_share_dir(): def _find_share_dir():
"""Return the first candidate that contains aisbf.sh, or None.""" """Return the first complete share directory found, or None."""
for candidate in _share_dir_candidates(): for candidate in _share_dir_candidates():
if (candidate / 'aisbf.sh').exists(): if _share_dir_is_complete(candidate):
return candidate return candidate
return None return None
def _bootstrap_from_package(): def _pkg_bundle_dir():
""" """
Last-resort: copy aisbf.sh from the bundled package data to Return the aisbf/_share/ directory bundled inside the installed package,
~/.local/share/aisbf/ so the user can at least run the script. or None if it doesn't exist (e.g. editable source install before build).
The other runtime files (main.py, templates, …) still need to be Falls back to the project root for editable installs.
present — this only fixes the 'aisbf.sh not found' error.
""" """
try: try:
import aisbf as _pkg import aisbf as _pkg
bundled = Path(_pkg.__file__).parent / 'aisbf.sh' pkg_dir = Path(_pkg.__file__).parent
if not bundled.exists():
return None # Normal wheel install: _share/ populated by build_py hook
share = pkg_dir / '_share'
dest_dir = Path.home() / '.local' / 'share' / 'aisbf' if share.exists() and (share / 'main.py').exists():
dest_dir.mkdir(parents=True, exist_ok=True) return share
dest = dest_dir / 'aisbf.sh'
shutil.copy2(str(bundled), str(dest)) # Editable install: files live in the project root (one level up)
dest.chmod(dest.stat().st_mode | 0o755) project_root = pkg_dir.parent
return dest_dir if (project_root / 'main.py').exists():
return project_root
except Exception: except Exception:
pass
return None
def _bootstrap_share_dir():
"""
Copy all runtime files from the bundled package data to
~/.local/share/aisbf/ and return the destination path, or None on failure.
"""
bundle = _pkg_bundle_dir()
if bundle is None:
return None
dest = Path.home() / '.local' / 'share' / 'aisbf'
try:
dest.mkdir(parents=True, exist_ok=True)
for fname in _REQUIRED_FILES:
src = bundle / fname
if src.exists():
shutil.copy2(src, dest / fname)
if fname.endswith('.sh'):
p = dest / fname
p.chmod(p.stat().st_mode | 0o755)
for dname in _REQUIRED_DIRS:
src = bundle / dname
dst = dest / dname
if src.exists():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
return dest if _share_dir_is_complete(dest) else None
except Exception as e:
print(f"Warning: could not bootstrap share directory: {e}", file=sys.stderr)
return None return None
...@@ -93,32 +142,31 @@ def main(): ...@@ -93,32 +142,31 @@ def main():
share_dir = _find_share_dir() share_dir = _find_share_dir()
if share_dir is None: if share_dir is None:
share_dir = _bootstrap_from_package() print(
"AISBF share directory not found or incomplete — bootstrapping from package data …",
file=sys.stderr,
)
share_dir = _bootstrap_share_dir()
if share_dir: if share_dir:
print( print(
"Warning: AISBF data files were not installed by pip to the expected\n" f"Bootstrapped to {share_dir}. Future runs will use this location.",
f"location. Bootstrapped aisbf.sh to {share_dir}.\n"
"If the server fails to start, runtime files (main.py, templates/,\n"
"static/, config/, requirements.txt) may be missing from that directory.\n"
"Re-install from source to fix this:\n"
" pip install aisbf --no-binary aisbf",
file=sys.stderr, file=sys.stderr,
) )
else:
if share_dir is None or not (share_dir / 'aisbf.sh').exists(): checked = '\n'.join(f' - {p}' for p in _share_dir_candidates())
checked = '\n'.join(f' - {p}' for p in _share_dir_candidates()) print(
print( "Error: could not set up the AISBF share directory.\n\n"
"Error: AISBF share directory not found.\n" "Checked locations:\n"
"The data files may not have been installed correctly from the wheel.\n\n" f"{checked}\n\n"
"Checked locations:\n" "The wheel may have been built without runtime files.\n"
f"{checked}\n\n" "Re-install from a source distribution to fix this:\n"
"To fix, reinstall from source so pip can place files correctly:\n" " pip install aisbf --no-binary aisbf\n"
" pip install aisbf --no-binary aisbf\n" "Or install system-wide as root:\n"
"Or install system-wide as root:\n" " sudo pip install aisbf",
" sudo pip install aisbf", file=sys.stderr,
file=sys.stderr, )
) sys.exit(1)
sys.exit(1)
script_path = share_dir / 'aisbf.sh' script_path = share_dir / 'aisbf.sh'
......
...@@ -23,8 +23,10 @@ Why did the programmer quit his job? Because he didn't get arrays! ...@@ -23,8 +23,10 @@ Why did the programmer quit his job? Because he didn't get arrays!
from setuptools import setup, find_packages from setuptools import setup, find_packages
from setuptools.command.install import install as _install from setuptools.command.install import install as _install
from setuptools.command.build_py import build_py as _build_py
from pathlib import Path from pathlib import Path
import os import os
import shutil
import sys import sys
# Read the contents of README file # Read the contents of README file
...@@ -37,9 +39,44 @@ if (this_directory / "requirements.txt").exists(): ...@@ -37,9 +39,44 @@ if (this_directory / "requirements.txt").exists():
with open(this_directory / "requirements.txt") as f: with open(this_directory / "requirements.txt") as f:
requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")] requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")]
class build_py(_build_py):
"""Populate aisbf/_share/ with runtime files before the wheel is assembled.
data_files in wheels are not reliably installed by pip for all install
modes (user vs system, --break-system-packages, etc.). Bundling the
runtime files as package_data inside aisbf/_share/ guarantees they land
in site-packages/aisbf/_share/ and can be extracted by cli.py on first run.
"""
_SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh']
_SHARE_DIRS = ['templates', 'static', 'config']
def run(self):
self._populate_share()
super().run()
def _populate_share(self):
root = Path(__file__).parent
share = root / 'aisbf' / '_share'
share.mkdir(exist_ok=True)
for fname in self._SHARE_FILES:
src = root / fname
if src.exists():
shutil.copy2(src, share / fname)
for dname in self._SHARE_DIRS:
src = root / dname
dst = share / dname
if src.exists():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
class InstallCommand(_install): class InstallCommand(_install):
"""Custom install command that adds --user flag for non-root users""" """Custom install command that adds --user flag for non-root users"""
def initialize_options(self): def initialize_options(self):
_install.initialize_options(self) _install.initialize_options(self)
# Check if running as non-root without --user flag # Check if running as non-root without --user flag
...@@ -75,9 +112,23 @@ setup( ...@@ -75,9 +112,23 @@ setup(
# install_requires=requirements, # install_requires=requirements,
include_package_data=True, include_package_data=True,
package_data={ package_data={
# aisbf.sh bundled inside the package so cli.py can bootstrap the share # _share/ is populated at build time by the build_py hook above and
# directory even when pip fails to install data_files from the wheel. # bundled in the wheel so cli.py can extract it when data_files fail.
"aisbf": ["*.json", "aisbf.sh"], "aisbf": [
"*.json",
"aisbf.sh",
"_share/main.py",
"_share/requirements.txt",
"_share/aisbf.sh",
"_share/templates/*.html",
"_share/templates/**/*.html",
"_share/templates/**/*.css",
"_share/templates/**/*.js",
"_share/static/*",
"_share/static/**/*",
"_share/config/*",
"_share/config/**/*",
],
"": ["templates/**/*.html", "templates/**/*.css", "templates/**/*.js", "static/**/*"], "": ["templates/**/*.html", "templates/**/*.css", "templates/**/*.js", "static/**/*"],
}, },
data_files=[ data_files=[
...@@ -313,6 +364,7 @@ setup( ...@@ -313,6 +364,7 @@ setup(
], ],
}, },
cmdclass={ cmdclass={
'build_py': build_py,
'install': InstallCommand, 'install': InstallCommand,
}, },
) )
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment