Source code for flexicon.code.BaseOperations

#
#   BaseOperations.py
#
#   Class: BaseOperations
#          Base class for all FLEx operation classes.
#          Provides reordering functionality for owning sequences.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#

# --- Imports ------------------------------------------------------------------

import uuid
from typing import Any

from .exceptions import (
    FP_ReadOnlyError,
    FP_NullParameterError,
    FP_ParameterError,
)
from .Shared.lcm_constants import OWNING_SEQUENCE_SUFFIX, FEATURE_STRUC_OWNER_TABLE
from .Shared.wrapper_base import LCMObjectWrapper
from .PythonicWrapper import PythonicWrapper, unwrap as _unwrap_pythonic

# --- Constants ---------------------------------------------------------------


[docs] class EnumerableWrapper: """ Wraps C# IEnumerable to provide Pythonic interface. C# IEnumerable collections don't support indexing or .Count in Python. This wrapper makes them behave like Python sequences while maintaining lazy evaluation when possible. Provides: - .Count property (returns count of items) - Indexing support ([0], [1:3], etc.) - Iteration support (for item in collection) - Contains support (x in collection) Usage:: items = GetWordforms() # Returns IEnumerable count = items.Count # ✅ Works (Pythonic!) first = items[0] # ✅ Works (indexing) if "word" in items: # ✅ Works (contains) ... """ def __init__(self, enumerable): """Wrap an IEnumerable collection. Args: enumerable: A C# IEnumerable object from LibLCM """ self._enumerable = enumerable self._cached_list = None def _ensure_list(self): """Convert to list on first access (lazy evaluation).""" if self._cached_list is None: self._cached_list = list(self._enumerable) return self._cached_list @property def Count(self): """Get count of items in the collection (Pythonic for C# .Count). Returns: int: Number of items in the enumerable. Example:: items = project.GetWordforms() count = items.Count # Returns number of wordforms """ return len(self._ensure_list()) def __len__(self): """Support len() function.""" return self.Count def __getitem__(self, index): """Support indexing and slicing. Args: index: Integer index or slice object Returns: Item at index or slice of items Example:: items = project.GetWordforms() first = items[0] # First item last = items[-1] # Last item some = items[1:5] # Slice """ return self._ensure_list()[index] def __iter__(self): """Support iteration (for item in collection).""" return iter(self._ensure_list()) def __contains__(self, item): """Support 'in' operator. Example:: items = project.GetWordforms() if my_word in items: ... """ return item in self._ensure_list() def __repr__(self): """String representation.""" return f"EnumerableWrapper({self._enumerable})"
def _needs_enumerable_wrap(result): """ Decide whether `result` needs to be wrapped in EnumerableWrapper. Historically this only checked for raw C# IEnumerable objects (those exposing `GetEnumerator`). That missed two common return shapes used throughout the Operations classes: - `self.project.ObjectsIn(...)`, which already calls Python's built-in `iter()` on the underlying C# enumerable before returning it. The resulting object exposes `__next__`/`__iter__` but *not* `GetEnumerator`, so it slipped past the old check untouched. - Generator-function GetAll()/GetAnalyses() implementations (using `yield`), which return a plain Python generator object -- also has `__next__` but no `GetEnumerator`, and is not subscriptable and has no `len()`. Both shapes produced the exact bug in issue #201: callers got back a bare iterator/generator that looked list-like but raised `TypeError` on `entries[0]` or `len(entries)`. Anything that already supports both indexing and `len()` (plain lists, tuples, existing EnumerableWrapper/SmartCollection instances, etc.) is left alone -- there's nothing to fix and no need to re-wrap it. Args: result: The raw value returned by the wrapped method. Returns: bool: True if `result` should be wrapped in EnumerableWrapper. """ if result is None: return False if isinstance(result, EnumerableWrapper): return False # Raw C# IEnumerable (pythonnet objects exposing GetEnumerator). if hasattr(result, "GetEnumerator"): return True # Plain Python iterator/generator (has __next__) that does not # already behave like a sequence (no __len__ + __getitem__ pair). # This covers both `iter(repo.AllInstances())` (from ObjectsIn()) # and generator-function bodies using `yield`. if hasattr(result, "__next__"): already_sequence = hasattr(result, "__len__") and hasattr(result, "__getitem__") if not already_sequence: return True return False
[docs] class wrap_enumerable: """ Descriptor to automatically wrap IEnumerable/iterator return values. Wraps methods that return C# IEnumerable collections -- or plain Python iterators/generators built on top of them (e.g. via `self.project.ObjectsIn(...)` or a `yield`-based method body) -- to make them Pythonic with `.Count`, `len()`, and indexing support. Must be a descriptor to properly delegate to OperationsMethod's __get__, ensuring the descriptor protocol works correctly when stacked decorators are used. Usage:: class MyOperations(BaseOperations): @wrap_enumerable @OperationsMethod def GetAll(self): return self.project.GetAllItems() # Now users can do: items = GetAll(project) count = items.Count # Works! first = items[0] # Works! length = len(items) # Works! Behavioral collection contract: Every ``GetAll`` in flexicon returns a **behavioral collection**: you can always loop it, ``len()`` it, index into it, and re-iterate it. The concrete return type -- ``EnumerableWrapper`` (this decorator, for large/lazily-materialized results), a plain ``list`` (already a sequence, so wrapping is a no-op), or a ``SmartCollection`` subtype (adds ``.filter()``/type-breakdown display on top of the same sequence guarantees) -- is an implementation detail callers never have to branch on. See ``docs/getall-contract.md`` for the full guarantee and rationale. """ def __init__(self, func): """Store the inner function/method.""" self.func = func self.__doc__ = getattr(func, '__doc__', '') self.__name__ = getattr(func, '__name__', 'wrapped') def __get__(self, obj, objtype=None): """ Descriptor protocol: delegate to inner descriptor if present. If the wrapped function is a descriptor (like OperationsMethod), call its __get__ to get the proper bound method, then wrap the result to handle IEnumerable returns. """ # If the inner function is a descriptor, get its bound method if hasattr(self.func, '__get__'): inner_method = self.func.__get__(obj, objtype) else: # Not a descriptor, just bind normally if obj is None: inner_method = self.func else: inner_method = self.func.__get__(obj, objtype) # Return a wrapper that will wrap the result def wrapped_method(*args, **kwargs): result = inner_method(*args, **kwargs) if _needs_enumerable_wrap(result): return EnumerableWrapper(result) return result return wrapped_method def __call__(self, *args, **kwargs): """ Direct call support (when decorator is applied to a function, not a method). This is called when the wrapped method is invoked without going through the descriptor protocol (rare, but needed for some edge cases). """ result = self.func(*args, **kwargs) if _needs_enumerable_wrap(result): return EnumerableWrapper(result) return result
[docs] class OperationsMethod: """ Descriptor enabling methods to work as both class and instance methods. Allows calling operation methods in two ways: - Class level (no instantiation): POSOperations.GetAll(project) - Instance level (traditional): POSOperations(project).GetAll() Both patterns work identically and are equally valid. The descriptor automatically handles instantiation when called at class level. Usage:: class POSOperations(BaseOperations): @wrap_enumerable @OperationsMethod def GetAll(self): # Implementation return self.project.GetAllPOS() # Both work: pos_list = POSOperations.GetAll(project) # Class-level pos_list = POSOperations(project).GetAll() # Instance-level """ def __init__(self, func): """Store the method being decorated.""" self.func = func self.__doc__ = func.__doc__ self.__name__ = func.__name__ def __get__(self, obj, objtype=None): """ Descriptor protocol: handle both class and instance access. When called on the class (POSOperations.GetAll(project)): - Returns a function that takes project as first argument - Automatically instantiates the class and calls the method When called on an instance (POSOperations(project).GetAll()): - Returns a bound method as normal Defensive casting: Unwraps nested OperationsMethod objects to prevent 'OperationsMethod' object is not callable errors from bad decorator order. """ # Defensive casting: unwrap if self.func is a nested OperationsMethod func = self.func while isinstance(func, OperationsMethod): func = func.func if obj is None: # Called on class: POSOperations.GetAll(project) def class_method(project, *args, **kwargs): """Automatically instantiate and call the method.""" instance = objtype(project) return func(instance, *args, **kwargs) return class_method else: # Called on instance: POSOperations(project).GetAll() return func.__get__(obj, objtype)
def _normalize_ws_tag(tag): """Fold a writing-system Id to canonical hyphen-lowercase form. Replicates ``WritingSystemOperations._NormalizeLangTag``'s ``tag.replace("_", "-").lower()`` locally rather than importing it: ``BaseOperations`` must not depend on a ``System`` Operations subclass (spec 250, fence 1.2), and that is the only reason this is a second copy rather than a shared call. Note ``FLExProject.__NormaliseLangTag`` uses the OPPOSITE convention (underscore-lowercase, e.g. ``en_us``) -- see spec 250 finding F3; do not conflate the two forms. """ return tag.replace("_", "-").lower() def _resolve_ws_handle(target_ws_by_id, tgt_ws_id, _index_cache=None): """Resolve a target ws_id to its handle: exact match first, then a normalized (case/separator tolerant) fallback (spec 250, Defect 4). Module-level and independent of any enclosing state -- takes every input as a parameter, depends on no ``self`` and no project handle (spec 250 C-D4-7) -- so that other self-resolving writing-system lookups (e.g. ``PhonemeOperations.__ApplyBasicIPASymbol``, ``ExampleOperations.ApplySyncableProperties``'s ``TranslationsOC`` loop) can later be routed through this same helper with a one-line substitution. Neither of those two call sites is changed by this function's introduction; they still self-resolve today. Args: target_ws_by_id: dict of {ws_id: handle}, exact-case, built by the caller from ``WritingSystems.GetAll()`` (active-only writing systems; spec 250 C-D4-6 -- this helper never widens that set, never consults ``AllWritingSystems``, and never activates or creates anything). tgt_ws_id: The (possibly case- or separator-divergent) ws_id to resolve, already passed through any caller-supplied ``ws_map`` indirection. _index_cache: Optional mutable dict used to memoize the normalized side-index across repeated calls within one apply operation (spec 250 C-D4-4: build the index at most once per apply call). Pass the SAME dict across every call within one ``_apply_props_loop`` invocation; this function populates ``_index_cache['index']`` in place on the first exact-match miss and reuses it thereafter. If None, an index is (re)built on every miss with no memoization. Returns: The resolved handle, or None if the ws_id is genuinely absent from ``target_ws_by_id`` under both exact and normalized matching (spec 250 Defect 3, deliberately unchanged: callers fall through to the existing silent ``continue``). Raises: FP_ParameterError: if the normalized ``tgt_ws_id`` matches two or more DISTINCT handles in ``target_ws_by_id`` (spec 250 C-D4-3 step 2b: an ambiguous spelling -- never guessed, never picked arbitrarily). Keys that normalize together but share ONE handle are not ambiguous and do not raise (step 2a). """ # Step 1: exact match. Untouched and unaffected by anything below -- # every write that succeeds today takes this path byte-for-byte # unchanged (spec 250 C-D4-3 step 1 / zero-regression basis). handle = target_ws_by_id.get(tgt_ws_id) if handle is not None: return handle # Step 2: normalized fallback. Build the side-index lazily on first # miss and reuse it via _index_cache for the rest of this apply call # (C-D4-4) -- the common case is all-exact-hits, which must stay # allocation-free, so this branch only runs on a miss. if _index_cache is not None and "index" in _index_cache: norm_index = _index_cache["index"] else: norm_index = {} for ws_id, ws_handle in target_ws_by_id.items(): norm_key = _normalize_ws_tag(ws_id) norm_index.setdefault(norm_key, {})[ws_id] = ws_handle if _index_cache is not None: _index_cache["index"] = norm_index candidates = norm_index.get(_normalize_ws_tag(tgt_ws_id)) if not candidates: # Step 2c: genuinely absent under both exact and normalized # matching. Falls through to the caller's existing silent # `continue` (Defect 3, deliberately out of scope here). return None distinct_handles = set(candidates.values()) if len(distinct_handles) > 1: raise FP_ParameterError( "Ambiguous writing-system spelling for '{}': matches {} which " "resolve to distinct handles {}. Refusing to guess -- supply " "an exact-case ws_map entry to disambiguate.".format( tgt_ws_id, sorted(candidates.keys()), sorted(distinct_handles) ) ) # Step 2a: exactly one distinct handle -- either a single normalized # match, or two-or-more spellings that happen to share one handle # (not ambiguous; deduped by handle before counting). return next(iter(distinct_handles)) def _apply_props_loop(item, props, target_ws_by_id, fill_gaps=False, ws_map=None, _default_ws_getter=None, _ts_string_utils=None): """Pure loop body of ApplySyncableProperties. No project handle required. Extracted so that unit tests (T-S3a) can call this directly with fabricated dicts and fake item objects, without needing a live self.project. Args: item: LCM object to update. props: dict of {prop_name: value} from GetSyncableProperties. target_ws_by_id: dict of {ws_id: handle} for multistring WS resolution. Must already be built from self.project.WritingSystems.GetAll() by the caller (ApplySyncableProperties); this helper makes no runtime lookups itself. fill_gaps: if True, skip non-empty target values (fill-gaps / merge mode). For multistring: a WS alt is skipped when existing.RunCount > 0 (RunCount directly reflects text-run presence; equivalent but weaker alternative: (existing.Text or "").strip()). For plain str: skipped when getattr(item, prop_name) is non-empty. For bool/int: ALWAYS skipped when fill_gaps=True — stored False/0 is a real choice, never overwrite. ws_map: Optional source->target WS Id mapping dict. _default_ws_getter: Callable returning the default analysis WS handle, used only for ITsString fallback on plain str properties. _ts_string_utils: The imported TsStringUtils class, passed in to avoid a re-import inside the pure helper. """ import logging # Lazily-built normalized WS-id side-index, shared across every # writing-system alt resolved within this single _apply_props_loop # call (spec 250 C-D4-4: built at most once per apply call, never # eagerly). Populated in place by _resolve_ws_handle on first # exact-match miss; stays empty and unused when every alt hits exactly. _ws_resolve_cache = {} for prop_name, value in props.items(): if value is None: continue if isinstance(value, dict): # Multi-WS multistring property. prop_obj = getattr(item, prop_name, None) if prop_obj is None: continue for src_ws_id, text in value.items(): if not text: continue tgt_ws_id = ( ws_map.get(src_ws_id, src_ws_id) if ws_map else src_ws_id ) # Exact match first, normalized (case/separator tolerant) # fallback second; ambiguity raises rather than guessing # (spec 250 Defect 4, C-D4-1..C-D4-5). tgt_handle = _resolve_ws_handle( target_ws_by_id, tgt_ws_id, _index_cache=_ws_resolve_cache ) if tgt_handle is None: # Target genuinely lacks this WS (absent from # target_ws_by_id under both exact and normalized # matching -- an ambiguous spelling already raised # above via _resolve_ws_handle). This is NOT treated as # an error: source and target projects legitimately # differ in writing-system coverage, and a raise here # would turn every ordinary partial-overlap sync into a # hard failure -- a worse regression than the drop it # replaces. But the drop is no longer silent (spec 250 # Defect 3): it is unconditionally logged, not gated # behind a strict= kwarg whose False default would # preserve the silent behaviour (CLAUDE.md "Don't Add a # Flag for Behaviour That Should Be Unconditional"). # Callers wanting strict mapping should pre-validate # with WritingSystemOperations.Ensure() (issue #250 # Defect 2), not Exists() (Defect 1) -- Exists() alone # cannot distinguish "will resolve" from "will drop". logging.getLogger("flexicon").warning( "_apply_props_loop: dropping %s alt for writing " "system %r (resolved target id %r) on %s Hvo=%s -- " "target project has no such writing system, active " "or in its LDML store. Call " "WritingSystemOperations.Ensure(tgt_ws_id, ...) " "first to activate or create it if this text " "should be kept.", prop_name, src_ws_id, tgt_ws_id, type(item).__name__, getattr(item, "Hvo", "?"), ) continue if fill_gaps: existing = prop_obj.get_String(tgt_handle) # Guard: treat whitespace-only alts as empty so a valid source # fill is not blocked by a phantom-whitespace alt (RunCount>0 # but no real text). Mirrors the plain-str branch below. if (existing.Text or "").strip(): continue # target alt non-empty: target wins if _ts_string_utils is not None: prop_obj.set_String( tgt_handle, _ts_string_utils.MakeString(text, tgt_handle) ) elif isinstance(value, str): # Plain string attribute. LCM properties come in three # shapes from the perspective of "assign a Python str": # (1) plain string properties (works directly with setattr); # (2) ITsString properties (need TsStringUtils wrapping); # (3) object-reference properties typed as some LCM # interface (e.g. IMoMorphSynAnalysis) — these can't # be assigned a string at all; they need a cross-project # object lookup which lives outside this dict-driven # sync path. # We handle (1) via the bare setattr, (2) via the ITsString # fallback, and (3) by skipping silently — the caller is # responsible for object-reference wiring. if not hasattr(item, prop_name): continue if fill_gaps: current = getattr(item, prop_name, None) if current is not None and str(current).strip(): continue # target str non-empty: target wins try: setattr(item, prop_name, value) except TypeError as exc: msg = str(exc) if "ITsString" in msg: try: default_ws = _default_ws_getter() if _default_ws_getter else None if default_ws is not None and _ts_string_utils is not None: setattr( item, prop_name, _ts_string_utils.MakeString(value, default_ws), ) except Exception: # If wrapping also fails, skip the property # silently — the sync framework treats this as # a soft incompatibility rather than a hard error. continue elif "cannot be converted to SIL.LCModel." in msg: # Object-reference property — case (3). Skip; the # caller wires cross-project references explicitly # via GUID lookup (e.g. target.MSA.CreateInflAff). continue else: raise elif isinstance(value, bool): # Bool flag (e.g. Disabled, Final). stored False is a deliberate # choice; always skip in fill-gaps mode. if fill_gaps: continue if hasattr(item, prop_name): try: setattr(item, prop_name, value) except (TypeError, AttributeError): continue elif isinstance(value, int): # Pure int attribute (e.g. HomographNumber). Apply the same # non-empty-current guard as the plain-str branch: in fill-gaps # mode only skip when the target already has a non-zero/non-None # value. bool is checked first so True/False never reaches here. if fill_gaps: current = getattr(item, prop_name, None) if current is not None and current != 0: continue if hasattr(item, prop_name): try: setattr(item, prop_name, value) except (TypeError, AttributeError): continue else: # Unknown shape; subclasses override to handle. continue
[docs] class BaseOperations: """ Base class for all FLEx operation classes. Provides common reordering functionality that works with any FLEx Owning Sequence (OS) collection. Subclasses must override _GetSequence() to specify which OS property to reorder. All 43 operation classes inherit from this base class, gaining access to 7 reordering methods without code duplication. Reordering Safety: - Reordering is SAFE - preserves all data connections - GUIDs, references, properties, and children remain intact - Only changes the sequence position (index) - Uses safe Clear/Add pattern for all operations Linguistic Significance: - Reordering changes linguistic meaning and behavior - Senses: First sense is primary - Allomorphs: First matching allomorph selected by parser - Examples: Order may reflect preference or pedagogy - Reorder only when linguistically justified Usage:: from flexicon import FLExProject project = FLExProject() project.OpenProject("MyProject", writeEnabled=True) entry = list(project.LexiconAllEntries())[0] # All operation classes have these methods: # Sort senses alphabetically project.Senses.Sort(entry, key_func=lambda s: project.Senses.GetGloss(s)) # Move sense up one position sense = entry.SensesOS[2] project.Senses.MoveUp(entry, sense) # Move allomorph to specific index allo = entry.AlternateFormsOS[3] project.Allomorphs.MoveToIndex(entry, allo, 0) # Swap two examples ex1 = sense.ExamplesOS[0] ex2 = sense.ExamplesOS[1] project.Examples.Swap(ex1, ex2) project.CloseProject() """ def __init__(self, project): """ Initialize BaseOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ self.project = project # ========== REORDERING METHODS ==========
[docs] @OperationsMethod def Sort(self, parent_or_hvo, key_func=None, reverse=False): """ Sort items in an owning sequence using a custom key function. This method reorders all items in the sequence according to a sorting criterion. The sort is stable and uses Python's built-in sort algorithm. Args: parent_or_hvo: The parent object or HVO containing the sequence. key_func: Optional function(item) -> comparable_value. If None, uses natural ordering (may fail if items don't support comparison). reverse: If True, sort in descending order. Default False. Returns: int: Number of items sorted (length of sequence). Raises: TypeError: If key_func is None and items don't support comparison. Exception: If any error occurs during sorting. Example: >>> # Sort allomorphs by form length >>> project.Allomorphs.Sort(entry, ... key_func=lambda a: len(project.Allomorphs.GetForm(a))) 3 >>> # Sort senses alphabetically by gloss >>> project.Senses.Sort(entry, ... key_func=lambda s: project.Senses.GetGloss(s)) 5 >>> # Sort in reverse order (most complex first) >>> def complexity(allo): ... envs = project.Allomorphs.GetPhoneEnv(allo) ... if not envs: ... return 0 ... ws = project.GetDefaultAnalysisWSHandle() ... name = ITsString(envs[0].Name.get_String(ws)).Text ... return len(name) if name else 0 >>> project.Allomorphs.Sort(entry, ... key_func=complexity, ... reverse=True) 3 >>> # Sort examples by length (shortest first) >>> project.Examples.Sort(sense, ... key_func=lambda ex: len(project.Examples.GetExample(ex))) 4 Notes: - Returns count even if order unchanged - Empty sequence returns 0 - Uses safe Clear/Add pattern - preserves all data - Sort is stable (equal elements maintain relative order) - If key_func raises exception, sort fails Linguistic Warning: Reordering changes linguistic behavior: - Senses: Primary sense is first - Allomorphs: Parser tries in sequence order - Examples: Order may be pedagogically significant See Also: MoveToIndex, MoveUp, MoveDown """ parent = self._GetObject(parent_or_hvo) sequence = self._GetSequence(parent) # Get all items with their indices count = sequence.Count if count <= 1: return count # Nothing to sort items_with_indices = [(i, sequence[i]) for i in range(count)] # Sort based on key function or natural order if key_func: items_with_indices.sort(key=lambda x: key_func(x[1]), reverse=reverse) else: items_with_indices.sort(key=lambda x: x[1], reverse=reverse) # Apply new order using MoveTo. Bracketed as ONE transaction: a sort # issues up to `count` MoveTo calls, and a failure partway through # leaves the sequence in a half-sorted order that is neither the old # nor the new one. This is exactly the multi-mutation case # _TransactionCM exists for. with self._TransactionCM("Sort sequence"): # We need to move items from their current position to their target position # Process from end to beginning to avoid index shifting issues for target_index in range(count): # Find where the item that should be at target_index currently is current_index = target_index for j in range(target_index, count): if sequence[j] == items_with_indices[target_index][1]: current_index = j break # Move it to the target position if not already there if current_index != target_index: sequence.MoveTo(current_index, current_index, sequence, target_index) return count
def _ApplySequenceOrder(self, sequence, desired_order): """ Reorder an LCM owning sequence to match ``desired_order`` using MoveTo. Never uses ``Clear()`` -- on an owning sequence, Clear deletes every child object (issue #470). Args: sequence: LCM owning sequence (``Count``, indexing, ``MoveTo``). desired_order: Every current member exactly once, in target order. Raises: FP_ParameterError: If ``desired_order`` length or membership does not match the sequence. """ count = sequence.Count if len(desired_order) != count: raise FP_ParameterError( f"desired_order length {len(desired_order)} does not match " f"sequence count {count}" ) current = [sequence[i] for i in range(count)] if set(current) != set(desired_order): raise FP_ParameterError( "desired_order must contain exactly the same objects as the sequence" ) # Every caller reaches this from inside its own Reorder bracket, so # this transaction joins that one (nesting-aware per B1). Stated # anyway so the site is grep-auditable per D5. with self._TransactionCM("Apply sequence order"): for target_index in range(count): current_index = target_index target_item = desired_order[target_index] for j in range(target_index, count): if sequence[j] == target_item: current_index = j break if current_index != target_index: sequence.MoveTo( current_index, current_index, sequence, target_index )
[docs] @OperationsMethod def MoveUp(self, parent_or_hvo, item, positions=1): """ Move an item up (toward index 0) by specified number of positions. Moves an item toward the beginning of the sequence. If the requested number of positions would move past index 0, the item is clamped at index 0 (no error raised). Args: parent_or_hvo: The parent object or HVO containing the sequence. item: The item to move (object, not HVO). positions: Number of positions to move up. Must be positive. Default is 1. Returns: int: Actual number of positions moved. May be less than requested if item reaches index 0. Returns 0 if already at index 0. Raises: ValueError: If item not found in sequence. ValueError: If positions is negative or zero. Example: >>> # Move sense up one position (e.g., from index 3 to 2) >>> sense = entry.SensesOS[3] >>> moved = project.Senses.MoveUp(entry, sense) >>> print(f"Moved {moved} positions") Moved 1 positions >>> # Move allomorph to top (up 5 positions) >>> allo = entry.AlternateFormsOS[5] >>> moved = project.Allomorphs.MoveUp(entry, allo, positions=5) >>> print(f"Now at index {list(entry.AlternateFormsOS).index(allo)}") Now at index 0 >>> # Try to move past start (clamped at 0) >>> example = sense.ExamplesOS[1] >>> moved = project.Examples.MoveUp(sense, example, positions=10) >>> print(f"Actually moved {moved} positions") Actually moved 1 positions >>> # Already at start - no movement >>> first = entry.SensesOS[0] >>> moved = project.Senses.MoveUp(entry, first) >>> print(f"Moved {moved} positions") Moved 0 positions Notes: - Auto-clamps at boundary (no IndexError) - Returns actual movement for UI feedback - Item stays at current position if already at top - Uses safe Clear/Add pattern - Perfect for "Move Up" buttons in UI Linguistic Warning: Moving items up increases their priority: - Senses: Moving to index 0 makes it primary - Allomorphs: Moving up means parser tries earlier See Also: MoveDown, MoveToIndex, MoveBefore """ self._EnsureWriteEnabled() if positions <= 0: raise ValueError("positions must be positive integer") item = self._UnwrapLcm(item) parent = self._GetObject(parent_or_hvo) sequence = self._GetSequence(parent) # Find current index current_index = -1 for i in range(sequence.Count): if sequence[i] == item: current_index = i break if current_index == -1: raise ValueError("Item not found in sequence") # Already at top - no movement if current_index == 0: return 0 # Calculate new index (clamped to 0) new_index = max(0, current_index - positions) actual_moved = current_index - new_index # Move using FLEx's MoveTo method # When moving backward (up), use target index directly if actual_moved > 0: with self._TransactionCM(f"Move item up {actual_moved} position(s)"): sequence.MoveTo(current_index, current_index, sequence, new_index) return actual_moved
[docs] @OperationsMethod def MoveDown(self, parent_or_hvo, item, positions=1): """ Move an item down (toward end) by specified number of positions. Moves an item toward the end of the sequence. If the requested number of positions would move past the end, the item is clamped at the last index (no error raised). Args: parent_or_hvo: The parent object or HVO containing the sequence. item: The item to move (object, not HVO). positions: Number of positions to move down. Must be positive. Default is 1. Returns: int: Actual number of positions moved. May be less than requested if item reaches last index. Returns 0 if already at end. Raises: ValueError: If item not found in sequence. ValueError: If positions is negative or zero. Example: >>> # Move sense down one position (e.g., from index 1 to 2) >>> sense = entry.SensesOS[1] >>> moved = project.Senses.MoveDown(entry, sense) >>> print(f"Moved {moved} positions") Moved 1 positions >>> # Demote primary sense significantly >>> primary = entry.SensesOS[0] >>> moved = project.Senses.MoveDown(entry, primary, positions=3) >>> print(f"Now at index {list(entry.SensesOS).index(primary)}") Now at index 3 >>> # Try to move past end (clamped) >>> allo = entry.AlternateFormsOS[8] # Count = 10 >>> moved = project.Allomorphs.MoveDown(entry, allo, positions=5) >>> print(f"Actually moved {moved} positions") Actually moved 1 positions >>> # Already at end - no movement >>> last = entry.SensesOS[entry.SensesOS.Count - 1] >>> moved = project.Senses.MoveDown(entry, last) >>> print(f"Moved {moved} positions") Moved 0 positions Notes: - Auto-clamps at boundary (no IndexError) - Returns actual movement for UI feedback - Item stays at current position if already at end - Uses safe Clear/Add pattern - Perfect for "Move Down" buttons in UI Linguistic Warning: Moving items down decreases their priority: - Senses: Moving from index 0 demotes primary sense - Allomorphs: Moving down means parser tries later See Also: MoveUp, MoveToIndex, MoveAfter """ self._EnsureWriteEnabled() if positions <= 0: raise ValueError("positions must be positive integer") item = self._UnwrapLcm(item) parent = self._GetObject(parent_or_hvo) sequence = self._GetSequence(parent) # Find current index current_index = -1 for i in range(sequence.Count): if sequence[i] == item: current_index = i break if current_index == -1: raise ValueError("Item not found in sequence") # Already at end - no movement max_index = sequence.Count - 1 if current_index == max_index: return 0 # Calculate new index (clamped to max) new_index = min(max_index, current_index + positions) actual_moved = new_index - current_index # Move using FLEx's MoveTo method # When moving forward (down), need to use new_index + 1 due to FLEx behavior if actual_moved > 0: with self._TransactionCM(f"Move item down {actual_moved} position(s)"): sequence.MoveTo(current_index, current_index, sequence, new_index + 1) return actual_moved
[docs] @OperationsMethod def MoveToIndex(self, parent_or_hvo, item, new_index): """ Move an item to a specific index position. Directly moves an item to the specified index. Other items are shifted accordingly. This is useful for absolute positioning. Args: parent_or_hvo: The parent object or HVO containing the sequence. item: The item to move (object, not HVO). new_index: Target index (0-based). Must be valid index for current sequence length. Returns: bool: True if successful. Raises: ValueError: If item not found in sequence. IndexError: If new_index is out of range [0, count-1]. Example: >>> # Make third sense the primary sense >>> third_sense = entry.SensesOS[2] >>> project.Senses.MoveToIndex(entry, third_sense, 0) True >>> # Move allomorph to end >>> allo = entry.AlternateFormsOS[1] >>> last_index = entry.AlternateFormsOS.Count - 1 >>> project.Allomorphs.MoveToIndex(entry, allo, last_index) True >>> # Move example to middle position >>> ex = sense.ExamplesOS[0] >>> project.Examples.MoveToIndex(sense, ex, 2) True Notes: - Validates index before moving - Raises IndexError for out-of-range index - Moving to current index is allowed (no-op) - Uses safe Clear/Add pattern - Good for drag-and-drop UI implementation Linguistic Warning: Index 0 has special significance: - Senses: Index 0 is the primary sense - Allomorphs: Index 0 is the default form - Moving to index 0 changes linguistic priority See Also: MoveUp, MoveDown, MoveBefore, MoveAfter """ self._EnsureWriteEnabled() item = self._UnwrapLcm(item) parent = self._GetObject(parent_or_hvo) sequence = self._GetSequence(parent) # Find current index current_index = -1 for i in range(sequence.Count): if sequence[i] == item: current_index = i break if current_index == -1: raise ValueError("Item not found in sequence") # Validate new index if new_index < 0 or new_index >= sequence.Count: raise IndexError(f"Index {new_index} out of range [0, {sequence.Count-1}]") # Move using FLEx's MoveTo method # Adjust destination index based on direction if current_index != new_index: with self._TransactionCM(f"Move item to index {new_index}"): if current_index < new_index: # Moving forward - use new_index + 1 sequence.MoveTo(current_index, current_index, sequence, new_index + 1) else: # Moving backward - use new_index directly sequence.MoveTo(current_index, current_index, sequence, new_index) return True
[docs] @OperationsMethod def MoveBefore(self, item_to_move, target_item): """ Move an item to position immediately before another item. Positions item_to_move directly before target_item in the sequence. Both items must be in the same sequence (same parent). The parent is automatically determined by examining the items' Owner property. Args: item_to_move: The item to reposition (object, not HVO). target_item: The item before which to insert (object, not HVO). Returns: bool: True if successful. Raises: ValueError: If items not in same sequence or not found. Example: >>> # Move secondary sense to become primary >>> primary = entry.SensesOS[0] >>> secondary = entry.SensesOS[2] >>> project.Senses.MoveBefore(secondary, primary) True >>> # Move variant allomorph before default >>> default = entry.AlternateFormsOS[0] >>> variant = entry.AlternateFormsOS[3] >>> project.Allomorphs.MoveBefore(variant, default) True Notes: - Automatically finds common parent sequence - Both items must be in same owning sequence - Uses safe Clear/Add pattern - Perfect for drag-and-drop "insert before" operations - No parent_or_hvo parameter needed Linguistic Warning: Relative positioning changes processing order: - Allomorphs: Earlier forms tried first by parser - Senses: Earlier senses are more prominent See Also: MoveAfter, MoveToIndex, Swap """ self._EnsureWriteEnabled() item_to_move = self._UnwrapLcm(item_to_move) target_item = self._UnwrapLcm(target_item) # Find which sequence contains both items sequence = self._FindCommonSequence(item_to_move, target_item) # Find indices of both items # Note: sequence from reflection may not support indexing, use enumeration move_index = -1 target_index = -1 index = 0 for item in sequence: if item == item_to_move: move_index = index if item == target_item: target_index = index index += 1 # Move using FLEx's MoveTo method if move_index != -1 and target_index != -1 and move_index != target_index: with self._TransactionCM("Move item before target"): if move_index < target_index: # Moving forward - use target_index (will end up before target) sequence.MoveTo(move_index, move_index, sequence, target_index) else: # Moving backward - use target_index directly sequence.MoveTo(move_index, move_index, sequence, target_index) return True
[docs] @OperationsMethod def MoveAfter(self, item_to_move, target_item): """ Move an item to position immediately after another item. Positions item_to_move directly after target_item in the sequence. Both items must be in the same sequence (same parent). The parent is automatically determined by examining the items' Owner property. Args: item_to_move: The item to reposition (object, not HVO). target_item: The item after which to insert (object, not HVO). Returns: bool: True if successful. Raises: ValueError: If items not in same sequence or not found. Example: >>> # Move primary sense to second position >>> primary = entry.SensesOS[0] >>> secondary = entry.SensesOS[1] >>> project.Senses.MoveAfter(primary, secondary) True >>> # Move variant allomorph after default >>> default = entry.AlternateFormsOS[0] >>> variant = entry.AlternateFormsOS[3] >>> project.Allomorphs.MoveAfter(variant, default) True Notes: - Automatically finds common parent sequence - Both items must be in same owning sequence - Uses safe Clear/Add pattern - Perfect for drag-and-drop "insert after" operations - No parent_or_hvo parameter needed Linguistic Warning: Relative positioning changes processing order: - Allomorphs: Later forms tried after earlier ones - Senses: Later senses are less prominent See Also: MoveBefore, MoveToIndex, Swap """ self._EnsureWriteEnabled() item_to_move = self._UnwrapLcm(item_to_move) target_item = self._UnwrapLcm(target_item) # Find which sequence contains both items sequence = self._FindCommonSequence(item_to_move, target_item) # Find indices of both items # Note: sequence from reflection may not support indexing, use enumeration move_index = -1 target_index = -1 index = 0 for item in sequence: if item == item_to_move: move_index = index if item == target_item: target_index = index index += 1 # Move to position after target if move_index != -1 and target_index != -1 and move_index != target_index: with self._TransactionCM("Move item after target"): # When moving after, we want to end up at target_index + 1 # If moving forward: use target_index + 1 (will insert after due to removal) # If moving backward: use target_index + 1 directly if move_index < target_index: sequence.MoveTo(move_index, move_index, sequence, target_index + 1) else: sequence.MoveTo(move_index, move_index, sequence, target_index + 1) return True
[docs] @OperationsMethod def Swap(self, item1, item2): """ Swap the positions of two items in a sequence. Exchanges the positions of two items. Both items must be in the same sequence (same parent). The parent is automatically determined by examining the items' Owner property. Args: item1: First item to swap (object, not HVO). item2: Second item to swap (object, not HVO). Returns: bool: True if successful. Raises: ValueError: If items not in same sequence or not found. Example: >>> # Swap first and second senses >>> sense1 = entry.SensesOS[0] >>> sense2 = entry.SensesOS[1] >>> project.Senses.Swap(sense1, sense2) True >>> # Swap allomorphs >>> allo1 = entry.AlternateFormsOS[0] >>> allo2 = entry.AlternateFormsOS[3] >>> project.Allomorphs.Swap(allo1, allo2) True Notes: - Automatically finds common parent sequence - Both items must be in same owning sequence - Swapping item with itself is allowed (no-op) - Uses safe Clear/Add pattern - Works for adjacent or non-adjacent items - No parent_or_hvo parameter needed Linguistic Warning: Swapping changes relative priority: - Swapping primary sense changes which is primary - Swapping allomorphs changes parser order See Also: MoveBefore, MoveAfter, MoveToIndex """ item1 = self._UnwrapLcm(item1) item2 = self._UnwrapLcm(item2) # Find which sequence contains both items sequence = self._FindCommonSequence(item1, item2) # Find indices # Note: sequence from reflection may not support indexing, use enumeration idx1 = -1 idx2 = -1 index = 0 for item in sequence: if item == item1: idx1 = index if item == item2: idx2 = index index += 1 # Swap using MoveTo operations. Bracketed as ONE transaction: the swap # is a deliberate two-step MoveTo dance, and failing between the steps # leaves the sequence in an order that is neither the original nor the # swapped one. # Strategy: Move lower-index item after higher-index item, then move higher item to original position if idx1 != -1 and idx2 != -1 and idx1 != idx2: with self._TransactionCM("Swap items"): if idx1 < idx2: # item1 is before item2 # Step 1: Move item1 to after item2 (this pushes item2 earlier) # After this: [..., item2 at idx1, ..., item1 at idx2, ...] sequence.MoveTo(idx1, idx1, sequence, idx2 + 1) # Step 2: Now item2 is at idx1, move it to idx2 # But idx2 is now idx2-1 because we removed item1 sequence.MoveTo(idx1, idx1, sequence, idx2) else: # item2 is before item1 # Step 1: Move item2 to after item1 sequence.MoveTo(idx2, idx2, sequence, idx1 + 1) # Step 2: Now item1 is at idx2, move it to idx1 sequence.MoveTo(idx2, idx2, sequence, idx1) return True
# ========== SYNC INTEGRATION METHODS ==========
[docs] @OperationsMethod def GetSyncableProperties(self, item): """ Get dictionary of syncable properties for cross-project synchronization. This method is OPTIONAL for sync framework integration. Subclasses that want to support the sync framework (flexicon.sync) should implement this method to specify which properties can be safely synchronized between projects. The sync framework uses this method to: - Extract property values for comparison (DiffEngine) - Build property-level diffs showing what changed - Enable selective merging of individual properties (MergeOperations) - Support conflict resolution in multi-way syncs Args: item: The FLEx object to extract properties from. Returns: dict: Property names mapped to their values. Keys should be property names (strings), values should be JSON-serializable when possible. For complex FLEx objects (MultiString, etc.), return appropriate representations. Raises: NotImplementedError: If subclass doesn't implement sync support. Example Implementation (in LexSenseOperations): >>> def GetSyncableProperties(self, sense): ... '''Get syncable properties from a sense.''' ... return { ... 'Gloss': self.GetGloss(sense), ... 'Definition': self.GetDefinition(sense), ... 'PartOfSpeech': self.GetPartOfSpeech(sense), ... 'SemanticDomains': self.GetSemanticDomains(sense), ... 'ExampleCount': sense.ExamplesOS.Count, ... # Note: Don't include order-dependent items in properties ... # The sync framework handles OS sequences separately ... } Example Usage (by sync framework): >>> from flexicon.sync import DiffEngine >>> >>> # Compare senses between two projects >>> props1 = project1.Senses.GetSyncableProperties(sense1) >>> props2 = project2.Senses.GetSyncableProperties(sense2) >>> >>> diff_engine = DiffEngine() >>> is_different, differences = diff_engine.CompareProperties( ... props1, props2 ... ) >>> >>> if is_different: ... print(f"Properties changed: {list(differences.keys())}") ... for prop, (old_val, new_val) in differences.items(): ... print(f" {prop}: {old_val} -> {new_val}") Notes: - Return only properties that make sense to sync (not GUIDs, HVOs) - Don't include computed properties that depend on context - Don't include owning sequences (OS) - sync framework handles those - Return None or empty string for missing/empty properties - Complex objects: return string representations or dicts - This method is optional - subclasses that don't implement it simply won't support property-level sync What to Include: - Text fields (gloss, definition, notes) - References (part of speech, semantic domains) - Simple flags/enums (morpheme type, status) - Counts (for validation) What to Exclude: - GUIDs (sync framework uses these for matching) - HVOs (project-specific IDs) - Owner references (implicit in structure) - Owning sequences (handled separately by sync framework) - DateCreated/DateModified (use merge strategy instead) Sync Framework Integration: Used by: flexicon.sync.DiffEngine.CompareItems() Used by: flexicon.sync.MergeOperations.MergeProperties() See also: CompareTo() for full item comparison See Also: CompareTo, flexicon.sync.DiffEngine, flexicon.sync.MergeOperations """ raise NotImplementedError( f"{self.__class__.__name__} does not implement GetSyncableProperties(). " "This method is OPTIONAL for sync framework integration. " "Implement it if you want to enable property-level synchronization " "for this item type. See flexicon.sync documentation for details." )
[docs] @OperationsMethod def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False): """ Apply a syncable-properties dict (from GetSyncableProperties) onto an item. Inverse of GetSyncableProperties. Used by cross-project transfer workflows (e.g. GramTrans Phase 0): extract syncable props from a source item, create a target item via the appropriate factory + add to owner + assign Guid, then call ApplySyncableProperties to copy the syncable fields. Caller handles creation and identity; this method only sets values. The default implementation handles two value shapes returned by canonical GetSyncableProperties implementations: - **dict[str, str]** — a multilingual string field, keyed by source writing-system Id. Each value is applied to the target object's matching multistring field via set_String(), with writing-system handles resolved on the target side. If ws_map is provided, source Id is translated via that mapping; otherwise identity match by Id. - **str** — a plain string attribute. Set directly via setattr. Subclasses MAY override to handle category-specific shapes (object references that need cross-project resolution, owning collections, etc.). Subclass overrides typically delegate to super().ApplySyncableProperties for the multistring/string case and then add their own per-field logic. Args: item: Target LCM object. MUST already exist and be owned in the target project (callers create + add to owner + assign Guid before calling). props: dict produced by GetSyncableProperties on a source item. ws_map: Optional dict mapping source-project writing-system Id strings to target-project writing-system Id strings. Default is identity (a source 'en' value is applied to target's 'en' writing system if it exists). Source values whose mapped target WS Id does not exist in the target are silently skipped — callers wishing strict matching should validate ws_map against the target's WS inventory before calling. Returns: None. Raises: FP_NullParameterError: If item is None. FP_ParameterError: If props is not a dict. Example (cross-project POS copy): >>> src_pos = source.POS.Find("Verb") >>> props = source.POS.GetSyncableProperties(src_pos) >>> # Create target POS via raw factory pattern. >>> factory = target.GetService(IPartOfSpeechFactory) >>> new_pos = factory.Create() >>> target.Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(new_pos) >>> new_pos.Guid = source_guid # GUID preservation >>> target.POS.ApplySyncableProperties(new_pos, props) """ self._EnsureWriteEnabled() if item is None: raise FP_NullParameterError() if not isinstance(props, dict): raise FP_ParameterError( f"ApplySyncableProperties: props must be a dict, got " f"{type(props).__name__}" ) # Lazy import — avoids burdening module load for users who don't sync. from SIL.LCModel.Core.Text import TsStringUtils target_ws_by_id = { ws.Id: ws.Handle for ws in self.project.WritingSystems.GetAll() } # B2/D5 bracket. This one was missed by the original 295-site sweep # because the sweep enumerated Operations *methods* and the writes # actually live in `_apply_props_loop`, a module-level helper -- so no # per-method scheme reached them. It was latent under `undoable=False` # (the session envelope covered every write) and became a hard failure # the moment DEF made `undoable=True` the default: with no envelope # open, `MultiUnicodeAccessor.set_String` raises # `InvalidOperationException: Not in the right state to register a # change.` One bracket for the whole loop, not per property: a partial # sync that failed halfway would otherwise leave the target item with # some fields updated and some stale, which is precisely the # half-applied state this method's callers cannot detect. with self._TransactionCM("Apply syncable properties"): _apply_props_loop(item, props, target_ws_by_id, fill_gaps, ws_map=ws_map, _default_ws_getter=self.project.GetDefaultAnalysisWSHandle, _ts_string_utils=TsStringUtils)
[docs] @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two items and return detailed differences. This method is OPTIONAL for sync framework integration. Subclasses that want to support the sync framework (flexicon.sync) should implement this method to enable intelligent comparison and merging between projects. The sync framework uses this method to: - Detect if two items (matched by GUID) have diverged - Generate detailed diff reports showing what changed - Support conflict detection in multi-way merges - Enable selective merge operations Args: item1: First item to compare (from source project). item2: Second item to compare (from target project). ops1: Optional. Operations instance for item1's project. If None, uses self (assumes items from same project). ops2: Optional. Operations instance for item2's project. If None, uses self (assumes items from same project). Returns: tuple: (is_different, differences) where: - is_different (bool): True if items differ in any way - differences (dict): Detailed differences with structure: { 'properties': { 'PropertyName': { 'source': value_in_item1, 'target': value_in_item2, 'type': 'modified'|'added'|'removed' }, ... }, 'children': { 'ChildSequenceName': { 'added': [guid1, guid2, ...], 'removed': [guid3, guid4, ...], 'modified': [guid5, guid6, ...] }, ... } } Raises: NotImplementedError: If subclass doesn't implement sync support. Example Implementation (in LexSenseOperations): >>> def CompareTo(self, sense1, sense2, ops1=None, ops2=None): ... '''Compare two senses for differences.''' ... if ops1 is None: ... ops1 = self ... if ops2 is None: ... ops2 = self ... ... is_different = False ... differences = {'properties': {}, 'children': {}} ... ... # Compare properties ... props1 = ops1.GetSyncableProperties(sense1) ... props2 = ops2.GetSyncableProperties(sense2) ... ... for key in set(props1.keys()) | set(props2.keys()): ... val1 = props1.get(key) ... val2 = props2.get(key) ... if val1 != val2: ... is_different = True ... differences['properties'][key] = { ... 'source': val1, ... 'target': val2, ... 'type': 'modified' ... } ... ... # Compare child sequences (examples) ... guids1 = {ex.Guid for ex in sense1.ExamplesOS} ... guids2 = {ex.Guid for ex in sense2.ExamplesOS} ... ... added = guids2 - guids1 ... removed = guids1 - guids2 ... ... if added or removed: ... is_different = True ... differences['children']['Examples'] = { ... 'added': list(added), ... 'removed': list(removed), ... 'modified': [] ... } ... ... return is_different, differences Example Usage (by sync framework): >>> from flexicon.sync import DiffEngine >>> >>> # Find matching senses by GUID in two projects >>> sense1 = project1.Senses.FindByGuid(guid) >>> sense2 = project2.Senses.FindByGuid(guid) >>> >>> # Compare them >>> is_diff, diffs = project1.Senses.CompareTo( ... sense1, sense2, ... ops1=project1.Senses, ... ops2=project2.Senses ... ) >>> >>> if is_diff: ... print("Sense has diverged between projects:") ... for prop, details in diffs['properties'].items(): ... print(f" {prop}: {details['source']} -> {details['target']}") ... ... for child_name, child_diffs in diffs['children'].items(): ... if child_diffs['added']: ... print(f" {child_name} added: {len(child_diffs['added'])}") ... if child_diffs['removed']: ... print(f" {child_name} removed: {len(child_diffs['removed'])}") Notes: - Compare items by content, not identity (different objects, same data) - Use GetSyncableProperties() for property comparison - Compare child sequences by GUID (not position or count alone) - Return empty differences dict if items are identical - This method is optional - subclasses that don't implement it simply won't support detailed diff/merge operations Comparison Strategy: 1. Extract properties using GetSyncableProperties() 2. Compare property values (use appropriate equality for types) 3. Compare child sequences by GUID membership 4. Optionally recurse to compare child content (use ops1/ops2) 5. Build structured differences dict Cross-Project Comparison: - ops1/ops2 allow comparing items from different projects - Each ops instance knows how to extract properties from its project - Handles differences in project structure gracefully - Use GUID for matching children across projects Sync Framework Integration: Used by: flexicon.sync.DiffEngine.GenerateDiff() Used by: flexicon.sync.MergeOperations.DetectConflicts() See also: GetSyncableProperties() for property extraction See Also: GetSyncableProperties, flexicon.sync.DiffEngine, flexicon.sync.MergeOperations """ raise NotImplementedError( f"{self.__class__.__name__} does not implement CompareTo(). " "This method is OPTIONAL for sync framework integration. " "Implement it if you want to enable detailed comparison and " "conflict detection for this item type. See flexicon.sync " "documentation for details." )
# ========== HELPER METHODS ========== def _GetSequence(self, parent): """ Get the owning sequence from parent object. This method MUST be overridden in subclasses to specify which owning sequence (OS) property to reorder. Args: parent: The parent object containing the sequence. Returns: ILcmOwningSequence: The sequence to reorder. Raises: NotImplementedError: If subclass doesn't override this method. Example (in subclass): >>> # In LexSenseOperations >>> def _GetSequence(self, parent): ... return parent.SensesOS >>> # In AllomorphOperations >>> def _GetSequence(self, parent): ... return parent.AlternateFormsOS >>> # In ExampleOperations >>> def _GetSequence(self, parent): ... return parent.ExamplesOS Notes: - Each subclass specifies its own OS property - Called internally by all reordering methods - Provides type safety and correct sequence access See Also: All reordering methods use this internally. """ raise NotImplementedError( f"{self.__class__.__name__} must implement _GetSequence() " "to specify which owning sequence to reorder. " "Example: return parent.SensesOS" ) def _UnwrapLcm(self, obj): """ Unwrap a flexicon wrapper object to the raw LCM object it holds. `GetAll()` on several Operations classes (Allomorph, MSA, MorphRule, PhonologicalRule) returns `LCMObjectWrapper` instances instead of raw LCM objects, so users can iterate results and then pass an item straight back into another Operations method (e.g. `project.Allomorphs.GetForm(item)` for `item` in `project.Allomorphs.GetAll(entry)`). Internal resolvers that later perform a pythonnet interface cast (`IMoStemAllomorph(obj)`, etc.) or an equality/`IndexOf`/`Remove` check against a raw LCM sequence need the raw object, not the wrapper -- pythonnet cannot cast a plain Python wrapper object, and wrapper instances don't define `__eq__` against raw LCM objects. This helper centralizes that unwrap step so every resolver applies it the same way, using `isinstance` checks against the two known wrapper types rather than `hasattr()` probes. `hasattr()` is unsafe here: both wrapper types proxy unknown attribute access to the wrapped LCM object via `__getattr__`, and raw pythonnet objects can raise on some attribute probes rather than returning False. Args: obj: An `LCMObjectWrapper`, a `PythonicWrapper`, a raw LCM object, an HVO (int), a string, or None. Returns: object: `obj.lcm_object` if obj is an `LCMObjectWrapper`; the underlying LCM object if obj is a `PythonicWrapper`; otherwise `obj` unchanged (including None, int, str, and raw LCM objects, which all pass through untouched). Example: >>> allo_wrapper = project.Allomorphs.GetAll(entry)[0] >>> raw = self._UnwrapLcm(allo_wrapper) >>> IMoAffixAllomorph(raw) # works; would TypeError on the wrapper Notes: - Never raises -- safe to call unconditionally on any value. - Idempotent: unwrapping an already-raw object is a no-op. See Also: LCMObjectWrapper.lcm_object, PythonicWrapper.unwrap() """ if isinstance(obj, LCMObjectWrapper): return obj.lcm_object if isinstance(obj, PythonicWrapper): return _unwrap_pythonic(obj) return obj def _GetObject(self, obj_or_hvo): """ Get object from HVO or return object directly. Handles the common pattern where methods accept either an object or its HVO (Handle Value Object = integer ID). Args: obj_or_hvo: Either an object or an HVO (int). May also be a flexicon wrapper object (e.g. an item from a wrapper- returning `GetAll()`), which is unwrapped to its raw LCM object before being returned. Returns: object: The resolved object. Example: >>> # Using object directly >>> sense = entry.SensesOS[0] >>> resolved = self._GetObject(sense) >>> assert resolved is sense >>> # Using HVO >>> hvo = sense.Hvo >>> resolved = self._GetObject(hvo) >>> assert resolved.Hvo == hvo Notes: - If obj_or_hvo is int, retrieves object by HVO - If obj_or_hvo is a wrapper, unwraps to the raw LCM object - If obj_or_hvo is a raw object, returns it unchanged - Uses FLExProject.Object() for HVO resolution See Also: All methods that accept parent_or_hvo use this. """ obj_or_hvo = self._UnwrapLcm(obj_or_hvo) if isinstance(obj_or_hvo, int): return self.project.Object(obj_or_hvo) return obj_or_hvo def _GetTypedOwner(self, obj): """ Return obj.Owner cast to its concrete LCM interface. Owned LCM objects expose `.Owner` as the base ICmObject interface, which does NOT surface typed collection properties (SensesOS, AlternateFormsOS, EtymologyOS, AnnotationsOC, EntryRefsOS, SubPossibilitiesOS, RowsOS, etc.). Operations that need to add to or remove from one of those collections must therefore route the owner through `cast_to_concrete()` first; raw `obj.Owner.XxxOS` raises AttributeError, and `hasattr(obj.Owner, "XxxOS")` returns False even when the concrete owner does expose the collection -- which silently no-ops Delete and orphans Duplicate output. This helper centralises that cast so individual operations classes do not each have to import lcm_casting. Args: obj: An owned LCM object (must have an .Owner property). Returns: The owner cast to its concrete interface (e.g. ILexEntry, ICmAnthroItem, IDsConstChart, IRnGenericRec). Returns the raw owner unchanged if cast_to_concrete() does not recognise its ClassName, or None if obj has no .Owner. Notes: - Use the return value to access typed properties like SensesOS, AlternateFormsOS, AnnotationsOC. - Caller still owns existence checks (e.g. whether the owner actually has the expected collection for that path). """ if obj is None or not hasattr(obj, "Owner"): return None owner = obj.Owner if owner is None: return None from .lcm_casting import cast_to_concrete return cast_to_concrete(owner) def _GetTypedElements(self, collection): """ Materialise `collection` with each element cast to its concrete LCM interface. The collection counterpart to `_GetTypedOwner()`. LCM collections are declared over a BASE interface -- `ComponentLexemesRS` and `TargetsRS` over `ICmObject`, `PossibilitiesOS` and `SubPossibilitiesOS` over `ICmPossibility`, `CellsOS` over `IConstituentChartCellPart` -- and pythonnet hands the elements back as that base. Every subtype-only property is then invisible, `isinstance(element, ILexEntry)` is False for objects that really are entries, and `hasattr(element, "SubPossibilitiesOS")` is False on an `ICmObject` element that really is a possibility. That makes collection elements impossible to round-trip back into other flexicon methods (issue #270). Args: collection: Any iterable of LCM objects (an owning/reference sequence, a generator, or a plain list). None yields []. Returns: list: New list, same order and length, each element passed through `cast_to_concrete()`. Unrecognised ClassNames come back unchanged, so the call is total. Notes: - Returning a plain list is deliberate: the `GetAll` contract (docs/getall-contract.md) only promises a behavioural collection, and `_needs_enumerable_wrap()` leaves lists alone. - Callers that need to *filter* by subtype should compare `element.ClassName` rather than `isinstance`, because an element that has not been through this helper will fail an `isinstance` check against its own concrete interface. See Also: _GetTypedOwner, flexicon.code.lcm_casting.cast_all """ from .lcm_casting import cast_all return cast_all(collection) def _ResolveFeatureStrucOwner(self, owner, slot=None): """ Resolve which atomic-owning ('OA') property on ``owner`` holds an ``IFsFeatStruc``, and return ``owner`` cast to the CONCRETE LCM interface that declares it. Single, ``ClassName``-driven source of truth for the "which differently-named property owns the feature structure on this object" question (spec ``feature-structure-sync-gap``, contract C1/C5). Backed by ``FEATURE_STRUC_OWNER_TABLE`` (``Shared/lcm_constants.py``) -- the one and only copy of the frozen C1 table. Args: owner: An LCM object (or an ``LCMObjectWrapper``-style wrapper exposing ``._obj``) whose ``ClassName`` is looked up in ``FEATURE_STRUC_OWNER_TABLE``. Callers reaching ``owner`` via an HVO or GUID must resolve/cast it to a real LCM object THEMSELVES first (contract C2) -- this method does not accept an ``int``/HVO. slot: Required ONLY when ``owner``'s ``ClassName`` has more than one row in the table (``MoDerivAffMsa``: ``"From"``/ ``"To"``; ``PartOfSpeech``: ``"Default"``/ ``"InherFeatVal"``). Ignored -- not an error -- for a ClassName with exactly one row, even if the caller passes one anyway (documented behaviour, not a defect). Returns: tuple: ``(concrete_owner, prop_name)`` where ``concrete_owner`` is ``owner`` cast to its concrete LCM interface (e.g. ``IMoStemMsa(owner)``) and ``prop_name`` is the LCM atomic-owning property name on that interface (e.g. ``"MsFeaturesOA"``) -- read/write it directly: ``getattr(concrete_owner, prop_name)`` / ``setattr(concrete_owner, prop_name, new_struct)``. Raises: FP_ParameterError: If ``owner`` is ``None``; if ``owner`` has no ``ClassName`` (not an LCM object); if ``owner``'s ``ClassName`` is not a row in ``FEATURE_STRUC_OWNER_TABLE`` (message names the ClassName AND lists every supported ClassName -- this covers the table's deliberately-excluded ``ClassName``s, ``MoDerivStepMsa``, ``LexEntryInflType``, ``MoStemName``, ``MoUnclassifiedAffixMsa``, which raise here rather than being guessed at); if ``owner``'s ClassName has more than one row and ``slot`` is ``None`` or does not match any row's slot (message names the valid slot values -- NEVER guessed); or if no ``I<ClassName>`` interface exists in ``SIL.LCModel`` for a ClassName that IS in the table (an environment/LCM-version mismatch, not a caller error). TypeError: If the concrete cast itself fails (pythonnet rejects casting ``owner`` to its own ClassName's interface -- should not happen in normal operation since the interface is chosen FROM ``owner.ClassName`` itself, but this is intentionally NOT caught: a cast failure here indicates something is genuinely wrong with ``owner`` and must surface loudly, not be silently swallowed (probe item 2; contract C1 step 5). Notes: - Never gates on ``hasattr`` for a subtype-declared member -- discriminates on ``.ClassName`` (always visible on the base interface under pythonnet) and then casts explicitly. This is the frozen rule the whole ``feature-structure-sync-gap`` family exists to enforce (spec D5). - This method resolves OWNERSHIP only. It does not read or create the ``IFsFeatStruc`` itself -- see ``_GetFeatureStruc`` (serialize) and the future ``_ApplyFeatureStruc`` (T4) for that, and remember the ownership-first invariant: attach the struct to the owning property BEFORE populating or even reading its ``FeatureSpecsOC`` (a free-floating ``IFsFeatStruc``'s own getter raises ``NullReferenceException``). Example:: >>> concrete, prop_name = self._ResolveFeatureStrucOwner( ... msa, slot=None ... ) >>> concrete.ClassName 'MoStemMsa' >>> prop_name 'MsFeaturesOA' >>> struct = getattr(concrete, prop_name) # IFsFeatStruc | None >>> # Ambiguous owner -- slot required >>> concrete, prop_name = self._ResolveFeatureStrucOwner( ... deriv_msa, slot="From" ... ) >>> prop_name 'FromMsFeaturesOA' """ if owner is None: raise FP_ParameterError( "_ResolveFeatureStrucOwner: owner is None." ) # Unwrap LCMObjectWrapper-style wrappers before reading ClassName. # Mirrors InflectionFeatureOperations.__Unwrap / PhonFeatureOperations # .__Unwrap -- a plain LCM object passes through unchanged. unwrapped = owner if hasattr(unwrapped, "_obj") and not hasattr(unwrapped, "Hvo"): unwrapped = unwrapped._obj elif hasattr(unwrapped, "_obj") and hasattr(unwrapped._obj, "Hvo"): unwrapped = unwrapped._obj if not hasattr(unwrapped, "ClassName"): raise FP_ParameterError( f"_ResolveFeatureStrucOwner: owner {unwrapped!r} has no " f"ClassName; expected an LCM object (or a wrapper " f"exposing one via ._obj)." ) class_name = unwrapped.ClassName rows = FEATURE_STRUC_OWNER_TABLE.get(class_name) if not rows: supported = ", ".join(sorted(FEATURE_STRUC_OWNER_TABLE)) raise FP_ParameterError( f"_ResolveFeatureStrucOwner: ClassName {class_name!r} is " f"not a recognized feature-structure owner. Supported " f"ClassNames: {supported}." ) if len(rows) > 1: valid_slots = ", ".join(repr(row[0]) for row in rows) if slot is None: raise FP_ParameterError( f"_ResolveFeatureStrucOwner: ClassName {class_name!r} " f"has {len(rows)} feature-structure owning properties " f"and requires an explicit slot=. Valid slot values: " f"{valid_slots}. Never guessed." ) for row_slot, prop_name, _props_key in rows: if row_slot == slot: break else: raise FP_ParameterError( f"_ResolveFeatureStrucOwner: ClassName {class_name!r} " f"has no slot {slot!r}. Valid slot values: " f"{valid_slots}." ) else: # Single row: slot is IGNORED (documented behaviour, not an # error), even if the caller supplied one. _row_slot, prop_name, _props_key = rows[0] import SIL.LCModel as _lcm_module iface_name = "I" + class_name interface_type = getattr(_lcm_module, iface_name, None) if interface_type is None: raise FP_ParameterError( f"_ResolveFeatureStrucOwner: no {iface_name} interface " f"found in SIL.LCModel for ClassName {class_name!r}, even " f"though it is a row in FEATURE_STRUC_OWNER_TABLE. This " f"indicates an LCM-version mismatch, not a caller error." ) # The cast itself is NOT wrapped in try/except -- a failure here # must raise TypeError loudly (contract C1 step 5); silently # falling back to the unchanged base object would defeat the # entire point of resolving a CONCRETE owner. concrete_owner = interface_type(unwrapped) return concrete_owner, prop_name def _GetFeatureStruc(self, struct, _top_level=True): """ Serialize an ``IFsFeatStruc`` into the frozen C4 sync wire-format dict, RECURSIVELY -- an ``IFsComplexValue`` spec's ``ValueOA`` becomes a nested dict of the same shape. Spec ``feature-structure-sync-gap``, contract C4/C5. Args: struct: An ``IFsFeatStruc`` (or an object castable to one -- e.g. the base-typed value read back from a nested ``IFsComplexValue.ValueOA``, which pythonnet returns statically typed as the base ``IFsAbstractStructure``). May be ``None``. _top_level: Internal recursion flag. Always leave at the default (``True``) when calling this method directly -- recursive calls into a nested ``ValueOA`` pass ``_top_level=False`` themselves. Controls only whether the returned dict carries a ``"Guid"`` key (see Returns). Returns: dict | None: ``None`` if ``struct`` is ``None`` (a genuinely absent/null feature structure -- the ONLY case that returns ``None``). Otherwise a dict shaped:: { "TypeGuid": "<guid>" | None, # struct.TypeRA, THIS level "Guid": "<guid>", # NESTED levels only -- # omitted at top level "specs": { "<featDefnGuid>": "<valueGuid>", # IFsClosedValue "<complexFeatGuid>": {...recursive...} # IFsComplexValue # .ValueOA }, } An empty-but-present structure (a real, attached ``IFsFeatStruc`` whose ``FeatureSpecsOC`` has zero entries) serializes as ``{"TypeGuid": ..., "specs": {}}`` -- NEVER ``None``. Only a struct that IS ``None`` returns ``None``. Notes: - Walks ``FeatureSpecsOC`` only. ``FeatureDisjunctionsOC`` is deliberately NOT traversed (out of scope for this feature; disjunctive feature structures are a new capability, filed separately per spec D2). - Discriminates each spec by ``.ClassName`` (``FsClosedValue`` vs ``FsComplexValue``), then casts explicitly -- never ``hasattr``-probes a subtype member. - ``TypeGuid`` is copied PER LEVEL, independently. Live data has a NULL outer ``TypeRA`` and a NON-NULL inner one on a nested struct -- ``TypeRA`` is never a whole-struct property. - A spec whose ``FeatureRA``/``ValueRA`` (closed) or ``FeatureRA``/``ValueOA`` (complex) is ``None`` is skipped, matching the existing NC/Phoneme capture behaviour for a malformed/partial spec. - This is a pure READ/serialize helper -- it never creates or attaches anything, so the ownership-first invariant does not apply to it directly. It DOES rely on the caller having already attached ``struct`` to its owner: a free-floating (unattached) ``IFsFeatStruc``'s own ``FeatureSpecsOC`` getter raises ``NullReferenceException`` in LCM. - C4a (Lead ruling, binding on future work): the CURRENTLY SHIPPED wire format for ``NaturalClassOperations`` / ``PhonemeOperations``'s ``props["Features"]`` is a FLAT LIST of ``{"FeatureGuid", "ValueGuid"}`` dicts (``NaturalClassOperations.py:1162``, ``PhonemeOperations.py:1373``), NOT this C4 dict shape. This method ALWAYS emits the C4 dict -- it does not, and must not, special-case its output to match the legacy list shape. The legacy flat list REMAINS a supported INPUT shape for the future ``_ApplyFeatureStruc``/generalized ``MakeFeatStruc`` (T4/T5) to accept, alongside the new recursive C4 dict and recursive-dict specs surface (C3). Capture in ``NaturalClassOperations``/``PhonemeOperations`` is NOT migrated to C4 by this method's addition -- those two classes' ``GetSyncableProperties`` are untouched here and keep emitting the flat list until T4/T10 re-point them. Example:: >>> concrete, prop_name = self._ResolveFeatureStrucOwner(msa) >>> struct = getattr(concrete, prop_name) >>> self._GetFeatureStruc(struct) {'TypeGuid': None, 'specs': { '11111111-...': '22222222-...', '33333333-...': {'TypeGuid': '44444444-...', 'Guid': '55555555-...', 'specs': {'66666666-...': '77777777-...'}}, }} """ if struct is None: return None from SIL.LCModel import IFsFeatStruc, IFsComplexValue, IFsClosedValue fs = IFsFeatStruc(struct) type_ra = fs.TypeRA result = { "TypeGuid": str(type_ra.Guid) if type_ra is not None else None, "specs": {}, } if not _top_level: result["Guid"] = str(fs.Guid) for spec in fs.FeatureSpecsOC: # Discriminate on ClassName (always visible on the base # interface under pythonnet), then cast explicitly -- never # hasattr-probe a subtype-declared member. spec_class_name = spec.ClassName if spec_class_name == "FsClosedValue": closed_value = IFsClosedValue(spec) feat_ra = closed_value.FeatureRA val_ra = closed_value.ValueRA if feat_ra is None or val_ra is None: continue result["specs"][str(feat_ra.Guid)] = str(val_ra.Guid) elif spec_class_name == "FsComplexValue": complex_value = IFsComplexValue(spec) feat_ra = complex_value.FeatureRA if feat_ra is None: continue nested_value_oa = complex_value.ValueOA if nested_value_oa is None: continue # Every read-back of a nested ValueOA needs its OWN # explicit IFsFeatStruc(...) cast -- it comes back typed # as the base IFsAbstractStructure (probe item 5). # _GetFeatureStruc performs that cast at the top of its # own recursive call, so we hand the raw ValueOA through. result["specs"][str(feat_ra.Guid)] = self._GetFeatureStruc( nested_value_oa, _top_level=False ) # else: an unrecognized spec ClassName -- out of scope # (FeatureDisjunctionsOC members live on a different # property entirely and are never reached via this loop # anyway). Silently skipped, matching NC/Phoneme's existing # "continue on unrecognized spec shape" behaviour. return result def _ResolveFsByGuid(self, guid, kind=None): """ Resolve a GUID string to an LCM object in THIS project, returning ``None`` on failure rather than raising. De-duplicates the byte-for-byte identical ``NaturalClassOperations.__ResolveByGuid`` (``NaturalClassOperations.py:1403``) and ``PhonemeOperations.__ResolveByGuid`` (``PhonemeOperations.py:1525``) private methods into one shared helper (spec ``feature-structure-sync-gap``, contract C5). Those two private methods are left untouched here -- re-pointing them at this helper is T4's job, not this task's; this addition is purely additive and changes no existing caller's behaviour. Args: guid: GUID string to resolve (braced or unbraced -- whatever ``FLExProject.Object()`` itself accepts). kind (str, optional): Descriptive label for what is being resolved (e.g. ``"feature"``, ``"value"``), used only for a debug-level log line on failure. Purely diagnostic -- does not affect this method's return value or behaviour. Returns: The resolved LCM object, or ``None`` if ``guid`` does not resolve to an object in this project (unknown/foreign GUID, or a malformed GUID string). Notes: - This method NEVER raises on a resolution failure -- per contract C7, an unresolved GUID must become a loud ``FP_ParameterError`` naming the GUID, but that raise is the CALLER's responsibility (mirroring the existing NC/Phoneme methods' division of labour: NC's caller raises, Phoneme's historically did not -- see spec D1/T9 for the ``skip`` default's own upcoming policy flip, which is unrelated to this method's own contract). """ import logging try: return self.project.Object(guid) except Exception as exc: logging.getLogger("flexicon").debug( "_ResolveFsByGuid: could not resolve %s GUID %r: %s: %s", kind or "object", guid, type(exc).__name__, exc, ) return None def _CastFsFeatStruc(self, obj): """ Cast ``obj`` to the concrete ``IFsFeatStruc`` LCM interface. Isolated into its own one-line method -- rather than an inline ``IFsFeatStruc(obj)`` -- purely as a TEST SEAM. ``SIL.LCModel`` is a pythonnet CLR namespace, not a regular Python module, and rejects ``setattr`` outright (``AttributeError: type does not support setting attributes``), so a fake-object unit test cannot monkeypatch ``SIL.LCModel.IFsFeatStruc`` the way ``NaturalClassOperations``'s own MODULE-LEVEL import of the same name CAN be monkeypatched (a plain Python module's ``__dict__`` *does* support ``setattr``). Routing the cast through a ``BaseOperations`` method gives fake-object tests the same seam already used for ``_TransactionCM``/``_CreateWithGuid`` (``monkeypatch.setattr(BaseOperations, "_CastFsFeatStruc", ...)``). Production callers are unaffected -- this performs exactly the cast the inline call used to. """ from SIL.LCModel import IFsFeatStruc return IFsFeatStruc(obj) def _CastFsClosedValue(self, obj): """Cast ``obj`` to the concrete ``IFsClosedValue`` LCM interface. See ``_CastFsFeatStruc`` for why this one-line cast is its own method (test-seam, since ``SIL.LCModel`` rejects ``setattr``).""" from SIL.LCModel import IFsClosedValue return IFsClosedValue(obj) def _CastFsComplexValue(self, obj): """Cast ``obj`` to the concrete ``IFsComplexValue`` LCM interface. See ``_CastFsFeatStruc`` for why this one-line cast is its own method (test-seam, since ``SIL.LCModel`` rejects ``setattr``).""" from SIL.LCModel import IFsComplexValue return IFsComplexValue(obj) def _ApplyFeatureStruc(self, owner, prop_name, spec_dict, struct_guid=None, on_unresolved="raise", label=None): """ Rewrite ``owner``'s ``prop_name``-owned ``IFsFeatStruc`` from a wire-format spec, resolving every feature/value/type GUID against THIS (target) project's feature system, RECURSIVELY. Spec ``feature-structure-sync-gap``, contract C5/C6/C7 (+ C4a). Single generalized implementation of the algorithm that used to be duplicated verbatim as ``NaturalClassOperations.__ApplyFeatures`` and ``PhonemeOperations.__ApplyFeatures`` -- both are now thin call-throughs to this method (T4). It also de-duplicates the two private ``__ResolveByGuid`` twins into ``_ResolveFsByGuid`` (T3). Args: owner: The target object ALREADY CAST to the concrete LCM interface that declares ``prop_name`` (e.g. an ``IPhNCFeatures``, an ``IPhPhoneme``) -- callers resolve this themselves via ``_ResolveFeatureStrucOwner`` (C1) or, for NC/Phoneme's own dispatch, their existing ``ClassName``-driven cast. This method does not re-derive ``prop_name`` from ``owner`` -- see ``prop_name``. prop_name: The LCM atomic-owning ('OA') property name on ``owner`` that holds the ``IFsFeatStruc`` (e.g. ``"FeaturesOA"``, ``"MsFeaturesOA"``). Read via ``getattr(owner, prop_name)``, written via ``setattr(owner, prop_name, new_struct)``. spec_dict: The feature-value specs to apply. C4a (FROZEN): accepts BOTH wire shapes -- (a) the C4 RECURSIVE DICT emitted by ``_GetFeatureStruc``: ``{"TypeGuid": ..., "specs": {featGuid: valGuid | {...nested C4 dict...}}}``; and (b) the LEGACY FLAT LIST shipped by ``NaturalClassOperations``/``PhonemeOperations``: ``[{"FeatureGuid": ..., "ValueGuid": ...}, ...]``. The shape is detected by ``isinstance(spec_dict, dict)`` -- a list (or ``None``, treated as ``[]``) is handled as legacy; a dict is handled as C4. Legacy is NOT rewritten into a C4 dict as a preprocessing pass -- it is applied by an equivalent single-level algorithm so that per-item malformed/unresolved handling stays IDENTICAL to the original NC/Phoneme code (including partial application before a mid-list raise -- see Notes). struct_guid: Optional ``str`` GUID of the source ``IFsFeatStruc``, used to preserve identity when a new struct must be created on ``owner``. ``None`` reproduces the historical Phoneme behaviour (bare ``factory.Create()``, no GUID preserved) via ``_CreateWithGuid(..., guid=None)``, which is defined to be exactly the old no-arg ``Create()``. on_unresolved: ``"raise"`` (default) or ``"skip"``. ``"raise"``: an unresolved feature/value/type GUID, or a malformed legacy list entry, raises ``FP_ParameterError`` naming the offending GUID/entry and ``label`` (contract C7 -- the NaturalClassOperations policy). ``"skip"``: the same conditions are silently skipped (the historical PhonemeOperations policy, D1). ``"skip"`` remains a fully supported, explicit opt-in -- it is just no caller's default after T9's flip. label: Human-readable description of ``owner`` used ONLY in raised error messages (e.g. ``"natural class 'Nasals'"``). Defaults to ``owner.ClassName`` when omitted. Returns: IFsFeatStruc: ``owner``'s (possibly newly-created) ``prop_name``-owned feature structure, cast to the concrete ``IFsFeatStruc`` interface. Raises: FP_ParameterError: If ``on_unresolved == "raise"`` and a feature, value, or (C4 only) feature-structure-type GUID does not resolve in the target project, or (legacy shape only) a list entry is malformed (not a dict, or missing ``FeatureGuid``/``ValueGuid``). If ``on_unresolved == "skip"``, all of the above are silently skipped instead. Notes: - Ownership-first (C5 invariant, at EVERY nesting level): a missing ``prop_name`` is created and attached to ``owner`` BEFORE its ``FeatureSpecsOC`` is populated or even read -- a free-floating ``IFsFeatStruc``'s own ``FeatureSpecsOC`` getter raises ``NullReferenceException`` (probe evidence). - Idempotency: existing ``(feature_guid, value_guid)`` pairs (legacy shape) / existing feature GUIDs (C4 shape, per level) are read ONCE before the loop and updated as each new spec is added, so re-applying the same ``ApplySyncableProperties`` call twice is a no-op the second time. This also fixes NC's original in-call duplicate-spec double-insert (the pre-T4 code re-scanned ``FeatureSpecsOC`` fresh each iteration; this method maintains one ``existing_*`` set/dict across the whole loop). - All guards (malformed entry, unresolved GUID) are evaluated OUTSIDE any ``_TransactionCM`` block, so a spec that fails to resolve never opens an empty named undo entry -- only the actual mutation (struct creation, spec insertion) is wrapped. - For the LEGACY list shape, items are applied ONE AT A TIME, in order, exactly like the original NC/Phoneme loops: a ``"raise"``-mode failure partway through the list leaves every ALREADY-PROCESSED prior item's mutation committed -- this method does not validate the whole list up front. This is a deliberate byte-for-byte behavioural match, not an oversight. - For the C4 dict shape, every read-back of a nested ``IFsComplexValue.ValueOA`` gets its own explicit ``IFsFeatStruc(...)`` cast (mirrors ``_GetFeatureStruc``); nested application is delegated to ``_ApplyFeatureStrucSpecMap`` (a private recursion helper, not part of the C5 frozen surface). - Transaction labels used by this method ("Create feature structure" / "Add feature value") are intentionally OWNER-AGNOSTIC -- the pre-T4 NC/Phoneme code used owner-specific undo-stack text ("Create natural class feature structure" / "Create phoneme feature structure"). No test locks that exact wording (only the RAISED ``FP_ParameterError`` messages are ever asserted on, and those still carry ``label``), so this is a cosmetic-only change to the FLEx Ctrl+Z menu entry text, not a functional behaviour change. - C4a's dual-shape support is exercised end-to-end for the legacy shape by the existing NC/Phoneme live tests (T4); the C4 dict shape (needed by future T6-T8 callers) has its own dedicated tests but is not yet driven by any NC/Phoneme call site -- ``_GetFeatureStruc`` always emits C4, but NC/Phoneme capture stays on the legacy list until T9b (C4b). """ if on_unresolved not in ("raise", "skip"): raise FP_ParameterError( f"_ApplyFeatureStruc: on_unresolved must be 'raise' or " f"'skip', got {on_unresolved!r}." ) if label is None: label = getattr(owner, "ClassName", None) or "object" from SIL.LCModel import ( IFsFeatStrucFactory, IFsClosedValueFactory, ) struct = getattr(owner, prop_name) if struct is None: factory = self.project.project.ServiceLocator.GetService( IFsFeatStrucFactory ) # Ownership-first: attach to prop_name before populating specs # (LCM accessors NPE on free-floating IFsFeatStruc objects). with self._TransactionCM("Create feature structure"): new_struct = self._CreateWithGuid( factory, guid=struct_guid, kind="feature structure", ) setattr(owner, prop_name, new_struct) struct = getattr(owner, prop_name) struct = self._CastFsFeatStruc(struct) if isinstance(spec_dict, dict): cv_factory = self.project.project.ServiceLocator.GetService( IFsClosedValueFactory ) self._ApplyFeatureStrucSpecMap( struct, spec_dict, on_unresolved, label, cv_factory, ) return struct # --- Legacy flat list of {"FeatureGuid", "ValueGuid"} dicts (C4a) --- specs = spec_dict or [] # Existing (feature, value) GUID pairs for idempotency. existing_pairs = set() for raw in struct.FeatureSpecsOC: try: cv = self._CastFsClosedValue(raw) if cv.FeatureRA is not None and cv.ValueRA is not None: existing_pairs.add( (str(cv.FeatureRA.Guid).lower(), str(cv.ValueRA.Guid).lower()) ) except Exception: continue cv_factory = self.project.project.ServiceLocator.GetService( IFsClosedValueFactory ) for spec in specs: if not isinstance(spec, dict): if on_unresolved == "raise": raise FP_ParameterError( f"ApplySyncableProperties: {label} Features entry " f"is not a dict: {spec!r}" ) continue feat_guid = spec.get("FeatureGuid") val_guid = spec.get("ValueGuid") if not feat_guid or not val_guid: if on_unresolved == "raise": raise FP_ParameterError( f"ApplySyncableProperties: {label} has a Features " f"spec missing FeatureGuid/ValueGuid: {spec!r}" ) continue if (feat_guid.lower(), val_guid.lower()) in existing_pairs: continue # already present (fill_gaps and normal both keep it) if on_unresolved == "raise": feat_obj = self._ResolveFsByGuid(feat_guid, kind="feature") if feat_obj is None: raise FP_ParameterError( f"ApplySyncableProperties: {label} references " f"feature GUID {feat_guid} which does not exist " f"in the target project. The feature system must " f"be synced before this item is rewired; " f"silently dropping this spec would leave the " f"target's feature structure incomplete with no " f"visible error." ) val_obj = self._ResolveFsByGuid(val_guid, kind="value") if val_obj is None: raise FP_ParameterError( f"ApplySyncableProperties: {label} references " f"value GUID {val_guid} (feature {feat_guid}) " f"which does not exist in the target project. " f"The feature system must be synced before this " f"item is rewired; silently dropping this spec " f"would leave the target's feature structure " f"incomplete with no visible error." ) else: feat_obj = self._ResolveFsByGuid(feat_guid, kind="feature") val_obj = self._ResolveFsByGuid(val_guid, kind="value") if feat_obj is None or val_obj is None: # Target feature system lacks this feature/value; skip. continue # Every guard above stays outside the transaction: a spec that # fails to resolve raises/continues before any transaction # opens, so no empty named undo entry is ever created. with self._TransactionCM("Add feature value"): closed_value = cv_factory.Create() struct.FeatureSpecsOC.Add(closed_value) cv = self._CastFsClosedValue(closed_value) cv.FeatureRA = feat_obj cv.ValueRA = val_obj existing_pairs.add((feat_guid.lower(), val_guid.lower())) return struct def _ApplyFeatureStrucSpecMap(self, struct, spec, on_unresolved, label, cv_factory): """ Apply a single C4-dict LEVEL onto an already-attached ``IFsFeatStruc`` ``struct``, recursing into any nested ``IFsComplexValue.ValueOA`` level. Private recursion helper for ``_ApplyFeatureStruc``'s C4 dict-shape branch (C4a) -- not part of the C5 frozen surface. Mirrors ``_GetFeatureStruc``'s traversal in reverse: a scalar ``specs`` value becomes an ``IFsClosedValue``; a nested-dict value becomes an ``IFsComplexValue`` whose ``ValueOA`` is populated by recursing. Args: struct: The ``IFsFeatStruc`` at THIS level (already attached to its owner -- ownership-first, per C5). spec: A C4-shaped dict for THIS level: ``{"TypeGuid": ..., "specs": {featGuid: valGuid | {...nested...}}}``. on_unresolved: ``"raise"`` | ``"skip"`` -- see ``_ApplyFeatureStruc``. label: Human-readable description used in raised messages. cv_factory: An ``IFsClosedValueFactory`` service instance, threaded through from the top-level call so every recursion level shares one factory lookup. Raises: FP_ParameterError: Mirrors ``_ApplyFeatureStruc``'s Raises section, for an unresolved feature/value/type GUID when ``on_unresolved == "raise"``. """ from SIL.LCModel import ( IFsComplexValueFactory, IFsFeatStrucFactory, ) type_guid = spec.get("TypeGuid") if type_guid: type_obj = self._ResolveFsByGuid( type_guid, kind="feature structure type" ) if type_obj is None: if on_unresolved == "raise": raise FP_ParameterError( f"ApplySyncableProperties: {label} references " f"feature structure type GUID {type_guid} which " f"does not exist in the target project." ) else: with self._TransactionCM("Set feature structure type"): struct.TypeRA = type_obj # Existing closed-value (feature -> IFsClosedValue) and complex-value # (feature -> IFsComplexValue) entries, keyed by lower-cased feature # GUID, for idempotency -- read ONCE before the loop (mirrors the # legacy-shape existing_pairs set in _ApplyFeatureStruc). existing_closed = {} existing_complex = {} for raw in struct.FeatureSpecsOC: spec_class_name = raw.ClassName if spec_class_name == "FsClosedValue": cv = self._CastFsClosedValue(raw) if cv.FeatureRA is not None and cv.ValueRA is not None: existing_closed[str(cv.FeatureRA.Guid).lower()] = cv elif spec_class_name == "FsComplexValue": cx = self._CastFsComplexValue(raw) if cx.FeatureRA is not None: existing_complex[str(cx.FeatureRA.Guid).lower()] = cx for feat_guid, value in (spec.get("specs") or {}).items(): feat_obj = self._ResolveFsByGuid(feat_guid, kind="feature") if feat_obj is None: if on_unresolved == "raise": raise FP_ParameterError( f"ApplySyncableProperties: {label} references " f"feature GUID {feat_guid} which does not exist " f"in the target project. The feature system must " f"be synced before this item is rewired; " f"silently dropping this spec would leave the " f"target's feature structure incomplete with no " f"visible error." ) continue if isinstance(value, dict): # Complex (nested) feature spec -> IFsComplexValue.ValueOA. existing_cx = existing_complex.get(feat_guid.lower()) if existing_cx is not None: nested_struct = self._CastFsFeatStruc(existing_cx.ValueOA) else: cx_factory = self.project.project.ServiceLocator.GetService( IFsComplexValueFactory ) fs_factory = self.project.project.ServiceLocator.GetService( IFsFeatStrucFactory ) nested_guid = value.get("Guid") with self._TransactionCM("Add feature value"): complex_value = cx_factory.Create() struct.FeatureSpecsOC.Add(complex_value) cx = self._CastFsComplexValue(complex_value) cx.FeatureRA = feat_obj nested = self._CreateWithGuid( fs_factory, guid=nested_guid, kind="feature structure", ) cx.ValueOA = nested existing_complex[feat_guid.lower()] = cx nested_struct = self._CastFsFeatStruc(cx.ValueOA) self._ApplyFeatureStrucSpecMap( nested_struct, value, on_unresolved, label, cv_factory, ) continue # Scalar (closed) feature spec. val_guid = value if feat_guid.lower() in existing_closed: continue # already present val_obj = self._ResolveFsByGuid(val_guid, kind="value") if val_obj is None: if on_unresolved == "raise": raise FP_ParameterError( f"ApplySyncableProperties: {label} references " f"value GUID {val_guid} (feature {feat_guid}) " f"which does not exist in the target project. " f"The feature system must be synced before this " f"item is rewired; silently dropping this spec " f"would leave the target's feature structure " f"incomplete with no visible error." ) continue with self._TransactionCM("Add feature value"): closed_value = cv_factory.Create() struct.FeatureSpecsOC.Add(closed_value) cv = self._CastFsClosedValue(closed_value) cv.FeatureRA = feat_obj cv.ValueRA = val_obj existing_closed[feat_guid.lower()] = cv def _MakeFeatStruc(self, specs, owner=None, slot=None): """ Build (and attach) an ``IFsFeatStruc`` from user-facing specs. Spec ``feature-structure-sync-gap``, contract C3 (FROZEN). Single generalized implementation of what used to be two byte-identical-except-for-the-owner-check bodies -- ``InflectionFeatureOperations.MakeFeatStruc`` (``:970``) and ``PhonFeatureOperations.MakeFeatStruc`` (``:553``) are now thin call-throughs to this method (T5). Closes issue **#256**. **#256 was NOT a casting bug.** Both pre-T5 bodies gated with ``if not hasattr(owner_unwrapped, "FeaturesOA"): raise`` -- which is coincidentally correct ONLY for ``IPhNCFeatures``/ ``IPhPhoneme`` (the two owners that happen to be named ``FeaturesOA``) and unconditionally wrong -- 100% dead-code-as-a- fix -- for every other owner (MSA x4, POS x2, Allomorph), which own their struct through a DIFFERENTLY NAMED property (``MsFeaturesOA``, ``InflFeatsOA``, ``FromMsFeaturesOA``, ...). This method replaces that ``hasattr`` gate with T2's ``ClassName``-driven ``_ResolveFeatureStrucOwner`` (``Shared/lcm_constants.py::FEATURE_STRUC_OWNER_TABLE``, C1) -- never guessing, never gating on ``hasattr`` for a subtype- declared member (spec D5). Args: specs: EITHER of two accepted shapes (both resolved via the SAME per-operand resolution the two pre-T5 bodies used -- an HVO ``int``, an already-resolved LCM object/wrapper, or (new, additive) a GUID ``str``; see Notes): (a) RECURSIVE DICT (canonical, C3):: {"noun agreement": {"class": "1", "number": "sg"}, "polarity": "positive"} A dict VALUE -> ``IFsComplexValue`` whose ``ValueOA`` is a nested ``IFsFeatStruc`` built by recursing. A SCALAR value -> ``IFsClosedValue``. No depth limit. (b) FLAT LIST OF ``(feature, value)`` TUPLES (legacy, SUPPORTED INDEFINITELY -- 5 internal call sites + 4 shipped test files pass this shape; see spec.md C3/C4a). Exactly equivalent to a one-level dict. The REJECTED ``("feat", [(f, v), ...])`` tuple-nesting overload (a silent ``isinstance`` branch capped at one extra level) is deliberately NOT special-cased here -- a list-shaped tuple VALUE falls through to the scalar branch and fails loudly at the LCM property-set call, which is the correct outcome for an overload this contract explicitly rejects. owner: LCM object (or wrapper) that owns the struct. **Required -- ``owner=None`` always raises** (issue #28 ruling, unchanged by T5): LCM property accessors NPE on free-floating ``IFsFeatStruc`` objects, so an unowned- empty mode would return an unusable struct. slot: Disambiguates owners with more than one feature- structure-owning property (``MoDerivAffMsa``: ``"From"``/``"To"``; ``PartOfSpeech``: ``"Default"``/ ``"InherFeatVal"``). Ignored -- not an error -- for a single-property owner, even if supplied. See C1 / ``_ResolveFeatureStrucOwner``. Returns: IFsFeatStruc: The populated feature structure, attached to ``owner``'s C1-resolved owning property. Raises: FP_ParameterError: If ``owner`` is ``None``; if a spec entry is malformed (legacy shape: not a 2-tuple); if ``owner``'s ``ClassName`` is not a recognized feature-structure owner, or is ambiguous and ``slot`` is missing/invalid (see ``_ResolveFeatureStrucOwner``); or if a GUID-string operand is malformed. Notes: - Per-operand resolution mirrors the two pre-T5 bodies EXACTLY for ``int`` (HVO) and already-resolved LCM object/wrapper inputs -- zero behaviour change for any input that worked before. GUID ``str`` support is ADDITIVE: neither pre-T5 body special-cased a string, so one used to fall through unresolved and fail at the LCM property-set call; routing it through ``project.Object()`` is a strict improvement with no back-compat risk. Plain feature/value NAME strings (issue #265) are also accepted: non-GUID strings resolve via the owner-driven morphological or phonological feature system using the same analysis-WS casefold rules as ``InflectionFeatures.Find`` / ``PhonFeatures.Find``, raising on ambiguity rather than guessing. - Ownership-first (C5 invariant) at EVERY nesting level: the struct/``ValueOA`` is attached to its owner BEFORE its ``FeatureSpecsOC`` is populated or even read -- a free- floating ``IFsFeatStruc``'s own getter raises ``NullReferenceException`` (probe evidence). - Every spec is normalized (resolved, malformed-shape checked) BEFORE the owner is resolved or any transaction is opened -- mirrors both pre-T5 bodies' up-front validation, so a malformed spec never leaves a partially-attached struct behind. - This is the C3 user-facing COMPOSE surface, resolving named/object/HVO/GUID operands directly against LCM -- a DIFFERENT recursion from the C4/C5 sync APPLY surface (``_ApplyFeatureStruc``/``_ApplyFeatureStrucSpecMap``, T3/T4), which resolves GUID-only wire-format specs against a target project's feature system. The two are not merged: they solve different problems (compose-from-friendly- specs vs. apply-a-serialized-wire-spec) and merging them would mean round-tripping every already-resolved object through a GUID for no reason. """ self._EnsureWriteEnabled() self._ValidateParam(specs, "specs") if owner is None: raise FP_ParameterError( "MakeFeatStruc requires an owner. LCM property " "accessors NPE on free-floating IFsFeatStruc objects, " "so the previous unowned-empty mode produced an " "unusable struct (issue #28). Pass owner=phoneme / " "owner=natural_class / owner=msa / owner=pos / " "owner=allomorph / owner=context, plus slot= when the " "owner has more than one feature-structure-owning " "property (MoDerivAffMsa: 'From'/'To'; PartOfSpeech: " "'Default'/'InherFeatVal') -- see " "_ResolveFeatureStrucOwner." ) domain = self._FeatStrucOperandDomain(owner) # Normalize (resolve + validate shape) ALL specs, recursively, # BEFORE touching owner or opening a transaction -- mirrors both # pre-T5 bodies' up-front normalization pass. normalized = self.__NormalizeFeatStrucLevel(specs, domain=domain) # THE #256 FIX: resolve owner via the C1 table instead of the # `hasattr(owner, "FeaturesOA")` gate. Raises FP_ParameterError # for an unrecognized/ambiguous-without-slot ClassName; raises # TypeError (uncaught, by design -- C1 step 5) if the concrete # cast itself fails. concrete_owner, prop_name = self._ResolveFeatureStrucOwner( owner, slot=slot ) from SIL.LCModel import IFsFeatStrucFactory factory = self.project.project.ServiceLocator.GetService( IFsFeatStrucFactory ) with self._TransactionCM("Make feature structure"): struct = factory.Create() setattr(concrete_owner, prop_name, struct) # Re-fetch via the owning property to hold the LCM view of # the now-owned struct (ownership-first, C5). struct = self._CastFsFeatStruc( getattr(concrete_owner, prop_name) ) self.__PopulateFeatStrucLevel(struct, normalized) return struct def __NormalizeFeatStrucLevel(self, level_specs, domain=None): """ Recursively resolve ONE level of ``_MakeFeatStruc`` specs (either shape from C3) into a list of ``(resolved_feat, resolved_val)`` pairs, where ``resolved_val`` is itself such a list for a nested (dict) value, or a resolved scalar LCM object/operand otherwise. Pure resolution -- ``project.Object()`` lookups only, no factory calls, no LCM mutation -- so it is safe to run to completion, and raise on a malformed shape, before any transaction opens. """ if isinstance(level_specs, dict): raw_pairs = list(level_specs.items()) else: raw_pairs = [] for i, pair in enumerate(level_specs): if not isinstance(pair, (list, tuple)) or len(pair) != 2: raise FP_ParameterError( f"specs[{i}] must be a (feature, value) tuple" ) raw_pairs.append(tuple(pair)) normalized = [] for feat_raw, val_raw in raw_pairs: feat = self.__ResolveFeatStrucOperand( feat_raw, domain=domain, operand_role="feature" ) if isinstance(val_raw, dict): nested = self.__NormalizeFeatStrucLevel(val_raw, domain=domain) normalized.append((feat, nested)) else: val = self.__ResolveFeatStrucOperand( val_raw, domain=domain, operand_role="value", value_feature=feat, ) normalized.append((feat, val)) return normalized def __PopulateFeatStrucLevel(self, struct, normalized_pairs, _factories=None): """ Populate an ALREADY-ATTACHED ``IFsFeatStruc`` ``struct`` from a level of ``(resolved_feat, resolved_val)`` pairs produced by ``__NormalizeFeatStrucLevel``, recursing into nested-list values (a nested value is always a ``list`` -- ``__NormalizeFeatStrucLevel`` only ever produces a ``list`` for a dict-shaped spec value; any other value, including a malformed raw ``list``, is treated as a scalar operand and left to fail naturally at the LCM property-set call). Ownership-first (C5) at every level: the nested struct is attached to its owning ``IFsComplexValue.ValueOA`` BEFORE this method recurses into it. """ if _factories is None: from SIL.LCModel import ( IFsClosedValueFactory, IFsComplexValueFactory, IFsFeatStrucFactory, ) _factories = ( self.project.project.ServiceLocator.GetService( IFsClosedValueFactory ), self.project.project.ServiceLocator.GetService( IFsComplexValueFactory ), self.project.project.ServiceLocator.GetService( IFsFeatStrucFactory ), ) cv_factory, cx_factory, fs_factory = _factories for feat, val in normalized_pairs: if isinstance(val, list): # Nested (complex) feature spec -> IFsComplexValue.ValueOA. # Own transaction per mutation (matches # _ApplyFeatureStrucSpecMap's "Add feature value" label for # the identical complex-value-creation shape, and satisfies # the B2g unbracketed-mutation ratchet, which scans each # method's OWN body for a lexically-enclosing # `with self._TransactionCM(...)` -- an outer caller's # transaction does not count). with self._TransactionCM("Add feature value"): complex_value = cx_factory.Create() struct.FeatureSpecsOC.Add(complex_value) cx = self._CastFsComplexValue(complex_value) cx.FeatureRA = feat nested_raw = fs_factory.Create() cx.ValueOA = nested_raw nested = self._CastFsFeatStruc(cx.ValueOA) self.__PopulateFeatStrucLevel(nested, val, _factories) else: with self._TransactionCM("Add feature value"): closed_value = cv_factory.Create() struct.FeatureSpecsOC.Add(closed_value) cv = self._CastFsClosedValue(closed_value) cv.FeatureRA = feat cv.ValueRA = val def __ResolveFeatStrucOperand( self, raw, domain=None, operand_role=None, value_feature=None ): """ Resolve one ``_MakeFeatStruc`` feature/value operand: an HVO (``int``), a GUID (``str``), a plain feature/value name (``str``, issue #265), or an already-resolved LCM object/wrapper. Returns: Polymorphic LCM object for the operand. HVO and GUID paths use ``project.Object(...)`` and return the bare pythonnet view (often ``ICmObject``); this helper intentionally does **not** cast -- downstream ``_MakeFeatStruc`` logic accepts several concrete operand types and must not narrow here (issue #494). """ if isinstance(raw, int) and not isinstance(raw, bool): return self.project.Object(raw) if isinstance(raw, str): if self._IsWellFormedGuidString(raw): return self.project.Object(raw) if domain is None or operand_role is None: raise FP_ParameterError( "MakeFeatStruc name operands require an owner so the " "correct feature system can be chosen (issue #265)." ) if operand_role == "feature": return self._ResolveFeatStrucFeatureName(raw, domain) if value_feature is None: raise FP_ParameterError( "MakeFeatStruc value name operands require a resolved " "feature for the same (feature, value) pair." ) return self._ResolveFeatStrucValueName( raw, domain, value_feature ) # Peel off LCMObjectWrapper-style wrappers -- mirrors # InflectionFeatureOperations.__Unwrap / PhonFeatureOperations # .__Unwrap. if hasattr(raw, "_obj") and not hasattr(raw, "Hvo"): return raw._obj if hasattr(raw, "_obj") and hasattr(raw._obj, "Hvo"): return raw._obj return raw @staticmethod def _IsWellFormedGuidString(text): try: uuid.UUID(str(text)) return True except (ValueError, AttributeError, TypeError): return False def _FeatStrucOperandDomain(self, owner): """ Morphological vs phonological feature-system domain for name operands, derived from ``owner.ClassName`` (issue #265 ruling). """ unwrapped = owner if hasattr(unwrapped, "_obj") and hasattr(unwrapped._obj, "ClassName"): unwrapped = unwrapped._obj elif hasattr(unwrapped, "_obj") and not hasattr(unwrapped, "ClassName"): unwrapped = unwrapped._obj class_name = getattr(unwrapped, "ClassName", None) if class_name in ("PhPhoneme", "PhNCFeatures"): return "phon" return "ms" def _ResolveFeatStrucFeatureName(self, name, domain): matches = self._FindFeaturesByName(name, domain) if not matches: system_label = ( "phonological" if domain == "phon" else "morphological" ) raise FP_ParameterError( f"MakeFeatStruc: no {system_label} feature named {name!r} " f"(analysis writing system, case-insensitive)." ) if len(matches) > 1: raise FP_ParameterError( f"MakeFeatStruc: feature name {name!r} is ambiguous " f"({len(matches)} matches in the " f"{'phonological' if domain == 'phon' else 'morphological'} " f"feature system)." ) return matches[0] def _ResolveFeatStrucValueName(self, name, domain, feature): matches = self._FindFeatureValuesByName(name, domain, feature) if not matches: raise FP_ParameterError( f"MakeFeatStruc: no value named {name!r} on feature " f"(analysis writing system, case-insensitive)." ) if len(matches) > 1: raise FP_ParameterError( f"MakeFeatStruc: value name {name!r} is ambiguous on the " f"given feature ({len(matches)} matches)." ) return matches[0] def _FindFeaturesByName(self, name, domain): from SIL.LCModel import IFsClosedFeature, IFsFeatDefn from SIL.LCModel.Core.KernelInterfaces import ITsString from flexicon.code.Shared.string_utils import normalize_match_key target = normalize_match_key(name, casefold=True) if not target: raise FP_ParameterError("Feature name cannot be empty") ws = self.project.project.DefaultAnalWs if domain == "phon": feature_system = self.project.lp.PhFeatureSystemOA cast = IFsClosedFeature else: feature_system = self.project.lp.MsFeatureSystemOA cast = IFsFeatDefn if feature_system is None: return [] matches = [] for raw in feature_system.FeaturesOC: feat = cast(raw) feat_name = ITsString(feat.Name.get_String(ws)).Text if ( feat_name and normalize_match_key(feat_name, casefold=True) == target ): matches.append(feat) return matches def _FindFeatureValuesByName(self, name, domain, feature): from SIL.LCModel.Core.KernelInterfaces import ITsString from flexicon.code.Shared.string_utils import normalize_match_key target = normalize_match_key(name, casefold=True) if not target: raise FP_ParameterError("Feature value name cannot be empty") ws = self.project.project.DefaultAnalWs if domain == "phon": values = list(self.project.PhonFeatures.GetValues(feature)) else: values = self.project.InflectionFeatures.FeatureGetValues(feature) matches = [] for val in values: if not hasattr(val, "Name"): continue val_name = ITsString(val.Name.get_String(ws)).Text if ( val_name and normalize_match_key(val_name, casefold=True) == target ): matches.append(val) return matches def _RejectLegacyKwargs(self, kwargs, legacy_to_new): """ Trap unexpected legacy keyword arguments with a clear, actionable TypeError pointing at the rename + migration guide. Used by methods that renamed parameters in d423e83 (v2.4 -> v2.5 breaking change: ``flat=`` -> ``recursive=``, ``include_subcategories=`` -> ``recursive=``). Per CLAUDE.md, the repo does not ship compat shims, so the rename remains a hard break -- but a bare ``TypeError: unexpected keyword 'flat'`` offers no breadcrumb to the new name. This helper raises a TypeError that names the new parameter and points at the migration guide. (issue #104) Args: kwargs: The caller's ``**kwargs`` dict (after the named parameters have been bound). legacy_to_new: Mapping of legacy kwarg name to a ``(new_kwarg_name, semantics_note)`` tuple, e.g. ``{"flat": ("recursive", "semantics inverted: " "flat=True is now recursive=False")}``. The semantics note is appended to the error message to flag any non-name-only changes. Raises: TypeError: When ``kwargs`` contains a legacy name OR any other unrecognized kwarg (so typos in the new name surface as errors too). Example: >>> def GetAll(self, recursive=True, **kwargs): ... self._RejectLegacyKwargs(kwargs, { ... "flat": ("recursive", ... "semantics inverted: flat=True -> " ... "recursive=False"), ... }) ... ... """ for legacy_name, (new_name, semantics_note) in legacy_to_new.items(): if legacy_name in kwargs: raise TypeError( f"{legacy_name!r} was renamed to {new_name!r} in v2.5 " f"({semantics_note}). See docs/MIGRATION_GUIDE.md." ) if kwargs: unknown = sorted(kwargs) raise TypeError( f"unexpected keyword argument(s) {unknown!r}" ) def _FindCommonSequence(self, item1, item2): """ Find the sequence that contains both items. Used by MoveBefore, MoveAfter, and Swap to automatically determine which sequence contains both items. Examines the Owner property and searches for OS properties containing both items. Args: item1: First item (object, not HVO). item2: Second item (object, not HVO). Returns: ILcmOwningSequence: The sequence containing both items. Raises: ValueError: If items don't have same owner. ValueError: If items not found in same sequence. ValueError: If no OS property contains both items. Example: >>> # Both senses from same entry >>> sense1 = entry.SensesOS[0] >>> sense2 = entry.SensesOS[2] >>> sequence = self._FindCommonSequence(sense1, sense2) >>> assert sequence is entry.SensesOS >>> # Both examples from same sense >>> ex1 = sense.ExamplesOS[0] >>> ex2 = sense.ExamplesOS[1] >>> sequence = self._FindCommonSequence(ex1, ex2) >>> assert sequence is sense.ExamplesOS Notes: - Checks Owner property first (most efficient) - Scans all properties ending in 'OS' (owning sequences) - Returns first sequence containing both items - Used internally by MoveBefore, MoveAfter, Swap Algorithm: 1. Check if items have same Owner 2. Search owner's properties for name ending in 'OS' 3. Check if both items present in sequence 4. Return first matching sequence See Also: MoveBefore, MoveAfter, Swap """ item1 = self._UnwrapLcm(item1) item2 = self._UnwrapLcm(item2) # Verify items have Owner property if not hasattr(item1, "Owner") or not hasattr(item2, "Owner"): raise ValueError("Items must have Owner property to find common sequence") # Check if items have same owner if item1.Owner != item2.Owner: raise ValueError( "Items not in same sequence (different owners). " "MoveBefore/MoveAfter/Swap require items in same sequence." ) # Items must also have the same OwningFlid (field ID) to be in same sequence if item1.OwningFlid != item2.OwningFlid: raise ValueError( "Items not in same sequence (different OwningFlid). " "MoveBefore/MoveAfter/Swap require items in same sequence." ) # Get the parent object # Note: item1.Owner returns ICmObject, which doesn't expose properties like SensesOS # We need to use reflection to access properties parent = self._GetObject(item1.Owner.Hvo) # Use reflection to find the sequence property that contains both items # Iterate through all properties ending in 'OS' (Owning Sequence) parent_type = parent.GetType() for prop_info in parent_type.GetProperties(): if prop_info.Name.endswith(OWNING_SEQUENCE_SUFFIX): try: sequence = prop_info.GetValue(parent, None) if sequence is None or not hasattr(sequence, "Count"): continue # Check if both items are in this sequence # Note: sequence from reflection may not support indexing, use iteration found1 = False found2 = False for item in sequence: if item == item1: found1 = True if item == item2: found2 = True if found1 and found2: return sequence except Exception: # Property might not be accessible or not a sequence continue # Items have same owner but not found in any OS property raise ValueError( "Items not in same sequence or sequence not found. " "Both items must be in the same owning sequence (OS)." ) # ========== VALIDATION METHODS ========== def _EnsureWriteEnabled(self) -> None: """ Verify that the project is open in write mode. This method checks that the FLExProject is properly opened with write capabilities. All modification operations should call this method before making any changes. Args: None Returns: None Raises: FP_ReadOnlyError: If project is not writable (opened read-only or closed). Example: >>> def CreateNewSense(self, entry): ... self._EnsureWriteEnabled() # Check before modification ... sense = entry.SensesOS.Create() ... return sense Notes: - Should be called at the start of any modification method - Provides early failure with clear error message - Prevents partial modifications on closed/read-only projects - Lightweight check (just checks project.writeEnabled property) Implementation Notes: - Checks project.writeEnabled property - Raises FP_ReadOnlyError with descriptive message - No side effects """ if not self.project.writeEnabled: raise FP_ReadOnlyError() def _TransactionCM(self, label): """ Return a transaction context manager appropriate to the project mode. Intended for methods that perform **two or more distinct LCM mutations** (e.g. ``factory.Create()``, ``OS.Add()``, and one or more property writes). Single-mutation methods (one ``OS.Add`` or one property set) do not need this wrapper -- the LCM write is already atomic. Auto-selects Phase 2 (``UndoableOperation``, visible in the FLEx Ctrl+Z menu) when the project was opened with ``undoable=True``, otherwise Phase 1 (``Transaction``). Use this to wrap the body of any write method that performs two or more LCM mutations, so a failure partway through is at least labelled and grouped -- see the Notes below for what this wrapper does and does NOT protect against in each mode; today, neither mode auto-rolls-back a partial write. Wrap only the mutation portion of a method: call validation helpers (``_EnsureWriteEnabled``, ``_Validate*``) and any lookups that may raise BEFORE entering this context, so input errors never mark the undo stack. Keep the method's ``return`` inside the ``with`` block. Args: label (str): Human-readable description used for logging and, in Phase 2, the FLEx undo menu (e.g. "Create entry 'famba'"). Returns: A ``_NestingAwareTransaction`` context manager that delegates to ``_FLExTransaction`` (Phase 1) or ``_FLExUndoableOperation`` (Phase 2), or becomes a no-op when nested inside another ``_TransactionCM`` block in Phase 2 (see Notes). Example:: def Create(self, form, ws=None): self._EnsureWriteEnabled() self._ValidateStringNotEmpty(form, "form") with self._TransactionCM(f"Create entry '{form}'"): entry = entry_factory.Create() entry.LexemeFormOA = allomorph_factory.Create() entry.LexemeFormOA.Form.set_String(ws_handle, tss) return entry Notes: - Phase 1 (``Transaction``) does NOT roll back on exception in the current build. liblcm exposes no reachable rollback-to-mark API in this mode (issue #236; see ``specs/write-path-transactions/spec.md`` D1 and ``FLExProject.Transaction()``'s docstring for the specific API name checked). ``_TransactionCM`` in Phase 1 is a labelling and nesting construct only; the atomicity unit is the whole session, not this block. See ``docs/EXCEPTION_HANDLING.md``. - Phase 2 (``UndoableOperation``) IS rollback-capable (see B1, `specs/write-path-transactions/spec.md`): ``_TransactionCM`` constructs liblcm's own ``UndoableUnitOfWorkHelper`` directly for the outermost block. An exception raised inside the block rolls back every mutation that block made, in addition to the partial work remaining undoable by the FLEx user via Ctrl+Z up until the point of rollback. - ``_undoable`` is only ever True when the project is also write-enabled, so Phase 2 selection cannot collide with the read-only guard already enforced by ``_EnsureWriteEnabled``. - Nesting differs by phase: * Phase 1 (``Transaction``) nests without error. Each ``with`` block enters and exits cleanly, but since neither the inner nor the outer block can roll back, "nesting" here means only that labels compose -- there is no independent rollback point to speak of at any depth. * Phase 2 (``UndoableOperation``) does NOT nest at the LCM level: ``BeginUndoTask``/``EndUndoTask`` cannot be nested without corrupting the undo stack. ``_TransactionCM`` guards against this automatically by asking LCM's own ``cache.ActionHandlerAccessor.CurrentDepth`` at every ``__enter__`` -- never by tracking depth itself in Python (that hand-rolled counter was issue #234, and is gone). Only the OUTERMOST block (``CurrentDepth == 0``) opens a new ``UndoableUnitOfWorkHelper``; any ``_TransactionCM`` entered while one is already active (``CurrentDepth > 0``) becomes a no-op and lets the outer task group all of its mutations into the single named undo entry (the desired Phase 2 behavior). This means an inner method's mutations are not separately undoable (or separately rolled back) in Phase 2 -- they are absorbed into the enclosing operation's undo task, and its rollback decision covers them too. """ from .transaction import _NestingAwareTransaction return _NestingAwareTransaction(self.project, label) def _CreateWithGuid(self, factory, guid=None, kind=None): """ Create an object through an LCM factory, preserving a caller-supplied GUID. Nearly every LibLCM factory exposes a ``Create(Guid)`` overload alongside the no-arg ``Create()``. Callers that are REPRODUCING an object from another project (a transfer, a merge, a round-trip) must be able to keep the original identity: a target object carrying the source GUID is recognisably the same object on a later run, which is what makes deduplication and re-linking possible at all. Without it, downstream tools are forced to invent structural fingerprints to recover identity that was needlessly discarded. When ``guid`` is None this is exactly the old no-arg ``Create()``, so existing callers are unaffected. Args: factory: An LCM factory instance exposing ``Create()`` and, optionally, ``Create(Guid)``. guid: Optional. The GUID to assign, as a ``System.Guid`` or a string (braced or unbraced). None (the default) mints a fresh GUID. kind (str, optional): Descriptive name used in the warning logged when a requested GUID could not be honoured. Returns: The newly created object. Raises: FP_ParameterError: If ``guid`` was supplied but is not a valid GUID. Note: If the GUID is already present in the project, LCM raises and this falls back to a fresh identity, logging a warning that names the GUID. Callers that must not silently lose identity should check for an existing object first (see the ``Find``/``Get*`` helpers). Example: >>> # Reproduce a paragraph from another project under its own GUID >>> para = project.Paragraphs.Create(text, "In the beginning...", ... guid=src_para.Guid) >>> str(para.Guid) == str(src_para.Guid) True See Also: _ValidateParam """ import logging label = f"Create {kind}" if kind else "Create object" if guid is None: with self._TransactionCM(label): return factory.Create() # GUID parsing happens BEFORE the transaction: a malformed GUID is a # caller error and must raise without opening an undo task (decision # D5's validate-then-mutate discipline). guid_arg = guid if isinstance(guid, str): from System import Guid as _DotNetGuid try: guid_arg = _DotNetGuid.Parse(guid) except Exception as exc: raise FP_ParameterError( "guid %r is not a valid GUID" % (guid,)) from exc # The attempt-then-fallback pair is ONE transaction. The failed # Create(Guid) is caught here rather than escaping, so the block still # exits cleanly and commits the fallback object -- the identity-loss # warning, not a rollback, is the documented outcome. with self._TransactionCM(label): try: return factory.Create(guid_arg) except Exception as exc: logging.getLogger("flexicon").warning( "%s: Create(Guid=%s) failed (%s: %s); falling back to a new " "identity. The requested GUID was NOT preserved.", kind or type(factory).__name__, guid, type(exc).__name__, exc) return factory.Create() @staticmethod def _UnwrapLcmObject(obj): """ Peel LCMObjectWrapper-style wrappers before pythonnet interface casts. Plain LCM objects pass through unchanged. Shared resolvers call this so items from wrapper-returning ``GetAll()`` round-trip back into Operations methods (issue #449). """ if obj is None: return obj if hasattr(obj, "lcm_object"): return obj.lcm_object if hasattr(obj, "_obj") and not hasattr(obj, "Hvo"): return obj._obj if hasattr(obj, "_obj") and hasattr(obj._obj, "Hvo"): return obj._obj return obj def _ValidateParam(self, param: Any, param_name: str = "parameter") -> None: """ Validate that a parameter is not None and not a stale LCM object. This method performs a null check on a required parameter. Use this for any parameter that must be provided and non-None. When the parameter is a live LCM object reference, it is additionally rejected if it refers to a deleted/invalid object (``IsValidObject`` is False). Args: param: The parameter value to validate (any type). param_name: Optional. Descriptive name for error messages. Default: "parameter". Returns: None Raises: FP_NullParameterError: If param is None. FP_ParameterError: If param is an LCM object that has been deleted or otherwise invalidated (``IsValidObject`` is False). Example: >>> def SetGloss(self, sense, gloss): ... self._ValidateParam(sense, "sense") ... self._ValidateParam(gloss, "gloss") ... sense.Gloss.BestAnalysisAlternative.Text = gloss >>> def MoveItem(self, parent, item): ... self._ValidateParam(parent, "parent") ... self._ValidateParam(item, "item") ... # Perform move operation Notes: - Lightweight null check - Works with any type (objects, primitives, etc.) - Provides helpful error message with parameter name - Use _ValidateInstanceOf for type checking - Use _ValidateParamNotEmpty for string empty checks Implementation Notes: - Simple if param is None check - Stale-reference guard: a cascade-deleted LCM object keeps a live .NET reference but its Cache/Services pointers are torn down, so touching any property raises a NullReferenceException from deep inside LCM. ICmObject exposes IsValidObject (False once deleted); mirrors LCM's own guard pattern (OverridesLing_Lex.cs:1500). - getattr(..., None) means non-LCM params (str/int/dict/etc.) have no IsValidObject attribute -> None -> skipped. Only an explicit False triggers the error, so a True value is never mistaken. - No side effects - Exception message includes parameter name """ if param is None: raise FP_NullParameterError() # Reject stale LCM object references (cascade-deleted / invalidated). # Non-LCM params return None here and are left untouched. if getattr(param, "IsValidObject", None) is False: raise FP_ParameterError( f"{param_name} refers to a deleted or invalid LCM object" ) def _ValidateParamNotEmpty(self, param: Any, param_name: str = "parameter") -> None: """ Validate that a parameter is not None and not empty. This method performs both a null check and an emptiness check. Use this for parameters like lists, strings, or collections that must have content. Args: param: The parameter to validate (list, string, dict, or object with __len__ method). param_name: Optional. Descriptive name for error messages. Default: "parameter". Returns: None Raises: Exception: If param is None. Exception: If param is empty (len() == 0). Example: >>> def CreateEntries(self, entry_list): ... self._ValidateParamNotEmpty(entry_list, "entry_list") ... for entry in entry_list: ... # Process each entry >>> def SortItems(self, items): ... self._ValidateParamNotEmpty(items, "items") ... return self.Sort(parent, key_func=lambda i: str(i)) >>> def SetGloss(self, gloss): ... self._ValidateParamNotEmpty(gloss, "gloss text") ... sense.Gloss.BestAnalysisAlternative.Text = gloss Notes: - Checks for both None and empty conditions - Works with any object supporting len() - Provides helpful error messages - Use _ValidateParam for None-only checks - Use _ValidateStringNotEmpty for string-specific validation Implementation Notes: - First checks if param is None - Then checks if len(param) == 0 - No side effects """ if param is None: raise FP_NullParameterError() if len(param) == 0: raise FP_ParameterError(f"{param_name} cannot be empty") def _ValidateInstanceOf(self, obj: Any, expected_type: type, param_name: str = "object") -> None: """ Validate that an object is an instance of expected type. This method performs strict type checking. Use this to ensure parameters are the correct type before attempting operations that depend on specific properties or methods. Args: obj: The object to validate. expected_type: The expected type or tuple of types. Examples: str, int, ILexEntry, (str, int) param_name: Optional. Descriptive name for error messages. Default: "object". Returns: None Raises: TypeError: If obj is not instance of expected_type. Example: >>> def UpdateSense(self, sense): ... self._ValidateInstanceOf(sense, ILexSense, "sense") ... sense.Gloss.BestAnalysisAlternative.Text = "new gloss" >>> def ProcessEntries(self, entries): ... self._ValidateInstanceOf(entries, list, "entries") ... for entry in entries: ... self._ValidateInstanceOf(entry, ILexEntry, "entry") >>> def FindSenseByGloss(self, entry, gloss): ... self._ValidateInstanceOf(entry, ILexEntry, "entry") ... self._ValidateInstanceOf(gloss, (str, type(None)), "gloss") Notes: - Performs isinstance() check - Supports single type or tuple of types - Helpful error message with actual and expected types - Use _ValidateParam for None checks - Use _ValidateStringNotEmpty for string-specific validation Implementation Notes: - Uses isinstance() internally - Raises TypeError (not generic Exception) - Gets type names for helpful error messages - No side effects """ if not isinstance(obj, expected_type): if isinstance(expected_type, tuple): type_names = " or ".join(t.__name__ for t in expected_type) else: type_names = expected_type.__name__ raise TypeError(f"{param_name} must be {type_names}, " f"got {type(obj).__name__}") def _ValidateStringNotEmpty(self, text: str, param_name: str = "text") -> None: """ Validate that a string is not None and not empty. This method performs validation specific to string parameters. Use this for text fields that require non-empty content. Args: text: The string to validate. param_name: Optional. Descriptive name for error messages. Default: "text". Returns: None Raises: TypeError: If text is not a string. Exception: If text is None. Exception: If text is empty string or contains only whitespace. Example: >>> def SetGloss(self, sense, gloss_text): ... self._ValidateStringNotEmpty(gloss_text, "gloss") ... sense.Gloss.BestAnalysisAlternative.Text = gloss_text >>> def UpdateDefinition(self, sense, definition): ... self._ValidateStringNotEmpty(definition, "definition") ... sense.Definition.BestAnalysisAlternative.Text = definition >>> def CreateNote(self, note_text): ... self._ValidateStringNotEmpty(note_text, "note") ... # Create note with validated text Notes: - Checks for None, empty string, and whitespace-only - Validates type is string - Useful for text fields that must have content - Use _ValidateParam for general None checks - Use _ValidateParamNotEmpty for collections Implementation Notes: - First validates type is string - Then checks if None - Then checks if stripped length is 0 - No side effects """ if not isinstance(text, str): raise TypeError(f"{param_name} must be a string, got {type(text).__name__}") if text is None: raise FP_NullParameterError() if len(text.strip()) == 0: raise FP_ParameterError(f"{param_name} cannot be empty or contain only whitespace") def _ValidateIndexBounds(self, index: int, max_count: int, param_name: str = "index") -> None: """ Validate that an index is within bounds [0, max_count-1]. This method performs bounds checking for array/collection indices. Use this to ensure index parameters are valid before accessing sequences. Args: index: The index to validate (must be int). max_count: The maximum count (length) of the collection. Must be positive int. param_name: Optional. Descriptive name for error messages. Default: "index". Returns: None Raises: TypeError: If index is not an integer. ValueError: If index is negative. IndexError: If index >= max_count. Example: >>> def GetSenseAt(self, entry, index): ... self._ValidateIndexBounds(index, entry.SensesOS.Count, "sense_index") ... return entry.SensesOS[index] >>> def MoveToIndex(self, parent, item, index): ... self._ValidateIndexBounds(index, len(self._GetSequence(parent)), "target_index") ... # Perform move operation >>> def InsertAtPosition(self, collection, index, item): ... self._ValidateIndexBounds(index, len(collection) + 1, "insertion_index") ... collection.Insert(index, item) Notes: - Validates both lower bound (>= 0) and upper bound (< max_count) - Requires integer index - max_count must be positive - Provides helpful error with valid range - Use for array/sequence/collection access - Use _ValidateParam for other integer validation Implementation Notes: - Checks isinstance(index, int) - Checks index >= 0 - Checks index < max_count - Raises appropriate exception types - No side effects """ if not isinstance(index, int): raise TypeError(f"{param_name} must be an integer, got {type(index).__name__}") if index < 0: raise ValueError(f"{param_name} cannot be negative, got {index}") if index >= max_count: raise IndexError(f"{param_name} out of bounds: {index} >= {max_count} " f"(valid range: 0-{max_count - 1})") def _ValidateOwner(self, obj: Any, expected_owner: Any, param_name: str = "object") -> None: """ Validate that an object has expected owner. This method performs owner validation. Use this to ensure an object belongs to the correct parent before performing ownership-dependent operations. Args: obj: The object to validate (must have Owner property). expected_owner: The expected owner object. param_name: Optional. Descriptive name for error messages. Default: "object". Returns: None Raises: AttributeError: If obj doesn't have Owner property. ValueError: If obj.Owner does not match expected_owner. Example: >>> def AddExampleToSense(self, sense, example): ... self._ValidateOwner(example, sense, "example") ... # Example must belong to this sense >>> def MoveToParent(self, item, new_parent): ... if hasattr(item, 'Owner'): ... old_owner = item.Owner ... self._ValidateOwner(item, old_owner, "item") >>> def VerifySenseInEntry(self, sense, entry): ... self._ValidateOwner(sense, entry, "sense") ... # Now we know sense.Owner == entry Notes: - Checks that object has Owner property - Validates Owner matches expected_owner - Works with any object with Owner property - Provides helpful error message - Useful for ensuring object hierarchy correctness Implementation Notes: - Checks if obj has Owner attribute - Compares obj.Owner with expected_owner - Uses equality check (==), not identity (is) - No side effects """ if not hasattr(obj, "Owner"): raise AttributeError(f"{param_name} does not have Owner property") if obj.Owner != expected_owner: raise ValueError( f"{param_name} owner does not match expected owner. " f"Object owner: {obj.Owner}, Expected: {expected_owner}" ) # ========== DATA TRANSFORMATION HELPERS ========== def _NormalizeMultiString(self, value: str) -> str: """ Convert FLEx empty placeholder to Python empty string. LibLCM (the underlying C# library) represents empty multistring fields with the placeholder "***". This helper converts it to Python's standard empty string ("") for a more Pythonic API. Args: value: The string value from a LibLCM multistring field. May be "***", "", None, or actual text. Returns: str: The value converted to "" if it was "***", otherwise unchanged. - "***" → "" - "" → "" - "word" → "word" - None → None (unchanged) Example: >>> sense_gloss = sense.Gloss.BestAnalysisAlternative.Text # Returns "***" >>> normalized = self._NormalizeMultiString(sense_gloss) # Returns "" >>> if normalized: # Python-standard empty check works ... print(f"Gloss: {normalized}") Notes: - This is called automatically by all public methods that return multistring field values, so users don't need to call it directly - See MIGRATION_GUIDE.md for breaking change details - FLEx/LCM Convention: "***" is used to represent empty multistring fields rather than None or empty string (for internal consistency) Implementation Notes: - Simple string comparison and replacement - No side effects - Preserves None (useful for optional fields) """ if value == "***": return "" return value # ========== ITsString (SINGLE-STRING) HELPERS ========== # # FLEx multilingual fields come in two LCM flavors that look similar at # the API surface but behave very differently. Confusing them is the # recurring source of the "single-string field" bug class: # # ITsString # One localised string with an embedded writing-system handle. # Read via .Text directly. Build via TsStringUtils.MakeString. # Examples (NON-exhaustive): # - ILexSense.Source # - ILexSense.ScientificName # - ILexSense.ImportResidue # - ILexEntry.ImportResidue # # IMultiString / IMultiUnicode # Collection of localised strings indexed by ws handle. Read via # .get_String(ws); write via .set_String(ws, ts_string). # Examples (NON-exhaustive): # - ILexEtymology.Source (same name as above, DIFFERENT type) # - IStText.Source (same name as above, DIFFERENT type) # - ILexSense.Definition # - ILexSense.Gloss # # The two helpers below are the canonical adapters for ITsString fields. # Use them instead of assigning a Python str directly to an ITsString # attribute (raises TypeError at the pythonnet boundary) or passing a # raw ITsString through a Python-string normaliser (returns garbage). def _MakeTsString(self, text, wsHandle=None): """ Build an ITsString for assignment to a single-string ITsString field. Args: text (str): The Python string to wrap. wsHandle: Optional writing system handle (int) or language tag (str). Defaults to the project's analysis WS when None. Returns: ITsString: The wrapped string ready to assign to an ITsString-typed LCM property. Notes: - This is the correct path for writes to ILexSense.Source, ILexSense.ScientificName, ILexSense.ImportResidue, ILexEntry.ImportResidue, and other single-string fields. - For IMultiString fields use ``field.set_String(ws, ts)`` directly with a TsStringUtils.MakeString result; do NOT assign through this helper. """ from SIL.LCModel.Core.Text import TsStringUtils if wsHandle is None: ws = self.project.project.DefaultAnalWs else: ws = self.project._FLExProject__WSHandle( wsHandle, self.project.project.DefaultAnalWs ) return TsStringUtils.MakeString(text, ws) def _ReadTsString(self, tss): """ Read an ITsString field as a Python str. Collapses unset / None / the FLEx null-marker ('***') to "". Single-string fields have no per-WS dimension where None vs "" would be meaningful, so the multistring family's None-passthrough is not appropriate here. Args: tss: An ITsString-typed LCM value (or None). Returns: str: The string content. Always a Python str -- never None, never a raw ITsString object. Notes: - This is the correct path for reads from ILexSense.Source, ILexSense.ScientificName, ILexSense.ImportResidue, ILexEntry.ImportResidue, and other single-string fields. - For IMultiString fields read via ``field.get_String(ws)`` and call ``ITsString(...).Text`` yourself; do NOT route a per-WS read through this helper. """ from SIL.LCModel.Core.KernelInterfaces import ITsString if tss is None: return "" text = ITsString(tss).Text if text is None: return "" return self._NormalizeMultiString(text)