2026-10-16 14:45:38 -06:00
|
|
|
"""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:
|
2026-10-19 13:36:42 -06:00
|
|
|
from mfileskit.drawings import DrawingStatus, RevisionFile
|
2026-10-16 14:45:38 -06:00
|
|
|
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 = ""
|
2026-10-19 13:36:42 -06:00
|
|
|
# Set only on a `Lookup.revision` result: M-Files' Controlled Status for that rev ("Effective", "Retired") and
|
|
|
|
|
# its workflow state ("Superseded").
|
|
|
|
|
controlled: str = ""
|
|
|
|
|
state: str = ""
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def effective(self) -> bool:
|
|
|
|
|
return self.controlled.casefold() == "effective"
|
2026-10-16 14:45:38 -06:00
|
|
|
|
|
|
|
|
@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: ...
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def revision(self, dwg: str, rev: int) -> Found | None:
|
|
|
|
|
"""One rev's file whatever its status (a Retired rev VISUAL still calls), with `controlled` set; None when
|
|
|
|
|
M-Files has no document at that rev. Raises LookupError when M-Files can't be read."""
|
|
|
|
|
...
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
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],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def from_revision(rf: RevisionFile) -> Found:
|
|
|
|
|
"""MFilesKit's RevisionFile -> Found, with `controlled` and `state` set."""
|
|
|
|
|
ov, fi = rf.obj_ver, rf.file
|
|
|
|
|
return Found(
|
|
|
|
|
dwg=rf.drawing,
|
|
|
|
|
ok=True,
|
|
|
|
|
rev=rf.rev,
|
|
|
|
|
name=rf.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=rf.base,
|
|
|
|
|
controlled=rf.status or "",
|
|
|
|
|
state=rf.state or "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
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] = {}
|
2026-10-19 13:36:42 -06:00
|
|
|
self._seen_rev: dict[tuple[str, int], RevisionFile] = {}
|
2026-10-16 14:45:38 -06:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
def revision(self, dwg: str, rev: int) -> Found | None:
|
|
|
|
|
from mfileskit.errors import MFilesError
|
|
|
|
|
|
|
|
|
|
dwg = (dwg or "").strip()
|
|
|
|
|
try:
|
|
|
|
|
rf = self._lookup.revision(dwg, int(rev))
|
|
|
|
|
except MFilesError as e:
|
|
|
|
|
raise DrawingFetchError(f"{dwg}: M-Files lookup of rev {rev} failed: {e}"[:NOTE_MAX]) from None
|
|
|
|
|
if rf is None:
|
|
|
|
|
return None
|
|
|
|
|
self._seen_rev[(dwg, int(rev))] = rf
|
|
|
|
|
return from_revision(rf)
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
def download(self, found: Found, dest: Path) -> int:
|
|
|
|
|
from mfileskit.errors import MFilesError
|
|
|
|
|
|
2026-10-19 13:36:42 -06:00
|
|
|
st: DrawingStatus | RevisionFile | None
|
|
|
|
|
if found.controlled and found.rev is not None:
|
|
|
|
|
st = self._seen_rev.get((found.dwg, found.rev))
|
|
|
|
|
else:
|
|
|
|
|
st = self._seen.get(found.dwg)
|
2026-10-16 14:45:38 -06:00
|
|
|
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
|