#
# POSOperations.py
#
# Class: POSOperations
# Parts of Speech 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 and decorators
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
# Import FLEx LCM types
from SIL.LCModel import (
ILexEntryRepository,
IMoInflAffixSlot,
IMoInflAffixSlotFactory,
IPartOfSpeech,
IPartOfSpeechFactory,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
# Import flexlibs exceptions
from ..FLExProject import (
FP_NullParameterError, # noqa: F401 -- raised by _ValidateParam
FP_ParameterError,
FP_ReadOnlyError, # noqa: F401 -- raised by _EnsureWriteEnabled
)
# Import LCM casting utilities for pythonnet interface casting
from ..lcm_casting import get_pos_from_msa
# Import string utilities
from ..Shared.string_utils import normalize_match_key, normalize_text
# Import the AffixSlot wrapper (issue #542 read-side pair for CreateAffixSlot)
from .affix_slot import AffixSlot
# Catalog (GOLDEtic) parsing helpers
from ..Shared.catalog import parse_etic_catalog
from ..Shared.catalog_backed import CatalogBackedMixin
[docs]
class POSOperations(BaseOperations, CatalogBackedMixin):
"""
This class provides operations for managing Parts of Speech in a
FieldWorks project.
Parts of Speech are fundamental grammatical categories used in linguistic
analysis (e.g., Noun, Verb, Adjective, etc.).
Usage::
from flexicon import FLExProject, POSOperations
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
posOps = POSOperations(project)
# Get all parts of speech
for pos in posOps.GetAll():
print(posOps.GetName(pos), posOps.GetAbbreviation(pos))
# Create a new POS
noun = posOps.Create("Noun", "N")
# Find and update
verb = posOps.Find("Verb")
if verb:
posOps.SetAbbreviation(verb, "V")
project.CloseProject()
"""
# --- CatalogBackedMixin configuration ------------------------------
# The GOLDEtic catalog ships with FW under Templates/GOLDEtic.xml and
# is parsed by parse_etic_catalog. POSs are written with a
# "GOLD:<id>" CatalogSourceId so FixGuidsAgainstCatalog can find them
# later. POSs are hierarchical (SubPossibilitiesOS), so the mixin's
# recursive-entries flag is on.
CATALOG_FILE = "GOLDEtic.xml"
CATALOG_SUBDIR = "Templates"
CATALOG_PARSER = staticmethod(parse_etic_catalog)
CATALOG_PREFIX_WRITE = "GOLD"
DOMAIN_LABEL = "POS"
_supports_recursive_entries = True
def __init__(self, project):
"""
Initialize POSOperations 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 POS.
For POS, we reorder parent.SubPossibilitiesOS
"""
return parent.SubPossibilitiesOS
@wrap_enumerable
@OperationsMethod
def GetAll(self, recursive=True, **kwargs):
"""
Get all parts of speech in the project.
Can be called two ways:
POSOperations.GetAll(project) # Class-level, no instantiation
POSOperations(project).GetAll() # Instance-level, traditional
Args:
recursive (bool): If True (default), yields every POS in the
hierarchy (depth-first, parents before children). If False,
yields only top-level POSs and the caller must descend via
GetSubcategories.
Returns:
EnumerableWrapper[IPartOfSpeech]: Each part of speech object.
Example:
>>> posOps = POSOperations(project)
>>> for pos in posOps.GetAll():
... print(posOps.GetName(pos)) # Includes Proper Noun, Common Noun, etc.
>>> # Top-level only
>>> for pos in posOps.GetAll(recursive=False):
... print(posOps.GetName(pos))
See Also:
GetSubcategories, Find
"""
self._RejectLegacyKwargs(kwargs, {
"flat": ("recursive", "semantics inverted: flat=True is now recursive=True"),
})
pos_list = self.project.lp.PartsOfSpeechOA
if pos_list is None:
return
def walk(collection):
for raw in collection:
pos = IPartOfSpeech(raw)
yield pos
if recursive and pos.SubPossibilitiesOS.Count > 0:
yield from walk(pos.SubPossibilitiesOS)
yield from walk(pos_list.PossibilitiesOS)
@OperationsMethod
def Create(self, name, abbreviation, catalogSourceId=None, parent=None):
"""
Create a new part of speech.
This is the canonical creation path for both top-level categories
and subcategories; AddSubcategory delegates to it.
Args:
name (str): The name of the POS (e.g., "Noun", "Verb").
abbreviation (str): Short abbreviation (e.g., "N", "V").
catalogSourceId (str, optional): Optional catalog identifier for
linguistic databases (e.g., "GOLD:Noun"). Defaults to None.
parent: Optional parent IPartOfSpeech object or HVO. If None
(default), creates a top-level category; if given, creates
a subcategory of that parent. Defaults to None.
Returns:
IPartOfSpeech: The newly created POS object.
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, or if a
top-level POS with this name already exists.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Create("Noun", "N")
>>> print(posOps.GetName(noun))
Noun
>>> proper_noun = posOps.Create("Proper Noun", "PN", "GOLD:Noun")
>>> print(posOps.GetAbbreviation(proper_noun))
PN
>>> # A subcategory is just a Create with a parent
>>> proper = posOps.Create("Proper Noun", "PN", parent=noun)
Notes:
- Top-level names must be unique within the project (the
pre-existing Exists check); subcategory names are not
uniqueness-checked, preserving AddSubcategory's behaviour.
- Abbreviations don't need to be unique but should be distinct
- CatalogSourceId links to linguistic ontologies (e.g., GOLD)
- The POS is created in the default analysis writing system
See Also:
AddSubcategory, Delete, Exists, Find
"""
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")
parent_pos = None
if parent is not None:
self._ValidateParam(parent, "parent")
parent_pos = self.__ResolveObject(parent)
label = f"Add subcategory '{name}'" if parent_pos is not None else f"Create part of speech '{name}'"
# If the caller supplied a "GOLD:..." catalog id, defer to the
# catalog path so the POS gets the canonical GUID and any extra
# WS data the catalog provides. We then overlay the user's
# name/abbreviation on top so explicit args still win.
if catalogSourceId and catalogSourceId.upper().startswith("GOLD:"):
wsHandle = self.project.project.DefaultAnalWs
with self._TransactionCM(label):
new_pos = self.CreateFromCatalog(catalogSourceId, parent=parent_pos)
# Overlay user-supplied name and abbreviation in the
# analysis WS (catalog values stay in other WSes).
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
new_pos.Name.set_String(wsHandle, mkstr_name)
mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle)
new_pos.Abbreviation.set_String(wsHandle, mkstr_abbr)
return new_pos
# Check if POS already exists (top-level creates only, preserving
# AddSubcategory's no-uniqueness-check behaviour for children).
if parent_pos is None and self.Exists(name):
raise FP_ParameterError(f"Part of Speech '{name}' already exists")
# Get the writing system handle
wsHandle = self.project.project.DefaultAnalWs
# Create the new POS using the factory
factory = self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
with self._TransactionCM(label):
new_pos = factory.Create()
# Attach to the parent subcategory list or the top-level POS
# list (must be done before setting properties)
if parent_pos is not None:
parent_pos.SubPossibilitiesOS.Add(new_pos)
else:
pos_list = self.project.lp.PartsOfSpeechOA
pos_list.PossibilitiesOS.Add(new_pos)
# Set name and abbreviation
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
new_pos.Name.set_String(wsHandle, mkstr_name)
mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle)
new_pos.Abbreviation.set_String(wsHandle, mkstr_abbr)
# Set catalog source ID if provided
if catalogSourceId:
new_pos.CatalogSourceId = catalogSourceId
return new_pos
@OperationsMethod
def Delete(self, pos_or_hvo):
"""
Delete a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO to delete.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If pos_or_hvo is None.
FP_ParameterError: If the POS is in use and cannot be deleted.
Example:
>>> posOps = POSOperations(project)
>>> obsolete = posOps.Find("Obsolete")
>>> if obsolete:
... posOps.Delete(obsolete)
Warning:
- Deleting a POS that is in use may raise an error from FLEx
- Will also delete all subcategories recursively
- Deletion is permanent and cannot be undone
- Lexical entries using this POS should be updated first
See Also:
Create, Exists, Find
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
# Resolve to POS object
pos = self.__ResolveObject(pos_or_hvo)
# Remove from the POS list
pos_list = self.project.lp.PartsOfSpeechOA
with self._TransactionCM("Delete part of speech"):
pos_list.PossibilitiesOS.Remove(pos)
@OperationsMethod
def Exists(self, name):
"""
Check if a part of speech with the given name exists.
Args:
name (str): The name to search for (case-insensitive).
Returns:
bool: True if POS exists, False otherwise.
Raises:
FP_NullParameterError: If name is None.
Example:
>>> posOps = POSOperations(project)
>>> if not posOps.Exists("Noun"):
... posOps.Create("Noun", "N")
Notes:
- Comparison is case-insensitive
- Searches recursively through all POS including subcategories
- Use Find() to get the actual object
See Also:
Find, Create
"""
self._ValidateParam(name, "name")
return self.Find(name) is not None
@OperationsMethod
def Find(self, name):
"""
Find a part of speech by name.
Args:
name (str): The name to search for (case-insensitive).
Returns:
IPartOfSpeech or None: The POS object if found, None otherwise.
Raises:
FP_NullParameterError: If name is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> if noun:
... abbr = posOps.GetAbbreviation(noun)
... print(f"Found: {abbr}")
Found: N
Notes:
- Returns first match only
- Search is case-insensitive
- Searches recursively through all POS including subcategories
- Returns None if not found (doesn't raise exception)
See Also:
Exists, GetName
"""
self._ValidateParam(name, "name")
target = normalize_match_key(name, casefold=True)
wsHandle = self.project.project.DefaultAnalWs
# Search recursively through POS hierarchy
def search_pos_list(pos_collection):
for pos in pos_collection:
pos_name = ITsString(pos.Name.get_String(wsHandle)).Text
if normalize_match_key(pos_name, casefold=True) == target:
return pos
# Search subcategories
if pos.SubPossibilitiesOS.Count > 0:
found = search_pos_list(pos.SubPossibilitiesOS)
if found:
return found
return None
pos_list = self.project.lp.PartsOfSpeechOA
if pos_list is None:
return None
found = search_pos_list(pos_list.PossibilitiesOS)
# search_pos_list walks Possibilities/SubPossibilities collections
# which are typed ICmPossibility; cast the result so callers can
# access IPartOfSpeech-specific properties.
return IPartOfSpeech(found) if found is not None else None
@OperationsMethod
def GetName(self, pos_or_hvo, wsHandle=None):
"""
Get the name of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The POS name, or empty string if not set.
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> name = posOps.GetName(noun)
>>> print(name)
Noun
>>> # Get name in a specific writing system
>>> vern_name = posOps.GetName(noun, project.WSHandle('en'))
See Also:
SetName, GetAbbreviation
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
name = ITsString(pos.Name.get_String(wsHandle)).Text
return name or ""
@OperationsMethod
def SetName(self, pos_or_hvo, name, wsHandle=None):
"""
Set the name of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech 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 pos_or_hvo or name is None.
FP_ParameterError: If name is empty.
Example:
>>> posOps = POSOperations(project)
>>> typo = posOps.Find("Nown") # typo
>>> if typo:
... posOps.SetName(typo, "Noun") # fix it
See Also:
GetName, SetAbbreviation
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
self._ValidateParam(name, "name")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
pos = self.__ResolveObject(pos_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(name, wsHandle)
with self._TransactionCM(f"Set part of speech name '{name}'"):
pos.Name.set_String(wsHandle, mkstr)
@OperationsMethod
def GetAbbreviation(self, pos_or_hvo, wsHandle=None):
"""
Get the abbreviation of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The POS abbreviation, or empty string if not set.
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> abbr = posOps.GetAbbreviation(noun)
>>> print(abbr)
N
See Also:
SetAbbreviation, GetName
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
abbr = ITsString(pos.Abbreviation.get_String(wsHandle)).Text
return abbr or ""
@OperationsMethod
def SetAbbreviation(self, pos_or_hvo, abbr, wsHandle=None):
"""
Set the abbreviation of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
abbr (str): The new abbreviation.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If pos_or_hvo or abbr is None.
FP_ParameterError: If abbr is empty.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> posOps.SetAbbreviation(noun, "N")
See Also:
GetAbbreviation, SetName
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
self._ValidateParam(abbr, "abbr")
if not abbr or not abbr.strip():
raise FP_ParameterError("Abbreviation cannot be empty")
pos = self.__ResolveObject(pos_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(abbr, wsHandle)
with self._TransactionCM(f"Set part of speech abbreviation '{abbr}'"):
pos.Abbreviation.set_String(wsHandle, mkstr)
@wrap_enumerable
@OperationsMethod
def GetSubcategories(self, pos_or_hvo, recursive=True, **kwargs):
"""
Get the subcategories of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech 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 IPartOfSpeech subcategory objects (empty list if none).
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> for subcat in posOps.GetSubcategories(noun):
... print(posOps.GetName(subcat)) # All descendants
>>> # Direct children only
>>> for subcat in posOps.GetSubcategories(noun, recursive=False):
... print(posOps.GetName(subcat))
See Also:
GetAll, Find
"""
self._RejectLegacyKwargs(kwargs, {
"flat": ("recursive", "semantics inverted: flat=True is now recursive=True"),
})
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
# SubPossibilitiesOS is typed ICmPossibility in C#; cast each child to
# IPartOfSpeech so callers can access POS-specific properties.
if not recursive:
return [IPartOfSpeech(p) for p in pos.SubPossibilitiesOS]
result = []
def walk(collection):
for raw in collection:
child = IPartOfSpeech(raw)
result.append(child)
if child.SubPossibilitiesOS.Count > 0:
walk(child.SubPossibilitiesOS)
walk(pos.SubPossibilitiesOS)
return result
@OperationsMethod
def GetParent(self, pos_or_hvo):
"""
Get the parent category of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
Returns:
IPartOfSpeech: The owning part of speech, or None if this POS
is top-level (owned by the PartsOfSpeechOA possibility list
rather than by another category).
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> proper = posOps.AddSubcategory(noun, "Proper Noun", "PN")
>>> posOps.GetName(posOps.GetParent(proper))
'Noun'
>>> # Walk the hierarchy upward to the root
>>> cat = proper
>>> while cat is not None:
... print(posOps.GetName(cat))
... cat = posOps.GetParent(cat)
Proper Noun
Noun
Notes:
- Returns None for top-level categories: their Owner is the
ICmPossibilityList at ``lp.PartsOfSpeechOA``, which is not a
possibility and therefore not a parent category.
- The owner is discriminated by ``ClassName`` rather than by
attempting a CLR cast and catching the failure, matching
``__ResolveObject``'s ClassName-gated shape (contract C2).
An owner that is a POS is routed back through
``__ResolveObject`` so the returned object is cast to
``IPartOfSpeech`` and exposes subtype-only members --
``Owner`` alone hands back a bare ``ICmObject``.
- This is the inverse of GetSubcategories: for any subcategory
``s`` of ``p``, ``GetParent(s) is p``.
See Also:
GetSubcategories, AddSubcategory, GetAll
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
owner = getattr(pos, "Owner", None)
if owner is None:
return None
# Top-level POSs are owned by the PartsOfSpeechOA possibility list
# ("CmPossibilityList"); only a subcategory is owned by another
# "PartOfSpeech". Anything else (an owner with no ClassName at all,
# e.g. a non-LCM stand-in) is treated as "no parent category"
# rather than raising.
if getattr(owner, "ClassName", None) != "PartOfSpeech":
return None
return self.__ResolveObject(owner)
@OperationsMethod
def AddSubcategory(self, pos_or_hvo, name, abbreviation, catalogSourceId=None):
"""
Add a subcategory to a part of speech.
Thin wrapper over Create with parent=pos_or_hvo; all creation
logic (including the "GOLD:..." catalog path) lives on Create.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO to add subcategory to.
name (str): The name of the subcategory.
abbreviation (str): Short abbreviation for the subcategory.
catalogSourceId (str, optional): Optional catalog identifier for
linguistic databases (e.g., "GOLD:Noun"). Defaults to None.
Returns:
IPartOfSpeech: The newly created subcategory object.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If pos_or_hvo, name, or abbreviation is None.
FP_ParameterError: If name or abbreviation is empty.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> proper_noun = posOps.AddSubcategory(noun, "Proper Noun", "PN")
>>> print(posOps.GetName(proper_noun))
Proper Noun
>>> proper_noun = posOps.AddSubcategory(noun, "Proper Noun", "PN", "GOLD:Noun")
Notes:
- Equivalent to ``Create(name, abbreviation,
catalogSourceId=catalogSourceId, parent=pos_or_hvo)``.
- If the catalog GUID already exists, the existing item is
returned with name/abbreviation overlaid, not re-parented
(same idempotency as CreateFromCatalog).
See Also:
Create, RemoveSubcategory, GetSubcategories
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
# Bracketed per D5 (the scanner cannot see through same-class
# delegation): joins Create's inner transaction via nesting
# (B1) rather than opening a separate undo unit. House pattern:
# Shared/FilterOperations.ImportFilter, Shared/MediaOperations.
with self._TransactionCM(f"Add subcategory '{name}'"):
return self.Create(
name,
abbreviation,
catalogSourceId=catalogSourceId,
parent=pos_or_hvo,
)
@OperationsMethod
def RemoveSubcategory(self, pos_or_hvo, subcat_or_hvo):
"""
Remove a subcategory from a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO (parent).
subcat_or_hvo: The subcategory IPartOfSpeech object or HVO to remove.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If pos_or_hvo or subcat_or_hvo is None.
FP_ParameterError: If the subcategory is in use and cannot be deleted.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> subcats = posOps.GetSubcategories(noun)
>>> for subcat in subcats:
... if posOps.GetName(subcat) == "Obsolete Subcategory":
... posOps.RemoveSubcategory(noun, subcat)
Warning:
- Removing a subcategory that is in use may raise an error from FLEx
- Will also delete all nested subcategories recursively
- Removal is permanent and cannot be undone
- Lexical entries using this subcategory should be updated first
See Also:
AddSubcategory, GetSubcategories, Delete
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
self._ValidateParam(subcat_or_hvo, "subcat_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
subcat = self.__ResolveObject(subcat_or_hvo)
# Remove from parent's SubPossibilitiesOS
with self._TransactionCM("Remove subcategory"):
pos.SubPossibilitiesOS.Remove(subcat)
@OperationsMethod
def GetCatalogSourceId(self, pos_or_hvo):
"""
Get the catalog source ID of a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
Returns:
str: The catalog source ID, or empty string if not set.
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> catalog_id = posOps.GetCatalogSourceId(noun)
>>> print(catalog_id)
GOLD:Noun
Notes:
- Catalog source IDs link POS to linguistic ontologies (e.g., GOLD)
- Returns empty string if no catalog ID is set
- Used for cross-linguistic standardization and data sharing
See Also:
Create
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
return pos.CatalogSourceId or ""
@wrap_enumerable
@OperationsMethod
def GetInflectionClasses(self, pos_or_hvo):
"""
Get all inflection classes associated with a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
Returns:
list: List of inflection class objects (empty list if none).
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> verb = posOps.Find("Verb")
>>> classes = posOps.GetInflectionClasses(verb)
>>> for infl_class in classes:
... print(infl_class.Name)
Regular Verb
Irregular Verb
Modal Verb
Notes:
- Inflection classes define morphological paradigms
- Each POS can have multiple inflection classes
- Returns empty list if no inflection classes are defined
- Used for morphological analysis and generation
See Also:
GetAffixSlots
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
# IPartOfSpeech has InflectionClassesOC
return list(pos.InflectionClassesOC)
@wrap_enumerable
@OperationsMethod
def GetAffixSlots(self, pos_or_hvo):
"""
Get all affix slots associated with a part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
Returns:
list: List of AffixSlot wrapper objects (empty list if none).
Each wrapper exposes ``.name`` (str), ``.optional`` (bool), and
``.affixes`` (the IMoInflAffMsa objects filling the slot), and
still proxies raw LCM member access (``.Name``, ``.Optional``,
``.Hvo``) through to the underlying IMoInflAffixSlot for
backward compatibility with existing callers.
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> verb = posOps.Find("Verb")
>>> slots = posOps.GetAffixSlots(verb)
>>> for slot in slots:
... print(slot.name)
Tense
Aspect
Mood
Notes:
- Affix slots define positions for affixes in morphological templates
- Each POS can have multiple affix slots
- Returns empty list if no affix slots are defined
- Used for morphological parsing and generation
See Also:
GetInflectionClasses, CreateAffixSlot, GetSlotName, IsSlotOptional,
GetAffixesInSlot
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
# IPartOfSpeech has AffixSlotsOC
return [AffixSlot(slot) for slot in pos.AffixSlotsOC]
@OperationsMethod
def CreateAffixSlot(self, pos, name, optional=False):
"""
Create an inflectional affix slot owned by a part of speech.
Access via FLExProject as ``project.POS.CreateAffixSlot``. The new
slot is attached to ``IPartOfSpeech.AffixSlotsOC`` before its Name
or Optional is written. Place it on a template with
``project.MorphRules.AddSlotToTemplate``.
Args:
pos: The IPartOfSpeech object or HVO that will own the slot.
name (str): Slot name, written in the analysis writing system.
optional (bool): Whether the slot may be left empty. Defaults
to False. A new FLEx affix slot is obligatory; pass True
to create an optional slot.
Returns:
AffixSlot: A wrapper around the newly created IMoInflAffixSlot,
exposing ``.name`` (str), ``.optional`` (bool), and ``.affixes``,
while still proxying raw LCM member access (``.Name``,
``.Optional``, ``.Hvo``) through to the underlying
IMoInflAffixSlot for backward compatibility. Pass it directly
to ``MorphRules.AddSlotToTemplate`` or
``MSA.SetInflAffMsaSlots``; both unwrap it automatically.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If pos or name is None.
FP_ParameterError: If name is empty.
Example:
>>> verb = project.POS.Find("Verb")
>>> slot = project.POS.CreateAffixSlot(verb, "PossConcord", optional=False)
>>> slot.name
'PossConcord'
See Also:
GetAffixSlots
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos, "pos")
self._ValidateParam(name, "name")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
pos = self.__ResolveObject(pos)
wsHandle = self.project.project.DefaultAnalWs
factory = self.project.project.ServiceLocator.GetService(IMoInflAffixSlotFactory)
with self._TransactionCM(f"Create affix slot '{name}'"):
slot = factory.Create()
# Attach before property writes (Phase 2 ownership rule).
pos.AffixSlotsOC.Add(slot)
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
slot.Name.set_String(wsHandle, mkstr_name)
# Boolean property is Optional (clr reflection, SIL.LCModel 11).
slot.Optional = optional
return AffixSlot(slot)
@OperationsMethod
def GetSlotName(self, slot_or_hvo, wsHandle=None):
"""
Get the name of an inflectional affix slot.
Read-side pair for ``CreateAffixSlot`` (issue #542).
``IMoInflAffixSlot.Name`` is an ``IMultiUnicode``, not a string;
this method reads it and returns plain text.
Args:
slot_or_hvo: The IMoInflAffixSlot object, its HVO, or an
AffixSlot wrapper (from ``GetAffixSlots`` or
``AffixTemplate.prefix_slots``/etc.).
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The slot name, or empty string if not set (FLEx's
``***`` null marker is normalized to empty).
Raises:
FP_NullParameterError: If slot_or_hvo is None.
FP_ParameterError: If slot_or_hvo does not resolve to an
IMoInflAffixSlot.
Example:
>>> posOps = POSOperations(project)
>>> verb = posOps.Find("Verb")
>>> slot = posOps.CreateAffixSlot(verb, "Tense")
>>> posOps.GetSlotName(slot)
'Tense'
See Also:
SetSlotName, IsSlotOptional, GetAffixesInSlot
"""
self._ValidateParam(slot_or_hvo, "slot_or_hvo")
slot = self.__ResolveSlot(slot_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
name = ITsString(slot.Name.get_String(wsHandle)).Text
return normalize_text(name)
@OperationsMethod
def SetSlotName(self, slot_or_hvo, name, wsHandle=None):
"""
Set the name of an inflectional affix slot.
Args:
slot_or_hvo: The IMoInflAffixSlot object, its HVO, or an
AffixSlot wrapper.
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 slot_or_hvo or name is None.
FP_ParameterError: If name is empty, or slot_or_hvo does not
resolve to an IMoInflAffixSlot.
Example:
>>> posOps = POSOperations(project)
>>> slot = posOps.CreateAffixSlot(verb, "Tense")
>>> posOps.SetSlotName(slot, "Aspect")
See Also:
GetSlotName, IsSlotOptional, SetSlotOptional
"""
self._EnsureWriteEnabled()
self._ValidateParam(slot_or_hvo, "slot_or_hvo")
self._ValidateParam(name, "name")
if not name or not name.strip():
raise FP_ParameterError("Name cannot be empty")
slot = self.__ResolveSlot(slot_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(name, wsHandle)
with self._TransactionCM(f"Set affix slot name '{name}'"):
slot.Name.set_String(wsHandle, mkstr)
@OperationsMethod
def IsSlotOptional(self, slot_or_hvo):
"""
Check whether an inflectional affix slot may be left empty.
Args:
slot_or_hvo: The IMoInflAffixSlot object, its HVO, or an
AffixSlot wrapper.
Returns:
bool: True if the slot is optional, False if obligatory
(FLEx's default for a newly-created slot).
Raises:
FP_NullParameterError: If slot_or_hvo is None.
FP_ParameterError: If slot_or_hvo does not resolve to an
IMoInflAffixSlot.
Example:
>>> posOps = POSOperations(project)
>>> slot = posOps.CreateAffixSlot(verb, "PossConcord", optional=True)
>>> posOps.IsSlotOptional(slot)
True
See Also:
SetSlotOptional, GetSlotName
"""
self._ValidateParam(slot_or_hvo, "slot_or_hvo")
slot = self.__ResolveSlot(slot_or_hvo)
return bool(slot.Optional)
@OperationsMethod
def SetSlotOptional(self, slot_or_hvo, optional):
"""
Set whether an inflectional affix slot may be left empty.
Args:
slot_or_hvo: The IMoInflAffixSlot object, its HVO, or an
AffixSlot wrapper.
optional (bool): True to make the slot optional, False to make
it obligatory.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If slot_or_hvo is None.
FP_ParameterError: If slot_or_hvo does not resolve to an
IMoInflAffixSlot.
Example:
>>> posOps = POSOperations(project)
>>> slot = posOps.CreateAffixSlot(verb, "PossConcord")
>>> posOps.SetSlotOptional(slot, True)
See Also:
IsSlotOptional, SetSlotName
"""
self._EnsureWriteEnabled()
self._ValidateParam(slot_or_hvo, "slot_or_hvo")
slot = self.__ResolveSlot(slot_or_hvo)
with self._TransactionCM(f"Set affix slot optional={bool(optional)}"):
slot.Optional = bool(optional)
@wrap_enumerable
@OperationsMethod
def GetAffixesInSlot(self, slot_or_hvo):
"""
Get the inflectional-affix MSAs that fill an affix slot.
Args:
slot_or_hvo: The IMoInflAffixSlot object, its HVO, or an
AffixSlot wrapper.
Returns:
list: IMoInflAffMsa objects whose SlotsRC contains this slot
(empty list if none).
Raises:
FP_NullParameterError: If slot_or_hvo is None.
FP_ParameterError: If slot_or_hvo does not resolve to an
IMoInflAffixSlot.
Example:
>>> posOps = POSOperations(project)
>>> slot = posOps.CreateAffixSlot(verb, "Tense")
>>> project.MSA.SetInflAffMsaSlots(sense, [slot])
>>> affixes = posOps.GetAffixesInSlot(slot)
>>> len(affixes)
1
Notes:
- Read via ``IMoInflAffixSlot.Affixes``, the direct
back-reference to every ``IMoInflAffMsa`` whose ``SlotsRC``
contains this slot (confirmed by live reflection: returns
``IEnumerable<IMoInflAffMsa>``). This is the single most
direct LCM path -- it is the exact inverse of
``MSAOperations.GetInflAffMsaSlots`` (issue #543), which
reads ``IMoInflAffMsa.SlotsRC`` from the MSA side. No
repository scan or entry walk is needed.
See Also:
GetAffixSlots, MSA.GetInflAffMsaSlots, MSA.SetInflAffMsaSlots
"""
self._ValidateParam(slot_or_hvo, "slot_or_hvo")
slot = self.__ResolveSlot(slot_or_hvo)
affixes = getattr(slot, "Affixes", None)
if affixes is None:
return []
return list(affixes)
@OperationsMethod
def GetEntryCount(self, pos_or_hvo, recursive=False):
"""
Count the number of lexical entries using this part of speech.
Args:
pos_or_hvo: The IPartOfSpeech object or HVO.
recursive (bool): If False (default), counts only entries
tagged with this POS exactly -- matches what FLEx's UI
shows in the Categories tool, Lexicon Browse view, and
Tools > Statistics (every count column in FLEx is
direct-only). If True, rolls up entries tagged with
this POS OR any descendant POS (e.g., counting "Noun"
also picks up "Proper Noun", "Common Noun", etc.).
Returns:
int: The count of entries using this POS (direct-only by
default; including descendants when ``recursive=True``).
Raises:
FP_NullParameterError: If pos_or_hvo is None.
Example:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> count = posOps.GetEntryCount(noun)
>>> print(f"There are {count} entries tagged exactly Noun")
>>> # Roll-up: include Proper Noun, Common Noun, etc.
>>> rollup = posOps.GetEntryCount(noun, recursive=True)
Notes:
Counting queries default to ``recursive=False`` (FLEx UI
parity). Collection queries elsewhere in this codebase
(e.g. ``GetSubcategories``) default to ``recursive=True``;
the asymmetry is intentional -- counts answer "what does
the user see in FLEx?" while collections answer "what's
in the subtree?"
See Also:
Delete, GetSubcategories
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
# Build the set of POS HVOs to match. With recursive=True this expands
# to include every descendant POS, so callers don't miss entries that
# are tagged with a subcategory of the requested POS.
match_hvos = {pos.Hvo}
if recursive:
match_hvos.update(d.Hvo for d in self.GetSubcategories(pos, recursive=True))
count = 0
entry_repo = self.project.project.ServiceLocator.GetService(ILexEntryRepository)
for entry in entry_repo.AllInstances():
for msa in entry.MorphoSyntaxAnalysesOC:
msa_pos = get_pos_from_msa(msa)
if msa_pos and msa_pos.Hvo in match_hvos:
count += 1
break # Count each entry only once
return count
@OperationsMethod
def Duplicate(self, item_or_hvo, insert_after=True, deep=False):
"""
Duplicate a part of speech, creating a new copy with a new GUID.
Args:
item_or_hvo: The IPartOfSpeech object or HVO to duplicate.
insert_after (bool): If True (default), insert after the source POS.
If False, insert at end of parent's possibilities list.
deep (bool): If True, recursively duplicate all subcategories.
If False (default), only duplicate the POS itself.
Returns:
IPartOfSpeech: The newly created duplicate POS 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:
>>> posOps = POSOperations(project)
>>> noun = posOps.Find("Noun")
>>> # Shallow copy (no subcategories)
>>> noun_copy = posOps.Duplicate(noun)
>>> print(posOps.GetName(noun_copy))
Noun
>>> # Deep copy (includes all subcategories)
>>> verb = posOps.Find("Verb")
>>> verb_copy = posOps.Duplicate(verb, deep=True)
>>> orig_subs = posOps.GetSubcategories(verb)
>>> copy_subs = posOps.GetSubcategories(verb_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 POS's position
- Simple properties copied: Name, Abbreviation, Description (MultiString)
- String property copied: CatalogSourceId
- deep=True recursively duplicates SubPossibilitiesOS hierarchy
- Inflection classes and affix slots are NOT copied (references)
- Use after copying to create variants of existing categories
See Also:
Create, Delete, GetSubcategories
"""
self._EnsureWriteEnabled()
self._ValidateParam(item_or_hvo, "item_or_hvo")
# Get source POS and parent
source = self.__ResolveObject(item_or_hvo)
# Create new POS using factory (auto-generates new GUID)
factory = self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
with self._TransactionCM("Duplicate POS"):
duplicate = factory.Create()
# Determine parent and insertion position
# Check if source is a subcategory or top-level
parent_is_possibility = False
try:
parent_pos = IPartOfSpeech(source.Owner)
parent_is_possibility = True
except Exception:
parent_is_possibility = False
if parent_is_possibility:
# Source is a subcategory
parent_pos = IPartOfSpeech(source.Owner)
if insert_after:
source_index = parent_pos.SubPossibilitiesOS.IndexOf(source)
parent_pos.SubPossibilitiesOS.Insert(source_index + 1, duplicate)
else:
parent_pos.SubPossibilitiesOS.Add(duplicate)
else:
# Source is top-level
pos_list = self.project.lp.PartsOfSpeechOA
if insert_after:
source_index = pos_list.PossibilitiesOS.IndexOf(source)
pos_list.PossibilitiesOS.Insert(source_index + 1, duplicate)
else:
pos_list.PossibilitiesOS.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)
# Copy string property
if source.CatalogSourceId:
duplicate.CatalogSourceId = source.CatalogSourceId
# Deep copy: recursively duplicate subcategories
if deep and source.SubPossibilitiesOS.Count > 0:
for sub_pos in source.SubPossibilitiesOS:
# Recursively duplicate each subcategory
self.__DuplicateSubcategory(sub_pos, duplicate)
return duplicate
def __DuplicateSubcategory(self, source_sub, parent_duplicate):
"""
Helper method to recursively duplicate a subcategory.
Args:
source_sub: The source IPartOfSpeech subcategory to duplicate.
parent_duplicate: The parent IPartOfSpeech to add the duplicate to.
Returns:
IPartOfSpeech: The duplicated subcategory.
"""
# Create new subcategory. Reached only from inside Duplicate's
# bracket, so this transaction joins that one (nesting-aware per B1).
# Stated anyway so the site is grep-auditable per D5.
factory = self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
with self._TransactionCM("Duplicate subcategory"):
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)
if source_sub.CatalogSourceId:
sub_duplicate.CatalogSourceId = source_sub.CatalogSourceId
# 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
# ========== CATALOG (GOLDEtic) 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 POS-specific LCM types.
#
# The canonical FW pipeline for POS catalog import lives in
# SIL.FieldWorks.LexText.Controls.MasterCategory; we reimplement here
# via the mixin so we don't have to pull in the GUI-side
# LexTextControls.dll dependency.
#
# Phase 2 ownership-ordering lesson applies: the mixin attaches via
# the 2-arg factory overload (POS placed into its owning list at
# creation) THEN sets the multistring properties; never sets
# properties on a free-floating POS.
# --- CatalogBackedMixin hooks --------------------------------------
def _get_root_list(self):
"""Return the top-level owner (PartsOfSpeechOA)."""
return self.project.lp.PartsOfSpeechOA
def _get_factory(self):
"""Resolve the IPartOfSpeech factory for the mixin's Path B."""
return self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
def _factory_create_attached(self, guid, parent_obj):
"""
Path A: try the 2-arg factory overload that creates and attaches
in one step. parent_obj is either an IPartOfSpeech (subcategory
case) or None (top-level; we pass the PartsOfSpeechOA list).
Returns the new LCM object on success, or None to let the mixin
fall back to Path B.
Per issue #14, pythonnet may not expose the 2-arg overload on
the interface variable -- it's an explicit interface impl. So we
try and let the mixin handle Path B on AttributeError /
MissingMethodException.
"""
factory = self._get_factory()
try:
# Reached from inside the mixin's "Create ... from catalog"
# bracket (catalog_backed.py), so this joins that transaction
# rather than opening its own (nesting-aware per B1). Stated
# anyway so the site is grep-auditable per D5.
with self._TransactionCM("Create part of speech from catalog"):
if parent_obj is not None:
return factory.Create(guid, parent_obj)
return factory.Create(guid, self._get_root_list())
except Exception:
return None
def _path_b_attach(self, new_obj, parent_obj):
"""
Path B fallback: attach a just-created free-floating POS to the
right owner. parent_obj is the IPartOfSpeech parent for a
subcategory, or None for a top-level POS (in which case we
attach to the PartsOfSpeechOA list).
"""
# Both branches mutate, so the owner-shape guard sits inside the
# bracket. Joins the mixin's catalog bracket per B1.
with self._TransactionCM("Attach part of speech from catalog"):
if parent_obj is not None:
parent_obj.SubPossibilitiesOS.Add(new_obj)
else:
self._get_root_list().PossibilitiesOS.Add(new_obj)
def _cast_to_domain(self, raw):
"""Return the IPartOfSpeech view of a raw LCM POS object."""
return IPartOfSpeech(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)
self._set_multistring(obj.Description, def_, missing_ws_seen, warnings)
def _walk_existing(self):
"""
Yield (IPartOfSpeech, parent_or_None) for every POS in the
project, walking SubPossibilitiesOS recursively. parent is None
for top-level POSs. Format matches the mixin's expectation of
either a 2-tuple or a bare object.
"""
pos_list = self.project.lp.PartsOfSpeechOA
if pos_list is None:
return
def _walk(collection, parent):
for raw in collection:
pos = IPartOfSpeech(raw)
yield pos, parent
if pos.SubPossibilitiesOS.Count > 0:
yield from _walk(pos.SubPossibilitiesOS, pos)
yield from _walk(pos_list.PossibilitiesOS, None)
def _handle_entry_children(self, entry, created_obj, missing_ws_seen, warnings, result):
"""
POS uses _supports_recursive_entries=True so the mixin recurses
on entry.children itself; this hook is a no-op.
"""
pass
# --- Private Helper Methods ---
def __ResolveObject(self, pos_or_hvo):
"""
Resolve HVO or object to IPartOfSpeech.
Args:
pos_or_hvo: Either an IPartOfSpeech object or an HVO (int).
Returns:
IPartOfSpeech: The resolved POS object, cast to the concrete
``IPartOfSpeech`` interface when its ``ClassName`` is
``"PartOfSpeech"`` (contract C2). ``self.project.Object(hvo)``
returns a bare ``ICmObject``; without this cast, a caller
reaching this method via an HVO (rather than an already-typed
object from, e.g., ``GetAll()``, which already casts via
``IPartOfSpeech(raw)``) would silently lose access to every
subtype-only member -- including the four PRE-EXISTING
``GetSyncableProperties`` fields (``Name``/``Abbreviation``/
``Description``/``CatalogSourceId``), not just the two new
feature-struct properties T7 adds (see evidence/live-T7.md,
prediction P1).
Any ``ClassName`` other than ``"PartOfSpeech"`` (or a non-LCM
input with no ``ClassName`` at all, e.g. a GUID ``str`` --
OUT of scope for this method, folded into T12) is returned
UNCHANGED -- this method never raises on a miss, mirroring
``MSAOperations.__GetMsaObject``'s ClassName-discriminated,
never-raising shape.
"""
if isinstance(pos_or_hvo, int):
obj = self.project.Object(pos_or_hvo)
else:
obj = self._UnwrapLcmObject(pos_or_hvo)
if getattr(obj, "ClassName", None) == "PartOfSpeech":
return IPartOfSpeech(obj)
return obj
def __ResolveSlot(self, slot_or_hvo):
"""
Resolve HVO, raw LCM object, or AffixSlot wrapper to IMoInflAffixSlot.
Unlike ``__ResolveObject`` (which never raises on a ClassName
miss -- deliberately, per its own docstring), this resolver
*really casts*: it performs an actual pythonnet interface cast to
``IMoInflAffixSlot`` and raises ``FP_ParameterError`` when the cast
fails, rather than silently handing back an object that is missing
the members every slot reader/writer method here needs (``Name``,
``Optional``, ``Affixes``). The 4.10.0 live gate (commit
9218b3c) found resolvers "that never cast" across the #455-#508
series; this method exists specifically to not repeat that shape.
Args:
slot_or_hvo: An ``IMoInflAffixSlot`` object, its HVO (int), or
an ``AffixSlot`` wrapper (unwrapped via
``_UnwrapLcmObject`` before casting).
Returns:
IMoInflAffixSlot: The resolved, cast slot object.
Raises:
FP_ParameterError: If the resolved object cannot be cast to
IMoInflAffixSlot (wrong type, or a stale/invalid HVO).
"""
if isinstance(slot_or_hvo, int):
obj = self.project.Object(slot_or_hvo)
else:
obj = self._UnwrapLcmObject(slot_or_hvo)
try:
return IMoInflAffixSlot(obj)
except Exception:
raise FP_ParameterError(
"slot_or_hvo must be an IMoInflAffixSlot, its HVO, or an "
f"AffixSlot wrapper; got {obj!r}"
)
# ========== SYNC INTEGRATION METHODS ==========
#
# Closes issue #252 (spec feature-structure-sync-gap, task T7):
# PartOfSpeech has TWO feature-struct-owning properties --
# DefaultFeaturesOA (slot="Default") and InherFeatValOA
# (slot="InherFeatVal"), the frozen C1 "PartOfSpeech" row in
# FEATURE_STRUC_OWNER_TABLE (Shared/lcm_constants.py) -- neither of
# which was ever captured or applied, so a synced POS carried correct
# Name/Abbreviation/Description/CatalogSourceId but a permanently null
# feature structure. Shape mirrors MSAOperations' #251 fix
# (:841-1158), but T7 additionally fixes an independent C2 hole in
# __ResolveObject itself: unlike GetAll() (which already casts every
# POS via `IPartOfSpeech(raw)`), a POS reached through this method via
# a bare HVO was returned as an uncast `ICmObject`, silently dropping
# even the four PRE-EXISTING scalar/multistring properties on that
# entry path (evidence/live-T7.md, prediction P1). Both are fixed
# together since __ResolveObject is the single choke point both
# methods route through.
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get dictionary of syncable properties for cross-project synchronization.
Args:
item: The IPartOfSpeech object, or its HVO (int) -- resolved
and cast via ``__ResolveObject`` (contract C2).
Returns:
dict: Dictionary mapping property names to their values.
Keys are property names, values are the property values.
In addition to the pre-existing scalar/multistring keys,
emits (per the frozen C1 "PartOfSpeech" table row, when
the owning property is non-None -- C6 presence, not
truthiness):
- ``DefaultFeatures`` / ``DefaultFeaturesGuid`` --
``DefaultFeaturesOA`` (C4 recursive-dict spec / str
GUID).
- ``InherFeatVal`` / ``InherFeatValGuid`` --
``InherFeatValOA``.
An owning property that is present but genuinely empty
(an ``IFsFeatStruc`` with zero ``FeatureSpecsOC`` entries)
still emits BOTH its keys -- ``_GetFeatureStruc`` never
returns ``None`` for a non-None struct (C4). A NULL owning
property omits both keys entirely.
Example:
>>> posOps = POSOperations(project)
>>> pos = list(posOps.GetAll())[0]
>>> props = posOps.GetSyncableProperties(pos)
>>> print(props.keys())
dict_keys(['Name', 'Abbreviation', 'Description', 'CatalogSourceId'])
Notes:
- Returns all MultiString properties (all writing systems)
- Returns CatalogSourceId string property
- Does not include SubPossibilitiesOS (subcategories)
- Does not include InflectionClassesOC or AffixSlotsOC
- Does not include GUID or HVO of the POS itself
- Feature-struct capture (``DefaultFeatures``/``InherFeatVal``)
is entirely ``.ClassName``-driven, delegating to
``BaseOperations._ResolveFeatureStrucOwner``/
``_GetFeatureStruc`` (C1/C4) -- zero ``hasattr`` probes on
either feature-struct property. The two ``hasattr`` calls
below (on ``Name``/``Abbreviation``/``Description`` and
``CatalogSourceId``) are PRE-EXISTING and deliberately kept
(lead ruling 3): once ``__ResolveObject`` casts, they are
redundant but harmless, and removing them is out of this
task's scope.
"""
pos = 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"]:
if hasattr(pos, prop_name):
prop_obj = getattr(pos, 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
# String properties
if hasattr(pos, "CatalogSourceId") and pos.CatalogSourceId:
props["CatalogSourceId"] = pos.CatalogSourceId
# Feature-struct properties (C1 "PartOfSpeech" table row, T7/#252).
# slot= is REQUIRED here (unlike MoStemMsa's single-row None) --
# PartOfSpeech has two rows in FEATURE_STRUC_OWNER_TABLE.
self.__CaptureFeatureStrucProp(props, pos, "Default", "DefaultFeatures")
self.__CaptureFeatureStrucProp(props, pos, "InherFeatVal", "InherFeatVal")
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""
Apply syncable properties (from GetSyncableProperties) onto a POS.
Handles the two C1 "PartOfSpeech" feature-struct key-pairs
(``DefaultFeatures``/``DefaultFeaturesGuid``,
``InherFeatVal``/``InherFeatValGuid``) directly; everything else in
``props`` (the pre-existing multi-WS Name/Abbreviation/Description
+ plain-string CatalogSourceId shape) is delegated to
``BaseOperations.ApplySyncableProperties`` unchanged.
Args:
item: Target POS (already created + owned + GUID-assigned by
the caller), or its HVO (int) -- resolved and cast via
``__ResolveObject`` (C2).
props: dict produced by GetSyncableProperties (or built by a
caller following the same shape).
ws_map: Optional source->target writing-system Id mapping.
Unused by the feature-struct branches (which resolve by
GUID, not writing system); passed through to the base
loop.
fill_gaps: Passed through to the base loop. Has no additional
effect on the feature-struct branches, which are always
purely additive/idempotent by GUID.
Raises:
FP_ParameterError: If ``item`` is None, ``props`` is not a
dict, or (C7) a ``<Name>``/``<Name>Guid`` spec references a
feature, value, or feature-structure-type GUID that does
not exist in the target project -- naming the unresolved
GUID and instructing the caller to sync the feature system
first.
Notes:
- The two feature-struct keys are POPPED out of ``props``
(via a filtered copy) BEFORE calling ``super()`` (C6):
``BaseOperations._apply_props_loop`` dispatches on
``isinstance(value, dict)`` and would otherwise route a C4
dict into the multi-writing-system multistring path and
silently drop it.
- Gates on KEY PRESENCE, never truthiness (C6): a present-but-
empty feature structure (``<Name>Guid`` set, ``<Name>``
absent/``{}``) is a real, empty-but-attached
``IFsFeatStruc`` on the source and must still create/attach
an empty struct on the target.
"""
if item is None:
raise FP_ParameterError("ApplySyncableProperties: item is None")
if not isinstance(props, dict):
raise FP_ParameterError(
f"ApplySyncableProperties: props must be a dict, got "
f"{type(props).__name__}"
)
pos = self.__ResolveObject(item)
# Pop the two feature-struct key-pairs out of props BEFORE calling
# super() (C6) -- BaseOperations._apply_props_loop dispatches a
# dict value into the multistring path and would drop a C4 dict
# silently at that layer instead of raising.
base_props = {
k: v for k, v in props.items() if k not in self.__FEATURE_STRUC_KEYS
}
super().ApplySyncableProperties(pos, base_props, ws_map, fill_gaps=fill_gaps)
self.__ApplyFeatureStrucProp(pos, "Default", "DefaultFeatures", props)
self.__ApplyFeatureStrucProp(pos, "InherFeatVal", "InherFeatVal", props)
# ------------------------------------------------------------------
# Feature-struct sync internals (T7/#252)
# ------------------------------------------------------------------
# The four props keys handled directly by ApplySyncableProperties's
# feature-struct branches -- must be excluded from the base-loop
# pass-through (C6). Kept as one tuple so the pop-filter and any
# future audit share a single source of truth.
__FEATURE_STRUC_KEYS = (
"DefaultFeatures", "DefaultFeaturesGuid",
"InherFeatVal", "InherFeatValGuid",
)
def __CaptureFeatureStrucProp(self, props, pos, slot, key):
"""
Capture one C1 "PartOfSpeech" feature-struct row into ``props``,
in place.
Args:
props: The dict being built by GetSyncableProperties;
mutated in place.
pos: The POS object (already resolved via ``__ResolveObject``).
slot: ``"Default"`` | ``"InherFeatVal"`` -- REQUIRED (unlike
MoStemMsa's single-row ``None``, PartOfSpeech has TWO rows
in ``FEATURE_STRUC_OWNER_TABLE``) -- passed straight
through to ``_ResolveFeatureStrucOwner`` (C1).
key: The props key stem (e.g. ``"DefaultFeatures"``) -- the C1
table's props-key column. ``f"{key}Guid"`` is the sibling
GUID key.
Notes:
- Delegates the owner/property resolution entirely to
``BaseOperations._ResolveFeatureStrucOwner`` -- no
``hasattr`` probe, no local cast.
- Only emits keys when the owning property is non-None (a
present-but-empty struct still emits both keys, since
``_GetFeatureStruc`` never returns ``None`` for a non-None
struct -- C4). A null owning property emits neither key,
which is the PRESENCE gate C6 requires on the apply side.
"""
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(pos, slot=slot)
struct = getattr(concrete_owner, prop_name)
if struct is not None:
props[key] = self._GetFeatureStruc(struct)
props[f"{key}Guid"] = str(struct.Guid)
def __ApplyFeatureStrucProp(self, pos, slot, key, props):
"""
Apply one C1 "PartOfSpeech" feature-struct row from ``props`` onto
``pos``, if present.
Args:
pos: The POS object (already resolved via ``__ResolveObject``).
slot: ``"Default"`` | ``"InherFeatVal"``.
key: The props key stem (e.g. ``"DefaultFeatures"``).
props: The ORIGINAL (unfiltered) props dict passed to
``ApplySyncableProperties`` -- read-only here.
Notes:
- Gates on KEY PRESENCE, never truthiness (C6):
``if key in props or guid_key in props`` -- a present-but-
empty source struct carries ``<Name>Guid`` with ``<Name>``
absent (or ``{}``), and must still create/attach an empty
target struct, not be skipped as "source has none".
- ``on_unresolved="raise"`` unconditionally (C7): an
unresolvable feature/value/type GUID must never be
silently dropped for a POS sync.
"""
guid_key = f"{key}Guid"
if key in props or guid_key in props:
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(
pos, slot=slot
)
spec = props.get(key) or {}
struct_guid = props.get(guid_key)
self._ApplyFeatureStruc(
concrete_owner,
prop_name,
spec,
struct_guid=struct_guid,
on_unresolved="raise",
label=f"PartOfSpeech ({prop_name})",
)
def __ReadPOSFeatureStrucSpec(self, pos, slot):
"""Return a C4 feature-struct dict for one PartOfSpeech slot, or None."""
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(pos, slot=slot)
struct = getattr(concrete_owner, prop_name)
if struct is None:
return None
return self._GetFeatureStruc(struct)
def __WritePOSFeatureStrucSpec(self, pos, slot, spec, struct_guid=None):
"""Apply a C4 feature-struct dict onto one PartOfSpeech slot."""
if spec is None:
raise FP_ParameterError("spec cannot be None; pass {} to clear entries")
if not isinstance(spec, dict):
raise FP_ParameterError(
f"spec must be a dict (C4 feature-struct shape), got {type(spec).__name__}"
)
concrete_owner, prop_name = self._ResolveFeatureStrucOwner(pos, slot=slot)
self._ApplyFeatureStruc(
concrete_owner,
prop_name,
spec,
struct_guid=struct_guid,
on_unresolved="raise",
label=f"PartOfSpeech ({prop_name})",
)
@OperationsMethod
def GetDefaultFeatures(self, pos_or_hvo):
"""
Read ``DefaultFeaturesOA`` as a C4 feature-structure dict.
Args:
pos_or_hvo: ``IPartOfSpeech`` or HVO.
Returns:
dict or None: Recursive feature-structure spec (same shape as
``GetSyncableProperties()['DefaultFeatures']`` when present),
or ``None`` when ``DefaultFeaturesOA`` is unset.
Example:
>>> noun = project.POS.Find("Noun")
>>> spec = project.POS.GetDefaultFeatures(noun)
>>> if spec:
... print(spec.get("specs", {}))
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
return self.__ReadPOSFeatureStrucSpec(pos, "Default")
@OperationsMethod
def SetDefaultFeatures(self, pos_or_hvo, spec, struct_guid=None):
"""
Replace ``DefaultFeaturesOA`` from a C4 feature-structure dict.
Args:
pos_or_hvo: ``IPartOfSpeech`` or HVO.
spec (dict): C4 recursive dict (see ``BaseOperations._GetFeatureStruc``).
struct_guid (str, optional): Preserve or assign struct GUID.
Raises:
FP_ReadOnlyError: When the project is not write-enabled.
FP_ParameterError: On malformed ``spec`` or unresolved feature GUIDs.
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
with self._TransactionCM("Set POS DefaultFeatures"):
self.__WritePOSFeatureStrucSpec(pos, "Default", spec, struct_guid=struct_guid)
@OperationsMethod
def GetInherFeatVal(self, pos_or_hvo):
"""
Read ``InherFeatValOA`` as a C4 feature-structure dict.
Args:
pos_or_hvo: ``IPartOfSpeech`` or HVO.
Returns:
dict or None: Same shape as ``GetSyncableProperties()['InherFeatVal']``
when present, or ``None`` when ``InherFeatValOA`` is unset.
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
return self.__ReadPOSFeatureStrucSpec(pos, "InherFeatVal")
@OperationsMethod
def SetInherFeatVal(self, pos_or_hvo, spec, struct_guid=None):
"""
Replace ``InherFeatValOA`` from a C4 feature-structure dict.
Args:
pos_or_hvo: ``IPartOfSpeech`` or HVO.
spec (dict): C4 recursive dict.
struct_guid (str, optional): Preserve or assign struct GUID.
Raises:
FP_ReadOnlyError: When the project is not write-enabled.
FP_ParameterError: On malformed ``spec`` or unresolved feature GUIDs.
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
with self._TransactionCM("Set POS InherFeatVal"):
self.__WritePOSFeatureStrucSpec(
pos, "InherFeatVal", spec, struct_guid=struct_guid
)
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two parts of speech and return detailed differences.
Args:
item1: First POS to compare (from source project).
item2: Second POS to compare (from target project).
ops1: Optional POSOperations instance for item1's project.
Defaults to self.
ops2: Optional POSOperations 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:
>>> pos1 = project1_posOps.Find("Noun")
>>> pos2 = project2_posOps.Find("Noun")
>>> is_diff, diffs = project1_posOps.CompareTo(
... pos1, pos2,
... ops1=project1_posOps,
... ops2=project2_posOps
... )
>>> if is_diff:
... for prop, (val1, val2) in diffs.items():
... print(f"{prop}: {val1} -> {val2}")
Notes:
- Compares all MultiString properties across all writing systems
- Compares string properties
- 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)
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)