#
# headless_ui.py
#
# Class: HeadlessLcmUI
# Non-blocking ILcmUI implementation for headless / server-hosted
# use of the SIL Language and Culture Model (LCM) API.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2025
#
"""
A non-blocking ``ILcmUI`` for processes with no WinForms message pump.
LCM asks its ``ILcmUI`` for decisions at several points, most importantly on a
conflicting save. The default implementation used by FieldWorks, and formerly
used unconditionally by flexicon (until issue #285 flipped the default to
``HeadlessLcmUI``), is ``FwLcmUI``, a WinForms adapter whose members open
modal dialogs and marshal through ``Control.Invoke``. In a process with no
message pump that produces three distinct failures (issue #238):
1. ``ConflictingSave()`` opens ``ConflictingSaveDlg``, which has no close box
(``ControlBox = false``), on the desktop with no owning application. Worse,
its polarity is dangerous: anything other than ``OK`` returns ``true``, and
``UnitOfWorkService.GetUserInputOnConflictingSave`` responds to ``true`` by
calling ``RevertToSavedState()`` -- discarding the caller's unsaved work.
2. ``DisplayMessage`` marshals through ``ISynchronizeInvoke``. It is reached
from ``XMLBackendProvider.ReportProblem`` on the background commit thread,
so a failed write hangs that thread, and ``CompleteAllCommits()`` then hangs
the main thread on cache dispose.
3. ``FwLcmUI`` is constructed with ``helpTopicProvider = None``, and
``DisplayMessage`` dereferences ``m_helpTopicProvider.HelpFile`` for any
non-empty help topic.
``SIL.LCModel.SilentLcmUI`` is *not* a safe substitute: its ``ConflictingSave()``
returns ``true`` unconditionally, i.e. silent total discard of unsaved changes
with no message and no exception. That is strictly worse than the dialog.
``HeadlessLcmUI`` never blocks, never marshals, and never silently discards.
Every decision point takes the non-destructive branch and logs; a conflicting
save raises ``FP_ConflictingSaveError`` so the condition surfaces to the caller
as an exception it can handle.
Usage::
from flexicon import FLExProject
from flexicon import HeadlessLcmUI # also importable from
# flexicon.code.headless_ui
project = FLExProject()
project.OpenProject("MyProject", writeEnabled=True) # ui=None -> HeadlessLcmUI()
Since issue #285, passing no ``ui`` already gets you a bare ``HeadlessLcmUI()``
-- that is now the library-wide default. Pass ``ui=FwLcmUI(None,
ThreadHelper())`` explicitly to opt back into the historical, WinForms-dialog
behaviour.
"""
import logging
import clr
import System
from System import DateTime
from SIL.LCModel import ILcmUI, MessageType, FileSelection, YesNoCancel
from SIL.LCModel.Utils import IThreadedProgress, SingleThreadedSynchronizeInvoke
# FP_ConflictingSaveError lives in exceptions.py alongside every other FP_*
# type so `except FP_RuntimeError` catches it too (see docs/EXCEPTION_HANDLING.md).
# Re-imported here (not re-defined) so
# `from flexicon.code.headless_ui import FP_ConflictingSaveError` keeps working.
from .exceptions import FP_ConflictingSaveError, FP_ParameterError
logger = logging.getLogger(__name__)
[docs]
class HeadlessLcmUI(ILcmUI):
"""
``ILcmUI`` that makes non-destructive decisions without blocking.
Implements all ten methods and both properties of ``SIL.LCModel.ILcmUI``.
"""
# Required by pythonnet to emit a real .NET type implementing the interface.
__namespace__ = "Flexicon.Headless"
def __init__(self, raise_on_conflicting_save=True):
"""
Args:
raise_on_conflicting_save (bool): When True (default),
``ConflictingSave()`` raises ``FP_ConflictingSaveError``. When
False it logs and returns False, which tells LCM to keep this
session's changes and skip ``RevertToSavedState()``.
"""
self._raise_on_conflicting_save = raise_on_conflicting_save
self._last_activity = DateTime.Now
# Inline invoker: InvokeRequired is False, so LCM never marshals to a
# UI thread (#238) but SendPropChangedNotifications does not NRE once
# a change listener is registered (e.g. HermitCrab HCParser, #441).
self._synchronize_invoke = SingleThreadedSynchronizeInvoke()
# -- Properties ---------------------------------------------------
@property
def SynchronizeInvoke(self):
"""
``SingleThreadedSynchronizeInvoke`` -- runs LCM notification callbacks
inline on the calling thread.
``FwLcmUI`` marshals through a real UI pump and can deadlock headless
processes (#238). Returning ``None`` avoided that but breaks every
write once an ``IVwNotifyChange`` subscriber exists (HermitCrab parser,
issue #441): liblcm dereferences ``SynchronizeInvoke`` unguarded in
``UnitOfWorkService.SendPropChangedNotifications`` and
``UndoStack.DoTasksForEndOfPropChanged``.
``SingleThreadedSynchronizeInvoke.InvokeRequired`` is ``False``, so
``SynchronizeInvokeExtensions.Invoke`` executes the action immediately
without cross-thread dispatch. ``HeadlessLcmUI.DisplayMessage`` still
logs directly and does not use this property.
"""
return self._synchronize_invoke
[docs]
def get_SynchronizeInvoke(self):
return self._synchronize_invoke
@property
def LastActivityTime(self):
return self._last_activity
[docs]
def get_LastActivityTime(self):
return self._last_activity
[docs]
def TouchActivity(self):
"""
Record caller activity. ``UnitOfWorkService.SaveOnIdle`` consults
``LastActivityTime`` to decide whether to defer an auto-save, so a
long-running caller should touch this periodically.
"""
self._last_activity = DateTime.Now
# -- The decision that can lose data ------------------------------
[docs]
def ConflictingSave(self):
"""
Report whether to revert this session's changes to the saved state.
Returns False -- never revert -- and by default raises so the caller
learns that a reconcile failed. LCM calls this only after
``ChangeReconciler.OkToReconcileChanges()`` has already determined the
foreign changes cannot be merged, so by this point some manual
resolution is required either way.
"""
logger.error(
"ConflictingSave: another client saved changes that cannot be "
"reconciled with this session's unsaved changes. Refusing to "
"revert to saved state."
)
if self._raise_on_conflicting_save:
raise FP_ConflictingSaveError(
"Another client saved conflicting changes to this project. "
"This session's unsaved changes were NOT discarded. Close "
"without saving, or reopen and re-apply the operation."
)
return False
# -- Non-blocking reports -----------------------------------------
[docs]
def DisplayMessage(self, type, message, caption, helpTopic):
level = {
MessageType.Error: logging.ERROR,
MessageType.Warning: logging.WARNING,
}.get(type, logging.INFO)
logger.log(level, f"LCM message [{caption}]: {message}")
[docs]
def ReportException(self, error, isLethal):
"""
Returns False: do not attempt to continue after a lethal error.
"""
logger.error(f"LCM exception (isLethal={isLethal}): {error}")
return False
[docs]
def ReportDuplicateGuids(self, errorText):
logger.error(f"LCM duplicate GUIDs: {errorText}")
[docs]
def DisplayCircularRefBreakerReport(self, msg, caption):
logger.warning(f"LCM circular reference breaker [{caption}]: {msg}")
# -- Decisions with a non-destructive branch -----------------------
[docs]
def Retry(self, msg, caption):
"""
Returns False. Retrying unattended risks an unbounded loop on a
persistent condition such as a locked file.
"""
logger.warning(f"LCM retry request declined [{caption}]: {msg}")
return False
[docs]
def OfferToRestore(self, projectPath, backupPath):
"""
Returns False. Restoring from a backup unattended would overwrite the
project; that decision belongs to a human.
"""
logger.warning(
f"LCM offered to restore '{projectPath}' from '{backupPath}'. "
"Declined - restore is not performed unattended."
)
return False
[docs]
def RestoreLinkedFilesInProjectFolder(self):
"""
Returns False -- leave linked files at their original location.
True would move/restore linked files into the project folder, an
unattended file-system side effect. False is the non-destructive
branch: linked files are left where they already are.
"""
logger.info(
"LCM RestoreLinkedFilesInProjectFolder: leaving linked files at "
"their original location (non-destructive branch)."
)
return False
[docs]
def ChooseFilesToUse(self):
logger.info("LCM ChooseFilesToUse: defaulting to OkKeepNewer.")
return FileSelection.OkKeepNewer
[docs]
def CannotRestoreLinkedFilesToOriginalLocation(self):
"""
Returns OkNo - skip restoring linked files. The least destructive of
the three branches.
"""
logger.warning(
"LCM cannot restore linked files to their original location. "
"Skipping linked-file restore."
)
return YesNoCancel.OkNo
# ---------------------------------------------------------------------------
# HeadlessThreadedProgress
#
# This cannot be a Python subclass of IThreadedProgress. IThreadedProgress
# inherits IProgress, which declares a .NET event (Canceling), and pythonnet
# 3.x cannot emit event members on a derived type: the class statement
# raises "Method 'add_Canceling' ... does not have an implementation" and
# takes `import flexicon` down with it (regression of issue #289).
# FieldWorks ships no public no-UI implementation (its NullThreadedProgress
# lives in test assemblies), so the type is compiled once per process from
# the C# below -- the same shape as FieldWorks' NullThreadedProgress.
# ---------------------------------------------------------------------------
_HEADLESS_PROGRESS_CS = r"""
using System;
using System.ComponentModel;
using SIL.LCModel.Utils;
namespace Flexicon.Headless
{
/// IThreadedProgress that runs work on the calling thread with no UI.
public class HeadlessThreadedProgress : IThreadedProgress, IDisposable
{
private string m_title = "";
private string m_message = "";
public event CancelEventHandler Canceling;
public HeadlessThreadedProgress()
{
Maximum = 100;
StepSize = 1;
}
public string Title
{
get { return m_title; }
set { m_title = value ?? ""; }
}
public string Message
{
get { return m_message; }
set { m_message = value ?? ""; }
}
public int Position { get; set; }
public int StepSize { get; set; }
public int Minimum { get; set; }
public int Maximum { get; set; }
public bool IsIndeterminate { get; set; }
public bool AllowCancel { get; set; }
public bool IsCanceling { get; set; }
public bool Canceled { get; private set; }
public bool IsDisposed { get; private set; }
// No UI thread to marshal to; callers run work directly.
public ISynchronizeInvoke SynchronizeInvoke { get { return null; } }
public void Step(int amount)
{
Position += amount;
}
// Nothing here ever cancels; present so the event is not flagged unused.
internal bool HasCancelingHandlers { get { return Canceling != null; } }
public object RunTask(Func<IThreadedProgress, object[], object> backgroundTask,
params object[] parameters)
{
return RunTask(true, backgroundTask, parameters);
}
// useSeparateThread is ignored: headless callers have no message pump
// to keep responsive, so the task always runs on the calling thread.
public object RunTask(bool useSeparateThread,
Func<IThreadedProgress, object[], object> backgroundTask,
params object[] parameters)
{
if (backgroundTask == null)
return null;
return backgroundTask(this, parameters);
}
public void Dispose()
{
IsDisposed = true;
}
}
}
"""
def _compile_headless_progress():
"""
Compile ``_HEADLESS_PROGRESS_CS`` in memory and return the .NET type.
Uses CodeDom, available because FieldWorks 9 runs on .NET Framework 4.8.
Compile errors are raised with csc's messages so a mismatch with a future
LCM ``IThreadedProgress`` surface names the member at fault.
"""
from Microsoft.CSharp import CSharpCodeProvider
from System.CodeDom.Compiler import CompilerParameters
lcm_utils_path = clr.GetClrType(IThreadedProgress).Assembly.Location
params = CompilerParameters()
params.GenerateInMemory = True
params.GenerateExecutable = False
params.ReferencedAssemblies.Add("System.dll")
params.ReferencedAssemblies.Add(lcm_utils_path)
provider = CSharpCodeProvider()
try:
results = provider.CompileAssemblyFromSource(params, _HEADLESS_PROGRESS_CS)
finally:
provider.Dispose()
if results.Errors.HasErrors:
messages = "; ".join(
str(e) for e in results.Errors if not e.IsWarning
)
raise FP_ParameterError(
f"Could not compile HeadlessThreadedProgress against "
f"{lcm_utils_path}: {messages}"
)
return results.CompiledAssembly.GetType(
"Flexicon.Headless.HeadlessThreadedProgress", True
)
def _load_headless_progress_class():
_compile_headless_progress()
# pythonnet indexes assemblies as they load, so the in-memory one is
# importable by namespace as soon as CompileAssemblyFromSource returns.
from Flexicon.Headless import HeadlessThreadedProgress as compiled
return compiled
# IThreadedProgress that runs work on the calling thread with no UI.
#
# FieldWorks' ProgressDialogWithTask allocates a WinForms Form and forces a
# Win32 handle on construction (issue #289); OpenProject defaults to this
# class instead. RunTask(task, args) and RunTask(useSeparateThread, task,
# args) both run task(progress, args) synchronously and return its result.
HeadlessThreadedProgress = _load_headless_progress_class()