` for a team's."""
- return f"{scope}@{graph_id}" if scope else graph_id
-
-
-def resource_id(url: str | None) -> str | None:
- """The resource id in a Graph resource address, accepted only from Graph's own https host."""
- if not url or len(url) > 2000:
- return None
- try:
- parts = urlsplit(html.unescape(url))
- port = parts.port
- except ValueError:
- return None
- if parts.scheme != "https" or (parts.hostname or "").lower() != GRAPH_HOST or port not in (None, 443) or parts.username or parts.password:
- return None
- match = RESOURCE_PATH.search(parts.path)
- return match.group(1) if match and SAFE_ID.fullmatch(match.group(1)) else None
-
-
-def element_id(node: _Node) -> str | None:
- """The id a PATCH can target: the one OneNote generated (`includeIDs=true`)."""
- for name in ("id", "data-id"):
- value = node.attrs.get(name)
- if value and SAFE_ID.fullmatch(value):
- return value
- return None
-
-
-def text_only(node: _Node) -> bool:
- return all(isinstance(c, str) or (c.tag in TEXT_TAGS and text_only(c)) for c in node.children)
-
-
-class _Render:
- def __init__(self, scope: str | None, write: bool = False):
- self.scope = scope
- self.write = write
- self.resources: dict[str, dict] = {}
-
- def children(self, node: _Node) -> str:
- return "".join(self.node(c) for c in node.children)
-
- def node(self, node) -> str:
- if isinstance(node, str):
- return html.escape(node, quote=False)
- tag = node.tag
- if tag in DROP_CONTENT:
- return ""
- if tag == "img":
- return "" if self.write else self.image(node, positioned=False)
- if tag == "object":
- return "" if self.write else self.file(node, positioned=False)
- if tag not in TEXT_TAGS:
- return self.children(node)
- if tag == "br":
- return "
" if self.write else "
"
- attrs = []
- if tag == "a":
- href = safe_href(node.attrs.get("href"))
- if href:
- attrs.append(f'href="{html.escape(href, quote=True)}"')
- if not self.write:
- attrs.append('rel="noopener noreferrer"')
- if tag in ("td", "th"):
- for name in ("colspan", "rowspan"):
- value = node.attrs.get(name, "")
- if value.isdigit() and 1 <= int(value) <= 100:
- attrs.append(f'{name}="{int(value)}"')
- tag_value = node.attrs.get("data-tag", "").lower()
- if tag_value and TAG_VALUE.fullmatch(tag_value) and tag in ("p", "li", "span", "div", "h1", "h2", "h3", "h4", "h5", "h6"):
- attrs.append(f'data-tag="{tag_value}"')
- style = _style(node.attrs.get("style"), WRITE_STYLE_PROPS if self.write else STYLE_PROPS)
- if style:
- attrs.append(f'style="{html.escape(style, quote=True)}"')
- head = f"<{tag} {' '.join(attrs)}>" if attrs else f"<{tag}>"
- return f"{head}{self.children(node)}{tag}>"
-
- def _position(self, node: _Node, positioned: bool, width: float | None = None) -> str:
- if not positioned:
- return ""
- style = node.attrs.get("style")
- left, top = _length(style, "left") or 0.0, _length(style, "top") or 0.0
- parts = [f"position:absolute;left:{_num(left)}px;top:{_num(top)}px"]
- if width is not None:
- parts.append(f"width:{_num(width)}px")
- return f' style="{";".join(parts)}"'
-
- def image(self, node: _Node, positioned: bool) -> str:
- rid = resource_id(node.attrs.get("data-fullres-src")) or resource_id(node.attrs.get("src"))
- if rid is None:
- return ""
- sid = scoped(self.scope, rid)
- kind = (node.attrs.get("data-fullres-src-type") or node.attrs.get("data-src-type") or "").lower()
- self.resources.setdefault(sid, {"id": sid, "kind": "image", "name": None, "contentType": kind if CONTENT_TYPE.fullmatch(kind) else None})
- alt = (node.attrs.get("alt") or "")[:MAX_ALT]
- width, height = _attr_number(node.attrs.get("width")), _attr_number(node.attrs.get("height"))
- size = (f' width="{_num(width)}"' if width else "") + (f' height="{_num(height)}"' if height else "")
- return f'
'
-
- def file(self, node: _Node, positioned: bool) -> str:
- rid = resource_id(node.attrs.get("data"))
- if rid is None:
- return ""
- sid = scoped(self.scope, rid)
- name = re.sub(r"[\x00-\x1f\x7f]", "", node.attrs.get("data-attachment") or "")[:MAX_NAME] or "file"
- kind = (node.attrs.get("type") or "").lower()
- self.resources.setdefault(sid, {"id": sid, "kind": "file", "name": name, "contentType": kind if CONTENT_TYPE.fullmatch(kind) else None})
- return f'{html.escape(name)}'
-
-
-def split_parts(body: str) -> tuple[str, str | None]:
- """The HTML and the InkML of an answer. With `includeInkML` Graph answers multipart, the HTML first; without, the HTML alone."""
- first = body.split("\n", 1)[0].strip()
- if not first.startswith("--") or len(first) < 3:
- return body, None
- page = ink = None
- for part in body.split(first):
- head, sep, rest = part.partition("\r\n\r\n")
- if not sep:
- head, sep, rest = part.partition("\n\n")
- if not sep:
- continue
- head = head.lower()
- if "text/html" in head and page is None:
- page = rest.strip().removesuffix("--").strip()
- elif "inkml" in head and ink is None:
- ink = rest.strip().removesuffix("--").strip()
- return page if page is not None else body, ink
-
-
-def has_ink(page: str) -> bool:
- """OneNote only marks ink in the HTML; the strokes come with `includeInkML`."""
- return "InkNode is not supported" in page
-
-
-def render_page(page: str, ink: str | None = None, scope: str | None = None) -> dict:
- """A page as sanitized markup, its resources, its text boxes and the size of its canvas."""
- root = _parse(page)
- title_node = _find(root, "title")
- title = re.sub(r"\s+", " ", _text(title_node)).strip() if title_node else ""
- body = _find(root, "body") or root
- render = _Render(scope)
- parts, outlines = [], []
- width = height = 0.0
- for child in body.children:
- if isinstance(child, str):
- parts.append(render.node(child))
- continue
- style = child.attrs.get("style")
- left, top = _length(style, "left"), _length(style, "top")
- positioned = left is not None or top is not None
- if child.tag == "div":
- oid = element_id(child)
- box_width = _length(style, "width")
- inner = render.children(child)
- editable = oid is not None and text_only(child)
- attrs = (f' data-outline="{html.escape(oid, quote=True)}" data-editable="{"true" if editable else "false"}"' if oid else "") + render._position(child, positioned, box_width)
- parts.append(f"{inner}
")
- if oid:
- outlines.append({"id": oid, "editable": editable, "html": inner})
- if positioned:
- width = max(width, (left or 0) + (box_width or BOX_WIDTH))
- height = max(height, (top or 0) + BOX_HEIGHT)
- elif child.tag == "img":
- parts.append(render.image(child, positioned))
- if positioned:
- width = max(width, (left or 0) + (_attr_number(child.attrs.get("width")) or 0))
- height = max(height, (top or 0) + (_attr_number(child.attrs.get("height")) or 0))
- elif child.tag == "object":
- parts.append(render.file(child, positioned))
- if positioned:
- width, height = max(width, (left or 0) + 240), max(height, (top or 0) + 40)
- else:
- parts.append(render.node(child))
- strokes = ink_svg(ink) if ink else None
- if strokes:
- svg, ink_w, ink_h = strokes
- parts.append(svg)
- width, height = max(width, ink_w), max(height, ink_h)
- return {
- "title": title,
- "html": "".join(parts),
- "width": int(width + 24) if width else 0,
- "height": int(height + 24) if height else 0,
- "resources": list(render.resources.values()),
- "outlines": outlines,
- "hasInk": has_ink(page),
- }
-
-
-def outline_style(page: str, outline: str) -> str | None:
- """The place of one editable text box of the current page (`position:absolute;left;top;width`), or None when it is not one."""
- body = _find(_parse(page), "body")
- for child in body.children if body else []:
- if isinstance(child, _Node) and child.tag == "div" and element_id(child) == outline:
- if not text_only(child):
- return None
- style = child.attrs.get("style")
- left, top, width = _length(style, "left"), _length(style, "top"), _length(style, "width")
- if left is None and top is None:
- return ""
- out = f"position:absolute;left:{_num(left or 0)}px;top:{_num(top or 0)}px"
- return out + (f";width:{_num(width)}px" if width else "")
- return None
-
-
-def sanitize_for_write(markup: str) -> str:
- """Rich text from the editor, cut down to formatting OneNote keeps, as XHTML. No pictures, files or positions."""
- return _Render(None, write=True).children(_parse(markup or ""))
-
-
-def new_page(title: str, body: str | None) -> str:
- """The XHTML document a new page is created from."""
- content = f"{sanitize_for_write(body)}
" if body and body.strip() else ""
- return f"{html.escape(title, quote=False)}{content}"
-
-
-# --- ink -------------------------------------------------------------------------------------
-
-
-class _Ink(HTMLParser):
- """InkML read as tags: brushes with their properties, and traces with the brush they use. Parsed as markup, never as XML (no entities, no DTD)."""
-
- def __init__(self):
- super().__init__(convert_charrefs=True)
- self.brushes: dict[str, dict] = {}
- self.brush: dict | None = None
- self.traces: list[tuple[str, list[str]]] = []
- self.trace: tuple[str, list[str]] | None = None
-
- def handle_starttag(self, tag, attrs):
- attrs = {k.lower(): v or "" for k, v in attrs}
- if tag == "inkml:brush":
- self.brush = {"color": "#333333", "width": 50.0, "opacity": 1.0, "highlight": False}
- self.brushes[attrs.get("xml:id", "")] = self.brush
- elif tag == "inkml:brushproperty" and self.brush is not None:
- name, value = attrs.get("name", "").lower(), attrs.get("value", "")
- if name == "color" and COLOR.fullmatch(value):
- self.brush["color"] = value
- elif name == "width" and NUMBER.fullmatch(value):
- self.brush["width"] = float(value)
- elif name == "transparency" and NUMBER.fullmatch(value):
- self.brush["opacity"] = min(1.0, max(0.05, 1.0 - float(value) / 255.0))
- elif name == "rasterop":
- self.brush["highlight"] = value.lower() == "maskpen"
- elif tag == "inkml:trace" and len(self.traces) < MAX_TRACES:
- self.trace = (attrs.get("brushref", "").lstrip("#"), [])
- self.traces.append(self.trace)
-
- handle_startendtag = handle_starttag
-
- def handle_endtag(self, tag):
- if tag == "inkml:brush":
- self.brush = None
- elif tag == "inkml:trace":
- self.trace = None
-
- def handle_data(self, data):
- if self.trace is not None:
- self.trace[1].append(data)
-
-
-def _points(trace: str, budget: int) -> list[tuple[float, float]]:
- """The points of one trace (`x y f, x y f`), where a value may carry `'` (first difference) or `"` (second difference)."""
- out, value, speed = [], [0.0, 0.0], [0.0, 0.0]
- for sample in trace.split(","):
- if len(out) >= budget:
- break
- for channel, token in enumerate(sample.split()[:2]):
- kind = 1 if token.startswith("'") else 2 if token.startswith('"') else 0
- token = token.lstrip("'\"")
- try:
- number = float(token)
- except ValueError:
- continue
- if kind == 0:
- value[channel], speed[channel] = number, 0.0
- elif kind == 1:
- speed[channel] = number
- value[channel] += number
- else:
- speed[channel] += number
- value[channel] += speed[channel]
- out.append((value[0] * PX_PER_HIMETRIC, value[1] * PX_PER_HIMETRIC))
- return out
-
-
-def ink_svg(xml: str) -> tuple[str, float, float] | None:
- """The strokes as one SVG of paths in page pixels, with its width and height; None when there are none."""
- reader = _Ink()
- try:
- reader.feed(xml)
- reader.close()
- except Exception:
- return None
- paths: dict[str, list[str]] = {}
- right = bottom = 0.0
- budget = MAX_POINTS
- for brush_id, chunks in reader.traces:
- if brush_id not in reader.brushes or budget <= 0:
- continue
- points = _points("".join(chunks), budget)
- budget -= len(points)
- d, last = [], None
- for x, y in points:
- if last and abs(x - last[0]) < 0.4 and abs(y - last[1]) < 0.4:
- continue
- d.append(f"{'L' if d else 'M'}{x:.1f} {y:.1f}" + ("" if d else "l0.1 0"))
- last = (x, y)
- right, bottom = max(right, x), max(bottom, y)
- if d:
- paths.setdefault(brush_id, []).append("".join(d))
- if not paths:
- return None
- width, height = round(right + 8), round(bottom + 8)
- out = [f'")
- return "".join(out), float(width), float(height)
-
-
-def plain_text(markup: str) -> str:
- """Visible text of sanitized markup (search snippets, tests)."""
- return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", markup))).strip()
diff --git a/src/outnote_client.py b/src/outnote_client.py
deleted file mode 100644
index 8bc480f..0000000
--- a/src/outnote_client.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""Thin, stateless wrapper over OutNote / Entrance.
-
-The device-code sign-in is two documented OAuth calls (start + poll). Data reads
-build an OutNote client from the caller's FOCI refresh token, which the broker may
-rotate; callers get the rotated token back and own its persistence. Nothing here is
-stored server-side.
-"""
-
-import asyncio
-import hashlib
-import logging
-import threading
-import time
-from collections import OrderedDict
-from collections.abc import Callable
-from http.cookiejar import DefaultCookiePolicy
-
-import entrance
-import httpx
-from entrance import Broker
-from outnote import OutNote
-
-from src import config
-from src.account_cache import AccountCache
-
-log = logging.getLogger("schuly.microsoft")
-
-
-class MicrosoftError(Exception):
- def __init__(self, error: str, description: str = ""):
- self.error = error
- super().__init__(f"{error}: {description}" if description else error)
-
-
-async def start_device_login(client: httpx.AsyncClient | None = None) -> dict:
- """Begin the device-code flow: returns user_code, verification_uri and the device_code to poll with."""
- data = await _post(config.DEVICECODE_URL, {"client_id": config.DEVICE_CLIENT_ID, "scope": config.DEVICE_SCOPE}, client)
- if not data.get("device_code"):
- raise MicrosoftError(data.get("error", "no_device_code"), data.get("error_description", "the client may not support the device flow"))
- return data
-
-
-async def poll_device_login(device_code: str, client: httpx.AsyncClient | None = None) -> dict:
- """Poll once. Returns the raw token response: access_token + refresh_token, or error=authorization_pending."""
- return await _post(config.TOKEN_URL, {"grant_type": config.DEVICE_GRANT, "client_id": config.DEVICE_CLIENT_ID, "device_code": device_code}, client)
-
-
-BROKER_CACHE_SIZE = 1000
-
-
-class _SharedBroker(Broker):
- """A Broker shared by every request that carries the same refresh token.
-
- Entrance's Broker already keeps one access token per (client id, scope) and only redeems the
- refresh token (which makes Microsoft rotate it) when that entry is missing or within 60s of
- expiry. The Broker used to be rebuilt per request, so every call redeemed. Sharing it means
- repeat calls reuse the minted access tokens and the refresh token stays unchanged. The lock makes
- concurrent misses redeem once instead of racing; `on_rotate` re-registers the broker under the
- new token's hash so the caller's next request (which sends the rotated token) still hits.
-
- It also owns the account's one OutNote client, as OutNote recommends for long-lived use: the ids that client keeps in
- memory (user, tenant) belong to this account and live and die with this broker, so accounts never share one."""
-
- def __init__(self, refresh_token: str, on_rotate: Callable[[str, "_SharedBroker"], None]):
- super().__init__(refresh_token)
- self._lock = threading.Lock()
- self._on_rotate = on_rotate
- # Per-account values that rarely change (folder roles, chat pictures); shared with the broker's lifetime.
- self.cache = AccountCache()
- self.note = OutNote(self)
-
- def __call__(self, client_id, scope="https://graph.microsoft.com/.default"):
- with self._lock:
- before = self.refresh_token
- token = super().__call__(client_id, scope)
- if self.refresh_token != before:
- self._on_rotate(self.refresh_token, self)
- return token
-
-
-class _BrokerCache:
- """In-memory LRU of brokers keyed by SHA-256 of the refresh token (the raw token is never a key)."""
-
- def __init__(self, max_size: int = BROKER_CACHE_SIZE):
- self._max = max_size
- self._items: OrderedDict[str, _SharedBroker] = OrderedDict()
- self._lock = threading.Lock()
-
- @staticmethod
- def _key(refresh_token: str) -> str:
- return hashlib.sha256(refresh_token.encode()).hexdigest()
-
- def get(self, refresh_token: str) -> _SharedBroker:
- key = self._key(refresh_token)
- with self._lock:
- broker = self._items.get(key)
- if broker is None:
- broker = _SharedBroker(refresh_token, self._register)
- self._store(key, broker)
- else:
- self._items.move_to_end(key)
- return broker
-
- def _register(self, refresh_token: str, broker: _SharedBroker):
- with self._lock:
- self._store(self._key(refresh_token), broker)
-
- def _store(self, key: str, broker: _SharedBroker):
- self._items[key] = broker
- self._items.move_to_end(key)
- while len(self._items) > self._max:
- self._items.popitem(last=False)
-
- def clear(self):
- with self._lock:
- self._items.clear()
-
-
-_brokers = _BrokerCache()
-
-
-def build_client(refresh_token: str) -> tuple[OutNote, Broker]:
- """An OutNote client over a shared per-token broker; read `broker.refresh_token` after use for the (possibly rotated) value."""
- broker = _brokers.get(refresh_token)
- return broker.note, broker
-
-
-def strip_native(value):
- """Removes OutNote's `_native` copies (the raw Teams answer, with raw ids) from a result, in place, at any depth."""
- if isinstance(value, dict):
- value.pop("_native", None)
- for item in value.values():
- strip_native(item)
- elif isinstance(value, (list, tuple)):
- for item in value:
- strip_native(item)
- return value
-
-
-_async_client: httpx.AsyncClient | None = None
-_download_client: httpx.Client | None = None
-_download_lock = threading.Lock()
-
-
-def download_client() -> httpx.Client:
- """One keep-alive client for bodies that must stay capped or be streamed: `entrance.http_client()` reads every body into memory first and has no stream.
-
- It is shared by all accounts, so it keeps no cookies (SharePoint sets them on downloads) and never holds a token; a bearer goes per request, with redirects off."""
- global _download_client
- if _download_client is None:
- with _download_lock:
- if _download_client is None:
- client = httpx.Client(
- timeout=httpx.Timeout(30.0, connect=5.0),
- limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=60),
- transport=httpx.HTTPTransport(retries=3),
- follow_redirects=False,
- )
- client.cookies.jar.set_policy(DefaultCookiePolicy(allowed_domains=[]))
- _download_client = client
- return _download_client
-
-
-def shared_async_client() -> httpx.AsyncClient:
- """One keep-alive client for the sign-in calls (connect bounded to 5 s, connect retries)."""
- global _async_client
- if _async_client is None:
- _async_client = httpx.AsyncClient(
- timeout=httpx.Timeout(30.0, connect=5.0),
- limits=httpx.Limits(max_connections=50, max_keepalive_connections=10, keepalive_expiry=60),
- transport=httpx.AsyncHTTPTransport(retries=3),
- )
- return _async_client
-
-
-async def close_shared_client():
- global _async_client, _download_client
- client, _async_client = _async_client, None
- if client is not None:
- await client.aclose()
- with _download_lock:
- download, _download_client = _download_client, None
- if download is not None:
- download.close()
-
-
-async def warm_up():
- """Open connections to Microsoft's login and Graph hosts so the first real call skips DNS, TCP and TLS."""
- await asyncio.to_thread(entrance.warm_up, [config.TOKEN_URL, "https://graph.microsoft.com/v1.0/"])
-
-
-async def _post(url: str, data: dict, client: httpx.AsyncClient | None) -> dict:
- client = client or shared_async_client()
- started = time.perf_counter()
- try:
- response = await client.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
- return response.json()
- finally:
- log.debug("POST %s took %.0f ms", url, (time.perf_counter() - started) * 1000)
-
-
-def stream_download(url: str, limit: int, follow_redirects: bool = True):
- """Yields a pre-authorized download URL's bytes in chunks (no bearer token is sent), stopping past `limit` bytes."""
- with download_client().stream("GET", url, timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=follow_redirects) as r:
- r.raise_for_status()
- sent = 0
- for chunk in r.iter_bytes(64 * 1024):
- sent += len(chunk)
- if sent > limit:
- return
- yield chunk
-
-
-# Read helpers, kept tiny so routers stay declarative. Each returns plain JSON-able data.
-def read(outnote: OutNote, action: Callable[[OutNote], object]) -> object:
- return action(outnote)
diff --git a/src/password_signin.py b/src/password_signin.py
deleted file mode 100644
index 84f6e53..0000000
--- a/src/password_signin.py
+++ /dev/null
@@ -1,392 +0,0 @@
-"""Microsoft password sign-in with an interactive second factor, as short-lived in-memory jobs.
-
-The ONE place in this service that receives a Microsoft password. It is handed to Entrance once,
-on a worker thread, wrapped in a one-shot `entrance.Secret`; Entrance posts it to Microsoft and
-drops it. It is never stored, cached, logged or returned, and never lives on a job: a job only holds
-progress (state, methods, the push number, the result). Jobs are in memory only, so the service must
-run as ONE process (see asgi.py) - a second worker would not know a job started on the first.
-
-Entrance runs the sign-in on the worker thread and calls back into the job (an `MfaPrompt`) when
-Microsoft asks for MFA; the job blocks there on a condition until the HTTP side supplies the choice
-or the code.
-"""
-
-import hashlib
-import logging
-import os
-import secrets
-import threading
-import time
-from collections import deque
-
-import entrance
-from entrance import MfaCancelled, MfaPrompt, MfaSwitch, Secret
-
-log = logging.getLogger("schuly.microsoft.password")
-
-JOB_TTL = int(os.getenv("PASSWORD_SIGNIN_TTL", "600")) # hard cap per job, seconds (<= 10 min)
-JOB_TTL = min(JOB_TTL, 600)
-DONE_TTL = 120 # a finished job stays readable this long (a lost response can be re-read)
-MAX_JOBS = int(os.getenv("PASSWORD_SIGNIN_MAX_JOBS", "100"))
-CLIENT_LIMIT = int(os.getenv("PASSWORD_SIGNIN_CLIENT_LIMIT", "30")) # starts per Schuly user per minute
-ACCOUNT_LIMIT = int(os.getenv("PASSWORD_SIGNIN_ACCOUNT_LIMIT", "6")) # FAILED password attempts per (Schuly user, account) per 10 minutes
-OTP_JOB_BUDGET = 5 # authenticator codes tried per job, however often the method is re-chosen
-OTP_ACCOUNT_BUDGET = 10 # codes per (Schuly user, account) per hour, across jobs
-PUSH_TIMEOUT = 120
-PUSH_INTERVAL = 1.5
-OTP_ATTEMPTS = entrance.OTP_ATTEMPTS
-
-# States. Transient ones mean "the worker is busy"; everything else is a place the caller can act on.
-WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING = "working", "starting_push", "starting_otp", "verifying"
-MFA_REQUIRED, PUSH_PENDING, OTP_REQUIRED = "mfa_required", "push_pending", "otp_required"
-DENIED, TIMED_OUT, COMPLETE, ERROR = "denied", "timed_out", "complete", "error"
-TRANSIENT = {WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING}
-CHOOSABLE = {MFA_REQUIRED, DENIED, TIMED_OUT} # states where /method picks a (new) method
-
-MESSAGES = {
- "invalid_credentials": "Wrong email or password.",
- "unsupported": "This Microsoft account can't be signed in with a password here.",
- "blocked": "Microsoft blocked this sign-in because of a security policy.",
- "failed": "The Microsoft sign-in failed.",
- "otp_exhausted": "Too many wrong codes. Start the sign-in again later.",
-}
-
-
-class RateLimited(Exception):
- """Too many sign-in attempts (per client or per account) or too many jobs in progress."""
-
-
-class RateLimiter:
- """Sliding-window counter keyed by an opaque string (never the raw account name)."""
-
- def __init__(self):
- self._hits: dict[str, deque] = {}
- self._lock = threading.Lock()
-
- def allow(self, key: str, limit: int, window: float) -> bool:
- now = time.monotonic()
- with self._lock:
- hits = self._hits.setdefault(key, deque())
- while hits and now - hits[0] > window:
- hits.popleft()
- if len(hits) >= limit:
- return False
- hits.append(now)
- return True
-
- def add(self, key: str):
- with self._lock:
- self._hits.setdefault(key, deque()).append(time.monotonic())
-
- def count(self, key: str, window: float) -> int:
- now = time.monotonic()
- with self._lock:
- hits = self._hits.get(key)
- if not hits:
- return 0
- while hits and now - hits[0] > window:
- hits.popleft()
- return len(hits)
-
- def sweep(self, window: float = 600):
- now = time.monotonic()
- with self._lock:
- for key in [k for k, h in self._hits.items() if not h or now - h[-1] > window]:
- del self._hits[key]
-
-
-def failure_for(ex: BaseException) -> tuple[str, str]:
- """Map a sign-in exception to a stable (code, message). Only the exception TYPE decides; the
- exception text is never copied into a response."""
- if isinstance(ex, entrance.InvalidCredentials):
- code = "invalid_credentials"
- elif isinstance(ex, entrance.OtpRejected):
- code = "otp_exhausted"
- elif isinstance(ex, entrance.Unsupported):
- code = "unsupported"
- elif isinstance(ex, entrance.Blocked):
- code = "blocked"
- else:
- code = "failed"
- return code, MESSAGES[code]
-
-
-class SignInJob(MfaPrompt):
- """Progress of one sign-in, and the `MfaPrompt` Entrance calls back into. Holds no password."""
-
- def __init__(self, job_id: str, user: str, account: str, failures: "RateLimiter", otp_hits: "RateLimiter"):
- self.id = job_id
- self.user = user # hash of the Schuly user id that started the job; only that user may touch it
- self.acct = account # hash of (Schuly user, Microsoft account name)
- self.replaces: str | None = None # this user's earlier job for the same account, replaced once the password passed
- self._failures = failures
- self._otp_hits = otp_hits
- self.otp_used = 0
- self._budget_out = False
- self.expires = time.monotonic() + JOB_TTL
- self.done_at: float | None = None
- self.cond = threading.Condition()
- self.state = WORKING
- self.version = 0
- self.methods: list[str] = []
- self.notice: str | None = None
- self.number: str | None = None
- self.push_deadline = 0.0
- self.attempts_left = 0
- self.otp_invalid = False
- self.requested: str | None = None
- self.code: str | None = None # a one-time authenticator code, taken (cleared) by the worker
- self.refresh_token: str | None = None
- self.account = None
- self.error_code: str | None = None
- self.error_message: str | None = None
- self.cancel = False
- self._retry_notice: str | None = None
-
- # -- state ---------------------------------------------------------------------------------
- def _set(self, state: str, **fields):
- """Move to a state (caller holds `cond`) and wake every waiter."""
- self.state = state
- self.version += 1
- for key, value in fields.items():
- setattr(self, key, value)
- self.cond.notify_all()
-
- def _dead(self) -> bool:
- return self.cancel or time.monotonic() > self.expires
-
- def _wait(self, predicate):
- """Block (holding `cond`) until `predicate()` or the job is cancelled / expired."""
- while not predicate():
- if self._dead():
- raise MfaCancelled("Sign-in cancelled")
- self.cond.wait(timeout=max(0.05, min(1.0, self.expires - time.monotonic())))
-
- def stop(self):
- with self.cond:
- self.cancel = True
- self.cond.notify_all()
-
- def wait_settled(self, after_version: int, timeout: float) -> bool:
- """Wait (from the HTTP side) until a non-transient state newer than `after_version`."""
- deadline = time.monotonic() + timeout
- with self.cond:
- while not (self.version > after_version and self.state not in TRANSIENT):
- left = deadline - time.monotonic()
- if left <= 0:
- return False
- self.cond.wait(timeout=left)
- return True
-
- def snapshot(self) -> dict:
- """Everything a response needs, taken under the lock. Never contains the password."""
- with self.cond:
- snap = {"state": self.state, "version": self.version, "methods": list(self.methods), "notice": self.notice}
- if self.state == PUSH_PENDING:
- snap["number"] = self.number
- snap["expires_in"] = max(0, int(self.push_deadline - time.monotonic()))
- if self.state in (OTP_REQUIRED, MFA_REQUIRED) or self.otp_invalid:
- snap["attempts_left"] = self.attempts_left
- snap["otp_invalid"] = self.otp_invalid
- if self.state == COMPLETE:
- snap["refresh_token"] = self.refresh_token
- if self.state == ERROR:
- snap["code"], snap["message"] = self.error_code, self.error_message
- return snap
-
- # -- actions from the HTTP side --------------------------------------------------------------
- def request_method(self, method: str) -> str:
- """Ask for a method. Returns "ok", "same" (already on it), "unavailable" (not offered) or "invalid_state"."""
- with self.cond:
- if method not in self.methods:
- return "unavailable"
- if self.state in CHOOSABLE or (self.state == PUSH_PENDING and method == "otp") or (self.state == OTP_REQUIRED and method == "push"):
- self.requested = method
- self.cond.notify_all()
- return "ok"
- if (self.state == PUSH_PENDING and method == "push") or (self.state == OTP_REQUIRED and method == "otp"):
- return "same" # already on that method
- return "invalid_state"
-
- def submit_code(self, code: str) -> bool:
- with self.cond:
- if self.state != OTP_REQUIRED or self.code is not None:
- return False
- self.code = code
- self.cond.notify_all()
- return True
-
- # -- MfaPrompt (called by Entrance on the worker thread) -------------------------------------
- def choose(self, methods):
- with self.cond:
- self.methods = list(methods)
- if self.requested not in methods:
- notice, self._retry_notice = self._retry_notice, None
- state = notice if notice in (DENIED, TIMED_OUT) else MFA_REQUIRED
- self._set(state, notice=notice, number=None)
- self._wait(lambda: self.requested in self.methods)
- method, self.requested = self.requested, None
- self._set(STARTING_PUSH if method == "push" else STARTING_OTP, notice=None, otp_invalid=False)
- return method
-
- def on_push(self, event):
- if event.state == entrance.NUMBER_MATCHING:
- with self.cond:
- self._set(PUSH_PENDING, number=event.number, push_deadline=time.monotonic() + (event.expires_in or PUSH_TIMEOUT))
- elif event.state == entrance.APPROVED:
- with self.cond:
- self._set(VERIFYING)
-
- def should_switch(self):
- return self.requested is not None
-
- def cancelled(self):
- return self._dead()
-
- def otp(self, attempt):
- with self.cond:
- if self.otp_used >= OTP_JOB_BUDGET or self._otp_hits.count(self.acct, 3600) >= OTP_ACCOUNT_BUDGET:
- self._budget_out = True
- raise entrance.OtpRejected("authenticator code budget spent", attempts=self.otp_used)
- self.attempts_left = min(OTP_ATTEMPTS - attempt + 1, OTP_JOB_BUDGET - self.otp_used)
- self._set(OTP_REQUIRED, otp_invalid=attempt > 1)
- self._wait(lambda: self.code is not None or self.requested is not None)
- if self.code is None:
- raise MfaSwitch()
- code, self.code = self.code, None
- self.otp_used += 1
- self._otp_hits.add(self.acct)
- self._set(VERIFYING)
- return code
-
- def retry(self, error):
- if isinstance(error, entrance.PushError) and error.state in (DENIED, TIMED_OUT):
- self._retry_notice = error.state
- return True
- if isinstance(error, entrance.OtpRejected):
- if self._budget_out or self.otp_used >= OTP_JOB_BUDGET:
- return False # terminal: a fresh sign-in is required
- with self.cond:
- self.attempts_left = 0
- self.otp_invalid = True
- self._retry_notice = "otp_exhausted"
- return True
- return False
-
- # -- the worker --------------------------------------------------------------------------------
- def run(self, username: str, secret: Secret):
- try:
- broker = entrance.foci_login(username, secret, cookies=None, frt_store=None, mfa=self, push_timeout=PUSH_TIMEOUT, push_interval=PUSH_INTERVAL)
- with self.cond:
- self.done_at = time.monotonic()
- self._set(COMPLETE, refresh_token=broker.refresh_token)
- except MfaCancelled:
- log.info("password sign-in %s cancelled", self.id)
- except BaseException as ex: # noqa: BLE001 - the worker must always end in a terminal state
- code, message = failure_for(ex)
- if code == "invalid_credentials":
- self._failures.add(self.acct) # only failed password attempts count against the limiter
- log.warning("password sign-in %s ended: %s (%s)", self.id, code, type(ex).__name__)
- with self.cond:
- self.done_at = time.monotonic()
- self._set(ERROR, error_code=code, error_message=message)
- finally:
- secret.take() # no-op once submitted; guarantees nothing outlives the call
-
-
-class JobStore:
- """In-memory jobs plus the rate limits. One instance per process."""
-
- def __init__(self):
- self._jobs: dict[str, SignInJob] = {}
- self._by_owner: dict[str, str] = {}
- self._lock = threading.Lock()
- self._starts = RateLimiter()
- self._failures = RateLimiter()
- self._otp_hits = RateLimiter()
-
- @staticmethod
- def _hash(*parts: str) -> str:
- return hashlib.sha256("\x00".join(p.strip().lower() for p in parts).encode()).hexdigest()
-
- def start(self, user_id: str, username: str, secret: Secret) -> SignInJob:
- user = self._hash(user_id)
- account = self._hash(user_id, username)
- if not self._starts.allow(user, CLIENT_LIMIT, 60):
- raise RateLimited("Too many sign-in attempts.")
- if self._failures.count(account, 600) >= ACCOUNT_LIMIT:
- raise RateLimited("Too many failed attempts for this account. Try again later.")
- job = SignInJob(secrets.token_urlsafe(24), user, account, self._failures, self._otp_hits)
- with self._lock:
- self._prune()
- if len(self._jobs) >= MAX_JOBS:
- raise RateLimited("Too many sign-ins in progress.")
- job.replaces = self._by_owner.get(account)
- self._jobs[job.id] = job
- threading.Thread(target=job.run, args=(username.strip(), secret), name=f"pwd-signin-{job.id[:6]}", daemon=True).start()
- return job
-
- def promote(self, job: SignInJob):
- """The password passed: this job becomes the user's current one for the account and replaces their earlier one."""
- with self._lock:
- if job.id not in self._jobs:
- return
- previous = self._jobs.get(job.replaces or "")
- if previous is not None and previous.user == job.user:
- self._drop(previous)
- self._by_owner[job.acct] = job.id
-
- def get(self, job_id: str, user_id: str) -> SignInJob | None:
- """The job, only for the Schuly user that started it (anyone else gets None, like an unknown id)."""
- with self._lock:
- job = self._jobs.get(job_id)
- if job is None or job.user != self._hash(user_id):
- return None
- if self._expired(job):
- self._drop(job)
- return None
- return job
-
- def remove(self, job_id: str, user_id: str):
- with self._lock:
- job = self._jobs.get(job_id)
- if job is not None and job.user == self._hash(user_id):
- self._drop(job)
-
- def sweep(self):
- with self._lock:
- self._prune()
- self._starts.sweep(60)
- self._failures.sweep(600)
- self._otp_hits.sweep(3600)
-
- def clear(self):
- with self._lock:
- for job in self._jobs.values():
- job.stop()
- self._jobs.clear()
- self._by_owner.clear()
- self._starts = RateLimiter()
- self._failures = RateLimiter()
- self._otp_hits = RateLimiter()
-
- def __len__(self):
- return len(self._jobs)
-
- @staticmethod
- def _expired(job: SignInJob) -> bool:
- now = time.monotonic()
- return now > job.expires or (job.done_at is not None and now - job.done_at > DONE_TTL)
-
- def _drop(self, job: SignInJob):
- job.stop()
- self._jobs.pop(job.id, None)
- if self._by_owner.get(job.acct) == job.id:
- del self._by_owner[job.acct]
-
- def _prune(self):
- for job in [j for j in self._jobs.values() if self._expired(j)]:
- self._drop(job)
-
-
-store = JobStore()
diff --git a/src/people_photos.py b/src/people_photos.py
deleted file mode 100644
index dcc487e..0000000
--- a/src/people_photos.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Profile photos by e-mail address, for mail senders (which come with an address, not a user id).
-
-Graph accepts a UPN in `users/{id}`, and UPN and mail are usually equal, so the address is tried
-directly first; when that 404s, the user is resolved with `users?$filter=mail eq '...'`. A person
-outside the tenant resolves to nobody and reads as no photo. Results are cached per account: a
-photo for 6 hours, a miss for 30 minutes.
-"""
-
-from urllib.parse import quote
-
-from outnote import GraphError
-
-from src.account_cache import for_broker
-
-HIT_TTL = 6 * 3600
-MISS_TTL = 30 * 60
-MAX_CACHED_BYTES = 200 * 1024
-
-
-class Throttled(RuntimeError):
- def __init__(self, retry_after: float | None = None):
- self.retry_after = retry_after
- super().__init__("throttled")
-
-
-def _odata_string(value: str) -> str:
- return value.replace("'", "''")
-
-
-def _first(note, user: str, size: int) -> bytes | None:
- seg = quote(user, safe="@")
- for path in (f"users/{seg}/photos/{size}x{size}/$value", f"users/{seg}/photo/$value"):
- try:
- return note.get(path, raw="bytes")
- except GraphError as ex:
- if ex.status == 429:
- raise Throttled(ex.retry_after) from None
- if ex.status not in (400, 404):
- raise
- return None
-
-
-def _resolve(note, email: str) -> str | None:
- try:
- found = note.list("users", {"$filter": f"mail eq '{_odata_string(email)}'", "$select": "id", "$top": 1}, limit=1)
- except GraphError as ex:
- if ex.status == 429:
- raise Throttled(ex.retry_after) from None
- if ex.status in (400, 403, 404):
- return None
- raise
- return found[0].get("id") if found else None
-
-
-def photo(note, broker, email: str, size: int) -> bytes | None:
- email = email.strip().lower()
- cache = for_broker(broker)
- key = ("people_photo", email, size)
- marker = object()
- hit = cache.get(key, marker)
- if hit is not marker:
- return hit
- data = _first(note, email, size)
- if data is None:
- user_id = _resolve(note, email)
- if user_id:
- data = _first(note, user_id, size)
- if data is None:
- cache.set(key, None, MISS_TTL)
- elif len(data) <= MAX_CACHED_BYTES:
- cache.set(key, data, HIT_TTL)
- return data
diff --git a/src/profile.py b/src/profile.py
deleted file mode 100644
index 75c4405..0000000
--- a/src/profile.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""The signed-in user's own profile (`GET /me`), cached per account, never stalled by throttling.
-
-Graph throttles `/me` (a directory read) on its own, with 429 and Retry-After ~21 s, even when every
-other route answers at once. OutNote sleeps each Retry-After in the worker thread and retries up to
-4 times, which made the first `/api/me` after a start take 42 s (two 21 s waits). So `/me` is asked
-exactly once, without OutNote's retries; on 429 (or any failure) the profile comes from the claims of
-the access token the broker just minted (oid, name, upn), which Microsoft signed for this user. A
-`/me` answer is kept for an hour, a profile from claims for five minutes, so `/me` is tried again later.
-"""
-
-import base64
-import binascii
-import json
-import logging
-
-from entrance import HTTPError, http_client
-from outnote import C_OFFICE, GraphError
-
-from src.account_cache import for_broker
-
-log = logging.getLogger("schuly.microsoft")
-
-CACHE_KEY = "me_profile"
-TTL = 3600.0
-CLAIMS_TTL = 300.0
-ME_URL = "https://graph.microsoft.com/v1.0/me"
-ME_TIMEOUT = 10.0
-
-
-def get(note, broker) -> dict:
- cache = for_broker(broker)
- hit = cache.get(CACHE_KEY)
- if hit is not None:
- return hit
- with cache.lock(CACHE_KEY):
- hit = cache.get(CACHE_KEY)
- if hit is None:
- hit, ttl = _fetch(note, broker)
- cache.set(CACHE_KEY, hit, ttl)
- return hit
-
-
-def _fetch(note, broker) -> tuple[dict, float]:
- token = broker(C_OFFICE)
- status = None
- try:
- r = http_client().request("GET", ME_URL, headers={"Authorization": f"Bearer {token}"}, timeout=ME_TIMEOUT)
- status = r.status_code
- if status == 200:
- return r.json(), TTL
- log.info("/me answered %s (Retry-After %s); using the token's claims", status, r.headers.get("Retry-After"))
- except (*HTTPError, ValueError) as ex: # entrance's HTTPError is a tuple, so it is unpacked
- log.info("/me failed (%s); using the token's claims", type(ex).__name__)
- claims = from_claims(token)
- if claims is None:
- raise GraphError(status or 502, "The profile is unavailable.")
- return claims, CLAIMS_TTL
-
-
-def from_claims(access_token: str) -> dict | None:
- """A `/me`-shaped profile from the access token's claims, or None when it carries no user object id."""
- try:
- payload = access_token.split(".")[1]
- claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
- except (IndexError, ValueError, binascii.Error):
- return None
- if not isinstance(claims, dict) or not isinstance(claims.get("oid"), str):
- return None
- upn = claims.get("upn") or claims.get("unique_name") or claims.get("preferred_username")
- return {
- "id": claims["oid"],
- "displayName": claims.get("name"),
- "givenName": claims.get("given_name"),
- "surname": claims.get("family_name"),
- "userPrincipalName": upn,
- "mail": claims.get("email") or upn,
- }
diff --git a/src/search.py b/src/search.py
deleted file mode 100644
index b06a127..0000000
--- a/src/search.py
+++ /dev/null
@@ -1,371 +0,0 @@
-"""Search over files, mail, chats and channel posts with the Microsoft Search API, with the place each hit lives in.
-
-Files (`driveItem`) and messages (`message` + `chatMessage`) can't share one Search API request ("Invalid entity type combination"),
-so they run as two requests in parallel; a source that fails is reported in `errors` and never fails the search. When the Search API
-refuses the file search (400 / 403 / 5xx), the user's drive and each joined team's library are searched one by one instead. The query
-text is never logged; it only travels in the request body to Microsoft (and, for the drive fallback, in a Graph URL).
-"""
-
-import logging
-import re
-from concurrent.futures import ThreadPoolExecutor
-from html import unescape
-from urllib.parse import quote, unquote
-
-from outnote import GraphError
-
-from src import teams_files
-from src.account_cache import for_broker
-
-log = logging.getLogger("schuly.microsoft")
-
-SNIPPET_MAX = 300
-MAP_TTL = 3600
-SHARED_TTL = 600
-FALLBACK_WORKERS = 4
-FALLBACK_PER_DRIVE = 50
-TEAM_SEARCH_CAP = 200
-TAGS = re.compile(r"<[^>]*>")
-TYPES = ("files", "mail", "chats", "channels")
-
-
-def snippet(summary) -> str:
- """The Search API's summary as plain text: `` becomes an ellipsis, highlight and other tags go, entities are decoded."""
- if not isinstance(summary, str):
- return ""
- text = summary.replace("", "…")
- text = unescape(TAGS.sub("", text))
- text = " ".join(text.split())
- return text if len(text) <= SNIPPET_MAX else text[: SNIPPET_MAX - 1].rstrip() + "…"
-
-
-def reason(ex: Exception) -> str:
- if isinstance(ex, GraphError):
- if ex.status in (401, 403):
- return f"not permitted ({ex.status})"
- if ex.status == 429:
- return "throttled (429)"
- if ex.status >= 500:
- return f"service error ({ex.status})"
- return f"request refused ({ex.status})"
- return "request failed"
-
-
-def _sender(person):
- """`{displayName, email}` out of a Graph emailAddress wrapper, an identitySet or a bare user."""
- if not isinstance(person, dict):
- return None
- inner = person.get("emailAddress") or person.get("user") or person
- name, mail = inner.get("name") or inner.get("displayName"), inner.get("address") or inner.get("email")
- return {"displayName": name, "email": mail} if name or mail else None
-
-
-def _identity(value):
- return _sender(value) if isinstance(value, dict) else None
-
-
-# --- Search API -------------------------------------------------------------
-def _query(note, entities: list[str], q: str, start: int, size: int):
- """One Search API request: (hits, total, more)."""
- body = {"requests": [{"entityTypes": entities, "query": {"queryString": q}, "from": start, "size": size}]}
- data = note.post("search/query", body) or {}
- hits, total, more = [], 0, False
- for container in ((data.get("value") or [{}])[0].get("hitsContainers") or []):
- hits.extend(container.get("hits") or [])
- total += int(container.get("total") or 0)
- more = more or bool(container.get("moreResultsAvailable"))
- return hits, total, more
-
-
-def file_hit(hit: dict) -> dict | None:
- r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {}
- ref = r.get("parentReference") or {}
- item_id = r.get("id") or hit.get("hitId")
- if not item_id:
- return None
- out = {"type": "file", "id": item_id, "driveId": ref.get("driveId"), "title": r.get("name"), "snippet": snippet(hit.get("summary")), "date": r.get("lastModifiedDateTime"), "webUrl": r.get("webUrl"), "size": r.get("size"), "isFolder": r.get("folder") is not None, "mimeType": (r.get("file") or {}).get("mimeType"), "from": _identity(r.get("lastModifiedBy") or r.get("createdBy"))}
- path = ref.get("path")
- if isinstance(path, str) and "root:" in path:
- out["_path"] = _clean_path(path)
- return out
-
-
-def _clean_path(raw: str) -> str:
- return unquote(raw.split("root:", 1)[1]).strip("/")
-
-
-def message_hit(hit: dict) -> dict | None:
- r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {}
- kind = str(r.get("@odata.type") or "")
- mid = r.get("id") or hit.get("hitId")
- if not mid:
- return None
- if kind.endswith("chatMessage") or "channelIdentity" in r or "chatId" in r:
- sender = _sender(r.get("from"))
- channel = r.get("channelIdentity") if isinstance(r.get("channelIdentity"), dict) else None
- return {"type": "channel" if channel else "chat", "id": mid, "title": r.get("subject") or (sender or {}).get("displayName") or "Chat", "snippet": snippet(hit.get("summary")), "date": r.get("createdDateTime"), "webUrl": r.get("webLink") or r.get("webUrl"), "from": sender, "_channel": channel, "_chatId": r.get("chatId")}
- return {"type": "mail", "id": mid, "title": r.get("subject"), "snippet": snippet(hit.get("summary") or r.get("bodyPreview")), "date": r.get("receivedDateTime") or r.get("sentDateTime"), "webUrl": r.get("webLink"), "from": _sender(r.get("from"))}
-
-
-# --- location context -------------------------------------------------------
-class Context:
- """Lazily loaded lookups shared by the hits of one search; each failure only costs the detail it would have given."""
-
- def __init__(self, note, broker):
- self.note, self.cache = note, for_broker(broker)
- self.broker = broker
- self._own = self._drives = self._shared = None
- self._teams = None
- self._channels: dict[str, dict[str, str]] = {}
-
- def teams(self) -> dict[str, str]:
- if self._teams is None:
- try:
- self._teams = {t["id"]: t.get("displayName") or "" for t in teams_files.joined_teams(self.note, self.broker)}
- except Exception as ex:
- log.info("search: joined teams failed: %s", type(ex).__name__)
- self._teams = {}
- return self._teams
-
- def own_drive(self):
- if self._own is None:
- try:
- self._own = teams_files.own_drive_id(self.note, self.broker) or ""
- except Exception as ex:
- log.info("search: own drive failed: %s", type(ex).__name__)
- self._own = ""
- return self._own
-
- def team_drives(self) -> dict[str, str]:
- """Library drive id -> team id for the joined teams (cached 1 h)."""
- if self._drives is None:
- hit = self.cache.get(("search", "team_drives"))
- if hit is None:
- hit = {}
- complete = True
-
- def one(team_id):
- try:
- return team_id, teams_files.team_drive_id(self.note, self.broker, team_id)
- except Exception:
- return team_id, None
-
- with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool:
- for team_id, drive_id in pool.map(one, list(self.teams())):
- if drive_id:
- hit[drive_id] = team_id
- else:
- complete = False
- self.cache.set(("search", "team_drives"), hit, MAP_TTL if complete else 300)
- self._drives = hit
- return self._drives
-
- def channel_names(self, team_id: str) -> dict[str, str]:
- """Channel id -> name (cached through teams_files.channels)."""
- if team_id not in self._channels:
- try:
- self._channels[team_id] = {c["id"]: c.get("displayName") or "" for c in teams_files.channels(self.note, self.broker, team_id)}
- except Exception as ex:
- log.info("search: channels failed: %s", type(ex).__name__)
- self._channels[team_id] = {}
- return self._channels[team_id]
-
- def shared_ids(self) -> set[str]:
- if self._shared is None:
- hit = self.cache.get(("search", "shared_ids"))
- if hit is None:
- try:
- hit = {i["id"] for i in teams_files.shared_with_me(self.note, 200)}
- except Exception as ex:
- log.info("search: sharedWithMe failed: %s", type(ex).__name__)
- hit = set()
- self.cache.set(("search", "shared_ids"), hit, SHARED_TTL)
- self._shared = hit
- return self._shared
-
-
-def _paths(note, hits: list[dict]):
- """Fills `_path` of file hits that have none through `$batch` (parentReference.path is not in Search API results)."""
- missing = [h for h in hits if h.get("driveId") and "_path" not in h]
- if not missing:
- return
- try:
- rows = note.batch([f"drives/{teams_files.seg(h['driveId'])}/items/{teams_files.seg(h['id'])}?$select=id,parentReference" for h in missing])
- except Exception as ex:
- log.info("search: path lookup failed: %s", type(ex).__name__)
- return
- for h, row in zip(missing, rows):
- path = ((row or {}).get("parentReference") or {}).get("path") if isinstance(row, dict) else None
- if isinstance(path, str) and "root:" in path:
- h["_path"] = _clean_path(path)
- elif isinstance(path, str) and path.endswith("/root"):
- h["_path"] = ""
-
-
-def _join(*parts) -> str:
- return " > ".join(p for p in parts if p)
-
-
-def locate(hits: list[dict], ctx: Context):
- """Adds `location` to every hit and drops the helper keys."""
- files = [h for h in hits if h["type"] == "file"]
- if files:
- _paths(ctx.note, files)
- own = ctx.own_drive() if files else ""
- drives = ctx.team_drives() if any(h.get("driveId") and h["driveId"] != own for h in files) else {}
- for h in hits:
- if h["type"] == "file":
- path = h.pop("_path", None)
- drive = h.get("driveId")
- loc = {"path": path}
- if drive and drive == own:
- loc["kind"] = "onedrive"
- loc["label"] = _join("OneDrive", path)
- elif drive in drives:
- team_id = drives[drive]
- name = ctx.teams().get(team_id) or ""
- loc.update(kind="team", teamId=team_id, teamName=name)
- top = (path or "").split("/", 1)[0]
- if top:
- for cid, cname in ctx.channel_names(team_id).items():
- if cname == top:
- loc.update(channelId=cid, channelName=cname)
- break
- loc["label"] = _join(f"Team {name}" if name else "Team", path)
- elif h["id"] in ctx.shared_ids():
- loc["kind"] = "shared"
- loc["label"] = _join("Shared with me", path)
- else:
- loc["kind"] = "sharepoint"
- loc["label"] = _join("SharePoint", path)
- h["location"] = loc
- elif h["type"] == "mail":
- h["location"] = {"kind": "mailbox", "label": "Mailbox"}
- elif h["type"] == "channel":
- channel = h.pop("_channel", None) or {}
- h.pop("_chatId", None)
- team_id, channel_id = channel.get("teamId"), channel.get("channelId")
- team_name = ctx.teams().get(team_id) if team_id else None
- channel_name = ctx.channel_names(team_id).get(channel_id) if team_id and channel_id else None
- h["location"] = {"kind": "channel", "teamId": team_id, "teamName": team_name, "channelId": channel_id, "channelName": channel_name, "label": _join(f"Team {team_name}" if team_name else "Team", channel_name)}
- else:
- chat_id = h.pop("_chatId", None)
- h.pop("_channel", None)
- h["location"] = {"kind": "chat", "chatId": chat_id, "label": "Chat"}
-
-
-# --- fallback and team scope ------------------------------------------------
-def drive_search(note, drive_id: str | None, q: str, top: int) -> list[dict]:
- """Hits of `drives/{d}/root/search(q=)` (or the user's own drive when `drive_id` is None) shaped like Search API hits."""
- literal = quote(q.replace("'", "''"), safe="")
- base = f"drives/{teams_files.seg(drive_id)}" if drive_id else "me/drive"
- rows = note.list(f"{base}/root/search(q='{literal}')", {"$top": min(top, 200), "$select": teams_files.ITEM_FIELDS}, limit=top)
- return [{"resource": teams_files._with_drive(r, drive_id)} for r in rows]
-
-
-def fallback_files(note, broker, q: str, ctx: Context):
- """Own drive + each joined team's library, searched in parallel; returns (hits, errors)."""
- targets: list[str | None] = [None]
- errors = []
- try:
- targets += list(ctx.team_drives())
- except Exception as ex:
- errors.append(f"teams: {reason(ex)}")
-
- def one(drive_id):
- try:
- return drive_search(note, drive_id, q, FALLBACK_PER_DRIVE), None
- except Exception as ex:
- if "invalid_grant" in str(ex):
- raise
- return [], reason(ex)
-
- hits, failed = [], 0
- with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool:
- for found, err in pool.map(one, targets):
- hits.extend(found)
- failed += 1 if err else 0
- if failed:
- errors.append(f"files: {failed} of {len(targets)} libraries could not be searched")
- return hits, errors
-
-
-def _by_date(hits: list[dict]) -> list[dict]:
- return sorted(hits, key=lambda h: (h.get("resource") or {}).get("lastModifiedDateTime") or "", reverse=True)
-
-
-# --- entry point ------------------------------------------------------------
-def run(note, broker, q: str, types: list[str], start: int, size: int, team_id: str | None = None) -> dict:
- """The SearchResult for `q` over the wanted `types` (files, mail, chats, channels), optionally limited to one team."""
- wanted = [t for t in TYPES if t in types]
- if team_id:
- wanted = [t for t in wanted if t in ("files", "channels")]
- ctx = Context(note, broker)
- errors: list[str] = []
- out: list[dict] = []
- totals = {"files": 0, "messages": 0}
- more = False
- fallback = False
-
- def files_source():
- if team_id:
- drive_id = teams_files.team_drive_id(note, broker, team_id)
- rows = drive_search(note, drive_id, q, min(start + size + 1, TEAM_SEARCH_CAP))
- page = rows[start : start + size]
- return page, len(rows), len(rows) > start + size, False
- try:
- hits, total, has_more = _query(note, ["driveItem"], q, start, size)
- return hits, total, has_more, False
- except GraphError as ex:
- if ex.status in (400, 403) or ex.status >= 500:
- found, errs = fallback_files(note, broker, q, ctx)
- errors.extend(errs)
- found = _by_date(found)
- return found[start : start + size], len(found), len(found) > start + size, True
- raise
-
- entities = []
- if "mail" in wanted:
- entities.append("message")
- if "chats" in wanted or "channels" in wanted:
- entities.append("chatMessage")
-
- def messages_source():
- hits, total, has_more = _query(note, entities, q, start, size)
- return hits, total, has_more
-
- jobs = {}
- with ThreadPoolExecutor(max_workers=2) as pool:
- if "files" in wanted:
- jobs["files"] = pool.submit(files_source)
- if entities:
- jobs["messages"] = pool.submit(messages_source)
- results = {}
- for name, future in jobs.items():
- try:
- results[name] = future.result()
- except Exception as ex:
- if "invalid_grant" in str(ex):
- raise
- label = name if name == "files" else ("mail" if entities == ["message"] else "messages")
- errors.append(f"{label}: {reason(ex)}")
- if "files" in results:
- hits, totals["files"], has_more, fallback = results["files"]
- more = more or has_more
- out.extend(h for h in (file_hit(x) for x in hits) if h)
- if "messages" in results:
- hits, totals["messages"], has_more = results["messages"]
- more = more or has_more
- for h in (message_hit(x) for x in hits):
- if h is None:
- continue
- if h["type"] == "mail" and "mail" not in wanted:
- continue
- if h["type"] == "chat" and "chats" not in wanted:
- continue
- if h["type"] == "channel" and ("channels" not in wanted or (team_id and (h.get("_channel") or {}).get("teamId") != team_id)):
- continue
- out.append(h)
- locate(out, ctx)
- out.sort(key=lambda h: h.get("date") or "", reverse=True)
- return {"query": q, "hits": out, "from": start, "size": size, "moreAvailable": more, "nextFrom": start + size if more else None, "totals": totals, "fallback": fallback, "errors": errors}
diff --git a/src/teams_chat.py b/src/teams_chat.py
deleted file mode 100644
index 6b4f59b..0000000
--- a/src/teams_chat.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""Group chat pictures from the Teams chat service.
-
-Graph has no endpoint for a chat's custom picture, but the Teams clients read it from the chat
-service: the conversation's `threadProperties.picture` holds `URL@`, and the
-image is served by AMS (the async media gateway). Both accept a skype token, which the Teams auth
-service issues for a token to `api.spaces.skype.com`:
-
-1. broker(Teams client, https://api.spaces.skype.com/.default) -> access token (FOCI redeem)
-2. POST https://teams.microsoft.com/api/authsvc/v1.0/authz (Bearer) -> skypeToken, regionGtms.chatService
-3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...)
-4. GET (Authorization: skype_token ...) -> image bytes
-
-The skype token and each chat's picture URL are cached per account (see `account_cache`).
-"""
-
-import time
-from concurrent.futures import ThreadPoolExecutor, wait
-from urllib.parse import quote, urlsplit
-
-import httpx
-from entrance import TokenError, http_client
-
-from src import outnote_client
-from src.account_cache import for_broker
-
-TEAMS_CLIENT = "1fec8e78-bce4-4aaf-ab1b-5451cc387264" # Microsoft Teams, a FOCI family client
-SPACES_SCOPE = "https://api.spaces.skype.com/.default"
-AUTHZ_URL = "https://teams.microsoft.com/api/authsvc/v1.0/authz"
-# Picture URLs come from chat data any member can change, so only Microsoft's media hosts are fetched (the skype token rides on that request).
-PICTURE_HOSTS = (".teams.microsoft.com", ".asm.skype.com")
-PICTURE_TTL = 3600
-SESSION_MARGIN = 120
-SESSION_RETRY = 600 # after a refused sign-in to Teams, the list stops asking for a while
-MAX_PICTURE_BYTES = 5 * 1024 * 1024
-LOOKUP_WORKERS = 8
-LOOKUP_BUDGET = 4.0 # seconds the chat list waits for uncached picture lookups
-_TIMEOUT = 15.0
-_STREAM_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
-
-
-class ChatServiceError(RuntimeError):
- def __init__(self, status: int, message: str):
- self.status = status
- super().__init__(f"Teams chat service {status}: {message}")
-
-
-def _session(broker) -> tuple[str, str]:
- """(skype token, chat service base URL) for the account, minted once per token lifetime."""
- cache = for_broker(broker)
- hit = cache.get("teams_session")
- if hit:
- return hit
- with cache.lock("teams_session"):
- hit = cache.get("teams_session")
- if hit:
- return hit
- failed = cache.get("teams_session_failed")
- if failed:
- raise ChatServiceError(failed, "Teams sign-in refused recently")
- try:
- return _new_session(broker, cache)
- except ChatServiceError as ex:
- if ex.status in (401, 403):
- cache.set("teams_session_failed", ex.status, SESSION_RETRY)
- raise
-
-
-def _new_session(broker, cache) -> tuple[str, str]:
- try:
- bearer = broker(TEAMS_CLIENT, SPACES_SCOPE)
- except TokenError as ex:
- if ex.error == "invalid_grant":
- raise
- # The tenant or client may not allow the Teams resource; that is a missing feature, not an expired sign-in.
- raise ChatServiceError(403, ex.error) from None
- r = http_client().post(AUTHZ_URL, headers={"Authorization": f"Bearer {bearer}"}, content=b"", timeout=_TIMEOUT)
- if r.status_code != 200:
- raise ChatServiceError(r.status_code, "authz refused")
- body = r.json()
- skype = (body.get("tokens") or {}).get("skypeToken")
- service = ((body.get("regionGtms") or {}).get("chatService") or "").rstrip("/")
- if not skype or not service.startswith("https://"):
- raise ChatServiceError(502, "authz returned no skype token or chat service")
- lifetime = int((body.get("tokens") or {}).get("expiresIn") or 3600)
- session = (skype, service)
- ams = _media_host((body.get("regionGtms") or {}).get("amsV2") or (body.get("regionGtms") or {}).get("ams"))
- cache.set("teams_ams", ams, max(60, lifetime - SESSION_MARGIN))
- cache.set("teams_session", session, max(60, lifetime - SESSION_MARGIN))
- return session
-
-
-def _drop_session(broker):
- # A skype token the chat service no longer accepts; the next call mints a fresh one.
- for_broker(broker).set("teams_session", None, 0)
-
-
-def _media_host(value) -> str | None:
- """The media service base URL when it is an https Microsoft media host, else None."""
- if not isinstance(value, str):
- return None
- parts = urlsplit(value)
- host = (parts.hostname or "").lower()
- return f"https://{host}" if parts.scheme == "https" and parts.port in (None, 443) and any(host.endswith(h) for h in PICTURE_HOSTS) else None
-
-
-def _picture_url(value) -> str | None:
- if not isinstance(value, str) or not value.startswith("URL@"):
- return None
- url = value[4:]
- parts = urlsplit(url)
- host = (parts.hostname or "").lower()
- if parts.scheme != "https" or not any(host.endswith(h) for h in PICTURE_HOSTS):
- return None
- return url
-
-
-def picture_url(broker, chat_id: str) -> str | None:
- """The chat's custom picture URL, or None when it has none (or the chat service doesn't know the chat)."""
- cache = for_broker(broker)
- key = ("chat_picture", chat_id)
- marker = object()
- hit = cache.get(key, marker)
- if hit is not marker:
- return hit
- skype, service = _session(broker)
- r = http_client().request("GET", f"{service}/v1/users/ME/conversations/{quote(chat_id, safe='')}?view=msnp24Equivalent", headers={"Authentication": f"skypetoken={skype}"}, timeout=_TIMEOUT)
- if r.status_code == 404:
- url = None
- elif r.status_code != 200:
- if r.status_code == 401:
- _drop_session(broker)
- raise ChatServiceError(r.status_code, "conversation lookup failed")
- else:
- try:
- url = _picture_url((r.json().get("threadProperties") or {}).get("picture"))
- except (ValueError, AttributeError):
- url = None
- cache.set(key, url, PICTURE_TTL)
- return url
-
-
-def picture(broker, chat_id: str) -> bytes | None:
- """The chat's custom picture bytes, or None when it has none. A skype token the service stopped accepting is replaced once."""
- try:
- return _download(broker, chat_id)
- except ChatServiceError as ex:
- if ex.status != 401:
- raise
- return _download(broker, chat_id)
-
-
-def _download(broker, chat_id: str) -> bytes | None:
- url = picture_url(broker, chat_id)
- if not url:
- return None
- skype, _ = _session(broker)
- with outnote_client.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=_STREAM_TIMEOUT, follow_redirects=False) as r:
- if r.status_code == 404:
- for_broker(broker).set(("chat_picture", chat_id), None, PICTURE_TTL)
- return None
- if r.status_code != 200:
- if r.status_code == 401:
- _drop_session(broker)
- raise ChatServiceError(r.status_code, "picture download failed")
- data = bytearray()
- for chunk in r.iter_bytes():
- data.extend(chunk)
- if len(data) > MAX_PICTURE_BYTES:
- raise ChatServiceError(413, "picture larger than 5 MB")
- return bytes(data)
-
-
-def mark_images(broker, chats: list[dict], budget: float = LOOKUP_BUDGET) -> list[dict]:
- """Sets `hasImage` on each Graph chat. Only group chats can carry a custom picture; uncached ones are looked up in parallel within `budget` seconds, and a lookup that fails or runs late reads as False (it is cached once it lands)."""
- cache = for_broker(broker)
- found: dict[str, bool] = {}
- todo = []
- for chat in chats:
- cid = chat.get("id")
- if chat.get("chatType") != "group" or not isinstance(cid, str):
- found[cid] = False
- continue
- marker = object()
- hit = cache.get(("chat_picture", cid), marker)
- if hit is marker:
- todo.append(cid)
- else:
- found[cid] = bool(hit)
- if todo:
- pool = ThreadPoolExecutor(max_workers=min(LOOKUP_WORKERS, len(todo)))
- futures = {pool.submit(picture_url, broker, cid): cid for cid in todo}
- deadline = time.monotonic() + budget
- done, _ = wait(futures, timeout=max(0.0, deadline - time.monotonic()))
- pool.shutdown(wait=False)
- for future, cid in futures.items():
- found[cid] = future in done and future.exception() is None and bool(future.result())
- for chat in chats:
- chat["hasImage"] = found.get(chat.get("id"), False)
- return chats
-
-
-
-CUSTOM_EMOJI_VIEWS = {True: "imgt2_anim", False: "img_thumb_tiny"}
-MAX_EMOJI_BYTES = 2 * 1024 * 1024
-
-
-def custom_emoji(broker, document_id: str, animated: bool) -> bytes | None:
- """A tenant custom emoji's picture from the Teams media service (`document_id` already validated), or None when it is gone. An animated view is a real GIF/WEBP, not a sprite."""
- try:
- return _custom_emoji(broker, document_id, animated)
- except ChatServiceError as ex:
- if ex.status != 401:
- raise
- return _custom_emoji(broker, document_id, animated)
-
-
-def _custom_emoji(broker, document_id: str, animated: bool) -> bytes | None:
- skype, _ = _session(broker)
- base = for_broker(broker).get("teams_ams")
- if not base:
- raise ChatServiceError(404, "no media service for this account")
- url = f"{base}/v1/objects/{quote(document_id, safe='')}/views/{CUSTOM_EMOJI_VIEWS[animated]}"
- with outnote_client.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=_STREAM_TIMEOUT, follow_redirects=False) as r:
- if r.status_code == 404:
- return None
- if r.status_code != 200:
- if r.status_code == 401:
- _drop_session(broker)
- raise ChatServiceError(r.status_code, "custom emoji download failed")
- data = bytearray()
- for chunk in r.iter_bytes():
- data.extend(chunk)
- if len(data) > MAX_EMOJI_BYTES:
- raise ChatServiceError(413, "custom emoji larger than 2 MB")
- return bytes(data)
diff --git a/src/teams_files.py b/src/teams_files.py
deleted file mode 100644
index 48c03f1..0000000
--- a/src/teams_files.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""Teams, channels and the files behind them (team libraries, channel folders), plus shared / recent files and raw drive items.
-
-Live facts: `groups/{id}/drive` answers 404 on the Office client, so a team's library id is resolved with the OfficeHome client
-(`C_SITES`) and cached for an hour; browsing `drives/{driveId}/...` then works on the default client. Every drive entry handed out
-carries `parentReference.driveId` so the caller can address it again.
-"""
-
-import re
-from urllib.parse import quote, urlsplit
-
-import httpx
-from outnote import C_SITES, GraphError
-
-from src import outnote_client
-from src.account_cache import for_broker
-
-LIST_TTL = 600
-DRIVE_TTL = 3600
-MAX_PATH_CHARS = 400
-MAX_PATH_SEGMENTS = 30
-MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024
-FETCH_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
-ITEM_FIELDS = "id,name,size,file,folder,webUrl,lastModifiedDateTime,createdDateTime,parentReference,lastModifiedBy,createdBy"
-FORBIDDEN_PATH = re.compile(r"[%\\:#?\x00-\x1f\x7f]")
-
-
-class FileFetchError(Exception):
- """A download that can't be served: `status` is the HTTP status to answer with."""
-
- def __init__(self, status: int, message: str):
- self.status = status
- super().__init__(message)
-
-
-def seg(value: str) -> str:
- return quote(value, safe="")
-
-
-def valid_path(path) -> list[str]:
- """The folder names of a relative path ('' -> none). Raises ValueError on dot segments, `% \\ : # ?`, control characters or a path over 400 chars."""
- if path is None or path == "":
- return []
- if not isinstance(path, str) or len(path) > MAX_PATH_CHARS or FORBIDDEN_PATH.search(path):
- raise ValueError("invalid path")
- parts = [p for p in path.split("/") if p != ""]
- if len(parts) > MAX_PATH_SEGMENTS or any(p in (".", "..") or ".." in p or p != p.strip() for p in parts):
- raise ValueError("invalid path")
- return parts
-
-
-def is_download_url(url) -> bool:
- """True only for an https SharePoint download URL: host ends with `.sharepoint.com`, default port, no credentials."""
- if not isinstance(url, str) or not url or len(url) > 8000 or any(ord(c) < 33 for c in url):
- return False
- try:
- parts = urlsplit(url)
- port = parts.port
- except ValueError:
- return False
- host = (parts.hostname or "").lower()
- return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and host.endswith(".sharepoint.com")
-
-
-def fetch_bytes(url: str, cap: int) -> bytes:
- """The bytes behind a pre-authorized download URL (no bearer token, no redirects), 413 past `cap`."""
- if not is_download_url(url):
- raise FileFetchError(502, "The file has an unexpected download location.")
- out = bytearray()
- with outnote_client.download_client().stream("GET", url, timeout=FETCH_TIMEOUT, follow_redirects=False) as r:
- if r.status_code != 200:
- raise FileFetchError(502, f"The download failed ({r.status_code}).")
- for chunk in r.iter_bytes(64 * 1024):
- out.extend(chunk)
- if len(out) > cap:
- raise FileFetchError(413, "The file is too large.")
- return bytes(out)
-
-
-def download_url(note, path: str, item: dict) -> str | None:
- """The pre-authorized link of the item at `path`, or None when `/content` answers without a redirect. Graph can leave it out of a `$select` answer, so ask again without one, then through `/content`; a refusal there is raised.
-
- OutNote 2.0.1's `download_url` has the same fallback but can't take the `shares/...` path chat files need and always asks Graph again, and its `download` holds the whole file in memory with no cap."""
- url = item.get("@microsoft.graph.downloadUrl")
- if url:
- return url
- full = note.get(path)
- url = full.get("@microsoft.graph.downloadUrl") if isinstance(full, dict) else None
- if url:
- return url
- try:
- return note.redirect(f"{path}/content")
- except GraphError as ex:
- if ex.status >= 400:
- raise
- return None
-
-
-def item_base(drive_id: str | None, item_id: str) -> str:
- return f"drives/{seg(drive_id)}/items/{seg(item_id)}" if drive_id else f"me/drive/items/{seg(item_id)}"
-
-
-def _with_drive(item: dict, drive_id: str | None) -> dict:
- if drive_id and isinstance(item, dict):
- ref = item.get("parentReference")
- if not isinstance(ref, dict):
- ref = item["parentReference"] = {}
- ref.setdefault("driveId", drive_id)
- return item
-
-
-def joined_teams(note, broker) -> list[dict]:
- cache = for_broker(broker)
- hit = cache.get(("teams", "joined"))
- if hit is not None:
- return hit
- rows = note.list("me/joinedTeams", {"$select": "id,displayName,description"})
- teams = [{"id": t.get("id"), "displayName": t.get("displayName"), "description": t.get("description")} for t in rows if t.get("id")]
- cache.set(("teams", "joined"), teams, LIST_TTL)
- return teams
-
-
-def channels(note, broker, team_id: str) -> list[dict]:
- cache = for_broker(broker)
- key = ("teams", "channels", team_id)
- hit = cache.get(key)
- if hit is not None:
- return hit
- rows = note.list(f"teams/{seg(team_id)}/channels", {"$select": "id,displayName,membershipType,description"})
- out = [{"id": c.get("id"), "displayName": c.get("displayName"), "membershipType": c.get("membershipType"), "description": c.get("description")} for c in rows if c.get("id")]
- cache.set(key, out, LIST_TTL)
- return out
-
-
-def team_drive_id(note, broker, team_id: str) -> str:
- """The team's document library id (the Office client can't read `groups/{id}/drive`, the OfficeHome client can); cached 1 h."""
- cache = for_broker(broker)
- key = ("teams", "drive", team_id)
- hit = cache.get(key)
- if hit:
- return hit
- drive = note.get(f"groups/{seg(team_id)}/drive", {"$select": "id"}, client=C_SITES) or {}
- drive_id = drive.get("id")
- if not drive_id:
- raise GraphError(404, "The team has no document library.")
- cache.set(key, drive_id, DRIVE_TTL)
- return drive_id
-
-
-def own_drive_id(note, broker) -> str | None:
- cache = for_broker(broker)
- key = ("drive", "own")
- hit = cache.get(key)
- if hit:
- return hit
- drive_id = (note.get("me/drive", {"$select": "id"}) or {}).get("id")
- if drive_id:
- cache.set(key, drive_id, DRIVE_TTL)
- return drive_id
-
-
-def team_files(note, broker, team_id: str, channel_id: str | None, path: str | None, top: int) -> dict:
- """`{driveId, folder: {id, name, path}, items}` for a team's library root, or a channel's folder, or a sub-folder of either."""
- names = valid_path(path)
- if channel_id:
- root = note.get(f"teams/{seg(team_id)}/channels/{seg(channel_id)}/filesFolder", {"$select": "id,name,parentReference"}) or {}
- drive_id = (root.get("parentReference") or {}).get("driveId")
- if not drive_id or not root.get("id"):
- raise GraphError(404, "The channel has no files folder.")
- else:
- drive_id = team_drive_id(note, broker, team_id)
- root = note.get(f"drives/{seg(drive_id)}/root", {"$select": "id,name"}) or {}
- if not root.get("id"):
- raise GraphError(404, "The team library has no root.")
- folder = root
- if names:
- folder = note.get(f"drives/{seg(drive_id)}/items/{seg(root['id'])}:/{'/'.join(seg(n) for n in names)}", {"$select": "id,name,folder"}) or {}
- if folder.get("folder") is None or not folder.get("id"):
- raise GraphError(404, "Folder not found.")
- items = note.list(f"drives/{seg(drive_id)}/items/{seg(folder['id'])}/children", {"$top": min(top, 200), "$select": ITEM_FIELDS}, limit=top)
- return {"driveId": drive_id, "folder": {"id": folder["id"], "name": folder.get("name"), "path": "/".join(names)}, "items": [_with_drive(i, drive_id) for i in items]}
-
-
-def drive_item(note, drive_id: str, item_id: str) -> dict:
- """One drive item with a fresh download URL."""
- return _with_drive(note.get(item_base(drive_id, item_id)) or {}, drive_id)
-
-
-def children(note, drive_id: str, item_id: str, top: int) -> list[dict]:
- rows = note.list(f"{item_base(drive_id, item_id)}/children", {"$top": min(top, 200), "$select": ITEM_FIELDS}, limit=top)
- return [_with_drive(i, drive_id) for i in rows]
-
-
-def _identity(value):
- """`{displayName, email}` out of a Graph identitySet (or a bare identity)."""
- if not isinstance(value, dict):
- return None
- user = value.get("user") if isinstance(value.get("user"), dict) else value
- name, email = user.get("displayName"), user.get("email")
- return {"displayName": name, "email": email} if name or email else None
-
-
-def flatten(entry: dict) -> dict | None:
- """A sharedWithMe / recent row as a plain driveItem (the `remoteItem` unwrapped), always with `parentReference.driveId`."""
- remote = entry.get("remoteItem") if isinstance(entry.get("remoteItem"), dict) else entry
- ref = remote.get("parentReference") or entry.get("parentReference") or {}
- drive_id = ref.get("driveId")
- if not remote.get("id") or not drive_id:
- return None
- out = {"id": remote["id"], "name": remote.get("name") or entry.get("name"), "size": remote.get("size", entry.get("size")), "webUrl": remote.get("webUrl") or entry.get("webUrl"), "lastModifiedDateTime": remote.get("lastModifiedDateTime") or entry.get("lastModifiedDateTime"), "parentReference": {"driveId": drive_id}}
- if remote.get("file") is not None:
- out["file"] = remote["file"]
- if remote.get("folder") is not None:
- out["folder"] = remote["folder"]
- by = _identity(remote.get("lastModifiedBy") or entry.get("lastModifiedBy"))
- if by:
- out["lastModifiedBy"] = {"user": by}
- shared = remote.get("shared") or entry.get("shared") or {}
- sharer = _identity(shared.get("sharedBy") or shared.get("owner"))
- if sharer or shared.get("sharedDateTime"):
- out["shared"] = {"sharedBy": sharer, "sharedDateTime": shared.get("sharedDateTime")}
- return out
-
-
-def shared_with_me(note, top: int) -> list[dict]:
- rows = note.list("me/drive/sharedWithMe", {"$top": min(top, 200)}, limit=top)
- return [f for f in (flatten(r) for r in rows) if f]
-
-
-def recent(note, top: int) -> list[dict]:
- rows = note.list("me/drive/recent", {"$top": min(top, 200)}, limit=top)
- return [f for f in (flatten(r) for r in rows) if f]
diff --git a/tests/conftest.py b/tests/conftest.py
index 60f6226..cdca115 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
import pytest
-from src import profile
+from tests.services import profile
REAL_FETCH = profile._fetch
diff --git a/tests/services.py b/tests/services.py
new file mode 100644
index 0000000..a592582
--- /dev/null
+++ b/tests/services.py
@@ -0,0 +1,58 @@
+"""What the tests reach into, resolved from the composition root: the same singletons the routes use, so a patch here is a patch there."""
+
+import src.infrastructure.microsoft.account_cache as account_cache # noqa: F401
+from src.api.app import app
+from src.api.http_headers import HttpHeaders as config # noqa: F401
+from src.application.abstractions.i_browser_sign_in import IBrowserSignIn
+from src.application.abstractions.i_chat_sender import IChatSender
+from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue
+from src.application.abstractions.i_file_text_extractor import IFileTextExtractor
+from src.application.abstractions.i_file_thumbnails import IFileThumbnails
+from src.application.abstractions.i_gif_search import IGifSearch
+from src.application.abstractions.i_graph_client_factory import IGraphClientFactory
+from src.application.abstractions.i_graph_search import IGraphSearch
+from src.application.abstractions.i_mail_folder_roles import IMailFolderRoles
+from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients
+from src.application.abstractions.i_one_note import IOneNote
+from src.application.abstractions.i_pdf_converter import IPdfConverter
+from src.application.abstractions.i_profile_reader import IProfileReader
+from src.application.abstractions.i_sign_in_store import ISignInStore
+from src.application.abstractions.i_teams_chat import ITeamsChat
+from src.application.abstractions.i_teams_files import ITeamsFiles
+from src.application.options.password_sign_in_options import PasswordSignInOptions
+from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer
+from src.application.services.onenote_html_sanitizer import OneNoteHtmlSanitizer
+from src.application.services.warm_up_tracker import WarmUpTracker
+from src.domain.chat.outgoing_message import OutgoingMessage # noqa: F401
+from src.domain.chat.upload import Upload # noqa: F401
+from src.domain.errors.file_fetch_error import FileFetchError # noqa: F401
+from src.domain.errors.resource_too_large import ResourceTooLarge # noqa: F401
+from src.domain.errors.send_error import SendError # noqa: F401
+from src.domain.errors.text_error import TextError # noqa: F401
+from src.domain.files.file_limits import FileLimits as app_module # noqa: F401
+from src.infrastructure.microsoft.account_runner import AccountRunner # noqa: F401
+from src.infrastructure.microsoft.broker_cache import BrokerCache as _BrokerCache # noqa: F401
+from src.infrastructure.password_signin.sign_in_job import SignInJob # noqa: F401
+
+services = app.state.services
+
+outnote_client = services.get(IGraphClientFactory)
+http_clients = services.get(IMicrosoftHttpClients)
+browser_auth = services.get(IBrowserSignIn)
+profile = services.get(IProfileReader)
+folder_roles = services.get(IMailFolderRoles)
+teams_chat = services.get(ITeamsChat)
+chat_send = services.get(IChatSender)
+emojis = services.get(IEmojiCatalogue)
+file_pdf = services.get(IPdfConverter)
+file_text = services.get(IFileTextExtractor)
+file_thumbs = services.get(IFileThumbnails)
+gifs = services.get(IGifSearch)
+onenote = services.get(IOneNote)
+search = services.get(IGraphSearch)
+teams_files = services.get(ITeamsFiles)
+chat_html = services.get(ChatHtmlSanitizer)
+onenote_html = services.get(OneNoteHtmlSanitizer)
+sign_in_store = services.get(ISignInStore)
+sign_in_options = services.get(PasswordSignInOptions)
+warm_up_tracker = services.get(WarmUpTracker)
diff --git a/tests/test_architecture.py b/tests/test_architecture.py
new file mode 100644
index 0000000..0f79ffd
--- /dev/null
+++ b/tests/test_architecture.py
@@ -0,0 +1,71 @@
+"""The onion rule, read from the imports of every file under src/: dependencies only point inward."""
+
+import ast
+import sys
+from pathlib import Path
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+THIRD_PARTY_FOR = {
+ "domain": set(),
+ "application": {"mediatorx", "pydantic"},
+}
+# The composition root: the only part of `api` that may name concrete infrastructure classes.
+COMPOSITION_ROOT = SRC / "api" / "extensions"
+
+
+def imports_of(path: Path) -> set[str]:
+ found = set()
+ for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
+ if isinstance(node, ast.Import):
+ found.update(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
+ found.add(node.module)
+ return found
+
+
+def ring_of(module: str) -> str | None:
+ parts = module.split(".")
+ return parts[1] if parts[0] == "src" and len(parts) > 1 else None
+
+
+def broken_rules(path: Path, ring: str) -> list[str]:
+ broken = []
+ for module in imports_of(path):
+ target = ring_of(module)
+ top = module.split(".")[0]
+ if target is None and top != "src":
+ if ring in THIRD_PARTY_FOR and top not in sys.stdlib_module_names and top not in THIRD_PARTY_FOR[ring]:
+ broken.append(f"{ring} imports {module}")
+ continue
+ if ring == "domain" and target != "domain":
+ broken.append(f"domain imports {module}")
+ elif ring == "application" and target not in ("domain", "application"):
+ broken.append(f"application imports {module}")
+ elif ring == "infrastructure" and target == "api":
+ broken.append(f"infrastructure imports {module}")
+ elif ring == "api" and target == "infrastructure" and COMPOSITION_ROOT not in path.parents:
+ broken.append(f"api outside the composition root imports {module}")
+ return broken
+
+
+def test_dependencies_only_point_inward():
+ broken = {}
+ for ring in ("domain", "application", "infrastructure", "api"):
+ for path in (SRC / ring).rglob("*.py"):
+ rules = broken_rules(path, ring)
+ if rules:
+ broken[str(path.relative_to(SRC))] = rules
+ assert broken == {}
+
+
+def test_every_file_under_src_belongs_to_a_ring():
+ strays = [p.relative_to(SRC) for p in SRC.rglob("*.py") if p.relative_to(SRC).parts[0] not in ("domain", "application", "infrastructure", "api") and p.name != "__init__.py"]
+ assert strays == []
+
+
+def test_the_rule_catches_a_violation(tmp_path):
+ bad = tmp_path / "bad.py"
+ bad.write_text("from src.infrastructure.logging_config import LoggerNames\nimport fastapi\nfrom outnote import GraphError\n", encoding="utf-8")
+ assert len(broken_rules(bad, "application")) == 3
+ assert len(broken_rules(bad, "domain")) == 3
+ assert any("infrastructure" in rule for rule in broken_rules(bad, "api"))
diff --git a/tests/test_broker_cache.py b/tests/test_broker_cache.py
index bbef62c..575e5c6 100644
--- a/tests/test_broker_cache.py
+++ b/tests/test_broker_cache.py
@@ -3,8 +3,7 @@
import pytest
from entrance import _foci
-from src import outnote_client
-from src.outnote_client import _BrokerCache
+from tests.services import _BrokerCache, outnote_client
class Calls(list):
diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py
index ea307e6..c69906a 100644
--- a/tests/test_browser_auth.py
+++ b/tests/test_browser_auth.py
@@ -6,8 +6,8 @@
import pytest
from fastapi.testclient import TestClient
-from src import browser_auth, outnote_client
-from src.app import app
+from tests.services import browser_auth, http_clients, outnote_client
+from src.api.app import app
def test_start_builds_a_pkce_authorize_url():
@@ -41,13 +41,13 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"access_token": "at", "refresh_token": "frt-from-browser", "expires_in": 3600})
return httpx.Response(400, json={"error": "invalid_grant", "error_description": "bad code"})
- original = outnote_client._post
+ original = http_clients.post_form
async def patched(url, data, client=None):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
return await original(url, data, c)
- monkeypatch.setattr(outnote_client, "_post", patched)
+ monkeypatch.setattr(http_clients, "post_form", patched)
monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), type("B", (), {"refresh_token": frt})()))
return seen
diff --git a/tests/test_cold_start.py b/tests/test_cold_start.py
index 81c066e..f7c5120 100644
--- a/tests/test_cold_start.py
+++ b/tests/test_cold_start.py
@@ -9,10 +9,10 @@
from entrance import _foci
from fastapi.testclient import TestClient
-from src import app as app_module
-from src import config, outnote_client
-from src.app import app
-from src.outnote_client import _BrokerCache
+
+from tests.services import _BrokerCache, config, http_clients, outnote_client, warm_up_tracker
+from src.api.app import app
+
HEADER = {config.REFRESH_TOKEN_HEADER: "frt-cold"}
REDEEM_SECONDS = 0.5
@@ -49,7 +49,7 @@ def post(url, data):
async def no_warm_up():
return None
- monkeypatch.setattr(outnote_client, "warm_up", no_warm_up)
+ monkeypatch.setattr(http_clients, "warm_up", no_warm_up)
return redeems, reads
@@ -81,7 +81,7 @@ def test_ready_is_503_until_warm_up_finishes(monkeypatch):
async def slow_warm_up():
await asyncio.to_thread(release.wait, 10)
- monkeypatch.setattr(outnote_client, "warm_up", slow_warm_up)
+ monkeypatch.setattr(http_clients, "warm_up", slow_warm_up)
with TestClient(app) as client:
assert client.get("/health/live").json() == {"status": "ok"}
blocked = client.get("/health/ready")
@@ -102,8 +102,8 @@ def test_ready_after_grace_period_even_if_warm_up_hangs(monkeypatch):
async def slow_warm_up():
await asyncio.to_thread(release.wait, 10)
- monkeypatch.setattr(outnote_client, "warm_up", slow_warm_up)
- monkeypatch.setattr(app_module, "READY_GRACE", 0.0)
+ monkeypatch.setattr(http_clients, "warm_up", slow_warm_up)
+ monkeypatch.setattr(warm_up_tracker, "READY_GRACE", 0.0)
with TestClient(app) as client:
assert client.get("/health/ready").status_code == 200
release.set()
diff --git a/tests/test_device_flow.py b/tests/test_device_flow.py
index 7d362b9..335b15e 100644
--- a/tests/test_device_flow.py
+++ b/tests/test_device_flow.py
@@ -4,8 +4,8 @@
import pytest
from fastapi.testclient import TestClient
-from src import outnote_client
-from src.app import app
+from tests.services import http_clients, outnote_client
+from src.api.app import app
class FakeNote:
@@ -31,13 +31,13 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"access_token": "at", "refresh_token": "new-frt", "expires_in": 3600})
transport = httpx.MockTransport(handler)
- original = outnote_client._post
+ original = http_clients.post_form
async def patched(url, data, client=None):
async with httpx.AsyncClient(transport=transport) as c:
return await original(url, data, c)
- monkeypatch.setattr(outnote_client, "_post", patched)
+ monkeypatch.setattr(http_clients, "post_form", patched)
monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), FakeBroker(frt)))
return state
diff --git a/tests/test_emojis.py b/tests/test_emojis.py
index 83329a2..fc851ff 100644
--- a/tests/test_emojis.py
+++ b/tests/test_emojis.py
@@ -6,8 +6,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import chat_html, chat_send, config, emojis, file_thumbs, outnote_client, teams_chat
-from src.app import app
+from tests.services import OutgoingMessage, SendError, chat_html, chat_send, config, emojis, file_thumbs, http_clients, outnote_client, teams_chat
+from src.api.app import app
from tests.test_teams_extras import Broker, Note
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
@@ -191,15 +191,15 @@ def test_skin_tones_only_on_diverse_emoji(cdn):
def test_send_posts_teams_emoji_markup(cdn):
- msg = chat_send.OutgoingMessage(content='go
', content_type="html")
+ msg = OutgoingMessage(content='go
', content_type="html")
body = chat_send.payload(chat_send.validate(msg))["body"]
assert body == {"contentType": "html", "content": 'go
'}
def test_send_says_503_when_the_catalogue_is_unavailable(cdn):
cdn.fail = True
- with pytest.raises(chat_send.SendError) as ex:
- chat_send.payload(chat_send.validate(chat_send.OutgoingMessage(content='', content_type="html")))
+ with pytest.raises(SendError) as ex:
+ chat_send.payload(chat_send.validate(OutgoingMessage(content='', content_type="html")))
assert ex.value.status == 503
@@ -234,7 +234,7 @@ def stream(self, method, url, headers=None, timeout=None, follow_redirects=None)
def ams(account, monkeypatch):
fake = AmsHttp()
monkeypatch.setattr(teams_chat, "http_client", lambda: fake)
- monkeypatch.setattr(outnote_client, "download_client", lambda: fake)
+ monkeypatch.setattr(http_clients, "download_client", lambda: fake)
return fake
diff --git a/tests/test_file_pdf.py b/tests/test_file_pdf.py
index 88bcf52..59ccf07 100644
--- a/tests/test_file_pdf.py
+++ b/tests/test_file_pdf.py
@@ -8,8 +8,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, file_pdf, outnote_client
-from src.app import app
+from tests.services import config, file_pdf, http_clients, outnote_client
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
PDF = b"%PDF-1.7\n" + b"x" * 300
@@ -104,7 +104,7 @@ def env(monkeypatch):
note = Note(broker)
http = Http()
monkeypatch.setattr(outnote_client, "build_client", lambda frt: (note, broker))
- monkeypatch.setattr(outnote_client, "download_client", lambda: http)
+ monkeypatch.setattr(http_clients, "download_client", lambda: http)
return note, http, TestClient(app)
@@ -289,7 +289,7 @@ def test_convert_route_caps_the_declared_length(env):
def test_convert_route_counts_a_chunked_body(env, monkeypatch):
- import src.app as service
+ from tests.services import app_module as service
monkeypatch.setattr(service, "MAX_SEND_BYTES", 1024)
note, _, client = env
diff --git a/tests/test_file_text.py b/tests/test_file_text.py
index 75e0366..48ef6a3 100644
--- a/tests/test_file_text.py
+++ b/tests/test_file_text.py
@@ -6,7 +6,7 @@
import pytest
-from src import file_text
+from tests.services import TextError, file_text
def docx_bytes():
@@ -106,7 +106,7 @@ def test_xlsx_rows_dates_sheets_and_paging():
assert nxt["text"].splitlines()[0] == "r1,,1" and nxt["nextRowOffset"] is None
other = file_text.extract("Sheet.xlsx", xlsx_bytes(), sheet="Other")
assert other["text"] == "only\n" and other["sheet"] == "Other"
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("Sheet.xlsx", xlsx_bytes(), sheet="Nope")
assert ex.value.status == 404
@@ -140,24 +140,24 @@ def test_text_decoding_and_html():
def test_unknown_and_legacy_types_are_415():
for name in ("a.exe", "a.png", "noext"):
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract(name, b"x")
assert ex.value.status == 415 and str(ex.value) == "This file type cannot be read as text."
assert file_text.kind_of("old.doc") == "legacy" and file_text.kind_of("a.ODS") == "legacy"
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("old.doc", b"x")
assert ex.value.status == 415
def test_source_cap_is_413(monkeypatch):
monkeypatch.setattr(file_text, "MAX_SOURCE_BYTES", 10)
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("a.txt", b"x" * 11)
assert ex.value.status == 413
def test_damaged_office_file_is_422():
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("a.docx", b"not a zip")
assert ex.value.status == 422
@@ -172,18 +172,18 @@ def _zip(members):
def test_zip_bomb_guards(monkeypatch):
monkeypatch.setattr(file_text, "MAX_UNCOMPRESSED", 1000)
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("a.xlsx", _zip({"a.xml": b"0" * 5000}))
assert ex.value.status == 413
monkeypatch.setattr(file_text, "MAX_UNCOMPRESSED", 200 * 1024 * 1024)
monkeypatch.setattr(file_text, "RATIO_FROM", 1000)
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("a.docx", _zip({"word/document.xml": b"0" * 100000}))
assert ex.value.status == 413
def test_xml_entity_declarations_are_refused():
evil = b']>&a;'
- with pytest.raises(file_text.TextError) as ex:
+ with pytest.raises(TextError) as ex:
file_text.extract("a.pptx", _zip({"ppt/presentation.xml": evil}))
assert ex.value.status == 422
diff --git a/tests/test_http.py b/tests/test_http.py
index f5e3c93..9d2d230 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -1,24 +1,25 @@
"""Shared HTTP client for sign-in calls and the startup warm-up."""
+import entrance
import httpx
import pytest
-from src import outnote_client
+from tests.services import http_clients
@pytest.mark.asyncio
async def test_post_reuses_one_shared_client_with_bounded_connect():
- first = outnote_client.shared_async_client()
- assert outnote_client.shared_async_client() is first
+ first = http_clients.shared_async_client()
+ assert http_clients.shared_async_client() is first
assert first.timeout.connect == 5.0 and first.timeout.read == 30.0
- await outnote_client.close_shared_client()
- assert outnote_client.shared_async_client() is not first
- await outnote_client.close_shared_client()
+ await http_clients.close_shared_client()
+ assert http_clients.shared_async_client() is not first
+ await http_clients.close_shared_client()
@pytest.mark.asyncio
async def test_warm_up_opens_login_and_graph_hosts_and_never_raises(monkeypatch):
seen = []
- monkeypatch.setattr(outnote_client.entrance, "warm_up", lambda urls: seen.extend(urls))
- await outnote_client.warm_up()
+ monkeypatch.setattr(entrance, "warm_up", lambda urls: seen.extend(urls))
+ await http_clients.warm_up()
assert any("login.microsoftonline.com" in u for u in seen) and any("graph.microsoft.com" in u for u in seen)
diff --git a/tests/test_mail.py b/tests/test_mail.py
index 2e50564..4398d37 100644
--- a/tests/test_mail.py
+++ b/tests/test_mail.py
@@ -6,8 +6,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, outnote_client
-from src.app import app
+from tests.services import config, outnote_client
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
MID = "AAMkAGI2=-_x"
diff --git a/tests/test_onenote.py b/tests/test_onenote.py
index dcd1b6e..7289de2 100644
--- a/tests/test_onenote.py
+++ b/tests/test_onenote.py
@@ -6,9 +6,9 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, onenote, onenote_html, outnote_client
-from src.account_cache import AccountCache
-from src.app import app
+from tests.services import ResourceTooLarge, config, onenote, onenote_html, outnote_client
+from src.infrastructure.microsoft.account_cache import AccountCache
+from src.api.app import app
GROUP = "3f2a9c10-1111-4222-8333-944455556666"
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
@@ -378,7 +378,7 @@ def test_resources_serve_only_images_inline_and_files_as_downloads(graph, monkey
def test_an_oversized_resource_is_refused(graph, monkeypatch):
def too_big(broker, rid, limit):
- raise onenote.ResourceTooLarge()
+ raise ResourceTooLarge()
monkeypatch.setattr(onenote, "resource", too_big)
assert client().get("/api/onenote/resources/0-a", headers=H).status_code == 413
diff --git a/tests/test_outnote2.py b/tests/test_outnote2.py
index fa1cac9..b500f36 100644
--- a/tests/test_outnote2.py
+++ b/tests/test_outnote2.py
@@ -13,9 +13,9 @@
from outnote import GraphError, OutNote
from outnote._native import message_from_native
-from src import chat_send, config, gifs, onenote, outnote_client, profile, teams_files
-from src.app import app
-from src.outnote_client import _BrokerCache
+from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, gifs, http_clients, onenote, outnote_client, profile, teams_files
+from src.api.app import app
+
from tests.conftest import REAL_FETCH
H = {config.REFRESH_TOKEN_HEADER: "frt-1"}
@@ -85,9 +85,9 @@ def server():
@pytest.fixture
def fresh_download_client(monkeypatch):
- monkeypatch.setattr(outnote_client, "_download_client", None)
+ monkeypatch.setattr(http_clients, "_download_client", None)
yield
- client = outnote_client._download_client
+ client = http_clients._download_client
if client is not None:
client.close()
@@ -124,7 +124,7 @@ class N:
def post(self, path, body=None):
return {"uploadUrl": server.url + "/session"}
- assert chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) == {"id": "BIG"}
+ assert chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) == {"id": "BIG"}
puts = [s for s in server.seen if s["method"] == "PUT"]
assert [len(p["body"]) for p in puts] == [chat_send.UPLOAD_CHUNK, size - chat_send.UPLOAD_CHUNK]
assert puts[1]["headers"]["Content-Range"] == f"bytes {chat_send.UPLOAD_CHUNK}-{size - 1}/{size}"
@@ -148,7 +148,7 @@ def failing(method, url, **kw):
client = type("C", (), {"request": staticmethod(failing)})()
monkeypatch.setattr(chat_send, "http_client", lambda: client)
with pytest.raises(RuntimeError):
- chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4"))
+ chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4"))
assert [s["method"] for s in server.seen] == ["DELETE"]
@@ -156,7 +156,7 @@ def failing(method, url, **kw):
def test_a_download_streams_chunk_by_chunk_instead_of_loading_the_file(server, fresh_download_client):
- stream = outnote_client.stream_download(server.url + "/slow", 10 * CHUNK)
+ stream = http_clients.stream_download(server.url + "/slow", 10 * CHUNK)
first = next(stream) # the server holds the rest back until this arrives: a buffering client would hang here
assert first and set(first) == {ord("a")} and len(first) <= CHUNK
server.got_first.set()
@@ -165,16 +165,16 @@ def test_a_download_streams_chunk_by_chunk_instead_of_loading_the_file(server, f
def test_a_download_stops_at_its_cap(server, fresh_download_client):
- got = b"".join(outnote_client.stream_download(server.url + "/big", CHUNK))
+ got = b"".join(http_clients.stream_download(server.url + "/big", CHUNK))
assert len(got) <= CHUNK
def test_fetch_bytes_refuses_past_its_cap_and_does_not_follow_redirects(server, fresh_download_client, monkeypatch):
monkeypatch.setattr(teams_files, "is_download_url", lambda url: True)
- with pytest.raises(teams_files.FileFetchError) as big:
+ with pytest.raises(FileFetchError) as big:
teams_files.fetch_bytes(server.url + "/big", CHUNK)
assert big.value.status == 413
- with pytest.raises(teams_files.FileFetchError) as moved:
+ with pytest.raises(FileFetchError) as moved:
teams_files.fetch_bytes(server.url + "/redirect", CHUNK)
assert moved.value.status == 502
assert not any(s["path"] == "/landed" for s in server.seen)
@@ -182,20 +182,20 @@ def test_fetch_bytes_refuses_past_its_cap_and_does_not_follow_redirects(server,
def test_stream_download_can_refuse_redirects(server, fresh_download_client):
with pytest.raises(Exception):
- list(outnote_client.stream_download(server.url + "/redirect", CHUNK, follow_redirects=False))
+ list(http_clients.stream_download(server.url + "/redirect", CHUNK, follow_redirects=False))
assert not any(s["path"] == "/landed" for s in server.seen)
def test_the_shared_download_client_keeps_no_cookies_between_accounts(server, fresh_download_client):
- list(outnote_client.stream_download(server.url + "/cookie", CHUNK))
- list(outnote_client.stream_download(server.url + "/cookie", CHUNK))
+ list(http_clients.stream_download(server.url + "/cookie", CHUNK))
+ list(http_clients.stream_download(server.url + "/cookie", CHUNK))
assert not any(s["headers"].get("Cookie") for s in server.seen)
- assert outnote_client.download_client() is outnote_client.download_client()
+ assert http_clients.download_client() is http_clients.download_client()
def test_the_onenote_resource_stays_capped_with_the_real_stream(server, fresh_download_client, monkeypatch):
monkeypatch.setattr(onenote, "GRAPH", server.url + "/big")
- with pytest.raises(onenote.ResourceTooLarge):
+ with pytest.raises(ResourceTooLarge):
onenote.resource(lambda client: "bearer", "1-res", CHUNK)
assert server.seen[0]["headers"]["Authorization"] == "Bearer bearer"
@@ -342,8 +342,8 @@ def batch(self, requests):
def test_strip_native_walks_tuples_and_leaves_other_values_alone():
item, data = {"_native": 1, "a": 2}, b"bytes"
- assert outnote_client.strip_native((item, data)) == ({"a": 2}, b"bytes")
- assert outnote_client.strip_native(None) is None
+ assert AccountRunner.strip_native((item, data)) == ({"a": 2}, b"bytes")
+ assert AccountRunner.strip_native(None) is None
# --- client reuse --------------------------------------------------------------------------------
diff --git a/tests/test_password_signin.py b/tests/test_password_signin.py
index ed2ca37..e1c4eef 100644
--- a/tests/test_password_signin.py
+++ b/tests/test_password_signin.py
@@ -11,8 +11,8 @@
from entrance import MfaCancelled, MfaSwitch, PushEvent
from fastapi.testclient import TestClient
-from src import outnote_client, password_signin
-from src.app import app
+from tests.services import SignInJob, outnote_client, sign_in_options, sign_in_store
+from src.api.app import app
PASSWORD = "Tr0ub4dor&3-never-leak-me"
USER = "alex@school.ch"
@@ -37,10 +37,10 @@ def __init__(self, refresh_token="frt-new"):
@pytest.fixture(autouse=True)
def clean(monkeypatch):
monkeypatch.setenv("SCHULY_SERVICE_KEY", KEY)
- password_signin.store.clear()
+ sign_in_store.clear()
monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), FakeBroker(frt)))
yield
- password_signin.store.clear()
+ sign_in_store.clear()
@pytest.fixture
@@ -58,7 +58,7 @@ def login(username, secret, **kw):
del submitted
return script(kw["mfa"])
- monkeypatch.setattr(password_signin.entrance, "foci_login", login)
+ monkeypatch.setattr(entrance, "foci_login", login)
def drive(prompt, offered, push=None, code=None, wrong=0):
@@ -255,7 +255,7 @@ def fail(prompt):
scripted(monkeypatch, fail)
body = start(api).json()
assert body["status"] == "error" and body["code"] == code
- assert body["message"] == password_signin.MESSAGES[code]
+ assert body["message"] == SignInJob.MESSAGES[code]
assert "refresh_token" not in body
@@ -285,23 +285,23 @@ def test_expired_job_is_gone(api, monkeypatch):
approve = threading.Event()
scripted(monkeypatch, lambda p: drive(p, BOTH, push=push_wait(approve)))
sid = start(api).json()["sign_in_id"]
- job = password_signin.store.get(sid, UID)
+ job = sign_in_store.get(sid, UID)
job.expires = time.monotonic() - 1
assert api.get(f"/api/auth/password/{sid}").status_code == 404
- assert len(password_signin.store) == 0
+ assert len(sign_in_store) == 0
def test_sweeper_drops_expired_and_stale_finished_jobs(api, monkeypatch):
scripted(monkeypatch, lambda p: FakeBroker())
sid = start(api).json()["sign_in_id"]
- assert len(password_signin.store) == 1
- password_signin.store.get(sid, UID).done_at = time.monotonic() - password_signin.DONE_TTL - 1
- password_signin.store.sweep()
- assert len(password_signin.store) == 0
+ assert len(sign_in_store) == 1
+ sign_in_store.get(sid, UID).done_at = time.monotonic() - sign_in_options.done_ttl - 1
+ sign_in_store.sweep()
+ assert len(sign_in_store) == 0
def test_ttl_is_capped_at_ten_minutes():
- assert password_signin.JOB_TTL <= 600
+ assert sign_in_options.job_ttl <= 600
def test_delete_cancels_the_worker(api, monkeypatch):
@@ -363,7 +363,7 @@ def test_malformed_code_is_rejected(api, monkeypatch):
# --- rate limits -------------------------------------------------------------------------------------
def test_per_client_rate_limit(api, monkeypatch):
- monkeypatch.setattr(password_signin, "CLIENT_LIMIT", 2)
+ monkeypatch.setattr(sign_in_options, "client_limit", 2)
scripted(monkeypatch, lambda p: FakeBroker())
assert start(api, username="a@school.ch").status_code == 200
assert start(api, username="b@school.ch").status_code == 200
@@ -372,7 +372,7 @@ def test_per_client_rate_limit(api, monkeypatch):
def test_only_failed_attempts_count_against_the_account_limit(api, monkeypatch):
- monkeypatch.setattr(password_signin, "ACCOUNT_LIMIT", 2)
+ monkeypatch.setattr(sign_in_options, "account_limit", 2)
scripted(monkeypatch, lambda p: FakeBroker())
for _ in range(4): # successes never count
assert start(api).json()["status"] == "complete"
@@ -389,7 +389,7 @@ def wrong(prompt):
def test_a_third_party_cannot_lock_a_victim_out(api, monkeypatch):
- monkeypatch.setattr(password_signin, "ACCOUNT_LIMIT", 2)
+ monkeypatch.setattr(sign_in_options, "account_limit", 2)
def wrong(prompt):
raise entrance.InvalidCredentials("no")
@@ -404,7 +404,7 @@ def wrong(prompt):
def test_max_concurrent_jobs(api, monkeypatch):
- monkeypatch.setattr(password_signin, "MAX_JOBS", 1)
+ monkeypatch.setattr(sign_in_options, "max_jobs", 1)
scripted(monkeypatch, lambda p: drive(p, BOTH))
assert start(api, username="a@school.ch").json()["status"] == "mfa_required"
r = start(api, username="b@school.ch")
@@ -447,13 +447,13 @@ def test_password_is_not_in_the_job_store_at_any_stage(api, monkeypatch):
approve = threading.Event()
scripted(monkeypatch, lambda p: drive(p, BOTH, push=push_wait(approve)))
sid = start(api).json()["sign_in_id"]
- assert not reaches(password_signin.store, PASSWORD)
+ assert not reaches(sign_in_store, PASSWORD)
api.post(f"/api/auth/password/{sid}/method", json={"method": "push"})
- assert not reaches(password_signin.store, PASSWORD)
+ assert not reaches(sign_in_store, PASSWORD)
approve.set()
until(api, sid, "complete")
- assert not reaches(password_signin.store, PASSWORD)
- assert not reaches(password_signin.store._jobs[sid], PASSWORD)
+ assert not reaches(sign_in_store, PASSWORD)
+ assert not reaches(sign_in_store._jobs[sid], PASSWORD)
def test_reaches_helper_actually_finds_strings():
@@ -488,7 +488,7 @@ def script(prompt):
for text in seen:
assert PASSWORD not in text
assert PASSWORD not in caplog.text
- assert not reaches(password_signin.store, PASSWORD)
+ assert not reaches(sign_in_store, PASSWORD)
def test_validation_errors_do_not_echo_the_password(api):
@@ -507,7 +507,7 @@ def test_other_routes_keep_the_default_validation_body(api):
def test_password_model_never_reprs_the_password():
- from src.models import PasswordStartRequest
+ from src.application.dtos.password_start_request import PasswordStartRequest
model = PasswordStartRequest(username=USER, password=PASSWORD)
assert PASSWORD not in repr(model) and PASSWORD not in model.model_dump_json()
@@ -521,7 +521,7 @@ def login(username, secret, **kw):
spent.append(secret)
return FakeBroker()
- monkeypatch.setattr(password_signin.entrance, "foci_login", login)
+ monkeypatch.setattr(entrance, "foci_login", login)
assert start(api).json()["status"] == "complete"
assert spent and not spent[0] and PASSWORD not in repr(spent[0])
@@ -599,7 +599,7 @@ def get(self, url, **kw):
_MsSession.gate = threading.Event()
real_sleep = time.sleep
monkeypatch.setattr(ep.time, "sleep", lambda s: real_sleep(0.005))
- monkeypatch.setattr(password_signin, "PUSH_INTERVAL", 0)
+ monkeypatch.setattr(sign_in_options, "push_interval", 0)
monkeypatch.setattr(ef, "_post_token", lambda url, data: {"refresh_token": "real-frt", "access_token": "at"})
@@ -612,7 +612,7 @@ def test_real_entrance_push(api, real_entrance):
_MsSession.gate.set()
done = until(api, sid, "complete")
assert done["refresh_token"] == "real-frt" and done["account"]["email"] == USER
- assert not reaches(password_signin.store, PASSWORD)
+ assert not reaches(sign_in_store, PASSWORD)
def test_real_entrance_otp_wrong_then_ok(api, real_entrance):
@@ -644,7 +644,7 @@ def test_password_routes_need_the_service_key_and_a_user_id(monkeypatch, headers
assert bare.delete("/api/auth/password/x", headers=headers).status_code == 401
assert bare.post("/api/auth/password/x/method", json={"method": "push"}, headers=headers).status_code == 401
assert bare.post("/api/auth/password/x/otp", json={"code": "123456"}, headers=headers).status_code == 401
- assert len(password_signin.store) == 0
+ assert len(sign_in_store) == 0
def test_routes_are_disabled_when_no_service_key_is_configured(monkeypatch):
@@ -710,7 +710,7 @@ def test_otp_budget_per_job_cannot_be_reset_by_rechoosing(api, monkeypatch):
def test_otp_budget_per_account_holds_across_jobs(api, monkeypatch):
- monkeypatch.setattr(password_signin, "OTP_ACCOUNT_BUDGET", 4)
+ monkeypatch.setattr(sign_in_options, "otp_account_budget", 4)
scripted(monkeypatch, lambda p: drive(p, BOTH, code="654321"))
sid = start(api).json()["sign_in_id"]
api.post(f"/api/auth/password/{sid}/method", json={"method": "otp"})
diff --git a/tests/test_path_ids.py b/tests/test_path_ids.py
index fdf0770..e6ce85d 100644
--- a/tests/test_path_ids.py
+++ b/tests/test_path_ids.py
@@ -3,8 +3,8 @@
import pytest
from fastapi.testclient import TestClient
-from src import config, outnote_client
-from src.app import app
+from tests.services import config, outnote_client
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
BAD = ["..", ".", "a..b", "%2E%2E", "%252E%252E"]
@@ -30,11 +30,11 @@ class Broker:
@pytest.mark.parametrize("bad", BAD)
def test_the_shared_validator_refuses_dot_segments(bad):
- from fastapi import HTTPException
- from src.app import GRAPH_ID_PATTERN, _check_id
+ from src.domain.errors.service_error import ServiceError as HTTPException
+ from src.domain.validation.graph_ids import GraphIds
for value in (bad, f"a/{bad}/b"):
with pytest.raises(HTTPException):
- _check_id(value, GRAPH_ID_PATTERN, "id")
+ GraphIds.check(value, GraphIds.GRAPH_ID_PATTERN, "id")
# A literal "." or ".." in a URL path is collapsed by the HTTP client before it is sent, so the routes are probed with the encoded and embedded forms.
diff --git a/tests/test_people_photos.py b/tests/test_people_photos.py
index 5169ee1..7b41bf4 100644
--- a/tests/test_people_photos.py
+++ b/tests/test_people_photos.py
@@ -4,8 +4,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, outnote_client
-from src.app import app
+from tests.services import config, outnote_client
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
JPEG = bytes([0xFF, 0xD8, 0xFF]) + b"photo"
diff --git a/tests/test_profile.py b/tests/test_profile.py
index 5c2d991..eb7cadd 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -8,7 +8,7 @@
import wreq.exceptions
import pytest
-from src import profile
+from tests.services import profile
from tests.conftest import REAL_FETCH
diff --git a/tests/test_reads.py b/tests/test_reads.py
index d1fcb1f..504439f 100644
--- a/tests/test_reads.py
+++ b/tests/test_reads.py
@@ -4,9 +4,9 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, outnote_client
-from src import app as app_module
-from src.app import app
+from tests.services import config, outnote_client
+
+from src.api.app import app
class FakeNote:
diff --git a/tests/test_search.py b/tests/test_search.py
index 0ffedac..2d58919 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -4,8 +4,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import config, outnote_client, search
-from src.app import app
+from tests.services import config, outnote_client, search
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
TEAM = "11111111-2222-3333-4444-555555555555"
diff --git a/tests/test_teams_extras.py b/tests/test_teams_extras.py
index 76d3c17..322f621 100644
--- a/tests/test_teams_extras.py
+++ b/tests/test_teams_extras.py
@@ -7,8 +7,8 @@
from fastapi.testclient import TestClient
from outnote import GraphError
-from src import account_cache, chat_html, chat_send, config, folder_roles, outnote_client, teams_chat
-from src.app import app
+from tests.services import OutgoingMessage, SendError, Upload, account_cache, chat_html, chat_send, config, folder_roles, http_clients, outnote_client, teams_chat
+from src.api.app import app
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 20
@@ -158,7 +158,7 @@ def stream(self, method, url, headers=None, timeout=None, follow_redirects=None)
def http(monkeypatch):
fake = FakeHttp()
monkeypatch.setattr(teams_chat, "http_client", lambda: fake)
- monkeypatch.setattr(outnote_client, "download_client", lambda: fake)
+ monkeypatch.setattr(http_clients, "download_client", lambda: fake)
return fake
@@ -318,7 +318,7 @@ def post(self, path, body=None):
assert path.endswith(":/createUploadSession")
return {"uploadUrl": "https://upload.example/session"}
- item = chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4"))
+ item = chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4"))
assert item == {"id": "BIG"}
assert all(n % (320 * 1024) == 0 for n, _ in http.puts[:-1])
assert http.puts[-1][1] == f"bytes {chat_send.UPLOAD_CHUNK}-{size - 1}/{size}"
@@ -326,8 +326,8 @@ def post(self, path, body=None):
def test_files_over_100_mb_are_refused():
import io
- with pytest.raises(chat_send.SendError) as ex:
- chat_send.validate(chat_send.OutgoingMessage(content="x", files=[chat_send.Upload("a", io.BytesIO(), chat_send.MAX_FILE_BYTES + 1)]))
+ with pytest.raises(SendError) as ex:
+ chat_send.validate(OutgoingMessage(content="x", files=[Upload("a", io.BytesIO(), chat_send.MAX_FILE_BYTES + 1)]))
assert ex.value.status == 413
@@ -358,7 +358,7 @@ def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch):
def test_gif_search_is_rate_limited_per_account(env, monkeypatch):
- from src import gifs
+ from tests.services import gifs
monkeypatch.setenv("GIPHY_API_KEY", "k")
monkeypatch.setattr(gifs, "_search", lambda key, query, limit: [])
client = TestClient(app)
@@ -367,7 +367,7 @@ def test_gif_search_is_rate_limited_per_account(env, monkeypatch):
def test_gif_search_returns_only_giphy_urls(env, monkeypatch):
- from src import gifs
+ from tests.services import gifs
calls = []
class Http:
@@ -400,7 +400,7 @@ def test_account_cache_expires_and_is_bounded(monkeypatch):
def test_no_response_carrying_a_token_is_cacheable(env, http, monkeypatch):
"""Every route that can hand back a rotated refresh token must forbid storing the response."""
- from src import gifs
+ from tests.services import gifs
note, _ = env
monkeypatch.setenv("GIPHY_API_KEY", "k")
monkeypatch.setattr(gifs, "_search", lambda key, query, limit: [])
@@ -455,7 +455,7 @@ def test_oversized_sends_are_refused_before_parsing(env):
r = client.post("/api/chats/c1/messages", headers={**H, "Content-Length": str(200 * 1024 * 1024), "Content-Type": "multipart/form-data; boundary=x"}, content=b"--x--")
assert r.status_code == 413
big = b"--x\r\nContent-Disposition: form-data; name=\"files\"; filename=\"a.bin\"\r\n\r\n" + b"0" * (300 * 1024) + b"\r\n--x--\r\n"
- import src.app as app_module
+ from tests.services import app_module
old = app_module.MAX_SEND_BYTES
app_module.MAX_SEND_BYTES = 100 * 1024
try:
diff --git a/tests/test_teams_files.py b/tests/test_teams_files.py
index dd7e115..4de7dfc 100644
--- a/tests/test_teams_files.py
+++ b/tests/test_teams_files.py
@@ -7,8 +7,8 @@
from fastapi.testclient import TestClient
from outnote import C_SITES, GraphError
-from src import config, file_text, outnote_client, teams_files
-from src.app import app
+from tests.services import config, http_clients, outnote_client, teams_files
+from src.api.app import app
from tests.test_file_text import docx_bytes
H = {config.REFRESH_TOKEN_HEADER: "old-frt"}
@@ -104,7 +104,7 @@ def env(monkeypatch):
note = Note(broker)
http = Http()
monkeypatch.setattr(outnote_client, "build_client", lambda frt: (note, broker))
- monkeypatch.setattr(outnote_client, "download_client", lambda: http)
+ monkeypatch.setattr(http_clients, "download_client", lambda: http)
return note, http, TestClient(app)
@@ -251,7 +251,7 @@ def test_text_route_converts_legacy_through_pdf(env, monkeypatch):
note, http, client = env
from tests.test_file_text import pdf_bytes
import io
- from src import file_pdf
+ from tests.services import file_pdf
note.item = {"id": "I", "name": "old.doc", "size": 5, "file": {}}
monkeypatch.setattr(file_pdf, "drive_item_pdf", lambda n, item_id, drive_id=None: io.BytesIO(pdf_bytes(1)))
@@ -273,7 +273,7 @@ def test_convert_text_raw_bytes(env):
def test_convert_text_body_cap(env, monkeypatch):
_, _, client = env
- import src.app as app_module
+ from tests.services import app_module
monkeypatch.setattr(app_module, "MAX_TEXT_SOURCE_BYTES", 100)
assert client.post("/api/convert/text?name=x.txt", content=b"a" * 101, headers=H).status_code == 413
@@ -291,7 +291,7 @@ def get(self, path):
def redirect(self, path):
self.paths.append(path)
if self.location is None:
- raise teams_files.GraphError(200, "expected a redirect, got 200")
+ raise GraphError(200, "expected a redirect, got 200")
return self.location
From 4cffc526a83649f6a74f55318921024d9e814bab Mon Sep 17 00:00:00 2001
From: PianoNic <79938743+Pianonic@users.noreply.github.com>
Date: Tue, 3 Nov 2026 14:02:56 +0100
Subject: [PATCH 5/5] Documented the onion architecture and the new layout
---
.claude/CLAUDE.md | 94 +++++++++++++++++++++++++--------------
README.md | 11 +++++
docs/setup/development.md | 1 +
3 files changed, 72 insertions(+), 34 deletions(-)
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 124b772..2c91be5 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -15,7 +15,7 @@ The Schuly **Microsoft plugin** is the only caller.
once for the push/TOTP sign-in, holds it only in memory for that call, and never stores, caches,
logs or returns it. Every other route still never sees a password. Do **not** add any other
password/credential endpoints.
-- Password sign-in jobs (`src/password_signin.py`) are **in memory only** (TTL <= 10 min, sweeper,
+- Password sign-in jobs (`src/infrastructure/password_signin/`) are **in memory only** (TTL <= 10 min, sweeper,
max concurrent jobs, per-client and per-account rate limits). They hold progress and the result,
never the password, so the service **must run as ONE process** (`asgi.py` pins `workers=1`; do not
add uvicorn/gunicorn workers or replicas). Keep the password out of logs, job objects, errors
@@ -23,30 +23,30 @@ The Schuly **Microsoft plugin** is the only caller.
- Uses only the **public** OutNote/Entrance API (`outnote.OutNote`, `entrance.Broker`,
`entrance.http_client`) plus the documented OAuth calls for the browser and device flows. Don't
reach into their private modules. Needs Python 3.11 or newer. The one non-Graph surface is the Teams chat service for group
- chat pictures (`teams_chat.py`): a Teams-client token for `api.spaces.skype.com` is traded at
+ chat pictures (`TeamsChatClient`): a Teams-client token for `api.spaces.skype.com` is traded at
`teams.microsoft.com/api/authsvc/v1.0/authz` for a skype token.
## OutNote / Entrance 2.x
- `entrance.http_client()` offers only `request`, `post`, `head` and `close`, takes a float timeout
and reads every body into memory. Anything that must stay capped or be streamed uses
- `outnote_client.download_client()` (httpx, HTTP/1.1, no redirects by default, no cookies kept,
+ `IMicrosoftHttpClients.download_client()` (httpx, HTTP/1.1, no redirects by default, no cookies kept,
because all accounts share it). Don't call `note.download` or `note.download_shared`: they hold the
whole file with no cap and no host check.
- `except entrance.HTTPError` works alone, but it is a tuple: unpack it next to other types.
-- `teams_files.download_url` stays: it serves the `shares/...` path chat files need, which OutNote's
+- `TeamsFiles.download_url` stays: it serves the `shares/...` path chat files need, which OutNote's
`download_url` can't. A folder is a 404 that says so; Graph's refusal at `/content` keeps its own
status (403, 404, 415, 429).
- One `OutNote` per account lives on its broker. A throttled call raises `GraphError` 429 after about
- 60 s, which `_read` answers as 429 with `Retry-After` (and any rotated token).
-- `_read` strips `_native` from every result. No route calls OutNote's native reads today: chat
- messages stay on Graph's list because `chat_read` and the callers need its attachments, mentions and
+ 60 s, which `AccountRunner` answers as 429 with `Retry-After` (and any rotated token).
+- `AccountRunner` strips `_native` from every result. No route calls OutNote's native reads today: chat
+ messages stay on Graph's list because `ChatReader` and the callers need its attachments, mentions and
reactions. `emails()` gets an explicit `$select` because the web's list shows `toRecipients`.
- Never add `SQLiteCache` or any other on-disk cache. `stream()` cursors would have to live in the plugin.
## Cold start
-- Read `/me` only through `profile.get`: it asks `/me` once without OutNote's retries (Graph 429s it with ~21 s Retry-After) and falls back to the access token's claims (oid, name, upn).
+- Read `/me` only through `IProfileReader.get`: it asks `/me` once without OutNote's retries (Graph 429s it with ~21 s Retry-After) and falls back to the access token's claims (oid, name, upn).
- `GET /health/live` is always 200; `GET /health/ready` is 503 until the startup warm-up finished or 15 s passed
(the Dockerfile `HEALTHCHECK` uses it).
@@ -56,36 +56,62 @@ The Schuly **Microsoft plugin** is the only caller.
across multiple lines, however long. Call sites, comprehensions and multi-line `if`/`for`
stay as they are.
+## Architecture
+
+Onion clean architecture with CQRS through [mediatorx](https://pypi.org/project/mediatorx/), in SchulwareAPI's style and with SchulyServer's ideas.
+**Dependencies only point inward**; `tests/test_architecture.py` parses every import under `src/` and fails when a file breaks this.
+
+- `domain`: pure types, errors and validators. Imports nothing from the other rings and no framework.
+- `application`: commands, queries, DTOs, options, services, and the ABCs it needs from outside (`abstractions/`). Imports only `domain` (plus `mediatorx` and `pydantic`), never FastAPI, OutNote, Entrance or httpx.
+- `infrastructure`: implements those ABCs over OutNote, Entrance, Graph, Teams, Giphy and the file libraries. Never imports `api`.
+- `api`: controllers, middleware and the composition root. Only `src/api/extensions/` may name concrete infrastructure classes.
+
+Conventions:
+
+- **Controllers are thin**: `@controller(router)` classes with `mediator: Mediator = Depends(get_mediator)` that translate HTTP and call `self.mediator.send(...)`. Route method names are the operation ids, so keep them; `OpenApiExtensions` strips the class name the decorator adds.
+- **A command or query** is a dataclass paired with its handler in one file, one file per operation. A refresh token field is `field(repr=False)`, and so is anything else secret.
+- **Constructor injection only**: nothing is reached through a module-level singleton. `ServiceCollection` holds the lifetimes (`add_singleton`, `add_scoped`, `add_handler`), one `Extensions` class per area, called from `Program` in `src/api/app.py`. Handlers are scoped; everything with state is a singleton: `JobStore`, `OutNoteClientFactory` (the per-account broker cache), `MicrosoftHttpClients`, `EmojiCatalogue`, `GifSearch`, `WarmUpTracker`.
+- **Refusals**: raise `ServiceError(status, detail, headers)` or a subclass from anywhere; `ExceptionHandlingExtensions` is the one place that makes a response from it. `SignInRefused` is the password routes' shape, and they alone get the stripped 422.
+- **Token rotation**: `AccountRunner` records the rotated token through `ITokenRotation`; `TokenRotationMiddleware` puts it on the response. It records on success, on a `ServiceError` raised by the action and on a 429, and not on a plain 401, 404 or 502. mediatorx has pipeline behaviors, but a behavior can't see the broker.
+
+To add a route: a query or command with its handler in `src/application/`, its registration in the area's `src/api/extensions/_extensions.py`, and a method on `src/api/controllers/_controller.py`. A streaming answer returns a `FileStream` and the controller answers `FileResponses.stream(...)`.
+
## Layout
```
-asgi.py # entrypoint (uvicorn on :8010)
+asgi.py # entrypoint (uvicorn on :8010)
src/
- app.py # FastAPI app + routes
- config.py # client ids, scopes, endpoints
- browser_auth.py # browser sign-in (authorize URL + PKCE, code exchange)
- password_signin.py # password sign-in jobs (worker thread per job, MfaPrompt bridge, rate limits)
- outnote_client.py # thin OutNote/Entrance wrapper (device start/poll, build_client)
- models.py # pydantic DTOs
- account_cache.py # per-account TTL cache living on the shared broker
- profile.py # the user's /me profile: one try, token claims on 429; cached 1 h (claims 5 min)
- folder_roles.py # well-known mail folder names via one $batch
- teams_chat.py # group chat pictures over the Teams chat service (skype token, AMS)
- chat_read.py # chat messages + media/files/cards for the web
- chat_html.py # composer HTML allowlist sanitizer, body media/card extraction
- chat_send.py # rich sends: hosted images, GIFs, mentions, OneDrive file shares
- people_photos.py # profile photos by e-mail (mail senders), cached per account
- emojis.py # Teams emoji catalogue (public CDN, 24 h cache), message emoji parsing, send allowlist
- file_thumbs.py # file kinds, SharePoint/OneDrive host check, chat file and drive thumbnails
- file_pdf.py # Office files as PDF (Graph format=pdf); temp app-folder upload + delete + hourly sweep, converted-host allowlist
- teams_files.py # joined teams, channels, team/channel library browsing (drive id via C_SITES), drive items, shared/recent files, download host check
- search.py # Search API (files / mail / chats / channel posts) with hit locations, per-drive fallback, per-source errors; never logs the query
- file_text.py # text out of docx/xlsx/pptx/pdf/text (lazy imports, zip-bomb guard, caps, paging)
- channel_messages.py # Teams channel posts and replies, normalized (html to text)
- gifs.py # Giphy search (needs GIPHY_API_KEY; 503 without)
- onenote.py # OneNote over Graph: notebooks (own + teams), section tree, pages, search, writes, resource stream
- onenote_html.py # OneNote page HTML allowlist sanitizer (read and write), text-box editability, InkML to SVG
-tests/ # pytest, fully mocked (no network)
+ api/
+ app.py # Program: the composition root and the request pipeline
+ controller.py # class-based controller decorator (vendored, MIT, from SchulwareAPI)
+ dependencies.py # get_mediator, the password routes' service key and user id gate
+ http_headers.py # header names
+ controllers/ # one @controller class per area
+ extensions/ # Extensions (registration per area), exception handling, OpenAPI names
+ di/ # ServiceCollection, ServiceProvider (mediatorx's resolver)
+ middleware/ # TokenRotationMiddleware
+ token_rotation/ # ContextTokenRotation: the per-request slot behind ITokenRotation
+ request_bodies/ # capped raw upload body, chat send body (JSON or multipart)
+ responses/ # FileResponses: download, stream, inline PDF and image headers
+ application/
+ abstractions/ # I*: what the application needs from outside
+ commands/ queries/ # one dataclass + handler per operation
+ dtos/ # pydantic request and response models, one per file
+ options/ # MicrosoftOptions, ServiceAuthOptions, PasswordSignInOptions, GiphyOptions, EmojiOptions
+ services/ # ChatHtmlSanitizer, OneNoteHtmlSanitizer, MailMessageBuilder, DriveFileTextReader, PasswordSignInResponder, ApplicationLifetime, WarmUpTracker, ...
+ domain/
+ errors/ # ServiceError and its subclasses, SignInRefused, RateLimited, ...
+ validation/ # GraphIds (the one id validator), RefreshTokenFormat
+ files/ chat/ sign_in/ # limits, file results, outgoing message, emoji catalogue, sign-in states
+ infrastructure/
+ logging_config.py # logger names
+ microsoft/ # AccountRunner, OutNoteClientFactory, BrokerCache, SharedBroker, AccountCache, MicrosoftHttpClients, DeviceLoginClient, BrowserSignInClient, ProfileReader
+ graph/ # TeamsFiles, GraphSearch, OneNoteClient, OneNoteRunner, PdfConverter, FileThumbnails, ChannelMessages, PeoplePhotos, MailFolderRoles
+ teams/ # TeamsChatClient (skype token, AMS), ChatReader, ChatSender, EmojiCatalogue
+ giphy/ # GifSearch (needs GIPHY_API_KEY; 503 without)
+ files/ # FileTextExtractor (lazy imports, zip-bomb guard, caps, paging)
+ password_signin/ # JobStore, SignInJob (worker thread per job, MfaPrompt bridge), RateLimiter
+tests/ # pytest, fully mocked (no network); services.py resolves the singletons the tests patch
```
## Run / test
diff --git a/README.md b/README.md
index 059859c..2b8bd53 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,17 @@ seconds, after waiting about 60 seconds itself. A rotated refresh token still co
On the file routes, a folder is a `404` that says so, and a file Microsoft refuses to serve answers
`403`, `404` or `415`.
+## Architecture
+
+Onion clean architecture with CQRS through `mediatorx`; dependencies only point inward, and a test enforces it.
+
+| Ring | Holds |
+|---|---|
+| `src/domain` | Pure types, errors and validators |
+| `src/application` | Commands and queries with their handlers, DTOs, options, services, and the abstractions it needs from outside |
+| `src/infrastructure` | OutNote, Entrance, Graph, Teams, Giphy and file parsing, implementing those abstractions |
+| `src/api` | Controllers, middleware and the composition root (`src/api/app.py`, `src/api/extensions`) |
+
## Run
Needs Python 3.11 or newer. The Docker image uses 3.13.
diff --git a/docs/setup/development.md b/docs/setup/development.md
index afe4edd..bf04489 100644
--- a/docs/setup/development.md
+++ b/docs/setup/development.md
@@ -34,5 +34,6 @@ python -m pytest tests -q
- Keep passwords out of logs, sign-in jobs, errors, and responses.
- Keep the service to one process. Don't add workers or replicas.
- Use only the public OutNote and Entrance APIs.
+- Keep dependencies pointing inward: `domain`, then `application`, then `infrastructure` and `api`. `tests/test_architecture.py` fails otherwise.
For the workflow, see [Contribute](/contribute/).