Source code for flexicon.code.Grammar.MorphRuleOperations

#
#   MorphRuleOperations.py
#
#   Class: MorphRuleOperations
#          Morphological rule operations for FieldWorks Language Explorer
#          projects via SIL Language and Culture Model (LCM) API.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#
#   Morphological rules in the LCM data model are distributed across
#   several locations:
#
#   - Compound rules (MoEndoCompound, MoExoCompound):
#     Owned by MoMorphData.CompoundRulesOS
#
#   - Inflectional affix templates (MoInflAffixTemplate):
#     Owned by PartOfSpeech.AffixTemplatesOS (per POS in hierarchy)
#
#   - Ad hoc co-prohibitions (MoAdhocProhib subclasses):
#     Owned by MoMorphData.AdhocCoProhibitionsOC
#     (Separate API — different property interface from rules/templates)
#
#   - Affix processes (MoAffixProcess):
#     Owned by LexEntry as allomorphs (LexemeFormOA/AlternateFormsOS),
#     managed through AllomorphOperations, not this class.
#

# Import BaseOperations parent class
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable

# Import wrapper classes
from .affix_template import AffixTemplate
from .affix_template_collection import AffixTemplateCollection
from .compound_rule import CompoundRule
from .compound_rule_collection import CompoundRuleCollection

# Import FLEx LCM types
from SIL.LCModel import (
    IMoEndoCompoundFactory,
    IMoExoCompoundFactory,
    IMoInflAffixTemplateFactory,
    IPartOfSpeech,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

# Import flexlibs exceptions
from ..FLExProject import (
    FP_NullParameterError,  # noqa: F401  -- raised by _ValidateParam
    FP_ParameterError,
    FP_ReadOnlyError,  # noqa: F401  -- raised by _EnsureWriteEnabled
)

# IMoInflAffixTemplate reference sequences. clr reflection on
# ILcmReferenceSequence<IMoInflAffixSlot> (SIL.LCModel 11.0.0) shows
# IList.Insert(Int32, T) and ICollection.Add(T). There is no InsertAt.
_AFFIX_TEMPLATE_SLOT_SIDES = {
    "prefix": "PrefixSlotsRS",
    "suffix": "SuffixSlotsRS",
    "proclitic": "ProcliticSlotsRS",
    "enclitic": "EncliticSlotsRS",
}


[docs] class MorphRuleOperations(BaseOperations): """ Operations for managing morphological rules in a FieldWorks project. Morphological rules are distributed across the LCM data model: - **Compound rules** (MoMorphData.CompoundRulesOS): Define compound word formation patterns (endocentric and exocentric). - **Affix templates** (PartOfSpeech.AffixTemplatesOS): Define inflectional affix template morphology per part of speech. - **Ad hoc co-prohibitions** (MoMorphData.AdhocCoProhibitionsOC): Define morpheme co-occurrence restrictions (separate property interface). Usage:: from flexicon import FLExProject, MorphRuleOperations project = FLExProject() project.OpenProject("my project", writeEnabled=True) ruleOps = MorphRuleOperations(project) # Get all compound rules for rule in ruleOps.GetAllCompoundRules(): print(ruleOps.GetName(rule), rule.ClassName) # Get all affix templates across all parts of speech for template in ruleOps.GetAllAffixTemplates(): print(ruleOps.GetName(template)) # Get affix templates for a specific POS verb = posOps.Find("Verb") for template in ruleOps.GetAllAffixTemplatesForPOS(verb): print(ruleOps.GetName(template)) # Create a compound rule rule = ruleOps.CreateCompoundRule("Noun-Noun Compound") # Create an affix template on a POS template = ruleOps.CreateAffixTemplate(verb, "Verb Inflection") project.CloseProject() """ def __init__(self, project): """ Initialize MorphRuleOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ super().__init__(project) def _GetSequence(self, parent): """ Return the appropriate rule sequence for reordering. Supports two parent types: - MoMorphData → CompoundRulesOS - PartOfSpeech → AffixTemplatesOS Ad hoc co-prohibitions are in an owning collection (OC), not an owning sequence (OS), so reordering does not apply. """ if hasattr(parent, "CompoundRulesOS"): return parent.CompoundRulesOS elif hasattr(parent, "AffixTemplatesOS"): return parent.AffixTemplatesOS raise ValueError("Parent must be MoMorphData (for compound rules) or " "PartOfSpeech (for affix templates)") # ========== ENUMERATION ========== @wrap_enumerable @OperationsMethod def GetAll(self): """ Get all compound rules and affix templates in the project. Yields items from CompoundRulesOS and AffixTemplatesOS. All yielded items support GetName, GetDescription, and GetStratum. Ad hoc co-prohibitions are NOT included (different property interface); use GetAllAdhocCoProhibitions() separately. Returns: EnumerableWrapper[CompoundRule | AffixTemplate]: Each item is a ``CompoundRule`` or ``AffixTemplate`` wrapper object (NOT a raw ``IMoCompoundRule``/``IMoInflAffixTemplate``), yielded from ``GetAllCompoundRules()`` then ``GetAllAffixTemplates()``. Items can be passed straight back into other MorphRuleOperations methods (e.g. ``GetName(item)``, ``Delete(item)``, ``SetStratum(item, ...)``) -- resolvers unwrap the wrapper internally (issue #449). A caller performing a direct pythonnet cast, e.g. ``IMoInflAffixTemplate(item)``, must use ``item.lcm_object`` instead. Example: >>> ruleOps = MorphRuleOperations(project) >>> for rule in ruleOps.GetAll(): ... name = ruleOps.GetName(rule) ... print(f"{name} ({rule.ClassName})") Noun-Noun Compound (MoEndoCompound) Verb Inflection (MoInflAffixTemplate) See Also: GetAllCompoundRules, GetAllAffixTemplates, GetAllAdhocCoProhibitions """ yield from self.GetAllCompoundRules() yield from self.GetAllAffixTemplates() @OperationsMethod def GetAllCompoundRules(self): """ Get all compound rules from MoMorphData.CompoundRulesOS. Returns: CompoundRuleCollection[CompoundRule]: Collection of CompoundRule wrapper objects (MoEndoCompound or MoExoCompound). Example: >>> rules = ruleOps.GetAllCompoundRules() >>> for rule in rules: ... print(rule.name, rule.is_endo_compound) Noun-Noun True Notes: - Compound rules define patterns for compound word formation - MoEndoCompound: head is inside the compound - MoExoCompound: head is outside (e.g., exocentric compounds) - Returns empty collection if no morphological data defined - Returns CompoundRuleCollection (supports filtering and chaining) - Items can be passed straight back into other MorphRuleOperations methods -- resolvers unwrap the wrapper internally (issue #449). A direct pythonnet cast needs ``item.lcm_object``. See Also: CreateCompoundRule, GetAll, CompoundRuleCollection """ morph_data = self.project.lp.MorphologicalDataOA if morph_data is not None: wrapped = [CompoundRule(rule) for rule in morph_data.CompoundRulesOS] return CompoundRuleCollection(wrapped) return CompoundRuleCollection() @OperationsMethod def GetAllAffixTemplates(self): """ Get all inflectional affix templates from all parts of speech. Walks the entire POS hierarchy and returns templates from each POS as a smart collection with filtering capabilities. Returns: AffixTemplateCollection[AffixTemplate]: Collection of AffixTemplate wrapper objects from all parts of speech. Example: >>> templates = ruleOps.GetAllAffixTemplates() >>> verb_templates = templates.filter(name_contains='Verb') >>> prefix_templates = templates.with_prefix_slots() >>> for template in prefix_templates: ... print(template.name, template.prefix_slot_count) Notes: - Affix templates are owned by PartOfSpeech, not MoMorphData - Each POS can have its own set of templates - Subcategories are included (full hierarchy walk) - Returns AffixTemplateCollection (supports filtering and chaining) - Use with_prefix_slots(), with_suffix_slots(), etc. for filtering - Items can be passed straight back into other MorphRuleOperations methods -- resolvers unwrap the wrapper internally (issue #449). A direct pythonnet cast needs ``item.lcm_object``. See Also: GetAllAffixTemplatesForPOS, CreateAffixTemplate, GetAll, AffixTemplateCollection """ templates = [] pos_list = self.project.lp.PartsOfSpeechOA if pos_list is not None: for raw in pos_list.PossibilitiesOS: pos = IPartOfSpeech(raw) templates.extend(self.__WalkPOSForTemplates(pos)) wrapped = [AffixTemplate(t) for t in templates] return AffixTemplateCollection(wrapped) @OperationsMethod def GetAllAffixTemplatesForPOS(self, pos_or_hvo): """ Get affix templates for a specific part of speech (non-recursive). Args: pos_or_hvo: The IPartOfSpeech object or HVO. Returns: AffixTemplateCollection[AffixTemplate]: Collection of AffixTemplate wrapper objects on the given POS. Raises: FP_NullParameterError: If pos_or_hvo is None. Example: >>> verb = posOps.Find("Verb") >>> templates = ruleOps.GetAllAffixTemplatesForPOS(verb) >>> prefix = templates.with_prefix_slots() >>> for template in prefix: ... print(template.name) Notes: - Only returns templates directly owned by this POS - Does not include templates from sub-categories - Use GetAllAffixTemplates() to get templates from all POS - Returns AffixTemplateCollection (supports filtering and chaining) - Items can be passed straight back into other MorphRuleOperations methods -- resolvers unwrap the wrapper internally (issue #449). A direct pythonnet cast needs ``item.lcm_object``. See Also: GetAllAffixTemplates, CreateAffixTemplate, AffixTemplateCollection """ self._ValidateParam(pos_or_hvo, "pos_or_hvo") pos = self.__ResolveObject(pos_or_hvo) if hasattr(pos, "AffixTemplatesOS"): wrapped = [AffixTemplate(t) for t in pos.AffixTemplatesOS] return AffixTemplateCollection(wrapped) return AffixTemplateCollection() @wrap_enumerable @OperationsMethod def GetAllAdhocCoProhibitions(self): """ Get all ad hoc co-occurrence prohibitions from MoMorphData. These have a different property interface from compound rules and affix templates (no Name/Description). Use rule.ClassName to determine the subtype: MoAdhocProhibGr, MoAdhocProhibMorph, or MoAdhocProhibAllomorph. Returns: EnumerableWrapper[IMoAdhocProhib]: Each co-occurrence prohibition. Example: >>> for prohib in ruleOps.GetAllAdhocCoProhibitions(): ... print(prohib.ClassName) Notes: - These are in an owning collection (OC), not a sequence (OS) - Reordering methods do not apply to co-prohibitions - Not included in GetAll() due to different property interface See Also: GetAll """ morph_data = self.project.lp.MorphologicalDataOA if morph_data is not None: for prohib in morph_data.AdhocCoProhibitionsOC: yield prohib # ========== CREATION ========== @OperationsMethod def CreateCompoundRule(self, name, endocentric=True, description=None): """ Create a new compound rule in MoMorphData.CompoundRulesOS. Args: name (str): The name of the compound rule. endocentric (bool): If True (default), creates MoEndoCompound. If False, creates MoExoCompound. description (str, optional): Optional description. Returns: IMoCompoundRule: The newly created compound rule. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If name is None. FP_ParameterError: If name is empty or no morphological data. Example: >>> rule = ruleOps.CreateCompoundRule("Noun-Noun Compound") >>> exo = ruleOps.CreateCompoundRule("Verb-Noun", endocentric=False) Notes: - MoEndoCompound: head is inside the compound - MoExoCompound: head is outside the compound - New rules are added at the end of CompoundRulesOS See Also: CreateAffixTemplate, Delete, GetAllCompoundRules """ self._EnsureWriteEnabled() self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") morph_data = self.project.lp.MorphologicalDataOA if morph_data is None: raise FP_ParameterError("Project has no morphological data defined") wsHandle = self.project.project.DefaultAnalWs if endocentric: factory = self.project.project.ServiceLocator.GetService(IMoEndoCompoundFactory) else: factory = self.project.project.ServiceLocator.GetService(IMoExoCompoundFactory) with self._TransactionCM(f"Create compound rule '{name}'"): new_rule = factory.Create() morph_data.CompoundRulesOS.Add(new_rule) mkstr_name = TsStringUtils.MakeString(name, wsHandle) new_rule.Name.set_String(wsHandle, mkstr_name) if description: mkstr_desc = TsStringUtils.MakeString(description, wsHandle) new_rule.Description.set_String(wsHandle, mkstr_desc) return new_rule @OperationsMethod def CreateAffixTemplate(self, pos_or_hvo, name, description=None): """ Create a new inflectional affix template on a part of speech. Args: pos_or_hvo: The IPartOfSpeech object or HVO to own the template. name (str): The name of the template. description (str, optional): Optional description. Returns: IMoInflAffixTemplate: The newly created affix template. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If pos_or_hvo or name is None. FP_ParameterError: If name is empty. Example: >>> verb = posOps.Find("Verb") >>> template = ruleOps.CreateAffixTemplate(verb, "Verb Inflection") >>> print(ruleOps.GetName(template)) Verb Inflection Notes: - Templates are owned by PartOfSpeech, not MoMorphData - New templates are added at the end of AffixTemplatesOS - Slot assignments are made with AddSlotToTemplate See Also: CreateCompoundRule, Delete, GetAllAffixTemplates, AddSlotToTemplate """ self._EnsureWriteEnabled() self._ValidateParam(pos_or_hvo, "pos_or_hvo") self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") pos = self.__ResolveObject(pos_or_hvo) wsHandle = self.project.project.DefaultAnalWs factory = self.project.project.ServiceLocator.GetService(IMoInflAffixTemplateFactory) with self._TransactionCM(f"Create affix template '{name}'"): new_template = factory.Create() pos.AffixTemplatesOS.Add(new_template) mkstr_name = TsStringUtils.MakeString(name, wsHandle) new_template.Name.set_String(wsHandle, mkstr_name) if description: mkstr_desc = TsStringUtils.MakeString(description, wsHandle) new_template.Description.set_String(wsHandle, mkstr_desc) return new_template @OperationsMethod def AddSlotToTemplate(self, template, slot, side, index=None): """ Insert an affix slot into one side of an inflectional template. Access via FLExProject as ``project.MorphRules.AddSlotToTemplate``. The slot must already be in the template category's AllAffixSlots: that category's own slots, or a slot owned by an ancestor category (see ``project.POS.CreateAffixSlot``). The four sides are reference sequences, so this method does not create the slot. Args: template: The IMoInflAffixTemplate object or HVO. slot: The IMoInflAffixSlot object, its HVO, or the AffixSlot wrapper returned by ``project.POS.CreateAffixSlot``. side (str): ``prefix``, ``suffix``, ``proclitic``, or ``enclitic``. Case-insensitive. index (int, optional): Position to insert at. ``None`` (the default) appends. An index outside ``0..Count`` is rejected. Returns: IMoInflAffixTemplate: The template the slot was added to. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If template, slot, or side is None. FP_ParameterError: If side is not one of the four sides, if the slot is not in the template category's AllAffixSlots, or if index is not an integer in range. Example: >>> slot = project.POS.CreateAffixSlot(verb, "PossConcord") >>> template = project.MorphRules.CreateAffixTemplate(verb, "Verb Inflection") >>> project.MorphRules.AddSlotToTemplate(template, slot, "prefix") See Also: CreateAffixTemplate """ self._EnsureWriteEnabled() self._ValidateParam(template, "template") self._ValidateParam(slot, "slot") self._ValidateParam(side, "side") if not isinstance(side, str): raise FP_ParameterError( "side must be one of prefix, suffix, proclitic, enclitic" ) prop_name = _AFFIX_TEMPLATE_SLOT_SIDES.get(side.lower()) if prop_name is None: raise FP_ParameterError( "side must be one of prefix, suffix, proclitic, enclitic" ) template = self.__ResolveObject(template) slot = self.__ResolveObject(slot) # Owner can be a bare ICmObject. __ResolveObject casts a # PartOfSpeech to IPartOfSpeech so AllAffixSlots is visible. pos = self.__ResolveObject(getattr(template, "Owner", None)) visible = None if getattr(pos, "ClassName", None) == "PartOfSpeech": visible = self.__AllAffixSlotHvos(pos) slot_hvo = getattr(slot, "Hvo", None) if ( visible is None or slot_hvo is None or int(slot_hvo) not in visible ): raise FP_ParameterError( "Affix slot must be in the template category's AllAffixSlots " "(the category itself or an ancestor)" ) sequence = getattr(template, prop_name) if index is None: insert_at = None elif isinstance(index, bool) or not isinstance(index, int): raise FP_ParameterError("index must be an integer or None") else: count = sequence.Count if index < 0 or index > count: raise FP_ParameterError( f"index {index} is out of range for a slot sequence of length {count}" ) insert_at = index with self._TransactionCM(f"Add affix slot to {prop_name}"): if insert_at is None: sequence.Add(slot) else: sequence.Insert(insert_at, slot) return template # ========== DELETION ========== @OperationsMethod def Delete(self, rule_or_hvo): """ Delete a morphological rule. Automatically determines the owning collection based on ClassName and removes the rule from its parent. Args: rule_or_hvo: The rule object or HVO to delete. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If rule_or_hvo is None. Example: >>> ruleOps.Delete(rule) Warning: - Deletion is permanent and cannot be undone - Deleting a rule that is referenced elsewhere may cause errors - Consider disabling instead of deleting (SetDisabled) See Also: CreateCompoundRule, CreateAffixTemplate, SetDisabled """ self._EnsureWriteEnabled() self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) class_name = rule.ClassName morph_data = self.project.lp.MorphologicalDataOA # One bracket per branch, each inside its own guard: an unmatched # class_name or a missing container must not open an empty named undo # entry that Phase 2 would Rollback(0). if class_name in ("MoEndoCompound", "MoExoCompound"): if morph_data is not None: with self._TransactionCM("Delete compound rule"): morph_data.CompoundRulesOS.Remove(rule) elif class_name == "MoInflAffixTemplate": # AffixTemplatesOS lives on IPartOfSpeech, not on the base # ICmObject returned from .Owner / _GetObject(Hvo). (issue #467) owner = self._GetTypedOwner(rule) if owner is not None and hasattr(owner, "AffixTemplatesOS"): with self._TransactionCM("Delete affix template"): owner.AffixTemplatesOS.Remove(rule) elif class_name in ("MoAdhocProhibGr", "MoAdhocProhibMorph", "MoAdhocProhibAllomorph"): if morph_data is not None: with self._TransactionCM("Delete ad hoc prohibition"): morph_data.AdhocCoProhibitionsOC.Remove(rule) # ========== PROPERTIES ========== @OperationsMethod def GetName(self, rule_or_hvo, wsHandle=None): """ Get the name of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The rule name, or empty string if not set. Raises: FP_NullParameterError: If rule_or_hvo is None. Example: >>> for rule in ruleOps.GetAll(): ... print(ruleOps.GetName(rule)) See Also: SetName, GetDescription """ self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) wsHandle = self.__WSHandle(wsHandle) name = ITsString(rule.Name.get_String(wsHandle)).Text return name or "" @OperationsMethod def SetName(self, rule_or_hvo, name, wsHandle=None): """ Set the name of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. name (str): The new name. wsHandle: Optional writing system handle. Defaults to analysis WS. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If rule_or_hvo or name is None. FP_ParameterError: If name is empty. Example: >>> ruleOps.SetName(rule, "Noun-Noun Compound") See Also: GetName, SetDescription """ self._EnsureWriteEnabled() self._ValidateParam(rule_or_hvo, "rule_or_hvo") self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") rule = self.__ResolveObject(rule_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(name, wsHandle) with self._TransactionCM(f"Set rule name '{name}'"): rule.Name.set_String(wsHandle, mkstr) @OperationsMethod def GetDescription(self, rule_or_hvo, wsHandle=None): """ Get the description of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The rule description, or empty string if not set. Raises: FP_NullParameterError: If rule_or_hvo is None. Example: >>> for rule in ruleOps.GetAll(): ... print(ruleOps.GetDescription(rule)) See Also: SetDescription, GetName """ self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) wsHandle = self.__WSHandle(wsHandle) desc = ITsString(rule.Description.get_String(wsHandle)).Text return desc or "" @OperationsMethod def SetDescription(self, rule_or_hvo, description, wsHandle=None): """ Set the description of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. description (str): The new description. wsHandle: Optional writing system handle. Defaults to analysis WS. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If rule_or_hvo or description is None. Example: >>> ruleOps.SetDescription(rule, "Forms compound nouns") See Also: GetDescription, SetName """ self._EnsureWriteEnabled() self._ValidateParam(rule_or_hvo, "rule_or_hvo") self._ValidateParam(description, "description") rule = self.__ResolveObject(rule_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(description, wsHandle) with self._TransactionCM("Set rule description"): rule.Description.set_String(wsHandle, mkstr) @OperationsMethod def GetStratum(self, rule_or_hvo): """ Get the stratum of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. Returns: IMoStratum or None: The stratum object if set, None otherwise. Raises: FP_NullParameterError: If rule_or_hvo is None. Example: >>> stratum = ruleOps.GetStratum(rule) >>> if stratum: ... print(stratum.Name.BestAnalysisAlternative.Text) Notes: - Both compound rules and affix templates can reference a stratum - Strata define ordering levels for rule application - Returns None if no stratum is assigned See Also: SetStratum """ self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) if hasattr(rule, "StratumRA") and rule.StratumRA: return rule.StratumRA return None @OperationsMethod def SetStratum(self, rule_or_hvo, stratum): """ Set the stratum of a morphological rule or template. Args: rule_or_hvo: The rule object or HVO. stratum: The IMoStratum object, HVO, or None to clear. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If rule_or_hvo is None. Example: >>> strata = list(project.Strata.GetAll()) >>> if strata: ... ruleOps.SetStratum(rule, strata[0]) >>> # Clear stratum assignment >>> ruleOps.SetStratum(rule, None) See Also: GetStratum """ self._EnsureWriteEnabled() self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) if hasattr(rule, "StratumRA"): # Resolution hoisted out of both branches so an unresolvable HVO # raises before the transaction opens (reference-setter decision, # batch 8), collapsing the two assignments into one bracket. if stratum is not None and isinstance(stratum, int): stratum = self.project.Object(stratum) with self._TransactionCM("Set rule stratum"): rule.StratumRA = stratum @OperationsMethod def IsDisabled(self, rule_or_hvo): """ Check if a morphological rule is disabled. Args: rule_or_hvo: The rule object or HVO. Returns: bool: True if the rule is disabled, False otherwise. Raises: FP_NullParameterError: If rule_or_hvo is None. Example: >>> for rule in ruleOps.GetAllCompoundRules(): ... status = "disabled" if ruleOps.IsDisabled(rule) else "active" ... print(f"{ruleOps.GetName(rule)}: {status}") Notes: - Returns False if the rule type does not have a Disabled property See Also: SetDisabled """ self._ValidateParam(rule_or_hvo, "rule_or_hvo") rule = self.__ResolveObject(rule_or_hvo) if hasattr(rule, "Disabled"): return rule.Disabled return False @OperationsMethod def SetDisabled(self, rule_or_hvo, disabled): """ Set the disabled state of a morphological rule. Args: rule_or_hvo: The rule object or HVO. disabled (bool): True to disable the rule, False to enable. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If rule_or_hvo or disabled is None. Example: >>> ruleOps.SetDisabled(rule, True) # Disable >>> ruleOps.SetDisabled(rule, False) # Enable Notes: - No-op if the rule type does not have a Disabled property See Also: IsDisabled """ self._EnsureWriteEnabled() self._ValidateParam(rule_or_hvo, "rule_or_hvo") self._ValidateParam(disabled, "disabled") rule = self.__ResolveObject(rule_or_hvo) # hasattr guard stays OUTSIDE the bracket: a rule type without the # property is a genuine no-op and must not open an empty unit of work. if hasattr(rule, "Disabled"): with self._TransactionCM("Set morphological rule disabled flag"): rule.Disabled = bool(disabled) # ========== DUPLICATION ========== @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=True, deep=True): """ Duplicate a morphological rule or template, creating a copy with a new GUID. Handles compound rules (MoEndoCompound, MoExoCompound) and affix templates (MoInflAffixTemplate). Uses the appropriate factory for each type and inserts into the correct owning collection. Args: item_or_hvo: The rule object or HVO to duplicate. insert_after (bool): If True (default), insert after the source. If False, insert at end of collection. deep (bool): If True (default), copy reference sequences (slot assignments for affix templates). If False, only copy simple properties. Note: this default (True) matches LexEntry/Text (deep by default); it differs from Media/Wordform (deep=False by default). See issue #203 -- the split is deliberate per object family, not an inconsistency to "fix" elsewhere. Returns: The newly created duplicate with a new GUID. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If item_or_hvo is None. FP_ParameterError: If the rule type is not supported for duplication. Example: >>> copy = ruleOps.Duplicate(compound_rule) >>> print(ruleOps.GetName(copy)) >>> # Deep copy affix template (includes slot references) >>> copy = ruleOps.Duplicate(template, deep=True) Notes: - Factory.Create() automatically generates a new GUID - Simple properties copied: Name, Description (MultiString) - Reference property copied: StratumRA - Boolean properties copied: Disabled, HeadLast, Final (where applicable) - deep=True for affix templates copies PrefixSlotsRS, SuffixSlotsRS, ProcliticSlotsRS, EncliticSlotsRS (references to existing slot objects) See Also: CreateCompoundRule, CreateAffixTemplate, Delete """ self._EnsureWriteEnabled() self._ValidateParam(item_or_hvo, "item_or_hvo") source = self.__ResolveObject(item_or_hvo) class_name = source.ClassName with self._TransactionCM(f"Duplicate {class_name}"): # Create duplicate and add to owning collection if class_name in ("MoEndoCompound", "MoExoCompound"): duplicate = self.__DuplicateCompoundRule(source, class_name, insert_after) elif class_name == "MoInflAffixTemplate": duplicate = self.__DuplicateAffixTemplate(source, insert_after) else: raise FP_ParameterError(f"Unsupported rule type for duplication: {class_name}") # Copy MultiString properties (AFTER adding to parent) duplicate.Name.CopyAlternatives(source.Name) duplicate.Description.CopyAlternatives(source.Description) # Copy Reference Atomic (RA) properties if hasattr(source, "StratumRA") and source.StratumRA: duplicate.StratumRA = source.StratumRA # Copy boolean properties (where applicable) if hasattr(source, "Disabled"): duplicate.Disabled = source.Disabled if hasattr(source, "HeadLast"): duplicate.HeadLast = source.HeadLast if hasattr(source, "Final"): duplicate.Final = source.Final # Deep copy: reference sequences for affix templates if deep and class_name == "MoInflAffixTemplate": for slot_prop in ("PrefixSlotsRS", "SuffixSlotsRS", "ProcliticSlotsRS", "EncliticSlotsRS"): if hasattr(source, slot_prop) and hasattr(duplicate, slot_prop): src_slots = getattr(source, slot_prop) dst_slots = getattr(duplicate, slot_prop) for slot in src_slots: dst_slots.Add(slot) return duplicate def __DuplicateCompoundRule(self, source, class_name, insert_after): """Create and insert a duplicate compound rule.""" if class_name == "MoEndoCompound": factory = self.project.project.ServiceLocator.GetService(IMoEndoCompoundFactory) else: factory = self.project.project.ServiceLocator.GetService(IMoExoCompoundFactory) # Reached only from inside Duplicate's bracket, so this transaction # joins that one (nesting-aware per B1). Stated anyway so the site is # grep-auditable per D5. with self._TransactionCM("Duplicate compound rule"): duplicate = factory.Create() morph_data = self.project.lp.MorphologicalDataOA if insert_after: # Index by HVO (issue #537). CompoundRulesOS can yield bare interface # views whose Python identity differs from source. rule_list = list(morph_data.CompoundRulesOS) target_hvo = source.Hvo source_index = None for i, rule in enumerate(rule_list): if rule.Hvo == target_hvo: source_index = i break if source_index is None: insert_index = len(rule_list) else: insert_index = source_index + 1 morph_data.CompoundRulesOS.Insert(insert_index, duplicate) else: morph_data.CompoundRulesOS.Add(duplicate) return duplicate def __DuplicateAffixTemplate(self, source, insert_after): """Create and insert a duplicate affix template on the same POS.""" factory = self.project.project.ServiceLocator.GetService(IMoInflAffixTemplateFactory) # Reached only from inside Duplicate's bracket, so this transaction # joins that one (nesting-aware per B1). Stated anyway so the site is # grep-auditable per D5. with self._TransactionCM("Duplicate affix template"): duplicate = factory.Create() owner = self._GetTypedOwner(source) if owner is None: raise FP_ParameterError("Affix template has no owning Part of Speech") if insert_after: # Index by HVO (issue #537). AffixTemplatesOS can yield bare interface # views whose Python identity differs from source. template_list = list(owner.AffixTemplatesOS) target_hvo = source.Hvo source_index = None for i, tmpl in enumerate(template_list): if tmpl.Hvo == target_hvo: source_index = i break if source_index is None: insert_index = len(template_list) else: insert_index = source_index + 1 owner.AffixTemplatesOS.Insert(insert_index, duplicate) else: owner.AffixTemplatesOS.Add(duplicate) return duplicate # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get dictionary of syncable properties for cross-project synchronization. Args: item: The rule or template object. Returns: dict: Dictionary mapping property names to their values. Example: >>> props = ruleOps.GetSyncableProperties(rule) >>> print(props.keys()) dict_keys(['Name', 'Description', 'StratumGuid', 'Disabled']) Notes: - Returns all MultiString properties (all writing systems) - Returns boolean properties (Disabled, HeadLast, Final) - Returns StratumGuid as string (GUID of referenced stratum) """ rule = self.__ResolveObject(item) # Get all writing systems for MultiString properties # Fix: ILgWritingSystemFactory does not expose a .WritingSystems # property; enumerate via the wrapper's WritingSystemOperations.GetAll(), # which returns CoreWritingSystemDefinition objects with .Id / .Handle. all_ws = {ws.Id: ws.Handle for ws in self.project.WritingSystems.GetAll()} props = {} # MultiString properties for prop_name in ["Name", "Description"]: if hasattr(rule, prop_name): prop_obj = getattr(rule, prop_name) ws_values = {} for ws_id, ws_handle in all_ws.items(): text = ITsString(prop_obj.get_String(ws_handle)).Text if text: ws_values[ws_id] = text if ws_values: props[prop_name] = ws_values # Boolean properties for prop_name in ["Disabled", "HeadLast", "Final"]: if hasattr(rule, prop_name): props[prop_name] = getattr(rule, prop_name) # Reference Atomic (RA) properties - return GUID as string if hasattr(rule, "StratumRA") and rule.StratumRA: props["StratumGuid"] = str(rule.StratumRA.Guid) return props @OperationsMethod def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False): """Apply syncable properties (from GetSyncableProperties) onto an item. Inherited from BaseOperations; declared on the concrete class so static API indexers see it. The base implementation handles every property shape this class's GetSyncableProperties emits. """ return super().ApplySyncableProperties(item, props, ws_map, fill_gaps=fill_gaps) @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two morphological rules and return detailed differences. Args: item1: First rule to compare (from source project). item2: Second rule to compare (from target project). ops1: Optional MorphRuleOperations instance for item1's project. Defaults to self. ops2: Optional MorphRuleOperations instance for item2's project. Defaults to self. Returns: tuple: (is_different, differences) where: - is_different (bool): True if items differ - differences (dict): Maps property names to (value1, value2) tuples Example: >>> is_diff, diffs = ruleOps.CompareTo(rule1, rule2) >>> if is_diff: ... for prop, (val1, val2) in diffs.items(): ... print(f"{prop}: {val1} -> {val2}") """ if ops1 is None: ops1 = self if ops2 is None: ops2 = self props1 = ops1.GetSyncableProperties(item1) props2 = ops2.GetSyncableProperties(item2) is_different = False differences = {} all_keys = set(props1.keys()) | set(props2.keys()) for key in all_keys: val1 = props1.get(key) val2 = props2.get(key) if val1 != val2: is_different = True differences[key] = (val1, val2) return (is_different, differences) # ========== PRIVATE HELPERS ========== def __ResolveObject(self, rule_or_hvo): """Resolve an HVO or object to a concretely-typed LCM object. Every resolved object is routed through ``cast_to_concrete`` (issue #561), replacing the previous ``ClassName == "PartOfSpeech"``-gated cast. That gate covered only one of the four types this class resolves: ``PartOfSpeech``, ``MoInflAffixTemplate``, ``MoEndoCompound`` and ``MoExoCompound``. The other three came back as a bare ``ICmObject``, on which no subtype-only member is reachable -- so a documented input shape (HVO) raised ``AttributeError: 'ICmObject' object has no attribute 'Name'`` on every call, e.g. ``Duplicate(project.Object(hvo))``. The ``hasattr``-guarded copies in ``Duplicate`` would likewise have been silently skipped rather than reported. ``cast_to_concrete`` is total -- an unrecognised ``ClassName`` (or a failed cast) yields the original object unchanged -- and ``lcm_casting``'s registry already carries all four ClassNames above, so this is a strict widening of the old gate rather than a behaviour change for any call that works today. ``rule_or_hvo`` may also be a ``CompoundRule``/``AffixTemplate`` wrapper item from ``GetAll()``/``GetAllCompoundRules()``/ ``GetAllAffixTemplates()``/``GetAllAffixTemplatesForPOS()`` (issue #449); such wrappers are unwrapped to their raw LCM object before any further processing, since a wrapper cannot be compared against or removed/indexed within a raw LCM sequence, and assigning through a wrapper's proxied setters (e.g. ``rule.StratumRA = ...``) is a silent no-op. """ from ..lcm_casting import cast_to_concrete rule_or_hvo = self._UnwrapLcm(rule_or_hvo) if isinstance(rule_or_hvo, int): obj = self.project.Object(rule_or_hvo) else: obj = rule_or_hvo return cast_to_concrete(obj) def __AllAffixSlotHvos(self, pos): """HVOs in ``IPartOfSpeech.AllAffixSlots``, or None if that property is absent. Confirmed on the live interface with ``clr.GetClrType``: ``AllAffixSlots`` is ``IEnumerable<IMoInflAffixSlot>``. It is the category's own affix slots plus the owner's ``AllAffixSlots``, walking up. There is no same-owner HVO fallback. """ import clr from SIL.LCModel import IPartOfSpeech as PartOfSpeechIface prop = clr.GetClrType(PartOfSpeechIface).GetProperty("AllAffixSlots") if prop is None: return None try: slots = pos.AllAffixSlots except AttributeError: return None if slots is None: return set() return {int(item.Hvo) for item in slots} def __WSHandle(self, wsHandle): """Get writing system handle, defaulting to analysis WS.""" if wsHandle is None: return self.project.project.DefaultAnalWs return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs) def __WalkPOSForTemplates(self, pos): """Recursively yield affix templates from a POS and its subcategories.""" for template in pos.AffixTemplatesOS: yield template for raw in pos.SubPossibilitiesOS: sub = IPartOfSpeech(raw) yield from self.__WalkPOSForTemplates(sub)