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

logger = logging.getLogger(__name__)

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

# Import FLEx LCM types
from SIL.LCModel import (
    IMoForm,
    IMoStemAllomorphFactory,
    IMoAffixAllomorphFactory,
    ILexEntry,
    IPhEnvironment,
    MoMorphTypeTags,
)
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 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 flexlibs2 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: 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 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 >>> morphType = project.lexDB.MorphTypesOA.PossibilitiesOS[0] >>> allomorph = project.Allomorphs.Create(entry, "ran", morphType) >>> # 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). if isinstance(morphType, str): bare = morphType.strip('-=~<>') morph_types = self.project.lp.LexDbOA.MorphTypesOA from ..Shared.string_utils import normalize_match_key, best_analysis_text target = normalize_match_key(bare, casefold=True) def _find(possibilities): for mt in possibilities: mt_name = best_analysis_text(mt.Name) if mt_name and normalize_match_key(mt_name, casefold=True) == target: return mt if mt.SubPossibilitiesOS.Count > 0: found = _find(mt.SubPossibilitiesOS) if found: return found return None resolved = _find(morph_types.PossibilitiesOS) if morph_types else None if resolved is None: raise FP_ParameterError( f"Morph type '{morphType}' not found. " "Use project.LexEntry.GetAvailableMorphTypes() to see valid names." ) 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): """ 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): Reserved for future use (allomorphs have no owned objects). 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) - 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 if class_name == "MoStemAllomorph": from SIL.LCModel import IMoStemAllomorphFactory factory = self.project.project.ServiceLocator.GetService(IMoStemAllomorphFactory) elif class_name == "MoAffixAllomorph": from SIL.LCModel import IMoAffixAllomorphFactory factory = self.project.project.ServiceLocator.GetService(IMoAffixAllomorphFactory) 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 Reference Atomic (RA) properties duplicate.MorphTypeRA = source.MorphTypeRA # Copy Reference Collection (RC) properties for env in source.PhoneEnvRC: duplicate.PhoneEnvRC.Add(env) return duplicate # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get all syncable properties of an allomorph for comparison. Args: item: The IMoForm object (allomorph). 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 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 """ props = {} # MultiString properties # Form - the allomorph form in various writing systems form_dict = {} if hasattr(item, "Form"): for ws_def in self.project.WritingSystems.GetAll(): text = normalize_text(ITsString(item.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(item, "IsAbstract"): props["IsAbstract"] = item.IsAbstract # Reference Atomic (RA) properties # MorphTypeRA - morpheme type (prefix, suffix, stem, etc.) if hasattr(item, "MorphTypeRA") and item.MorphTypeRA: props["MorphTypeRA"] = str(item.MorphTypeRA.Guid) else: props["MorphTypeRA"] = None return props @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) 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.project.DefaultAnalWs ... 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.lp.MorphTypesOA.PossibilitiesOS ... prefix_type = [mt for mt in morphTypes ... if "prefix" in str(mt).lower()][0] ... 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) allomorph.MorphTypeRA = morphType @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.project.DefaultAnalWs ... 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)) >>> if allomorphs and project.lp.PhonologicalDataOA: ... envs = project.lp.PhonologicalDataOA.EnvironmentsOS ... if envs.Count > 0: ... allomorphOps.AddPhoneEnv(allomorphs[0], envs[0]) >>> # Define that "-es" appears after sibilants >>> # (assuming you have created the environment) >>> sibilant_env = project.lp.PhonologicalDataOA.EnvironmentsOS[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) 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) # Only remove if it's actually in the collection if env in allomorph.PhoneEnvRC: allomorph.PhoneEnvRC.Remove(env) # --- 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): return self.project.Object(entry_or_hvo) return entry_or_hvo def __GetAllomorphObject(self, allomorph_or_hvo): """ Resolve HVO or object to IMoForm. Args: allomorph_or_hvo: Either an IMoForm object or an HVO (int). Returns: IMoForm: The resolved allomorph object. """ if isinstance(allomorph_or_hvo, int): return self.project.Object(allomorph_or_hvo) return allomorph_or_hvo def __GetEnvironmentObject(self, env_or_hvo): """ Resolve HVO or object to IPhEnvironment. Args: env_or_hvo: Either an IPhEnvironment object or an HVO (int). Returns: IPhEnvironment: The resolved environment object. """ if isinstance(env_or_hvo, int): return self.project.Object(env_or_hvo) return env_or_hvo def __IsStemType(self, morph_type): """ Determine if a morph type should use MoStemAllomorph or MoAffixAllomorph. Args: morph_type: IMoMorphType object Returns: bool: True if stem type (uses MoStemAllomorph), False if affix type Notes: Based on FLEx logic - matches LexEntryOperations.__IsStemType """ if morph_type is None: return True # Default to stem # Check GUID against known stem types stem_guids = { MoMorphTypeTags.kguidMorphStem, MoMorphTypeTags.kguidMorphRoot, MoMorphTypeTags.kguidMorphBoundRoot, MoMorphTypeTags.kguidMorphBoundStem, MoMorphTypeTags.kguidMorphClitic, MoMorphTypeTags.kguidMorphEnclitic, MoMorphTypeTags.kguidMorphProclitic, MoMorphTypeTags.kguidMorphParticle, MoMorphTypeTags.kguidMorphPhrase, MoMorphTypeTags.kguidMorphDiscontiguousPhrase, } return morph_type.Guid in stem_guids 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)