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.
Files changed (61) hide show
  1. backlot/__init__.py +5 -0
  2. backlot/__main__.py +10 -0
  3. backlot/acl.py +89 -0
  4. backlot/auth.py +209 -0
  5. backlot/cli.py +164 -0
  6. backlot/config.py +130 -0
  7. backlot/data/hello.jsonl +136 -0
  8. backlot/errors/__init__.py +44 -0
  9. backlot/errors/atlassian.py +46 -0
  10. backlot/errors/google.py +230 -0
  11. backlot/graphql/__init__.py +8 -0
  12. backlot/graphql/engine.py +147 -0
  13. backlot/graphql/fireflies.graphql +240 -0
  14. backlot/graphql/fireflies_resolvers.py +357 -0
  15. backlot/graphql/linear.graphql +836 -0
  16. backlot/graphql/linear_filters.py +458 -0
  17. backlot/graphql/linear_resolvers.py +1215 -0
  18. backlot/importer/__init__.py +5 -0
  19. backlot/importer/byo.py +1440 -0
  20. backlot/importer/erb.py +2458 -0
  21. backlot/integrations/__init__.py +11 -0
  22. backlot/integrations/llamaindex.py +312 -0
  23. backlot/integrations/mirage.py +100 -0
  24. backlot/main.py +403 -0
  25. backlot/oauth.py +160 -0
  26. backlot/openapi.py +137 -0
  27. backlot/pagination.py +116 -0
  28. backlot/routers/__init__.py +25 -0
  29. backlot/routers/atlassian.py +1154 -0
  30. backlot/routers/fireflies.py +69 -0
  31. backlot/routers/github.py +901 -0
  32. backlot/routers/google.py +2144 -0
  33. backlot/routers/hubspot.py +622 -0
  34. backlot/routers/linear.py +79 -0
  35. backlot/routers/notion.py +541 -0
  36. backlot/routers/oauth.py +65 -0
  37. backlot/routers/s3.py +376 -0
  38. backlot/routers/slack.py +862 -0
  39. backlot/schemas/README.md +217 -0
  40. backlot/schemas/confluence.schema.json +188 -0
  41. backlot/schemas/fireflies.schema.json +304 -0
  42. backlot/schemas/github.schema.json +255 -0
  43. backlot/schemas/gmail.schema.json +222 -0
  44. backlot/schemas/google_drive.schema.json +138 -0
  45. backlot/schemas/hubspot.schema.json +140 -0
  46. backlot/schemas/jira.schema.json +231 -0
  47. backlot/schemas/linear.schema.json +298 -0
  48. backlot/schemas/notion.schema.json +173 -0
  49. backlot/schemas/s3.schema.json +118 -0
  50. backlot/schemas/slack.schema.json +161 -0
  51. backlot/sigv4.py +121 -0
  52. backlot/store.py +1871 -0
  53. backlot/synth.py +823 -0
  54. backlot/testing.py +276 -0
  55. backlot/validation.py +91 -0
  56. backlot-0.0.0.dist-info/METADATA +410 -0
  57. backlot-0.0.0.dist-info/RECORD +61 -0
  58. backlot-0.0.0.dist-info/WHEEL +5 -0
  59. backlot-0.0.0.dist-info/entry_points.txt +2 -0
  60. backlot-0.0.0.dist-info/licenses/LICENSE +21 -0
  61. backlot-0.0.0.dist-info/top_level.txt +1 -0
backlot/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Backlot — enterprise SaaS read APIs over your own corpus, with per-document ACLs."""
2
+
3
+ from backlot.testing import MockServer, mock_server, serve_or_connect, url_from_argv
4
+
5
+ __all__ = ["MockServer", "mock_server", "serve_or_connect", "url_from_argv"]
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