Files
PacketStation/server/packetstation/drawings/lookup.py
T

184 lines
6.0 KiB
Python
Raw Normal View History

"""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