184 lines
6.0 KiB
Python
184 lines
6.0 KiB
Python
"""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
|