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
# Worktrees
.worktrees/
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
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():
"""Return candidate share directories in priority order, deduplicated."""
seen = set()
......@@ -41,7 +47,7 @@ def _share_dir_candidates():
result.append(Path(p))
# 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')
for scheme in ('posix_user', 'posix_prefix', 'posix_home'):
......@@ -50,7 +56,7 @@ def _share_dir_candidates():
except Exception:
pass
# Legacy hardcoded fallbacks (setup.py installs here for system-wide)
# Legacy hardcoded fallbacks
add(Path('/usr/local/share/aisbf'))
add(Path('/usr/share/aisbf'))
add(Path.home() / '.local' / 'share' / 'aisbf')
......@@ -58,34 +64,77 @@ def _share_dir_candidates():
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():
"""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():
if (candidate / 'aisbf.sh').exists():
if _share_dir_is_complete(candidate):
return candidate
return None
def _bootstrap_from_package():
def _pkg_bundle_dir():
"""
Last-resort: copy aisbf.sh from the bundled package data to
~/.local/share/aisbf/ so the user can at least run the script.
The other runtime files (main.py, templates, …) still need to be
present — this only fixes the 'aisbf.sh not found' error.
Return the aisbf/_share/ directory bundled inside the installed package,
or None if it doesn't exist (e.g. editable source install before build).
Falls back to the project root for editable installs.
"""
try:
import aisbf as _pkg
bundled = Path(_pkg.__file__).parent / 'aisbf.sh'
if not bundled.exists():
return None
dest_dir = Path.home() / '.local' / 'share' / 'aisbf'
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / 'aisbf.sh'
shutil.copy2(str(bundled), str(dest))
dest.chmod(dest.stat().st_mode | 0o755)
return dest_dir
pkg_dir = Path(_pkg.__file__).parent
# Normal wheel install: _share/ populated by build_py hook
share = pkg_dir / '_share'
if share.exists() and (share / 'main.py').exists():
return share
# Editable install: files live in the project root (one level up)
project_root = pkg_dir.parent
if (project_root / 'main.py').exists():
return project_root
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
......@@ -93,32 +142,31 @@ def main():
share_dir = _find_share_dir()
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:
print(
"Warning: AISBF data files were not installed by pip to the expected\n"
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",
f"Bootstrapped to {share_dir}. Future runs will use this location.",
file=sys.stderr,
)
if share_dir is None or not (share_dir / 'aisbf.sh').exists():
checked = '\n'.join(f' - {p}' for p in _share_dir_candidates())
print(
"Error: AISBF share directory not found.\n"
"The data files may not have been installed correctly from the wheel.\n\n"
"Checked locations:\n"
f"{checked}\n\n"
"To fix, reinstall from source so pip can place files correctly:\n"
" pip install aisbf --no-binary aisbf\n"
"Or install system-wide as root:\n"
" sudo pip install aisbf",
file=sys.stderr,
)
sys.exit(1)
else:
checked = '\n'.join(f' - {p}' for p in _share_dir_candidates())
print(
"Error: could not set up the AISBF share directory.\n\n"
"Checked locations:\n"
f"{checked}\n\n"
"The wheel may have been built without runtime files.\n"
"Re-install from a source distribution to fix this:\n"
" pip install aisbf --no-binary aisbf\n"
"Or install system-wide as root:\n"
" sudo pip install aisbf",
file=sys.stderr,
)
sys.exit(1)
script_path = share_dir / 'aisbf.sh'
......
......@@ -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.command.install import install as _install
from setuptools.command.build_py import build_py as _build_py
from pathlib import Path
import os
import shutil
import sys
# Read the contents of README file
......@@ -37,9 +39,44 @@ if (this_directory / "requirements.txt").exists():
with open(this_directory / "requirements.txt") as f:
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):
"""Custom install command that adds --user flag for non-root users"""
def initialize_options(self):
_install.initialize_options(self)
# Check if running as non-root without --user flag
......@@ -75,9 +112,23 @@ setup(
# install_requires=requirements,
include_package_data=True,
package_data={
# aisbf.sh bundled inside the package so cli.py can bootstrap the share
# directory even when pip fails to install data_files from the wheel.
"aisbf": ["*.json", "aisbf.sh"],
# _share/ is populated at build time by the build_py hook above and
# bundled in the wheel so cli.py can extract it when data_files fail.
"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/**/*"],
},
data_files=[
......@@ -313,6 +364,7 @@ setup(
],
},
cmdclass={
'build_py': build_py,
'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