chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_replace_errors() -> None:
|
||||
try:
|
||||
if getattr(sys.stdout, "errors", None) != "replace":
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_ensure_replace_errors()
|
||||
|
||||
|
||||
def banner(title: str, lines: list[str] | tuple[str, ...] = ()) -> None:
|
||||
print(f"== {title} ==")
|
||||
for line in lines:
|
||||
print(str(line))
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
print(f"[ok] {message}")
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
print(f"[error] {message}")
|
||||
|
||||
|
||||
__all__ = ["banner", "log_error", "log_success"]
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python
|
||||
"""Run Docker Compose with port mappings rendered from JSON settings.
|
||||
|
||||
Docker Compose cannot read ``data/user/settings/system.json`` directly for
|
||||
host port interpolation. This wrapper renders a tiny compose env file from the
|
||||
JSON settings and then invokes ``docker compose --env-file``. It intentionally
|
||||
does not read or migrate the project-root ``.env`` file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SETTINGS_DIR = PROJECT_ROOT / "data" / "user" / "settings"
|
||||
DOCKER_ENV_PATH = SETTINGS_DIR / "docker.env"
|
||||
|
||||
DEFAULT_BACKEND_PORT = 8001
|
||||
DEFAULT_FRONTEND_PORT = 3782
|
||||
DEFAULT_POCKETBASE_PORT = 8090
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
def _coerce_port(value: Any, default: int) -> int:
|
||||
try:
|
||||
port = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return port if 1 <= port <= 65535 else default
|
||||
|
||||
|
||||
def render_docker_env(
|
||||
settings_dir: Path = SETTINGS_DIR,
|
||||
output_path: Path = DOCKER_ENV_PATH,
|
||||
) -> dict[str, str]:
|
||||
"""Render compose interpolation vars from JSON settings only."""
|
||||
system = _read_json_object(settings_dir / "system.json")
|
||||
integrations = _read_json_object(settings_dir / "integrations.json")
|
||||
values = {
|
||||
"DEEPTUTOR_DOCKER_BACKEND_PORT": str(
|
||||
_coerce_port(system.get("backend_port"), DEFAULT_BACKEND_PORT)
|
||||
),
|
||||
"DEEPTUTOR_DOCKER_FRONTEND_PORT": str(
|
||||
_coerce_port(system.get("frontend_port"), DEFAULT_FRONTEND_PORT)
|
||||
),
|
||||
"DEEPTUTOR_DOCKER_POCKETBASE_PORT": str(
|
||||
_coerce_port(integrations.get("pocketbase_port"), DEFAULT_POCKETBASE_PORT)
|
||||
),
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"# Auto-generated by scripts/docker_compose.py from data/user/settings/*.json.",
|
||||
"# Do not edit manually; update system.json/integrations.json instead.",
|
||||
]
|
||||
lines.extend(f"{key}={value}" for key, value in values.items())
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return values
|
||||
|
||||
|
||||
def _compose_command(args: list[str]) -> list[str]:
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
raise SystemExit("docker was not found on PATH")
|
||||
return [docker, "compose", "--env-file", str(DOCKER_ENV_PATH), *args]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(argv if argv is not None else sys.argv[1:])
|
||||
if not args:
|
||||
args = ["up", "-d"]
|
||||
|
||||
values = render_docker_env()
|
||||
print(
|
||||
"Docker settings: "
|
||||
f"backend={values['DEEPTUTOR_DOCKER_BACKEND_PORT']} "
|
||||
f"frontend={values['DEEPTUTOR_DOCKER_FRONTEND_PORT']} "
|
||||
f"pocketbase={values['DEEPTUTOR_DOCKER_POCKETBASE_PORT']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Keep Docker execution detached from host process overrides.
|
||||
for key in (
|
||||
"BACKEND_PORT",
|
||||
"FRONTEND_PORT",
|
||||
"POCKETBASE_PORT",
|
||||
"AUTH_ENABLED",
|
||||
"POCKETBASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE",
|
||||
"NEXT_PUBLIC_API_BASE_EXTERNAL",
|
||||
):
|
||||
env.pop(key, None)
|
||||
|
||||
result = subprocess.run(_compose_command(args), cwd=str(PROJECT_ROOT), env=env, check=False)
|
||||
return int(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PocketBase collection bootstrap script.
|
||||
|
||||
Run this once after starting PocketBase for the first time:
|
||||
|
||||
python scripts/pb_setup.py
|
||||
|
||||
Requires integrations.pocketbase_url, integrations.pocketbase_admin_email, and
|
||||
integrations.pocketbase_admin_password in data/user/settings/integrations.json.
|
||||
|
||||
Safe to re-run — existing collections are left untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Allow running from project root without installing the package.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from deeptutor.services.config import load_integrations_settings
|
||||
|
||||
_INTEGRATIONS = load_integrations_settings()
|
||||
POCKETBASE_BASE_URL = str(_INTEGRATIONS["pocketbase_url"]).rstrip("/")
|
||||
ADMIN_EMAIL = str(_INTEGRATIONS["pocketbase_admin_email"])
|
||||
ADMIN_PASSWORD = str(_INTEGRATIONS["pocketbase_admin_password"])
|
||||
|
||||
|
||||
def _require_env():
|
||||
missing = []
|
||||
if not POCKETBASE_BASE_URL:
|
||||
missing.append("integrations.pocketbase_url")
|
||||
if not ADMIN_EMAIL:
|
||||
missing.append("integrations.pocketbase_admin_email")
|
||||
if not ADMIN_PASSWORD:
|
||||
missing.append("integrations.pocketbase_admin_password")
|
||||
if missing:
|
||||
print(f"ERROR: Missing required integration settings: {', '.join(missing)}")
|
||||
print("Set them in data/user/settings/integrations.json before running this script.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_client():
|
||||
try:
|
||||
from pocketbase import PocketBase # type: ignore[import]
|
||||
except ImportError:
|
||||
print("ERROR: pocketbase package not installed.")
|
||||
print("Run: pip install pocketbase")
|
||||
sys.exit(1)
|
||||
|
||||
pb = PocketBase(POCKETBASE_BASE_URL)
|
||||
pb.admins.auth_with_password(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
return pb
|
||||
|
||||
|
||||
def _existing_collections(pb) -> set[str]:
|
||||
try:
|
||||
collections = pb.collections.get_full_list()
|
||||
return {c.name for c in collections}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _create_if_missing(pb, name: str, schema: dict, existing: set[str]):
|
||||
if name in existing:
|
||||
print(f" skip {name} (already exists)")
|
||||
return
|
||||
try:
|
||||
pb.collections.create(schema)
|
||||
print(f" create {name}")
|
||||
except Exception as exc:
|
||||
print(f" ERROR creating {name}: {exc}")
|
||||
|
||||
|
||||
def main():
|
||||
_require_env()
|
||||
print(f"Connecting to PocketBase at {POCKETBASE_BASE_URL} ...")
|
||||
pb = _get_client()
|
||||
print("Authenticated as admin.")
|
||||
|
||||
existing = _existing_collections(pb)
|
||||
print(f"Found {len(existing)} existing collection(s): {sorted(existing) or '(none)'}\n")
|
||||
|
||||
# Access control is enforced in the application layer, not by PocketBase
|
||||
# collection rules: the backend connects with a single admin-authenticated
|
||||
# client (see services/pocketbase_client.py), which bypasses collection
|
||||
# RBAC entirely, so the rules below stay empty by design. Per-user session
|
||||
# isolation is implemented in PocketBaseSessionStore by stamping every
|
||||
# session row with ``user_id`` and filtering every query by the current
|
||||
# user. Do NOT rely on these listRule/viewRule strings for isolation.
|
||||
collections = [
|
||||
# ----------------------------------------------------------------
|
||||
# sessions (``user_id`` populated + filtered by PocketBaseSessionStore)
|
||||
# ----------------------------------------------------------------
|
||||
{
|
||||
"name": "sessions",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{"name": "session_id", "type": "text", "required": True},
|
||||
{"name": "user_id", "type": "text", "required": False},
|
||||
{"name": "title", "type": "text", "required": False},
|
||||
{"name": "compressed_summary", "type": "text", "required": False},
|
||||
{"name": "summary_up_to_msg_id", "type": "number", "required": False},
|
||||
{"name": "preferences_json", "type": "json", "required": False},
|
||||
{"name": "capability", "type": "text", "required": False},
|
||||
{"name": "status", "type": "text", "required": False},
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
},
|
||||
# ----------------------------------------------------------------
|
||||
# messages
|
||||
# ----------------------------------------------------------------
|
||||
{
|
||||
"name": "messages",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{"name": "session_id", "type": "text", "required": True},
|
||||
{"name": "role", "type": "text", "required": True},
|
||||
{"name": "content", "type": "text", "required": False},
|
||||
{"name": "capability", "type": "text", "required": False},
|
||||
{"name": "events_json", "type": "json", "required": False},
|
||||
{"name": "attachments_json", "type": "json", "required": False},
|
||||
{"name": "msg_created_at", "type": "number", "required": False},
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
},
|
||||
# ----------------------------------------------------------------
|
||||
# turns
|
||||
# ----------------------------------------------------------------
|
||||
{
|
||||
"name": "turns",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{"name": "turn_id", "type": "text", "required": True},
|
||||
{"name": "session_id", "type": "text", "required": True},
|
||||
{"name": "capability", "type": "text", "required": False},
|
||||
{"name": "status", "type": "text", "required": False},
|
||||
{"name": "error", "type": "text", "required": False},
|
||||
{"name": "turn_created_at", "type": "number", "required": False},
|
||||
{"name": "turn_updated_at", "type": "number", "required": False},
|
||||
{"name": "finished_at", "type": "number", "required": False},
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
},
|
||||
# ----------------------------------------------------------------
|
||||
# turn_events
|
||||
# ----------------------------------------------------------------
|
||||
{
|
||||
"name": "turn_events",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{"name": "turn_id", "type": "text", "required": True},
|
||||
{"name": "session_id", "type": "text", "required": False},
|
||||
{"name": "seq", "type": "number", "required": True},
|
||||
{"name": "type", "type": "text", "required": False},
|
||||
{"name": "source", "type": "text", "required": False},
|
||||
{"name": "stage", "type": "text", "required": False},
|
||||
{"name": "content", "type": "text", "required": False},
|
||||
{"name": "metadata_json", "type": "json", "required": False},
|
||||
{"name": "event_timestamp", "type": "number", "required": False},
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
},
|
||||
# ----------------------------------------------------------------
|
||||
# knowledge_bases
|
||||
# ----------------------------------------------------------------
|
||||
{
|
||||
"name": "knowledge_bases",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{"name": "kb_name", "type": "text", "required": True},
|
||||
{"name": "user_id", "type": "text", "required": False},
|
||||
{"name": "description", "type": "text", "required": False},
|
||||
{"name": "rag_provider", "type": "text", "required": False},
|
||||
{"name": "needs_reindex", "type": "bool", "required": False},
|
||||
{"name": "status", "type": "text", "required": False},
|
||||
{"name": "kb_created_at", "type": "text", "required": False},
|
||||
{
|
||||
"name": "raw_files",
|
||||
"type": "file",
|
||||
"required": False,
|
||||
"options": {"maxSelect": 99, "maxSize": 52428800},
|
||||
},
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
},
|
||||
]
|
||||
|
||||
print("Creating collections:")
|
||||
for col in collections:
|
||||
_create_if_missing(pb, col["name"], col, existing)
|
||||
|
||||
print("\nDone. PocketBase collections are ready.")
|
||||
print(f"Open the admin panel at {POCKETBASE_BASE_URL}/_/ to view and configure collections.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python
|
||||
"""Prepare ``deeptutor_web`` package data from a Next.js standalone build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
WEB_DIR = PROJECT_ROOT / "web"
|
||||
PACKAGE_DIR = PROJECT_ROOT / "deeptutor_web"
|
||||
|
||||
|
||||
def _clean_package_dir(package_dir: Path) -> None:
|
||||
package_dir.mkdir(parents=True, exist_ok=True)
|
||||
init_file = package_dir / "__init__.py"
|
||||
init_text = init_file.read_text(encoding="utf-8") if init_file.exists() else ""
|
||||
for child in package_dir.iterdir():
|
||||
if child.name == "__init__.py":
|
||||
continue
|
||||
if child.is_dir():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
if init_text:
|
||||
init_file.write_text(init_text, encoding="utf-8")
|
||||
|
||||
|
||||
def prepare_web_package(*, skip_build: bool = False) -> None:
|
||||
if not skip_build:
|
||||
subprocess.run(["npm", "run", "build"], cwd=WEB_DIR, check=True)
|
||||
|
||||
standalone = WEB_DIR / ".next" / "standalone"
|
||||
static_dir = WEB_DIR / ".next" / "static"
|
||||
public_dir = WEB_DIR / "public"
|
||||
if not (standalone / "server.js").exists():
|
||||
raise SystemExit(
|
||||
"Missing web/.next/standalone/server.js. Run this script after `npm run build` "
|
||||
"or omit --skip-build."
|
||||
)
|
||||
|
||||
_clean_package_dir(PACKAGE_DIR)
|
||||
shutil.copytree(standalone, PACKAGE_DIR, dirs_exist_ok=True)
|
||||
if static_dir.exists():
|
||||
shutil.copytree(static_dir, PACKAGE_DIR / ".next" / "static", dirs_exist_ok=True)
|
||||
if public_dir.exists():
|
||||
shutil.copytree(public_dir, PACKAGE_DIR / "public", dirs_exist_ok=True)
|
||||
(PACKAGE_DIR / "BUILD_INFO").write_text(
|
||||
"Generated by scripts/prepare_web_package.py from web/.next/standalone.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Copy an existing web/.next/standalone build without running npm.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
prepare_web_package(skip_build=args.skip_build)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
@echo off
|
||||
REM DeepTutor Backend Startup Script
|
||||
REM Activates virtual environment and starts the backend API server
|
||||
|
||||
REM Move to the project root (this script lives in scripts/)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
REM Set UTF-8 encoding for stdout to support emoji characters
|
||||
set PYTHONIOENCODING=utf-8
|
||||
set PYTHONUTF8=1
|
||||
|
||||
echo Activating Python virtual environment...
|
||||
call .venv\Scripts\activate.bat
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to activate virtual environment. Make sure .venv exists.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting DeepTutor Backend Server...
|
||||
echo Backend will be available at: http://localhost:8001
|
||||
echo Press Ctrl+C to stop the server.
|
||||
python -m deeptutor.api.run_server
|
||||
pause
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
REM DeepTutor Frontend Startup Script
|
||||
REM Starts the frontend Next.js development server
|
||||
|
||||
REM Move to the project root (this script lives in scripts/)
|
||||
cd /d "%~dp0.."
|
||||
echo Starting DeepTutor Frontend...
|
||||
echo Frontend will be available at: http://localhost:3782
|
||||
echo Press Ctrl+C to stop the server.
|
||||
cd web
|
||||
npm run dev -- -p 3782
|
||||
pause
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python
|
||||
"""DeepTutor settings tour.
|
||||
|
||||
This script configures the runtime files under ``data/user/settings`` only.
|
||||
It does not install Python packages, install Node dependencies, or start the
|
||||
Web app. For day-to-day use prefer:
|
||||
|
||||
deeptutor init
|
||||
deeptutor start
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from deeptutor_cli.init_cmd import run_init # noqa: E402
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create or update DeepTutor settings under data/user/settings.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cli",
|
||||
action="store_true",
|
||||
help="Configure for CLI-only use and skip Web port prompts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--home",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Runtime workspace root. Defaults to the current directory.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = build_parser().parse_args(argv)
|
||||
print("DeepTutor settings tour")
|
||||
print("Writing configuration to data/user/settings; no dependencies will be installed.")
|
||||
run_init(cli_only=args.cli, home=args.home)
|
||||
if args.cli:
|
||||
print("\nNext: deeptutor chat")
|
||||
else:
|
||||
print("\nNext: deeptutor start")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
"""Compatibility wrapper for ``deeptutor start``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from deeptutor.runtime.launcher import start # noqa: E402
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Start DeepTutor Web.")
|
||||
parser.add_argument(
|
||||
"--home",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Runtime workspace root. Defaults to the current directory.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
start(home=build_parser().parse_args(argv).home)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env python
|
||||
"""Safely update a local DeepTutor git checkout.
|
||||
|
||||
The updater is intentionally conservative:
|
||||
1. Fetch the remote for the current branch.
|
||||
2. Show the local-vs-remote gap and ask for confirmation.
|
||||
3. Fast-forward pull only when the branch can be updated safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Raised for user-actionable update failures."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitResult:
|
||||
args: tuple[str, ...]
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchTarget:
|
||||
local_branch: str
|
||||
remote: str
|
||||
remote_branch: str
|
||||
remote_ref: str
|
||||
upstream: str | None
|
||||
source: str
|
||||
|
||||
@property
|
||||
def display_remote_ref(self) -> str:
|
||||
return f"{self.remote}/{self.remote_branch}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchGap:
|
||||
local_sha: str
|
||||
local_subject: str
|
||||
remote_sha: str
|
||||
remote_subject: str
|
||||
ahead: int
|
||||
behind: int
|
||||
incoming_commits: list[str]
|
||||
outgoing_commits: list[str]
|
||||
diff_stat: str
|
||||
dirty_entries: list[str]
|
||||
|
||||
@property
|
||||
def is_up_to_date(self) -> bool:
|
||||
return self.ahead == 0 and self.behind == 0
|
||||
|
||||
@property
|
||||
def is_fast_forwardable(self) -> bool:
|
||||
return self.ahead == 0 and self.behind > 0
|
||||
|
||||
@property
|
||||
def is_diverged(self) -> bool:
|
||||
return self.ahead > 0 and self.behind > 0
|
||||
|
||||
|
||||
class Git:
|
||||
def __init__(self, repo_root: Path) -> None:
|
||||
self.repo_root = repo_root
|
||||
|
||||
def run(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout: int | None = 60,
|
||||
) -> GitResult:
|
||||
cmd = ["git", *args]
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
cwd=self.repo_root,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
result = GitResult(
|
||||
args=tuple(args),
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout.strip(),
|
||||
stderr=completed.stderr.strip(),
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
message = result.stderr or result.stdout or f"git {' '.join(args)} failed"
|
||||
raise UpdateError(message)
|
||||
return result
|
||||
|
||||
|
||||
def _print_section(title: str) -> None:
|
||||
print()
|
||||
print(f"== {title} ==")
|
||||
|
||||
|
||||
def _print_list(title: str, items: list[str], *, empty: str) -> None:
|
||||
print(f"{title}:")
|
||||
if not items:
|
||||
print(f" {empty}")
|
||||
return
|
||||
for item in items:
|
||||
print(f" {item}")
|
||||
|
||||
|
||||
def ensure_git_checkout(git: Git) -> None:
|
||||
inside = git.run(["rev-parse", "--is-inside-work-tree"]).stdout
|
||||
if inside != "true":
|
||||
raise UpdateError("This directory is not inside a git checkout.")
|
||||
|
||||
|
||||
def current_branch(git: Git) -> str:
|
||||
branch = git.run(["branch", "--show-current"]).stdout
|
||||
if not branch:
|
||||
raise UpdateError(
|
||||
"Detached HEAD detected. Please switch to a branch before running the updater."
|
||||
)
|
||||
return branch
|
||||
|
||||
|
||||
def git_config(git: Git, key: str) -> str | None:
|
||||
result = git.run(["config", "--get", key], check=False)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout or None
|
||||
|
||||
|
||||
def available_remotes(git: Git) -> list[str]:
|
||||
remotes = git.run(["remote"], check=False).stdout.splitlines()
|
||||
return [remote.strip() for remote in remotes if remote.strip()]
|
||||
|
||||
|
||||
def normalize_merge_ref(merge_ref: str | None, fallback_branch: str) -> str:
|
||||
if not merge_ref:
|
||||
return fallback_branch
|
||||
prefix = "refs/heads/"
|
||||
if merge_ref.startswith(prefix):
|
||||
return merge_ref[len(prefix) :]
|
||||
return merge_ref
|
||||
|
||||
|
||||
def resolve_branch_target(git: Git, branch: str) -> BranchTarget:
|
||||
remote = git_config(git, f"branch.{branch}.remote")
|
||||
merge_ref = git_config(git, f"branch.{branch}.merge")
|
||||
|
||||
if remote and remote != ".":
|
||||
remote_branch = normalize_merge_ref(merge_ref, branch)
|
||||
return BranchTarget(
|
||||
local_branch=branch,
|
||||
remote=remote,
|
||||
remote_branch=remote_branch,
|
||||
remote_ref=f"refs/remotes/{remote}/{remote_branch}",
|
||||
upstream=f"{remote}/{remote_branch}",
|
||||
source="configured upstream",
|
||||
)
|
||||
|
||||
remotes = available_remotes(git)
|
||||
if not remotes:
|
||||
raise UpdateError("No git remote is configured for this checkout.")
|
||||
|
||||
selected_remote = "origin" if "origin" in remotes else remotes[0]
|
||||
return BranchTarget(
|
||||
local_branch=branch,
|
||||
remote=selected_remote,
|
||||
remote_branch=branch,
|
||||
remote_ref=f"refs/remotes/{selected_remote}/{branch}",
|
||||
upstream=None,
|
||||
source=f"default remote '{selected_remote}' with matching branch name",
|
||||
)
|
||||
|
||||
|
||||
def fetch_remote(git: Git, target: BranchTarget) -> None:
|
||||
print(f"Fetching latest refs from {target.remote} ...")
|
||||
git.run(["fetch", "--prune", target.remote], timeout=None)
|
||||
|
||||
|
||||
def verify_remote_branch(git: Git, target: BranchTarget) -> None:
|
||||
result = git.run(
|
||||
["rev-parse", "--verify", "--quiet", f"{target.remote_ref}^{{commit}}"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
raise UpdateError(
|
||||
"Remote branch not found after fetch: "
|
||||
f"{target.display_remote_ref}. Set the branch upstream or push it first."
|
||||
)
|
||||
|
||||
|
||||
def short_commit(git: Git, ref: str) -> tuple[str, str]:
|
||||
sha = git.run(["rev-parse", "--short", ref]).stdout
|
||||
subject = git.run(["log", "-1", "--format=%s", ref]).stdout
|
||||
return sha, subject
|
||||
|
||||
|
||||
def log_lines(git: Git, revision_range: str, limit: int = 8) -> list[str]:
|
||||
result = git.run(
|
||||
["log", "--oneline", "--decorate", f"--max-count={limit}", revision_range],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return []
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
def tracked_dirty_entries(git: Git) -> list[str]:
|
||||
git.run(["update-index", "-q", "--refresh"], check=False)
|
||||
result = git.run(["status", "--porcelain=v1", "--untracked-files=no"], check=False)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return []
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
def analyze_gap(git: Git, target: BranchTarget) -> BranchGap:
|
||||
local_sha, local_subject = short_commit(git, "HEAD")
|
||||
remote_sha, remote_subject = short_commit(git, target.remote_ref)
|
||||
|
||||
counts = git.run(["rev-list", "--left-right", "--count", f"HEAD...{target.remote_ref}"])
|
||||
ahead_str, behind_str = counts.stdout.split()
|
||||
ahead = int(ahead_str)
|
||||
behind = int(behind_str)
|
||||
|
||||
diff_stat = ""
|
||||
if behind:
|
||||
diff_stat = git.run(
|
||||
["diff", "--stat", "--compact-summary", f"HEAD..{target.remote_ref}"]
|
||||
).stdout
|
||||
|
||||
return BranchGap(
|
||||
local_sha=local_sha,
|
||||
local_subject=local_subject,
|
||||
remote_sha=remote_sha,
|
||||
remote_subject=remote_subject,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
incoming_commits=log_lines(git, f"HEAD..{target.remote_ref}"),
|
||||
outgoing_commits=log_lines(git, f"{target.remote_ref}..HEAD"),
|
||||
diff_stat=diff_stat,
|
||||
dirty_entries=tracked_dirty_entries(git),
|
||||
)
|
||||
|
||||
|
||||
def print_gap(target: BranchTarget, gap: BranchGap) -> None:
|
||||
_print_section("Detected branch")
|
||||
print(f"Local branch: {target.local_branch}")
|
||||
print(f"Remote branch: {target.display_remote_ref}")
|
||||
print(f"Selection: {target.source}")
|
||||
if target.upstream:
|
||||
print(f"Upstream: {target.upstream}")
|
||||
else:
|
||||
print("Upstream: not configured")
|
||||
|
||||
_print_section("Local vs remote")
|
||||
print(f"Local HEAD: {gap.local_sha} {gap.local_subject}")
|
||||
print(f"Remote HEAD: {gap.remote_sha} {gap.remote_subject}")
|
||||
print(f"Gap: local is {gap.ahead} commit(s) ahead, {gap.behind} commit(s) behind")
|
||||
|
||||
_print_list(
|
||||
"Incoming commits from remote",
|
||||
gap.incoming_commits,
|
||||
empty="none",
|
||||
)
|
||||
_print_list(
|
||||
"Local commits not on remote",
|
||||
gap.outgoing_commits,
|
||||
empty="none",
|
||||
)
|
||||
|
||||
if gap.diff_stat:
|
||||
print("Incoming file summary:")
|
||||
for line in gap.diff_stat.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
if gap.dirty_entries:
|
||||
_print_list(
|
||||
"Tracked local changes",
|
||||
gap.dirty_entries,
|
||||
empty="none",
|
||||
)
|
||||
|
||||
|
||||
def confirm_update(assume_yes: bool, target: BranchTarget, gap: BranchGap) -> bool:
|
||||
if assume_yes:
|
||||
return True
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
print()
|
||||
print("Confirmation required, but stdin is not interactive. Re-run with --yes to update.")
|
||||
return False
|
||||
|
||||
print()
|
||||
answer = input(
|
||||
"Confirm this branch mapping and update "
|
||||
f"{target.local_branch} from {target.display_remote_ref}? [y/N] "
|
||||
).strip()
|
||||
return answer.lower() in {"y", "yes"}
|
||||
|
||||
|
||||
def ensure_safe_to_update(gap: BranchGap) -> None:
|
||||
if gap.dirty_entries:
|
||||
raise UpdateError(
|
||||
"Tracked local changes are present. Commit or stash them before updating."
|
||||
)
|
||||
if gap.is_diverged:
|
||||
raise UpdateError(
|
||||
"Local and remote branches have diverged. Resolve manually, then rerun the updater."
|
||||
)
|
||||
if gap.ahead > 0 and gap.behind == 0:
|
||||
raise UpdateError(
|
||||
"Local branch has commits not present on the remote, and there are no remote "
|
||||
"commits to pull."
|
||||
)
|
||||
if not gap.is_fast_forwardable:
|
||||
raise UpdateError("This branch cannot be updated with a safe fast-forward pull.")
|
||||
|
||||
|
||||
def dependency_hints(changed_files: list[str]) -> list[str]:
|
||||
hints: list[str] = []
|
||||
if any(path == "pyproject.toml" or path.startswith("requirements/") for path in changed_files):
|
||||
hints.append("Backend dependencies changed: consider running python -m pip install -e .")
|
||||
if any(
|
||||
path in {"web/package.json", "web/package-lock.json", "web/pnpm-lock.yaml"}
|
||||
or path == "web/yarn.lock"
|
||||
for path in changed_files
|
||||
):
|
||||
hints.append("Frontend dependencies changed: consider running cd web && npm install")
|
||||
return hints
|
||||
|
||||
|
||||
def pull_updates(git: Git, target: BranchTarget) -> list[str]:
|
||||
old_sha = git.run(["rev-parse", "HEAD"]).stdout
|
||||
_print_section("Updating")
|
||||
git.run(["pull", "--ff-only", target.remote, target.remote_branch], timeout=None)
|
||||
new_sha = git.run(["rev-parse", "HEAD"]).stdout
|
||||
if old_sha == new_sha:
|
||||
return []
|
||||
changed = git.run(["diff", "--name-only", f"{old_sha}..{new_sha}"]).stdout
|
||||
return [line for line in changed.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def run_update(repo_root: Path, *, assume_yes: bool) -> int:
|
||||
git = Git(repo_root)
|
||||
ensure_git_checkout(git)
|
||||
branch = current_branch(git)
|
||||
target = resolve_branch_target(git, branch)
|
||||
fetch_remote(git, target)
|
||||
verify_remote_branch(git, target)
|
||||
gap = analyze_gap(git, target)
|
||||
print_gap(target, gap)
|
||||
|
||||
if gap.dirty_entries:
|
||||
raise UpdateError(
|
||||
"Tracked local changes are present. Commit or stash them before updating."
|
||||
)
|
||||
if gap.is_diverged:
|
||||
raise UpdateError(
|
||||
"Local and remote branches have diverged. Resolve manually, then rerun the updater."
|
||||
)
|
||||
if gap.is_up_to_date:
|
||||
print()
|
||||
print("Already up to date. No update is needed.")
|
||||
return 0
|
||||
if gap.ahead > 0 and gap.behind == 0:
|
||||
print()
|
||||
print("No remote commits to pull. Local branch is ahead of the remote.")
|
||||
return 0
|
||||
|
||||
if not confirm_update(assume_yes, target, gap):
|
||||
print("Update cancelled.")
|
||||
return 0
|
||||
|
||||
ensure_safe_to_update(gap)
|
||||
changed_files = pull_updates(git, target)
|
||||
|
||||
print()
|
||||
print(f"Updated {target.local_branch} from {target.display_remote_ref}.")
|
||||
if changed_files:
|
||||
print(f"Changed files: {len(changed_files)}")
|
||||
for hint in dependency_hints(changed_files):
|
||||
print(f"Next step: {hint}")
|
||||
print("Restart DeepTutor if it is currently running.")
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch, review, and fast-forward update a local DeepTutor checkout."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
help="Skip the interactive confirmation after printing the branch comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
type=Path,
|
||||
default=PROJECT_ROOT,
|
||||
help="Path to the git checkout to update. Defaults to this DeepTutor repository.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
return run_update(args.repo.resolve(), assume_yes=args.yes)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print("Update cancelled.")
|
||||
return 130
|
||||
except (OSError, subprocess.SubprocessError, UpdateError) as exc:
|
||||
print()
|
||||
print(f"Update failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user