#
# 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)