mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
The shell entry points got this in 99aa194; running the .py directly still died with ModuleNotFoundError, which is the command the file's own docstring had been telling people to use for months. tool/venv_bootstrap.py creates .venv, installs the project and re-executes the same command line through it. It is stdlib-only by necessity: it runs on an interpreter where this package does not import yet, so it cannot route through signal_bot.external, the one module allowed to start a process. That is why it gets the second scoped per-file-ignore rather than the rule being bent -- kuhy's call. test_external.py now asserts a closed list of two, so a third file still fails the gate. Two things that are less obvious than they look: It runs on import rather than exposing a function to call. A call would have to happen before the caller's own third-party imports, and code before imports is E402 -- so a call-based design needs a lint suppression in every file it protects. Importing the module is the whole interface. And isort would sort "venv_bootstrap" after "fastapi", silently breaking the ordering the module depends on. It gets its own isort section ahead of the standard library, so the formatter enforces the order instead of undoing it. Caught while verifying: ensure() used one check to decide both whether to re-exec and whether to install. The interpreter you typed never grows a fastapi, so every launch reinstalled the project first. Splitting them (ready() probes the venv, missing() probes the caller) takes a warm run from a full pip install to 0.47s. Pinned by a test. Verified on a clone with no .venv: first run installs and prints one line, second and third print nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
202 lines
7.9 KiB
Python
Executable File
202 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Tag a private photo album, in a browser, on this machine only.
|
|
|
|
Sixty-odd photos and seven people is not a hand-edited JSON file. This serves
|
|
one page on 127.0.0.1 that shows each item in turn with a numbered checkbox
|
|
per person, saves on every keystroke, and never leaves the machine.
|
|
|
|
Names are typed *into the page*, never as arguments: an argument lands in the
|
|
shell history, and the whole design of the pack is that the names exist in
|
|
one directory and nowhere else.
|
|
|
|
Either entry point sets up ``.venv`` on first use -- the dependencies are not
|
|
in the system interpreter, and a script that tells you to go and install them
|
|
yourself is a script that does not own its dependencies.
|
|
|
|
python tool/tagger.py --untagged # bootstraps, then runs
|
|
./tag.sh # the configured pack
|
|
./tag.sh --dir ~/pack --port 8140
|
|
./tag.sh --import ~/photos # dedup, strip and import, then tag
|
|
./tag.sh --import ~/photos --dry-run # the report, changing nothing
|
|
./tag.sh --untagged # only items with no tags yet
|
|
|
|
It prints a summary when it stops: how many items are untagged, and how many
|
|
photos each command would have. A command with zero is dead on arrival, and
|
|
this is where that shows up rather than in the group.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# Importing this sets up .venv if the interpreter running us cannot import
|
|
# what follows. It has to come first, and it is an import rather than a call
|
|
# so that nothing here needs an E402 suppression. "python tool/tagger.py"
|
|
# puts tool/ on sys.path, which is what makes the plain name resolve.
|
|
from venv_bootstrap import REPO_ROOT
|
|
|
|
import argparse
|
|
import sys
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
import httpx
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
|
|
from signal_bot.config_env import load_config
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.media_strip import ExiftoolStripper
|
|
from signal_bot.pack import MEDIA_DIR, default_dirs
|
|
from signal_bot.pack_album import Album
|
|
from signal_bot.pack_import import plan_import, run_import
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.pack_report import ImportReport
|
|
|
|
PAGE: Final = REPO_ROOT / "tool" / "tagger.html"
|
|
DEFAULT_PORT: Final = 8140
|
|
|
|
|
|
def live_groups() -> list[dict[str, str]]:
|
|
"""The groups this account is in, so an id is picked and never typed."""
|
|
try:
|
|
config = load_config()
|
|
response = httpx.get(config.groups_url, timeout=5.0)
|
|
response.raise_for_status()
|
|
listing = response.json()
|
|
except ConfigError, httpx.HTTPError, ValueError:
|
|
# No container, no .env, no network: none of them stop the tagging,
|
|
# they only mean the group id has to be pasted instead of picked.
|
|
return []
|
|
return [
|
|
{"id": str(entry.get("id", "")), "name": str(entry.get("name", ""))}
|
|
for entry in listing
|
|
if entry.get("id")
|
|
]
|
|
|
|
|
|
def roster_routes(app: FastAPI, album: Album) -> None:
|
|
"""The two endpoints that write people.json.
|
|
|
|
Split from build_app to keep it under the complexity gate, along the
|
|
seam that matters: everything here can refuse, and refusing has to reach
|
|
the page rather than the next restart.
|
|
"""
|
|
|
|
@app.post("/api/setup")
|
|
async def setup(payload: dict[str, Any]) -> dict[str, str]:
|
|
try:
|
|
album.setup(
|
|
list(payload.get("groups", [])),
|
|
list(payload.get("people", [])),
|
|
list(payload.get("combos", [])),
|
|
)
|
|
except ConfigError as error:
|
|
# Straight into the page: a manifest the bot would refuse should
|
|
# be fixed here, in the form, not found at the next restart.
|
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
return {"status": "ok"}
|
|
|
|
@app.post("/api/person")
|
|
async def person(payload: dict[str, Any]) -> dict[str, str]:
|
|
# Mid-session, not only at setup: the reason to add somebody is
|
|
# usually a photo of them already on the screen.
|
|
try:
|
|
key = album.add_person([str(t) for t in payload.get("triggers", [])])
|
|
except ConfigError as error:
|
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
|
return {"status": "ok", "key": key}
|
|
|
|
|
|
def build_app(album: Album) -> FastAPI:
|
|
"""The one-page application, bound to one album."""
|
|
app = FastAPI()
|
|
roster_routes(app, album)
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def page() -> str:
|
|
return PAGE.read_text(encoding="utf-8")
|
|
|
|
@app.get("/api/state")
|
|
async def state() -> dict[str, Any]:
|
|
return {**album.state(), "known_groups": live_groups()}
|
|
|
|
@app.post("/api/tag")
|
|
async def tag(payload: dict[str, Any]) -> dict[str, str]:
|
|
try:
|
|
album.tag(str(payload["file"]), [str(key) for key in payload.get("keys", [])])
|
|
except KeyError as error:
|
|
raise HTTPException(status_code=404, detail=str(error)) from error
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/api/summary")
|
|
async def summary() -> dict[str, list[str]]:
|
|
# The same lines the tagger prints when it stops. Shown in the page
|
|
# too, because "I tagged everything, did it save?" is a question the
|
|
# last photo used to answer by doing nothing at all.
|
|
return {"lines": album.summary()}
|
|
|
|
@app.get("/media/{index}")
|
|
async def media(index: int) -> FileResponse:
|
|
# By index, never by a name the page sends back: a filename from a
|
|
# client is a path, and portraits.path_for exists for the same reason.
|
|
files = album.files
|
|
if not 0 <= index < len(files):
|
|
raise HTTPException(status_code=404, detail="no such item")
|
|
return FileResponse(album.directory / MEDIA_DIR / files[index])
|
|
|
|
return app
|
|
|
|
|
|
def main() -> int:
|
|
"""Serve the tagger until it is stopped, then print the summary."""
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
parser.add_argument("--dir", type=Path, default=None, help="the pack directory")
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
parser.add_argument(
|
|
"--import", dest="source", type=Path, default=None, help="import media from here first"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run", action="store_true", help="report what --import would do, change nothing"
|
|
)
|
|
parser.add_argument(
|
|
"--untagged", action="store_true", help="present only items with no tags yet"
|
|
)
|
|
parser.add_argument("--no-open", action="store_true", help="do not open a browser")
|
|
arguments = parser.parse_args()
|
|
|
|
directory = arguments.dir or default_dirs()[0][0]
|
|
if arguments.source is not None:
|
|
report = _import(arguments.source, directory, dry_run=arguments.dry_run)
|
|
sys.stdout.write("\n".join(report.lines()) + "\n\n")
|
|
if arguments.dry_run:
|
|
# Nothing was written, so there is nothing to tag and no reason to
|
|
# open a browser at a server this run is not going to start.
|
|
return 0
|
|
|
|
album = Album(directory, untagged_only=arguments.untagged)
|
|
sys.stdout.write(f"== tagging {len(album.files)} item(s) in {directory}\n")
|
|
url = f"http://127.0.0.1:{arguments.port}/"
|
|
sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n")
|
|
if not arguments.no_open:
|
|
webbrowser.open(url)
|
|
uvicorn.run(build_app(album), host="127.0.0.1", port=arguments.port, log_level="warning")
|
|
sys.stdout.write("\n".join(album.summary()) + "\n")
|
|
return 0
|
|
|
|
|
|
def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport:
|
|
"""Import a folder, or work out what importing it would do."""
|
|
stripper = ExiftoolStripper()
|
|
if dry_run:
|
|
return plan_import(source, directory, stripper)
|
|
return run_import(source, directory, stripper)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|