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_ParameterError,
)


[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 flexlibs2 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. Yields: Each compound rule or affix template object. 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: 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) 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: 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 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: 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) 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() @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. Yields: 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 (prefix/suffix slots) must be configured separately See Also: CreateCompoundRule, Delete, GetAllAffixTemplates """ 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 # ========== 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 if class_name in ("MoEndoCompound", "MoExoCompound"): if morph_data is not None: morph_data.CompoundRulesOS.Remove(rule) elif class_name == "MoInflAffixTemplate": # Template is owned by a PartOfSpeech owner = self._GetObject(rule.Owner.Hvo) if hasattr(owner, "AffixTemplatesOS"): owner.AffixTemplatesOS.Remove(rule) elif class_name in ("MoAdhocProhibGr", "MoAdhocProhibMorph", "MoAdhocProhibAllomorph"): if morph_data is not None: 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) 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) 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: >>> morph_data = project.lp.MorphologicalDataOA >>> if morph_data and morph_data.StrataOS.Count > 0: ... ruleOps.SetStratum(rule, morph_data.StrataOS[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"): if stratum is None: rule.StratumRA = None else: if isinstance(stratum, int): stratum = self.project.Object(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) if hasattr(rule, "Disabled"): rule.Disabled = bool(disabled) # ========== DUPLICATION ========== @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=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, copy reference sequences (slot assignments for affix templates). If False (default), only copy simple properties. 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) duplicate = factory.Create() morph_data = self.project.lp.MorphologicalDataOA if insert_after: idx = morph_data.CompoundRulesOS.IndexOf(source) morph_data.CompoundRulesOS.Insert(idx + 1, 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) duplicate = factory.Create() owner = self._GetObject(source.Owner.Hvo) if insert_after: idx = owner.AffixTemplatesOS.IndexOf(source) owner.AffixTemplatesOS.Insert(idx + 1, 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 HVO or object to LCM object.""" if isinstance(rule_or_hvo, int): return self.project.Object(rule_or_hvo) return rule_or_hvo 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)