Source code for flexicon.code.Grammar.PhonFeatureOperations

#
#   PhonFeatureOperations.py
#
#   Class: PhonFeatureOperations
#          Phonological feature operations for FieldWorks Language Explorer
#          projects via SIL Language and Culture Model (LCM) API.
#
#          Manages the phonological feature system (PhFeatureSystemOA),
#          which owns IFsClosedFeature definitions and their IFsSymFeatVal
#          values. Also provides a helper for composing IFsFeatStruc objects
#          that attach to phonemes / natural classes.
#
#          The MGA PhonFeatsEticGlossList.xml catalog is the canonical
#          source for the standard set of phonological features and
#          their +/- values.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2026
#

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

# Import FLEx LCM types
from SIL.LCModel import (
    IFsClosedFeature,
    IFsClosedFeatureFactory,
    IFsSymFeatVal,
    IFsSymFeatValFactory,
    IFsFeatStruc,
    IFsFeatStrucFactory,
    IFsClosedValue,
    IFsClosedValueFactory,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

# .NET Guid for value-child creation (mixin handles the parent feature)
import System

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

# Import LCM casting utilities for pythonnet interface casting
from ..lcm_casting import cast_to_concrete

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

# Catalog (eticGlossList) parsing helpers
from ..Shared.catalog import parse_etic_gloss_list
from ..Shared.catalog_backed import CatalogBackedMixin


# Canonical relative subdir for the PhonFeats catalog under FWCodeDir.
# Kept as a module constant so verification / tests can reference it.
PHON_FEATS_CATALOG_FILENAME = "PhonFeatsEticGlossList.xml"
PHON_FEATS_CATALOG_SUBDIR = "Language Explorer/MGA/GlossLists"

# Optional prefix the wrapper accepts on CatalogSourceId values. Bare
# ids ("fPAConsonantal") are what FieldWorks itself writes; "PHON:" is
# accepted as a user-facing convenience and is stripped before lookup.
CATALOG_PREFIX = "PHON"


[docs] class PhonFeatureOperations(BaseOperations, CatalogBackedMixin): """ This class provides operations for managing phonological features and feature values in a FieldWorks project. Phonological features are owned by ``LangProject.PhFeatureSystemOA`` (an ``IFsFeatureSystem``). Each feature is an ``IFsClosedFeature`` with one or more ``IFsSymFeatVal`` value children (typically ``+`` and ``-`` for binary features). Phonemes and natural classes attach an ``IFsFeatStruc`` whose ``FeatureSpecsOC`` references (feature, value) pairs. The MGA ``PhonFeatsEticGlossList.xml`` catalog provides the canonical set of features and values with stable GUIDs; ``ImportCatalog`` and ``CreateFromCatalog`` populate the project from it. Usage:: from flexlibs2 import FLExProject project = FLExProject() project.OpenProject("my project", writeEnabled=True) phonFeats = project.PhonFeatures # Bulk-import the standard MGA feature set. result = phonFeats.ImportCatalog() print(f"Created {result.created_count}, " f"skipped {result.skipped_count}") # Or create a specific feature with its +/- values. cons = phonFeats.CreateFromCatalog("fPAConsonantal") # Compose a feature structure for a phoneme. plus = next(v for v in phonFeats.GetValues(cons) if phonFeats.GetAbbreviation(v) == "+") struct = phonFeats.MakeFeatStruc([(cons, plus)], owner=phoneme) project.CloseProject() """ # --- CatalogBackedMixin configuration ------------------------------ # The PhonFeats catalog ships with FW under # Language Explorer/MGA/GlossLists/PhonFeatsEticGlossList.xml and is # parsed by parse_etic_gloss_list. Unlike POS, FW writes BARE entry # ids (no "PHON:" prefix) to CatalogSourceId; the mixin honours that # with CATALOG_PREFIX_WRITE = None. Features are flat (each top-level # entry owns IFsSymFeatVal value children handled by # _handle_entry_children), so the recursive-entries flag is off. CATALOG_FILE = PHON_FEATS_CATALOG_FILENAME CATALOG_SUBDIR = PHON_FEATS_CATALOG_SUBDIR CATALOG_PARSER = staticmethod(parse_etic_gloss_list) CATALOG_PREFIX_WRITE = None DOMAIN_LABEL = "feature" _supports_recursive_entries = False def __init__(self, project): """ Initialize PhonFeatureOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ super().__init__(project) # ======================================================================== # READ METHODS # ======================================================================== @wrap_enumerable @OperationsMethod def GetAll(self): """ Get all phonological features in the project. Yields: IFsClosedFeature: Each feature in PhFeatureSystemOA.FeaturesOC. Notes: - ``FeaturesOC`` is typed as ``IFsFeatDefn`` in C#. Each yielded item is cast to ``IFsClosedFeature`` (the only concrete shape this catalog uses) so callers can access ``ValuesOC`` directly. - Returns nothing if the project has no PhFeatureSystemOA. """ feature_system = self.project.lp.PhFeatureSystemOA if feature_system is None: return for feat in feature_system.FeaturesOC: yield IFsClosedFeature(feat) @OperationsMethod def GetName(self, feature_or_hvo, wsHandle=None): """ Get the name of a phonological feature or value. Args: feature_or_hvo: An IFsClosedFeature, IFsSymFeatVal, or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The Name in the requested WS, or "" if not set. """ self._ValidateParam(feature_or_hvo, "feature_or_hvo") obj = self.__ResolveObject(feature_or_hvo) wsHandle = self.__WSHandle(wsHandle) name = ITsString(obj.Name.get_String(wsHandle)).Text return name or "" @OperationsMethod def GetAbbreviation(self, feature_or_hvo, wsHandle=None): """ Get the abbreviation of a phonological feature or value. For values, the abbreviation is typically ``+`` or ``-``. """ self._ValidateParam(feature_or_hvo, "feature_or_hvo") obj = self.__ResolveObject(feature_or_hvo) wsHandle = self.__WSHandle(wsHandle) abbr = ITsString(obj.Abbreviation.get_String(wsHandle)).Text return abbr or "" @OperationsMethod def GetDescription(self, feature_or_hvo, wsHandle=None): """ Get the description of a phonological feature or value. Returns "" if the object has no Description property. """ self._ValidateParam(feature_or_hvo, "feature_or_hvo") obj = self.__ResolveObject(feature_or_hvo) wsHandle = self.__WSHandle(wsHandle) if not hasattr(obj, "Description"): return "" desc = ITsString(obj.Description.get_String(wsHandle)).Text return desc or "" @wrap_enumerable @OperationsMethod def GetValues(self, feature_or_hvo): """ Get all IFsSymFeatVal values defined on a closed feature. Args: feature_or_hvo: The IFsClosedFeature object or HVO. Yields: IFsSymFeatVal: Each value (typically "+" and "-" for binary phonological features). """ self._ValidateParam(feature_or_hvo, "feature_or_hvo") feature = self.__ResolveObject(feature_or_hvo) # ValuesOC is typed as IFsSymFeatVal directly. for val in feature.ValuesOC: yield IFsSymFeatVal(val) @OperationsMethod def Find(self, name, wsHandle=None): """ Find a phonological feature by name. Args: name (str): The name to search for. Compared via NFD-normalised casefold match against each feature's Name in the chosen WS. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: IFsClosedFeature or None: First match, or None. Notes: - Does NOT search value names; values are looked up via ``GetValues(feature)``. - Matches the feature-name convention used by other linguistic Find methods (case-insensitive, NFD-normalised). """ self._ValidateParam(name, "name") target = normalize_match_key(name, casefold=True) if not target: return None wsHandle = self.__WSHandle(wsHandle) feature_system = self.project.lp.PhFeatureSystemOA if feature_system is None: return None for raw in feature_system.FeaturesOC: feat = IFsClosedFeature(raw) feat_name = ITsString(feat.Name.get_String(wsHandle)).Text if feat_name and normalize_match_key(feat_name, casefold=True) == target: return feat return None @OperationsMethod def Exists(self, name, wsHandle=None): """ Check whether a phonological feature with the given name exists. """ self._ValidateParam(name, "name") return self.Find(name, wsHandle=wsHandle) is not None # ======================================================================== # WRITE METHODS - FEATURE # ======================================================================== @OperationsMethod def Create(self, name, abbreviation, catalogSourceId=None, force=False): """ Create a new phonological feature (IFsClosedFeature). Args: name (str): Feature name (e.g. "consonantal"). abbreviation (str): Short abbreviation (e.g. "cons"). catalogSourceId (str, optional): Optional catalog id. If it starts with the ``PHON:`` prefix (case-insensitive), the feature is created from the MGA catalog (canonical GUID + localized strings + +/- value children). When the canonical feature already exists in the project, ``CreateFromCatalog`` is idempotent by GUID and returns the existing object; the user-supplied name/abbreviation are only overlaid when the analysis-WS slots are empty (a fresh import). When the canonical labels already match the user's args, the call is a no-op. When they conflict, the call refuses with FP_ParameterError -- pass ``force=True`` to overlay anyway. Otherwise the value is written verbatim to ``CatalogSourceId``. force (bool): If True, overlay name/abbreviation onto a pre-existing canonical feature even when the labels conflict. Default False (refuse). (issue #138) Returns: IFsClosedFeature: The newly created feature. Raises: FP_ReadOnlyError, FP_NullParameterError, FP_ParameterError: Per BaseOperations validation rules. FP_ParameterError is also raised when the catalog returns an existing feature with canonical labels that conflict with the user's args and ``force=False``. """ self._EnsureWriteEnabled() self._ValidateParam(name, "name") self._ValidateParam(abbreviation, "abbreviation") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") if not abbreviation or not abbreviation.strip(): raise FP_ParameterError("Abbreviation cannot be empty") # Catalog-driven creation: defer to CreateFromCatalog for the # canonical GUID + values, then overlay user-supplied strings # ONLY when the canonical labels are empty or force=True. # Otherwise the canonical labels (possibly set by the FLEx UI # user or a prior programmatic call) would be silently # overwritten. (issue #138) if catalogSourceId and catalogSourceId.upper().startswith(CATALOG_PREFIX + ":"): with self._TransactionCM(f"Create phonological feature '{name}'"): wsHandle = self.project.project.DefaultAnalWs new_feat = self.CreateFromCatalog(catalogSourceId) self.__OverlayCanonicalLabels( new_feat, name, abbreviation, wsHandle, force, catalogSourceId ) return new_feat # Uniqueness check by name within the feature system. if self.Exists(name): raise FP_ParameterError( f"Phonological feature '{name}' already exists" ) wsHandle = self.project.project.DefaultAnalWs feature_system = self.project.lp.PhFeatureSystemOA if feature_system is None: raise FP_ParameterError( "Project has no PhFeatureSystemOA; cannot create " "phonological features." ) # Phase 2 ownership-ordering rule: attach to the owning collection # FIRST, then mutate properties. The parameterless factory create # returns an unowned object whose property setters will NPE until # it is added to FeaturesOC. factory = self.project.project.ServiceLocator.GetService( IFsClosedFeatureFactory ) with self._TransactionCM(f"Create phonological feature '{name}'"): new_feat = factory.Create() feature_system.FeaturesOC.Add(new_feat) new_feat = IFsClosedFeature(new_feat) # Set name + abbreviation in default analysis WS. mkstr_name = TsStringUtils.MakeString(name, wsHandle) new_feat.Name.set_String(wsHandle, mkstr_name) mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle) new_feat.Abbreviation.set_String(wsHandle, mkstr_abbr) # Verbatim CatalogSourceId for non-PHON: prefixes (and bare ids). if catalogSourceId: new_feat.CatalogSourceId = catalogSourceId return new_feat @OperationsMethod def SetName(self, feature_or_hvo, name, wsHandle=None): """ Set the name of a phonological feature or value. """ self._EnsureWriteEnabled() self._ValidateParam(feature_or_hvo, "feature_or_hvo") self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") obj = self.__ResolveObject(feature_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(name, wsHandle) obj.Name.set_String(wsHandle, mkstr) @OperationsMethod def SetAbbreviation(self, feature_or_hvo, abbrev, wsHandle=None): """ Set the abbreviation of a phonological feature or value. For values, the abbreviation is typically ``+`` or ``-``. """ self._EnsureWriteEnabled() self._ValidateParam(feature_or_hvo, "feature_or_hvo") self._ValidateParam(abbrev, "abbrev") if not abbrev or not str(abbrev).strip(): raise FP_ParameterError("Abbreviation cannot be empty") obj = self.__ResolveObject(feature_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(abbrev, wsHandle) obj.Abbreviation.set_String(wsHandle, mkstr) @OperationsMethod def SetDescription(self, feature_or_hvo, description, wsHandle=None): """ Set the description of a phonological feature or value. Raises: FP_ParameterError: If the object has no Description property. """ self._EnsureWriteEnabled() self._ValidateParam(feature_or_hvo, "feature_or_hvo") self._ValidateParam(description, "description") obj = self.__ResolveObject(feature_or_hvo) if not hasattr(obj, "Description"): raise FP_ParameterError( "Target object has no Description multistring property." ) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(description, wsHandle) obj.Description.set_String(wsHandle, mkstr) @OperationsMethod def Delete(self, feature_or_hvo): """ Delete a phonological feature. Args: feature_or_hvo: The IFsClosedFeature, wrapper, or HVO to delete. Notes: - Per Phase 4 lesson, accepts a wrapped form and unwraps it before talking to the LCM collection. - Deletion removes the feature from PhFeatureSystemOA.FeaturesOC. LCM will cascade-delete its IFsSymFeatVal children and any references from IFsClosedValue.FeatureRA. """ self._EnsureWriteEnabled() self._ValidateParam(feature_or_hvo, "feature_or_hvo") feat = self.__Unwrap(self.__ResolveObject(feature_or_hvo)) feature_system = self.project.lp.PhFeatureSystemOA if feature_system is not None: feature_system.FeaturesOC.Remove(feat) # ======================================================================== # WRITE METHODS - VALUE (IFsSymFeatVal under a feature) # ======================================================================== @OperationsMethod def CreateValue(self, feature_or_hvo, name, abbreviation, value_marker=None): """ Create a new symbolic value (IFsSymFeatVal) under a closed feature. Args: feature_or_hvo: The IFsClosedFeature or HVO that owns the value. name (str): Value name (e.g. "positive", "negative"). abbreviation (str): Short abbreviation. For standard binary phonological features, conventionally "+" or "-". value_marker (str, optional): Currently informational only -- accepted for API symmetry with FW UIs that ask the user to choose +/-, but the abbreviation itself carries the actual marker. Defaults to None (no constraint). Returns: IFsSymFeatVal: The newly created value. Notes: - Applies the Phase 2 ownership-ordering rule: factory.Create() -> feature.ValuesOC.Add() -> set Name + Abbreviation. """ self._EnsureWriteEnabled() self._ValidateParam(feature_or_hvo, "feature_or_hvo") self._ValidateParam(name, "name") self._ValidateParam(abbreviation, "abbreviation") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") if not abbreviation or not str(abbreviation).strip(): raise FP_ParameterError("Abbreviation cannot be empty") feature = self.__Unwrap(self.__ResolveObject(feature_or_hvo)) wsHandle = self.project.project.DefaultAnalWs factory = self.project.project.ServiceLocator.GetService( IFsSymFeatValFactory ) with self._TransactionCM(f"Create feature value '{name}'"): new_val = factory.Create() # Ownership-first: attach to feature.ValuesOC before mutating strings. feature.ValuesOC.Add(new_val) new_val = IFsSymFeatVal(new_val) mkstr_name = TsStringUtils.MakeString(name, wsHandle) new_val.Name.set_String(wsHandle, mkstr_name) mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle) new_val.Abbreviation.set_String(wsHandle, mkstr_abbr) # value_marker is reserved for a future strict-mode check (e.g. # enforce that abbreviation == value_marker when both supplied). # Phase 5b deliberately leaves it informational only. _ = value_marker return new_val @OperationsMethod def DeleteValue(self, value_or_hvo): """ Delete a phonological feature value. Removes the value from its owning feature's ValuesOC. """ self._EnsureWriteEnabled() self._ValidateParam(value_or_hvo, "value_or_hvo") val = self.__Unwrap(self.__ResolveObject(value_or_hvo)) owner = val.Owner if owner is not None: # ValuesOC is declared on IFsClosedFeature; cast unconditionally so # pythonnet surfaces the typed collection accessor. The try/except # guards against the unlikely case that owner is some other type # (should not happen in practice, but preserves robustness). try: feat = IFsClosedFeature(owner) feat.ValuesOC.Remove(val) except Exception: pass # ======================================================================== # COMPOSE - FsFeatStruc # ======================================================================== @OperationsMethod def MakeFeatStruc(self, specs, owner=None): """ Build an IFsFeatStruc populated with (feature, value) pairs. Args: specs (list[tuple]): A list of ``(feature, value)`` tuples. Each side may be an IFsClosedFeature / IFsSymFeatVal object, a wrapper, or an HVO. Items are added to the struct's ``FeatureSpecsOC`` in the order provided. owner: LCM object that owns the struct via its ``FeaturesOA`` atomic-owning property. **Required.** The struct is attached to ``owner.FeaturesOA`` BEFORE its FeatureSpecsOC is populated, because LCM property setters and getters both NPE on free-floating IFsFeatStruc objects (Phase 2 ownership rule). Phonemes and natural classes both use ``FeaturesOA``. ``owner=None`` is rejected unconditionally (issue #28). The previous unowned-empty mode returned a struct whose property accessors NPE'd inside ``CmObject.get_Services()``; that footgun is now gone. Returns: IFsFeatStruc: The populated feature structure, attached to ``owner.FeaturesOA``. Raises: FP_ParameterError: If ``owner`` is None, if a spec tuple is malformed, or if ``owner`` has no FeaturesOA property. """ self._EnsureWriteEnabled() self._ValidateParam(specs, "specs") if owner is None: raise FP_ParameterError( "MakeFeatStruc requires an owner. LCM property " "accessors NPE on free-floating IFsFeatStruc objects, " "so the previous unowned-empty mode produced an " "unusable struct (issue #28). Pass owner=phoneme / " "owner=natural_class / owner=context." ) # Normalize and validate specs up front, before any LCM mutation. normalized = [] for i, pair in enumerate(specs): if not isinstance(pair, (list, tuple)) or len(pair) != 2: raise FP_ParameterError( f"specs[{i}] must be a (feature, value) tuple" ) feat_in, val_in = pair feat = self.__Unwrap(self.__ResolveObject(feat_in)) val = self.__Unwrap(self.__ResolveObject(val_in)) normalized.append((feat, val)) # Create the struct and attach it to its owner BEFORE any # FeatureSpecsOC mutation. Skipping the attach (or doing it # after) trips the Phase 2 NPE pattern -- LCM property setters # on free-floating structs raise NullReferenceException. owner_unwrapped = self.__Unwrap(owner) if not hasattr(owner_unwrapped, "FeaturesOA"): raise FP_ParameterError( "owner has no FeaturesOA property; cannot attach FsFeatStruc." ) factory = self.project.project.ServiceLocator.GetService( IFsFeatStrucFactory ) with self._TransactionCM("Make feature structure"): struct = factory.Create() owner_unwrapped.FeaturesOA = struct # Re-fetch via the owning property to hold the LCM view of the # now-owned struct. struct = IFsFeatStruc(owner_unwrapped.FeaturesOA) # Populate FeatureSpecsOC. Each spec is an IFsClosedValue with # FeatureRA -> feature and ValueRA -> value. cv_factory = self.project.project.ServiceLocator.GetService( IFsClosedValueFactory ) for feat, val in normalized: closed_value = cv_factory.Create() struct.FeatureSpecsOC.Add(closed_value) cv = IFsClosedValue(closed_value) cv.FeatureRA = feat cv.ValueRA = val return struct # ======================================================================== # CATALOG (eticGlossList) IMPORT METHODS # ======================================================================== # # The public API (ImportCatalog / CreateFromCatalog / # FixGuidsAgainstCatalog) and the catalog-walking helpers live on # CatalogBackedMixin (extracted in Phase 5c). The hooks below tell # the mixin how to talk to the PhonFeats-specific LCM types # (IFsClosedFeature + its IFsSymFeatVal value children). # # Phase 2 ownership-ordering lesson applies: the mixin attaches via # the 2-arg factory overload (feature placed into FeaturesOC at # creation) THEN sets the multistring properties; never sets # properties on a free-floating feature. # --- CatalogBackedMixin hooks -------------------------------------- def _get_root_list(self): """Return the top-level owner (PhFeatureSystemOA).""" return self.project.lp.PhFeatureSystemOA def _get_factory(self): """Resolve the IFsClosedFeature factory for the mixin's Path B.""" return self.project.project.ServiceLocator.GetService( IFsClosedFeatureFactory ) def _factory_create_attached(self, guid, parent_obj): """ Path A: try the 2-arg factory overload. For features, parent_obj is always None (features are flat, owned directly by PhFeatureSystemOA.FeaturesOC), so we always pass the feature system as the second arg. Pythonnet may not expose the 2-arg overload on the interface variable; returning None lets the mixin fall back to Path B. """ factory = self._get_factory() feature_system = self._get_root_list() try: return factory.Create(guid, feature_system) except Exception: return None def _path_b_attach(self, new_obj, parent_obj): """ Path B fallback: attach a free-floating feature to PhFeatureSystemOA.FeaturesOC. parent_obj is ignored (always None for top-level features). """ self._get_root_list().FeaturesOC.Add(new_obj) def _cast_to_domain(self, raw): """Return the IFsClosedFeature view of a raw LCM feature object.""" return IFsClosedFeature(raw) def _set_localized(self, obj, term, abbrev, def_, missing_ws_seen, warnings): """Per-WS multistring writes for Name/Abbreviation/Description.""" self._set_multistring(obj.Name, term, missing_ws_seen, warnings) self._set_multistring(obj.Abbreviation, abbrev, missing_ws_seen, warnings) if hasattr(obj, "Description"): self._set_multistring(obj.Description, def_, missing_ws_seen, warnings) def _walk_existing(self): """ Yield each IFsClosedFeature in PhFeatureSystemOA. Features are flat (no hierarchy among themselves), so this is a single-level walk. Value children are NOT included here -- value idempotency is checked per-feature in _handle_entry_children. """ feature_system = self.project.lp.PhFeatureSystemOA if feature_system is None: return for raw in feature_system.FeaturesOC: yield IFsClosedFeature(raw) def _handle_entry_children(self, entry, created_feature, missing_ws_seen, warnings, result): """ Create IFsSymFeatVal items for each value child of `entry` under `created_feature.ValuesOC`. Idempotency is checked per-feature against the existing value GUIDs on the feature. ``result`` is a CatalogImportResult when called from ImportCatalog, or None when called from CreateFromCatalog / FixGuidsAgainstCatalog (we still create missing values; we just don't accumulate counts). """ existing_value_guids = { str(v.Guid).lower() for v in created_feature.ValuesOC } for value_entry in entry.children: v_guid = value_entry.guid.lower() if value_entry.guid else "" if v_guid and v_guid in existing_value_guids: if result is not None: result.skipped_count += 1 continue self._CreateValueFromEntry( value_entry, created_feature, missing_ws_seen, warnings ) if result is not None: result.created_count += 1 if v_guid: result.created_guids.append(v_guid) if v_guid: # Track within this pass so a duplicate entry (e.g. # badly-formed catalog) won't re-create. existing_value_guids.add(v_guid) # --- Value-child creation ------------------------------------------ # # The mixin handles feature-level creation. Value (IFsSymFeatVal) # creation stays local because IFsSymFeatVal doesn't fit the same # _create_from_entry contract (no CatalogSourceId field; different # parent shape). Keeping it here keeps the mixin simpler. def _CreateValueFromEntry( self, value_entry, parent_feature, missing_ws_seen, warnings ): """ Internal: instantiate one IFsSymFeatVal under `parent_feature` from a value-typed CatalogEntry. Applies the canonical GUID and per-WS strings. Applies the Phase 2 ownership-ordering rule: attach first (via the 2-arg factory overload, or fall back to Create+Add), then mutate properties. """ factory = self.project.project.ServiceLocator.GetService( IFsSymFeatValFactory ) guid = System.Guid(value_entry.guid) new_val = None # Path A: 2-arg factory overload if pythonnet exposes it. try: new_val = factory.Create(guid, parent_feature) except Exception: new_val = None if new_val is None: # Path B: implementation-side Create(Guid) followed by Add(). concrete_factory = ( cast_to_concrete(factory) if hasattr(factory, "ClassName") else factory ) try: new_val = concrete_factory.Create(guid) except Exception as e: # No safe fallback: parameterless Create() would generate # a random GUID. Match the mixin's Path-A+B-failure # discipline (Phase 5a). raise FP_ParameterError( f"Could not create feature value '{value_entry.id}' " f"with canonical GUID {value_entry.guid} via either " f"Create(Guid, parent) or Create(Guid) factory " f"overloads." ) from e parent_feature.ValuesOC.Add(new_val) new_val = IFsSymFeatVal(new_val) # Per-WS strings (abbreviation is the +/- marker; term is the # positive/negative name). self._set_multistring( new_val.Name, value_entry.term, missing_ws_seen, warnings ) self._set_multistring( new_val.Abbreviation, value_entry.abbrev, missing_ws_seen, warnings ) if hasattr(new_val, "Description"): self._set_multistring( new_val.Description, value_entry.def_, missing_ws_seen, warnings ) # IFsSymFeatVal does not have a CatalogSourceId field in stock # LCM, so we don't try to set one. Value-level catalog provenance # is recoverable indirectly via the parent feature's # CatalogSourceId and the value's canonical GUID. return new_val # ======================================================================== # PRIVATE HELPER METHODS # ======================================================================== def __OverlayCanonicalLabels(self, feat, name, abbreviation, wsHandle, force, catalogSourceId): """ Overlay user-supplied name + abbreviation onto a feature returned by CreateFromCatalog -- but only when it is safe to do so. CreateFromCatalog is idempotent by canonical GUID: if the feature already exists in the project (a prior import or a FLEx UI user having created it), the existing object is returned. Blindly writing user-supplied name/abbreviation onto it would silently clobber whatever canonical labels were already there. Policy: - Slots empty -> overlay (newly imported feature, no conflict). - Slots match args -> idempotent no-op. - Slots differ and force=False -> refuse with a clear pointer. - Slots differ and force=True -> overlay (caller opted in). (issue #138) """ existing_name = ITsString(feat.Name.get_String(wsHandle)).Text or "" existing_abbr = ITsString(feat.Abbreviation.get_String(wsHandle)).Text or "" if not force and (existing_name or existing_abbr): if existing_name == name and existing_abbr == abbreviation: # Already labeled exactly as requested; idempotent. return raise FP_ParameterError( f"Phonological feature for catalog id {catalogSourceId!r} " f"already exists with canonical labels " f"Name={existing_name!r} Abbreviation={existing_abbr!r}. " f"Refusing to overwrite with Name={name!r} " f"Abbreviation={abbreviation!r}. Pass force=True to " f"overlay anyway." ) mkstr_name = TsStringUtils.MakeString(name, wsHandle) feat.Name.set_String(wsHandle, mkstr_name) mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle) feat.Abbreviation.set_String(wsHandle, mkstr_abbr) def __ResolveObject(self, obj_or_hvo): """ Resolve HVO or object to its LCM object. Wrappers are passed through unchanged here; use __Unwrap() to peel them off if you need to call LCM property setters on the result. """ if isinstance(obj_or_hvo, int): return self.project.Object(obj_or_hvo) return obj_or_hvo def __Unwrap(self, obj): """ Peel off LCMObjectWrapper-style wrappers, if any. Plain LCM objects pass through. Mirrors the Phase 4 wrapper-aware pattern. """ # Wrapper classes expose ._obj to the underlying LCM object. if hasattr(obj, "_obj") and not hasattr(obj, "Hvo"): return obj._obj if hasattr(obj, "_obj") and hasattr(obj._obj, "Hvo"): # Both shapes: wrapper that proxies .Hvo. Prefer the inner LCM obj. return obj._obj return obj def __WSHandle(self, wsHandle): """ Get writing system handle, defaulting to analysis WS. """ if wsHandle is None: return self.project.project.DefaultAnalWs return self.project._FLExProject__WSHandle( wsHandle, self.project.project.DefaultAnalWs )