#
# InflectionFeatureOperations.py
#
# Class: InflectionFeatureOperations
# Inflection class and feature operations for FieldWorks Language
# Explorer projects via SIL Language and Culture Model (LCM) API.
#
# Manages inflection classes (IMoInflClass) under MorphologicalDataOA
# and the morphosyntactic feature system (MsFeatureSystemOA), which
# owns IFsClosedFeature definitions and their IFsSymFeatVal value
# children. The MGA EticGlossList.xml catalog is the canonical
# source for the standard set of inflection (morphosyntactic)
# features and their values; ImportCatalog / CreateFromCatalog /
# FixGuidsAgainstCatalog populate the project from it.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2025-2026
#
# Import BaseOperations parent class
from ..BaseOperations import BaseOperations, OperationsMethod
# Import FLEx LCM types
from SIL.LCModel import (
IMoInflClass,
IMoInflClassFactory,
IFsFeatStruc,
IFsFeatStrucFactory,
IFsFeatDefn, # Fixed: was IFsFeatureDefn
IFsComplexFeature,
IFsComplexFeatureFactory,
IFsClosedFeature,
IFsClosedFeatureFactory,
IFsSymFeatVal,
IFsSymFeatValFactory,
IFsClosedValue,
IFsClosedValueFactory,
IFsFeatStrucType,
IFsFeatStrucTypeFactory,
)
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 MGA inflection-feature catalog under
# FWCodeDir. Kept as module constants so verification / tests can reference
# them.
INFL_FEATS_CATALOG_FILENAME = "EticGlossList.xml"
INFL_FEATS_CATALOG_SUBDIR = "Language Explorer/MGA/GlossLists"
# Optional prefix the wrapper accepts on CatalogSourceId values. Bare ids
# ("fDeg", "vPositive") are what FieldWorks itself writes (the catalog
# shares the eticGlossList shape with PhonFeats); "INFL:" is accepted as
# a user-facing convenience and is stripped before lookup.
CATALOG_PREFIX = "INFL"
[docs]
class InflectionFeatureOperations(BaseOperations, CatalogBackedMixin):
"""
This class provides operations for managing inflection classes, feature
structures, and features in a FieldWorks project.
Inflection classes group lexical items that inflect similarly (e.g., Latin
noun declensions, Spanish verb conjugations). Feature structures and features
represent grammatical properties like person, number, gender, tense, aspect,
mood, etc.
Usage::
from flexlibs2 import FLExProject, InflectionFeatureOperations
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
inflOps = InflectionFeatureOperations(project)
# Get all inflection classes
for ic in inflOps.InflectionClassGetAll():
name = inflOps.InflectionClassGetName(ic)
print(f"Inflection Class: {name}")
# Create a new inflection class
first_decl = inflOps.InflectionClassCreate("First Declension")
# Work with features
for feature in inflOps.FeatureGetAll():
print(f"Feature: {feature}")
project.CloseProject()
"""
# --- CatalogBackedMixin configuration ------------------------------
# The MGA inflection-feature catalog ships with FW under
# Language Explorer/MGA/GlossLists/EticGlossList.xml and is parsed by
# parse_etic_gloss_list (shared with PhonFeats since both catalogs use
# the eticGlossList shape). FW writes BARE entry ids (no "INFL:"
# prefix) to CatalogSourceId; the mixin honours that with
# CATALOG_PREFIX_WRITE = None. Features are flat (each top-level
# feature entry owns IFsSymFeatVal value children handled by
# _handle_entry_children), so the recursive-entries flag is off.
# The catalog also contains type="fsType"/"complex" items; the
# parser silently skips them, so Phase 6a only imports closed
# (symbolic) features. Complex-feature import is deferred.
CATALOG_FILE = INFL_FEATS_CATALOG_FILENAME
CATALOG_SUBDIR = INFL_FEATS_CATALOG_SUBDIR
CATALOG_PARSER = staticmethod(parse_etic_gloss_list)
CATALOG_PREFIX_WRITE = None
DOMAIN_LABEL = "inflection feature"
_supports_recursive_entries = False
def __init__(self, project):
"""
Initialize InflectionFeatureOperations 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 inflection features.
For InflectionFeature, we reorder parent.FeaturesOA.PossibilitiesOS
"""
return parent.FeaturesOA.PossibilitiesOS
# ========================================================================
# INFLECTION CLASS OPERATIONS
# ========================================================================
@OperationsMethod
def InflectionClassGetAll(self):
"""
Get all inflection classes in the project.
Yields:
IMoInflClass: Each inflection class object in the project.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> for ic in inflOps.InflectionClassGetAll():
... name = inflOps.InflectionClassGetName(ic)
... print(f"Class: {name}")
Class: First Declension
Class: Second Declension
Class: Irregular Verb
Class: Regular Verb
Notes:
- Returns all inflection classes from MorphologicalDataOA
- Classes organize lexical items by inflectional pattern
- Each class defines how entries inflect (conjugate/decline)
- Returns empty if no classes defined
See Also:
InflectionClassCreate, InflectionClassGetName
"""
morph_data = self.project.lp.MorphologicalDataOA
if morph_data is not None and hasattr(morph_data, "ProdRestrictOA") and morph_data.ProdRestrictOA is not None:
# Inflection classes are stored in the ProdRestrictOA (Production Restrictions)
infl_classes = morph_data.ProdRestrictOA
for ic in infl_classes.PossibilitiesOS:
yield IMoInflClass(ic)
@OperationsMethod
def InflectionClassCreate(self, name):
"""
Create a new inflection class.
Args:
name (str): The name of the inflection class (e.g., "First Declension").
Returns:
IMoInflClass: The newly created inflection class object.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If name is None.
FP_ParameterError: If name is empty or if a class with this name
already exists.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> first_decl = inflOps.InflectionClassCreate("First Declension")
>>> print(inflOps.InflectionClassGetName(first_decl))
First Declension
>>> # Create Spanish verb conjugation classes
>>> ar_verbs = inflOps.InflectionClassCreate("AR Verbs")
>>> er_verbs = inflOps.InflectionClassCreate("ER Verbs")
>>> ir_verbs = inflOps.InflectionClassCreate("IR Verbs")
Notes:
- Inflection classes group entries that inflect the same way
- Name should describe the inflectional pattern
- Classes can be associated with specific parts of speech
- Used in morphological templates and paradigms
- The class is created in the default analysis writing system
See Also:
InflectionClassDelete, InflectionClassGetAll, InflectionClassSetName
"""
self._EnsureWriteEnabled()
self._ValidateParam(name, "name")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
# Check if class already exists
target = normalize_match_key(name, casefold=True)
for existing_ic in self.InflectionClassGetAll():
existing_name = self.InflectionClassGetName(existing_ic)
if existing_name and normalize_match_key(existing_name, casefold=True) == target:
raise FP_ParameterError(f"Inflection class '{name}' already exists")
# Get the writing system handle
wsHandle = self.project.project.DefaultAnalWs
# Create the new inflection class using the factory
factory = self.project.project.ServiceLocator.GetService(IMoInflClassFactory)
# Pre-flight: ensure the morphological data container and production
# restrictions list exist before creating any object.
morph_data = self.project.lp.MorphologicalDataOA
if morph_data is None or morph_data.ProdRestrictOA is None:
raise FP_ParameterError(
"Project has no morphological data / production restrictions list defined"
)
with self._TransactionCM(f"Create inflection class '{name}'"):
new_ic = factory.Create()
# Add to the inflection classes list (must be done before setting properties)
morph_data.ProdRestrictOA.PossibilitiesOS.Add(new_ic)
# Set name
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
new_ic.Name.set_String(wsHandle, mkstr_name)
return new_ic
@OperationsMethod
def InflectionClassDelete(self, ic_or_hvo):
"""
Delete an inflection class.
Args:
ic_or_hvo: The IMoInflClass object or HVO to delete.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If ic_or_hvo is None.
FP_ParameterError: If the class is in use and cannot be deleted.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> # Find and delete an obsolete class
>>> for ic in inflOps.InflectionClassGetAll():
... if inflOps.InflectionClassGetName(ic) == "Obsolete":
... inflOps.InflectionClassDelete(ic)
... break
Warning:
- Deleting a class that is in use may raise an error from FLEx
- Entries using this class should be updated first
- Deletion is permanent and cannot be undone
- Check for references before deletion
See Also:
InflectionClassCreate, InflectionClassGetAll
"""
self._EnsureWriteEnabled()
self._ValidateParam(ic_or_hvo, "ic_or_hvo")
# Resolve to inflection class object
ic = self.__ResolveInflectionClass(ic_or_hvo)
# Remove from the inflection classes list
morph_data = self.project.lp.MorphologicalDataOA
if morph_data.ProdRestrictOA:
morph_data.ProdRestrictOA.PossibilitiesOS.Remove(ic)
@OperationsMethod
def InflectionClassGetName(self, ic_or_hvo, wsHandle=None):
"""
Get the name of an inflection class.
Args:
ic_or_hvo: The IMoInflClass object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The inflection class name, or empty string if not set.
Raises:
FP_NullParameterError: If ic_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> for ic in inflOps.InflectionClassGetAll():
... name = inflOps.InflectionClassGetName(ic)
... print(f"Inflection Class: {name}")
Inflection Class: First Declension
Inflection Class: Second Declension
See Also:
InflectionClassSetName, InflectionClassGetAll
"""
self._ValidateParam(ic_or_hvo, "ic_or_hvo")
ic = self.__ResolveInflectionClass(ic_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
name = ITsString(ic.Name.get_String(wsHandle)).Text
return name or ""
@OperationsMethod
def InflectionClassSetName(self, ic_or_hvo, name, wsHandle=None):
"""
Set the name of an inflection class.
Args:
ic_or_hvo: The IMoInflClass object or HVO.
name (str): The new name.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If ic_or_hvo or name is None.
FP_ParameterError: If name is empty.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> ic = inflOps.InflectionClassCreate("1st Decl")
>>> inflOps.InflectionClassSetName(ic, "First Declension")
>>> print(inflOps.InflectionClassGetName(ic))
First Declension
See Also:
InflectionClassGetName, InflectionClassCreate
"""
self._EnsureWriteEnabled()
self._ValidateParam(ic_or_hvo, "ic_or_hvo")
self._ValidateParam(name, "name")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
ic = self.__ResolveInflectionClass(ic_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(name, wsHandle)
ic.Name.set_String(wsHandle, mkstr)
# ========================================================================
# FEATURE STRUCTURE OPERATIONS
# ========================================================================
@OperationsMethod
def FeatureStructureGetAll(self):
"""
Get all feature structures in the project.
Yields nothing in the current implementation.
``IFsFeatStruc`` objects are not stored in a central
project-level collection; they are owned by various LCM
objects (morphemes, MSAs, allomorphs, entries, etc.) and
attached as ``FeatureStructureRA`` or in domain-specific
owning collections. Walking them requires iterating each
owning surface in turn, not enumerating ``MsFeatureSystemOA``.
The previous implementation guarded the inner loop with
``hasattr(feature, "FeaturesOS")`` against IFsFeatDefn variants
that do not expose that property; the check was always False
at runtime and the body was always-empty. Removed in (#140).
Yields:
IFsFeatStruc: Empty generator (placeholder for a future
cross-surface walk implementation).
See Also:
FeatureStructureCreate, FeatureGetAll
"""
# Empty generator: see docstring. Returning empty rather than
# raising preserves the historic "calls succeed and yield []"
# contract for any caller that loops over the result.
return
yield # pragma: no cover -- makes this a generator
@OperationsMethod
def FeatureStructureCreate(self):
"""
Create a new feature structure.
Returns:
IFsFeatStruc: The newly created feature structure object.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> fs = inflOps.FeatureStructureCreate()
>>> print(f"Created feature structure: {fs.Hvo}")
Notes:
- Feature structures organize related grammatical features
- After creation, features must be added separately
- Structures can represent complex feature combinations
- Used to specify morphosyntactic properties
- The structure is created as an empty container
See Also:
FeatureStructureDelete, FeatureGetAll
"""
self._EnsureWriteEnabled()
# Create the new feature structure using the factory
factory = self.project.project.ServiceLocator.GetService(IFsFeatStrucFactory)
new_fs = factory.Create()
return new_fs
@OperationsMethod
def FeatureStructureDelete(self, fs_or_hvo):
"""
Delete a feature structure.
Args:
fs_or_hvo: The IFsFeatStruc object or HVO to delete.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If fs_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> fs = inflOps.FeatureStructureCreate()
>>> inflOps.FeatureStructureDelete(fs)
Warning:
- Cannot delete if referenced by entries, morphemes, or rules
- Check dependencies before deletion
- Deletion is permanent and cannot be undone
See Also:
FeatureStructureCreate, FeatureStructureGetAll
"""
self._EnsureWriteEnabled()
self._ValidateParam(fs_or_hvo, "fs_or_hvo")
# Resolve to feature structure object
fs = self.__ResolveFeatureStructure(fs_or_hvo)
# Feature structures are held in the parent's FeaturesOA slot
# (e.g. IPosFeatures.FeaturesOA, IFsComplexFeature.FeaturesOA).
# pythonnet narrows fs.Owner to the base ICmObject interface,
# which does NOT expose FeaturesOA -- so the prior hasattr(owner,
# "FeaturesOA") was always False and Delete silently did nothing.
# Route through _GetTypedOwner to recover the concrete interface
# before checking the slot. (issue #133, same class as #98/#116)
parent = self._GetTypedOwner(fs)
if parent is not None and hasattr(parent, "FeaturesOA") and parent.FeaturesOA == fs:
parent.FeaturesOA = None
# ========================================================================
# FEATURE OPERATIONS
# ========================================================================
@OperationsMethod
def FeatureGetAll(self):
"""
Get all feature definitions in the project.
Yields:
IFsFeatureDefn: Each feature definition object in the project.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> for feature in inflOps.FeatureGetAll():
... print(f"Feature: {feature}")
Notes:
- Feature definitions describe grammatical categories
- Each feature has a name and set of possible values
- Examples: person (1st/2nd/3rd), number (sg/pl), tense, aspect, mood
- Used to build feature structures for morphosyntactic analysis
- Returns empty if no feature system defined
See Also:
FeatureCreate, FeatureGetValues
"""
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system:
for feature in feature_system.FeaturesOC:
yield feature
@OperationsMethod
def Find(self, name, wsHandle=None):
"""
Find an inflection 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:
IFsFeatDefn or None: First match, or None.
Notes:
- Mirrors PhonFeatureOperations.Find. Searches both closed and
complex features in MsFeatureSystemOA.FeaturesOC.
- Does NOT search value names; values are looked up via
``FeatureGetValues(feature)``.
"""
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.MsFeatureSystemOA
if feature_system is None:
return None
for raw in feature_system.FeaturesOC:
feat = IFsFeatDefn(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 an inflection feature with the given name exists.
Args:
name (str): Name to look up.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
bool: True if the feature exists, False otherwise.
"""
self._ValidateParam(name, "name")
return self.Find(name, wsHandle=wsHandle) is not None
@OperationsMethod
def TypeFind(self, name, wsHandle=None):
"""
Find an inflection feature-struct type by name.
IFsFeatStrucType objects live in ``MsFeatureSystemOA.TypesOC``
and group related IFsClosedFeatures into FsFeatStruc shapes
(e.g. "tCommonAgr" for Bantu agreement features). Used by
catalog-aligned advanced workflows where multiple closed
features share a structural type.
Args:
name (str): The type's name. Compared via NFD-normalised
casefold match against each type's Name in the chosen WS.
wsHandle: Optional writing system handle. Defaults to
analysis WS.
Returns:
IFsFeatStrucType or None: First match, or None when no
type with that name exists or when the feature system is
not initialised.
"""
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.MsFeatureSystemOA
if feature_system is None:
return None
for raw in feature_system.TypesOC:
type_obj = IFsFeatStrucType(raw)
type_name = ITsString(type_obj.Name.get_String(wsHandle)).Text
if type_name and normalize_match_key(type_name, casefold=True) == target:
return type_obj
return None
@OperationsMethod
def TypeCreate(self, name, abbreviation):
"""
Create a new IFsFeatStrucType under ``MsFeatureSystemOA.TypesOC``.
Use this to introduce a structural grouping for inflection
features that will share a FsFeatStruc shape -- for example,
Bantu's "tCommonAgr" type, which groups person, number, and
noun-class features.
The new type is attached to ``MsFeatureSystemOA.TypesOC``
BEFORE its Name and Abbreviation multistrings are written
(Phase 2 ownership rule), so the property setters never hit
an orphan LCM object.
A fresh random GUID is generated. To create a catalog-aligned
type with a canonical GUID, use the bulk ImportCatalog path
instead -- the EticGlossList parser handles fsType entries
as part of the full feature-system import.
Args:
name (str): The type's display name (analysis WS),
e.g. "tCommonAgr". Cannot be empty.
abbreviation (str): Short label written to the analysis-WS
Abbreviation alternative. Cannot be empty.
Returns:
IFsFeatStrucType: The newly created type.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If name or abbreviation is None.
FP_ParameterError: If name or abbreviation is empty, if a
type with the same name already exists, or if
MsFeatureSystemOA is not initialised on the project.
Example:
>>> infl = project.InflectionFeatures
>>> agr_type = infl.TypeCreate("tCommonAgr", "Agr")
>>> assert infl.TypeFind("tCommonAgr") is not None
"""
self._EnsureWriteEnabled()
self._ValidateParam(name, "name")
self._ValidateParam(abbreviation, "abbreviation")
if not name or not name.strip():
raise FP_ParameterError("Type name cannot be empty")
if not abbreviation or not abbreviation.strip():
raise FP_ParameterError("Type abbreviation cannot be empty")
existing = self.TypeFind(name)
if existing is not None:
raise FP_ParameterError(
f"Inflection feature-struct type '{name}' already exists"
)
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system is None:
raise FP_ParameterError(
"Project has no MsFeatureSystemOA; cannot create "
"feature-struct type. Open the project in FieldWorks "
"once to initialise the feature system."
)
# The interface IFsFeatStrucTypeFactory declares a 2-arg
# Create(Guid, IFsFeatureSystem) overload, but the concrete
# SIL.LCModel.DomainImpl.FsFeatStrucTypeFactory only exposes
# Create() and Create(Guid) -- the 2-arg form is an interface
# default/extension that pythonnet's overload resolver
# cannot bind. Path B is therefore the only viable path:
# create the type with an explicit GUID, then attach to
# MsFeatureSystemOA.TypesOC BEFORE the multistring writes so
# the Phase 2 ownership rule still holds.
factory = self.project.project.ServiceLocator.GetService(
IFsFeatStrucTypeFactory
)
new_guid = System.Guid.NewGuid()
with self._TransactionCM(f"Create feature-struct type '{name}'"):
new_type = factory.Create(new_guid)
feature_system.TypesOC.Add(new_type)
ws_handle = self.__WSHandle(None)
new_type.Name.set_String(
ws_handle, TsStringUtils.MakeString(name, ws_handle)
)
new_type.Abbreviation.set_String(
ws_handle, TsStringUtils.MakeString(abbreviation, ws_handle)
)
return new_type
@OperationsMethod
def Create(self, name, abbreviation, type="closed", catalogSourceId=None, force=False):
"""
Create a new inflection feature (IFsClosedFeature or IFsComplexFeature).
Mirrors PhonFeatureOperations.Create so the API surface is symmetric
across the two feature systems. The legacy FeatureCreate(name, type)
method is preserved as a back-compat shim.
Args:
name (str): Feature name (e.g. "person", "number").
abbreviation (str): Short abbreviation (e.g. "pers", "num").
type (str): "closed" (default) for IFsClosedFeature, "complex"
for IFsComplexFeature.
catalogSourceId (str, optional): Optional catalog id. If it starts
with the ``INFL:`` prefix (case-insensitive), the feature is
created from the MGA EticGlossList.xml 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
(e.g. the FLEx UI user localized the labels), 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:
IFsFeatDefn: 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 (inherited from
# CatalogBackedMixin) for the canonical GUID + values, then overlay
# ONLY if 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 + ":"):
wsHandle = self.project.project.DefaultAnalWs
with self._TransactionCM(f"Create inflection feature '{name}'"):
new_feat = self.CreateFromCatalog(catalogSourceId)
self.__OverlayCanonicalLabels(
new_feat, name, abbreviation, wsHandle, force, catalogSourceId
)
return new_feat
# Uniqueness by name.
if self.Exists(name):
raise FP_ParameterError(f"Inflection feature '{name}' already exists")
wsHandle = self.project.project.DefaultAnalWs
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system is None:
raise FP_ParameterError(
"Project has no MsFeatureSystemOA; cannot create "
"inflection features."
)
# Phase 2 ownership-ordering rule: attach to the owning collection
# FIRST, then mutate properties.
type_normalized = (type or "closed").strip().lower()
with self._TransactionCM(f"Create inflection feature '{name}'"):
if type_normalized == "complex":
factory = self.project.project.ServiceLocator.GetService(
IFsComplexFeatureFactory
)
new_feat = factory.Create()
feature_system.FeaturesOC.Add(new_feat)
new_feat = IFsComplexFeature(new_feat)
else:
factory = self.project.project.ServiceLocator.GetService(
IFsClosedFeatureFactory
)
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-INFL: prefixes (and bare ids).
if catalogSourceId:
new_feat.CatalogSourceId = catalogSourceId
return new_feat
@OperationsMethod
def CreateValue(self, feature_or_hvo, name, abbreviation, value_marker=None):
"""
Create a new symbolic value (IFsSymFeatVal) under a closed inflection
feature.
Args:
feature_or_hvo: The IFsClosedFeature or HVO that owns the value.
name (str): Value name (e.g. "first", "second", "third" for person).
abbreviation (str): Short abbreviation (e.g. "1", "2", "3").
value_marker (str, optional): Currently informational only -- the
abbreviation itself carries the marker.
Returns:
IFsSymFeatVal: The newly created value.
Notes:
- Applies the Phase 2 ownership-ordering rule: factory.Create()
-> feature.ValuesOC.Add() -> set Name + Abbreviation.
- Mirrors PhonFeatureOperations.CreateValue.
"""
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.__ResolveFeature(feature_or_hvo)
# Cast to IFsClosedFeature so ValuesOC is accessible. IFsComplexFeature
# has no ValuesOC -- value creation only makes sense on closed features.
try:
feature = IFsClosedFeature(feature)
except Exception:
raise FP_ParameterError(
"CreateValue requires an IFsClosedFeature; complex features "
"do not have a ValuesOC collection."
)
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 # reserved for future strict-mode check
return new_val
@OperationsMethod
def CreateClosedFeatureWithValues(
self, name, abbreviation, values, catalogSourceId=None
):
"""
One-shot convenience: create a closed feature plus its symbolic
values in a single call.
Wraps Create(...) and CreateValue(...) so the very common pattern
of "define a gender feature with masculine/feminine/neuter values"
doesn't require interleaving wrapper calls.
Args:
name (str): Feature name (e.g. "gender").
abbreviation (str): Feature abbreviation (e.g. "gen").
values (sequence): Iterable of ``(value_name, value_abbreviation)``
tuples. Order is preserved.
catalogSourceId (str, optional): Optional catalog id forwarded
to Create(). When the "INFL:" prefix is used, value
children are populated from the catalog and these
``values`` overlay/append to them.
Returns:
tuple[IFsClosedFeature, list[IFsSymFeatVal]]: The new feature
and the list of newly created value objects (in the order
they were declared in ``values``).
Raises:
FP_ParameterError: If values is not iterable or contains
malformed tuples.
Example:
>>> feature, vals = project.Features.CreateClosedFeatureWithValues(
... name="gender", abbreviation="gen",
... values=[("masculine", "m"), ("feminine", "f"), ("neuter", "n")],
... )
"""
self._EnsureWriteEnabled()
self._ValidateParam(values, "values")
# Validate the value tuples up front so a malformed list doesn't
# leave a half-populated feature behind.
normalized = []
for i, pair in enumerate(values):
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
raise FP_ParameterError(
f"values[{i}] must be a (name, abbreviation) tuple"
)
vname, vabbr = pair
if not vname or not str(vname).strip():
raise FP_ParameterError(f"values[{i}] name cannot be empty")
if not vabbr or not str(vabbr).strip():
raise FP_ParameterError(
f"values[{i}] abbreviation cannot be empty"
)
normalized.append((str(vname), str(vabbr)))
with self._TransactionCM(f"Create closed feature '{name}' with values"):
feature = self.Create(
name=name,
abbreviation=abbreviation,
type="closed",
catalogSourceId=catalogSourceId,
)
# Roll the feature back if any CreateValue fails mid-loop, so a
# downstream LCM exception (WS issue, GUID collision, etc.)
# doesn't leave a partially-populated feature behind. Up-front
# tuple-shape validation above already covers the cheap-fail
# cases; this guards the LCM-call slice. (issue #127)
created_values = []
try:
for vname, vabbr in normalized:
created_values.append(self.CreateValue(feature, vname, vabbr))
except Exception:
try:
self.FeatureDelete(feature)
except Exception:
# Best-effort rollback; if FeatureDelete also fails
# (read-only transaction, weird LCM state), let the
# original exception propagate -- it carries the real
# failure.
pass
raise
return feature, created_values
@OperationsMethod
def MakeFeatStruc(self, specs, owner=None):
"""
Build an IFsFeatStruc populated with (feature, value) pairs.
Mirrors PhonFeatureOperations.MakeFeatStruc; produces inflection
feature structures suitable for attaching to MSAs and inflection
templates.
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 BEFORE its FeatureSpecsOC is
populated (Phase 2 ownership rule -- LCM property
accessors NPE on free-floating IFsFeatStruc objects).
``owner=None`` is rejected unconditionally (issue #28,
matching PhonFeatureOperations.MakeFeatStruc).
Inflection-feature structs typically attach to MSAs,
inflection templates, or syntactic contexts.
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=msa / "
"owner=template / 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.__ResolveFeature(feat_in) if not isinstance(feat_in, int) else self.project.Object(feat_in))
val = self.__Unwrap(val_in if not isinstance(val_in, int) else self.project.Object(val_in))
normalized.append((feat, val))
# Attach owner FIRST so subsequent FeatureSpecsOC mutations
# don't trip the Phase 2 NPE pattern.
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
@OperationsMethod
def FeatureCreate(self, name, type):
"""
Create a new feature definition.
Args:
name (str): The name of the feature (e.g., "person", "number", "tense").
type (str): The type of feature (e.g., "complex" for structured features).
Returns:
IFsFeatureDefn: The newly created feature definition object.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If name or type is None.
FP_ParameterError: If name or type is empty, or if a feature with
this name already exists.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> person = inflOps.FeatureCreate("person", "complex")
>>> number = inflOps.FeatureCreate("number", "complex")
>>> tense = inflOps.FeatureCreate("tense", "complex")
Notes:
- Features define grammatical categories
- Values must be added separately after creation
- Features can be shared across multiple parts of speech
- The 'complex' type is used for features with multiple values
- Feature is created in the default analysis writing system
See Also:
FeatureDelete, FeatureGetAll, FeatureGetValues
"""
self._EnsureWriteEnabled()
self._ValidateParam(name, "name")
self._ValidateParam(type, "type")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
if not type or not type.strip():
raise FP_ParameterError("Type cannot be empty")
# Check if feature already exists
wsHandle = self.project.project.DefaultAnalWs
target = normalize_match_key(name, casefold=True)
for existing_feature in self.FeatureGetAll():
existing_name = ITsString(existing_feature.Name.get_String(wsHandle)).Text
if existing_name and normalize_match_key(existing_name, casefold=True) == target:
raise FP_ParameterError(f"Feature '{name}' already exists")
# Create the new feature using the factory
# Using complex feature factory as it's the most common type
factory = self.project.project.ServiceLocator.GetService(IFsComplexFeatureFactory)
with self._TransactionCM(f"Create feature '{name}'"):
new_feature = factory.Create()
# Add to the feature system (must be done before setting properties)
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system:
feature_system.FeaturesOC.Add(new_feature)
# Set name
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
new_feature.Name.set_String(wsHandle, mkstr_name)
return new_feature
@OperationsMethod
def FeatureDelete(self, feature_or_hvo):
"""
Delete a feature definition.
Args:
feature_or_hvo: The IFsFeatureDefn object or HVO to delete.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If feature_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> # Find and delete a test feature
>>> for feature in inflOps.FeatureGetAll():
... ws = project.project.DefaultAnalWs
... name = ITsString(feature.Name.get_String(ws)).Text
... if name == "test_feature":
... inflOps.FeatureDelete(feature)
... break
Warning:
- Cannot delete if used in feature structures
- Cannot delete if referenced by entries or morphemes
- Check dependencies before deletion
- Deletion is permanent and cannot be undone
See Also:
FeatureCreate, FeatureGetAll
"""
self._EnsureWriteEnabled()
self._ValidateParam(feature_or_hvo, "feature_or_hvo")
# Resolve to feature object
feature = self.__ResolveFeature(feature_or_hvo)
# Remove from the feature system
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system:
feature_system.FeaturesOC.Remove(feature)
@OperationsMethod
def FeatureGetValues(self, feature_or_hvo):
"""
Get all possible values for a feature.
Args:
feature_or_hvo: The IFsFeatureDefn object or HVO.
Returns:
list: List of feature value objects (empty list if none).
Raises:
FP_NullParameterError: If feature_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> person = inflOps.FeatureCreate("person", "complex")
>>> # After adding values 1st, 2nd, 3rd...
>>> values = inflOps.FeatureGetValues(person)
>>> print(f"Person has {len(values)} values")
Notes:
- Returns symbolic values (named choices)
- Empty list if no values defined yet
- Values represent the possible settings for this feature
- Example: person feature has values [1st, 2nd, 3rd]
- Example: number feature has values [singular, plural]
- The type and structure of values depends on the feature type
See Also:
FeatureCreate, FeatureGetAll
"""
self._ValidateParam(feature_or_hvo, "feature_or_hvo")
feature = self.__ResolveFeature(feature_or_hvo)
# IFsClosedFeature has the symbolic ValuesOC; IFsComplexFeature
# has no children of its own at this surface (sub-features live
# on its TypeRA.FeaturesRS, a separate read shape). The previous
# hasattr(feature, "FeaturesOS") fallback was always False at
# runtime -- no IFsFeatDefn variant exposes FeaturesOS. Removed
# in (#140); a IFsComplexFeature traversal would belong in a
# separate method with an explicit isinstance check.
if hasattr(feature, "ValuesOC"):
return list(feature.ValuesOC)
return []
@OperationsMethod
def GetFeatures(self, feature_system_or_hvo):
"""
Get all features in the feature system (READ-ONLY).
Args:
feature_system_or_hvo: The feature system object or HVO.
Returns:
list: List of feature definition objects (IFsFeatDefn).
Raises:
FP_NullParameterError: If feature_system_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> # Get the feature system
>>> feature_system = project.lp.MsFeatureSystemOA
>>> if feature_system:
... features = inflOps.GetFeatures(feature_system)
... print(f"Feature system has {len(features)} features")
... for feature in features:
... wsHandle = project.project.DefaultAnalWs
... name = ITsString(feature.Name.get_String(wsHandle)).Text
... print(f" - {name}")
Feature system has 3 features
- person
- number
- tense
Notes:
- READ-ONLY property - returns the collection
- Access via feature_system.FeaturesOC (IFsFeatureSystem owns
feature definitions as an owning collection, not sequence)
- Returns empty list if no features defined
- Features represent grammatical categories
- Each feature can have multiple values
See Also:
GetFeatureConstraints, FeatureGetAll, FeatureCreate
"""
self._ValidateParam(feature_system_or_hvo, "feature_system_or_hvo")
# Resolve to feature system object
if isinstance(feature_system_or_hvo, int):
feature_system = self.project.Object(feature_system_or_hvo)
else:
feature_system = feature_system_or_hvo
# Return features collection if it exists
if hasattr(feature_system, "FeaturesOC"):
return list(feature_system.FeaturesOC)
return []
@OperationsMethod
def GetFeatureConstraints(self, feature_system_or_hvo):
"""
Get all feature constraints in the feature system (READ-ONLY).
Args:
feature_system_or_hvo: The feature system object or HVO.
Returns:
list: List of feature constraint objects.
Raises:
FP_NullParameterError: If feature_system_or_hvo is None.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> # Get the feature system
>>> feature_system = project.lp.MsFeatureSystemOA
>>> if feature_system:
... constraints = inflOps.GetFeatureConstraints(feature_system)
... print(f"Feature system has {len(constraints)} constraints")
... for constraint in constraints:
... print(f" - Constraint HVO: {constraint.Hvo}")
Notes:
- READ-ONLY property - returns the collection
- Access via feature_system.FeatureConstraints
- Returns empty list if no constraints defined
- Constraints restrict valid feature combinations
- Used for modeling co-occurrence restrictions
- Example: agreement constraints between features
See Also:
GetFeatures, FeatureGetAll
"""
self._ValidateParam(feature_system_or_hvo, "feature_system_or_hvo")
# Resolve to feature system object
if isinstance(feature_system_or_hvo, int):
feature_system = self.project.Object(feature_system_or_hvo)
else:
feature_system = feature_system_or_hvo
# Return feature constraints collection if it exists
if hasattr(feature_system, "FeatureConstraintsOC"):
return list(feature_system.FeatureConstraintsOC)
return []
@OperationsMethod
def GetTypes(self, feature_system_or_hvo):
"""
Get the feature types collection from a feature system (READ-ONLY).
This is a getter-only method that returns the collection of feature types
defined in the morphosyntactic feature system. Feature types categorize
features into groups (e.g., inflectional features, agreement features).
Args:
feature_system_or_hvo: The IFsFeatureSystem object or HVO, or None to
use the project's default feature system.
Returns:
list: List of feature type objects (IFsFeatDefn) from the TypesOC
collection, or empty list if none defined.
Raises:
FP_NullParameterError: If feature_system_or_hvo is explicitly None
and no default feature system exists.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> # Get types from default feature system
>>> types = inflOps.GetTypes(None)
>>> for ftype in types:
... print(f"Feature type: {ftype}")
>>> # Get types from specific feature system
>>> fs = project.lp.MsFeatureSystemOA
>>> types = inflOps.GetTypes(fs)
>>> print(f"Found {len(types)} feature types")
>>> # Iterate and display type names
>>> for ftype in types:
... ws = project.project.DefaultAnalWs
... name = ITsString(ftype.Name.get_String(ws)).Text
... print(f"Type: {name}")
Notes:
- This is a READ-ONLY property (no setter provided)
- Returns the TypesOC collection from the feature system
- TypesOC contains feature type definitions
- Returns empty list if feature system has no types
- Feature types organize and categorize features
- If feature_system_or_hvo is None, uses MsFeatureSystemOA
- Common feature types include inflectional, derivational, agreement
See Also:
FeatureGetAll, FeatureCreate, FeatureStructureGetAll
"""
# Get the feature system
if feature_system_or_hvo is None:
feature_system = self.project.lp.MsFeatureSystemOA
self._ValidateParam(feature_system, "feature_system")
else:
feature_system = self.__ResolveFeatureSystem(feature_system_or_hvo)
# Return the types collection if it exists
if hasattr(feature_system, "TypesOC"):
return list(feature_system.TypesOC)
return []
# ========================================================================
# 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 InflFeats-specific LCM types
# (IFsClosedFeature + its IFsSymFeatVal value children) under
# MsFeatureSystemOA.
#
# 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.
#
# Phase 6a MVP scope: closed features only (type="feature" + nested
# type="value"). The catalog also has type="fsType" and type="complex"
# items; parse_etic_gloss_list silently skips them at parse time, so
# they never reach these hooks. fsType/complex import is deferred.
# --- CatalogBackedMixin hooks --------------------------------------
def _get_root_list(self):
"""Return the top-level owner (MsFeatureSystemOA)."""
return self.project.lp.MsFeatureSystemOA
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 inflection features,
parent_obj is always None (features are flat, owned directly by
MsFeatureSystemOA.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
MsFeatureSystemOA.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 MsFeatureSystemOA. 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.
FeaturesOC may also contain IFsComplexFeature items (FW's stock
projects ship complex morphosyntactic features pre-installed).
Those are not catalog candidates here, so we filter by
ClassName and yield only the closed features.
"""
feature_system = self.project.lp.MsFeatureSystemOA
if feature_system is None:
return
for raw in feature_system.FeaturesOC:
if raw.ClassName == "FsClosedFeature":
yield IFsClosedFeature(raw)
# else: complex/other feature types live alongside but
# aren't part of this catalog domain; skip silently.
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). Mirrors the PhonFeats implementation.
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 short value marker; term is
# the value 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 __ResolveInflectionClass(self, ic_or_hvo):
"""
Resolve HVO or object to IMoInflClass.
Args:
ic_or_hvo: Either an IMoInflClass object or an HVO (int).
Returns:
IMoInflClass: The resolved inflection class object.
"""
if isinstance(ic_or_hvo, int):
return self.project.Object(ic_or_hvo)
return ic_or_hvo
def __ResolveFeatureStructure(self, fs_or_hvo):
"""
Resolve HVO or object to IFsFeatStruc.
Args:
fs_or_hvo: Either an IFsFeatStruc object or an HVO (int).
Returns:
IFsFeatStruc: The resolved feature structure object.
"""
if isinstance(fs_or_hvo, int):
return self.project.Object(fs_or_hvo)
return fs_or_hvo
def __ResolveFeature(self, feature_or_hvo):
"""
Resolve HVO or object to IFsFeatureDefn.
Args:
feature_or_hvo: Either an IFsFeatureDefn object or an HVO (int).
Returns:
IFsFeatureDefn: The resolved feature definition object.
"""
if isinstance(feature_or_hvo, int):
return self.project.Object(feature_or_hvo)
return feature_or_hvo
def __ResolveFeatureSystem(self, fs_or_hvo):
"""
Resolve HVO or object to IFsFeatureSystem.
Args:
fs_or_hvo: Either an IFsFeatureSystem object or an HVO (int).
Returns:
IFsFeatureSystem: The resolved feature system object.
"""
if isinstance(fs_or_hvo, int):
return self.project.Object(fs_or_hvo)
return fs_or_hvo
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get dictionary of syncable properties for cross-project synchronization.
This method works with inflection classes (IMoInflClass). For features and
feature structures, use separate sync methods if needed.
Args:
item: The IMoInflClass (inflection class) object.
Returns:
dict: Dictionary mapping property names to their values.
Keys are property names, values are the property values.
Example:
>>> inflOps = InflectionFeatureOperations(project)
>>> ic = list(inflOps.InflectionClassGetAll())[0]
>>> props = inflOps.GetSyncableProperties(ic)
>>> print(props.keys())
dict_keys(['Name', 'Abbreviation', 'Description'])
Notes:
- Returns all MultiString properties (all writing systems)
- This implementation focuses on inflection classes
- Does not include GUID or HVO
"""
ic = self.__ResolveInflectionClass(item)
# Get all writing systems for MultiString properties
# Fix: ILgWritingSystemFactory does not expose a .WritingSystems
# property; enumerate via the wrapper's WritingSystemOperations.GetAll(),
# which returns CoreWritingSystemDefinition objects with .Id / .Handle.
all_ws = {ws.Id: ws.Handle for ws in self.project.WritingSystems.GetAll()}
props = {}
# MultiString properties
for prop_name in ["Name", "Abbreviation", "Description"]:
if hasattr(ic, prop_name):
prop_obj = getattr(ic, prop_name)
ws_values = {}
for ws_id, ws_handle in all_ws.items():
text = ITsString(prop_obj.get_String(ws_handle)).Text
if text: # Only include non-empty values
ws_values[ws_id] = text
if ws_values: # Only include property if it has values
props[prop_name] = ws_values
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""Apply syncable properties (from GetSyncableProperties) onto an item.
Inherited from BaseOperations; declared on the concrete class so static
API indexers see it. The base implementation handles every property
shape this class's GetSyncableProperties emits.
"""
return super().ApplySyncableProperties(item, props, ws_map, fill_gaps=fill_gaps)
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two inflection classes and return detailed differences.
Args:
item1: First inflection class to compare (from source project).
item2: Second inflection class to compare (from target project).
ops1: Optional InflectionFeatureOperations instance for item1's project.
Defaults to self.
ops2: Optional InflectionFeatureOperations instance for item2's project.
Defaults to self.
Returns:
tuple: (is_different, differences) where:
- is_different (bool): True if items differ
- differences (dict): Maps property names to (value1, value2) tuples
Example:
>>> ic1 = project1_inflOps.InflectionClassFind("First Declension")
>>> ic2 = project2_inflOps.InflectionClassFind("First Declension")
>>> is_diff, diffs = project1_inflOps.CompareTo(
... ic1, ic2,
... ops1=project1_inflOps,
... ops2=project2_inflOps
... )
>>> if is_diff:
... for prop, (val1, val2) in diffs.items():
... print(f"{prop}: {val1} -> {val2}")
Notes:
- Compares all MultiString properties across all writing systems
- Returns empty dict if items are identical
- Handles cross-project comparison via ops1/ops2
"""
if ops1 is None:
ops1 = self
if ops2 is None:
ops2 = self
# Get syncable properties from both items
props1 = ops1.GetSyncableProperties(item1)
props2 = ops2.GetSyncableProperties(item2)
is_different = False
differences = {}
# Compare each property
all_keys = set(props1.keys()) | set(props2.keys())
for key in all_keys:
val1 = props1.get(key)
val2 = props2.get(key)
# For MultiString properties, compare the dictionaries
if val1 != val2:
is_different = True
differences[key] = (val1, val2)
return (is_different, differences)
# ========================================================================
# 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"Inflection 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 __Unwrap(self, obj):
"""
Peel off LCMObjectWrapper-style wrappers, if any. Plain LCM
objects pass through unchanged. Mirrors PhonFeatureOperations.__Unwrap
so that wrapper objects can be passed as feature/value/owner
arguments to MakeFeatStruc (issue #120).
"""
if hasattr(obj, "_obj") and not hasattr(obj, "Hvo"):
return obj._obj
if hasattr(obj, "_obj") and hasattr(obj._obj, "Hvo"):
return obj._obj
return obj
def __WSHandle(self, wsHandle):
"""
Get writing system handle, defaulting to analysis WS.
Args:
wsHandle: Optional writing system handle.
Returns:
int: The writing system handle.
"""
if wsHandle is None:
return self.project.project.DefaultAnalWs
return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs)