This commit is contained in:
2025-12-31 22:05:25 -08:00
parent 807ea7f53a
commit cfd791d415
12 changed files with 248 additions and 175 deletions

View File

@@ -526,41 +526,10 @@ try {
$globalBin = Join-Path $env:USERPROFILE 'bin'
New-Item -ItemType Directory -Path $globalBin -Force | Out-Null
$mmCmd = Join-Path $globalBin 'mm.cmd'
$mmPs1 = Join-Path $globalBin 'mm.ps1'
$repo = $repoRoot
$cmdText = @"
@echo off
set "REPO=__REPO__"
if exist "%REPO%\.venv\Scripts\mm.exe" "%REPO%\.venv\Scripts\mm.exe" %*
if defined MM_DEBUG (
echo MM_DEBUG: REPO=%REPO%
if exist "%REPO%\.venv\Scripts\python.exe" (
"%REPO%\.venv\Scripts\python.exe" -c "import sys,importlib,importlib.util; print('sys.executable:', sys.executable); print('sys.path (first 8):', sys.path[:8]);"
) else (
python -c "import sys,importlib,importlib.util; print('sys.executable:', sys.executable); print('sys.path (first 8):', sys.path[:8]);"
)
)
if exist "%REPO%\.venv\Scripts\python.exe" (
"%REPO%\.venv\Scripts\python.exe" -m medeia_macina.cli_entry %*
exit /b %ERRORLEVEL%
)
if exist "%REPO%\CLI.py" (
python "%REPO%\CLI.py" %*
exit /b %ERRORLEVEL%
)
python -m medeia_macina.cli_entry %*
"@
# Inject actual repo path safely (escape double-quotes if any)
$cmdText = $cmdText.Replace('__REPO__', $repo.Replace('"', '""'))
if (Test-Path $mmCmd) {
$bak = "$mmCmd.bak$(Get-Date -UFormat %s)"
Move-Item -Path $mmCmd -Destination $bak -Force
}
Set-Content -LiteralPath $mmCmd -Value $cmdText -Encoding UTF8
# PowerShell shim: use single-quoted here-string so literal PowerShell variables
# (like $args) are not expanded by this script when writing the file.
$ps1Text = @'

View File

@@ -3,7 +3,7 @@
Unified project bootstrap helper (Python-only).
This script installs Python dependencies from `requirements.txt` and then
This script installs Python dependencies from `scripts/requirements.txt` and then
downloads Playwright browser binaries by running `python -m playwright install`.
By default this script installs **Chromium** only to conserve space; pass
`--browsers all` to install all supported engines (chromium, firefox, webkit).
@@ -30,7 +30,7 @@ Usage:
python ./scripts/bootstrap.py --playwright-only
Optional flags:
--skip-deps Skip `pip install -r requirements.txt` step
--skip-deps Skip `pip install -r scripts/requirements.txt` step
--no-playwright Skip running `python -m playwright install` (still installs deps)
--playwright-only Install only Playwright browsers (installs playwright package if missing)
--browsers Comma-separated list of Playwright browsers to install (default: chromium)
@@ -223,7 +223,7 @@ def main() -> int:
parser.add_argument(
"--skip-deps",
action="store_true",
help="Skip installing Python dependencies from requirements.txt",
help="Skip installing Python dependencies from scripts/requirements.txt",
)
parser.add_argument(
"--no-playwright",
@@ -317,12 +317,102 @@ def main() -> int:
return False
def _do_uninstall() -> int:
"""Attempt to remove the local venv and any shims written to the user's bin."""
"""Attempt to remove the local venv and any shims written to the user's bin.
If this script is running using the Python inside the local `.venv`, we
attempt to re-run the uninstall using a Python interpreter outside the
venv (so files can be removed on Windows). If no suitable external
interpreter can be found, the user is asked to deactivate the venv and
re-run the uninstall.
"""
vdir = repo_root / ".venv"
if not vdir.exists():
if not args.quiet:
print("No local .venv found; nothing to uninstall.")
return 0
# If the current interpreter is the one inside the local venv, try to
# run the uninstall via a Python outside the venv so files (including
# the interpreter binary) can be removed on Windows.
try:
current_exe = Path(sys.executable).resolve()
in_venv = str(current_exe).lower().startswith(str(vdir.resolve()).lower())
except Exception:
in_venv = False
if in_venv:
if not args.quiet:
print(f"Detected local venv Python in use: {current_exe}")
if not args.yes:
try:
resp = input("Uninstall will be attempted using a system Python outside the .venv. Continue? [Y/n]: ")
except EOFError:
print("Non-interactive environment; pass --uninstall --yes to uninstall without prompts.", file=sys.stderr)
return 2
if resp.strip().lower() in ("n", "no"):
print("Uninstall aborted.")
return 1
def _find_external_python() -> list[str] | None:
"""Return a command (list) for a Python interpreter outside the venv, or None."""
try:
base = Path(sys.base_prefix)
candidates: list[Path | str] = []
if platform.system().lower() == "windows":
candidates.append(base / "python.exe")
else:
candidates.extend([base / "bin" / "python3", base / "bin" / "python"])
for name in ("python3", "python"):
p = shutil.which(name)
if p:
candidates.append(Path(p))
# Special-case the Windows py launcher: ensure it resolves
# to a Python outside the venv before returning ['py','-3']
if platform.system().lower() == "windows":
py_launcher = shutil.which("py")
if py_launcher:
try:
out = subprocess.check_output(["py", "-3", "-c", "import sys; print(sys.executable)"], text=True).strip()
if out and not str(Path(out).resolve()).lower().startswith(str(vdir.resolve()).lower()):
return ["py", "-3"]
except Exception:
pass
for c in candidates:
try:
if isinstance(c, Path) and c.exists():
c_resolved = Path(c).resolve()
if not str(c_resolved).lower().startswith(str(vdir.resolve()).lower()) and c_resolved != current_exe:
return [str(c_resolved)]
except Exception:
continue
except Exception:
pass
return None
ext = _find_external_python()
if ext:
cmd = ext + [str(repo_root / "scripts" / "bootstrap.py"), "--uninstall", "--yes"]
if not args.quiet:
print("Attempting uninstall using external Python:", " ".join(cmd))
rc = subprocess.run(cmd)
if rc.returncode != 0:
print(
f"External uninstall exited with {rc.returncode}; ensure no processes are using files in {vdir} and try again.",
file=sys.stderr,
)
return int(rc.returncode or 0)
print(
"Could not find a Python interpreter outside the local .venv. Please deactivate your venv (run 'deactivate') or run the uninstall from a system Python:\n python ./scripts/bootstrap.py --uninstall --yes",
file=sys.stderr,
)
return 2
# Normal (non-venv) uninstall flow: confirm and remove launchers, shims, and venv
if not args.yes:
try:
prompt = input(f"Remove local virtualenv at {vdir} and installed user shims? [y/N]: ")
@@ -334,15 +424,19 @@ def main() -> int:
return 1
# Remove repo-local launchers
for name in ("mm", "mm.ps1", "mm.bat"):
p = repo_root / name
if p.exists():
def _remove_launcher(path: Path) -> None:
if path.exists():
try:
p.unlink()
path.unlink()
if not args.quiet:
print(f"Removed local launcher: {p}")
print(f"Removed local launcher: {path}")
except Exception as exc:
print(f"Warning: failed to remove {p}: {exc}", file=sys.stderr)
print(f"Warning: failed to remove {path}: {exc}", file=sys.stderr)
scripts_launcher = repo_root / "scripts" / "mm.ps1"
_remove_launcher(scripts_launcher)
for legacy in ("mm", "mm.ps1", "mm.bat"):
_remove_launcher(repo_root / legacy)
# Remove user shims that the installer may have written
try:
@@ -350,12 +444,15 @@ def main() -> int:
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "bin"
if user_bin.exists():
for name in ("mm.cmd", "mm.ps1"):
for name in ("mm.ps1",):
p = user_bin / name
if p.exists():
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
try:
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
except Exception as exc:
print(f"Warning: failed to remove {p}: {exc}", file=sys.stderr)
else:
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(Path.home() / ".local/bin")))
if user_bin.exists():
@@ -480,11 +577,37 @@ def main() -> int:
except subprocess.CalledProcessError as exc:
print(f"Failed to create or prepare local venv: {exc}", file=sys.stderr)
raise
def _ensure_pip_available(python_path: Path) -> None:
"""Ensure pip is available inside the venv; fall back to ensurepip if needed."""
try:
subprocess.run(
[str(python_path), "-m", "pip", "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return
except Exception:
pass
if not args.quiet:
print("Bootstrapping pip inside the local virtualenv...")
try:
run([str(python_path), "-m", "ensurepip", "--upgrade"])
except subprocess.CalledProcessError as exc:
print(
"Failed to install pip inside the local virtualenv via ensurepip; ensure your Python build includes ensurepip and retry.",
file=sys.stderr,
)
raise
# Ensure a local venv is present and use it for subsequent installs.
venv_python = _ensure_local_venv()
if not args.quiet:
print(f"Using venv python: {venv_python}")
_ensure_pip_available(venv_python)
# Enforce opinionated behavior: install deps, playwright, deno, and install project in editable mode.
# Ignore `--skip-deps` and `--install-editable` flags to keep the setup deterministic.
@@ -531,7 +654,7 @@ def main() -> int:
)
if not args.skip_deps:
req_file = repo_root / "requirements.txt"
req_file = repo_root / "scripts" / "requirements.txt"
if not req_file.exists():
print(
f"requirements.txt not found at {req_file}; skipping dependency installation.",
@@ -662,35 +785,15 @@ def main() -> int:
print("Deno installation failed.", file=sys.stderr)
return rc
# Write project-local launcher scripts (project root) that prefer the local .venv
# Write project-local launcher script under scripts/ to keep the repo root uncluttered.
def _write_launchers() -> None:
sh = repo_root / "mm"
ps1 = repo_root / "mm.ps1"
bat = repo_root / "mm.bat"
sh_text = """#!/usr/bin/env bash
set -e
SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"
REPO=\"$SCRIPT_DIR\"
VENV=\"$REPO/.venv\"
# Make tools installed into the local venv available in PATH for provider discovery
export PATH=\"$VENV/bin:$PATH\"
PY=\"$VENV/bin/python\"
if [ -x \"$PY\" ]; then
exec \"$PY\" -m medeia_macina.cli_entry \"$@\"
else
exec python -m medeia_macina.cli_entry \"$@\"
fi
"""
try:
sh.write_text(sh_text, encoding="utf-8")
sh.chmod(sh.stat().st_mode | 0o111)
except Exception:
pass
launcher_dir = repo_root / "scripts"
launcher_dir.mkdir(parents=True, exist_ok=True)
ps1 = launcher_dir / "mm.ps1"
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repo = $scriptDir
$repo = (Resolve-Path (Join-Path $scriptDir "..")).Path
$venv = Join-Path $repo '.venv'
# Ensure venv Scripts dir is on PATH for provider discovery
$venvScripts = Join-Path $venv 'Scripts'
@@ -707,19 +810,6 @@ python -m medeia_macina.cli_entry @args
except Exception:
pass
bat_text = (
"@echo off\r\n"
"set SCRIPT_DIR=%~dp0\r\n"
"set PATH=%SCRIPT_DIR%\\.venv\\Scripts;%PATH%\r\n"
'if exist "%SCRIPT_DIR%\\.venv\\Scripts\\python.exe" "%SCRIPT_DIR%\\.venv\\Scripts\\python.exe" -m medeia_macina.cli_entry %*\r\n'
'if exist "%SCRIPT_DIR%\\CLI.py" python "%SCRIPT_DIR%\\CLI.py" %*\r\n'
"python -m medeia_macina.cli_entry %*\r\n"
)
try:
bat.write_text(bat_text, encoding="utf-8")
except Exception:
pass
_write_launchers()
# Install user-global shims so `mm` can be executed from any shell session.
@@ -732,24 +822,6 @@ python -m medeia_macina.cli_entry @args
user_bin = Path(os.environ.get("USERPROFILE", str(home))) / "bin"
user_bin.mkdir(parents=True, exist_ok=True)
# Write mm.cmd (CMD shim)
mm_cmd = user_bin / "mm.cmd"
cmd_text = (
f"@echo off\r\n"
f"set REPO={repo}\r\n"
f'if exist "%REPO%\\.venv\\Scripts\\mm.exe" "%REPO%\\.venv\\Scripts\\mm.exe" %*\r\n'
f"if defined MM_DEBUG (\r\n"
f" echo MM_DEBUG: REPO=%REPO%\r\n"
f' if exist "%REPO%\\.venv\\Scripts\\python.exe" "%REPO%\\.venv\\Scripts\\python.exe" -c "import sys,importlib,importlib.util; print(\'sys.executable:\', sys.executable); print(\'sys.path (first 8):\', sys.path[:8]);" \r\n'
f")\r\n"
f'if exist "%REPO%\\.venv\\Scripts\\python.exe" "%REPO%\\.venv\\Scripts\\python.exe" -m medeia_macina.cli_entry %*\r\n'
f"python -m medeia_macina.cli_entry %*\r\n"
)
if mm_cmd.exists():
bak = mm_cmd.with_suffix(f".bak{int(time.time())}")
mm_cmd.replace(bak)
mm_cmd.write_text(cmd_text, encoding="utf-8")
# Write mm.ps1 (PowerShell shim)
mm_ps1 = user_bin / "mm.ps1"
ps1_text = (

View File

@@ -10,7 +10,7 @@ Works on Linux and Windows. Behavior:
2) Update hydrus (git pull)
3) Re-clone (remove and re-clone)
- If `git` is not available, the script will fall back to downloading the repository ZIP and extracting it.
- By default the script will create a repository-local virtual environment `./<dest>/.venv` after cloning/extraction; use `--no-venv` to skip this. By default the script will install dependencies from `requirements.txt` into that venv (use `--no-install-deps` to skip). After setup the script will print instructions for running the client; use `--run-client` to *launch* `hydrus_client.py` using the created repo venv's Python (use `--run-client-detached` to run it in the background).
- By default the script will create a repository-local virtual environment `./<dest>/.venv` after cloning/extraction; use `--no-venv` to skip this. By default the script will install dependencies from `scripts/requirements.txt` into that venv (use `--no-install-deps` to skip). After setup the script will print instructions for running the client; use `--run-client` to *launch* `hydrus_client.py` using the created repo venv's Python (use `--run-client-detached` to run it in the background).
Examples:
python scripts/hydrusnetwork.py
@@ -510,17 +510,18 @@ def fix_permissions(
def find_requirements(root: Path) -> Optional[Path]:
"""Return a requirements.txt Path if found in common locations (root, client, requirements) or via a shallow search.
"""Return a requirements.txt Path if found in common locations (scripts, root, client, requirements) or via a shallow search.
This tries a few sensible locations used by various projects and performs a shallow
two-level walk to find a requirements.txt so installation works even if the file is
not at the repository root.
"""
candidates = [
root / "requirements.txt",
root / "client" / "requirements.txt",
root / "requirements" / "requirements.txt",
]
candidates = [
root / "scripts" / "requirements.txt",
root / "requirements.txt",
root / "client" / "requirements.txt",
root / "requirements" / "requirements.txt",
]
for c in candidates:
if c.exists():
return c

View File

@@ -0,0 +1,29 @@
# Development dependencies for Medeia-Macina
# Install with: pip install -r requirements-dev.txt
# Main requirements
-r requirements.txt
# Testing
pytest>=7.4.0
pytest-cov>=4.1.0
pytest-asyncio>=0.21.0
# Code quality
black>=23.11.0
flake8>=6.1.0
isort>=5.12.0
mypy>=1.7.0
pylint>=3.0.0
# Documentation
sphinx>=7.2.0
sphinx-rtd-theme>=1.3.0
# Debugging and profiling
ipython>=8.17.0
ipdb>=0.13.0
memory-profiler>=0.61.0
# Version control and CI/CD helpers
pre-commit>=3.5.0

47
scripts/requirements.txt Normal file
View File

@@ -0,0 +1,47 @@
# Core CLI and TUI frameworks
typer>=0.9.0
rich>=13.7.0
prompt-toolkit>=3.0.0
textual>=0.30.0
# Media processing and downloading
yt-dlp[default]>=2023.11.0
requests>=2.31.0
httpx>=0.25.0
# Ensure requests can detect encodings and ship certificates
charset-normalizer>=3.2.0
certifi>=2024.12.0
# Optional Telegram support installs telethon>=1.36.0 when [provider=telegram] is configured.
internetarchive>=4.1.0
# Document and data handling
pypdf>=3.0.0
mutagen>=1.46.0
cbor2>=4.0
zstandard>=0.23.0
# Image and media support
Pillow>=10.0.0
python-bidi>=0.4.2
ffmpeg-python>=0.2.0
# Metadata extraction and processing
musicbrainzngs>=0.7.0
lxml>=4.9.0
# Advanced searching and libraries
# Optional Soulseek support installs aioslsk>=1.6.0 when [provider=soulseek] is configured.
imdbinfo>=0.1.10
# Encryption and security (if needed by Crypto usage)
pycryptodome>=3.18.0
# Data processing
bencode3
tqdm>=4.66.0
# Browser automation (for web scraping if needed)
playwright>=1.40.0
# Development and utilities
python-dateutil>=2.8.0

View File

@@ -7,7 +7,7 @@ present or its copy of this helper gets removed.
Features (subset of the repo helper):
- Locate repository venv (default: <workspace>/hydrusnetwork/.venv)
- Install or reinstall requirements.txt into the venv
- Install or reinstall scripts/requirements.txt into the venv
- Verify key imports
- Launch hydrus_client.py (foreground or detached)
- Install/uninstall simple user-level start-on-boot services (schtasks/systemd/crontab)
@@ -50,7 +50,7 @@ def get_python_in_venv(venv_dir: Path) -> Optional[Path]:
def find_requirements(root: Path) -> Optional[Path]:
candidates = [root / "requirements.txt", root / "client" / "requirements.txt"]
candidates = [root / "scripts" / "requirements.txt", root / "requirements.txt", root / "client" / "requirements.txt"]
for c in candidates:
if c.exists():
return c