Source code for flexicon.code.Lexicon.AllomorphOperations

#
#   AllomorphOperations.py
#
#   Class: AllomorphOperations
#          Allomorph operations for FieldWorks Language Explorer
#          projects via SIL Language and Culture Model (LCM) API.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#

import logging
from collections import namedtuple

logger = logging.getLogger(__name__)

# --- Structured result for RemoveOrphaned (issue #231, slice 1) ------------

RemovedAlternateAllomorph = namedtuple(
    "RemovedAlternateAllomorph", ("entry_hvo", "allomorph_hvo", "class_name", "reason")
)
RemovedAlternateAllomorph.__doc__ = """
One alternate allomorph removed from ILexEntry.AlternateFormsOS by
AllomorphOperations.RemoveOrphaned.

Fields:
    entry_hvo (int): Hvo of the owning ILexEntry.
    allomorph_hvo (int): Hvo of the removed allomorph.
    class_name (str): ClassName of the removed allomorph.
    reason (str): Machine-readable removal reason (e.g. ``duplicate_lexeme``).
"""

EntryAlternateOrphanBreakdown = namedtuple(
    "EntryAlternateOrphanBreakdown", ("entry_hvo", "removed_count", "kept_count")
)

RemoveOrphanedAlternatesResult = namedtuple(
    "RemoveOrphanedAlternatesResult",
    ("removed_count", "kept_count", "removed", "by_entry"),
)
RemoveOrphanedAlternatesResult.__doc__ = """
Structured result for AllomorphOperations.RemoveOrphaned.
"""

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

# Import FLEx LCM types
from SIL.LCModel import (
    IMoStemAllomorph,
    IMoAffixAllomorph,
    IMoStemAllomorphFactory,
    IMoAffixAllomorphFactory,
    ILexEntry,
    ILexEntryRepository,
    IPhEnvironment,
    LexEntryTags,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

# Import flexlibs exceptions
from ..FLExProject import (
    FP_ParameterError,
)

# Import string utilities
from ..Shared.string_utils import normalize_text

# Import shared morph-type resolution utilities (single source of truth
# shared with LexEntryOperations.Create -- see issue #213/#214)
from ..Shared.morph_type_utils import find_morph_type, is_stem_morph_type, morph_type_not_found_error
from ..lcm_casting import cast_to_concrete

# Import wrapper classes
from .allomorph import Allomorph
from .allomorph_collection import AllomorphCollection


[docs] class AllomorphOperations(BaseOperations): """ This class provides operations for managing allomorphs in a FieldWorks project. Allomorphs are variant forms of morphemes that appear in different phonological or morphological contexts. For example, the English plural morpheme has allomorphs "-s", "-es", and "-en" (ox/oxen). Usage:: from flexicon import FLExProject, AllomorphOperations project = FLExProject() project.OpenProject("my project", writeEnabled=True) allomorphOps = AllomorphOperations(project) # Get entry entry = project.LexiconAllEntries()[0] # Get all allomorphs for an entry for allomorph in allomorphOps.GetAll(entry): form = allomorphOps.GetForm(allomorph) print(f"Allomorph: {form}") # Create a new allomorph morphType = project.lp.MorphTypesOA.PossibilitiesOS[0] allomorph = allomorphOps.Create(entry, "walk", morphType) # Set phonological environment env = project.lp.PhonologicalDataOA.EnvironmentsOS[0] allomorphOps.AddPhoneEnv(allomorph, env) project.CloseProject() """ def __init__(self, project): """ Initialize AllomorphOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ super().__init__(project) def _GetSequence(self, parent): """ Specify which sequence to reorder for allomorphs. For Allomorph, we reorder entry.AlternateFormsOS """ return parent.AlternateFormsOS @wrap_enumerable @OperationsMethod def GetAll(self, entry_or_hvo=None): """ Get all allomorphs for a lexical entry, or all allomorphs in the entire project. Returns a smart collection of wrapped allomorph objects that transparently handle the two concrete types (MoStemAllomorph and MoAffixAllomorph). Args: entry_or_hvo: The ILexEntry object or HVO. If None, iterates all allomorphs in the entire project. Returns: AllomorphCollection[Allomorph]: Smart collection of Allomorph wrapper objects showing type breakdown and supporting filtered queries. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> >>> # Get all allomorphs for specific entry >>> allomorphs = allomorphOps.GetAll(entry) >>> print(allomorphs) # Shows type breakdown # AllomorphCollection (8 total) # MoStemAllomorph: 5 (62%) # MoAffixAllomorph: 3 (38%) >>> >>> # Iterate through wrapped objects >>> for allomorph in allomorphs: ... print(f"Form: {allomorph.form}, Type: {allomorph.class_type}") Form: run, Type: MoStemAllomorph Form: ran, Type: MoStemAllomorph Form: running, Type: MoStemAllomorph >>> >>> # Filter by type >>> stems = allomorphs.stem_allomorphs() >>> print(f"Found {len(stems)} stem allomorphs") Found 5 stem allomorphs >>> >>> # Filter by form >>> ing_forms = allomorphs.filter(form_contains='ing') >>> for allomorph in ing_forms: ... print(allomorph.form) running >>> >>> # Chain filters >>> ing_stems = allomorphs.stem_allomorphs().filter(form_contains='ing') >>> >>> # Get ALL allomorphs in entire project >>> all_project_allomorphs = allomorphOps.GetAll() >>> print(f"Project has {len(all_project_allomorphs)} allomorphs") Notes: - Returns AllomorphCollection (smart collection) not raw generator - When entry_or_hvo is provided: - Returns the lexeme form first (if it exists) - Then returns all alternate forms - Order follows FLEx database order - Returns empty collection if entry has no allomorphs - When entry_or_hvo is None: - Iterates ALL entries in the project - For each entry, yields lexeme form then alternate forms - Useful for project-wide allomorph operations - AllomorphCollection shows type breakdown and supports filtering - Use convenience methods like stem_allomorphs() and affix_allomorphs() - Use filter() for form-based filtering - Use where() for complex custom filtering - Individual items are Allomorph wrapper objects with properties like form, environment, is_stem_allomorph, is_affix_allomorph - Items can be passed straight back into other AllomorphOperations methods (e.g. ``GetForm(item)``, ``SetForm(item, ...)``, ``Delete(item)``) -- the resolver unwraps the wrapper internally (issue #449). A caller performing a direct pythonnet cast, e.g. ``IMoStemAllomorph(item)``, must use ``item.lcm_object`` instead (``IMoStemAllomorph(item.lcm_object)``), since pythonnet cannot cast a Python wrapper instance. See Also: Create, GetForm, AllomorphCollection, Allomorph """ # Collect all allomorphs allomorphs = [] if entry_or_hvo is None: # Iterate ALL allomorphs in entire project for entry in self.project.lexDB.Entries: # First add the lexeme form if it exists if entry.LexemeFormOA: allomorphs.append(Allomorph(entry.LexemeFormOA)) # Then add all alternate forms for allomorph in entry.AlternateFormsOS: allomorphs.append(Allomorph(allomorph)) else: # Iterate allomorphs for specific entry entry = self.__GetEntryObject(entry_or_hvo) # First add the lexeme form if it exists if entry.LexemeFormOA: allomorphs.append(Allomorph(entry.LexemeFormOA)) # Then add all alternate forms for allomorph in entry.AlternateFormsOS: allomorphs.append(Allomorph(allomorph)) # Return smart collection return AllomorphCollection(allomorphs) @OperationsMethod def Create(self, entry_or_hvo, form, morphType=None, wsHandle=None): """ Create a new allomorph for a lexical entry. Args: entry_or_hvo: The ILexEntry object or HVO. form (str): The allomorph form (e.g., "-ing", "walk", "pre-"). morphType (IMoMorphType | str | None): The morpheme type. Pass an IMoMorphType object, a name string (e.g. 'suffix', '=enclitic', '-prefix'), or None to inherit from the entry's LexemeFormOA morph type (matching FLEx GUI behaviour). Display markers are stripped automatically so UI-copied names resolve correctly. wsHandle: Optional writing system handle. Defaults to vernacular WS. Returns: IMoForm: The newly created allomorph object. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If entry_or_hvo or form is None. FP_ParameterError: If form is empty or entry has no LexemeFormOA when morphType is not provided. Example: >>> # Create allomorph with inherited morph type (default) >>> entry = project.LexEntry.Create("run") >>> allomorph = project.Allomorphs.Create(entry, "running") >>> print(project.Allomorphs.GetForm(allomorph)) running >>> # Create with explicit morph type >>> allomorph = project.Allomorphs.Create(entry, "ran", morphType="prefix") >>> # Create with specific writing system >>> allomorph = project.Allomorphs.Create(entry, "rʌn", ... wsHandle=project.WSHandle('en-fonipa')) Notes: - The allomorph is added to the entry's alternate forms list - If the entry has no lexeme form, this becomes the lexeme form - Otherwise, it's added as an alternate form - By default, inherits morph type from entry's LexemeFormOA (matches FLEx) - Correct allomorph class (MoStemAllomorph vs MoAffixAllomorph) is automatically chosen based on morph type See Also: Delete, GetAll, SetForm """ self._EnsureWriteEnabled() self._ValidateParam(entry_or_hvo, "entry_or_hvo") self._ValidateParam(form, "form") self._ValidateStringNotEmpty(form, "form") entry = self.__GetEntryObject(entry_or_hvo) wsHandle = self.__WSHandle(wsHandle) # Accept morph type as a string name (with or without display markers). # Uses the SAME resolver as LexEntryOperations.Create (see issue #213) # so '=enclitic', 'proclitic=', '-suffix', etc. all resolve identically # regardless of which operations class is used. if isinstance(morphType, str): resolved = find_morph_type(self.project, morphType) if resolved is None: raise FP_ParameterError(morph_type_not_found_error(morphType)) morphType = resolved # If no morphType provided, inherit from entry's LexemeFormOA (FLEx behavior) if morphType is None: if not entry.LexemeFormOA or not entry.LexemeFormOA.MorphTypeRA: raise FP_ParameterError( "Cannot inherit morph type: entry has no LexemeFormOA with MorphTypeRA. " "Either provide morphType parameter or ensure entry has a lexeme form." ) morphType = entry.LexemeFormOA.MorphTypeRA # Create the new allomorph using the appropriate factory based on morph type if self.__IsStemType(morphType): factory = self.project.project.ServiceLocator.GetService(IMoStemAllomorphFactory) else: factory = self.project.project.ServiceLocator.GetService(IMoAffixAllomorphFactory) with self._TransactionCM(f"Create allomorph '{form}'"): allomorph = factory.Create() # Add to entry (must be done before setting properties) # If no lexeme form, set as lexeme, else add as alternate if not entry.LexemeFormOA: entry.LexemeFormOA = allomorph else: entry.AlternateFormsOS.Add(allomorph) # Set form mkstr = TsStringUtils.MakeString(form, wsHandle) allomorph.Form.set_String(wsHandle, mkstr) # Set morph type allomorph.MorphTypeRA = morphType return allomorph @OperationsMethod def Delete(self, allomorph_or_hvo): """ Delete an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO to delete. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo is None. FP_ParameterError: If the allomorph is in use or cannot be deleted. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if len(allomorphs) > 1: ... allomorphOps.Delete(allomorphs[-1]) Warning: - Deleting an allomorph that is in use in analyses may cause issues - If deleting the lexeme form and alternates exist, the first alternate becomes the new lexeme form - Deletion is permanent and cannot be undone - Consider checking usage in texts before deletion Notes: - Removes the allomorph from the entry's forms collection - All references to this allomorph in analyses are affected See Also: Create, GetAll """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) # Get the owning entry. allomorph.Owner is typed as ICmObject and # does not expose LexemeFormOA / AlternateFormsOS; cast to the # concrete interface so the typed collections are reachable. owner = self._GetTypedOwner(allomorph) if owner is None: raise FP_ParameterError("Allomorph has no owning entry") # Check if this is the lexeme form or an alternate with self._TransactionCM("Delete allomorph"): if hasattr(owner, "LexemeFormOA") and owner.LexemeFormOA == allomorph: # Deleting the lexeme form # If there are alternates, promote the first one to lexeme if owner.AlternateFormsOS.Count > 0: new_lexeme = owner.AlternateFormsOS[0] owner.AlternateFormsOS.RemoveAt(0) owner.LexemeFormOA = new_lexeme else: # No alternates, just clear the lexeme form owner.LexemeFormOA = None elif hasattr(owner, "AlternateFormsOS"): # Deleting an alternate form owner.AlternateFormsOS.Remove(allomorph) @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=True, deep=False): """ Duplicate an allomorph, creating a new copy with a new GUID. Args: item_or_hvo: The IMoForm object or HVO to duplicate. insert_after (bool): If True (default), insert after the source allomorph. If False, insert at end of parent's alternate forms list. deep (bool): Accepted for API uniformity across Operations classes. Allomorph has no owned objects, so this parameter is ignored. Returns: IMoForm: The newly created duplicate allomorph with a new GUID. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If item_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if len(allomorphs) > 1: # Don't duplicate lexeme form ... # Duplicate an alternate form ... dup = allomorphOps.Duplicate(allomorphs[1]) ... print(f"Original: {allomorphOps.GetGuid(allomorphs[1])}") ... print(f"Duplicate: {allomorphOps.GetGuid(dup)}") ... print(f"Form: {allomorphOps.GetForm(dup)}") Original: 12345678-1234-1234-1234-123456789abc Duplicate: 87654321-4321-4321-4321-cba987654321 Form: walk Notes: - Factory.Create() automatically generates a new GUID - Factory type determined by source ClassName (MoStemAllomorph, MoAffixAllomorph, etc.) - insert_after=True preserves the original allomorph's position/priority - Simple properties copied: Form (MultiString), IsAbstract (bool) - Reference properties copied: MorphTypeRA, PhoneEnvRC - Allomorphs have no owned objects, so deep parameter has no effect - Only duplicates alternate forms, not lexeme forms See Also: Create, Delete, GetGuid """ self._EnsureWriteEnabled() self._ValidateParam(item_or_hvo, "item_or_hvo") # Get source allomorph and parent. source.Owner is typed as # ICmObject; cast to the concrete owning entry so AlternateFormsOS # and LexemeFormOA are reachable and the duplicate actually gets # attached (raw .Owner.AlternateFormsOS would orphan the dup). source = self.__GetAllomorphObject(item_or_hvo) parent = self._GetTypedOwner(source) if parent is None: raise FP_ParameterError("Allomorph has no owning entry") # Determine the factory type based on the source's ClassName class_name = source.ClassName factory = None # PhoneEnvRC is declared on the concrete allomorph interfaces, not # on the base IMoForm that __GetAllomorphObject returns; cast # `source` to whichever concrete type class_name identifies so # PhoneEnvRC is reachable below. concrete_source = None if class_name == "MoStemAllomorph": from SIL.LCModel import IMoStemAllomorph, IMoStemAllomorphFactory factory = self.project.project.ServiceLocator.GetService(IMoStemAllomorphFactory) concrete_source = IMoStemAllomorph(source) elif class_name == "MoAffixAllomorph": from SIL.LCModel import IMoAffixAllomorph, IMoAffixAllomorphFactory factory = self.project.project.ServiceLocator.GetService(IMoAffixAllomorphFactory) concrete_source = IMoAffixAllomorph(source) else: # Unrecognized allomorph type - raise error instead of defaulting raise FP_ParameterError( f"Unrecognized allomorph type: {class_name}. " f"Expected 'MoStemAllomorph' or 'MoAffixAllomorph'." ) # Create new allomorph using factory (auto-generates new GUID) with self._TransactionCM("Duplicate allomorph"): duplicate = factory.Create() # Determine insertion position # Note: Allomorphs can be lexeme forms or alternate forms if hasattr(parent, "LexemeFormOA") and parent.LexemeFormOA == source: # Source is lexeme form - add duplicate as alternate form if insert_after: parent.AlternateFormsOS.Insert(0, duplicate) else: parent.AlternateFormsOS.Add(duplicate) elif hasattr(parent, "AlternateFormsOS"): # Source is alternate form if insert_after: source_index = parent.AlternateFormsOS.IndexOf(source) parent.AlternateFormsOS.Insert(source_index + 1, duplicate) else: parent.AlternateFormsOS.Add(duplicate) # Copy simple MultiString properties (AFTER adding to parent) duplicate.Form.CopyAlternatives(source.Form) # Copy atomic properties duplicate.IsAbstract = bool(source.IsAbstract) # Copy Reference Atomic (RA) properties duplicate.MorphTypeRA = source.MorphTypeRA # Copy Reference Collection (RC) properties for env in concrete_source.PhoneEnvRC: duplicate.PhoneEnvRC.Add(env) return duplicate # ------------------------------------------------------------------ # Orphan cleanup (issue #231 -- lex-lead rulings, slices 1-2) # ------------------------------------------------------------------ @OperationsMethod def RemoveOrphaned(self, entry=None, progress=None): """ Remove spurious allomorphs from ``ILexEntry.AlternateFormsOS``. Lexeme-form promotion and other entry edits can leave the lexeme-form object listed twice: once on ``LexemeFormOA`` and again in ``AlternateFormsOS``. Those duplicates are safe to drop from the alternates list only -- the lexeme form itself is untouched. Cascade deletes and partial saves can also leave **stale** handles in ``AlternateFormsOS`` where ``IsValidObject`` is false. Those list slots are removed as well (they are not counted as kept alternates). A project-wide ``IWfiMorphBundle.MorphRA``-aware unused-allomorph sweep (alternates with no interlinear link but still valid objects) and the example-sentence / feature-structure gaps in issue #231 remain out of scope until lex-domain confirms each back-ref set. Alternates that are valid but not yet referenced in any text are not treated as orphans here. Args: entry: An ``ILexEntry`` (or HVO) to limit the scan to one entry. Pass ``None`` (default) to sweep every entry in the project. progress: Optional callback ``progress(current, total)`` invoked once per entry scanned. Exceptions from the callback are logged and ignored. Returns: RemoveOrphanedAlternatesResult: ``removed_count``, ``kept_count``, ``removed`` (list of ``RemovedAlternateAllomorph``), and ``by_entry`` (list of ``EntryAlternateOrphanBreakdown``). Raises: FP_ReadOnlyError: If the project is not write-enabled. FP_ParameterError: If ``entry`` does not resolve to ``ILexEntry``. See Also: MSAOperations.RemoveOrphaned (issue #206) """ self._EnsureWriteEnabled() if entry is not None: entries = [self.__GetEntryObject(entry)] else: entries = list(self.project.ObjectsIn(ILexEntryRepository)) removed = [] by_entry = [] removed_count = 0 kept_count = 0 total = len(entries) with self._TransactionCM("Remove orphaned alternate allomorphs"): for i, entry_obj in enumerate(entries, start=1): lexeme = entry_obj.LexemeFormOA lexeme_hvo = lexeme.Hvo if lexeme is not None else None entry_removed = 0 entry_kept = 0 candidates = list(entry_obj.AlternateFormsOS) for allo in candidates: if not allo.IsValidObject: entry_obj.AlternateFormsOS.Remove(allo) removed.append( RemovedAlternateAllomorph( entry_obj.Hvo, getattr(allo, "Hvo", 0), getattr(allo, "ClassName", ""), "invalid_stale", ) ) entry_removed += 1 continue if lexeme_hvo is not None and allo.Hvo == lexeme_hvo: entry_obj.AlternateFormsOS.Remove(allo) removed.append( RemovedAlternateAllomorph( entry_obj.Hvo, allo.Hvo, allo.ClassName, "duplicate_lexeme", ) ) entry_removed += 1 else: entry_kept += 1 removed_count += entry_removed kept_count += entry_kept if entry_removed or entry_kept: by_entry.append( EntryAlternateOrphanBreakdown( 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 spurious alternate(s), kept %d " "alternate(s) across %d entr%s.", removed_count, kept_count, total, "y" if total == 1 else "ies", ) return RemoveOrphanedAlternatesResult( removed_count, kept_count, removed, by_entry ) # ========== SYNC INTEGRATION METHODS ========== # # T8 (spec feature-structure-sync-gap, unfiled P0 -- spec.md:655; NO # GitHub issue, filing one is an outstanding USER decision, never # reference an issue number for this task): adds capture/apply of # ``MsEnvFeaturesOA`` (the frozen C1 "MoAffixAllomorph" row in # ``FEATURE_STRUC_OWNER_TABLE``, ``Shared/lcm_constants.py``) and # fixes two independent, PRE-EXISTING defects verified live first-hand # by the lead (same triple shape as T6/T7's #251/#252 fixes): # # (i) ``GetSyncableProperties`` previously used ``item`` RAW instead # of routing through ``__GetAllomorphObject`` -- an HVO int made # every ``hasattr`` gate below False and silently returned # ``{"Form": {}, "MorphTypeRA": None}`` with no raise. Fixed by # resolving ``item`` through the shared resolver first. # (ii) ``__GetAllomorphObject`` (the SHARED resolver used by 11 other # call sites in this module) returned ``self.project.Object(hvo)`` # UNCAST -- a bare ``ICmObject``. It now casts to # ``IMoStemAllomorph``/``IMoAffixAllomorph`` by ``ClassName`` # and NEVER raises on an unrecognized ``ClassName`` (unlike # ``Duplicate``, which does raise -- that raise is local to # ``Duplicate``; this shared resolver must stay permissive). # # ``MoAffixAllomorph`` has exactly ONE row in the C1 table (slot=None # -- there is no slot ambiguity for the allomorph family, unlike # ``MoDerivAffMsa``/``PartOfSpeech``). ``MoStemAllomorph`` has NO row # and is deliberately excluded from the C1 table's naming rule; # dispatch below is a POSITIVE ``if class_name == "MoAffixAllomorph"`` # check with no ``else`` and no explicit ``MoStemAllomorph`` guard, so # any other ``ClassName`` (including ``MoStemAllomorph``) falls # through emitting no feature-struct key and never reaching the # resolver -- mirrors ``MSAOperations.GetSyncableProperties``'s # out-of-table fallback. @OperationsMethod def GetSyncableProperties(self, item): """ Get all syncable properties of an allomorph for comparison. Args: item: The IMoForm object (allomorph), or its HVO (int) -- resolved and cast via ``__GetAllomorphObject`` (contract C2). Returns: dict: Dictionary mapping property names to their values: - MultiString properties as dicts {ws: text} - Atomic properties as simple values - Reference Atomic (RA) properties as GUID strings - Does NOT include Owning Sequence (OS) properties - For a ``MoAffixAllomorph`` ONLY (C1 table row, T8): ``MsEnvFeatures`` / ``MsEnvFeaturesGuid`` -- C4 recursive-dict spec of ``MsEnvFeaturesOA`` / str GUID. Emitted only when the owning property is non-None (C6 presence, not truthiness); a present-but-empty struct still emits both keys (C4 -- ``_GetFeatureStruc`` never returns ``None`` for a non-None struct). A ``MoStemAllomorph`` (or any other ``ClassName``) emits neither key and never reaches the resolver. Example: >>> allo = list(project.Allomorphs.GetAll(entry))[0] >>> props = project.Allomorphs.GetSyncableProperties(allo) >>> print(props['Form']) # MultiString {'en': 'run', 'fr': 'courir'} >>> print(props['IsAbstract']) # Boolean True Notes: - The three ``hasattr`` gates below (``Form``/``IsAbstract``/ ``MorphTypeRA``) are PRE-EXISTING and deliberately KEPT (redundant-but-harmless once ``__GetAllomorphObject`` casts, same allowlist ruling T7 applied to ``POSOperations``). ``MsEnvFeaturesOA`` capture is entirely ``.ClassName``-driven via ``BaseOperations._ResolveFeatureStrucOwner``/ ``_GetFeatureStruc`` -- zero ``hasattr`` probes on the feature-struct property itself (D5). """ allomorph = self.__GetAllomorphObject(item) props = {} # MultiString properties # Form - the allomorph form in various writing systems form_dict = {} if hasattr(allomorph, "Form"): for ws_def in self.project.WritingSystems.GetAll(): text = normalize_text(ITsString(allomorph.Form.get_String(ws_def.Handle)).Text) if text: form_dict[ws_def.Id] = text props["Form"] = form_dict # Atomic properties # IsAbstract - whether this is an abstract form if hasattr(allomorph, "IsAbstract"): props["IsAbstract"] = allomorph.IsAbstract # Reference Atomic (RA) properties # MorphTypeRA - morpheme type (prefix, suffix, stem, etc.) if hasattr(allomorph, "MorphTypeRA") and allomorph.MorphTypeRA: props["MorphTypeRA"] = str(allomorph.MorphTypeRA.Guid) else: props["MorphTypeRA"] = None # Feature-struct property (C1 "MoAffixAllomorph" table row, T8). # Positive ClassName dispatch only -- MoStemAllomorph has NO row # in FEATURE_STRUC_OWNER_TABLE and carries no MsEnvFeaturesOA # property at all (lead ruling R16-1); any other ClassName falls # through emitting no feature key and never reaching the resolver. if allomorph.ClassName == "MoAffixAllomorph": self.__CaptureFeatureStrucProp(props, allomorph, None, "MsEnvFeatures") return props @OperationsMethod def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False): """ Apply syncable properties (from GetSyncableProperties) onto an allomorph. Handles the single C1 "MoAffixAllomorph" feature-struct key-pair (``MsEnvFeatures``/``MsEnvFeaturesGuid``) directly; everything else in ``props`` (the pre-existing ``Form``/``IsAbstract``/ ``MorphTypeRA`` shape) is delegated to ``BaseOperations.ApplySyncableProperties`` unchanged. Args: item: Target allomorph (already created + owned + GUID- assigned by the caller), or its HVO (int) -- resolved and cast via ``__GetAllomorphObject`` (C2). props: dict produced by GetSyncableProperties (or built by a caller following the same shape). ws_map: Optional source->target writing-system Id mapping, passed through to the base loop. fill_gaps: Passed through to the base loop. Raises: FP_ParameterError: If ``item`` is None, ``props`` is not a dict, or (C7) the ``MsEnvFeatures``/``MsEnvFeaturesGuid`` spec references a feature, value, or feature-structure- type GUID that does not exist in the target project -- naming the unresolved GUID. Notes: - The two 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 (``MsEnvFeaturesGuid`` set, ``MsEnvFeatures`` absent/``{}``) is a real, empty-but- attached ``IFsFeatStruc`` on the source and must still create/attach an empty struct on the target. - Positive ``ClassName`` dispatch only (mirrors capture): the feature-struct branch runs ONLY when ``allomorph.ClassName == "MoAffixAllomorph"``; any other ``ClassName`` (including ``MoStemAllomorph``) never reaches the resolver. """ 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__}" ) allomorph = self.__GetAllomorphObject(item) # Pop the feature-struct key-pair 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(allomorph, base_props, ws_map, fill_gaps=fill_gaps) if allomorph.ClassName == "MoAffixAllomorph": self.__ApplyFeatureStrucProp(allomorph, None, "MsEnvFeatures", props) # ------------------------------------------------------------------ # Feature-struct sync internals (T8) # ------------------------------------------------------------------ # The single props key-pair handled directly by ApplySyncableProperties's # feature-struct branch -- 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 = ( "MsEnvFeatures", "MsEnvFeaturesGuid", ) def __CaptureFeatureStrucProp(self, props, allomorph, slot, key): """ Capture the C1 "MoAffixAllomorph" feature-struct row into ``props``, in place. Args: props: The dict being built by GetSyncableProperties; mutated in place. allomorph: The allomorph object (already resolved via ``__GetAllomorphObject``, and already confirmed by the caller to be a ``MoAffixAllomorph``). slot: ``None`` -- the "MoAffixAllomorph" C1 row has exactly one entry (unlike ``MoDerivAffMsa``/``PartOfSpeech``), so ``slot`` is always ``None``; passed straight through to ``_ResolveFeatureStrucOwner`` (C1) for symmetry with the sibling capture helpers. key: The props key stem (``"MsEnvFeatures"``) -- 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(allomorph, 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, allomorph, slot, key, props): """ Apply the C1 "MoAffixAllomorph" feature-struct row from ``props`` onto ``allomorph``, if present. Args: allomorph: The allomorph object (already resolved via ``__GetAllomorphObject``, and already confirmed by the caller to be a ``MoAffixAllomorph``). slot: ``None`` (see ``__CaptureFeatureStrucProp``). key: The props key stem (``"MsEnvFeatures"``). 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 ``MsEnvFeaturesGuid`` with ``MsEnvFeatures`` 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 allomorph sync. """ guid_key = f"{key}Guid" if key in props or guid_key in props: concrete_owner, prop_name = self._ResolveFeatureStrucOwner( allomorph, 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"Allomorph ({allomorph.ClassName}, {prop_name})", ) @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two allomorphs and return their differences. Args: item1: The first IMoForm object. item2: The second IMoForm object. ops1: Optional AllomorphOperations instance for item1 (for cross-project comparison). If None, uses self. ops2: Optional AllomorphOperations instance for item2 (for cross-project comparison). If None, uses self. Returns: tuple: (is_different, differences_dict) where: - is_different: True if items differ, False otherwise - differences_dict: Maps property names to (value1, value2) tuples for differing properties Example: >>> allo1 = list(project1.Allomorphs.GetAll(entry1))[0] >>> allo2 = list(project2.Allomorphs.GetAll(entry2))[0] >>> is_diff, diffs = project1.Allomorphs.CompareTo(allo1, allo2, ... project1.Allomorphs, ... project2.Allomorphs) >>> if is_diff: ... for prop, (val1, val2) in diffs.items(): ... print(f"{prop}: {val1} -> {val2}") """ # Use provided ops or default to self ops1 = ops1 or self ops2 = ops2 or self # Get syncable properties from both items props1 = ops1.GetSyncableProperties(item1) props2 = ops2.GetSyncableProperties(item2) differences = {} # Compare all properties all_keys = set(props1.keys()) | set(props2.keys()) for key in all_keys: val1 = props1.get(key) val2 = props2.get(key) # Handle MultiString comparison (dict comparison) if isinstance(val1, dict) and isinstance(val2, dict): if val1 != val2: differences[key] = (val1, val2) # Handle None values elif val1 != val2: differences[key] = (val1, val2) is_different = len(differences) > 0 return is_different, differences @OperationsMethod def GetForm(self, allomorph_or_hvo, wsHandle=None): """ Get the form (text) of an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. wsHandle: Optional writing system handle. Defaults to vernacular WS. Returns: str: The allomorph form, or empty string if not set. Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... form = allomorphOps.GetForm(allomorphs[0]) ... print(form) walk >>> # Get form in specific writing system >>> form_ipa = allomorphOps.GetForm(allomorphs[0], ... project.WSHandle('en-fonipa')) >>> print(form_ipa) wɔk Notes: - Returns empty string if form not set in specified writing system - Use different writing systems for pronunciation variants See Also: SetForm, GetAll """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) wsHandle = self.__WSHandle(wsHandle) form = ITsString(allomorph.Form.get_String(wsHandle)).Text return self._NormalizeMultiString(form) @OperationsMethod def SetForm(self, allomorph_or_hvo, form, wsHandle=None): """ Set the form (text) of an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. form (str): The new allomorph form. wsHandle: Optional writing system handle. Defaults to vernacular WS. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or form is None. FP_ParameterError: If form is empty. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... allomorphOps.SetForm(allomorphs[0], "walked") ... print(allomorphOps.GetForm(allomorphs[0])) walked >>> # Set IPA pronunciation >>> allomorphOps.SetForm(allomorphs[0], "wɔkt", ... project.WSHandle('en-fonipa')) Notes: - Changing the form affects all analyses using this allomorph - Parser may need to be rerun after form changes - Use different writing systems for different representations See Also: GetForm, Create """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(form, "form") self._ValidateStringNotEmpty(form, "form") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(form, wsHandle) with self._TransactionCM(f"Set allomorph form '{form}'"): allomorph.Form.set_String(wsHandle, mkstr) @OperationsMethod def SetFormAudio(self, allomorph_or_hvo, file_path, wsHandle=None): """ Set an audio recording for an allomorph's Form field. This is a convenience method for working with audio writing systems. The audio file is embedded as a file path reference in the Form field for an audio writing system (e.g., en-Zxxx-x-audio). Args: allomorph_or_hvo: The IMoForm object or HVO. file_path: Path to audio file. This should be either: - A path within LinkedFiles (e.g., "LinkedFiles/AudioVisual/audio.wav") - An external path (will be copied to LinkedFiles automatically) wsHandle: Optional audio writing system handle. If None, uses the first audio writing system found in the project. Returns: str: The internal path where the audio file was stored (relative to project). Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or file_path is None. FP_ParameterError: If no audio writing system is available, or if the wsHandle provided is not an audio writing system. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorph = list(allomorphOps.GetAll(entry))[0] >>> >>> # Set audio from external file (will be copied to LinkedFiles) >>> audio_path = allomorphOps.SetFormAudio( ... allomorph, ... "/path/to/recordings/pronunciation.wav" ... ) >>> print(f"Audio stored at: {audio_path}") LinkedFiles/AudioVisual/pronunciation.wav >>> # Set audio with specific audio writing system >>> audio_ws = project.WSHandle('en-Zxxx-x-audio') >>> allomorphOps.SetFormAudio(allomorph, "/path/to/audio.wav", audio_ws) Notes: - Audio writing systems use the Zxxx script code (ISO 15924 for "no written form") - Tag format: {lang}-Zxxx-x-audio (e.g., "en-Zxxx-x-audio") - The audio WS must already exist in the project (create it in FLEx UI first) - If file_path is external, it will be copied to LinkedFiles/AudioVisual - The file path is embedded as an ORC (Object Replacement Character) reference - FLEx will display this as a playable audio control in the UI - This is different from attaching media files via MediaFilesOS See Also: GetFormAudio, SetForm, project.SetAudioPath, project.IsAudioWritingSystem """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(file_path, "file_path") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) # Find or validate audio writing system if wsHandle is None: # Auto-detect first audio WS for ws_def in self.project.WritingSystems.GetAll(): if self.project.IsAudioWritingSystem(ws_def.Handle): wsHandle = ws_def.Handle break if wsHandle is None: raise FP_ParameterError( "No audio writing system found in project. " "Create one in FLEx first (e.g., en-Zxxx-x-audio)" ) else: # Validate that provided WS is audio if not self.project.IsAudioWritingSystem(wsHandle): raise FP_ParameterError( "The provided writing system is not an audio writing system. " "Use project.IsAudioWritingSystem() to check." ) # Determine if we need to copy the file to LinkedFiles import os internal_path = file_path # If file_path is an external file (not already in LinkedFiles) if os.path.isabs(file_path) or not file_path.startswith("LinkedFiles"): # Copy file to project using Media operations try: from ..Shared.MediaOperations import MediaOperations media_ops = MediaOperations(self.project) # Copy file to LinkedFiles/AudioVisual media_file = media_ops.CopyToProject(file_path, internal_subdir="AudioVisual") internal_path = media_ops.GetInternalPath(media_file) except Exception as e: # If Media operations fail, just use the path as-is logger.warning(f"Could not copy audio file to LinkedFiles: {e}") internal_path = file_path # Set audio path in Form field self.project.SetAudioPath(allomorph.Form, wsHandle, internal_path) return internal_path @OperationsMethod def GetFormAudio(self, allomorph_or_hvo, wsHandle=None): """ Get the audio file path from an allomorph's Form field. This is a convenience method for extracting audio file references from audio writing system fields. Args: allomorph_or_hvo: The IMoForm object or HVO. wsHandle: Optional audio writing system handle. If None, uses the first audio writing system found in the project. Returns: str: Path to audio file (relative to project root), or None if no audio is set. Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorph = list(allomorphOps.GetAll(entry))[0] >>> >>> # Get audio file path >>> audio_path = allomorphOps.GetFormAudio(allomorph) >>> if audio_path: ... print(f"Audio file: {audio_path}") ... # Construct full path if needed ... import os ... full_path = os.path.join( ... project.GetLinkedFilesDir(), ... audio_path.replace("LinkedFiles/", "") ... ) ... print(f"Full path: {full_path}") ... else: ... print("No audio recording") Audio file: LinkedFiles/AudioVisual/pronunciation.wav >>> # Get audio with specific writing system >>> audio_ws = project.WSHandle('en-Zxxx-x-audio') >>> audio_path = allomorphOps.GetFormAudio(allomorph, audio_ws) Notes: - Returns None if no audio writing system is found in the project - Returns None if the specified writing system has no audio data - The returned path is relative to the project root - Use project.GetLinkedFilesDir() to construct absolute paths - Audio must have been set using SetFormAudio() or project.SetAudioPath() - This only retrieves the file path; it doesn't play the audio See Also: SetFormAudio, GetForm, project.GetAudioPath, project.IsAudioWritingSystem """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) # Find audio writing system if not provided if wsHandle is None: for ws_def in self.project.WritingSystems.GetAll(): if self.project.IsAudioWritingSystem(ws_def.Handle): wsHandle = ws_def.Handle break if wsHandle is None: # No audio WS in project return None # Get audio path from Form field try: return self.project.GetAudioPath(allomorph.Form, wsHandle) except FP_ParameterError: # Not an audio writing system return None @OperationsMethod def GetMorphType(self, allomorph_or_hvo): """ Get the morpheme type of an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. Returns: IMoMorphType: The morpheme type (stem, prefix, suffix, etc.). Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... morphType = allomorphOps.GetMorphType(allomorphs[0]) ... # Get type name ... wsHandle = project.GetDefaultAnalysisWSHandle() ... type_name = ITsString(morphType.Name.get_String(wsHandle)).Text ... print(type_name) stem Notes: - Morpheme types include: stem, root, bound root, prefix, suffix, infix, circumfix, clitic, proclitic, enclitic, simulfix, etc. - Type determines parsing behavior and template slots - Returns the IMoMorphType object which can be queried for details See Also: SetMorphType, Create """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) return allomorph.MorphTypeRA @OperationsMethod def SetMorphType(self, allomorph_or_hvo, morphType): """ Set the morpheme type of an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. morphType: The new morpheme type (IMoMorphType object). Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or morphType is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... # Get a different morph type ... morphTypes = project.LexEntry.GetAvailableMorphTypes() ... prefix_type = next(mt for name, mt, is_stem in morphTypes ... if not is_stem and "prefix" in name.lower()) ... allomorphOps.SetMorphType(allomorphs[0], prefix_type) Notes: - Changing type affects parsing and morphological analysis - Incompatible changes may require template updates - Parser should be rerun after type changes See Also: GetMorphType, Create """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(morphType, "morphType") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) with self._TransactionCM("Set allomorph morph type"): allomorph.MorphTypeRA = morphType @OperationsMethod def GetIsAbstract(self, allomorph_or_hvo): """ Check whether an allomorph is an abstract underlying form. FLEx marks abstract underlying forms with the "Abstract form" checkbox; they matter for parser work. Args: allomorph_or_hvo: The IMoForm object or HVO. Accepts a lexeme form (``entry.LexemeFormOA``), an alternate form, an HVO int, or an ``Allomorph`` wrapper from ``GetAll()``. Returns: bool: True if this is an abstract form, False otherwise. Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... print(allomorphOps.GetIsAbstract(allomorphs[0])) False Notes: - Works for the lexeme form as well as alternate forms. - This is NOT a counterpart to the deprecated ``LexEntry.DoNotUseForParsing``; recipes should not rely on ``DoNotUseForParsing``. See Also: SetIsAbstract, GetSyncableProperties """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) return bool(allomorph.IsAbstract) @OperationsMethod def SetIsAbstract(self, allomorph_or_hvo, value): """ Set whether an allomorph is an abstract underlying form. Args: allomorph_or_hvo: The IMoForm object or HVO. Accepts a lexeme form (``entry.LexemeFormOA``), an alternate form, an HVO int, or an ``Allomorph`` wrapper from ``GetAll()``. value (bool): True to mark as abstract, False to clear. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or value is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... allomorphOps.SetIsAbstract(allomorphs[0], True) ... print(allomorphOps.GetIsAbstract(allomorphs[0])) True Notes: - Works for the lexeme form as well as alternate forms. See Also: GetIsAbstract, GetSyncableProperties """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(value, "value") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) with self._TransactionCM("Set allomorph abstract flag"): allomorph.IsAbstract = bool(value) @OperationsMethod def GetPhoneEnv(self, allomorph_or_hvo): """ Get the phonological environments for an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. Returns: list: List of IPhEnvironment objects (empty list if none). Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... envs = allomorphOps.GetPhoneEnv(allomorphs[0]) ... for env in envs: ... wsHandle = project.GetDefaultAnalysisWSHandle() ... name = ITsString(env.Name.get_String(wsHandle)).Text ... print(f"Environment: {name}") Environment: After voiceless consonant Notes: - Phonological environments define distribution of allomorphs - Empty list means allomorph appears in all contexts - Multiple environments are OR'd (any match allows allomorph) - Used by the parser to select appropriate allomorph See Also: AddPhoneEnv, RemovePhoneEnv """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) return list(allomorph.PhoneEnvRC) @OperationsMethod def AddPhoneEnv(self, allomorph_or_hvo, env_or_hvo): """ Add a phonological environment to an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. env_or_hvo: The IPhEnvironment object or HVO to add. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or env_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> envs = list(project.Environments.GetAll()) >>> if allomorphs and envs: ... allomorphOps.AddPhoneEnv(allomorphs[0], envs[0]) >>> # Define that "-es" appears after sibilants >>> # (assuming you have created the environment) >>> sibilant_env = list(project.Environments.GetAll())[0] >>> allomorphOps.AddPhoneEnv(allomorphs[0], sibilant_env) Notes: - Multiple environments can be added (OR logic) - If environment already exists in the list, it's still added (duplicates are allowed but not recommended) - Environments guide parser in selecting appropriate allomorph See Also: GetPhoneEnv, RemovePhoneEnv """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(env_or_hvo, "env_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) env = self.__GetEnvironmentObject(env_or_hvo) with self._TransactionCM("Add phonological environment"): allomorph.PhoneEnvRC.Add(env) @OperationsMethod def RemovePhoneEnv(self, allomorph_or_hvo, env_or_hvo): """ Remove a phonological environment from an allomorph. Args: allomorph_or_hvo: The IMoForm object or HVO. env_or_hvo: The IPhEnvironment object or HVO to remove. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If allomorph_or_hvo or env_or_hvo is None. Example: >>> allomorphOps = AllomorphOperations(project) >>> entry = project.LexiconAllEntries()[0] >>> allomorphs = list(allomorphOps.GetAll(entry)) >>> if allomorphs: ... envs = allomorphOps.GetPhoneEnv(allomorphs[0]) ... if envs: ... # Remove the first environment ... allomorphOps.RemovePhoneEnv(allomorphs[0], envs[0]) Notes: - If environment not in list, this is a no-op (no error) - Removing all environments means allomorph appears in all contexts - Parser behavior changes when environments are modified See Also: GetPhoneEnv, AddPhoneEnv """ self._EnsureWriteEnabled() self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") self._ValidateParam(env_or_hvo, "env_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) env = self.__GetEnvironmentObject(env_or_hvo) # Membership test stays outside the bracket so a redundant remove is a # true no-op rather than an empty named undo entry (D5). if env in allomorph.PhoneEnvRC: with self._TransactionCM("Remove phonological environment"): allomorph.PhoneEnvRC.Remove(env) # --- Navigation Operations --- @OperationsMethod def GetOwningEntry(self, allomorph_or_hvo): """ Get the lexical entry that owns this allomorph. Args: allomorph_or_hvo: The IMoForm object (IMoStemAllomorph or IMoAffixAllomorph) or HVO. Returns: ILexEntry: The owning entry, or None if the allomorph has no owning entry anywhere above it in the ownership chain. Raises: FP_NullParameterError: If allomorph_or_hvo is None. Example: >>> entry = project.LexEntry.Find("run") >>> allomorphs = project.Allomorphs.GetAll(entry) >>> owner = project.Allomorphs.GetOwningEntry(allomorphs[0]) >>> print(project.LexEntry.GetHeadword(owner)) run >>> # Round trip: GetAll walks entry -> allomorphs; this walks >>> # allomorph -> entry. >>> for allomorph in project.Allomorphs.GetAll(): ... owner = project.Allomorphs.GetOwningEntry(allomorph) ... if owner is None: ... continue ... form = project.Allomorphs.GetForm(allomorph) ... print(f"{form} -> {project.LexEntry.GetHeadword(owner)}") run -> run ran -> run Notes: - Returns None rather than raising when no owning entry exists. Callers must handle None; do not assume an entry is always found. - Climbs the ownership chain to the nearest ILexEntry ancestor (liblcm CmObject.cs:3349, OwnerOfClass walks Owner recursively and answers null when no ancestor of the class is found). It does NOT take a single `.Owner` hop: an IMoForm reached through an affix-form chain does not necessarily sit directly under its entry, so one hop can land on the wrong object. The one-hop `GetOwningEntry` implementations on EtymologyOperations, PronunciationOperations and VariantOperations are valid for their own owner shapes and are deliberately NOT the template here (D-A8). - The null guard runs BEFORE the ILexEntry cast, because casting a null result is the crash this guard exists to prevent. See Also: GetAll, GetForm, Create """ self._ValidateParam(allomorph_or_hvo, "allomorph_or_hvo") allomorph = self.__GetAllomorphObject(allomorph_or_hvo) # Template: LexSenseOperations.GetOwningEntry (LexSenseOperations.py # :2826), not the one-hop `.Owner` siblings in this directory. # OwnerOfClass is confirmed present on IMoForm / IMoStemAllomorph / # IMoAffixAllomorph in tests/contract/snapshots/liblcm_baseline.json, # and returns null when there is no such ancestor # (liblcm src/SIL.LCModel/DomainImpl/CmObject.cs:3349). _owner = allomorph.OwnerOfClass(LexEntryTags.kClassId) if _owner is None: return None # Cast to the declared return type only after the null guard. Raw # OwnerOfClass output is typed ICmObject; pythonnet surfaces # ILexEntry members (LexemeFormOA, SensesOS, ...) only after the # explicit interface cast. return ILexEntry(_owner) # --- Private Helper Methods --- def __GetEntryObject(self, entry_or_hvo): """ Resolve HVO or object to ILexEntry. Args: entry_or_hvo: Either an ILexEntry object or an HVO (int). Returns: ILexEntry: The resolved entry object. """ if isinstance(entry_or_hvo, int): entry_or_hvo = self.project.Object(entry_or_hvo) return cast_to_concrete(entry_or_hvo) def __GetAllomorphObject(self, allomorph_or_hvo): """ Resolve HVO or object to IMoForm. Casts to the concrete allomorph interface -- ``IMoStemAllomorph`` / ``IMoAffixAllomorph`` -- by ``ClassName`` BEFORE returning (contract C2, T8 defect ii). ``FLExProject.Object(hvo)`` returns a bare ``ICmObject``; without this cast, a caller reaching this SHARED resolver via an HVO (rather than an already-typed object from, e.g., ``GetAll()``) would silently lose access to every subtype-only member on that entry path -- including ``MsEnvFeaturesOA`` (T8) and, independently, the three PRE-EXISTING ``GetSyncableProperties`` ``hasattr`` gates on ``Form``/``IsAbstract``/``MorphTypeRA`` (T8 defect i, fixed alongside this one since ``GetSyncableProperties`` previously used ``item`` raw instead of routing through this resolver at all). Any ``ClassName`` other than ``"MoStemAllomorph"``/ ``"MoAffixAllomorph"`` (or a non-LCM input with no ``ClassName``) is returned UNCHANGED -- this shared resolver never raises on a miss, mirroring ``MSAOperations.__GetMsaObject``'s / ``POSOperations.__ResolveObject``'s ClassName-discriminated, never-raising shape (lead ruling R16-4(ii)). This differs from ``Duplicate`` (above), which DOES raise on an unrecognized ``ClassName`` -- that raise is local to ``Duplicate``; this SHARED resolver must stay permissive since 11 other call sites depend on it never raising. Args: allomorph_or_hvo: Either an IMoForm object, an HVO (int), or an ``Allomorph`` wrapper item from ``GetAll()`` (issue #449) -- unwrapped to the raw LCM object before casting, since pythonnet cannot cast a Python wrapper instance. Returns: IMoForm: The resolved allomorph, cast to its concrete interface when its ``ClassName`` is one of the two recognised allomorph subtypes; returned unchanged otherwise. """ allomorph_or_hvo = self._UnwrapLcm(allomorph_or_hvo) if isinstance(allomorph_or_hvo, int): obj = self.project.Object(allomorph_or_hvo) else: obj = self._UnwrapLcmObject(allomorph_or_hvo) class_name = getattr(obj, "ClassName", None) if class_name == "MoStemAllomorph": return IMoStemAllomorph(obj) elif class_name == "MoAffixAllomorph": return IMoAffixAllomorph(obj) return obj def __GetEnvironmentObject(self, env_or_hvo): """ Resolve HVO or object to IPhEnvironment. Casts to ``IPhEnvironment`` by ``ClassName`` BEFORE returning -- **contract conformance (flexicon#260), with ZERO measured behavioural effect at this resolver's two call sites.** Cycle 1 measured LIVE, on the unmodified/uncast baseline, that both callers -- ``AddPhoneEnv`` and ``RemovePhoneEnv`` -- SUCCEED with a genuine int HVO: they only ever hand the resolved object to ``allomorph.PhoneEnvRC.Add``/``.Remove``, a strongly-typed .NET ``ILcmReferenceCollection[IPhEnvironment]`` method, and the CLR binds the argument on the object's RUNTIME type (which does implement ``IPhEnvironment``) rather than on the Python wrapper's static type. This is UNLIKE its sibling resolver, ``Grammar/EnvironmentOperations.py __ResolveObject``, whose callers perform direct PYTHON ATTRIBUTE ACCESS on the resolved object (``env.Name``, ``getattr(env, "StringRepresentation")``) -- pythonnet's static wrapper-type gate blocks that path on an uncast bare ``ICmObject``, which is a genuine behavioural defect there (fixed separately, same cycle). See specs/260-environment-resolver-cast/reviews/cycle1-programmer.md (P2 FALSIFIED) and cycle2-programmer.md for the live evidence behind both halves of this distinction. This cast is landed anyway, NOT as a verified bug fix, but so this resolver's ``Returns: IPhEnvironment`` docstring is true on every entry path, and so it matches its immediate neighbour ``__GetAllomorphObject``'s discipline in the same file rather than leaving two adjacent shared resolvers with opposite casting behaviour for the same defect family -- the exact landmine this feature was opened to close (flexicon#260's own body asks for this sibling by name). ``IPhEnvironment`` has exactly ONE implementing type in the whole ``SIL.LCModel`` assembly (``SIL.LCModel.DomainImpl.PhEnvironment``, confirmed live via reflection), so there is nothing to discriminate between -- a single ``ClassName`` guard, merging the int and object branches, is the correct and complete shape (unlike ``__GetAllomorphObject``'s two-branch ``IMoStemAllomorph``/``IMoAffixAllomorph`` dispatch). **Identity hazard.** ``RemovePhoneEnv`` does ``if env in allomorph.PhoneEnvRC`` before ``.Remove(env)``. Casting mints a NEW pythonnet wrapper over the same CLR object, so that membership test now depends on .NET equality of a re-wrapped object rather than the original bare one. Re-verified LIVE after this cast landed (cycle2-programmer.md, P9): both of this resolver's existing gate tests stay green and unchanged. Any ``ClassName`` other than ``"PhEnvironment"`` (or a non-LCM input with no ``ClassName``) is returned UNCHANGED -- this SHARED resolver never raises on a miss, mirroring ``__GetAllomorphObject``'s permissive shape above. Args: env_or_hvo: Either an IPhEnvironment object or an HVO (int). Returns: IPhEnvironment: The resolved environment, cast to the concrete interface when its ``ClassName`` is ``"PhEnvironment"``; returned unchanged otherwise. """ if isinstance(env_or_hvo, int): obj = self.project.Object(env_or_hvo) else: obj = env_or_hvo class_name = getattr(obj, "ClassName", None) if class_name == "PhEnvironment": return IPhEnvironment(obj) return obj def __IsStemType(self, morph_type): """ Determine if a morph type should use MoStemAllomorph or MoAffixAllomorph. Args: morph_type: IMoMorphType object, or a string morph-type name. Returns: bool: True if stem type (uses MoStemAllomorph), False if affix type Raises: FP_ParameterError: If morph_type is a string that does not resolve to a known morph type. Notes: Delegates to Shared.morph_type_utils.is_stem_morph_type -- the same classification used by LexEntryOperations.__IsStemType. Accepts a string here (in addition to an IMoMorphType object) so that callers passing a raw name never hit an AttributeError deep in this helper (see issue #213). """ if isinstance(morph_type, str): resolved = find_morph_type(self.project, morph_type) if resolved is None: raise FP_ParameterError(morph_type_not_found_error(morph_type)) morph_type = resolved return is_stem_morph_type(morph_type) def __WSHandle(self, wsHandle): """ Get writing system handle, defaulting to vernacular WS for allomorph forms. Args: wsHandle: Optional writing system handle. Returns: int: The writing system handle. """ if wsHandle is None: return self.project.project.DefaultVernWs return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultVernWs)