#
# MSAOperations.py
#
# Class: MSAOperations
# Morphosyntactic-analysis (MSA) creation operations for FieldWorks
# Language Explorer projects via SIL Language and Culture Model
# (LCM) API.
#
# Pairs with morphosyntax_analysis.py (the reading wrapper) and
# msa_collection.py (iteration). This module handles the creation
# + attach side of the four concrete MSA types:
# - MoStemMsa (kStem) -- stem entries, takes one POS
# - MoDerivAffMsa (kDeriv) -- derivational affixes, from-POS + to-POS
# - MoInflAffMsa (kInfl) -- inflectional affixes, POS + slots
# - MoUnclassifiedAffixMsa (kUnclassified) -- catch-all affix
#
# All four use the same idiom: build a SandboxGenericMSA with the
# MsaType + POS info, call the type-specific factory's
# Create(sense.Owner, sandbox) overload, then attach via
# sense.MorphoSyntaxAnalysisRA.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2026
#
import logging
from collections import namedtuple
logger = logging.getLogger(__name__)
# Import BaseOperations parent class
from ..BaseOperations import BaseOperations, OperationsMethod
# Import FLEx LCM types
from SIL.LCModel import (
IMoStemMsa,
IMoStemMsaFactory,
IMoDerivAffMsa,
IMoDerivAffMsaFactory,
IMoInflAffMsa,
IMoInflAffMsaFactory,
IMoUnclassifiedAffixMsa,
IMoUnclassifiedAffixMsaFactory,
ILexSense,
ILexEntry,
ILexEntryRepository,
IWfiMorphBundleRepository,
LexEntryTags,
MsaType,
)
from SIL.LCModel.DomainServices import SandboxGenericMSA
import clr
# Import flexlibs exceptions
from ..FLExProject import (
FP_ParameterError,
FP_ReadOnlyError,
FP_NullParameterError,
)
# Import the read-side wrapper + smart collection this module's GetAll
# hands back. Both already existed in full (morphosyntax_analysis.py,
# msa_collection.py); GetAll is the call path that finally instantiates
# them, so the subtype differences between the four concrete MSA classes
# stay behind the wrapper's is_* / as_* / pos_* families rather than
# reaching the caller as a ClassName test or a cast (Principle VI).
from .morphosyntax_analysis import MorphosyntaxAnalysis
from .msa_collection import MSACollection
# --- Structured result for RemoveOrphaned (issue #206) ----------------------
# Follows the namedtuple-with-docstring convention used elsewhere in this
# codebase for multi-value structured results (see
# LocalizedListsOperations.ImportLocalizedListsResult).
RemovedMSA = namedtuple("RemovedMSA", ("entry_hvo", "msa_hvo", "class_name"))
RemovedMSA.__doc__ = """
One MSA removed by MSAOperations.RemoveOrphaned.
Fields:
entry_hvo (int): Hvo of the owning ILexEntry the MSA was removed from.
msa_hvo (int): Hvo of the removed MSA.
class_name (str): ClassName of the removed MSA (e.g. "MoStemMsa").
"""
EntryOrphanBreakdown = namedtuple(
"EntryOrphanBreakdown", ("entry_hvo", "removed_count", "kept_count")
)
EntryOrphanBreakdown.__doc__ = """
Per-entry breakdown produced by MSAOperations.RemoveOrphaned.
Fields:
entry_hvo (int): Hvo of the scanned ILexEntry.
removed_count (int): Number of orphaned MSAs removed from this
entry's MorphoSyntaxAnalysesOC.
kept_count (int): Number of MSAs in this entry's MorphoSyntaxAnalysesOC
that were still referenced (by an entry-local sense or a
project-wide morph bundle) and therefore kept.
"""
RemoveOrphanedResult = namedtuple(
"RemoveOrphanedResult",
("removed_count", "kept_count", "removed", "by_entry"),
)
RemoveOrphanedResult.__doc__ = """
Structured result for MSAOperations.RemoveOrphaned.
Fields:
removed_count (int): Total number of orphaned MSAs removed.
kept_count (int): Total number of MSAs examined that were still
referenced (by an entry-local sense's MorphoSyntaxAnalysisRA, or
project-wide by an IWfiMorphBundle.MsaRA) and therefore kept.
removed (list[RemovedMSA]): One entry per MSA actually removed.
by_entry (list[EntryOrphanBreakdown]): One entry per scanned
ILexEntry that owned at least one MSA, summarising removed/kept
counts for that entry. Entries with an empty
MorphoSyntaxAnalysesOC are omitted.
"""
[docs]
class MSAOperations(BaseOperations):
"""
Read, creation and attach operations for morphosyntactic analyses (MSAs).
A LexSense's grammatical analysis lives in
``sense.MorphoSyntaxAnalysisRA``, which is a reference to an MSA owned
by ``sense.Entry.MorphoSyntaxAnalysesOC``. LCM offers four concrete
MSA subtypes that share IMoMorphSynAnalysis as base; each subtype has
its own factory whose 2-arg Create overload takes an owner (the sense's
entry) and a SandboxGenericMSA descriptor.
This wrapper hides the ServiceLocator + SandboxGenericMSA dance and
auto-attaches the new MSA to the sense; ``GetAll`` covers the reading
direction, handing back MorphosyntaxAnalysis wrappers in an
MSACollection so subtype differences never reach the caller.
Usage::
from flexicon import FLExProject
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
entry = list(project.LexiconAllEntries())[0]
sense = entry.SensesOS[0]
# Read: every MSA owned by the entry, already wrapped.
for msa in project.MSA.GetAll(entry):
if msa.is_deriv_aff_msa:
print(msa.pos_from, "->", msa.pos_to)
# Stem MSA (most common case): assign POS to a lexical entry.
verb_pos = project.POS.Find("Verb")
project.MSA.CreateStem(sense, verb_pos)
# Derivational affix: noun -> verb
n_pos = project.POS.Find("Noun")
v_pos = project.POS.Find("Verb")
project.MSA.CreateDerivAff(sense, from_pos=n_pos, to_pos=v_pos)
See Also:
morphosyntax_analysis.MorphosyntaxAnalysis (reading)
msa_collection.MSACollection (iteration)
"""
def __init__(self, project):
super().__init__(project)
# ------------------------------------------------------------------
# Reading
# ------------------------------------------------------------------
@OperationsMethod
def GetAll(self, entry_or_hvo=None):
"""
Get every morphosyntactic analysis owned by an entry, or by the
whole project.
Reads ``entry.MorphoSyntaxAnalysesOC`` and returns a smart
collection of wrapped MSA objects that transparently handle the
four concrete subtypes (MoStemMsa, MoDerivAffMsa, MoInflAffMsa,
MoUnclassifiedAffixMsa). The caller never tests ``ClassName`` and
never casts: subtype differences are reached through the
wrapper's ``is_*`` / ``as_*`` / ``pos_*`` families instead.
Args:
entry_or_hvo: The ILexEntry object or HVO whose
MorphoSyntaxAnalysesOC should be read. Pass None (the
default) to sweep every entry in the project.
Returns:
MSACollection[MorphosyntaxAnalysis]: Smart collection of
MorphosyntaxAnalysis wrapper objects, showing a subtype
breakdown on ``str()`` and supporting filtered queries.
Empty collection if the entry owns no MSAs.
Raises:
FP_ParameterError: If entry_or_hvo is supplied but does not
resolve to a valid ILexEntry.
Example:
>>> entry = list(project.LexiconAllEntries())[0]
>>>
>>> # Every MSA on one entry
>>> msas = project.MSA.GetAll(entry)
>>> print(msas) # Shows subtype breakdown
# MSACollection (3 total)
# MoStemMsa: 2 (66%)
# MoDerivAffMsa: 1 (33%)
>>>
>>> # Iterate the wrapped objects -- no ClassName, no cast
>>> for msa in msas:
... if msa.is_deriv_aff_msa:
... print(msa.pos_from, "->", msa.pos_to)
... else:
... print(msa.pos_main)
>>>
>>> # len() and indexing work directly on the collection
>>> print(len(msas))
3
>>> first = msas[0]
>>>
>>> # Filter by subtype, or by POS, and chain the two
>>> verb_pos = project.POS.Find("Verb")
>>> verb_stems = project.MSA.GetAll(entry).filter(pos_main=verb_pos)
>>>
>>> # Project-wide sweep
>>> all_msas = project.MSA.GetAll()
>>> print(f"Project has {len(all_msas)} MSAs")
Notes:
- Wiring, not new design. MorphosyntaxAnalysis
(morphosyntax_analysis.py) and MSACollection
(msa_collection.py) were already complete; this accessor is
the call path that instantiates them.
- Deliberately NOT decorated with ``@wrap_enumerable``, unlike
most GetAll methods in this library. That decorator adapts
return values that lack sequence behavior -- a raw C#
IEnumerable or a bare Python generator. ``MSACollection``
already supplies ``__len__``, ``__getitem__`` (including
slicing) and ``__iter__`` through SmartCollection, so
``_needs_enumerable_wrap`` (BaseOperations.py) returns False
for it and the decorator would be an inert no-op that
falsely implied the result needed adapting. The behavioral
collection contract -- loop it, ``len()`` it, index it,
re-iterate it -- is met in full, by MSACollection itself.
- This method performs no write and does not require a
write-enabled project. That is a property of this method,
not of MSAOperations: the class is write-capable, and its
CreateStem / CreateDerivAff / CreateInflAff /
CreateUnclassifiedAffix / SetStemMsaPos / SetDerivAffMsaPos
/ SetInflAffMsaSlots / GetInflAffMsaSlots / ChangeAffixVariant
/ RemoveOrphaned
siblings all mutate
the project and call _EnsureWriteEnabled.
- Collection order follows FLEx's MorphoSyntaxAnalysesOC
order. When entry_or_hvo is None, entries are visited in
ILexEntryRepository order and each entry's MSAs are
appended in turn.
- An entry's MorphoSyntaxAnalysesOC can contain an MSA no
sense currently points at; GetAll reports what the entry
owns, and does not filter orphans. Use RemoveOrphaned to
prune them.
- ``ILexEntry.MorphoSyntaxAnalysesOC`` is
``ILcmOwningCollection<IMoMorphSynAnalysis>``, read-only,
per tests/contract/snapshots/liblcm_baseline.json
(liblcm 11.0.0.0).
- Items can be passed straight back into other MSAOperations
methods (e.g. ``ChangeAffixVariant(item, ...)``) -- resolvers
unwrap the wrapper internally (issue #449). A caller
performing a direct pythonnet cast, e.g. ``IMoInflAffMsa(item)``,
must use ``item.lcm_object`` instead
(``IMoInflAffMsa(item.lcm_object)``), since pythonnet cannot
cast a Python wrapper instance.
See Also:
CreateStem, CreateDerivAff, CreateInflAff,
CreateUnclassifiedAffix, RemoveOrphaned,
morphosyntax_analysis.MorphosyntaxAnalysis,
msa_collection.MSACollection
"""
analyses = []
if entry_or_hvo is None:
entries = self.project.ObjectsIn(ILexEntryRepository)
else:
entries = [self.__ResolveEntry(entry_or_hvo)]
for entry_obj in entries:
for msa in entry_obj.MorphoSyntaxAnalysesOC:
analyses.append(MorphosyntaxAnalysis(msa))
return MSACollection(analyses)
# ------------------------------------------------------------------
# Creation + attach
# ------------------------------------------------------------------
@OperationsMethod
def CreateStem(self, sense, pos):
"""
Create an IMoStemMsa, attach it to the sense.
Args:
sense: An ILexSense (or HVO) to attach the MSA to.
pos: An IPartOfSpeech (or HVO) -- the grammatical category, or
None to leave PartOfSpeechRA unset. Passing None is valid and
means "grammatical category not specified" -- a very common
state for stem entries in FLEx (the category cell is blank).
BEHAVIOR CHANGE (issue: null-category stems): previously a None
pos raised FP_NullParameterError, which made it impossible to
round-trip a legitimately category-less stem MSA. This mirrors
CreateDerivAff's to_pos=None "unset" precedent.
Returns:
IMoStemMsa: The newly created and attached MSA.
Raises:
FP_ReadOnlyError, FP_NullParameterError, FP_ParameterError.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
# pos intentionally not validated -- None is a legal "unset" value.
sense_obj = self.__ResolveSense(sense)
pos_obj = self.__Resolve(pos) if pos is not None else None
sandbox = SandboxGenericMSA()
sandbox.MsaType = MsaType.kStem
sandbox.MainPOS = pos_obj
new_msa = self.__CreateAndAttach(
sense_obj, sandbox, IMoStemMsaFactory
)
return IMoStemMsa(new_msa)
@OperationsMethod
def CreateDerivAff(self, sense, from_pos, to_pos=None):
"""
Create an IMoDerivAffMsa, attach it to the sense.
Args:
sense: An ILexSense (or HVO) to attach the MSA to.
from_pos: An IPartOfSpeech the affix attaches to (input category).
to_pos: An IPartOfSpeech the affix produces (output category), or
None to leave ToPartOfSpeechRA unset. Passing None is valid
and means "output category not yet determined" -- the user can
fill this in later via MSA.SetDerivAffMsaPos(sense, to_pos=X).
BEHAVIOR CHANGE (Cycle 4, issue #91): Previously the default
was to copy from_pos when to_pos was omitted, producing a
linguistically invalid "derivation that doesn't change
category". The default is now None (unset), which is the
correct state for an incompletely specified derivational affix.
Returns:
IMoDerivAffMsa: The newly created and attached MSA.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
self._ValidateParam(from_pos, "from_pos")
# to_pos intentionally not validated -- None is a legal "unset" value.
sense_obj = self.__ResolveSense(sense)
from_pos_obj = self.__Resolve(from_pos)
to_pos_obj = self.__Resolve(to_pos) if to_pos is not None else None
sandbox = SandboxGenericMSA()
sandbox.MsaType = MsaType.kDeriv
sandbox.MainPOS = from_pos_obj
sandbox.SecondaryPOS = to_pos_obj
new_msa = self.__CreateAndAttach(
sense_obj, sandbox, IMoDerivAffMsaFactory
)
deriv = IMoDerivAffMsa(new_msa)
# Explicitly set ToPartOfSpeechRA after creation: SandboxGenericMSA's
# SecondaryPOS mapping may not reliably clear the field when None is
# passed, so we set it directly to ensure the unset state is stored.
# __CreateAndAttach has its own bracket which has already committed by
# here, so this follow-up write needs a transaction of its own (D6).
with self._TransactionCM("Set derivational affix output category"):
deriv.ToPartOfSpeechRA = to_pos_obj
return deriv
@OperationsMethod
def CreateInflAff(self, sense, pos, slots=None):
"""
Create an IMoInflAffMsa, attach it to the sense.
Args:
sense: An ILexSense (or HVO) to attach the MSA to.
pos: An IPartOfSpeech -- the category this affix inflects, or None
to leave PartOfSpeechRA unset. Passing None is valid and means
"category not yet specified": an inflectional affix MSA may
legitimately carry a blank category cell in FLEx. Mirrors
CreateStem's / CreateUnclassifiedAffix's pos=None support.
slots: Optional sequence of IMoInflAffixSlot objects. Slots
are added to the MSA's SlotsRC reference collection
after creation (Phase 2 ownership-ordering doesn't apply
to reference collections).
Returns:
IMoInflAffMsa: The newly created and attached MSA.
Note:
HermitCrab uses ``IMoInflAffixSlot`` (template slots) to
constrain which inflection classes of the target POS an
affix is valid for. If the language uses inflection classes
AND the target slot has class restrictions, HermitCrab will
reject analyses where the MSA is not wired into a slot
whose ``InflectionClassesRC`` matches the stem's class.
Populate ``slots`` here, then configure each slot's
``InflectionClassesRC`` separately. Languages without
inflection classes do not need slot-level class
restrictions.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
# pos intentionally not validated -- None is a legal "unset" value
# (a category-less inflectional affix). Mirrors CreateUnclassifiedAffix.
sense_obj = self.__ResolveSense(sense)
pos_obj = self.__Resolve(pos) if pos is not None else None
sandbox = SandboxGenericMSA()
sandbox.MsaType = MsaType.kInfl
sandbox.MainPOS = pos_obj
with self._TransactionCM("Create inflectional affix MSA"):
new_msa = self.__CreateAndAttach(
sense_obj, sandbox, IMoInflAffMsaFactory
)
new_msa = IMoInflAffMsa(new_msa)
if slots:
for slot in slots:
resolved = self.__Resolve(slot)
new_msa.SlotsRC.Add(resolved)
return new_msa
@OperationsMethod
def CreateUnclassifiedAffix(self, sense, pos):
"""
Create an IMoUnclassifiedAffixMsa, attach it to the sense.
Args:
sense: An ILexSense (or HVO) to attach the MSA to.
pos: An IPartOfSpeech (or HVO) -- the grammatical category, or None
to leave PartOfSpeechRA unset. Passing None is valid and means
"category not specified" -- an unclassified affix legitimately
may carry no grammatical category (that is what "unclassified"
means). Mirrors CreateStem's / CreateDerivAff's pos=None support.
Returns:
IMoUnclassifiedAffixMsa: The newly created and attached MSA.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
# pos intentionally not validated -- None is a legal "unset" value.
sense_obj = self.__ResolveSense(sense)
pos_obj = self.__Resolve(pos) if pos is not None else None
sandbox = SandboxGenericMSA()
sandbox.MsaType = MsaType.kUnclassified
sandbox.MainPOS = pos_obj
new_msa = self.__CreateAndAttach(
sense_obj, sandbox, IMoUnclassifiedAffixMsaFactory
)
return IMoUnclassifiedAffixMsa(new_msa)
@OperationsMethod
def SetStemMsaPos(self, sense, pos):
"""
Update the POS on an existing IMoStemMsa attached to a sense.
If the sense has no MSA, or if its MSA isn't a stem MSA, raises
FP_ParameterError. For type conversion (e.g. stem -> deriv-aff)
the caller should create a new MSA via CreateStem / CreateDerivAff;
in-place conversion across MSA types is intentionally not
supported by this wrapper because LCM doesn't expose a clean
idiom for it.
Args:
sense: An ILexSense whose MSA should be updated.
pos: New IPartOfSpeech (or HVO) for the stem.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
self._ValidateParam(pos, "pos")
sense_obj = self.__ResolveSense(sense)
existing = sense_obj.MorphoSyntaxAnalysisRA
if existing is None:
raise FP_ParameterError(
"Sense has no MSA; use CreateStem to create one."
)
try:
stem = IMoStemMsa(existing)
except Exception:
raise FP_ParameterError(
"Sense's existing MSA is not a stem MSA. To change MSA "
"type, create a new MSA with the appropriate Create* method."
)
# Resolution stays outside the bracket so an unresolvable POS raises
# before a named undo entry is opened (D5).
pos_obj = self.__Resolve(pos)
with self._TransactionCM("Set stem MSA POS"):
stem.PartOfSpeechRA = pos_obj
@OperationsMethod
def SetDerivAffMsaPos(self, sense, from_pos=None, to_pos=None):
"""
Update the from-POS and/or to-POS on an existing IMoDerivAffMsa
attached to a sense.
If the sense has no MSA, or if its MSA isn't a derivational-affix
MSA, raises FP_ParameterError. At least one of from_pos or to_pos
must be supplied.
Args:
sense: An ILexSense whose MSA should be updated.
from_pos: New IPartOfSpeech (or HVO) for the input category
(FromPartOfSpeechRA). Pass None to leave unchanged.
to_pos: New IPartOfSpeech (or HVO) for the output category
(ToPartOfSpeechRA). Pass None to leave unchanged.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
if from_pos is None and to_pos is None:
raise FP_ParameterError(
"At least one of from_pos or to_pos must be supplied."
)
sense_obj = self.__ResolveSense(sense)
existing = sense_obj.MorphoSyntaxAnalysisRA
if existing is None:
raise FP_ParameterError(
"Sense has no MSA; use CreateDerivAff to create one."
)
try:
deriv = IMoDerivAffMsa(existing)
except Exception:
raise FP_ParameterError(
"Sense's existing MSA is not a derivational-affix MSA. To "
"change MSA type, create a new MSA with the appropriate "
"Create* method."
)
with self._TransactionCM("Set derivational affix MSA POS"):
if from_pos is not None:
deriv.FromPartOfSpeechRA = self.__Resolve(from_pos)
if to_pos is not None:
deriv.ToPartOfSpeechRA = self.__Resolve(to_pos)
@OperationsMethod
def SetInflAffMsaSlots(self, sense, slots, replace=True):
"""
Update the ``SlotsRC`` reference collection on an existing
``IMoInflAffMsa`` attached to a sense.
``CreateInflAff`` accepts ``slots=`` only at creation time; this
method edits slot membership on an MSA that already exists.
Args:
sense: An ``ILexSense`` (or HVO) whose inflectional-affix MSA
should be updated.
slots: Sequence of ``IMoInflAffixSlot`` objects (or HVOs) to
attach. An empty sequence with ``replace=True`` clears all
slots.
replace: When ``True`` (default), existing slots are cleared
before the new set is added. When ``False``, each resolved
slot is appended without clearing.
Raises:
FP_ReadOnlyError, FP_NullParameterError, FP_ParameterError.
"""
self._EnsureWriteEnabled()
self._ValidateParam(sense, "sense")
self._ValidateParam(slots, "slots")
sense_obj = self.__ResolveSense(sense)
existing = sense_obj.MorphoSyntaxAnalysisRA
if existing is None:
raise FP_ParameterError(
"Sense has no MSA; use CreateInflAff to create one."
)
try:
infl = IMoInflAffMsa(existing)
except Exception:
raise FP_ParameterError(
"Sense's existing MSA is not an inflectional-affix MSA. To "
"change MSA type, create a new MSA with the appropriate "
"Create* method."
)
resolved_slots = [self.__Resolve(slot) for slot in slots]
with self._TransactionCM("Set inflectional affix MSA slots"):
if replace:
infl.SlotsRC.Clear()
for slot_obj in resolved_slots:
infl.SlotsRC.Add(slot_obj)
@OperationsMethod
def GetInflAffMsaSlots(self, sense_or_msa):
"""
Read ``SlotsRC`` on an inflectional-affix MSA.
This is the read-side pair for ``SetInflAffMsaSlots``. Pass either
the sense whose ``MorphoSyntaxAnalysisRA`` should be read, or the
inflectional-affix MSA (or its HVO) directly.
Args:
sense_or_msa: An ``ILexSense``, ``IMoInflAffMsa``, HVO, or
``MorphosyntaxAnalysis`` wrapper.
Returns:
list: ``IMoInflAffixSlot`` objects in ``SlotsRC`` order. An
empty list when the sense has no MSA, the MSA is not
inflectional-affix, or ``SlotsRC`` is empty.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
Example:
>>> slots = project.MSA.GetInflAffMsaSlots(sense)
>>> hvos = {int(s.Hvo) for s in slots}
>>> same = project.MSA.GetInflAffMsaSlots(infl_msa_hvo)
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
infl = self.__TryResolveInflAffMsa(sense_or_msa)
if infl is None:
return []
slots_rc = infl.SlotsRC
if slots_rc is None or slots_rc.Count == 0:
return []
return list(slots_rc)
# ------------------------------------------------------------------
# MSA feature-structure getters (issue #544 -- reverse of MakeFeatStruc)
# ------------------------------------------------------------------
#
# ``InflectionFeatures.MakeFeatStruc(specs, owner=msa, ...)`` writes an
# MSA's feature structure, but until this section there was no public
# way to read one back. The private ``__CaptureFeatureStrucProp`` (used
# by ``GetSyncableProperties``) already resolves the owning property
# and serializes via ``_GetFeatureStruc`` -- but that method returns the
# C4 SYNC WIRE FORMAT (``{"TypeGuid": ..., "specs": {...}}``, nested
# complex values carry an extra ``"Guid"`` key), which is NOT the shape
# ``MakeFeatStruc`` accepts back (a plain recursive ``{feature: value |
# {...}}`` dict). Confirmed by reading ``_MakeFeatStruc``/
# ``__ResolveFeatStrucOperand`` directly: a GUID *string* resolves via
# ``project.Object(guid)`` for either a feature or a value key, so a
# dict of GUID-string keys/values is a valid, unambiguous
# ``MakeFeatStruc`` input. ``__C4ToFeatStrucSpec`` performs that one
# conversion (recursively, for nested ``IFsComplexValue`` structures),
# so these getters return something that can be fed straight back into
# ``MakeFeatStruc(getter_output, owner=other_msa)`` -- the round-trip
# the issue asks for. NOTE: ``_MakeFeatStruc`` never sets ``TypeRA`` on
# the struct it creates (only the C4/C5 sync-apply surface,
# ``_ApplyFeatureStruc``, does), so ``TypeGuid`` carries no information
# for a round trip through ``MakeFeatStruc`` and is intentionally
# dropped by the converter -- this is a pre-existing ``MakeFeatStruc``
# limitation, not something introduced here.
def __C4ToFeatStrucSpec(self, c4):
"""
Convert one ``_GetFeatureStruc`` (C4 wire-format) dict into the
plain recursive dict ``_MakeFeatStruc`` accepts as ``specs``.
Args:
c4: A C4 dict (``{"TypeGuid": ..., "specs": {...}}``) as
returned by ``_GetFeatureStruc``, or ``None``.
Returns:
dict or None: ``None`` when ``c4`` is ``None`` (mirrors
``_GetFeatureStruc``'s own null passthrough -- a null owning
property stays ``None``, never ``{}``). Otherwise a dict keyed
by feature GUID string, where each value is either a value
GUID string (``IFsClosedValue``) or a nested dict of the same
shape (``IFsComplexValue``'s ``ValueOA``) -- exactly the
recursive shape ``_MakeFeatStruc`` resolves GUID-string
operands against. A present-but-empty struct (``c4 ==
{"TypeGuid": ..., "specs": {}}``) converts to ``{}``, not
``None`` -- same presence-vs-emptiness distinction C4 itself
preserves.
Notes:
- ``TypeGuid`` at every level is dropped (see the module-level
note above this method): it is not part of the
``_MakeFeatStruc`` input shape and ``_MakeFeatStruc`` never
writes it back, so keeping it here would be misleading.
- The nested ``"Guid"`` key C4 attaches to non-top-level
structs (identifying the already-attached
``IFsComplexValue.ValueOA``) is likewise dropped -- a fresh
call to ``MakeFeatStruc`` always creates new nested structs
and cannot target an existing ``Guid``.
"""
if c4 is None:
return None
result = {}
for feat_guid, value in c4.get("specs", {}).items():
if isinstance(value, dict):
result[feat_guid] = self.__C4ToFeatStrucSpec(value)
else:
result[feat_guid] = value
return result
def __ResolveMsaForFeatures(self, sense_or_msa):
"""
Resolve ``sense_or_msa`` to a concrete, ``ClassName``-cast MSA
object for the feature-structure getters, or ``None``.
Accepts a sense (object/HVO/wrapper) -- reads its
``MorphoSyntaxAnalysisRA`` -- or an MSA (object/HVO/GUID/wrapper)
directly, of ANY of the four concrete MSA classes (unlike
``__TryResolveInflAffMsa``, which only recognizes
``MoInflAffMsa``). Delegates the concrete cast to
``__GetMsaObject`` (C2) so every downstream getter receives a
properly typed object regardless of entry path.
Returns:
The concrete MSA object, or ``None`` when ``sense_or_msa`` is
a sense with no ``MorphoSyntaxAnalysisRA``.
"""
obj = self.__Resolve(sense_or_msa)
try:
sense = ILexSense(obj)
except Exception:
sense = None
if sense is not None:
existing = sense.MorphoSyntaxAnalysisRA
if existing is None:
return None
return self.__GetMsaObject(existing)
return self.__GetMsaObject(obj)
def __ReadMsaFeatureStrucSpec(self, msa, expected_class_name, slot):
"""
Read one feature-struct owning property off ``msa`` and convert
it to the ``_MakeFeatStruc``-shaped spec, or ``None``.
Returns ``None`` (never raises) when ``msa.ClassName`` does not
match ``expected_class_name`` -- mirrors ``GetInflAffMsaSlots``'s
graceful non-raise on a wrong-type MSA, applied to this method's
``None``-shaped return instead of ``GetInflAffMsaSlots``'s ``[]``.
"""
if msa.ClassName != expected_class_name:
return None
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(
msa, slot=slot
)
struct = getattr(concrete_owner, prop_name)
return self.__C4ToFeatStrucSpec(self._GetFeatureStruc(struct))
@OperationsMethod
def GetStemFeatures(self, sense_or_msa):
"""
Read ``IMoStemMsa.MsFeaturesOA`` as a ``MakeFeatStruc``-shaped spec.
This is the read-side pair for
``project.InflectionFeatures.MakeFeatStruc(specs, owner=stem_msa)``:
the returned spec can be fed straight back into ``MakeFeatStruc``
to reproduce an equivalent (GUID-keyed) feature structure on
another owner.
Args:
sense_or_msa: An ``ILexSense``, ``IMoStemMsa``, HVO, GUID
string, or ``MorphosyntaxAnalysis`` wrapper.
Returns:
dict or None: Recursive ``{featureGuid: valueGuid | {...}}``
spec. ``None`` when ``sense_or_msa`` denotes a sense with no
MSA, an MSA that is not ``MoStemMsa``, or a stem MSA whose
``MsFeaturesOA`` is null. ``{}`` when ``MsFeaturesOA`` is a
present-but-empty feature structure.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
Example:
>>> spec = project.MSA.GetStemFeatures(sense)
>>> if spec is not None:
... project.InflectionFeatures.MakeFeatStruc(
... spec, owner=other_stem_msa
... )
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
msa = self.__ResolveMsaForFeatures(sense_or_msa)
if msa is None:
return None
return self.__ReadMsaFeatureStrucSpec(msa, "MoStemMsa", slot=None)
@OperationsMethod
def GetInflAffFeatures(self, sense_or_msa):
"""
Read ``IMoInflAffMsa.InflFeatsOA`` as a ``MakeFeatStruc``-shaped
spec.
Read-side pair for
``project.InflectionFeatures.MakeFeatStruc(specs, owner=infl_msa)``.
Args:
sense_or_msa: An ``ILexSense``, ``IMoInflAffMsa``, HVO, GUID
string, or ``MorphosyntaxAnalysis`` wrapper.
Returns:
dict or None: Recursive ``{featureGuid: valueGuid | {...}}``
spec. ``None`` when ``sense_or_msa`` denotes a sense with no
MSA, an MSA that is not ``MoInflAffMsa``, or an inflectional
affix MSA whose ``InflFeatsOA`` is null. ``{}`` when
``InflFeatsOA`` is a present-but-empty feature structure.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
Example:
>>> spec = project.MSA.GetInflAffFeatures(sense)
>>> if spec is not None:
... project.InflectionFeatures.MakeFeatStruc(
... spec, owner=other_infl_msa
... )
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
msa = self.__ResolveMsaForFeatures(sense_or_msa)
if msa is None:
return None
return self.__ReadMsaFeatureStrucSpec(msa, "MoInflAffMsa", slot=None)
@OperationsMethod
def GetDerivFromFeatures(self, sense_or_msa):
"""
Read ``IMoDerivAffMsa.FromMsFeaturesOA`` as a ``MakeFeatStruc``-
shaped spec.
Read-side pair for
``project.InflectionFeatures.MakeFeatStruc(specs, owner=deriv_msa,
slot="From")``.
Args:
sense_or_msa: An ``ILexSense``, ``IMoDerivAffMsa``, HVO, GUID
string, or ``MorphosyntaxAnalysis`` wrapper.
Returns:
dict or None: Recursive ``{featureGuid: valueGuid | {...}}``
spec. ``None`` when ``sense_or_msa`` denotes a sense with no
MSA, an MSA that is not ``MoDerivAffMsa``, or a derivational
affix MSA whose ``FromMsFeaturesOA`` is null. ``{}`` when
``FromMsFeaturesOA`` is a present-but-empty feature structure.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
Example:
>>> spec = project.MSA.GetDerivFromFeatures(sense)
>>> if spec is not None:
... project.InflectionFeatures.MakeFeatStruc(
... spec, owner=other_deriv_msa, slot="From"
... )
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
msa = self.__ResolveMsaForFeatures(sense_or_msa)
if msa is None:
return None
return self.__ReadMsaFeatureStrucSpec(msa, "MoDerivAffMsa", slot="From")
@OperationsMethod
def GetDerivToFeatures(self, sense_or_msa):
"""
Read ``IMoDerivAffMsa.ToMsFeaturesOA`` as a ``MakeFeatStruc``-
shaped spec.
Read-side pair for
``project.InflectionFeatures.MakeFeatStruc(specs, owner=deriv_msa,
slot="To")``.
Args:
sense_or_msa: An ``ILexSense``, ``IMoDerivAffMsa``, HVO, GUID
string, or ``MorphosyntaxAnalysis`` wrapper.
Returns:
dict or None: Recursive ``{featureGuid: valueGuid | {...}}``
spec. ``None`` when ``sense_or_msa`` denotes a sense with no
MSA, an MSA that is not ``MoDerivAffMsa``, or a derivational
affix MSA whose ``ToMsFeaturesOA`` is null. ``{}`` when
``ToMsFeaturesOA`` is a present-but-empty feature structure.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
Example:
>>> spec = project.MSA.GetDerivToFeatures(sense)
>>> if spec is not None:
... project.InflectionFeatures.MakeFeatStruc(
... spec, owner=other_deriv_msa, slot="To"
... )
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
msa = self.__ResolveMsaForFeatures(sense_or_msa)
if msa is None:
return None
return self.__ReadMsaFeatureStrucSpec(msa, "MoDerivAffMsa", slot="To")
@OperationsMethod
def GetFeatures(self, sense_or_msa, slot=None):
"""
Read an MSA's feature structure as a ``MakeFeatStruc``-shaped
spec, dispatching on the MSA's concrete ``ClassName``.
Single entry point covering all four concrete MSA classes --
prefer this over the explicit per-class getters
(``GetStemFeatures`` / ``GetInflAffFeatures`` /
``GetDerivFromFeatures`` / ``GetDerivToFeatures``) when the
caller does not already know the MSA's class (Principle III,
docs/API_DESIGN_PHILOSOPHY.md).
Args:
sense_or_msa: An ``ILexSense``, any concrete MSA object, HVO,
GUID string, or ``MorphosyntaxAnalysis`` wrapper.
slot: Required ONLY for a ``MoDerivAffMsa`` (which has two
independent feature-struct slots): ``"From"`` or ``"To"``.
Ignored for every other MSA class, even if supplied.
Returns:
dict or None: Recursive ``{featureGuid: valueGuid | {...}}``
spec (see the per-class getters for the exact owning
property). ``None`` when ``sense_or_msa`` denotes a sense
with no MSA, an ``MoUnclassifiedAffixMsa`` (carries no
feature-struct property at all -- confirmed by the live
probe backing ``GetSyncableProperties``), or any other
``ClassName`` outside the four recognized MSA subtypes.
``{}`` when the owning property is a present-but-empty
feature structure.
Raises:
FP_NullParameterError: If ``sense_or_msa`` is null.
FP_ParameterError: If the resolved MSA is ``MoDerivAffMsa``
and ``slot`` is not ``"From"`` or ``"To"`` -- a
derivational affix MSA has two independent feature-struct
slots and this method never guesses which one the caller
means.
Example:
>>> spec = project.MSA.GetFeatures(sense)
>>> deriv_from = project.MSA.GetFeatures(deriv_msa, slot="From")
"""
self._ValidateParam(sense_or_msa, "sense_or_msa")
msa = self.__ResolveMsaForFeatures(sense_or_msa)
if msa is None:
return None
class_name = msa.ClassName
if class_name == "MoStemMsa":
return self.__ReadMsaFeatureStrucSpec(msa, "MoStemMsa", slot=None)
if class_name == "MoInflAffMsa":
return self.__ReadMsaFeatureStrucSpec(msa, "MoInflAffMsa", slot=None)
if class_name == "MoDerivAffMsa":
if slot not in ("From", "To"):
raise FP_ParameterError(
"GetFeatures: msa is MoDerivAffMsa, which has two "
"independent feature-struct slots; pass slot='From' "
"or slot='To'. Never guessed."
)
return self.__ReadMsaFeatureStrucSpec(msa, "MoDerivAffMsa", slot=slot)
# MoUnclassifiedAffixMsa (R2: no feature-struct property at all)
# and any out-of-C1-table ClassName (e.g. MoDerivStepMsa): no
# feature-struct property to read.
return None
# ------------------------------------------------------------------
# Affix MSA variant conversion
# ------------------------------------------------------------------
# Map ClassName -> source kind tag for internal use.
_AFFIX_CLASS_TO_KIND = {
"MoInflAffMsa": "infl",
"MoDerivAffMsa": "deriv",
"MoUnclassifiedAffixMsa": "unclassified",
}
@OperationsMethod
def ChangeAffixVariant(self, msa, target_kind: str):
"""
Convert an existing affix MSA to a different affix variant.
Creates a new MSA of the requested kind, copies the fields that
transfer across the conversion, warns about fields that will be
lost (only when they actually carry data), repoints all
ILexSenses in the owning entry whose MorphoSyntaxAnalysisRA
points at the old MSA, and removes the old MSA from
MorphoSyntaxAnalysesOC when no senses remain referencing it.
Args:
msa: An existing affix MSA (IMoInflAffMsa, IMoDerivAffMsa,
or IMoUnclassifiedAffixMsa), or a ``MorphosyntaxAnalysis``
wrapper item from ``GetAll()`` (unwrapped internally,
issue #449).
target_kind: 'infl' | 'deriv' | 'unclassified'
Returns:
The new MSA (same type as requested by target_kind), or
``msa`` unchanged if source_kind == target_kind.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If msa is None.
FP_ParameterError: If msa is not an affix MSA, or target_kind
is not one of the recognised values.
Notes:
- WfiMorphBundle.MsaRA references are NOT scanned here, so an
old MSA that is still referenced by morph bundles elsewhere
in the project will be left in place even after all
entry-local senses have been repointed. Call
``project.MSA.RemoveOrphaned(entry)`` (or
``RemoveOrphaned()`` for a project-wide sweep) afterwards
to safely clean up any MSA that is truly unreferenced by
both senses and morph bundles (issue #206).
- Fields that cannot transfer across a conversion (SlotsRC,
InflFeatsOA, FromPartOfSpeechRA, From/ToInflectionClassRA,
StratumRA, From/ToProdRestrictRC) are logged as warnings
only when they carry actual data on the source MSA.
"""
self._EnsureWriteEnabled()
msa = self._UnwrapLcm(msa)
self._ValidateParam(msa, "msa")
_VALID_KINDS = {"infl", "deriv", "unclassified"}
if target_kind not in _VALID_KINDS:
raise FP_ParameterError(
f"target_kind must be one of {sorted(_VALID_KINDS)}; "
f"got {target_kind!r}"
)
source_class = msa.ClassName
source_kind = self._AFFIX_CLASS_TO_KIND.get(source_class)
if source_kind is None:
raise FP_ParameterError(
f"msa must be an affix MSA (MoInflAffMsa, MoDerivAffMsa, "
f"or MoUnclassifiedAffixMsa); got ClassName={source_class!r}"
)
if source_kind == target_kind:
logger.debug(
"ChangeAffixVariant: source and target kinds are both %r; "
"returning msa unchanged.",
target_kind,
)
return msa
# Resolve the owning entry via the MSA's Owner.
entry = ILexEntry(msa.Owner)
# Build a temporary sense proxy to satisfy __CreateAndAttach's
# interface: we need a sense that owns the entry so the factory
# attaches the new MSA to the entry's MorphoSyntaxAnalysesOC.
# We pick the first sense in the entry (they all share the same
# owning entry; we will repoint senses manually after creation).
senses_in_entry = list(entry.SensesOS)
if not senses_in_entry:
raise FP_ParameterError(
"Owning entry has no senses; cannot attach a new MSA."
)
any_sense = senses_in_entry[0]
# --- Determine the POS to carry into the new MSA ---
# Conversion table for POS fields (spec table):
# Infl -> Deriv: PartOfSpeechRA -> FromPartOfSpeechRA
# Infl -> Unclass: PartOfSpeechRA -> PartOfSpeechRA
# Deriv -> Infl: ToPartOfSpeechRA -> PartOfSpeechRA
# Deriv -> Unclass: ToPartOfSpeechRA -> PartOfSpeechRA
# Unclass-> Infl: PartOfSpeechRA -> PartOfSpeechRA
# Unclass-> Deriv: PartOfSpeechRA -> ToPartOfSpeechRA
if source_kind == "infl":
concrete_src = IMoInflAffMsa(msa)
src_pos = concrete_src.PartOfSpeechRA
elif source_kind == "deriv":
concrete_src = IMoDerivAffMsa(msa)
src_pos = concrete_src.ToPartOfSpeechRA
else: # unclassified
concrete_src = IMoUnclassifiedAffixMsa(msa)
src_pos = concrete_src.PartOfSpeechRA
# --- Warn about fields that will be lost (only if populated) ---
lost_fields = []
if source_kind == "infl" and target_kind in ("deriv", "unclassified"):
infl_src = concrete_src
if infl_src.SlotsRC is not None and infl_src.SlotsRC.Count > 0:
lost_fields.append("SlotsRC")
if infl_src.InflFeatsOA is not None:
lost_fields.append("InflFeatsOA")
elif source_kind == "deriv" and target_kind in ("infl", "unclassified"):
deriv_src = concrete_src
if deriv_src.FromPartOfSpeechRA is not None:
lost_fields.append("FromPartOfSpeechRA")
if (
hasattr(deriv_src, "FromInflectionClassRA")
and deriv_src.FromInflectionClassRA is not None
):
lost_fields.append("FromInflectionClassRA")
if (
hasattr(deriv_src, "ToInflectionClassRA")
and deriv_src.ToInflectionClassRA is not None
):
lost_fields.append("ToInflectionClassRA")
if (
hasattr(deriv_src, "StratumRA")
and deriv_src.StratumRA is not None
):
lost_fields.append("StratumRA")
if lost_fields:
logger.warning(
"ChangeAffixVariant: converting %r -> %r on entry Hvo=%s; "
"the following fields carry data but cannot transfer to the "
"new MSA variant and will be lost: %s",
source_kind,
target_kind,
entry.Hvo,
", ".join(lost_fields),
)
# --- Create the new MSA ---
# We temporarily attach it to any_sense; we will repoint senses
# explicitly below, so this initial attachment is fine.
sandbox = SandboxGenericMSA()
if target_kind == "infl":
sandbox.MsaType = MsaType.kInfl
sandbox.MainPOS = src_pos
raw_new = self.__CreateAndAttach(any_sense, sandbox, IMoInflAffMsaFactory)
new_msa = IMoInflAffMsa(raw_new)
# Unclass->Infl: PartOfSpeechRA is already set via MainPOS.
# No additional field copies needed.
# Deriv->Infl: ToPartOfSpeechRA -> PartOfSpeechRA (done via MainPOS).
elif target_kind == "deriv":
sandbox.MsaType = MsaType.kDeriv
if source_kind == "infl":
# Infl->Deriv: PartOfSpeechRA -> FromPartOfSpeechRA; ToPartOfSpeechRA is blank.
sandbox.MainPOS = src_pos
sandbox.SecondaryPOS = None
else:
# Unclass->Deriv: PartOfSpeechRA -> ToPartOfSpeechRA; FromPartOfSpeechRA is blank.
sandbox.MainPOS = None
sandbox.SecondaryPOS = src_pos
raw_new = self.__CreateAndAttach(any_sense, sandbox, IMoDerivAffMsaFactory)
new_msa = IMoDerivAffMsa(raw_new)
# Patch the POS fields directly after creation since the
# sandbox MainPOS/SecondaryPOS mapping may not be symmetric.
# __CreateAndAttach's own bracket has already committed, so this
# follow-up patch needs a transaction of its own (D6). The
# source_kind dispatch stays INSIDE it: both branches mutate, so
# there is no no-op path to protect (D5).
with self._TransactionCM("Set derivational affix POS fields"):
if source_kind == "infl":
new_msa.FromPartOfSpeechRA = src_pos
new_msa.ToPartOfSpeechRA = None
else:
new_msa.ToPartOfSpeechRA = src_pos
new_msa.FromPartOfSpeechRA = None
else: # unclassified
sandbox.MsaType = MsaType.kUnclassified
sandbox.MainPOS = src_pos
raw_new = self.__CreateAndAttach(any_sense, sandbox, IMoUnclassifiedAffixMsaFactory)
new_msa = IMoUnclassifiedAffixMsa(raw_new)
# --- Repoint all senses in the entry that reference the old MSA ---
repointed = 0
# The per-sense skip guards stay outside the bracket so a sense that
# does not reference the old MSA is a true no-op rather than an empty
# named undo entry (D5).
for sense in entry.SensesOS:
if sense.MorphoSyntaxAnalysisRA is not None:
if sense.MorphoSyntaxAnalysisRA.Hvo == msa.Hvo:
with self._TransactionCM("Repoint sense to new MSA"):
sense.MorphoSyntaxAnalysisRA = new_msa
repointed += 1
logger.debug(
"ChangeAffixVariant: repointed %d sense(s) from old MSA Hvo=%s "
"to new MSA Hvo=%s.",
repointed,
msa.Hvo,
new_msa.Hvo,
)
# --- Detach old MSA if no senses reference it any longer ---
# Check all senses in entry again after repointing.
still_referenced = any(
(s.MorphoSyntaxAnalysisRA is not None
and s.MorphoSyntaxAnalysisRA.Hvo == msa.Hvo)
for s in entry.SensesOS
)
if not still_referenced:
# LCM may have already cascade-deleted the old MSA when
# __CreateAndAttach overwrote the anchor sense's
# MorphoSyntaxAnalysisRA, since a sense ref alone keeps the
# MSA alive (cf. LT-14740 in OverridesLing_Lex.cs:1500).
# Mirror LCM's own guard: only Remove() when still valid.
if msa.IsValidObject:
with self._TransactionCM("Remove superseded MSA"):
entry.MorphoSyntaxAnalysesOC.Remove(msa)
logger.debug(
"ChangeAffixVariant: old MSA Hvo=%s removed from "
"MorphoSyntaxAnalysesOC (no senses remaining).",
msa.Hvo,
)
else:
logger.debug(
"ChangeAffixVariant: old MSA was already cascade-"
"deleted by LCM; no explicit Remove needed."
)
else:
logger.warning(
"ChangeAffixVariant: old MSA Hvo=%s is still referenced by "
"one or more senses after repointing and has been left in "
"MorphoSyntaxAnalysesOC. Call RemoveOrphaned() afterwards "
"to clean up any MSA that is truly unreferenced (issue #206).",
msa.Hvo,
)
return new_msa
# ------------------------------------------------------------------
# Orphan cleanup
# ------------------------------------------------------------------
@OperationsMethod
def RemoveOrphaned(self, entry=None, progress=None):
"""
Remove MSAs that are no longer referenced by any sense or morph
bundle.
SetPartOfSpeech and ChangeAffixVariant detach a sense's
MorphoSyntaxAnalysisRA from an old MSA when reassigning or
converting it, but the old MSA can remain in its owning entry's
MorphoSyntaxAnalysesOC. That is safe to leave in place ONLY if
nothing else still points at it. This method performs the
project-wide safety check and removes any MSA that is truly
unreferenced.
An MSA is considered orphaned iff it is referenced by NEITHER:
1. Any ILexSense.MorphoSyntaxAnalysisRA (entry-local senses), NOR
2. Any IWfiMorphBundle.MsaRA (project-wide, across all
interlinear texts).
Args:
entry: An ILexEntry (or HVO) to limit the *scanned* MSAs to
(only that entry's MorphoSyntaxAnalysesOC is examined for
removal candidates). Pass None (the default) to sweep
every entry in the project. In BOTH cases, the safety
check against morph bundles is performed project-wide --
scoping to a single entry never skips the bundle
cross-check, since a bundle anywhere in the project can
be the only thing keeping an entry-local MSA alive.
progress: Optional callback invoked as ``progress(current,
total)`` once per entry scanned, where ``total`` is the
number of entries in scope (1 if ``entry`` was supplied,
or the full entry count for a project-wide sweep). Pass
None (the default) for no progress reporting. Exceptions
raised by the callback are caught and logged, never
propagated -- a broken progress reporter should not abort
the sweep.
Returns:
RemoveOrphanedResult: namedtuple with ``removed_count``,
``kept_count``, ``removed`` (list[RemovedMSA]), and
``by_entry`` (list[EntryOrphanBreakdown]).
Raises:
FP_ReadOnlyError: If the project is not opened with write
enabled.
FP_ParameterError: If ``entry`` does not resolve to a valid
ILexEntry.
Notes:
- Back-refs checked are exactly MorphoSyntaxAnalysisRA (on
senses) and MsaRA (on morph bundles). LexemeFormOA /
AlternateFormsOS allomorphs and ILexEntryRef do NOT carry
MSA references and are intentionally not checked.
- Performance: morph-bundle references are gathered in ONE
pass over ``IWfiMorphBundleRepository.AllInstances()`` into
a set of referenced HVOs, then each candidate MSA is tested
against that set -- never an O(MSAs x bundles) nested scan.
- Guards ``IsValidObject`` before removal, mirroring the
cascade-delete guard already used by ChangeAffixVariant.
"""
self._EnsureWriteEnabled()
if entry is not None:
entries = [self.__ResolveEntry(entry)]
else:
entries = list(self.project.ObjectsIn(ILexEntryRepository))
# --- Project-wide morph-bundle reference set, built in ONE pass. ---
# Safety-first (issue #206): even an entry-scoped call must
# cross-check against ALL morph bundles project-wide, since a
# bundle in some other interlinear text can be the only thing
# keeping an otherwise entry-orphaned MSA alive.
bundle_msa_hvos = set()
for bundle in self.project.ObjectsIn(IWfiMorphBundleRepository):
msa = bundle.MsaRA
if msa is not None:
bundle_msa_hvos.add(msa.Hvo)
removed = []
by_entry = []
removed_count = 0
kept_count = 0
total = len(entries)
with self._TransactionCM("Remove orphaned MSAs"):
for i, entry_obj in enumerate(entries, start=1):
# Entry-local sense back-refs.
sense_msa_hvos = set()
for sense in entry_obj.SensesOS:
msa_ra = sense.MorphoSyntaxAnalysisRA
if msa_ra is not None:
sense_msa_hvos.add(msa_ra.Hvo)
entry_removed = 0
entry_kept = 0
# Snapshot the collection before mutating it -- removing
# from MorphoSyntaxAnalysesOC while iterating it directly
# would be unsafe.
candidate_msas = list(entry_obj.MorphoSyntaxAnalysesOC)
for msa in candidate_msas:
if msa.Hvo in sense_msa_hvos or msa.Hvo in bundle_msa_hvos:
entry_kept += 1
continue
if not msa.IsValidObject:
# Already gone (e.g. cascade-deleted); nothing to
# remove and nothing to count as kept.
continue
class_name = msa.ClassName
entry_obj.MorphoSyntaxAnalysesOC.Remove(msa)
removed.append(
RemovedMSA(entry_obj.Hvo, msa.Hvo, class_name)
)
entry_removed += 1
removed_count += entry_removed
kept_count += entry_kept
if entry_removed or entry_kept:
by_entry.append(
EntryOrphanBreakdown(
entry_obj.Hvo, entry_removed, entry_kept
)
)
if progress is not None:
try:
progress(i, total)
except Exception:
logger.debug(
"RemoveOrphaned: progress callback raised; "
"ignoring.",
exc_info=True,
)
logger.info(
"RemoveOrphaned: removed %d orphaned MSA(s), kept %d "
"still-referenced MSA(s) across %d entr%s.",
removed_count,
kept_count,
total,
"y" if total == 1 else "ies",
)
return RemoveOrphanedResult(removed_count, kept_count, removed, by_entry)
# ========== SYNC INTEGRATION METHODS ==========
#
# Closes issue #251 (spec feature-structure-sync-gap, task T6):
# MSAOperations previously had ZERO sync methods, so every MSA synced
# across projects carried a correct ClassName/POS but a permanently
# null feature structure -- an MoStemMsa's MsFeaturesOA, an
# MoInflAffMsa's InflFeatsOA, or an MoDerivAffMsa's From/ToMsFeaturesOA
# (contract C1). Shape mirrors NaturalClassOperations'
# GetSyncableProperties/ApplySyncableProperties (:1039/:1169), the
# reference implementation for this whole feature family, but the
# dispatch itself is unique to MSA: unlike a natural class (reached via
# PhonologicalDataOA.NaturalClassesOS, always base-``IPhNaturalClass``-
# typed), an MSA reached via ``entry.MorphoSyntaxAnalysesOC`` is
# likewise base-``IMoMorphSynAnalysis``-typed, so ``hasattr`` on any of
# ``MsFeaturesOA``/``InflFeatsOA``/``From``/``ToMsFeaturesOA`` is
# 0/2088 True under pythonnet (spec D5, live probe) -- discriminating on
# ``.ClassName`` (always visible on the base interface) and casting
# explicitly via ``_ResolveFeatureStrucOwner`` (C1) is the only fix that
# is not dead code.
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get dictionary of syncable properties for cross-project
synchronization of an MSA's feature structure(s).
Args:
item: An IMoStemMsa / IMoInflAffMsa / IMoDerivAffMsa /
IMoUnclassifiedAffixMsa (or its HVO/GUID -- resolved and
cast to the concrete interface via ``__GetMsaObject``,
contract C2).
Returns:
dict: Keyed by ``ClassName`` (frozen C1 table rows for MSA):
- ``MoStemMsa``: ``MsFeatures`` (C4 recursive-dict spec of
``MsFeaturesOA``) / ``MsFeaturesGuid`` (str GUID).
- ``MoInflAffMsa``: ``InflFeats`` / ``InflFeatsGuid``
(``InflFeatsOA``).
- ``MoDerivAffMsa``: BOTH ``FromMsFeatures``/
``FromMsFeaturesGuid`` (``FromMsFeaturesOA``) AND
``ToMsFeatures``/``ToMsFeaturesGuid`` (``ToMsFeaturesOA``) --
a derivational affix MSA has two independent feature-struct
slots (C1 ``slot="From"``/``"To"``), each captured
independently; either, both, or neither key-pair may be
present depending on which slots are actually populated.
- ``MoUnclassifiedAffixMsa``: always ``{}`` -- confirmed by the
live probe to carry NO feature-struct property at all (R2).
This ClassName is discriminated FIRST, before any resolver
call, so capturing one of these (routinely created by
``CreateUnclassifiedAffix``) never raises.
- Any other ``ClassName`` (e.g. ``MoDerivStepMsa`` -- out of
the C1 table by design): always ``{}``. The resolver is
never consulted for an out-of-table ClassName either, so
this defensive fallback cannot raise -- mirrors
``NaturalClassOperations.GetSyncableProperties``'s own
unknown-``ClassName`` fallback.
An owning property that is present but genuinely empty (an
``IFsFeatStruc`` with zero ``FeatureSpecsOC`` entries) still
emits BOTH its ``<Name>``/``<Name>Guid`` keys -- ``<Name>``
serialises to ``{"TypeGuid": ..., "specs": {}}``, never
``None`` (C4). A NULL owning property (e.g. ``MsFeaturesOA``
was never populated) omits both keys entirely -- gate on key
PRESENCE (C6), never on the value's truthiness.
Notes:
- Emits ONLY the four C1 MSA rows -- no plain scalar or
multistring MSA properties are captured here (POS
references are T7's territory via ``POSOperations``, not
this method's).
- Zero ``hasattr`` gates on any feature-struct property:
dispatch is entirely ``.ClassName``-driven, then delegates
to ``BaseOperations._ResolveFeatureStrucOwner`` (cast) and
``_GetFeatureStruc`` (recursive C4 serialize).
"""
msa = self.__GetMsaObject(item)
props = {}
class_name = msa.ClassName
if class_name == "MoUnclassifiedAffixMsa":
# R2 (lead ruling): MoUnclassifiedAffixMsa is EXCLUDED from
# FEATURE_STRUC_OWNER_TABLE and the resolver raises on it BY
# DESIGN -- but CreateUnclassifiedAffix (this very module)
# manufactures these routinely, so capture meets them in
# normal use. Discriminate here, BEFORE ever consulting the
# resolver, so routine capture of an unclassified affix MSA
# never raises. The live probe confirmed this ClassName
# carries no feature-struct property at all.
return props
if class_name == "MoStemMsa":
self.__CaptureFeatureStrucProp(props, msa, None, "MsFeatures")
elif class_name == "MoInflAffMsa":
self.__CaptureFeatureStrucProp(props, msa, None, "InflFeats")
elif class_name == "MoDerivAffMsa":
self.__CaptureFeatureStrucProp(props, msa, "From", "FromMsFeatures")
self.__CaptureFeatureStrucProp(props, msa, "To", "ToMsFeatures")
# else: ClassName outside the C1 table's four in-scope MSA rows
# (e.g. MoDerivStepMsa, excluded by C1 -- never created by this
# module). No feature-struct keys captured; the resolver is never
# called here, so this defensive fallback cannot raise.
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""
Apply syncable properties (from GetSyncableProperties) onto an MSA.
Handles the four C1 MSA feature-struct key-pairs
(``MsFeatures``/``MsFeaturesGuid``, ``InflFeats``/
``InflFeatsGuid``, ``FromMsFeatures``/``FromMsFeaturesGuid``,
``ToMsFeatures``/``ToMsFeaturesGuid``) directly; everything else in
``props`` (currently nothing, since ``GetSyncableProperties`` emits
only these keys, but a caller-constructed ``props`` dict may carry
more) is delegated to ``BaseOperations.ApplySyncableProperties``
unchanged.
Args:
item: Target MSA (already created + owned + GUID-assigned by
the caller), or its HVO/GUID (cast via ``__GetMsaObject``,
C2).
props: dict produced by GetSyncableProperties (or built by a
caller following the same shape).
ws_map: Optional source->target writing-system Id mapping.
Unused by the feature-struct branches (which resolve by
GUID, not writing system); passed through to the base
loop for forward compatibility with any future plain
scalar/multistring MSA property.
fill_gaps: Passed through to the base loop. Has no additional
effect on the feature-struct branches, which are always
purely additive/idempotent by GUID (mirrors
NaturalClassOperations' equivalent note).
Raises:
FP_ParameterError: If ``item`` is None, ``props`` is not a
dict, or (C7) a ``<Name>``/``<Name>Guid`` spec references a
feature, value, or feature-structure-type GUID that does
not exist in the target project -- naming the unresolved
GUID and instructing the caller to sync the feature system
first. Silently dropping a spec would leave the target's
MSA feature structure incomplete with no visible error
(same bug class as the NaturalClassOperations/#222
lineage).
Notes:
- The four feature-struct keys are POPPED out of ``props``
(via a filtered copy) BEFORE calling ``super()`` (C6):
``BaseOperations._apply_props_loop`` dispatches on
``isinstance(value, dict)`` and would otherwise route a C4
dict into the multi-writing-system multistring path and
silently drop it.
- Gates on KEY PRESENCE, never truthiness (C6): a present-but-
empty feature structure (``<Name>Guid`` set, ``<Name>``
absent/``{}``) is a real, empty-but-attached
``IFsFeatStruc`` on the source and must still create/attach
an empty struct on the target, not be treated as "source has
none".
- ``MoUnclassifiedAffixMsa`` (R2) and any out-of-C1-table
ClassName: no feature-struct branch runs; only the base
loop's (here, empty) pass-through has any effect.
"""
if item is None:
raise FP_ParameterError("ApplySyncableProperties: item is None")
if not isinstance(props, dict):
raise FP_ParameterError(
f"ApplySyncableProperties: props must be a dict, got "
f"{type(props).__name__}"
)
msa = self.__GetMsaObject(item)
class_name = msa.ClassName
# Pop the four feature-struct key-pairs out of props BEFORE
# calling super() (C6) -- BaseOperations._apply_props_loop
# dispatches a dict value into the multistring path and would
# drop a C4 dict silently at that layer instead of raising.
base_props = {
k: v for k, v in props.items() if k not in self.__FEATURE_STRUC_KEYS
}
super().ApplySyncableProperties(msa, base_props, ws_map, fill_gaps=fill_gaps)
if class_name == "MoUnclassifiedAffixMsa":
# R2: no feature-struct property on this ClassName; the
# resolver is never consulted, so this cannot raise.
return
if class_name == "MoStemMsa":
self.__ApplyFeatureStrucProp(msa, None, "MsFeatures", props)
elif class_name == "MoInflAffMsa":
self.__ApplyFeatureStrucProp(msa, None, "InflFeats", props)
elif class_name == "MoDerivAffMsa":
self.__ApplyFeatureStrucProp(msa, "From", "FromMsFeatures", props)
self.__ApplyFeatureStrucProp(msa, "To", "ToMsFeatures", props)
# else: ClassName outside the C1 table's four in-scope MSA rows --
# nothing to apply; the resolver is never consulted here either.
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
# The eight props keys handled directly by ApplySyncableProperties'
# feature-struct branches -- must be excluded from the base-loop
# pass-through (C6). Kept as one tuple so the pop-filter and any
# future audit share a single source of truth.
__FEATURE_STRUC_KEYS = (
"MsFeatures", "MsFeaturesGuid",
"InflFeats", "InflFeatsGuid",
"FromMsFeatures", "FromMsFeaturesGuid",
"ToMsFeatures", "ToMsFeaturesGuid",
)
def __CaptureFeatureStrucProp(self, props, msa, slot, key):
"""
Capture one C1 feature-struct row into ``props``, in place.
Args:
props: The dict being built by GetSyncableProperties;
mutated in place.
msa: The MSA object (any ClassName already confirmed by the
caller to have a row in FEATURE_STRUC_OWNER_TABLE for
this ``slot``).
slot: ``None`` | ``"From"`` | ``"To"`` -- passed straight
through to ``_ResolveFeatureStrucOwner`` (C1).
key: The props key stem (e.g. ``"MsFeatures"``) -- the C1
table's props-key column. ``f"{key}Guid"`` is the sibling
GUID key.
Notes:
- Delegates the owner/property resolution entirely to
``BaseOperations._ResolveFeatureStrucOwner`` -- no
``hasattr`` probe, no local cast.
- Only emits keys when the owning property is non-None (a
present-but-empty struct still emits both keys, since
``_GetFeatureStruc`` never returns ``None`` for a non-None
struct -- C4). A null owning property emits neither key,
which is the PRESENCE gate C6 requires on the apply side.
"""
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(msa, slot=slot)
struct = getattr(concrete_owner, prop_name)
if struct is not None:
props[key] = self._GetFeatureStruc(struct)
props[f"{key}Guid"] = str(struct.Guid)
def __ApplyFeatureStrucProp(self, msa, slot, key, props):
"""
Apply one C1 feature-struct row from ``props`` onto ``msa``, if
present.
Args:
msa: The MSA object (any ClassName already confirmed by the
caller to have a row in FEATURE_STRUC_OWNER_TABLE for
this ``slot``).
slot: ``None`` | ``"From"`` | ``"To"`` -- passed straight
through to ``_ResolveFeatureStrucOwner`` (C1).
key: The props key stem (e.g. ``"MsFeatures"``).
props: The ORIGINAL (unfiltered) props dict passed to
``ApplySyncableProperties`` -- read-only here.
Notes:
- Gates on KEY PRESENCE, never truthiness (C6):
``if key in props or guid_key in props`` -- a present-but-
empty source struct carries ``<Name>Guid`` with ``<Name>``
absent (or ``{}``), and must still create/attach an empty
target struct, not be skipped as "source has none".
- ``on_unresolved="raise"`` unconditionally (C7): an
unresolvable feature/value/type GUID must never be
silently dropped for an MSA sync -- a rule referencing an
incomplete MSA would otherwise fail to match anything with
no visible error (same policy as
``NaturalClassOperations.ApplySyncableProperties``).
"""
guid_key = f"{key}Guid"
if key in props or guid_key in props:
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(
msa, slot=slot
)
spec = props.get(key) or {}
struct_guid = props.get(guid_key)
self._ApplyFeatureStruc(
concrete_owner,
prop_name,
spec,
struct_guid=struct_guid,
on_unresolved="raise",
label=f"MSA ({msa.ClassName}, {prop_name})",
)
def __GetMsaObject(self, msa_or_hvo):
"""
Internal helper to resolve an MSA parameter to a concrete LCM
object, accepting an object, HVO (int), or GUID (str).
Casts to the concrete MSA interface -- ``IMoStemMsa`` /
``IMoInflAffMsa`` / ``IMoDerivAffMsa`` / ``IMoUnclassifiedAffixMsa``
-- by ``ClassName`` BEFORE returning (contract C2).
``FLExProject.Object(hvo_or_guid)`` returns a bare ``ICmObject``;
without this cast, a caller reaching ``GetSyncableProperties``/
``ApplySyncableProperties`` via an HVO or GUID string (rather than
an already-typed object from, e.g., ``sense.MorphoSyntaxAnalysisRA``)
would silently omit the feature-struct keys downstream wherever a
subtype-only member were read directly -- this module avoids that
specific failure mode by routing all subtype access through
``_ResolveFeatureStrucOwner`` (which casts internally regardless),
so the eager cast here gives every downstream caller a properly
concrete-typed object regardless of entry path.
CORRECTION (2026-09-08, cycle 2 of spec
260-environment-resolver-cast, task T5): this docstring previously
claimed ``Grammar/NaturalClassOperations.py``'s
``__GetNaturalClassObject``/``__GetPhonemeObject`` were "sibling C2
fix sites" implying they already cast the way this resolver does.
They do NOT -- both are plain ``isinstance(x, int)`` HVO/object
resolvers with no ``ClassName`` cast at all (confirmed by reading
``Grammar/NaturalClassOperations.py`` directly). That was a false
"already fixed" marker; do not rely on it. Those two resolvers are
part of the Class-A caller-usage re-triage tracked in
``specs/260-environment-resolver-cast/STATUS.md`` and have not been
fixed as of this correction.
Args:
msa_or_hvo: An MSA object, an ``LCMObjectWrapper``/
``PythonicWrapper`` wrapper (e.g. a ``MorphosyntaxAnalysis``
item from ``GetAll()``, unwrapped via ``_UnwrapLcm``, issue
#449), an HVO (``int``), or a GUID (``str``).
Returns:
The resolved MSA, cast to its concrete interface when its
``ClassName`` is one of the four recognised MSA subtypes.
Any other ``ClassName`` (e.g. ``MoDerivStepMsa``) is returned
unchanged -- ``.ClassName`` stays readable either way, and the
dispatching callers above treat an unrecognised ClassName as
a no-op, never a cast attempt.
"""
msa_or_hvo = self._UnwrapLcm(msa_or_hvo)
if isinstance(msa_or_hvo, (int, str)):
obj = self.project.Object(msa_or_hvo)
else:
obj = msa_or_hvo
class_name = getattr(obj, "ClassName", None)
cast_iface = {
"MoStemMsa": IMoStemMsa,
"MoInflAffMsa": IMoInflAffMsa,
"MoDerivAffMsa": IMoDerivAffMsa,
"MoUnclassifiedAffixMsa": IMoUnclassifiedAffixMsa,
}.get(class_name)
if cast_iface is not None:
return cast_iface(obj)
return obj
def __CreateAndAttach(self, sense, sandbox, factory_interface):
"""
Common MSA-creation flow: resolve service, create with sandbox
descriptor, attach to sense.
Uses clr.GetClrType(factory_interface) because pythonnet's
ServiceLocator.GetService overload needs the System.Type form of
the interface rather than the raw interface object.
"""
factory = self.project.project.ServiceLocator.GetService(
clr.GetClrType(factory_interface)
)
if factory is None:
raise FP_ParameterError(
f"{factory_interface.__name__} service is unavailable."
)
# Factory.Create(owner, sandbox) -- the owner is the sense's
# ENTRY, not the sense's direct Owner. For a subsense,
# sense.Owner is the parent sense, not the entry; LCM expects
# the enclosing ILexEntry, so walk up the ownership chain via
# OwnerOfClass(LexEntryTags.kClassId). Same idiom that
# LexSenseOperations.SetPartOfSpeech uses to resolve the owning
# entry. (issue #129)
entry = ILexEntry(sense.OwnerOfClass(LexEntryTags.kClassId))
with self._TransactionCM("Create and attach MSA"):
new_msa = factory.Create(entry, sandbox)
sense.MorphoSyntaxAnalysisRA = new_msa
return new_msa
def __TryResolveInflAffMsa(self, sense_or_msa):
"""
Return IMoInflAffMsa for sense_or_msa when it denotes one, else None.
Accepts a sense (object/HVO/wrapper) or an inflectional-affix MSA
directly. Non-inflectional MSAs and senses with no MSA yield None.
"""
obj = self.__Resolve(sense_or_msa)
try:
sense = ILexSense(obj)
except Exception:
sense = None
if sense is not None:
existing = sense.MorphoSyntaxAnalysisRA
if existing is None:
return None
try:
return IMoInflAffMsa(existing)
except Exception:
return None
try:
return IMoInflAffMsa(obj)
except Exception:
return None
def __ResolveSense(self, sense_or_hvo):
"""Resolve a sense parameter, accepting either an object or HVO."""
if isinstance(sense_or_hvo, int):
obj = self.project.Object(sense_or_hvo)
return ILexSense(obj)
# Pass through; assume the caller gave us a usable sense object
# or wrapper. Wrappers' _obj is unwrapped lazily by LCM via the
# operations they pass through to.
if hasattr(sense_or_hvo, "_obj"):
return sense_or_hvo._obj
return sense_or_hvo
def __Resolve(self, obj_or_hvo):
"""Generic resolve -- HVO -> object, wrapper -> unwrapped."""
if isinstance(obj_or_hvo, int):
return self.project.Object(obj_or_hvo)
if hasattr(obj_or_hvo, "_obj"):
return obj_or_hvo._obj
return obj_or_hvo
def __ResolveEntry(self, entry_or_hvo):
"""Resolve an entry parameter, accepting either an object or HVO."""
obj = self.__Resolve(entry_or_hvo)
try:
return ILexEntry(obj)
except Exception:
raise FP_ParameterError(
"entry must be an ILexEntry (or its HVO); "
f"got {obj!r}"
)