Source code for flexicon.code.Grammar.GramCatOperations

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

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

# Import FLEx LCM types
from SIL.LCModel import (
    ICmPossibility,
    ICmPossibilityFactory,
    IFsFeatStrucTypeFactory,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

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


[docs] class GramCatOperations(BaseOperations): """ This class provides operations for managing Grammatical Categories in a FieldWorks project. Grammatical categories are used to classify and describe grammatical properties such as person, number, gender, tense, aspect, mood, case, and other morphosyntactic features used in linguistic analysis. Usage:: from flexlibs2 import FLExProject, GramCatOperations project = FLExProject() project.OpenProject("my project", writeEnabled=True) gramCatOps = GramCatOperations(project) # Get all grammatical categories for cat in gramCatOps.GetAll(): print(gramCatOps.GetName(cat)) # Create a new category person = gramCatOps.Create("person") # Create subcategories first = gramCatOps.Create("1st person", parent=person) second = gramCatOps.Create("2nd person", parent=person) third = gramCatOps.Create("3rd person", parent=person) # Navigate hierarchy parent = gramCatOps.GetParent(first) subcats = gramCatOps.GetSubcategories(person) project.CloseProject() """ def __init__(self, project): """ Initialize GramCatOperations 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 grammatical categories. For GramCat, we reorder parent.SubPossibilitiesOS """ return parent.SubPossibilitiesOS @wrap_enumerable @OperationsMethod def GetAll(self, recursive=True, **kwargs): """ Get all grammatical categories in the project. Args: recursive (bool): If True (default), yields every category in the hierarchy (depth-first, parents before children). If False, yields only top-level categories and the caller must descend via GetSubcategories. Yields: ICmPossibility: Each grammatical category. Example: >>> gramCatOps = GramCatOperations(project) >>> for cat in gramCatOps.GetAll(): ... print(gramCatOps.GetName(cat)) # Includes subcategories >>> # Top-level only >>> for cat in gramCatOps.GetAll(recursive=False): ... print(gramCatOps.GetName(cat)) See Also: GetSubcategories, Create """ self._RejectLegacyKwargs(kwargs, { "flat": ("recursive", "semantics inverted: flat=True is now recursive=True"), }) feature_system = self.project.lp.MsFeatureSystemOA if not feature_system: return def walk(collection): for cat in collection: yield cat if recursive and hasattr(cat, "SubPossibilitiesOS") and cat.SubPossibilitiesOS.Count > 0: yield from walk(cat.SubPossibilitiesOS) yield from walk(feature_system.TypesOC) @OperationsMethod def Create(self, name, parent=None): """ Create a new grammatical category. Args: name (str): The name of the category (e.g., "person", "1st person"). parent (ICmPossibility or int, optional): Optional parent category for creating subcategories. Can be an ICmPossibility object or an HVO. Defaults to None (creates top-level category). Returns: ICmPossibility: The newly created grammatical category 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 category with this name already exists. Example: >>> gramCatOps = GramCatOperations(project) >>> # Create top-level category >>> person = gramCatOps.Create("person") >>> print(gramCatOps.GetName(person)) person >>> # Create subcategories >>> first = gramCatOps.Create("1st person", parent=person) >>> second = gramCatOps.Create("2nd person", parent=person) >>> third = gramCatOps.Create("3rd person", parent=person) >>> # Create nested hierarchy >>> number = gramCatOps.Create("number") >>> singular = gramCatOps.Create("singular", parent=number) >>> plural = gramCatOps.Create("plural", parent=number) Notes: - If parent is None, creates a top-level category - If parent is provided, creates a subcategory - Name must be unique within the project - The category is created in the default analysis writing system - Used for linguistic feature analysis in morphology See Also: Delete, GetSubcategories, GetParent """ self._EnsureWriteEnabled() self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") # Get the writing system handle wsHandle = self.project.project.DefaultAnalWs # Dispatch the factory on the destination collection's typed # element. MsFeatureSystemOA.TypesOC is # LcmOwningCollection<IFsFeatStrucType> -- adding a bare # ICmPossibility there violates the typed-collection invariant # (the bug #163 calls out). SubPossibilitiesOS is the generic # ICmPossibility sequence and accepts the base factory. So: # top-level new categories go through IFsFeatStrucTypeFactory, # subcategories stay with ICmPossibilityFactory. # (issue #163) with self._TransactionCM(f"Create category '{name}'"): if parent: parent_obj = self.__ResolveObject(parent) factory = self.project.project.ServiceLocator.GetService(ICmPossibilityFactory) new_cat = factory.Create() parent_obj.SubPossibilitiesOS.Add(new_cat) else: feature_system = self.project.lp.MsFeatureSystemOA factory = self.project.project.ServiceLocator.GetService(IFsFeatStrucTypeFactory) new_cat = factory.Create() feature_system.TypesOC.Add(new_cat) # Set name mkstr_name = TsStringUtils.MakeString(name, wsHandle) new_cat.Name.set_String(wsHandle, mkstr_name) return new_cat @OperationsMethod def Delete(self, cat_or_hvo): """ Delete a grammatical category. Args: cat_or_hvo: The ICmPossibility object or HVO to delete. Raises: FP_ReadOnlyError: If the project is not opened with write enabled. FP_NullParameterError: If cat_or_hvo is None. FP_ParameterError: If the category is in use and cannot be deleted. Example: >>> gramCatOps = GramCatOperations(project) >>> obsolete = gramCatOps.Create("obsolete_feature") >>> # Later, if not needed... >>> gramCatOps.Delete(obsolete) Warning: - Deleting a category that is in use may raise an error from FLEx - Will also delete all subcategories recursively - Deletion is permanent and cannot be undone - Morphological rules or entries using this may be affected See Also: Create """ self._EnsureWriteEnabled() self._ValidateParam(cat_or_hvo, "cat_or_hvo") # Resolve to category object cat = self.__ResolveObject(cat_or_hvo) # Remove from the feature system TypesOC feature_system = self.project.lp.MsFeatureSystemOA feature_system.TypesOC.Remove(cat) @OperationsMethod def GetName(self, cat_or_hvo, wsHandle=None): """ Get the name of a grammatical category. Args: cat_or_hvo: The ICmPossibility object or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The category name, or empty string if not set. Raises: FP_NullParameterError: If cat_or_hvo is None. Example: >>> gramCatOps = GramCatOperations(project) >>> person = gramCatOps.Create("person") >>> name = gramCatOps.GetName(person) >>> print(name) person >>> # Get name in a specific writing system >>> vern_name = gramCatOps.GetName(person, project.WSHandle('en')) >>> # List all categories >>> for cat in gramCatOps.GetAll(): ... print(gramCatOps.GetName(cat)) See Also: SetName, GetSubcategories """ self._ValidateParam(cat_or_hvo, "cat_or_hvo") cat = self.__ResolveObject(cat_or_hvo) wsHandle = self.__WSHandle(wsHandle) name = ITsString(cat.Name.get_String(wsHandle)).Text return name or "" @OperationsMethod def SetName(self, cat_or_hvo, name, wsHandle=None): """ Set the name of a grammatical category. Args: cat_or_hvo: The ICmPossibility 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 cat_or_hvo or name is None. FP_ParameterError: If name is empty. Example: >>> gramCatOps = GramCatOperations(project) >>> cat = gramCatOps.Create("prson") # typo >>> gramCatOps.SetName(cat, "person") # fix it >>> # Set name in vernacular WS >>> gramCatOps.SetName(cat, "persona", project.WSHandle('es')) See Also: GetName """ self._EnsureWriteEnabled() self._ValidateParam(cat_or_hvo, "cat_or_hvo") self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Name cannot be empty") cat = self.__ResolveObject(cat_or_hvo) wsHandle = self.__WSHandle(wsHandle) mkstr = TsStringUtils.MakeString(name, wsHandle) cat.Name.set_String(wsHandle, mkstr) @wrap_enumerable @OperationsMethod def GetSubcategories(self, cat_or_hvo, recursive=True, **kwargs): """ Get the subcategories of a grammatical category. Args: cat_or_hvo: The ICmPossibility object or HVO. recursive (bool): If True (default), returns every descendant (depth-first, parents before children). If False, returns only direct children. Returns: list: List of ICmPossibility subcategory objects (empty list if none). Raises: FP_NullParameterError: If cat_or_hvo is None. Example: >>> gramCatOps = GramCatOperations(project) >>> person = gramCatOps.Find("person") >>> for subcat in gramCatOps.GetSubcategories(person): ... print(gramCatOps.GetName(subcat)) # All descendants >>> # Direct children only >>> for subcat in gramCatOps.GetSubcategories(person, recursive=False): ... print(gramCatOps.GetName(subcat)) See Also: GetAll, GetParent, Create """ self._RejectLegacyKwargs(kwargs, { "flat": ("recursive", "semantics inverted: flat=True is now recursive=True"), }) self._ValidateParam(cat_or_hvo, "cat_or_hvo") cat = self.__ResolveObject(cat_or_hvo) if not recursive: return list(cat.SubPossibilitiesOS) result = [] def walk(collection): for child in collection: result.append(child) if hasattr(child, "SubPossibilitiesOS") and child.SubPossibilitiesOS.Count > 0: walk(child.SubPossibilitiesOS) walk(cat.SubPossibilitiesOS) return result @OperationsMethod def GetParent(self, cat_or_hvo): """ Get the parent category of a grammatical category. Args: cat_or_hvo: The ICmPossibility object or HVO. Returns: ICmPossibility or None: Parent category, or None if top-level. Raises: FP_NullParameterError: If cat_or_hvo is None. Example: >>> gramCatOps = GramCatOperations(project) >>> person = gramCatOps.Create("person") >>> first = gramCatOps.Create("1st person", parent=person) >>> >>> parent = gramCatOps.GetParent(first) >>> print(gramCatOps.GetName(parent)) person >>> >>> # Top-level categories have no parent >>> print(gramCatOps.GetParent(person)) None >>> # Traverse up the hierarchy >>> cat = first >>> while cat: ... print(gramCatOps.GetName(cat)) ... cat = gramCatOps.GetParent(cat) 1st person person Notes: - Returns None for top-level categories - Use to traverse hierarchy upward - Useful for determining category depth - Parent-child relationships define category taxonomy See Also: GetSubcategories, Create """ self._ValidateParam(cat_or_hvo, "cat_or_hvo") cat = self.__ResolveObject(cat_or_hvo) # Check if this category has a parent # The Owner property returns the owning object if hasattr(cat, "Owner") and cat.Owner: # Check if the owner is also a possibility (not the feature system itself) try: owner = ICmPossibility(cat.Owner) return owner except (AttributeError, System.InvalidCastException) as e: # If owner is not a possibility, it's a top-level category return None return None @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=True, deep=False): """ Duplicate a grammatical category, creating a new copy with a new GUID. Args: item_or_hvo: The ICmPossibility object or HVO to duplicate. insert_after (bool): For subcategories (SubPossibilitiesOS is ordered), controls whether the duplicate is inserted immediately after the source. Ignored for top-level categories: TypesOC is an unordered ILcmOwningCollection, so top-level duplicates are always appended via Add(). deep (bool): If True, recursively duplicate all subcategories. If False (default), only duplicate the category itself. Returns: ICmPossibility: The newly created duplicate category 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: >>> gramCatOps = GramCatOperations(project) >>> person = gramCatOps.Create("person") >>> # Shallow copy (no subcategories) >>> person_copy = gramCatOps.Duplicate(person) >>> print(gramCatOps.GetName(person_copy)) person >>> # Deep copy (includes all subcategories) >>> number = gramCatOps.Create("number") >>> singular = gramCatOps.Create("singular", parent=number) >>> plural = gramCatOps.Create("plural", parent=number) >>> number_copy = gramCatOps.Duplicate(number, deep=True) >>> orig_subs = gramCatOps.GetSubcategories(number) >>> copy_subs = gramCatOps.GetSubcategories(number_copy) >>> print(f"Original has {len(orig_subs)} subcategories") >>> print(f"Copy has {len(copy_subs)} subcategories") Notes: - Factory.Create() automatically generates a new GUID - insert_after=True preserves the original category's position - Simple properties copied: Name, Abbreviation, Description (MultiString) - deep=True recursively duplicates SubPossibilitiesOS hierarchy - Used for creating variants of grammatical feature hierarchies See Also: Create, Delete, GetSubcategories """ self._EnsureWriteEnabled() self._ValidateParam(item_or_hvo, "item_or_hvo") # Get source category source = self.__ResolveObject(item_or_hvo) # Determine parent and insertion position. Top-level categories # live in feature_system.TypesOC (LcmOwningCollection<IFsFeatStrucType>); # subcategories live in their parent's SubPossibilitiesOS # (LcmOwningSequence<ICmPossibility>). Dispatch the factory on # the destination to satisfy each typed-collection invariant. # (issue #163) parent_is_possibility = False try: parent_cat = ICmPossibility(source.Owner) parent_is_possibility = True except Exception: parent_is_possibility = False with self._TransactionCM("Duplicate category"): if parent_is_possibility: # Source is a subcategory parent_cat = ICmPossibility(source.Owner) factory = self.project.project.ServiceLocator.GetService(ICmPossibilityFactory) duplicate = factory.Create() if insert_after: source_index = parent_cat.SubPossibilitiesOS.IndexOf(source) parent_cat.SubPossibilitiesOS.Insert(source_index + 1, duplicate) else: parent_cat.SubPossibilitiesOS.Add(duplicate) else: # Source is top-level -- TypesOC requires IFsFeatStrucType. # TypesOC is an unordered ILcmOwningCollection; insert_after has # no semantic meaning here and is ignored. feature_system = self.project.lp.MsFeatureSystemOA factory = self.project.project.ServiceLocator.GetService(IFsFeatStrucTypeFactory) duplicate = factory.Create() feature_system.TypesOC.Add(duplicate) # Copy simple MultiString properties (AFTER adding to parent) duplicate.Name.CopyAlternatives(source.Name) duplicate.Abbreviation.CopyAlternatives(source.Abbreviation) duplicate.Description.CopyAlternatives(source.Description) # Deep copy: recursively duplicate subcategories if deep and source.SubPossibilitiesOS.Count > 0: for sub_cat in source.SubPossibilitiesOS: # Recursively duplicate each subcategory self.__DuplicateSubcategory(sub_cat, duplicate) return duplicate def __DuplicateSubcategory(self, source_sub, parent_duplicate): """ Helper method to recursively duplicate a subcategory. Args: source_sub: The source ICmPossibility subcategory to duplicate. parent_duplicate: The parent ICmPossibility to add the duplicate to. Returns: ICmPossibility: The duplicated subcategory. """ # Create new subcategory factory = self.project.project.ServiceLocator.GetService(ICmPossibilityFactory) sub_duplicate = factory.Create() # Add to parent's SubPossibilitiesOS parent_duplicate.SubPossibilitiesOS.Add(sub_duplicate) # Copy properties sub_duplicate.Name.CopyAlternatives(source_sub.Name) sub_duplicate.Abbreviation.CopyAlternatives(source_sub.Abbreviation) sub_duplicate.Description.CopyAlternatives(source_sub.Description) # Recursively duplicate nested subcategories if source_sub.SubPossibilitiesOS.Count > 0: for nested_sub in source_sub.SubPossibilitiesOS: self.__DuplicateSubcategory(nested_sub, sub_duplicate) return sub_duplicate # --- Private Helper Methods --- def __ResolveObject(self, cat_or_hvo): """ Resolve HVO or object to ICmPossibility. Args: cat_or_hvo: Either an ICmPossibility object or an HVO (int). Returns: ICmPossibility: The resolved category object. """ if isinstance(cat_or_hvo, int): return self.project.Object(cat_or_hvo) return cat_or_hvo # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get dictionary of syncable properties for cross-project synchronization. Args: item: The ICmPossibility (grammatical category) object. Returns: dict: Dictionary mapping property names to their values. Keys are property names, values are the property values. Example: >>> gramCatOps = GramCatOperations(project) >>> cat = list(gramCatOps.GetAll())[0] >>> props = gramCatOps.GetSyncableProperties(cat) >>> print(props.keys()) dict_keys(['Name', 'Abbreviation', 'Description']) Notes: - Returns all MultiString properties (all writing systems) - Does not include SubPossibilitiesOS (child categories) - Does not include GUID or HVO """ cat = self.__ResolveObject(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"]: prop_obj = getattr(cat, 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 grammatical categories and return detailed differences. Args: item1: First category to compare (from source project). item2: Second category to compare (from target project). ops1: Optional GramCatOperations instance for item1's project. Defaults to self. ops2: Optional GramCatOperations 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: >>> cat1 = project1_gramCatOps.Find("person") >>> cat2 = project2_gramCatOps.Find("person") >>> is_diff, diffs = project1_gramCatOps.CompareTo( ... cat1, cat2, ... ops1=project1_gramCatOps, ... ops2=project2_gramCatOps ... ) >>> 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 __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)