#
# PhonemeOperations.py
#
# Class: PhonemeOperations
# Phoneme operations for FieldWorks Language Explorer
# projects via SIL Language and Culture Model (LCM) API.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2025
#
# Import BaseOperations parent class
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
# Import FLEx LCM types
from SIL.LCModel import (
IPhPhoneme,
IPhPhonemeFactory,
IPhCode,
IPhCodeFactory,
IFsFeatStruc,
IFsClosedFeature,
ICmObjectRepository,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
# Import flexlibs exceptions
from ..FLExProject import (
FP_ParameterError,
)
# Import string utilities
from ..Shared.string_utils import normalize_text, normalize_match_key
# Catalog parsing helpers (Phase 6d)
from ..Shared.catalog import (
CatalogImportResult,
find_catalog_file,
parse_basic_ipa_info,
parse_etic_gloss_list,
)
# Canonical relative subdir for the BasicIPAInfo catalog under FWCodeDir.
BASIC_IPA_CATALOG_FILENAME = "BasicIPAInfo.xml"
BASIC_IPA_CATALOG_SUBDIR = "Templates"
# PhonFeats catalog (BasicIPAInfo cross-references its feature/value ids).
PHON_FEATS_CATALOG_FILENAME = "PhonFeatsEticGlossList.xml"
PHON_FEATS_CATALOG_SUBDIR = "Language Explorer/MGA/GlossLists"
[docs]
class PhonemeOperations(BaseOperations):
"""
This class provides operations for managing Phonemes in a FieldWorks project.
Phonemes are the minimal distinctive units of sound in a language. For example,
in English, /p/ and /b/ are distinct phonemes because they distinguish words
like "pat" and "bat".
Usage::
from flexlibs2 import FLExProject, PhonemeOperations
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
phonemeOps = PhonemeOperations(project)
# Get all phonemes
for phoneme in phonemeOps.GetAll():
print(phonemeOps.GetRepresentation(phoneme))
# Create a new phoneme
p_phoneme = phonemeOps.Create("/p/")
phonemeOps.SetDescription(p_phoneme, "voiceless bilabial stop")
# Find and update
b_phoneme = phonemeOps.Find("/b/")
if b_phoneme:
phonemeOps.SetDescription(b_phoneme, "voiced bilabial stop")
project.CloseProject()
"""
def __init__(self, project):
"""
Initialize PhonemeOperations 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 phonemes.
For Phoneme, we reorder parent.PhonemesOS
"""
return parent.PhonemesOS
@wrap_enumerable
@OperationsMethod
def GetAll(self):
"""
Get all phonemes in the project.
Yields:
IPhPhoneme: Each phoneme object in the project's phoneme inventory.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> for phoneme in phonemeOps.GetAll():
... representation = phonemeOps.GetRepresentation(phoneme)
... description = phonemeOps.GetDescription(phoneme)
... print(f"{representation}: {description}")
/p/: voiceless bilabial stop
/b/: voiced bilabial stop
/t/: voiceless alveolar stop
/d/: voiced alveolar stop
Notes:
- Returns phonemes from the first phoneme set
- Returns empty if no phoneme sets exist
- Most projects have only one phoneme set
- Phonemes include both consonants and vowels
See Also:
Find, GetRepresentation
"""
phon_data = self.project.lp.PhonologicalDataOA
if phon_data and phon_data.PhonemeSetsOS.Count > 0:
phoneme_set = phon_data.PhonemeSetsOS[0]
for phoneme in phoneme_set.PhonemesOC:
yield phoneme
@OperationsMethod
def Create(self, representation, wsHandle=None):
"""
Create a new phoneme.
Args:
representation (str): The phonemic representation (e.g., "/p/", "/a/").
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
IPhPhoneme: The newly created phoneme object.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If representation is None.
FP_ParameterError: If representation is empty, or if a phoneme
with this representation already exists, or if no phoneme set exists.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Create("/p/")
>>> print(phonemeOps.GetRepresentation(p_phoneme))
/p/
>>> # Create with description
>>> ch_phoneme = phonemeOps.Create("/tʃ/")
>>> phonemeOps.SetDescription(ch_phoneme, "voiceless postalveolar affricate")
Notes:
- Representation should use IPA symbols
- Standard convention is to enclose in slashes: /p/
- Phoneme must be unique within the phoneme set
- Requires at least one phoneme set to exist in the project
See Also:
Delete, Exists, Find
"""
self._EnsureWriteEnabled()
self._ValidateParam(representation, "representation")
if not representation or not representation.strip():
raise FP_ParameterError("Representation cannot be empty")
# Check if phoneme already exists
if self.Exists(representation, wsHandle):
raise FP_ParameterError(f"Phoneme '{representation}' already exists")
# Get or create phoneme set
phon_data = self.project.lp.PhonologicalDataOA
if not phon_data:
raise FP_ParameterError("No phonological data in project")
if phon_data.PhonemeSetsOS.Count == 0:
raise FP_ParameterError("No phoneme set exists in project")
phoneme_set = phon_data.PhonemeSetsOS[0]
# Get the writing system handle
wsHandle = self.__WSHandle(wsHandle)
# Create the new phoneme using the factory
factory = self.project.project.ServiceLocator.GetService(IPhPhonemeFactory)
with self._TransactionCM(f"Create phoneme {representation}"):
new_phoneme = factory.Create()
# Add to the phoneme set (must be done before setting properties)
phoneme_set.PhonemesOC.Add(new_phoneme)
# Set representation
mkstr = TsStringUtils.MakeString(representation, wsHandle)
new_phoneme.Name.set_String(wsHandle, mkstr)
# The IPhPhoneme factory autocreates one IPhCode whose Representation
# is the FLEx null marker ('***'). Clean it up here so that Create()
# always returns a phoneme with no junk placeholder allophone.
# AddCode reuses the placeholder slot on the first call, but if the
# caller never calls AddCode the phoneme would otherwise carry a
# spurious '***' code that breaks HermitCrab parser loading.
# (issue #113 -- cleanup moved here from AddCode)
existing_codes = list(new_phoneme.CodesOS)
if len(existing_codes) == 1 and self.__is_placeholder_code(existing_codes[0]):
new_phoneme.CodesOS.Remove(existing_codes[0])
return new_phoneme
@OperationsMethod
def Delete(self, phoneme_or_hvo):
"""
Delete a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO to delete.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If phoneme_or_hvo is None.
FP_ParameterError: If the phoneme is in use and cannot be deleted.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> obsolete = phonemeOps.Find("/x/")
>>> if obsolete:
... phonemeOps.Delete(obsolete)
Warning:
- Deleting a phoneme that is in use may raise an error from FLEx
- This includes phonemes used in:
- Natural classes
- Phonological rules
- Allomorph environments
- Deletion is permanent and cannot be undone
- Consider updating references before deletion
See Also:
Create, Exists, Find
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
# Resolve to phoneme object
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
# Get phoneme set and remove
phon_data = self.project.lp.PhonologicalDataOA
if phon_data and phon_data.PhonemeSetsOS.Count > 0:
phoneme_set = phon_data.PhonemeSetsOS[0]
phoneme_set.PhonemesOC.Remove(phoneme)
@OperationsMethod
def Duplicate(self, item_or_hvo, insert_after=True, deep=True):
"""
Duplicate a phoneme, creating a new copy with a new GUID.
Args:
item_or_hvo: The IPhPhoneme object or HVO to duplicate.
insert_after (bool): If True (default), insert after the source phoneme.
If False, insert at end of phoneme set.
deep (bool): If True (default), also duplicate owned objects (codes/allophones).
If False, only copy simple properties and references.
Returns:
IPhPhoneme: The newly created duplicate phoneme with a new GUID.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If item_or_hvo is None.
FP_ParameterError: If no phoneme set exists.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Find("/p/")
>>> if p_phoneme:
... # Duplicate phoneme (deep - includes allophonic codes)
... dup = phonemeOps.Duplicate(p_phoneme)
... print(f"Original: {phonemeOps.GetGuid(dup)}")
... print(f"Representation: {phonemeOps.GetRepresentation(dup)}")
...
... # Shallow duplicate (no codes)
... shallow_dup = phonemeOps.Duplicate(p_phoneme, deep=False)
... codes = phonemeOps.GetCodes(deep_dup)
... print(f"Codes: {len(codes)}")
Notes:
- Factory.Create() automatically generates a new GUID
- insert_after=True preserves the original phoneme's position
- Simple properties copied: Name (representation), Description, BasicIPASymbol
- Reference properties copied: FeaturesOA (feature structure reference)
- Owned objects (deep=True): CodesOS (allophonic representations)
- Useful for creating similar phonemes or phoneme variants
See Also:
Create, Delete, GetGuid, GetCodes
"""
self._EnsureWriteEnabled()
self._ValidateParam(item_or_hvo, "item_or_hvo")
# Get source phoneme
source = self.__GetPhonemeObject(item_or_hvo)
# Get phoneme set
phon_data = self.project.lp.PhonologicalDataOA
if not phon_data or phon_data.PhonemeSetsOS.Count == 0:
raise FP_ParameterError("No phoneme set exists in project")
phoneme_set = phon_data.PhonemeSetsOS[0]
# Create new phoneme using factory (auto-generates new GUID)
factory = self.project.project.ServiceLocator.GetService(IPhPhonemeFactory)
with self._TransactionCM("Duplicate phoneme"):
duplicate = factory.Create()
# Add to phoneme set (OC types use Add only, no Insert)
phoneme_set.PhonemesOC.Add(duplicate)
# Copy simple MultiString properties
duplicate.Name.CopyAlternatives(source.Name)
duplicate.Description.CopyAlternatives(source.Description)
if hasattr(source, "BasicIPASymbol") and source.BasicIPASymbol:
duplicate.BasicIPASymbol.CopyAlternatives(source.BasicIPASymbol)
# Note: FeaturesOA is an owned atomic object (OA), NOT a reference.
# Assigning it directly would TRANSFER ownership from source, corrupting it.
# Feature structures are complex (recursive), so we skip them in shallow
# copy and handle via deep copy below using LCM's SetCloneProperties.
# Handle owned objects if deep=True
if deep:
from ..lcm_casting import clone_properties
# Deep copy FeaturesOA (feature structure)
if hasattr(source, "FeaturesOA") and source.FeaturesOA:
# Create new feature structure and copy all properties
try:
source_features = source.FeaturesOA
# Create new object of same type
new_features = self.project.project.ServiceLocator.ObjectRepository.NewObject(
source_features.ClassID
)
# Set as the feature structure for this phoneme
duplicate.FeaturesOA = new_features
# Deep clone all properties using clone_properties
clone_properties(source_features, new_features, self.project)
except Exception:
# Cannot copy complex feature structure - skip
pass
# Duplicate codes (allophonic representations)
if hasattr(source, "CodesOS") and source.CodesOS.Count > 0:
for code in source.CodesOS:
try:
code_factory = self.project.project.ServiceLocator.GetService(IPhCodeFactory)
new_code = code_factory.Create()
duplicate.CodesOS.Add(new_code)
# Deep clone code properties using clone_properties
clone_properties(code, new_code, self.project)
except Exception:
# Skip any code that fails to copy
pass
return duplicate
@OperationsMethod
def Exists(self, representation, wsHandle=None):
"""
Check if a phoneme with the given representation exists.
Args:
representation (str): The representation to search for (case-sensitive).
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
bool: True if phoneme exists, False otherwise.
Raises:
FP_NullParameterError: If representation is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> if not phonemeOps.Exists("/ŋ/"):
... phonemeOps.Create("/ŋ/")
... phonemeOps.SetDescription(phonemeOps.Find("/ŋ/"), "velar nasal")
Notes:
- Comparison is case-sensitive (IPA is case-sensitive)
- Use Find() to get the actual object
See Also:
Find, Create
"""
self._ValidateParam(representation, "representation")
return self.Find(representation, wsHandle) is not None
@OperationsMethod
def Find(self, representation, wsHandle=None):
"""
Find a phoneme by representation.
Args:
representation (str): The representation to search for (case-sensitive).
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
IPhPhoneme or None: The phoneme object if found, None otherwise.
Raises:
FP_NullParameterError: If representation is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Find("/p/")
>>> if p_phoneme:
... desc = phonemeOps.GetDescription(p_phoneme)
... print(f"/p/: {desc}")
/p/: voiceless bilabial stop
Notes:
- Returns first match only
- Search is case-sensitive (IPA is case-sensitive)
- Returns None if not found (doesn't raise exception)
See Also:
Exists, GetRepresentation
"""
self._ValidateParam(representation, "representation")
wsHandle = self.__WSHandle(wsHandle)
phon_data = self.project.lp.PhonologicalDataOA
if not phon_data or phon_data.PhonemeSetsOS.Count == 0:
return None
phoneme_set = phon_data.PhonemeSetsOS[0]
target = normalize_match_key(representation, casefold=False)
for phoneme in phoneme_set.PhonemesOC:
phoneme_repr = ITsString(phoneme.Name.get_String(wsHandle)).Text
if normalize_match_key(phoneme_repr, casefold=False) == target:
return phoneme
return None
@OperationsMethod
def GetRepresentation(self, phoneme_or_hvo, wsHandle=None):
"""
Get the representation of a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
str: The phoneme representation, or empty string if not set.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Find("/p/")
>>> representation = phonemeOps.GetRepresentation(p_phoneme)
>>> print(representation)
/p/
>>> # Iterate and print all phonemes
>>> for phoneme in phonemeOps.GetAll():
... print(phonemeOps.GetRepresentation(phoneme))
See Also:
SetRepresentation, GetDescription
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
representation = normalize_text(ITsString(phoneme.Name.get_String(wsHandle)).Text)
return representation or ""
@OperationsMethod
def SetRepresentation(self, phoneme_or_hvo, representation, wsHandle=None):
"""
Set the representation of a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
representation (str): The new representation.
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If phoneme_or_hvo or representation is None.
FP_ParameterError: If representation is empty.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> phoneme = phonemeOps.Find("/ph/") # non-standard notation
>>> if phoneme:
... phonemeOps.SetRepresentation(phoneme, "/pʰ/") # fix to proper IPA
Notes:
- Use standard IPA symbols for cross-linguistic compatibility
- Consider updating descriptions when changing representation
See Also:
GetRepresentation, SetDescription
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(representation, "representation")
if not representation or not representation.strip():
raise FP_ParameterError("Representation cannot be empty")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(representation, wsHandle)
phoneme.Name.set_String(wsHandle, mkstr)
@OperationsMethod
def GetDescription(self, phoneme_or_hvo, wsHandle=None):
"""
Get the description of a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The phoneme description, or empty string if not set.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Find("/p/")
>>> desc = phonemeOps.GetDescription(p_phoneme)
>>> print(desc)
voiceless bilabial stop
>>> # Print phoneme inventory with descriptions
>>> for phoneme in phonemeOps.GetAll():
... repr = phonemeOps.GetRepresentation(phoneme)
... desc = phonemeOps.GetDescription(phoneme)
... print(f"{repr}: {desc}")
/p/: voiceless bilabial stop
/b/: voiced bilabial stop
/t/: voiceless alveolar stop
Notes:
- Description typically includes articulatory features
- Returns empty string if no description set
- Uses analysis writing system by default (not vernacular)
See Also:
SetDescription, GetRepresentation
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
# Use analysis WS for description (not vernacular)
if wsHandle is None:
wsHandle = self.project.project.DefaultAnalWs
else:
wsHandle = self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs)
desc_str = ITsString(phoneme.Description.get_String(wsHandle)).Text
return desc_str or ""
@OperationsMethod
def SetDescription(self, phoneme_or_hvo, description, wsHandle=None):
"""
Set the description of a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
description (str): The new description.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If phoneme_or_hvo or description is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Create("/p/")
>>> phonemeOps.SetDescription(p_phoneme, "voiceless bilabial stop")
>>> # Add detailed articulatory description
>>> tap = phonemeOps.Find("/ɾ/")
>>> phonemeOps.SetDescription(
... tap,
... "voiced alveolar tap/flap - allophone of /r/ in intervocalic position"
... )
Notes:
- Description is optional but recommended for documentation
- Use standard phonetic terminology
- Can include information about allophones and distribution
- Uses analysis writing system by default (not vernacular)
- Description can be empty string to clear
See Also:
GetDescription, SetRepresentation
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(description, "description")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
# Use analysis WS for description (not vernacular)
if wsHandle is None:
wsHandle = self.project.project.DefaultAnalWs
else:
wsHandle = self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs)
mkstr = TsStringUtils.MakeString(description, wsHandle)
phoneme.Description.set_String(wsHandle, mkstr)
@OperationsMethod
def GetFeatures(self, phoneme_or_hvo):
"""
Get the feature structure of a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
Returns:
IFsFeatStruc or None: The phoneme's feature structure, or None if not set.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> p_phoneme = phonemeOps.Find("/p/")
>>> features = phonemeOps.GetFeatures(p_phoneme)
>>> if features:
... print(f"Feature structure: {features.Hvo}")
... else:
... print("No features defined")
Notes:
- Feature structures define distinctive phonological properties
- Used in phonological rules and natural class definitions
- Feature system must be defined in project settings
- Returns None if no features are set
- Returns the IFsFeatStruc object for further manipulation
See Also:
GetRepresentation, GetDescription
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
return phoneme.FeaturesOA if phoneme.FeaturesOA else None
# --- Advanced Operations ---
@OperationsMethod
def GetCodes(self, phoneme_or_hvo):
"""
Get all codes (allophonic representations) for a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
Returns:
list: List of IPhCode objects (empty list if none).
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> # Get allophones of /t/
>>> phoneme = project.Phonemes.Find("/t/")
>>> if phoneme:
... codes = project.Phonemes.GetCodes(phoneme)
... for code in codes:
... ws = project.project.DefaultVernWs
... repr = ITsString(code.Representation.get_String(ws)).Text
... print(repr)
[t] # plain voiceless alveolar stop
[tʰ] # aspirated (word-initial)
[ɾ] # flap (intervocalic)
>>> # Get vowel allophones
>>> phoneme = project.Phonemes.Find("/i/")
>>> if phoneme:
... codes = project.Phonemes.GetCodes(phoneme)
... print(f"Phoneme /i/ has {len(codes)} allophonic codes")
Notes:
- Codes represent allophones or context-specific realizations
- Convention is to use square brackets [p] for phones
- Slashes /p/ for phonemes
- Empty list if no codes defined
See Also:
AddCode, RemoveCode
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
return list(phoneme.CodesOS)
@OperationsMethod
def AddCode(self, phoneme_or_hvo, representation, wsHandle=None):
"""
Add a code (allophonic representation) to a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
representation: The code representation (e.g., "[tʰ]", "[ɾ]").
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
IPhCode: The newly created code object.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If phoneme_or_hvo or representation is None.
FP_ParameterError: If representation is empty.
Example:
>>> # Add aspiration allophone
>>> phoneme = project.Phonemes.Find("/p/")
>>> if phoneme:
... code = project.Phonemes.AddCode(phoneme, "[pʰ]")
>>> # Add multiple allophones for /t/
>>> phoneme = project.Phonemes.Find("/t/")
>>> if phoneme:
... project.Phonemes.AddCode(phoneme, "[t]") # plain
... project.Phonemes.AddCode(phoneme, "[tʰ]") # aspirated
... project.Phonemes.AddCode(phoneme, "[ɾ]") # flap
>>> # Add vowel allophones
>>> phoneme = project.Phonemes.Find("/a/")
>>> if phoneme:
... project.Phonemes.AddCode(phoneme, "[a]") # open front
... project.Phonemes.AddCode(phoneme, "[ɑ]") # open back
Notes:
- Use square brackets [p] for phones (allophones)
- Each code can have its own feature specifications
- Codes are used in phonological rule environments
- Duplicates are allowed (same code can be added multiple times)
See Also:
GetCodes, RemoveCode
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(representation, "representation")
if not representation or not representation.strip():
raise FP_ParameterError("Representation cannot be empty")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
mkstr = TsStringUtils.MakeString(representation, wsHandle)
# Create + append a new IPhCode. The factory-autocreated placeholder
# code ('***') is now removed by Create() before it returns, so
# AddCode always operates on a clean CodesOS and can unconditionally
# append. (issue #113 -- placeholder cleanup moved to Create)
factory = self.project.project.ServiceLocator.GetService(IPhCodeFactory)
with self._TransactionCM("Add phoneme code"):
code = factory.Create()
# Add to phoneme's codes (must be done before setting properties)
phoneme.CodesOS.Add(code)
# Set representation
code.Representation.set_String(wsHandle, mkstr)
return code
@OperationsMethod
def RemoveCode(self, phoneme_or_hvo, code_or_hvo):
"""
Remove a code from a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
code_or_hvo: The IPhCode object or HVO to remove.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If phoneme_or_hvo or code_or_hvo is None.
FP_ParameterError: If the code is not in the phoneme's code list.
Example:
>>> phoneme = project.Phonemes.Find("/t/")
>>> if phoneme:
... codes = project.Phonemes.GetCodes(phoneme)
... # Remove the flap allophone
... for code in codes:
... ws = project.project.DefaultVernWs
... repr = ITsString(code.Representation.get_String(ws)).Text
... if repr == "[ɾ]":
... project.Phonemes.RemoveCode(phoneme, code)
... break
>>> # Remove by HVO
>>> phoneme = project.Phonemes.Find("/p/")
>>> if phoneme:
... codes = project.Phonemes.GetCodes(phoneme)
... if codes:
... project.Phonemes.RemoveCode(phoneme, codes[0].Hvo)
Notes:
- Code object must be from the phoneme's CodesOS collection
- Use GetCodes() to find codes to remove
- Accepts either IPhCode object or HVO
See Also:
GetCodes, AddCode
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(code_or_hvo, "code_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
code = self.__GetCodeObject(code_or_hvo)
# Check if code is in the phoneme's codes
if code not in phoneme.CodesOS:
raise FP_ParameterError("Code not found in phoneme's code list")
phoneme.CodesOS.Remove(code)
@OperationsMethod
def FindCode(self, phoneme_or_hvo, representation, wsHandle=None):
"""
Find a code (allophonic representation) on a phoneme by its
rendered representation string.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
representation (str): The code representation to look up,
e.g. ``"[tʰ]"``. Compared via NFD-normalised match
(no casefold; IPA distinctions are case-sensitive).
wsHandle: Optional writing system handle. Defaults to
vernacular WS.
Returns:
IPhCode or None: The matching code object, or None if no
code on this phoneme renders to ``representation``.
Raises:
FP_NullParameterError: If phoneme_or_hvo or representation
is None.
Example:
>>> phoneme = project.Phonemes.Find("/t/")
>>> aspirated = project.Phonemes.FindCode(phoneme, "[tʰ]")
>>> if aspirated is not None:
... # safe to ReplaceCode or RemoveCode it
... pass
Notes:
- Match is case-sensitive (IPA is case-sensitive) but
NFD-normalised so combining-diacritic encoding doesn't
cause false negatives.
- Returns the first match; codes don't have to be unique by
representation but typically are.
See Also:
AddCode, RemoveCode, ReplaceCode, GetCodes
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(representation, "representation")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
ws = self.__WSHandle(wsHandle)
target = normalize_match_key(representation, casefold=False)
for code in phoneme.CodesOS:
code_repr = ITsString(code.Representation.get_String(ws)).Text
if code_repr is None:
continue
if normalize_match_key(code_repr, casefold=False) == target:
return code
return None
@OperationsMethod
def ReplaceCode(self, phoneme_or_hvo, old_code_or_repr, new_representation, wsHandle=None):
"""
Replace one code's representation on a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
old_code_or_repr: Either an IPhCode object on this phoneme,
an HVO, or a string representation to look up via
FindCode().
new_representation (str): The replacement representation,
e.g. ``"[dʰ]"``.
wsHandle: Optional writing system handle. Defaults to
vernacular WS.
Returns:
IPhCode: The newly created code carrying ``new_representation``.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If any required parameter is None.
FP_ParameterError: If ``old_code_or_repr`` is a string and
no matching code is found on the phoneme.
Example:
>>> phoneme = project.Phonemes.Find("/t/")
>>> # Fix an encoding typo: replace "[ts]" with the proper
>>> # affricate "[t͡s]".
>>> project.Phonemes.ReplaceCode(phoneme, "[ts]", "[t͡s]")
Notes:
- Implementation is RemoveCode + AddCode: any references to
the original IPhCode object are dropped. If you need to
preserve references, mutate the Representation multistring
directly instead.
- Returns the new IPhCode (not the old one).
See Also:
AddCode, RemoveCode, FindCode, GetCodes
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(old_code_or_repr, "old_code_or_repr")
self._ValidateParam(new_representation, "new_representation")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
if isinstance(old_code_or_repr, str):
target = self.FindCode(phoneme, old_code_or_repr, wsHandle)
if target is None:
raise FP_ParameterError(
f"No code matching '{old_code_or_repr}' found on phoneme"
)
else:
target = old_code_or_repr
with self._TransactionCM("Replace phoneme code"):
self.RemoveCode(phoneme, target)
return self.AddCode(phoneme, new_representation, wsHandle)
@OperationsMethod
def GetBasicIPASymbol(self, phoneme_or_hvo, wsHandle=None):
"""
Get the basic IPA symbol for a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Returns:
str: The basic IPA symbol, or empty string if not set.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> phoneme = project.Phonemes.Find("/p/")
>>> if phoneme:
... symbol = project.Phonemes.GetBasicIPASymbol(phoneme)
... print(symbol)
p
>>> # Compare representation with basic IPA
>>> phoneme = project.Phonemes.Find("/tʃ/")
>>> if phoneme:
... repr = project.Phonemes.GetRepresentation(phoneme)
... ipa = project.Phonemes.GetBasicIPASymbol(phoneme)
... print(f"Representation: {repr}, IPA: {ipa}")
Representation: /tʃ/, IPA: tʃ
Notes:
- Returns the IPA symbol without slashes or diacritics
- May differ from representation which can include slashes
- Used for cross-linguistic phoneme identification
- May be empty if not set
- ``BasicIPASymbol``'s LCM type varies: in some builds it is
IMultiString (per-WS), in others a scalar ITsString. This
getter tries the multistring ``get_String`` accessor first
and falls back to reading the property directly as an
ITsString (matching the defensive write pattern in
SetBasicIPASymbol).
- BasicIPASymbol property may not be available in all FLEx versions
See Also:
GetRepresentation
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
ws = self.__WSHandle(wsHandle)
if not hasattr(phoneme, "BasicIPASymbol") or not phoneme.BasicIPASymbol:
return ""
target = phoneme.BasicIPASymbol
if hasattr(target, "get_String"):
# MultiString shape: per-WS read. Matches the write pattern
# in SetBasicIPASymbol (which calls .set_String(ws, tss)).
tss = target.get_String(ws)
else:
# Scalar ITsString shape: the property value IS the ITsString.
tss = target
ipa_str = ITsString(tss).Text
return ipa_str or ""
@OperationsMethod
def SetBasicIPASymbol(self, phoneme_or_hvo, ipa, wsHandle=None):
"""
Set the basic IPA symbol for a phoneme.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
ipa (str): The IPA symbol to set, e.g. ``"p"``, ``"tʃ"``.
wsHandle: Optional writing system handle. Defaults to
vernacular WS.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If phoneme_or_hvo or ipa is None.
Example:
>>> phoneme = project.Phonemes.Find("/p/")
>>> project.Phonemes.SetBasicIPASymbol(phoneme, "p")
>>> assert project.Phonemes.GetBasicIPASymbol(phoneme) == "p"
Notes:
- ``BasicIPASymbol`` is typed as IMultiUnicode / IMultiString
in the LCM builds we ship against (matches the multistring
read pattern in GetBasicIPASymbol). Older issue tracking
describes it as ITsString-only, so this setter tries the
standard multistring write first and falls back to a
scalar set_BasicIPASymbol if the multistring API is not
present.
- Empty / whitespace-only strings are permitted: FW itself
treats unset IPA as "" and round-trips correctly.
See Also:
GetBasicIPASymbol
"""
self._EnsureWriteEnabled()
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
self._ValidateParam(ipa, "ipa")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
ws = self.__WSHandle(wsHandle)
tss = TsStringUtils.MakeString(ipa, ws)
target = phoneme.BasicIPASymbol
if hasattr(target, "set_String"):
# MultiString shape: per-WS write. This matches the read
# pattern in GetBasicIPASymbol (which calls .get_String(ws)).
target.set_String(ws, tss)
else:
# Scalar ITsString shape: assign via the property setter on
# the interface view. Cast to IPhPhoneme to surface the
# explicit-interface setter if pythonnet hid it on the base.
IPhPhoneme(phoneme).set_BasicIPASymbol(tss)
@OperationsMethod
def IsVowel(self, phoneme_or_hvo):
"""
Check if a phoneme is classified as a vowel.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
Returns:
bool: True if the phoneme is a vowel, False otherwise.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> # Filter vowels from phoneme inventory
>>> vowels = []
>>> for phoneme in project.Phonemes.GetAll():
... if project.Phonemes.IsVowel(phoneme):
... vowels.append(project.Phonemes.GetRepresentation(phoneme))
>>> print("Vowels:", ", ".join(vowels))
Vowels: /a/, /e/, /i/, /o/, /u/
>>> # Count vowels vs consonants
>>> vowel_count = 0
>>> for phoneme in project.Phonemes.GetAll():
... if project.Phonemes.IsVowel(phoneme):
... vowel_count += 1
>>> print(f"Total vowels: {vowel_count}")
Notes:
- Classification based on feature structure
- Typically checks for [-consonantal, +sonorant] or [+syllabic] features
- May return False if features not properly set
- Feature analysis depends on project's feature system
- Checks for common vowel feature indicators like 'syllabic', 'vocalic', 'vowel'
See Also:
IsConsonant, GetFeatures
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
# Check feature structure for vowel features
if not hasattr(phoneme, "FeaturesOA") or not phoneme.FeaturesOA:
return False
features = phoneme.FeaturesOA
# Check for typical vowel features
# Feature system varies by project, but common vowel features include:
# [-consonantal], [+sonorant], [+syllabic]
if hasattr(features, "FeatureSpecsOC"):
for spec in features.FeatureSpecsOC:
if hasattr(spec, "FeatureRA") and spec.FeatureRA:
feature_name = ""
if hasattr(spec.FeatureRA, "Name"):
ws = self.project.project.DefaultAnalWs
feature_name = ITsString(spec.FeatureRA.Name.get_String(ws)).Text or ""
# Check for common vowel feature indicators
if feature_name.lower() in ["syllabic", "vocalic", "vowel"]:
# Check if feature value is positive
if hasattr(spec, "ValueRA") and spec.ValueRA:
return True
return False
@OperationsMethod
def IsConsonant(self, phoneme_or_hvo):
"""
Check if a phoneme is classified as a consonant.
Args:
phoneme_or_hvo: The IPhPhoneme object or HVO.
Returns:
bool: True if the phoneme is a consonant, False otherwise.
Raises:
FP_NullParameterError: If phoneme_or_hvo is None.
Example:
>>> # Filter consonants from phoneme inventory
>>> consonants = []
>>> for phoneme in project.Phonemes.GetAll():
... if project.Phonemes.IsConsonant(phoneme):
... consonants.append(project.Phonemes.GetRepresentation(phoneme))
>>> print("Consonants:", ", ".join(consonants))
Consonants: /p/, /t/, /k/, /b/, /d/, /g/, /m/, /n/
>>> # Create consonant natural classes
>>> for phoneme in project.Phonemes.GetAll():
... if project.Phonemes.IsConsonant(phoneme):
... desc = project.Phonemes.GetDescription(phoneme)
... repr = project.Phonemes.GetRepresentation(phoneme)
... print(f"{repr}: {desc}")
Notes:
- Classification based on feature structure
- Typically checks for [+consonantal] or [-syllabic] features
- May return False if features not properly set
- Complementary to IsVowel() but not necessarily opposite
(some sounds like glides may be neither or both)
- Checks for common consonant feature indicators like 'consonantal', 'consonant'
See Also:
IsVowel, GetFeatures
"""
self._ValidateParam(phoneme_or_hvo, "phoneme_or_hvo")
phoneme = self.__GetPhonemeObject(phoneme_or_hvo)
# Check feature structure for consonant features
if not hasattr(phoneme, "FeaturesOA") or not phoneme.FeaturesOA:
return False
features = phoneme.FeaturesOA
# Check for typical consonant features
# Feature system varies by project, but common consonant features include:
# [+consonantal], [-syllabic]
if hasattr(features, "FeatureSpecsOC"):
for spec in features.FeatureSpecsOC:
if hasattr(spec, "FeatureRA") and spec.FeatureRA:
feature_name = ""
if hasattr(spec.FeatureRA, "Name"):
ws = self.project.project.DefaultAnalWs
feature_name = ITsString(spec.FeatureRA.Name.get_String(ws)).Text or ""
# Check for common consonant feature indicators
if feature_name.lower() in ["consonantal", "consonant"]:
# Check if feature value is positive
if hasattr(spec, "ValueRA") and spec.ValueRA:
return True
return False
# --- Private Helper Methods ---
def __GetPhonemeObject(self, phoneme_or_hvo):
"""
Resolve HVO or object to IPhPhoneme.
Args:
phoneme_or_hvo: Either an IPhPhoneme object or an HVO (int).
Returns:
IPhPhoneme: The resolved phoneme object.
"""
if isinstance(phoneme_or_hvo, int):
return self.project.Object(phoneme_or_hvo)
return phoneme_or_hvo
def __GetCodeObject(self, code_or_hvo):
"""
Resolve HVO or object to IPhCode.
Args:
code_or_hvo: Either an IPhCode object or an HVO (int).
Returns:
IPhCode: The resolved code object.
"""
if isinstance(code_or_hvo, int):
return self.project.Object(code_or_hvo)
return code_or_hvo
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get dictionary of syncable properties for cross-project synchronization.
Args:
item: The IPhPhoneme object.
Returns:
dict: Dictionary mapping property names to their values.
Keys are property names, values are the property values.
Example:
>>> phonemeOps = PhonemeOperations(project)
>>> phoneme = list(phonemeOps.GetAll())[0]
>>> props = phonemeOps.GetSyncableProperties(phoneme)
>>> print(props.keys())
dict_keys(['Name', 'Description', 'BasicIPASymbol', 'FeaturesGuid'])
Notes:
- Returns all MultiString properties (all writing systems)
- Returns FeaturesGuid as string (GUID of referenced feature structure)
- Does not include CodesOS (owned allophonic codes)
- Does not include GUID or HVO of the phoneme itself
"""
phoneme = self.__GetPhonemeObject(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 (Name = representation)
for prop_name in ["Name", "Description", "BasicIPASymbol"]:
if hasattr(phoneme, prop_name):
prop_obj = getattr(phoneme, prop_name)
if prop_obj: # Check if property exists
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
# Reference Atomic (RA) properties - return GUID as string
if hasattr(phoneme, "FeaturesOA") and phoneme.FeaturesOA:
props["FeaturesGuid"] = str(phoneme.FeaturesOA.Guid)
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""Apply syncable properties (from GetSyncableProperties) onto an item.
Inherited from BaseOperations; declared on the concrete class so static
API indexers see it. The base implementation handles every property
shape this class's GetSyncableProperties emits.
"""
return super().ApplySyncableProperties(item, props, ws_map, fill_gaps=fill_gaps)
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two phonemes and return detailed differences.
Args:
item1: First phoneme to compare (from source project).
item2: Second phoneme to compare (from target project).
ops1: Optional PhonemeOperations instance for item1's project.
Defaults to self.
ops2: Optional PhonemeOperations 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:
>>> phoneme1 = project1_phonemeOps.Find("/p/")
>>> phoneme2 = project2_phonemeOps.Find("/p/")
>>> is_diff, diffs = project1_phonemeOps.CompareTo(
... phoneme1, phoneme2,
... ops1=project1_phonemeOps,
... ops2=project2_phonemeOps
... )
>>> 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 feature structure reference by GUID
- 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 __is_placeholder_code(self, code):
"""
True iff `code` is the factory-autocreated placeholder IPhCode --
i.e. its Representation is empty (or the FLEX '***' null marker)
across EVERY populated writing system. (issue #112)
The naive "empty in this one WS" check would falsely classify a
real UI-created code as a placeholder whenever the caller's
target WS happened to be unpopulated -- and AddCode would then
silently overwrite real vernacular data.
"""
for ws_id in code.Representation.AvailableWritingSystemIds:
text = ITsString(code.Representation.get_String(ws_id)).Text
if normalize_text(text):
return False
return True
def __WSHandle(self, wsHandle):
"""
Get writing system handle, defaulting to vernacular WS for phoneme representations.
Args:
wsHandle: Optional writing system handle.
Returns:
int: The writing system handle.
"""
if wsHandle is None:
return self.project.project.DefaultVernWs
return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultVernWs)
# ========== CATALOG IMPORT METHODS ==========
#
# BasicIPAInfo.xml ships ~245 IPA segments with Name, BasicIPASymbol,
# English description, and a feature-value bundle keyed against
# PhonFeatsEticGlossList.xml. Unlike GOLDEtic / eticGlossList catalogs,
# segments have NO per-entry GUIDs and IPhPhoneme has no
# CatalogSourceId field -- so CatalogBackedMixin's GUID-based
# idempotency strategy doesn't fit. We use the Phase 6b/6c
# refuse-on-non-empty pattern instead.
@OperationsMethod
def ImportCatalog(self, progress=None, force=False):
"""
Import the BasicIPAInfo segment catalog into this project's
phoneme set.
Populates ~245 IPhPhonemes with representation (Name +
BasicIPASymbol), description, and a FeaturesOA feature structure
composed of (IFsClosedFeature, IFsSymFeatVal) pairs resolved
against PhFeatureSystemOA via the PhonFeats catalog.
Args:
progress: Optional callable accepting ``(count, total, name)``
for progress reporting. May be None.
force (bool): If True, skip the non-empty-phoneme-set guard.
BasicIPAInfo has no per-segment GUIDs and IPhPhoneme has
no CatalogSourceId, so re-importing would duplicate
existing phonemes; the default (force=False) refuses to
run on a non-empty set. Pass True to layer additional
segments onto an existing inventory.
Returns:
CatalogImportResult: Created/skipped counts and any warnings.
``created_guids`` contains synthetic ``"BasicIPA:{code_point_id}"``
tags (one per created phoneme) -- BasicIPAInfo has no real
GUIDs, so the field is repurposed for verification only.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_ParameterError: If the project has no PhonologicalDataOA,
no phoneme set, the phoneme set is non-empty and force is
False, or PhFeatureSystemOA has not been populated yet
(the BasicIPA catalog cross-references PhonFeats feature
ids and cannot be resolved against an empty feature
system).
FileNotFoundError: If BasicIPAInfo.xml or
PhonFeatsEticGlossList.xml is not found under FWCodeDir.
Notes:
- Refuse-on-non-empty idempotency (SemDom / Anthropology
pattern). If the phoneme set already contains phonemes and
``force=False``, raises FP_ParameterError.
- Refuse-with-remediation on missing PhonFeats. If the
project's PhFeatureSystemOA is empty, ImportCatalog refuses
and tells the caller to run
``project.PhonFeatures.ImportCatalog()`` first. We do NOT
auto-import: a silent dependency import would surprise the
caller and mask real configuration issues.
- Representation goes into both ``IPhPhoneme.Name`` (vernacular
WS, defaulting to en-fonipa when the project has it, else
the default vernacular WS) and ``BasicIPASymbol`` (same WS,
dual-shape MultiString/ITsString aware via SetBasicIPASymbol).
Description goes into the default analysis WS.
- PhonFeats value resolution is best-effort: missing feature
or value ids are recorded as warnings and the offending
FeatureValuePair is skipped. The phoneme is still created
with whatever pairs DID resolve.
- Segments with empty ``<Features/>`` (the seven tone entries
at the head of BasicIPAInfo.xml) are created without a
FeaturesOA struct -- MakeFeatStruc is only invoked when
there is at least one resolved spec.
Example:
>>> project.PhonFeatures.ImportCatalog() # prerequisite
>>> result = project.Phonemes.ImportCatalog()
>>> print(f"Created {result.created_count} phonemes; "
... f"{len(result.warnings)} warnings")
"""
self._EnsureWriteEnabled()
# --- Resolve phoneme set + non-empty guard --------------------
phon_data = self.project.lp.PhonologicalDataOA
if not phon_data:
raise FP_ParameterError(
"Project has no PhonologicalDataOA; cannot import phonemes."
)
if phon_data.PhonemeSetsOS.Count == 0:
raise FP_ParameterError(
"Project has no phoneme set; cannot import phonemes."
)
phoneme_set = phon_data.PhonemeSetsOS[0]
existing_count = phoneme_set.PhonemesOC.Count
if existing_count > 0 and not force:
raise FP_ParameterError(
f"PhonemeSet already has {existing_count} phoneme(s). "
f"BasicIPAInfo.xml has no per-segment GUIDs, so re-importing "
f"would duplicate. Pass force=True to layer additional segments."
)
# --- PhonFeats dependency check (refuse-with-remediation) -----
feature_system = self.project.lp.PhFeatureSystemOA
if feature_system is None or feature_system.FeaturesOC.Count == 0:
raise FP_ParameterError(
"BasicIPAInfo references PhonFeats features by id (e.g. "
"'fPAConsonantal'). Import PhonFeats first: "
"project.PhonFeatures.ImportCatalog()"
)
# --- Locate + parse both catalogs ------------------------------
basic_path = find_catalog_file(
BASIC_IPA_CATALOG_FILENAME, subdir=BASIC_IPA_CATALOG_SUBDIR
)
segments = parse_basic_ipa_info(basic_path)
phonfeats_path = find_catalog_file(
PHON_FEATS_CATALOG_FILENAME, subdir=PHON_FEATS_CATALOG_SUBDIR
)
phonfeats_entries = parse_etic_gloss_list(phonfeats_path)
# --- Build PhonFeats lookups (catalog side + project side) -----
# value_id -> (feature_id, value_term_en) from the catalog XML.
# Used to map a BasicIPAInfo value id ("vPAConsonantalNegative")
# to its parent feature id ("fPAConsonantal") and its analysis-WS
# term ("negative"), which is what we have to match against in
# the project's IFsClosedFeature.ValuesOC (we can't match by id
# because IFsSymFeatVal has no CatalogSourceId field).
value_id_to_info = {}
for feat_entry in phonfeats_entries:
for val_entry in feat_entry.children:
term_en = val_entry.term.get("en", "")
value_id_to_info[val_entry.id] = (feat_entry.id, term_en)
# feature_source_id -> IFsClosedFeature already in the project.
# PhonFeats writes BARE ids to CatalogSourceId (no "PHON:" prefix).
features_by_source_id = {}
for raw_feat in feature_system.FeaturesOC:
try:
cf = IFsClosedFeature(raw_feat)
except Exception:
continue
src_id = cf.CatalogSourceId or ""
if src_id:
features_by_source_id[src_id] = cf
# --- Writing systems -------------------------------------------
# Name + BasicIPASymbol use a vernacular WS; prefer en-fonipa
# when the project has it (matches FW convention for IPA), fall
# back to the default vernacular WS otherwise. Description uses
# the default analysis WS.
fonipa_handle = self.project.WSHandle("en-fonipa")
if fonipa_handle is None:
vern_ws = self.project.GetDefaultVernacularWSHandle()
else:
vern_ws = fonipa_handle
analysis_ws = self.project.GetDefaultAnalysisWSHandle()
# Precompute (feature_source_id, NFD-normalized term) -> IFsSymFeatVal.
# Walking feature_obj.ValuesOC inside the per-segment loop is O(values
# × pairs × segments) and does one LCM string fetch per visit; doing
# it here is O(total values) once. The NFD normalize via
# normalize_match_key makes the lookup safe against any future
# catalog change to a non-ASCII term ("négatif" etc.) — Python source
# is NFC, LCM stores NFD.
values_by_feat_and_term = {}
for src_id, cf in features_by_source_id.items():
for v in cf.ValuesOC:
v_name = ITsString(v.Name.get_String(analysis_ws)).Text or ""
key = (src_id, normalize_match_key(v_name, casefold=False))
values_by_feat_and_term[key] = v
# --- Import loop -----------------------------------------------
with self._TransactionCM("Import phoneme catalog"):
result = CatalogImportResult()
seen_code_points = set() # in-pass dedup against malformed catalogs
total = len(segments)
for idx, seg in enumerate(segments, start=1):
if not seg.representation:
result.warnings.append(
f"Segment '{seg.code_point_id}' has empty representation; skipping."
)
continue
# In-pass dedup: same unicodeCodePoints appearing twice in
# the catalog (shouldn't happen but defensive).
if seg.code_point_id and seg.code_point_id in seen_code_points:
result.skipped_count += 1
continue
seen_code_points.add(seg.code_point_id)
# Resolve (feature, value) specs against the project's
# PhFeatureSystemOA. Missing references are warned and
# skipped; the phoneme is still created.
specs = []
for feat_id, val_id in seg.feature_pairs:
cat_feat_id, val_term = value_id_to_info.get(val_id, (None, None))
if cat_feat_id is None:
result.warnings.append(
f"Segment '{seg.representation}': PhonFeats value "
f"'{val_id}' not found in catalog; skipping pair."
)
continue
# The pair's feature attribute and the value's catalog
# parent should agree; honour whichever is non-empty.
lookup_feat_id = feat_id or cat_feat_id
feature_obj = features_by_source_id.get(lookup_feat_id)
if feature_obj is None:
result.warnings.append(
f"Segment '{seg.representation}': PhonFeats feature "
f"'{lookup_feat_id}' not imported into project; "
f"skipping value '{val_id}'."
)
continue
# Match value by NFD-normalized analysis-WS Name. Lookup
# is O(1) per pair via the precomputed map; bare equality
# is unsafe across NFC/NFD encodings.
value_obj = values_by_feat_and_term.get(
(lookup_feat_id, normalize_match_key(val_term, casefold=False))
)
if value_obj is None:
result.warnings.append(
f"Segment '{seg.representation}': value '{val_term}' "
f"not found on feature '{lookup_feat_id}'.ValuesOC; "
f"skipping pair."
)
continue
specs.append((feature_obj, value_obj))
# Create the phoneme. Create() handles ownership-ordering
# (Phase 2): attach to PhonemesOC, then set Name.
try:
phoneme = self.Create(seg.representation, wsHandle=vern_ws)
except FP_ParameterError as e:
# Duplicate representation (only possible under force=True
# with an overlapping pre-existing inventory) -- skip and
# warn.
result.warnings.append(
f"Segment '{seg.representation}': {e}; skipping."
)
continue
# BasicIPASymbol (dual-shape MultiString/ITsString aware).
self.SetBasicIPASymbol(phoneme, seg.representation, wsHandle=vern_ws)
# Description (English; other langs in the catalog may be sparse).
desc_en = seg.descriptions.get("en", "")
if desc_en:
self.SetDescription(phoneme, desc_en, wsHandle=analysis_ws)
# FeaturesOA atomic-owning attach. MakeFeatStruc attaches FIRST
# (Phase 2) then populates FeatureSpecsOC -- pass owner=phoneme.
if specs:
try:
self.project.PhonFeatures.MakeFeatStruc(specs, owner=phoneme)
except Exception as e:
result.warnings.append(
f"Segment '{seg.representation}': failed to build "
f"FeaturesOA ({e}); phoneme created without features."
)
# Synthetic catalog tag (BasicIPAInfo has no real GUIDs).
synthetic_tag = (
f"BasicIPA:{seg.code_point_id}" if seg.code_point_id else
f"BasicIPA:{seg.representation}"
)
result.created_count += 1
result.created_guids.append(synthetic_tag)
if progress:
try:
progress(idx, total, seg.representation)
except Exception:
# Progress callbacks must never break the import.
pass
return result