"""The drawing lookup the job talks to: M-Files through MFilesKit, or the fixture files (fake.py). One M-Files `Vault` per worker process (each sign-in can use a licence), kept in the dict the scheduler passes (`JobContext.shared`) or, when there is none, in this module's own cache keyed by MFILES_URL. A new `DrawingLookup` per run, because its result cache lives on the instance. Read-only. Nothing here writes to M-Files. """ from __future__ import annotations import logging import os import threading from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol if TYPE_CHECKING: from mfileskit.drawings import DrawingStatus from mfileskit.vault import Vault from ..settings import Settings log = logging.getLogger(__name__) VAULT_KEY = "mfiles_vault" NOTE_MAX = 500 class DrawingFetchError(LookupError): """A download did not work. The message is safe to log (no credentials).""" @dataclass(slots=True) class Found: """What M-Files has for one drawing number: the subset the decision needs, plus identity for change detection.""" dwg: str ok: bool = True rev: int | None = None name: str | None = None obj_id: int | None = None obj_version: int | None = None file_id: int | None = None file_version: int | None = None file_size: int | None = None base: str | None = None draft_rev: int | None = None draft_where: str = "" in_drafting: bool = False note: str = "" @property def identity(self) -> tuple[int | None, int | None, int | None, int | None]: return (self.obj_id, self.obj_version, self.file_id, self.file_version) def failed(dwg: str, note: str) -> Found: return Found(dwg=dwg, ok=False, note=note[:NOTE_MAX]) class Lookup(Protocol): def status(self, dwg: str) -> Found: ... def download(self, found: Found, dest: Path) -> int: """Write the released PDF to `dest`; bytes written. Raises DrawingFetchError (a LookupError).""" ... def describe(self) -> str: ... def from_status(st: DrawingStatus) -> Found: """MFilesKit's DrawingStatus -> Found.""" ov, fi = st.obj_ver, st.file return Found( dwg=st.drawing, ok=bool(st.ok), rev=st.rev, name=st.name, obj_id=ov.id if ov is not None else None, obj_version=ov.version if ov is not None else None, file_id=fi.id if fi is not None else None, file_version=fi.version if fi is not None else None, file_size=fi.size if fi is not None else None, base=st.base, draft_rev=st.draft_rev, draft_where=st.draft_where or "", in_drafting=bool(st.in_drafting), note=(st.note or "")[:NOTE_MAX], ) class MFilesLookup: """M-Files through `mfileskit.drawings.DrawingLookup`. Read-only.""" def __init__(self, vault: Vault) -> None: from mfileskit.drawings import DrawingLookup self.vault = vault self._lookup = DrawingLookup(vault) # reads the vault schema; raises MFilesError when the vault is down self._seen: dict[str, DrawingStatus] = {} def status(self, dwg: str) -> Found: from mfileskit.errors import MFilesError dwg = (dwg or "").strip() try: st = self._lookup.status(dwg) except MFilesError as e: # status() catches these itself; this is a second net return failed(dwg, f"M-Files lookup failed: {e}") self._seen[dwg] = st return from_status(st) def download(self, found: Found, dest: Path) -> int: from mfileskit.errors import MFilesError st = self._seen.get(found.dwg) if st is None or st.obj_ver is None or st.file is None: raise DrawingFetchError(f"{found.dwg}: no released file to download") try: return int(self._lookup.download(st, dest)) except MFilesError as e: raise DrawingFetchError(f"{found.dwg}: download failed: {e}"[:NOTE_MAX]) from None def describe(self) -> str: url = getattr(getattr(self.vault, "settings", None), "url", "") or "" return f"M-Files {url}".strip() # ---------------------------------------------------------------- one vault per process _LOCK = threading.Lock() _CACHE: dict[str, Any] = {} def _cache_for(cache: dict[str, Any] | None) -> tuple[dict[str, Any], str]: if cache is not None: return cache, VAULT_KEY return _CACHE, f"{VAULT_KEY}:{os.environ.get('MFILES_URL', '')}" def vault_for(settings: Settings, cache: dict[str, Any] | None = None) -> Vault: """The process's Vault, made once with `Vault.from_env()` (MFILES_* from the environment; the entrypoint exports MFILES_PASSWORD from its secret file) and signed in.""" from mfileskit.vault import Vault store, key = _cache_for(cache) with _LOCK: vault = store.get(key) if vault is None: vault = Vault.from_env().connect() store[key] = vault return vault def forget_vault(cache: dict[str, Any] | None = None) -> None: """Drop (and close) the cached Vault, so the next run signs in again.""" store, key = _cache_for(cache) with _LOCK: vault = store.pop(key, None) if vault is not None: try: vault.close() except Exception as e: # closing a broken connection may fail; nothing to do log.info("closing the M-Files vault failed: %s", type(e).__name__) def make(settings: Settings, cache: dict[str, Any] | None = None) -> Lookup: """The lookup for one run. `drawing_source = fixture` reads `fixture_dir`; otherwise M-Files. Raises when M-Files cannot be opened (the job then records a degraded run). A failed open drops the cached Vault so the next run tries a fresh sign-in. """ if getattr(settings, "drawing_source", "mfiles") == "fixture": from .fake import FixtureLookup return FixtureLookup(Path(settings.fixture_dir)) try: return MFilesLookup(vault_for(settings, cache)) except Exception: forget_vault(cache) raise