backlot 0.0.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.
- backlot/__init__.py +5 -0
- backlot/__main__.py +10 -0
- backlot/acl.py +89 -0
- backlot/auth.py +209 -0
- backlot/cli.py +164 -0
- backlot/config.py +130 -0
- backlot/data/hello.jsonl +136 -0
- backlot/errors/__init__.py +44 -0
- backlot/errors/atlassian.py +46 -0
- backlot/errors/google.py +230 -0
- backlot/graphql/__init__.py +8 -0
- backlot/graphql/engine.py +147 -0
- backlot/graphql/fireflies.graphql +240 -0
- backlot/graphql/fireflies_resolvers.py +357 -0
- backlot/graphql/linear.graphql +836 -0
- backlot/graphql/linear_filters.py +458 -0
- backlot/graphql/linear_resolvers.py +1215 -0
- backlot/importer/__init__.py +5 -0
- backlot/importer/byo.py +1440 -0
- backlot/importer/erb.py +2458 -0
- backlot/integrations/__init__.py +11 -0
- backlot/integrations/llamaindex.py +312 -0
- backlot/integrations/mirage.py +100 -0
- backlot/main.py +403 -0
- backlot/oauth.py +160 -0
- backlot/openapi.py +137 -0
- backlot/pagination.py +116 -0
- backlot/routers/__init__.py +25 -0
- backlot/routers/atlassian.py +1154 -0
- backlot/routers/fireflies.py +69 -0
- backlot/routers/github.py +901 -0
- backlot/routers/google.py +2144 -0
- backlot/routers/hubspot.py +622 -0
- backlot/routers/linear.py +79 -0
- backlot/routers/notion.py +541 -0
- backlot/routers/oauth.py +65 -0
- backlot/routers/s3.py +376 -0
- backlot/routers/slack.py +862 -0
- backlot/schemas/README.md +217 -0
- backlot/schemas/confluence.schema.json +188 -0
- backlot/schemas/fireflies.schema.json +304 -0
- backlot/schemas/github.schema.json +255 -0
- backlot/schemas/gmail.schema.json +222 -0
- backlot/schemas/google_drive.schema.json +138 -0
- backlot/schemas/hubspot.schema.json +140 -0
- backlot/schemas/jira.schema.json +231 -0
- backlot/schemas/linear.schema.json +298 -0
- backlot/schemas/notion.schema.json +173 -0
- backlot/schemas/s3.schema.json +118 -0
- backlot/schemas/slack.schema.json +161 -0
- backlot/sigv4.py +121 -0
- backlot/store.py +1871 -0
- backlot/synth.py +823 -0
- backlot/testing.py +276 -0
- backlot/validation.py +91 -0
- backlot-0.0.0.dist-info/METADATA +410 -0
- backlot-0.0.0.dist-info/RECORD +61 -0
- backlot-0.0.0.dist-info/WHEEL +5 -0
- backlot-0.0.0.dist-info/entry_points.txt +2 -0
- backlot-0.0.0.dist-info/licenses/LICENSE +21 -0
- backlot-0.0.0.dist-info/top_level.txt +1 -0
backlot/__init__.py
ADDED
backlot/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""``python -m backlot`` — the same CLI as the ``backlot`` console script.
|
|
2
|
+
|
|
3
|
+
Worth having for the case where the script is not on PATH: a venv that has not been activated, or
|
|
4
|
+
`pipx run`. It is also the spelling `python -m backlot.main` looked like it should be but never
|
|
5
|
+
was — that module only defines the ASGI app, so running it imported everything and exited.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from backlot.cli import main
|
|
9
|
+
|
|
10
|
+
raise SystemExit(main())
|
backlot/acl.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Runtime ACL: resolve a caller token to an identity and compute what it may see.
|
|
2
|
+
|
|
3
|
+
Principal ids are globally unique across types (org name, group slugs, user emails),
|
|
4
|
+
so a document is visible to a caller iff any of the doc's ACL ``principal_id`` values
|
|
5
|
+
is in the caller's principal set: ``{org} ∪ {their groups} ∪ {their own email}``.
|
|
6
|
+
An admin/service token bypasses filtering entirely (``visible_ids`` -> ``None``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from backlot import store, synth
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Caller:
|
|
22
|
+
email: str | None # None for admin/service account
|
|
23
|
+
is_admin: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Acl:
|
|
27
|
+
def __init__(self, token_to_email: dict[str, str], admin_token: str, org_name: str):
|
|
28
|
+
self._tokens = token_to_email
|
|
29
|
+
self._admin_token = admin_token
|
|
30
|
+
self.org_name = org_name
|
|
31
|
+
|
|
32
|
+
# Derived S3 (SigV4) credentials: access-key-id -> (Caller, secret-access-key). Every
|
|
33
|
+
# bearer token (users + the admin/service token) gets a deterministic keypair via synth,
|
|
34
|
+
# so a signed S3 request resolves to the same identity a bearer token would.
|
|
35
|
+
self._access_keys: dict[str, tuple[Caller, str]] = {}
|
|
36
|
+
self._access_keys[synth.s3_access_key_id(admin_token)] = (
|
|
37
|
+
Caller(email=None, is_admin=True),
|
|
38
|
+
synth.s3_secret_access_key(admin_token),
|
|
39
|
+
)
|
|
40
|
+
for token, email in token_to_email.items():
|
|
41
|
+
self._access_keys[synth.s3_access_key_id(token)] = (
|
|
42
|
+
Caller(email=email, is_admin=False),
|
|
43
|
+
synth.s3_secret_access_key(token),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def admin_token(self) -> str:
|
|
48
|
+
return self._admin_token
|
|
49
|
+
|
|
50
|
+
def email_to_token(self) -> dict[str, str]:
|
|
51
|
+
"""Inverse of the token map (each user has exactly one token)."""
|
|
52
|
+
return {email: token for token, email in self._tokens.items()}
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def load(cls, tokens_path: Path, admin_token: str, org_name: str) -> "Acl":
|
|
56
|
+
token_to_email: dict[str, str] = {}
|
|
57
|
+
if tokens_path.exists():
|
|
58
|
+
data = yaml.safe_load(tokens_path.read_text()) or {}
|
|
59
|
+
for entry in data.get("users", []):
|
|
60
|
+
if entry.get("token") and entry.get("email"):
|
|
61
|
+
token_to_email[entry["token"]] = entry["email"]
|
|
62
|
+
# tokens.yaml may override the admin token and the org (BYO derives it from the corpus)
|
|
63
|
+
admin_token = data.get("admin_token", admin_token)
|
|
64
|
+
org_name = data.get("org", org_name)
|
|
65
|
+
return cls(token_to_email, admin_token, org_name)
|
|
66
|
+
|
|
67
|
+
def resolve(self, token: str | None) -> Caller | None:
|
|
68
|
+
"""Return the Caller for a raw token, or None if the token is unknown."""
|
|
69
|
+
if not token:
|
|
70
|
+
return None
|
|
71
|
+
if token == self._admin_token:
|
|
72
|
+
return Caller(email=None, is_admin=True)
|
|
73
|
+
email = self._tokens.get(token)
|
|
74
|
+
if email is None:
|
|
75
|
+
return None
|
|
76
|
+
return Caller(email=email, is_admin=False)
|
|
77
|
+
|
|
78
|
+
def resolve_access_key(self, access_key: str | None) -> tuple[Caller, str] | None:
|
|
79
|
+
"""Resolve a SigV4 access-key-id to ``(Caller, secret_access_key)``, or None if unknown."""
|
|
80
|
+
if not access_key:
|
|
81
|
+
return None
|
|
82
|
+
return self._access_keys.get(access_key)
|
|
83
|
+
|
|
84
|
+
def visible_ids(self, conn: sqlite3.Connection, caller: Caller) -> set[str] | None:
|
|
85
|
+
if caller.is_admin:
|
|
86
|
+
return None
|
|
87
|
+
ids = {self.org_name, caller.email}
|
|
88
|
+
ids.update(store.user_group_ids(conn, caller.email))
|
|
89
|
+
return ids
|
backlot/auth.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Auth helpers shared by the vendor routers.
|
|
2
|
+
|
|
3
|
+
Each vendor carries credentials differently (Slack bearer/query token, Google/GitHub
|
|
4
|
+
bearer, Atlassian Basic email:api_token, Linear a scheme-less API key). These helpers
|
|
5
|
+
extract the raw token, resolve it to a :class:`~backlot.acl.Caller` via the app's ACL, and
|
|
6
|
+
compute the caller's visible principal set. Error *shaping* (Slack's ``ok:false`` vs a
|
|
7
|
+
real 401) stays in the routers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hmac
|
|
14
|
+
import sqlite3
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from fastapi import HTTPException, Request
|
|
18
|
+
|
|
19
|
+
from backlot import sigv4
|
|
20
|
+
from backlot.acl import Acl, Caller
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def conn(request: Request) -> sqlite3.Connection:
|
|
24
|
+
return request.app.state.conn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def acl(request: Request) -> Acl:
|
|
28
|
+
return request.app.state.acl
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _authorization(request: Request) -> str | None:
|
|
32
|
+
return request.headers.get("authorization")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bearer_token(request: Request) -> str | None:
|
|
36
|
+
"""Parse ``Authorization: Bearer <t>`` or GitHub's legacy ``token <t>``."""
|
|
37
|
+
hdr = _authorization(request)
|
|
38
|
+
if not hdr:
|
|
39
|
+
return None
|
|
40
|
+
parts = hdr.split(None, 1)
|
|
41
|
+
if len(parts) == 2 and parts[0].lower() in ("bearer", "token"):
|
|
42
|
+
return parts[1].strip()
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def api_key_token(request: Request) -> str | None:
|
|
47
|
+
"""Parse ``Authorization: <key>`` — with or without a ``Bearer`` prefix.
|
|
48
|
+
|
|
49
|
+
Linear's GraphQL API carries a personal API key as the bare header value
|
|
50
|
+
(``Authorization: lin_api_...``, no scheme) and an OAuth access token as
|
|
51
|
+
``Bearer <token>``, accepting both on the same header, so this accepts both too.
|
|
52
|
+
Anything that is not a ``Bearer`` prefix is returned verbatim rather than having its
|
|
53
|
+
first word stripped: to the real API the whole header value *is* the key, so a stray
|
|
54
|
+
scheme fails to resolve instead of being quietly discarded.
|
|
55
|
+
"""
|
|
56
|
+
hdr = (_authorization(request) or "").strip()
|
|
57
|
+
if not hdr:
|
|
58
|
+
return None
|
|
59
|
+
parts = hdr.split(None, 1)
|
|
60
|
+
if parts[0].lower() == "bearer":
|
|
61
|
+
return parts[1].strip() or None if len(parts) == 2 else None
|
|
62
|
+
return hdr
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def basic_password(request: Request) -> tuple[str | None, str | None]:
|
|
66
|
+
"""Parse ``Authorization: Basic base64(user:pass)`` -> (user, pass)."""
|
|
67
|
+
hdr = _authorization(request)
|
|
68
|
+
if not hdr:
|
|
69
|
+
return None, None
|
|
70
|
+
parts = hdr.split(None, 1)
|
|
71
|
+
if len(parts) == 2 and parts[0].lower() == "basic":
|
|
72
|
+
try:
|
|
73
|
+
decoded = base64.b64decode(parts[1]).decode("utf-8", "replace")
|
|
74
|
+
user, _, pw = decoded.partition(":")
|
|
75
|
+
return user, pw
|
|
76
|
+
except (ValueError, UnicodeDecodeError):
|
|
77
|
+
return None, None
|
|
78
|
+
return None, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def slack_token(request: Request) -> str | None:
|
|
82
|
+
"""Slack accepts the token as a bearer header, query param, or form field. The official
|
|
83
|
+
slack-go SDK (and Slack's own clients) post it as the ``token`` form field, so fall back to
|
|
84
|
+
the form stashed on ``request.state._form`` by the slack-form middleware."""
|
|
85
|
+
form = getattr(request.state, "_form", None)
|
|
86
|
+
form_field = form.get("token") if form else None
|
|
87
|
+
return bearer_token(request) or request.query_params.get("token") or form_field
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_bearer(request: Request) -> Caller | None:
|
|
91
|
+
return acl(request).resolve(bearer_token(request))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def require_bearer(request: Request, detail: str) -> Caller:
|
|
95
|
+
"""Resolve a bearer token or raise 401 with the VENDOR's own message.
|
|
96
|
+
|
|
97
|
+
``detail`` is a parameter rather than something this function picks, because the message is
|
|
98
|
+
part of the emulated surface: GitHub says "Bad credentials", Google "Invalid Credentials",
|
|
99
|
+
Atlassian "Unauthorized", and a client that string-matches its vendor's error has to keep
|
|
100
|
+
matching. Each router states its own once (see ``tests/test_endpoints.py``).
|
|
101
|
+
"""
|
|
102
|
+
caller = resolve_bearer(request)
|
|
103
|
+
if caller is None:
|
|
104
|
+
raise HTTPException(status_code=401, detail=detail)
|
|
105
|
+
return caller
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def require_basic_or_bearer(request: Request, detail: str) -> Caller:
|
|
109
|
+
"""Same, for Atlassian: it carries Basic ``email:api_token`` and also accepts a bearer OAuth
|
|
110
|
+
token, so both are tried before refusing."""
|
|
111
|
+
caller = resolve_basic(request) or resolve_bearer(request)
|
|
112
|
+
if caller is None:
|
|
113
|
+
raise HTTPException(status_code=401, detail=detail)
|
|
114
|
+
return caller
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_api_key(request: Request) -> Caller | None:
|
|
118
|
+
return acl(request).resolve(api_key_token(request))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_basic(request: Request) -> Caller | None:
|
|
122
|
+
"""Atlassian: resolve by the api_token (password); fall back to the username email."""
|
|
123
|
+
a = acl(request)
|
|
124
|
+
user, pw = basic_password(request)
|
|
125
|
+
caller = a.resolve(pw)
|
|
126
|
+
if caller is not None:
|
|
127
|
+
return caller
|
|
128
|
+
# allow username=email as an identity shortcut (mock convenience)
|
|
129
|
+
if user and "@" in user:
|
|
130
|
+
from backlot import store
|
|
131
|
+
|
|
132
|
+
if store.get_user(conn(request), user):
|
|
133
|
+
return Caller(email=user, is_admin=False)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def visible_ids(request: Request, caller: Caller) -> set[str] | None:
|
|
138
|
+
return acl(request).visible_ids(conn(request), caller)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_sigv4(request: Request) -> tuple[Caller | None, str | None]:
|
|
142
|
+
"""Verify an S3 SigV4 request (header or presigned-query auth).
|
|
143
|
+
|
|
144
|
+
Returns ``(caller, None)`` on a valid signature, else ``(None, <S3 error code>)`` — one of
|
|
145
|
+
``MissingSecurityHeader`` / ``AuthorizationHeaderMalformed`` / ``InvalidAccessKeyId`` /
|
|
146
|
+
``RequestTimeTooSkewed`` / ``AccessDenied`` / ``SignatureDoesNotMatch``. Real S3's check
|
|
147
|
+
order is parse -> resolve access key -> time validity -> signature match, so a bogus access
|
|
148
|
+
key is reported before any time error, and a stale-but-correctly-signed request is reported
|
|
149
|
+
as a time error rather than a signature mismatch. The region is taken from the client's own
|
|
150
|
+
credential scope, so any region validates. The canonical URI is the raw wire path (S3 signs
|
|
151
|
+
it verbatim)."""
|
|
152
|
+
hdrs = {k.lower(): v for k, v in request.headers.items()}
|
|
153
|
+
qs = request.query_params
|
|
154
|
+
authz = hdrs.get("authorization", "")
|
|
155
|
+
presigned = False
|
|
156
|
+
if authz.startswith(sigv4.ALGORITHM):
|
|
157
|
+
parsed = sigv4.parse_authorization(authz)
|
|
158
|
+
if not parsed:
|
|
159
|
+
return None, "AuthorizationHeaderMalformed"
|
|
160
|
+
cred = sigv4.split_credential(parsed["credential"])
|
|
161
|
+
signed_headers, signature = parsed["signed_headers"], parsed["signature"]
|
|
162
|
+
amz_date = hdrs.get("x-amz-date", "")
|
|
163
|
+
payload_hash = hdrs.get("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
|
164
|
+
elif qs.get("X-Amz-Signature"):
|
|
165
|
+
presigned = True
|
|
166
|
+
cred = sigv4.split_credential(qs.get("X-Amz-Credential", ""))
|
|
167
|
+
signed_headers = qs.get("X-Amz-SignedHeaders", "host")
|
|
168
|
+
signature = qs["X-Amz-Signature"]
|
|
169
|
+
amz_date = qs.get("X-Amz-Date", "")
|
|
170
|
+
payload_hash = "UNSIGNED-PAYLOAD"
|
|
171
|
+
else:
|
|
172
|
+
return None, "MissingSecurityHeader"
|
|
173
|
+
if not cred:
|
|
174
|
+
return None, "AuthorizationHeaderMalformed"
|
|
175
|
+
access_key, date_stamp, region = cred
|
|
176
|
+
resolved = acl(request).resolve_access_key(access_key)
|
|
177
|
+
if resolved is None:
|
|
178
|
+
return None, "InvalidAccessKeyId"
|
|
179
|
+
caller, secret = resolved
|
|
180
|
+
request_time = sigv4.parse_amz_date(amz_date)
|
|
181
|
+
if request_time is None:
|
|
182
|
+
return None, "AuthorizationHeaderMalformed"
|
|
183
|
+
now = datetime.now(timezone.utc)
|
|
184
|
+
if presigned:
|
|
185
|
+
try:
|
|
186
|
+
expires_in = int(qs.get("X-Amz-Expires", ""))
|
|
187
|
+
except ValueError:
|
|
188
|
+
return None, "AuthorizationHeaderMalformed"
|
|
189
|
+
if (now - request_time).total_seconds() > expires_in:
|
|
190
|
+
return None, "AccessDenied"
|
|
191
|
+
elif sigv4.is_skewed(request_time, now):
|
|
192
|
+
return None, "RequestTimeTooSkewed"
|
|
193
|
+
raw = request.scope.get("raw_path")
|
|
194
|
+
path = raw.decode("ascii") if raw else request.url.path
|
|
195
|
+
expected = sigv4.expected_signature(
|
|
196
|
+
secret,
|
|
197
|
+
request.method,
|
|
198
|
+
path,
|
|
199
|
+
request.url.query,
|
|
200
|
+
hdrs,
|
|
201
|
+
signed_headers,
|
|
202
|
+
payload_hash,
|
|
203
|
+
amz_date,
|
|
204
|
+
date_stamp,
|
|
205
|
+
region,
|
|
206
|
+
)
|
|
207
|
+
if not hmac.compare_digest(expected, signature):
|
|
208
|
+
return None, "SignatureDoesNotMatch"
|
|
209
|
+
return caller, None
|
backlot/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The ``backlot`` console script — one entry point for serving and for building ``data/``.
|
|
2
|
+
|
|
3
|
+
Two commands, each a thin front end over code that already existed:
|
|
4
|
+
|
|
5
|
+
backlot serve # uvicorn backlot.main:app, with uvicorn's own defaults
|
|
6
|
+
backlot import <corpus.jsonl> # backlot.importer.byo (--type byo, the default)
|
|
7
|
+
backlot import --type erb # backlot.importer.erb
|
|
8
|
+
|
|
9
|
+
``import`` dispatches on ``--type`` and then hands the REMAINING argv to that importer's own
|
|
10
|
+
``main``, so every flag, default and message stays defined in exactly one place — the importer.
|
|
11
|
+
Nothing is re-declared here, which is why ``backlot import --dry-run`` and
|
|
12
|
+
``python -m backlot.importer.byo --dry-run`` cannot drift apart.
|
|
13
|
+
|
|
14
|
+
``python -m backlot.importer.{byo,erb}`` still work unchanged; this is a shorter spelling of them,
|
|
15
|
+
not a replacement.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
|
|
24
|
+
# Imported lazily inside each command, not here: `serve` must not pay for the importers' module
|
|
25
|
+
# import (backlot.importer.erb alone is 2,400 lines), and `import` must not pull in uvicorn.
|
|
26
|
+
|
|
27
|
+
# --type value -> the module implementing it. `erb` is accepted beside the full bench name because
|
|
28
|
+
# that is what the module, the tests and every existing doc call it.
|
|
29
|
+
IMPORTER_TYPES = ("byo", "enterpriserag-bench", "erb")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version() -> str:
|
|
33
|
+
try:
|
|
34
|
+
return version("backlot")
|
|
35
|
+
except PackageNotFoundError: # a source tree that was never installed
|
|
36
|
+
return "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _type_arg(ap: argparse.ArgumentParser) -> None:
|
|
40
|
+
"""Declare ``--type`` on ``ap``. Called for the dispatch parser and again for the parser that
|
|
41
|
+
renders ``--help``, so the flag is defined once and both spellings cannot disagree."""
|
|
42
|
+
ap.add_argument(
|
|
43
|
+
"--type",
|
|
44
|
+
"-t",
|
|
45
|
+
dest="corpus_type",
|
|
46
|
+
default="byo",
|
|
47
|
+
choices=IMPORTER_TYPES,
|
|
48
|
+
metavar="{byo,enterpriserag-bench}",
|
|
49
|
+
help="what kind of corpus to import: `byo` (default) reads a BYO-JSONL corpus, a "
|
|
50
|
+
"`.jsonl.gz`, or a sharded artifact directory; `enterpriserag-bench` (alias `erb`) "
|
|
51
|
+
"downloads and imports EnterpriseRAG-Bench. The remaining options are that "
|
|
52
|
+
"importer's own — see `backlot import --type <t> --help`",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _serve(argv: list[str]) -> int:
|
|
57
|
+
"""Run the ASGI app under uvicorn.
|
|
58
|
+
|
|
59
|
+
Every default here is uvicorn's own (127.0.0.1:8000, proxy headers on), so this is a shorter
|
|
60
|
+
spelling of `python -m uvicorn backlot.main:app` and not a second set of behaviour to keep in
|
|
61
|
+
step with it. The app is passed as an import STRING because that is what `--reload` requires.
|
|
62
|
+
"""
|
|
63
|
+
ap = argparse.ArgumentParser(
|
|
64
|
+
prog="backlot serve",
|
|
65
|
+
description="Serve the mock APIs over the corpus in the data dir (BACKLOT_DATA_DIR). "
|
|
66
|
+
"The corpus has to exist — build one with `backlot import` first.",
|
|
67
|
+
)
|
|
68
|
+
ap.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
|
|
69
|
+
ap.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
|
|
70
|
+
ap.add_argument("--reload", action="store_true", help="restart on source changes (development)")
|
|
71
|
+
ap.add_argument(
|
|
72
|
+
"--log-level",
|
|
73
|
+
default=None,
|
|
74
|
+
choices=("critical", "error", "warning", "info", "debug", "trace"),
|
|
75
|
+
help="uvicorn log level (default: info)",
|
|
76
|
+
)
|
|
77
|
+
# Behind a TLS-terminating proxy/ALB these two make the app honour X-Forwarded-Proto/Host and
|
|
78
|
+
# emit https self-URLs, which clients that follow returned URLs (PyGithub) need. On by default
|
|
79
|
+
# in uvicorn, so the flag that carries weight is the negative one.
|
|
80
|
+
ap.add_argument(
|
|
81
|
+
"--no-proxy-headers",
|
|
82
|
+
dest="proxy_headers",
|
|
83
|
+
action="store_false",
|
|
84
|
+
help="ignore X-Forwarded-* headers (uvicorn honours them by default)",
|
|
85
|
+
)
|
|
86
|
+
ap.add_argument(
|
|
87
|
+
"--forwarded-allow-ips",
|
|
88
|
+
default=None,
|
|
89
|
+
metavar="IPS",
|
|
90
|
+
help="comma-separated proxy IPs to trust X-Forwarded-* from, or * for any "
|
|
91
|
+
"(default: 127.0.0.1)",
|
|
92
|
+
)
|
|
93
|
+
args = ap.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
import uvicorn
|
|
96
|
+
|
|
97
|
+
uvicorn.run(
|
|
98
|
+
"backlot.main:app",
|
|
99
|
+
host=args.host,
|
|
100
|
+
port=args.port,
|
|
101
|
+
reload=args.reload,
|
|
102
|
+
log_level=args.log_level,
|
|
103
|
+
proxy_headers=args.proxy_headers,
|
|
104
|
+
forwarded_allow_ips=args.forwarded_allow_ips,
|
|
105
|
+
)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _import(argv: list[str]) -> int:
|
|
110
|
+
"""Dispatch to one importer's ``main`` with the rest of the argv untouched."""
|
|
111
|
+
# add_help=False and parse_known_args: everything that is not --type belongs to the importer,
|
|
112
|
+
# including -h, which is answered below against the CHOSEN importer's parser so one help
|
|
113
|
+
# screen shows both --type and that importer's own flags.
|
|
114
|
+
pre = argparse.ArgumentParser(prog="backlot import", add_help=False)
|
|
115
|
+
_type_arg(pre)
|
|
116
|
+
args, rest = pre.parse_known_args(argv)
|
|
117
|
+
|
|
118
|
+
if args.corpus_type == "byo":
|
|
119
|
+
from backlot.importer import byo as importer
|
|
120
|
+
else:
|
|
121
|
+
from backlot.importer import erb as importer
|
|
122
|
+
|
|
123
|
+
if any(a in ("-h", "--help") for a in rest):
|
|
124
|
+
ap = importer.build_parser(prog="backlot import")
|
|
125
|
+
_type_arg(ap)
|
|
126
|
+
ap.print_help()
|
|
127
|
+
return 0
|
|
128
|
+
return importer.main(rest, prog="backlot import")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
COMMANDS = {"serve": _serve, "import": _import}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _top_parser() -> argparse.ArgumentParser:
|
|
135
|
+
"""The parser for ``backlot`` itself — help, --version, and the command list.
|
|
136
|
+
|
|
137
|
+
The subcommands are declared for the help listing only; `main` dispatches on argv before this
|
|
138
|
+
parser ever runs, so a command's own flags (`backlot serve --reload`) are never parsed here.
|
|
139
|
+
"""
|
|
140
|
+
ap = argparse.ArgumentParser(
|
|
141
|
+
prog="backlot",
|
|
142
|
+
description="Enterprise SaaS read APIs (Slack, Gmail, Drive, GitHub, Jira, Confluence, "
|
|
143
|
+
"Notion, S3, HubSpot, Linear, Fireflies) over your own corpus, with per-document ACLs.",
|
|
144
|
+
epilog="Run `backlot <command> --help` for a command's own options.",
|
|
145
|
+
)
|
|
146
|
+
ap.add_argument("--version", action="version", version=f"backlot {_version()}")
|
|
147
|
+
sub = ap.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
148
|
+
sub.add_parser("serve", help="run the mock API server")
|
|
149
|
+
sub.add_parser("import", help="build the data dir from a corpus (--type byo | erb)")
|
|
150
|
+
return ap
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
155
|
+
if argv and argv[0] in COMMANDS:
|
|
156
|
+
return COMMANDS[argv[0]](argv[1:])
|
|
157
|
+
# No command, an unknown one, or a global flag: let argparse answer it. `--help`/`--version`
|
|
158
|
+
# exit 0 from inside parse_args; anything else is a usage error, which exits 2.
|
|
159
|
+
_top_parser().parse_args(argv)
|
|
160
|
+
return 2 # unreachable: parse_args above always exits when no command was dispatched
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(main())
|
backlot/config.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Runtime configuration for the mock server.
|
|
2
|
+
|
|
3
|
+
All settings are overridable via environment variables (prefix ``BACKLOT_``) so the
|
|
4
|
+
server and the offline build scripts read the same values.
|
|
5
|
+
|
|
6
|
+
Corpus-specific knobs do NOT belong here — this is what every layer reads, and a setting only one
|
|
7
|
+
importer uses would put that importer's dataset in front of everyone. A downloading importer keeps
|
|
8
|
+
its own settings beside itself (see ``backlot.importer.erb.BenchSettings``), on the same env prefix.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pydantic import model_validator
|
|
17
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BaseSettings):
|
|
21
|
+
model_config = SettingsConfigDict(env_prefix="BACKLOT_", env_file=".env", extra="ignore")
|
|
22
|
+
|
|
23
|
+
@model_validator(mode="before")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _resolve_path_defaults(cls, values):
|
|
26
|
+
"""Fill the path defaults from the CURRENT working directory.
|
|
27
|
+
|
|
28
|
+
Not plain field defaults: those are evaluated at class-definition time, so the path would
|
|
29
|
+
be frozen to the cwd at import. Not `Path(__file__).parent.parent` either — installed from
|
|
30
|
+
a wheel that is `site-packages`, and a default of `site-packages/data` is never what
|
|
31
|
+
anyone means. `BACKLOT_DATA_DIR` and an explicit kwarg are already present here, so
|
|
32
|
+
`setdefault` leaves them alone.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(values, dict):
|
|
35
|
+
values.setdefault("data_dir", Path("data").resolve())
|
|
36
|
+
return values
|
|
37
|
+
|
|
38
|
+
# --- paths --- (default supplied by _resolve_path_defaults above)
|
|
39
|
+
data_dir: Path = Path("data")
|
|
40
|
+
|
|
41
|
+
# --- identity / org ---
|
|
42
|
+
# The org name/domain are derived at import time from the dominant email domain in whatever
|
|
43
|
+
# was loaded, via infer_org() below. These are only the last-resort fallback for data that
|
|
44
|
+
# carries no emails; BACKLOT_ORG_NAME / BACKLOT_ORG_DOMAIN override the derivation entirely.
|
|
45
|
+
org_name: str = "example"
|
|
46
|
+
org_domain: str = "example.com"
|
|
47
|
+
# No `atlassian_site` here on purpose: the host in a Jira/Confluence `self` URL comes from the
|
|
48
|
+
# REQUEST's own Host header, falling back to `<org_name>.atlassian.net`. Both rungs are already
|
|
49
|
+
# customizable — per call by the header every SDK sends, and globally by BACKLOT_ORG_NAME — so a
|
|
50
|
+
# third setting could only disagree with the caller about where the caller just reached us.
|
|
51
|
+
# See backlot.routers.atlassian._site.
|
|
52
|
+
|
|
53
|
+
# --- auth ---
|
|
54
|
+
# A caller presenting this token bypasses ACL filtering (full crawl / service account).
|
|
55
|
+
admin_token: str = "admin-service-token"
|
|
56
|
+
# If false, any well-formed token is accepted as admin (ACL still exposed, not enforced).
|
|
57
|
+
enforce_acl: bool = True
|
|
58
|
+
# Expose the /_mock/users directory (per-user tokens) so callers can test per-user ACL.
|
|
59
|
+
# It hands out tokens in the clear — fine for a local test mock; set false to disable.
|
|
60
|
+
expose_tokens: bool = True
|
|
61
|
+
|
|
62
|
+
# --- pagination defaults ---
|
|
63
|
+
default_page_size: int = 100
|
|
64
|
+
max_page_size: int = 1000
|
|
65
|
+
|
|
66
|
+
# --- sqlite read tuning (serving connection; see store.connect_ro) ---
|
|
67
|
+
# Sized for the corpus most people serve — their own, or the bundled one, which is under a
|
|
68
|
+
# megabyte. A multi-GB corpus wants all three raised, and a deployment that serves one says so
|
|
69
|
+
# explicitly (see the `environment:` block in docker-compose.yml) rather than every laptop
|
|
70
|
+
# inheriting numbers picked for the biggest DB anyone has run here.
|
|
71
|
+
#
|
|
72
|
+
# Memory-map the DB so reads come from the OS page cache instead of a syscall each — the main
|
|
73
|
+
# lever against the "slow first request after idle" cold-read hit. SQLite maps
|
|
74
|
+
# min(this, db size), so a small DB costs only its own size in address space; raise it to at
|
|
75
|
+
# or above the DB size to map a big one fully.
|
|
76
|
+
sqlite_mmap_mb: int = 256
|
|
77
|
+
# SQLite's own page cache, per connection. 64 MiB is a real improvement on SQLite's ~2 MiB
|
|
78
|
+
# default without reserving a quarter gigabyte on a machine serving a 700 KB corpus.
|
|
79
|
+
sqlite_cache_mb: int = 64
|
|
80
|
+
# Wait (ms) for a lock instead of erroring, so a read rides through an out-of-band writer's
|
|
81
|
+
# commit (an in-place `build_fts`) rather than 500ing. Long enough to cover a commit, short
|
|
82
|
+
# enough that a genuinely stuck writer surfaces instead of hanging the client.
|
|
83
|
+
sqlite_busy_ms: int = 5000
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def db_path(self) -> Path:
|
|
87
|
+
return self.data_dir / "mock.sqlite"
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def tokens_path(self) -> Path:
|
|
91
|
+
return self.data_dir / "tokens.yaml"
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def credentials_path(self) -> Path:
|
|
95
|
+
return self.data_dir / "credentials.yaml"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@lru_cache
|
|
99
|
+
def get_settings() -> Settings:
|
|
100
|
+
return Settings()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def infer_org(emails, settings: Settings) -> tuple[str, str]:
|
|
104
|
+
"""Derive ``(org_name, org_domain)`` from the dominant email domain in ``emails`` — so a
|
|
105
|
+
``@acme.com`` dataset serves as org ``acme`` rather than a hardcoded brand. An explicit
|
|
106
|
+
``BACKLOT_ORG_NAME`` / ``BACKLOT_ORG_DOMAIN`` env var wins; data with no emails keeps the
|
|
107
|
+
settings fallback. ``org_name`` is the domain's first label (``acme.com`` -> ``acme``)."""
|
|
108
|
+
import os
|
|
109
|
+
from collections import Counter
|
|
110
|
+
|
|
111
|
+
name_set = "BACKLOT_ORG_NAME" in os.environ
|
|
112
|
+
domain_set = "BACKLOT_ORG_DOMAIN" in os.environ
|
|
113
|
+
counts: Counter = Counter()
|
|
114
|
+
for e in emails:
|
|
115
|
+
if isinstance(e, str) and "@" in e:
|
|
116
|
+
counts[e.split("@", 1)[1].lower()] += 1
|
|
117
|
+
|
|
118
|
+
if domain_set:
|
|
119
|
+
domain = settings.org_domain
|
|
120
|
+
elif counts:
|
|
121
|
+
domain = counts.most_common(1)[0][0]
|
|
122
|
+
else:
|
|
123
|
+
domain = settings.org_domain
|
|
124
|
+
if name_set:
|
|
125
|
+
name = settings.org_name
|
|
126
|
+
elif domain_set or counts:
|
|
127
|
+
name = domain.split(".")[0]
|
|
128
|
+
else:
|
|
129
|
+
name = settings.org_name
|
|
130
|
+
return name, domain
|