pandion-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """pandion-api: Pandion's public API over HTTP, with one Policy for a site's rules.
2
+
3
+ `create_app(ApiSettings(data_dir=...), policy)` gives a FastAPI app. Every
4
+ endpoint that takes a pipeline takes the pipeline file's shape as JSON.
5
+ `create_app(settings, policy, mode="serve", pipeline="pipeline.yaml")` instead
6
+ serves one fixed pipeline to a site's users. See docs/pandion-api/index.md.
7
+ """
8
+
9
+ from pandion_api.app import create_app
10
+ from pandion_api.policy import Policy
11
+ from pandion_api.serve import ServeSettings
12
+ from pandion_api.settings import ApiSettings
13
+ from pandion_api.version import VERSION as __version__
14
+
15
+ __all__ = ["ApiSettings", "Policy", "ServeSettings", "create_app", "__version__"]
pandion_api/app.py ADDED
@@ -0,0 +1,67 @@
1
+ """create_app: a FastAPI app over Pandion's public API, with a site's Policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI
10
+
11
+ from pandion_api.documents import DocumentStore
12
+ from pandion_api.policy import Policy
13
+ from pandion_api.routes import ask, documents, evaluations, health, projects, questions, results, sort, strategies
14
+ from pandion_api.routes import runs as run_routes
15
+ from pandion_api.routes import settings as settings_routes
16
+ from pandion_api.runs import RunManager
17
+ from pandion_api.settings import ApiSettings
18
+
19
+ #: Every router, in the order docs/pandion-api/index.md lists them.
20
+ ROUTERS = (
21
+ health.router, strategies.router, documents.router, questions.router, run_routes.router, sort.router, ask.router,
22
+ evaluations.router, results.router, settings_routes.router, projects.router,
23
+ )
24
+
25
+
26
+ def create_app(
27
+ settings: ApiSettings,
28
+ policy: Policy | None = None,
29
+ *,
30
+ mode: str = "lab",
31
+ pipeline: str | Path | None = None,
32
+ conversations: Any = None,
33
+ serve: Any = None,
34
+ ) -> FastAPI:
35
+ """The app. `policy` holds the site's rules; the default allows everything.
36
+
37
+ `mode="lab"` (the default) takes a pipeline with each request. `mode="serve"`
38
+ loads the one pipeline file at `pipeline` and answers POST /chat; it keeps
39
+ conversations in `conversations` (a Pandion conversation store, else SQL in
40
+ the data folder) and takes its chat settings from `serve` (`ServeSettings`).
41
+ See docs/pandion-api/serve-mode.md, "Serve mode"."""
42
+ if mode == "serve":
43
+ from pandion_api.serve import create_serve_app
44
+
45
+ return create_serve_app(settings, policy, pipeline, conversations, serve)
46
+ if mode != "lab":
47
+ raise ValueError(f"mode must be 'lab' or 'serve', not '{mode}'.")
48
+ policy = policy or Policy()
49
+ manager = RunManager(limit=policy.max_active_runs)
50
+
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ await policy.on_startup(app)
54
+ try:
55
+ yield
56
+ finally:
57
+ await manager.shutdown()
58
+ await policy.on_shutdown(app)
59
+
60
+ app = FastAPI(title=settings.title, lifespan=lifespan)
61
+ app.state.settings = settings
62
+ app.state.policy = policy
63
+ app.state.runs = manager
64
+ app.state.documents = DocumentStore(settings.sources)
65
+ for router in ROUTERS:
66
+ app.include_router(router, prefix=settings.prefix, dependencies=policy.dependencies())
67
+ return app
pandion_api/checks.py ADDED
@@ -0,0 +1,92 @@
1
+ """Read a pipeline dict step by step, for a site's rules (see Policy.check_run).
2
+
3
+ Every function here reads the pipeline file's shape, as `Pipeline.to_dict`
4
+ gives it, and the strategies' settings as `pandion.catalog()` describes them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import pandion
12
+
13
+ #: (section, key, stage) for each step that is one block.
14
+ SINGLE = (
15
+ ("index", "source", "source"),
16
+ ("index", "parse", "parse"),
17
+ ("index", "chunk", "chunk"),
18
+ ("index", "enrich", "enrich"),
19
+ ("index", "store", "index"),
20
+ ("retrieve", "rewrite", "query"),
21
+ ("retrieve", "search", "retrieve"),
22
+ )
23
+ #: (section, key, stage) for each step that is a list of blocks.
24
+ LISTS = (("index", "clean", "clean"), ("retrieve", "rerank", "rerank"))
25
+
26
+
27
+ def _block(block: Any) -> dict[str, Any] | None:
28
+ return block if isinstance(block, dict) and isinstance(block.get("use"), str) else None
29
+
30
+
31
+ def _section(config: dict[str, Any], name: str) -> dict[str, Any]:
32
+ section = config.get(name)
33
+ return section if isinstance(section, dict) else {}
34
+
35
+
36
+ def steps(config: dict[str, Any]) -> list[tuple[str, str, str, dict[str, Any]]]:
37
+ """(where, stage, use, settings) for every step in `config`."""
38
+ found: list[tuple[str, str, str, dict[str, Any]]] = []
39
+
40
+ def add(where: str, stage: str, block: dict[str, Any]) -> None:
41
+ found.append((where, stage, block["use"], {k: v for k, v in block.items() if k != "use"}))
42
+
43
+ # index.embed is not visited: to_dict folds the embedder's settings into it,
44
+ # and no embedder has a read-only setting or a custom endpoint today.
45
+ for section, key, stage in SINGLE:
46
+ block = _block(_section(config, section).get(key))
47
+ if block:
48
+ add(f"{section}.{key}", stage, block)
49
+ for section, key, stage in LISTS:
50
+ blocks = _section(config, section).get(key)
51
+ for n, block in enumerate(blocks if isinstance(blocks, list) else [], start=1):
52
+ if _block(block):
53
+ add(f"{section}.{key}.{n}", stage, block)
54
+ answer = _block(config.get("answer"))
55
+ if answer:
56
+ add("answer", "use_case", answer)
57
+ return found
58
+
59
+
60
+ def _properties(catalog: dict[str, Any], stage: str, use: str) -> dict[str, Any]:
61
+ return catalog.get(stage, {}).get(use, {}).get("config_schema", {}).get("properties", {})
62
+
63
+
64
+ def chat_providers(catalog: dict[str, Any] | None = None) -> dict[str, str]:
65
+ """{chat model id: its provider}, as the chat step's `model` setting lists them."""
66
+ catalog = catalog if catalog is not None else pandion.catalog()
67
+ return dict((_properties(catalog, "use_case", "chat").get("model") or {}).get("x-providers") or {})
68
+
69
+
70
+ def custom_endpoint_steps(config: dict[str, Any], catalog: dict[str, Any] | None = None) -> list[str]:
71
+ """The steps set to call a model at an address the request chose (an
72
+ OpenAI-compatible endpoint): a server would send a request there."""
73
+ catalog = catalog if catalog is not None else pandion.catalog()
74
+ found = []
75
+ for where, stage, use, settings in steps(config):
76
+ field = _properties(catalog, stage, use).get("model") or {}
77
+ providers = field.get("x-providers") or {}
78
+ model = settings.get("model")
79
+ if isinstance(model, str) and providers.get(model) == "openai_compatible":
80
+ found.append(where)
81
+ return found
82
+
83
+
84
+ def changed_read_only_settings(config: dict[str, Any], catalog: dict[str, Any] | None = None) -> list[str]:
85
+ """Every setting marked readOnly (a prompt, a model revision) set to anything but its default."""
86
+ catalog = catalog if catalog is not None else pandion.catalog()
87
+ found = []
88
+ for where, stage, use, settings in steps(config):
89
+ for key, prop in _properties(catalog, stage, use).items():
90
+ if prop.get("readOnly") and key in settings and settings[key] != prop.get("default"):
91
+ found.append(f"{where}.{key}")
92
+ return found
@@ -0,0 +1,58 @@
1
+ """Model keys: which header and variable each provider's key comes in, and
2
+ keeping keys out of everything a run shows.
3
+
4
+ A key is never stored, echoed, hashed or logged. A run gets the keys
5
+ `Policy.credentials(request)` resolves, holds them only in its worker's
6
+ closure, and every event and result it gives passes through `redact`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import traceback
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Iterable
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ProviderKey:
18
+ header: str # the request header a client sends a key in
19
+ env_var: str # the server's environment variable
20
+ extra: str # the key's name in Pandion's `credentials=`
21
+
22
+
23
+ PROVIDERS: dict[str, ProviderKey] = {
24
+ "anthropic": ProviderKey("X-Anthropic-Api-Key", "ANTHROPIC_API_KEY", "anthropic_api_key"),
25
+ "openai": ProviderKey("X-OpenAI-Api-Key", "OPENAI_API_KEY", "openai_api_key"),
26
+ "custom": ProviderKey("X-Custom-Api-Key", "OPENAI_COMPATIBLE_API_KEY", "custom_api_key"),
27
+ "openrouter": ProviderKey("X-OpenRouter-Api-Key", "OPENROUTER_API_KEY", "openrouter_api_key"),
28
+ }
29
+
30
+ #: What a scrubbed key is replaced with.
31
+ REDACTED = "[redacted]"
32
+
33
+
34
+ def redact(value: Any, key: str | Iterable[str] | None) -> Any:
35
+ """Replace every occurrence of `key` (or of each of several keys) in the
36
+ strings inside `value`."""
37
+ keys = [key] if isinstance(key, str) else [k for k in (key or []) if k]
38
+ if not keys:
39
+ return value
40
+ if isinstance(value, str):
41
+ for k in sorted(keys, key=len, reverse=True):
42
+ value = value.replace(k, REDACTED)
43
+ return value
44
+ if isinstance(value, dict):
45
+ return {k: redact(v, keys) for k, v in value.items()}
46
+ if isinstance(value, (list, tuple)):
47
+ return type(value)(redact(v, keys) for v in value)
48
+ return value
49
+
50
+
51
+ def guarded(keys: list[str], fn: Callable[[], Any]) -> Any:
52
+ """Run `fn`; a crash is raised again with every key scrubbed from its traceback."""
53
+ try:
54
+ return fn()
55
+ except Exception:
56
+ if not keys:
57
+ raise
58
+ raise RuntimeError(redact(traceback.format_exc(), keys)) from None
pandion_api/deps.py ADDED
@@ -0,0 +1,26 @@
1
+ """Plain FastAPI dependencies: what a route gets from the app it runs in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Request
6
+
7
+ from pandion_api.documents import DocumentStore
8
+ from pandion_api.policy import Policy
9
+ from pandion_api.runs import RunManager
10
+ from pandion_api.settings import ApiSettings
11
+
12
+
13
+ def get_policy(request: Request) -> Policy:
14
+ return request.app.state.policy
15
+
16
+
17
+ def get_settings(request: Request) -> ApiSettings:
18
+ return request.app.state.settings
19
+
20
+
21
+ def get_runs(request: Request) -> RunManager:
22
+ return request.app.state.runs
23
+
24
+
25
+ def get_documents(request: Request) -> DocumentStore:
26
+ return request.app.state.documents
@@ -0,0 +1,206 @@
1
+ """Documents: each uploaded file once, by its fingerprint, with a record beside it.
2
+
3
+ A file is kept as `<sha256><suffix>` in the `sources/` folder under the
4
+ server's data folder, the name Pandion's own upload step looks for. Its record
5
+ (`sources/.meta/<sha>.json`) holds the name it came with, its size and its
6
+ content type, the link it was fetched from for a URL document (`origin_url`),
7
+ and any other key a site's Policy adds, such as who uploaded it; a client is
8
+ shown only the first five. A question set written for it lives in
9
+ `sources/questions/<sha>.json`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import pandion
24
+ from pandion.extend import FORMATS, format_of
25
+
26
+ #: A document id is a sha256 in lowercase hex. Checked before an id reaches a
27
+ #: path, so a crafted one such as `../../x` never leaves the folder.
28
+ SHA = re.compile(r"^[0-9a-f]{64}\Z")
29
+ #: The 404 for a document that is not there, or not for this caller. It never
30
+ #: repeats the id the request sent.
31
+ NO_SUCH_DOCUMENT = "There is no document with that fingerprint."
32
+ META_DIR = ".meta"
33
+ QUESTIONS_DIR = "questions"
34
+ #: Held around every change to a record, here, in a Policy and in a site's own
35
+ #: clean-up, so two uploads of the same bytes, or an upload and a clean-up,
36
+ #: never lose what the other wrote.
37
+ SIDECAR_LOCK = threading.Lock()
38
+ #: The part of a request's file name that reaches a path: a plain suffix such
39
+ #: as `.pdf`. Anything else (a `:` that names a Windows stream, a space, a
40
+ #: separator) is dropped, and the file is kept under its sha alone.
41
+ SAFE_SUFFIX = re.compile(r"^\.[A-Za-z0-9_-]{1,16}$")
42
+
43
+ #: The refusal for a name ending in `.part`: a stored file with that suffix
44
+ #: would look like a half-written one, which every reader skips.
45
+ PART_REFUSED = "A file name ending in .part cannot be uploaded; rename it and try again."
46
+
47
+ _PUBLIC = ("sha", "filename", "size", "content_type", "origin_url")
48
+
49
+
50
+ def readable_formats() -> list[str] | None:
51
+ """Every format some parser here declares it reads, such as ["pdf", "docx"],
52
+ or None when a parser declares none (it is not checked, so any file may do)."""
53
+ found: set[str] = set()
54
+ for parser in pandion.catalog()["parse"].values():
55
+ formats = (parser.get("provides") or {}).get("formats")
56
+ if not formats:
57
+ return None
58
+ found.update(formats)
59
+ return [name for name in FORMATS if name in found]
60
+
61
+
62
+ def unreadable(filename: str) -> str | None:
63
+ """The 415 sentence for a file no parser here reads, or None. A name
64
+ whose suffix the store would not keep (see `suffix`) is not checked, the
65
+ same as Pandion leaves a file with no extension unchecked."""
66
+ formats = readable_formats()
67
+ if formats is None or not suffix(filename) or format_of(filename) in formats:
68
+ return None
69
+ names = [FORMATS[name][0] for name in formats]
70
+ listed = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} and {names[-1]}"
71
+ return f"This server reads {listed} files, and this file is not one of them."
72
+
73
+
74
+ def suffix(filename: str) -> str:
75
+ """The suffix a file is stored with: its name's, when it is a plain one, else none."""
76
+ found = Path(filename).suffix
77
+ return found if SAFE_SUFFIX.match(found) else ""
78
+
79
+
80
+ def safe_name(filename: str) -> str:
81
+ """`filename` as a run names its document. Pandion keeps its own copy of a
82
+ document as `<sha><the name's suffix>`, so the name's suffix must be the
83
+ one the store kept (`suffix`). When the store dropped it (a space, a `:`
84
+ that names a Windows stream, too long), every dot becomes `_`, leaving the
85
+ name no suffix at all. A plain name such as `report.pdf` is unchanged, so
86
+ its result ids are the old routes' ones."""
87
+ return filename if Path(filename).suffix == suffix(filename) else filename.replace(".", "_")
88
+
89
+
90
+ def is_part(filename: str) -> bool:
91
+ """Whether the name ends in `.part`, in any case (see PART_REFUSED)."""
92
+ return filename.lower().endswith(".part")
93
+
94
+
95
+ def read_sidecar(path: Path) -> dict[str, Any]:
96
+ """A record's JSON, or {} when it is missing, unreadable, or not an object."""
97
+ try:
98
+ data = json.loads(path.read_text(encoding="utf-8"))
99
+ except (OSError, json.JSONDecodeError):
100
+ return {}
101
+ return data if isinstance(data, dict) else {}
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Document:
106
+ sha: str
107
+ filename: str
108
+ size: int
109
+ content_type: str
110
+ #: The link a URL document was fetched from; None for an upload.
111
+ origin_url: str | None = None
112
+ #: Every other key in the record, for the site's Policy. Never shown.
113
+ extra: dict[str, Any] = field(default_factory=dict)
114
+
115
+ def record(self) -> dict[str, Any]:
116
+ """What a client is shown: `origin_url` only for a URL document."""
117
+ shown = {"sha": self.sha, "filename": self.filename, "size": self.size, "content_type": self.content_type}
118
+ return {**shown, "origin_url": self.origin_url} if self.origin_url else shown
119
+
120
+ @classmethod
121
+ def of(cls, body: dict[str, Any]) -> Document | None:
122
+ """The document a record describes, or None for one without a valid id and name."""
123
+ sha, filename = body.get("sha"), body.get("filename")
124
+ if not isinstance(sha, str) or not SHA.match(sha) or not isinstance(filename, str):
125
+ return None
126
+ size, content_type, origin = body.get("size"), body.get("content_type"), body.get("origin_url")
127
+ return cls(
128
+ sha=sha,
129
+ filename=filename,
130
+ size=size if isinstance(size, int) else 0,
131
+ content_type=content_type if isinstance(content_type, str) else "application/octet-stream",
132
+ origin_url=origin if isinstance(origin, str) else None,
133
+ extra={k: v for k, v in body.items() if k not in _PUBLIC},
134
+ )
135
+
136
+
137
+ class DocumentStore:
138
+ lock = SIDECAR_LOCK
139
+
140
+ def __init__(self, folder: str | Path) -> None:
141
+ self.folder = Path(folder)
142
+
143
+ def sidecar(self, sha: str) -> Path:
144
+ return self.folder / META_DIR / f"{sha}.json"
145
+
146
+ def questions(self, sha: str) -> Path:
147
+ return self.folder / QUESTIONS_DIR / f"{sha}.json"
148
+
149
+ def save(self, data: bytes, filename: str) -> str:
150
+ """Keep `data` as `<sha><suffix of filename>`, unless a file with this sha
151
+ is here already, under any suffix: the same bytes are never stored
152
+ twice, whatever they are called. Only a plain suffix comes from the
153
+ name (see `suffix`). The bytes go to a side file first and are renamed
154
+ into place, so a reader never sees half a file. Returns the sha."""
155
+ sha = hashlib.sha256(data).hexdigest()
156
+ dest = self.folder / f"{sha}{suffix(filename)}"
157
+ if self.file(sha) is None:
158
+ self.folder.mkdir(parents=True, exist_ok=True)
159
+ part = dest.with_name(dest.name + ".part")
160
+ part.write_bytes(data)
161
+ os.replace(part, dest)
162
+ return sha
163
+
164
+ def write(
165
+ self, sha: str, filename: str, size: int, content_type: str, origin_url: str | None = None
166
+ ) -> Document:
167
+ """Write the record. Keys a site added stay, and so does an `origin_url`
168
+ an earlier fetch of the same bytes wrote. Call with `lock` held."""
169
+ path = self.sidecar(sha)
170
+ path.parent.mkdir(parents=True, exist_ok=True)
171
+ body = {**read_sidecar(path), "sha": sha, "filename": filename, "size": size, "content_type": content_type}
172
+ if origin_url:
173
+ body["origin_url"] = origin_url
174
+ path.write_text(json.dumps(body), encoding="utf-8")
175
+ return Document.of(body)
176
+
177
+ def get(self, sha: str) -> Document | None:
178
+ if not SHA.match(sha):
179
+ return None
180
+ return Document.of(read_sidecar(self.sidecar(sha)))
181
+
182
+ def all(self) -> list[Document]:
183
+ meta = self.folder / META_DIR
184
+ if not meta.is_dir():
185
+ return []
186
+ found = (Document.of(read_sidecar(p)) for p in meta.glob("*.json"))
187
+ return [d for d in found if d is not None]
188
+
189
+ def file(self, sha: str) -> Path | None:
190
+ """The stored file, or None. A seeded file with no record is found too."""
191
+ if not SHA.match(sha) or not self.folder.is_dir():
192
+ return None
193
+ for p in sorted(self.folder.glob(f"{sha}*")):
194
+ if p.is_file() and not p.name.endswith(".part"):
195
+ return p
196
+ return None
197
+
198
+ def remove(self, sha: str) -> None:
199
+ """Delete the file, its record and its question set. Call with `lock` held."""
200
+ if not SHA.match(sha):
201
+ return
202
+ for p in self.folder.glob(f"{sha}*"):
203
+ if p.is_file():
204
+ p.unlink()
205
+ self.sidecar(sha).unlink(missing_ok=True)
206
+ self.questions(sha).unlink(missing_ok=True)
pandion_api/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """What a serve caller sees when something fails: one sentence and a trace id.
2
+ The detail goes to the log under the same id."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import uuid
8
+
9
+ logger = logging.getLogger("pandion_api")
10
+
11
+
12
+ def generic(trace_id: str) -> str:
13
+ return f"Something went wrong. Quote this trace id when you ask for help: {trace_id}."
14
+
15
+
16
+ def hide(detail: str, trace_id: str | None = None) -> str:
17
+ """Log `detail` at ERROR with a trace id and return the sentence for the caller."""
18
+ trace_id = trace_id or uuid.uuid4().hex
19
+ logger.error("Serve error (trace %s): %s", trace_id, detail)
20
+ return generic(trace_id)
pandion_api/jobs.py ADDED
@@ -0,0 +1,141 @@
1
+ """Starting a run, and the documents it reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Annotated, Any, Callable
7
+
8
+ from fastapi import HTTPException, Request
9
+ from pydantic import BaseModel, Field
10
+
11
+ from pandion_api.credentials import PROVIDERS, guarded, redact
12
+ from pandion_api.errors import hide
13
+ from pandion_api.documents import NO_SUCH_DOCUMENT, SHA, safe_name
14
+ from pandion_api.pipelines import problems
15
+
16
+ #: What a run's work does on the worker thread: given the event callback, the
17
+ #: cancel event and the keys (None when there are none), it returns
18
+ #: (ok, cancelled, result) with a plain JSON result.
19
+ Work = Callable[[Callable[[dict[str, Any]], None], threading.Event, dict[str, str] | None], tuple[bool, bool, Any]]
20
+
21
+
22
+ #: One document's labels: at most 50, a name up to 2,000 characters, a value up to 10,000.
23
+ Labels = Annotated[dict[Annotated[str, Field(max_length=2000)], Annotated[str, Field(max_length=10000)]], Field(max_length=50)]
24
+
25
+
26
+ class SortedIn(BaseModel):
27
+ """A request body that names documents and may say which category each
28
+ goes to. Both maps are keyed by the document's sha, as `documents` is."""
29
+
30
+ #: Pin a document to a category; this wins over the route.
31
+ categories: dict[str, str] = {}
32
+ #: Labels for a document, read by the route's `metadata` check.
33
+ metadata: dict[str, Labels] = {}
34
+
35
+
36
+ def assign_to(p: Any, body: SortedIn, names: list[str], request: Request) -> None:
37
+ """Give `p` the pins and labels of `body`, and the link each URL document
38
+ was fetched from. `names` are the document ids the pipeline knows, in the
39
+ order of `body.documents`. A pin or label for a document not in
40
+ `documents`, or a pin to an unknown category, is a 422."""
41
+ ids = dict(zip(body.documents, names))
42
+ store = request.app.state.documents
43
+ links = {name: d.origin_url for sha, name in ids.items() if (d := store.get(sha)) and d.origin_url}
44
+ for sha in [*body.categories, *body.metadata]:
45
+ if sha not in ids:
46
+ raise problems(f"categories and metadata name document {sha}, which is not one of the documents sent.")
47
+ try:
48
+ p.index.assign(
49
+ categories={ids[s]: c for s, c in body.categories.items()},
50
+ metadata={ids[s]: m for s, m in body.metadata.items()},
51
+ links=links,
52
+ )
53
+ except ValueError as exc:
54
+ raise problems(str(exc)) from None
55
+
56
+
57
+ def documents_of(shas: list[str], request: Request) -> dict[str, str]:
58
+ """{document name: stored file} for a run, named as each was uploaded, so
59
+ the same bytes under the same name always give the same result ids. A file
60
+ with no record takes the name `Policy.document_name` gives it, else its
61
+ stored file's name."""
62
+ store = request.app.state.documents
63
+ policy = request.app.state.policy
64
+ out: dict[str, str] = {}
65
+ for sha in shas:
66
+ if not SHA.match(sha):
67
+ raise HTTPException(status_code=404, detail=NO_SUCH_DOCUMENT)
68
+ if not policy.can_read(sha, request):
69
+ raise HTTPException(status_code=403, detail=policy.not_readable)
70
+ path = store.file(sha)
71
+ if path is None:
72
+ raise HTTPException(status_code=404, detail=NO_SUCH_DOCUMENT)
73
+ document = store.get(sha)
74
+ given = policy.document_name(sha, request) if document is None else None
75
+ name = safe_name(document.filename) if document is not None else safe_name(given) if given else path.name
76
+ if name in out:
77
+ raise HTTPException(
78
+ status_code=400,
79
+ detail=f"Two documents are both named {name}. Upload one of them under another name.",
80
+ )
81
+ out[name] = str(path)
82
+ return out
83
+
84
+
85
+ def scrub_errors(value: Any, scrub: Callable[[str], str]) -> Any:
86
+ """`value` with `scrub` applied to every string under an `error` key, at any depth."""
87
+ if isinstance(value, dict):
88
+ return {
89
+ k: scrub(v) if k == "error" and isinstance(v, str) else scrub_errors(v, scrub)
90
+ for k, v in value.items()
91
+ }
92
+ if isinstance(value, (list, tuple)):
93
+ return type(value)(scrub_errors(v, scrub) for v in value)
94
+ return value
95
+
96
+
97
+ def too_many_runs(most: int) -> str:
98
+ """The 429 for an owner who already has `most` runs going."""
99
+ runs = "run" if most == 1 else "runs"
100
+ return f"You already have {most} {runs} going. Wait for one to finish, or stop one."
101
+
102
+
103
+ def start(request: Request, kind: str, work: Work) -> dict[str, str]:
104
+ """Start `work` on the app's run manager with this request's keys, as this
105
+ request's owner (`Policy.run_owner`). Every event and the result are
106
+ scrubbed of the keys, and their error text goes through `Policy.scrub`; a
107
+ `run_cancelled` event marks the run cancelled. An owner who already has
108
+ `Policy.max_runs_per_owner` runs working or waiting is refused with a 429."""
109
+ policy = request.app.state.policy
110
+ owner = policy.run_owner(request)
111
+ most = policy.max_runs_per_owner(request)
112
+ if owner is not None and most is not None:
113
+ going = sum(1 for s in request.app.state.runs.active() if s.owner == owner)
114
+ if going >= most:
115
+ raise HTTPException(status_code=429, detail=too_many_runs(most))
116
+ serving = hasattr(request.app.state, "serve") # serve mode: env keys only, generic errors
117
+ if serving:
118
+ credentials = {
119
+ spec.extra: key for p, spec in PROVIDERS.items() if (key := policy.server_key(p)[0])
120
+ }
121
+ else:
122
+ credentials = policy.credentials(request)
123
+ keys = list(credentials.values())
124
+
125
+ def clean(value: Any) -> Any:
126
+ return scrub_errors(redact(value, keys), policy.scrub)
127
+
128
+ def job(emit, cancel):
129
+ seen: list[bool] = []
130
+
131
+ def on_event(e: dict[str, Any]) -> None:
132
+ if e.get("event") == "run_cancelled":
133
+ seen.append(True)
134
+ emit(clean(e))
135
+
136
+ ok, cancelled, result = guarded(keys, lambda: work(on_event, cancel, credentials or None))
137
+ return ok, cancelled or bool(seen), clean(result)
138
+
139
+ scrub = (lambda text: hide(redact(text, keys), getattr(request.state, "trace_id", None))) if serving else (lambda text: policy.scrub(redact(text, keys)))
140
+ state = request.app.state.runs.start(kind, job, owner=owner, scrub=scrub)
141
+ return {"run_id": state.run_id}