#
# 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 IPartOfSpeechFactory, IPartOfSpeech, ILexEntryRepository
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
# Import flexlibs exceptions
from ..FLExProject import (
FP_ParameterError,
)
# 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
# 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 flexlibs2 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.
Yields:
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):
"""
Create a new part of speech.
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.
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 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
Notes:
- Name must be unique within the project
- 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:
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")
# 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(f"Create part of speech '{name}'"):
new_pos = self.CreateFromCatalog(catalogSourceId)
# 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
if 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(f"Create part of speech '{name}'"):
new_pos = factory.Create()
# Add to the POS list (must be done before setting properties)
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
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)
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)
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 AddSubcategory(self, pos_or_hvo, name, abbreviation):
"""
Add a subcategory to a part of speech.
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.
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
Notes:
- The subcategory is created as a child of the parent POS
- Subcategories inherit the parent's properties where applicable
- Can be nested to create multi-level POS hierarchies
- Uses default analysis writing system
See Also:
RemoveSubcategory, GetSubcategories, Create
"""
self._EnsureWriteEnabled()
self._ValidateParam(pos_or_hvo, "pos_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 abbreviation.strip():
raise FP_ParameterError("Abbreviation cannot be empty")
pos = self.__ResolveObject(pos_or_hvo)
wsHandle = self.project.project.DefaultAnalWs
# Create the subcategory using the factory
factory = self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
with self._TransactionCM(f"Add subcategory '{name}'"):
subcat = factory.Create()
# Add to parent's SubPossibilitiesOS (must be done before setting properties)
pos.SubPossibilitiesOS.Add(subcat)
# Set name and abbreviation
mkstr_name = TsStringUtils.MakeString(name, wsHandle)
subcat.Name.set_String(wsHandle, mkstr_name)
mkstr_abbr = TsStringUtils.MakeString(abbreviation, wsHandle)
subcat.Abbreviation.set_String(wsHandle, mkstr_abbr)
return subcat
@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
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 affix slot objects (empty list if none).
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
"""
self._ValidateParam(pos_or_hvo, "pos_or_hvo")
pos = self.__ResolveObject(pos_or_hvo)
# IPartOfSpeech has AffixSlotsOC
return list(pos.AffixSlotsOC)
@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
factory = self.project.project.ServiceLocator.GetService(IPartOfSpeechFactory)
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:
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).
"""
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.
"""
if isinstance(pos_or_hvo, int):
return self.project.Object(pos_or_hvo)
return pos_or_hvo
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get dictionary of syncable properties for cross-project synchronization.
Args:
item: The IPartOfSpeech object.
Returns:
dict: Dictionary mapping property names to their values.
Keys are property names, values are the property values.
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
"""
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
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""Apply syncable properties (from GetSyncableProperties) onto a POS.
Inherited from BaseOperations; declared here so static API indexers
(e.g. the FLExToolsMCP validator) see it on the concrete class. The
BaseOperations implementation handles every property shape that
POSOperations.GetSyncableProperties emits (multi-WS Name/Abbreviation/
Description + plain-string CatalogSourceId), so no per-class
customisation is needed.
"""
return super().ApplySyncableProperties(item, props, ws_map, fill_gaps=fill_gaps)
@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)