Source code for flexicon.code.lcm_casting

#
#   lcm_casting.py
#
#   Module:     Utilities for casting LCM objects to their concrete interfaces
#               in pythonnet.
#
#   Platform:   Python.NET
#               FieldWorks Version 9+
#
#   Copyright 2025
#

"""
LCM Object Casting Utilities for pythonnet.

This module provides utilities for casting LCM objects from their base interface
types to their concrete derived interfaces. This is necessary because pythonnet
respects .NET interface typing strictly.

The Problem:
    When you iterate over a collection like MorphoSyntaxAnalysesOC, pythonnet
    returns objects typed as the base interface (IMoMorphSynAnalysis). Properties
    from derived interfaces like IMoStemMsa.PartOfSpeechRA are not accessible
    until you explicitly cast the object to its concrete interface type.

    For example::

        # This will NOT work - msa is typed as IMoMorphSynAnalysis
        for msa in entry.MorphoSyntaxAnalysesOC:
            pos = msa.PartOfSpeechRA  # AttributeError - property not found!

        # This WILL work - cast to concrete type first
        for msa in entry.MorphoSyntaxAnalysesOC:
            concrete_msa = cast_to_concrete(msa)
            if hasattr(concrete_msa, 'PartOfSpeechRA'):
                pos = concrete_msa.PartOfSpeechRA  # Works!

Why This Happens:
    In .NET, IMoStemMsa inherits from IMoMorphSynAnalysis. When you access a
    collection typed as IEnumerable<IMoMorphSynAnalysis>, the CLR returns objects
    as the interface type, not the concrete class. Pythonnet cannot automatically
    determine the derived interface type - you must cast explicitly.

Usage::

    from flexicon.code.lcm_casting import cast_to_concrete, get_pos_from_msa

    # Cast any LCM object to its concrete interface
    for msa in entry.MorphoSyntaxAnalysesOC:
        concrete = cast_to_concrete(msa)
        print(f"Class: {msa.ClassName}, Type: {type(concrete)}")

    # Convenience function for the common POS lookup pattern
    for msa in entry.MorphoSyntaxAnalysesOC:
        pos = get_pos_from_msa(msa)
        if pos:
            print(f"Part of Speech: {pos.Name.BestAnalysisAlternative.Text}")

Supported Types:
    - MSA types: MoStemMsa, MoDerivAffMsa, MoInflAffMsa, MoUnclassifiedAffixMsa
    - Allomorph types: MoStemAllomorph, MoAffixAllomorph
    - Phonological rule types: PhRegularRule, PhMetathesisRule
    - Compound rule types: MoEndoCompound, MoExoCompound
    - Morphosyntactic prohibition types: MoAdhocProhibGr, MoAdhocProhibMorph, MoAdhocProhibAllomorph
    - Owner / container types (used by .Owner casting paths in Lexicon,
      Notebook, and Discourse operations): LexEntry, LexSense, RnGenericRec,
      CmPossibility, CmAnthroItem, DsConstChart, Text, StText, StTxtPara
    - Entry-ref type: LexEntryRef (ILexEntry.EntryRefsOS elements; needed
      to reach ComplexEntryTypesRS / ComponentLexemesRS / PrimaryLexemesRS
      / VariantEntryTypesRS on a base-typed or HVO-resolved entry_ref)

Note:
    The interface cache is lazy-loaded on first use to avoid import issues
    at module load time. This is important because SIL.LCModel may not be
    available until after FLExInit has run.
"""

import logging

logger = logging.getLogger(__name__)

# Interface cache - populated on first use
_interface_cache = {}
_interfaces_loaded = False


def _ensure_interfaces() -> None:
    """
    Load and cache LCM interface types from SIL.LCModel.

    This function is called automatically on first use of cast_to_concrete().
    It populates the _interface_cache dictionary mapping ClassName strings
    to their corresponding interface types.

    The lazy loading pattern avoids import issues that can occur if
    SIL.LCModel is imported before FLExInit has configured the CLR.

    Returns:
        None

    Side Effects:
        Populates _interface_cache with ClassName -> Interface mappings.
        Sets _interfaces_loaded to True.

    Raises:
        ImportError: If SIL.LCModel cannot be imported. This typically means
            FLExInit has not been run, or the FieldWorks DLLs are not available.
    """
    global _interface_cache, _interfaces_loaded

    if _interfaces_loaded:
        return

    from SIL.LCModel import (
        # MSA (MorphoSyntaxAnalysis) interfaces
        IMoStemMsa,
        IMoDerivAffMsa,
        IMoInflAffMsa,
        IMoUnclassifiedAffixMsa,
        # Allomorph interfaces
        IMoStemAllomorph,
        IMoAffixAllomorph,
        IMoAffixForm,
    )

    # Phonological rule interfaces - try to import, but don't fail if unavailable
    # LCM defines exactly two concrete subclasses of PhSegmentRule:
    #   - PhRegularRule: Standard phonological rules (most common)
    #   - PhMetathesisRule: Metathesis rules (swapping segments)
    # There is no PhReduplicationRule class or IPhReduplicationRule interface
    # in this LCM (issue #326, T1 live reflection).
    try:
        from SIL.LCModel import (
            IPhRegularRule,
            IPhMetathesisRule,
            IPhSimpleContextSeg,
            IPhSimpleContextNC,
            IPhSegRuleRHS,
        )
    except ImportError:
        IPhRegularRule = IPhMetathesisRule = None
        IPhSimpleContextSeg = IPhSimpleContextNC = IPhSegRuleRHS = None

    # Compound rule interfaces - try to import, but don't fail if unavailable
    # These are the two main compound rule types:
    # - MoEndoCompound: Head is internal to the compound
    # - MoExoCompound: Head is external to the compound
    try:
        from SIL.LCModel import (
            IMoEndoCompound,
            IMoExoCompound,
        )
    except ImportError:
        IMoEndoCompound = IMoExoCompound = None

    # Morphosyntactic prohibition interfaces - LCM names diverge from the
    # POS-flavored shorthand flexicon historically used:
    #   - MoAdhocProhibGr   : Grammatical feature prohibitions       (class 110)
    #   - MoMorphAdhocProhib: Morpheme co-occurrence prohibitions    (class 102)
    #   - MoAlloAdhocProhib : Allomorph co-occurrence prohibitions   (class 101)
    # Earlier versions of this module spelled the latter two as
    # IMoAdhocProhibMorph / IMoAdhocProhibAllomorph -- which never existed in
    # LCM -- so the imports always failed and the cache silently went to None.
    # Use the real LCM names and expose them under both keys so existing
    # cache callers keep working.
    try:
        from SIL.LCModel import (
            IMoAdhocProhibGr,
            IMoMorphAdhocProhib,
            IMoAlloAdhocProhib,
        )
        IMoAdhocProhibMorph = IMoMorphAdhocProhib
        IMoAdhocProhibAllomorph = IMoAlloAdhocProhib
    except ImportError:
        IMoAdhocProhibGr = IMoMorphAdhocProhib = IMoAlloAdhocProhib = None
        IMoAdhocProhibMorph = IMoAdhocProhibAllomorph = None

    # Affix template interface - try to import, but don't fail if unavailable
    # - MoInflAffixTemplate: Inflectional affix template patterns
    try:
        from SIL.LCModel import (
            IMoInflAffixTemplate,
        )
    except ImportError:
        IMoInflAffixTemplate = None

    # Owner / container interfaces - the typed parents that own the
    # objects manipulated by Lexicon, Notebook, and Discourse operations.
    # These are needed because `.Owner` on an owned object returns the
    # base ICmObject interface, which does not expose typed collection
    # properties (AlternateFormsOS, EtymologyOS, EntryRefsOS, AnnotationsOC,
    # SubPossibilitiesOS, RowsOS, ...). cast_to_concrete() routes through
    # ClassName -> concrete interface so callers can reach those typed
    # collections without doing the cast themselves.
    try:
        from SIL.LCModel import (
            ILexEntry,
            ILexSense,
            ILexRefType,
            IRnGenericRec,
            ICmPossibility,
            ICmAnthroItem,
            IDsConstChart,
            IDsDiscourseData,
            IText,
            IStText,
            IStTxtPara,
            IWfiAnalysis,
            ILangProject,
        )
    except ImportError:
        ILexEntry = ILexSense = ILexRefType = IRnGenericRec = None
        ICmPossibility = ICmAnthroItem = None
        IDsConstChart = IDsDiscourseData = None
        IText = IStText = IStTxtPara = None
        IWfiAnalysis = None
        ILangProject = None

    # ILexEntryRef - the complex-form/variant entry-ref type owned by
    # ILexEntry.EntryRefsOS. Confirmed present in
    # tests/contract/snapshots/expected_contract.json. Registered
    # because elements read off EntryRefsOS directly happen to already
    # come back concrete-typed, but any HVO-resolved or otherwise
    # round-tripped entry_ref arrives as bare ICmObject, on which
    # ComplexEntryTypesRS / ComponentLexemesRS / PrimaryLexemesRS /
    # VariantEntryTypesRS are unreachable without this cast (issue #280).
    try:
        from SIL.LCModel import ILexEntryRef
    except ImportError:
        ILexEntryRef = None

    # Interlinear bundle/wordform + inflection-class interfaces
    # (issue #333). project.Object(hvo) returns a bare, gate-limited
    # ICmObject; cast_to_concrete() can only recover the derived surface
    # (MsaRA, MorphBundlesOS, ...) when the ClassName is registered in
    # _interface_cache. WfiAnalysis was already registered below, but
    # WfiMorphBundle / WfiWordform / MoInflClass were missing -- so a
    # resolver-side cast_to_concrete() call returned the object unchanged
    # (total-function miss) and the AttributeError survived the "fix".
    # All three names are already in the LCM contract baseline
    # (tests/contract/snapshots/expected_contract.json), so this import
    # adds no new type dependency.
    try:
        from SIL.LCModel import (
            IWfiMorphBundle,
            IWfiWordform,
            IMoInflClass,
        )
    except ImportError:
        IWfiMorphBundle = IWfiWordform = IMoInflClass = None

    # Feature-structure owner interfaces (phonology) - spec
    # feature-structure-sync-gap, decision D3. IPhNCFeatures and
    # IPhPhoneme both declare FeaturesOA directly; IPhNCSegments does
    # not (it uses SegmentsRC instead) but is imported alongside them
    # because all three are queried through the same PhNC/Phoneme
    # ClassName-discrimination path.
    try:
        from SIL.LCModel import (
            IPhNCFeatures,
            IPhNCSegments,
            IPhPhoneme,
        )
    except ImportError:
        IPhNCFeatures = IPhNCSegments = IPhPhoneme = None

    # Part-of-speech interface - confirmed real and already imported
    # unconditionally elsewhere in this repo (e.g. POSOperations.py),
    # so failure here would indicate a genuine environment problem, not
    # an absent/renamed type. Kept in its own guarded block regardless,
    # per this module's established per-interface isolation convention.
    try:
        from SIL.LCModel import IPartOfSpeech
    except ImportError:
        IPartOfSpeech = None

    # IPosFeatures - CONFIRMED ABSENT from this LCM version by live
    # introspection (2026-09-07, via `pytest tests/contract/
    # test_lcm_contract.py::TestLiveContractVerification -m requires_liblcm`,
    # `liblcm_snapshot["missing_types"] == ["IPosFeatures"]`). Only a
    # descriptive comment at InflectionFeatureOperations.py:486
    # ("IPosFeatures.FeaturesOA") ever referenced this name; no snapshot,
    # probe, or existing import confirms it. This is a hardcoded None with
    # NO `from SIL.LCModel import IPosFeatures` attempt -- a real import
    # statement would (a) always raise ImportError on every environment,
    # and (b) get picked up by tests/contract/test_lcm_contract.py's
    # static AST extractor as an "expected" type, permanently failing
    # TestLiveContractVerification.test_all_types_found. The D3 registry
    # slot is kept (see the registration block below) so a future
    # LCM version that does add this class only needs this line changed.
    IPosFeatures = None

    # Feature-structure interfaces themselves (IFsFeatStruc and its
    # nested members). IFsComplexFeature, IFsFeatStruc, and
    # IFsClosedValue are already imported unconditionally elsewhere
    # (InflectionFeatureOperations.py); IFsComplexValue is confirmed by
    # the live probe (evidence/live-cycle1-probe.md item "Create(Guid)")
    # even though it is absent from tests/contract/snapshots/
    # liblcm_baseline.json (a documented P2 snapshot gap, spec section 7).
    try:
        from SIL.LCModel import (
            IFsComplexFeature,
            IFsFeatStruc,
            IFsComplexValue,
            IFsClosedValue,
        )
    except ImportError:
        IFsComplexFeature = IFsFeatStruc = IFsComplexValue = IFsClosedValue = None

    _interface_cache = {
        # MSA types - used for grammatical category assignment
        "MoStemMsa": IMoStemMsa,
        "MoDerivAffMsa": IMoDerivAffMsa,
        "MoInflAffMsa": IMoInflAffMsa,
        "MoUnclassifiedAffixMsa": IMoUnclassifiedAffixMsa,
        # Allomorph types - used for morpheme form variants
        "MoStemAllomorph": IMoStemAllomorph,
        "MoAffixAllomorph": IMoAffixAllomorph,
        "MoAffixForm": IMoAffixForm,
    }

    # Add phonological rule types if imports succeeded
    # The 2 concrete subclasses of PhSegmentRule in this LCM:
    if IPhRegularRule is not None:
        _interface_cache["PhRegularRule"] = IPhRegularRule
    if IPhMetathesisRule is not None:
        _interface_cache["PhMetathesisRule"] = IPhMetathesisRule

    # Context and RHS types used within rules:
    if IPhSimpleContextSeg is not None:
        _interface_cache["PhSimpleContextSeg"] = IPhSimpleContextSeg
    if IPhSimpleContextNC is not None:
        _interface_cache["PhSimpleContextNC"] = IPhSimpleContextNC
    if IPhSegRuleRHS is not None:
        _interface_cache["PhSegRuleRHS"] = IPhSegRuleRHS

    # Add compound rule types if imports succeeded
    # The 2 main compound rule types in FLEx morphology:
    if IMoEndoCompound is not None:
        _interface_cache["MoEndoCompound"] = IMoEndoCompound
    if IMoExoCompound is not None:
        _interface_cache["MoExoCompound"] = IMoExoCompound

    # Add morphosyntactic prohibition types if imports succeeded
    # The 3 main ad hoc prohibition types in FLEx morphology:
    if IMoAdhocProhibGr is not None:
        _interface_cache["MoAdhocProhibGr"] = IMoAdhocProhibGr
    if IMoAdhocProhibMorph is not None:
        _interface_cache["MoAdhocProhibMorph"] = IMoAdhocProhibMorph
    if IMoAdhocProhibAllomorph is not None:
        _interface_cache["MoAdhocProhibAllomorph"] = IMoAdhocProhibAllomorph

    # Add affix template type if import succeeded
    if IMoInflAffixTemplate is not None:
        _interface_cache["MoInflAffixTemplate"] = IMoInflAffixTemplate

    # Add owner / container types if imports succeeded. Used by .Owner
    # casting paths in Lexicon, Notebook, and Discourse operations.
    if ILexEntry is not None:
        _interface_cache["LexEntry"] = ILexEntry
    if ILexSense is not None:
        _interface_cache["LexSense"] = ILexSense
    if ILexRefType is not None:
        _interface_cache["LexRefType"] = ILexRefType
    if ILexEntryRef is not None:
        _interface_cache["LexEntryRef"] = ILexEntryRef
    if IRnGenericRec is not None:
        _interface_cache["RnGenericRec"] = IRnGenericRec
    if ICmPossibility is not None:
        _interface_cache["CmPossibility"] = ICmPossibility
    if ICmAnthroItem is not None:
        _interface_cache["CmAnthroItem"] = ICmAnthroItem
    if IDsConstChart is not None:
        _interface_cache["DsConstChart"] = IDsConstChart
    if IDsDiscourseData is not None:
        # DsDiscourseData is the project-level owner of ChartsOC. Without
        # this mapping _GetTypedOwner() returned it unchanged (a bare
        # ICmObject), silently no-opping DiscourseOperations.Delete/Duplicate's
        # `hasattr(parent, "ChartsOC")` checks -- same failure shape as the
        # LangProject/AnnotationsOC mapping just below.
        _interface_cache["DsDiscourseData"] = IDsDiscourseData
    if IText is not None:
        _interface_cache["Text"] = IText
    if IStText is not None:
        _interface_cache["StText"] = IStText
    if IStTxtPara is not None:
        _interface_cache["StTxtPara"] = IStTxtPara
    if IWfiAnalysis is not None:
        _interface_cache["WfiAnalysis"] = IWfiAnalysis
    if IWfiMorphBundle is not None:
        _interface_cache["WfiMorphBundle"] = IWfiMorphBundle
    if IWfiWordform is not None:
        _interface_cache["WfiWordform"] = IWfiWordform
    if IMoInflClass is not None:
        _interface_cache["MoInflClass"] = IMoInflClass
    if ILangProject is not None:
        # LangProject is the sole owner of AnnotationsOC in this LCM
        # version -- no domain object (ILexEntry, ILexSense, IText, ...)
        # exposes AnnotationsOC itself. Notes/annotations reference their
        # subject via BeginObjectRA rather than being owned by it, so
        # note.Owner resolves to the project root; without this mapping
        # _GetTypedOwner() returned it unchanged (a bare ICmObject),
        # silently no-opping NoteOperations.Delete/Duplicate's
        # `hasattr(parent, "AnnotationsOC")` checks.
        _interface_cache["LangProject"] = ILangProject

    # Feature-structure owner types (spec feature-structure-sync-gap,
    # decision D3; issues #251/#252/#256, and the #133 completion at
    # InflectionFeatureOperations.py:492-493). `IFsFeatStruc` is owned
    # under a DIFFERENTLY NAMED atomic property on almost every owner
    # (MsFeaturesOA, InflFeatsOA, From/ToMsFeaturesOA, DefaultFeaturesOA,
    # InherFeatValOA, MsEnvFeaturesOA, FeaturesOA) and this cache had NO
    # entry at all for any feature-structure owner except the four
    # MSA/allomorph classes already registered above
    # (MoStemMsa/MoInflAffMsa/MoDerivAffMsa/MoAffixAllomorph).
    # `_GetTypedOwner()` (BaseOperations.py:1564) therefore returned
    # these owners unchanged as a bare ICmObject, which is why the #133
    # fix's `hasattr(parent, "FeaturesOA")` guard at
    # InflectionFeatureOperations.py:493 silently did nothing for
    # exactly the owner types its own comment at :485-486 names
    # (IPosFeatures.FeaturesOA, IFsComplexFeature.FeaturesOA) -- and the
    # same reasoning applies to IPhNCFeatures/IPhPhoneme/IPartOfSpeech.
    # Registering them here is the prerequisite for the shared
    # owner-property resolver (T2) and does not by itself resolve the
    # #251/#252/#256 family -- see this task's cycle2 report for the
    # caller-by-caller behavioural delta this addition causes.
    # FsFeatStruc/FsComplexValue/FsClosedValue are needed the other
    # direction: once a feature structure (or one of its FeatureSpecsOC
    # members / a nested ValueOA) is reached via an HVO/GUID or a
    # `.Owner` walk, it too arrives as a bare ICmObject and must be cast
    # to expose FeatureSpecsOC / ValueOA / FeatureRA-ValueRA.
    # PhNCSegments has NO FeaturesOA (it uses SegmentsRC instead) and is
    # inert for this feature; it is registered here only to unblock
    # spec 233-basetype-cast-sweep's SegmentsRC cast sweep, which shares
    # this same cache.
    if IPhNCFeatures is not None:
        _interface_cache["PhNCFeatures"] = IPhNCFeatures
    if IPhNCSegments is not None:
        _interface_cache["PhNCSegments"] = IPhNCSegments
    if IPhPhoneme is not None:
        _interface_cache["PhPhoneme"] = IPhPhoneme
    if IPartOfSpeech is not None:
        _interface_cache["PartOfSpeech"] = IPartOfSpeech
    if IPosFeatures is not None:
        _interface_cache["PosFeatures"] = IPosFeatures
    if IFsComplexFeature is not None:
        _interface_cache["FsComplexFeature"] = IFsComplexFeature
    if IFsFeatStruc is not None:
        _interface_cache["FsFeatStruc"] = IFsFeatStruc
    if IFsComplexValue is not None:
        _interface_cache["FsComplexValue"] = IFsComplexValue
    if IFsClosedValue is not None:
        _interface_cache["FsClosedValue"] = IFsClosedValue

    # Possibility subtypes and discourse cell-part subtypes (issue #270).
    #
    # These are the element types of collections whose DECLARED element
    # type is a base interface, so pythonnet hands the elements back as
    # that base and every subtype-only property is invisible:
    #
    #   ICmPossibilityList.PossibilitiesOS  -> ICmPossibility  (55 props)
    #   ICmPossibility.SubPossibilitiesOS   -> ICmPossibility
    #   IConstChartRow.CellsOS              -> IConstituentChartCellPart
    #
    # The possibility subtypes registered here really do add surface over
    # ICmPossibility (per tests/contract/snapshots/liblcm_baseline.json:
    # IPartOfSpeech 74 props, ICmPerson 64, IMoMorphType 64,
    # ICmAnnotationDefn 67, ICmSemanticDomain 60, ILexEntryType 57,
    # ICmLocation 56), so a getter over a generic possibility list has to
    # cast or those properties are unreachable. The four cell-part
    # subtypes are what makes an `isinstance(cell, IConstChartTag)` filter
    # over CellsOS work at all -- uncast it matches nothing and the filter
    # silently yields an empty list.
    #
    # IPartOfSpeech / ICmAnthroItem / ICmPossibility are already registered
    # above. The four types below were deferred in #270 until the contract
    # baseline covered their imports (#279).
    try:
        from SIL.LCModel import (
            ICmSemanticDomain,
            ICmLocation,
            ICmPerson,
            IMoMorphType,
            ICmAnnotationDefn,
            ILexEntryType,
            ILexEntryInflType,
            ICmCustomItem,
            IChkTerm,
            IConstituentChartCellPart,
            IConstChartRow,
            IConstChartTag,
            IConstChartWordGroup,
            IConstChartMovedTextMarker,
            IConstChartClauseMarker,
        )
    except ImportError:
        ICmSemanticDomain = ICmLocation = ICmPerson = None
        IMoMorphType = ICmAnnotationDefn = ILexEntryType = None
        ILexEntryInflType = ICmCustomItem = IChkTerm = None
        IConstituentChartCellPart = None
        IConstChartRow = IConstChartTag = IConstChartWordGroup = None
        IConstChartMovedTextMarker = IConstChartClauseMarker = None

    for _class_name, _iface in (
        ("CmSemanticDomain", ICmSemanticDomain),
        ("CmLocation", ICmLocation),
        ("CmPerson", ICmPerson),
        ("MoMorphType", IMoMorphType),
        ("CmAnnotationDefn", ICmAnnotationDefn),
        ("LexEntryType", ILexEntryType),
        ("LexEntryInflType", ILexEntryInflType),
        ("CmCustomItem", ICmCustomItem),
        ("ChkTerm", IChkTerm),
        ("ConstituentChartCellPart", IConstituentChartCellPart),
        ("ConstChartRow", IConstChartRow),
        ("ConstChartTag", IConstChartTag),
        ("ConstChartWordGroup", IConstChartWordGroup),
        ("ConstChartMovedTextMarker", IConstChartMovedTextMarker),
        ("ConstChartClauseMarker", IConstChartClauseMarker),
    ):
        if _iface is not None:
            _interface_cache[_class_name] = _iface

    # HVO-resolver targets (4.10.0 release gate). The #455-#508 resolver
    # fixes route `project.Object(hvo)` through cast_to_concrete(), but
    # these ClassNames were never registered, so the cast was a total-
    # function miss and every resolver below still returned a bare
    # ICmObject -- caught only by the live HVO gates. The set was taken
    # empirically from cache misses logged across the full live suite,
    # plus a static sweep of every cast_to_concrete(self.project.Object())
    # resolver's documented return interface.
    try:
        from SIL.LCModel import (
            ICmPossibilityList,
            ICmBaseAnnotation,
            IFsClosedFeature,
            IFsFeatureSystem,
            ILexExampleSentence,
            ILexPronunciation,
            ILexReference,
            ILexEtymology,
            IPhCode,
            IPhFeatureConstraint,
            IRnResearchNbk,
            IScrBook,
            IScrSection,
            IScrTxtPara,
            IScrScriptureNote,
            ISegment,
        )
    except ImportError:
        ICmPossibilityList = ICmBaseAnnotation = None
        IFsClosedFeature = IFsFeatureSystem = None
        ILexExampleSentence = ILexPronunciation = ILexReference = None
        ILexEtymology = IPhCode = IPhFeatureConstraint = None
        IRnResearchNbk = None
        IScrBook = IScrSection = IScrTxtPara = IScrScriptureNote = None
        ISegment = None

    for _class_name, _iface in (
        ("CmPossibilityList", ICmPossibilityList),
        ("CmBaseAnnotation", ICmBaseAnnotation),
        ("FsClosedFeature", IFsClosedFeature),
        ("FsFeatureSystem", IFsFeatureSystem),
        ("LexExampleSentence", ILexExampleSentence),
        ("LexPronunciation", ILexPronunciation),
        ("LexReference", ILexReference),
        ("LexEtymology", ILexEtymology),
        ("PhCode", IPhCode),
        ("PhFeatureConstraint", IPhFeatureConstraint),
        ("RnResearchNbk", IRnResearchNbk),
        ("ScrBook", IScrBook),
        ("ScrSection", IScrSection),
        ("ScrTxtPara", IScrTxtPara),
        ("ScrScriptureNote", IScrScriptureNote),
        ("Segment", ISegment),
    ):
        if _iface is not None:
            _interface_cache[_class_name] = _iface

    _interfaces_loaded = True


[docs] def cast_to_concrete(obj): """ Cast an LCM object to its concrete interface type based on ClassName. **Public API.** Import it as:: from flexicon import cast_to_concrete This is the supported remedy for the whole ``'ICmObject' object has no attribute 'X'`` failure class. pythonnet respects .NET interface typing strictly, so an element pulled out of a collection typed as ``IEnumerable<ICmObject>`` (or any base interface) exposes only the base interface's members, even when the underlying object is a ``LexEntry`` with a ``HeadWord``. ``cast_to_concrete`` looks up ``obj.ClassName`` and hands back a view typed as the concrete interface, from which the derived members are reachable. flexicon's own Operations classes cast internally, so most callers never need this. It is exported as the **escape hatch** for two cases that stay outside that coverage: 1. Direct-LCM work -- when you have reached past the wrapper API and are holding raw LCM objects yourself. 2. Collections that are legitimately polymorphic, such as ``ILexEntry.ComponentLexemesRS`` or ``ILexReference.TargetsRS``, whose elements may each be either an ``ILexEntry`` or an ``ILexSense``. Totality guarantee This function is **total**: it never raises for an input it does not recognise. An object whose ``ClassName`` is not in the mapping, an object with no ``ClassName`` at all, and a cast that fails inside the CLR all yield *the original object, unchanged*. That is precisely why it is preferable to the hand-rolled ``ILexEntry(x)`` workaround, which throws when ``x`` is legitimately an ``ILexSense`` -- exactly the case a polymorphic collection guarantees you will hit. Because the result may be the uncast original, guard derived-member access with ``hasattr`` (or ``getattr(..., None)``) rather than assuming the cast landed. The corollary is that ``cast_to_concrete`` is not a validator: a return value is never evidence that the object was of any particular type. Check ``obj.ClassName`` if you need to know. Args: obj: An LCM object with a ClassName property (e.g., IMoMorphSynAnalysis, IMoForm, or any ICmObject). Any other object is returned as-is. Returns: The object cast to its concrete interface type, or the original object if the ClassName is not recognized or casting fails. Example:: from flexicon import cast_to_concrete # A polymorphic collection: elements may be entries OR senses. for component in entry.EntryRefsOS[0].ComponentLexemesRS: concrete = cast_to_concrete(component) headword = getattr(concrete, "HeadWord", None) # entries only if headword is not None: print(headword.Text) # Iterate MSAs and access derived properties for msa in entry.MorphoSyntaxAnalysesOC: concrete_msa = cast_to_concrete(msa) # Now we can check for and access derived properties if hasattr(concrete_msa, 'PartOfSpeechRA'): pos = concrete_msa.PartOfSpeechRA if pos: print(f"POS: {pos.Name.BestAnalysisAlternative.Text}") # Cast allomorphs to access type-specific properties for allo in entry.AlternateFormsOS: concrete_allo = cast_to_concrete(allo) if hasattr(concrete_allo, 'StemName'): # This is a stem allomorph stem_name = concrete_allo.StemName elif hasattr(concrete_allo, 'InflectionClasses'): # This is an affix allomorph infl_classes = concrete_allo.InflectionClasses Notes: - Returns the original object if ClassName is not in the mapping - Returns the original object if it has no ClassName attribute at all - Returns the original object if casting fails for any reason - Thread-safe for the interface loading (uses lazy initialization) - The interface cache is loaded on first call, which is also the first point at which SIL.LCModel is imported -- importing this module (or ``flexicon`` itself) needs no FieldWorks install """ _ensure_interfaces() # Get the class name from the object if not hasattr(obj, "ClassName"): return obj class_name = obj.ClassName # Look up the interface type interface_type = _interface_cache.get(class_name) if interface_type is None: logger.debug( "cast_to_concrete: ClassName %r is not in the interface cache; " "returning object unchanged (issue #281 / #279)", class_name, ) return obj # Cast to the concrete interface try: return interface_type(obj) except Exception: logger.debug( "cast_to_concrete: cast to %r failed for ClassName %r; " "returning object unchanged", interface_type, class_name, ) return obj
[docs] def cast_all(collection): """ Materialise `collection` as a list with every element cast to its concrete LCM interface. This is the collection-level counterpart to `cast_to_concrete()`, added for issue #270: the Pattern A sweep cast `.Owner` return sites but left every *collection* getter handing back raw base-interface elements, so collection elements could not be round-tripped back into flexicon methods (`isinstance(comp, ILexEntry)` was False for every element of `GetComplexFormComponents()`, and `hasattr(item, "SubPossibilitiesOS")` was False for elements of a possibility list). Prefer `BaseOperations._GetTypedElements()` from inside an Operations class -- it delegates here and saves each class importing this module. Args: collection: Any iterable of LCM objects (an `ILcmOwningSequence`, `ILcmReferenceSequence`, a generator, or a plain list). None is accepted and yields an empty list. Returns: list: A new list of the same length and order, each element passed through `cast_to_concrete()`. Elements whose ClassName is not registered come back unchanged, so the call is total and safe over heterogeneous or non-LCM contents. Notes: - Deliberately NOT applied blanket-wise inside `EnumerableWrapper._ensure_list()`. See issue #270 for the reasoning: (1) most affected getters return plain Python lists which `_needs_enumerable_wrap()` intentionally does not wrap, so a wrapper-level cast would miss them; (2) it would add a per-element ClassName lookup to every large `GetAll*` in the library; and (3) it would silently change element identity for `EnumerableWrapper.__contains__`/`==` callers that pass in an uncast object. """ if collection is None: return [] return [cast_to_concrete(item) for item in collection]
# MSA ClassName -> the property that holds its Part-of-Speech reference. # This is the single source of truth for which MSA subtypes are # POS-bearing and which property to read; get_pos_from_msa() below # dispatches through it, and other Operations classes (e.g. # LexSenseOperations.GetPartOfSpeechObject) that need to know "is this # MSA subtype POS-bearing at all" should import POS_BEARING_MSA_CLASSES # rather than re-literalizing the class-name list (issue #232 P1 followup). _MSA_POS_PROPERTY = { "MoStemMsa": "PartOfSpeechRA", "MoDerivAffMsa": "ToPartOfSpeechRA", # output POS of derivation (see #87) "MoInflAffMsa": "PartOfSpeechRA", "MoUnclassifiedAffixMsa": "PartOfSpeechRA", } # Public: exported for use by other modules that need to check whether an # MSA ClassName is one of the recognized POS-bearing subtypes without # duplicating the literal list (see LexSenseOperations.GetPartOfSpeechObject). POS_BEARING_MSA_CLASSES = frozenset(_MSA_POS_PROPERTY)
[docs] def get_pos_from_msa(msa): """ Get the Part of Speech from any MSA type. This is a convenience function for the common pattern of extracting the Part of Speech reference from a MorphoSyntaxAnalysis object. It handles the casting internally and checks each MSA type for its POS property. Different MSA types store POS in different properties: - MoStemMsa: PartOfSpeechRA (main POS for stems) - MoDerivAffMsa: ToPartOfSpeechRA (output POS after derivation) - MoInflAffMsa: PartOfSpeechRA (POS this affix attaches to) - MoUnclassifiedAffixMsa: PartOfSpeechRA Args: msa: An MSA object (IMoMorphSynAnalysis or derived type). Returns: IPartOfSpeech: The Part of Speech reference, or None if: - The MSA type doesn't have a POS property - The POS property is not set (null reference) - The object cannot be cast to a known MSA type Example:: # Get POS for all MSAs on an entry for msa in entry.MorphoSyntaxAnalysesOC: pos = get_pos_from_msa(msa) if pos: pos_name = pos.Name.BestAnalysisAlternative.Text pos_abbr = pos.Abbreviation.BestAnalysisAlternative.Text print(f"{pos_name} ({pos_abbr})") # Check if entry has a specific POS target_pos_guid = some_guid has_target_pos = any( get_pos_from_msa(msa) and str(get_pos_from_msa(msa).Guid) == str(target_pos_guid) for msa in entry.MorphoSyntaxAnalysesOC ) Notes: - For MoDerivAffMsa, this returns ToPartOfSpeechRA (the output POS), not FromPartOfSpeechRA (the input POS). Use cast_to_concrete() directly if you need to access FromPartOfSpeechRA. - Returns None rather than raising exceptions for robustness - Handles all common MSA types found in typical FLEx projects """ _ensure_interfaces() if not hasattr(msa, "ClassName"): return None class_name = msa.ClassName pos_property = _MSA_POS_PROPERTY.get(class_name) if pos_property is None: # Unknown ClassName -- silently return None (no logging here; # callers that want to distinguish "unrecognized subtype" from # "no MSA" check POS_BEARING_MSA_CLASSES themselves, per #232). return None try: interface_type = _interface_cache.get(class_name) if interface_type: concrete = interface_type(msa) return getattr(concrete, pos_property) except Exception: # If anything fails, return None rather than crashing pass return None
# MSA ClassName -> the property that holds its Inflection Class reference. # Only MoStemMsa exposes a plain InflectionClassRA. MoDerivAffMsa instead # splits inflection-class information across FromInflectionClassRA / # ToInflectionClassRA (a different pair of properties, handled separately # by MSAOperations.ChangeAffixVariant), and MoInflAffMsa / # MoUnclassifiedAffixMsa have no inflection-class member at all. This is # the single source of truth for "does this MSA subtype carry a plain # InflectionClassRA" -- confirmed by live reflection against 1838 Sena 3 # bundles (1144 non-stem MSAs raise AttributeError on unguarded # .InflectionClassRA); see issue #259 / lcm-member-truth-sweep ruling C10. _MSA_INFLECTION_CLASS_PROPERTY = { "MoStemMsa": "InflectionClassRA", } # Public: exported for callers that need to know whether an MSA ClassName # carries a plain InflectionClassRA without re-literalizing the class name. INFLECTION_CLASS_BEARING_MSA_CLASSES = frozenset(_MSA_INFLECTION_CLASS_PROPERTY)
[docs] def get_inflection_class_from_msa(msa): """ Get the inflection class (IMoInflClass) from an MSA, if any. ``IWfiMorphBundle`` has no ``InflClassRA`` member of its own -- the inflection class lives on the bundle's linked MSA (``IMoStemMsa.InflectionClassRA``), reached via ``bundle.MsaRA``. This is the single navigation path for that lookup: callers should not re-implement "MsaRA -> cast -> narrow to IMoStemMsa -> InflectionClassRA" at each call site (issue #259 / lcm-member-truth-sweep C10). Args: msa: An MSA object (``IMoMorphSynAnalysis`` or a derived type), or ``None``. Returns: IMoInflClass or None: The inflection class if ``msa`` is a ``MoStemMsa`` with one set. Returns ``None`` for a ``None`` msa, for any non-stem MSA subtype (``MoDerivAffMsa``, ``MoInflAffMsa``, ``MoUnclassifiedAffixMsa``), and for a ``MoStemMsa`` with no inflection class set. Never raises. Example:: from flexicon.code.lcm_casting import get_inflection_class_from_msa infl_class = get_inflection_class_from_msa(bundle.MsaRA) if infl_class: name = infl_class.Name.BestAnalysisAlternative.Text Notes: - Returns None rather than raising exceptions for robustness. - Deliberately does NOT fall back to MoDerivAffMsa.FromInflectionClassRA/ToInflectionClassRA -- those are a different pair of properties with different semantics; use cast_to_concrete() directly if you need one of them. """ if msa is None: return None _ensure_interfaces() if not hasattr(msa, "ClassName"): return None class_name = msa.ClassName infl_class_property = _MSA_INFLECTION_CLASS_PROPERTY.get(class_name) if infl_class_property is None: # Not a stem MSA -- no plain InflectionClassRA on this subtype. return None try: interface_type = _interface_cache.get(class_name) if interface_type: concrete = interface_type(msa) return getattr(concrete, infl_class_property) except Exception: # If anything fails, return None rather than crashing pass return None
[docs] def set_inflection_class_on_msa(msa, infl_class): """ Set the inflection class (IMoInflClass) on an MSA's InflectionClassRA, if that MSA subtype carries one. Mirrors ``get_inflection_class_from_msa()``'s navigation for the write side: ``msa`` -> ``cast_to_concrete()`` -> narrow to ``IMoStemMsa`` -> set ``InflectionClassRA``. This is the single navigation path for that write: callers should not re-implement it at each call site (issue #259 / lcm-member-truth-sweep C10/C11). Args: msa: An MSA object (``IMoMorphSynAnalysis`` or a derived type), or ``None``. infl_class: The ``IMoInflClass`` to assign, or ``None`` to clear it. Returns: bool: True if ``msa`` is a ``MoStemMsa`` and its ``InflectionClassRA`` was set to ``infl_class``. False if ``msa`` is ``None`` or its ``ClassName`` is not ``MoStemMsa`` -- there was no writable target, and nothing was changed. Notes: - Never raises for a ``None`` or non-stem ``msa``; returns False instead so callers can build their own diagnostic naming the actual MSA state (see ``WfiMorphBundleOperations.SetInflectionClass``, which raises ``FP_ParameterError`` with the bundle's MSA ``ClassName``). - Deliberately does NOT fall back to ``MoDerivAffMsa.FromInflectionClassRA``/``ToInflectionClassRA`` -- those are a different pair of properties with different semantics; use ``cast_to_concrete()`` directly if you need one of them. - Because an MSA (``IMoStemMsa``) is typically shared -- referenced by ``LexSense.MorphoSyntaxAnalysisRA`` and by every ``WfiMorphBundle.MsaRA`` that points at it -- writing through this helper changes the value for every bundle and sense that shares the MSA. That fan-out is correct FLEx behaviour (the MSA *is* the shared "Grammatical Info"), not a bug; see the #259 domain ruling. """ if msa is None: return False _ensure_interfaces() if not hasattr(msa, "ClassName"): return False if msa.ClassName not in _MSA_INFLECTION_CLASS_PROPERTY: return False infl_class_property = _MSA_INFLECTION_CLASS_PROPERTY[msa.ClassName] concrete = cast_to_concrete(msa) setattr(concrete, infl_class_property, infl_class) return True
# LCM naming convention suffixes that denote a genuine backing-store # collection/atomic member (as opposed to a derived, rebuilt-per-access # property). See clone_properties() below (issue #321) for how this is used # to structurally exclude derived members like AllSenses/MorphTypes/ # PublishIn/ShowMainEntryIn/MinimalLexReferences/ReferringObjects from the # collection-clone branch, instead of relying on Count/Add duck-typing alone. # OS/OC/OA -> owned sequence/collection/atomic (safe to Clear()+Add()) # RS/RC/RA -> reference sequence/collection/atomic (shared, not owned; # already routed through the same branch prior to this fix, # so kept here to avoid narrowing existing behaviour) _OWNED_OR_REFERENCE_SUFFIXES = ("OS", "OC", "OA", "RS", "RC", "RA")
[docs] def clone_properties(source_obj, dest_obj, project=None): """ Deep clone all properties from source object to destination object. This is a Python equivalent of ICloneableCmObject.SetCloneProperties() from C#. It copies all properties recursively, handling: - Simple properties (names, descriptions, etc.) - Reference properties (RA) - Owned objects (OA) - creates new objects with cloned properties - Owned collections (OS/OC) - creates new objects for each item Args: source_obj: The source LCM object to clone from. dest_obj: The destination LCM object to clone to. project: Optional FLExProject instance for factory access. If not provided, extracted from the destination object's owner. Returns: None. The destination object is modified in place. Example:: from flexicon.code.lcm_casting import clone_properties # Clone a rule source_rule = phonRuleOps.GetAll()[0] new_rule = factory.Create() clone_properties(source_rule, new_rule, project) Notes: - Recursively clones owned objects - Shares reference objects (doesn't create copies of referenced objects) - Handles collections by adding cloned items to the destination collection - Silently skips any properties that cannot be cloned """ if not hasattr(source_obj, "ClassName") or not hasattr(dest_obj, "ClassName"): return # Cast both to concrete types for full property access source = cast_to_concrete(source_obj) dest = cast_to_concrete(dest_obj) # If project not provided, resolve via Cache.LanguageProject (canonical accessor) if project is None and hasattr(dest, "Cache"): try: project = dest.Cache.LanguageProject except Exception: pass # Get all properties from the source object for attr_name in dir(source): # Skip private, special, and known method attributes if attr_name.startswith("_") or attr_name in ["Clone", "PostClone"]: continue try: attr_value = getattr(source, attr_name, None) # Skip methods and special attributes if callable(attr_value) or attr_name in ["Hvo", "ClassID", "ClassName", "Guid", "Owner", "OwningFlid"]: continue # Try to set the property on destination if hasattr(dest, attr_name): try: # Check if it's a genuine LCM-owned/reference collection (OS/OC/OA/ # RS/RC/RA) - these need special Clear()+Add() handling. # # Issue #321: the old predicate was pure duck-typing -- # `hasattr(attr_value, "Count") and hasattr(attr_value, "Add")` -- # which also matches DERIVED, rebuilt-per-access properties such as # ILexEntry.AllSenses, .MorphTypes, .PublishIn, .ShowMainEntryIn, # .MinimalLexReferences, and the universal ICmObject.ReferringObjects. # Those expose Count/Add but are recomputed on every access, so # `dest_collection.Clear()` silently no-ops against them (confirmed # live on Sena 3, see # specs/318-321-nonexistent-member-mutations/evidence/live-321-derived-lists.md). # # LCM's naming convention encodes real backing-store membership in # the attribute name's suffix: OS/OC/OA = owned (sequence/collection/ # atomic), RS/RC/RA = reference (sequence/collection/atomic). Members # without one of these suffixes are derived/computed views and must # NOT be routed through the collection-clone branch. Gating on the # suffix *in addition to* the existing Count/Add duck-type check # keeps every genuine OS/OC/RS/RC/RA member clone_properties already # clones today (see cycle2-programmer-321.md for the before/after # member comparison) while structurally excluding the derived ones. if attr_name.endswith(_OWNED_OR_REFERENCE_SUFFIXES) and hasattr( attr_value, "Count" ) and hasattr(attr_value, "Add"): # This is a collection - clone each item dest_collection = getattr(dest, attr_name) try: dest_collection.Clear() except Exception as e: logging.debug(f"Failed to clear collection: {e}") # Add cloned items for item in attr_value: try: # Get factory based on item class name if project: factory = _get_factory_for_class(item.ClassName, project.project) if factory: cloned_item = factory.Create() dest_collection.Add(cloned_item) clone_properties(item, cloned_item, project) except Exception as e: # If we can't clone an item, just skip it logging.debug(f"Failed to clone item: {e}") else: # Simple property or reference - copy directly setattr(dest, attr_name, attr_value) except Exception as e: # If we can't set a property, skip it silently logging.debug(f"Failed to set property {attr_name}: {e}") except Exception as e: # If we can't read a property, skip it logging.debug(f"Failed to read property: {e}")
def _get_factory_for_class(class_name: str, project: object) -> "Optional[object]": """ Get the factory for creating an object of the given class. Args: class_name: String like 'PhRegularRule', 'PhSegRuleRHS', etc. project: The FLExProject instance. Returns: The factory object, or None if not found. """ try: from SIL.LCModel import ( IPhRegularRuleFactory, IPhMetathesisRuleFactory, IPhSegRuleRHSFactory, IPhSimpleContextSegFactory, IPhSimpleContextNCFactory, ) factory_map = { # The 2 concrete PhSegmentRule subclasses "PhRegularRule": IPhRegularRuleFactory, "PhMetathesisRule": IPhMetathesisRuleFactory, # Context and RHS types "PhSegRuleRHS": IPhSegRuleRHSFactory, "PhSimpleContextSeg": IPhSimpleContextSegFactory, "PhSimpleContextNC": IPhSimpleContextNCFactory, } factory_type = factory_map.get(class_name) if factory_type: return project.ServiceLocator.GetService(factory_type) except Exception as e: logging.debug(f"Failed to get factory for class {class_name}: {e}") return None
[docs] def cast_phonological_rule(rule_obj): """ Cast a phonological rule to its concrete interface type. Phonological rules come back from GetAll() typed as IPhSegmentRule (base interface). This function casts to the concrete interface based on ClassName: - PhRegularRule -> IPhRegularRule - PhMetathesisRule -> IPhMetathesisRule PhReduplicationRule is not a concrete type in this LCM (issue #326), so any object claiming that ClassName is returned unchanged. Args: rule_obj: A phonological rule object (typed as IPhSegmentRule or similar). Returns: The rule cast to its concrete interface, or the original object if not recognized. Example:: from flexicon.code.lcm_casting import cast_phonological_rule # Get rules and cast them for rule in phonRuleOps.GetAll(): concrete_rule = cast_phonological_rule(rule) # Now can access type-specific properties if concrete_rule.ClassName == 'PhRegularRule': rhs_count = concrete_rule.RightHandSidesOS.Count """ _ensure_interfaces() if not hasattr(rule_obj, "ClassName"): return rule_obj class_name = rule_obj.ClassName # Look up the interface for this rule type interface_type = _interface_cache.get(class_name) if interface_type is None: # Not a recognized rule type, return original return rule_obj try: return interface_type(rule_obj) except Exception: # If casting fails, return original return rule_obj
[docs] def validate_merge_compatibility(survivor_obj, victim_obj): """ Validate that two objects can be safely merged. Checks that both objects are of the same class and, for objects with multiple concrete implementations, that they have the same concrete type. This prevents merging incompatible types (e.g., PhRegularRule into PhMetathesisRule). Args: survivor_obj: The object that will receive merged data. victim_obj: The object that will be deleted/merged into survivor. Returns: tuple: (is_compatible, error_message) - (True, "") if merge is safe - (False, error_message) if merge should be blocked Example:: from flexicon.code.lcm_casting import validate_merge_compatibility # Validate before merging is_ok, msg = validate_merge_compatibility(entry1, entry2) if not is_ok: raise FP_ParameterError(msg) # Works for all object types is_ok, msg = validate_merge_compatibility(rule1, rule2) is_ok, msg = validate_merge_compatibility(sense1, sense2) Notes: - Both objects must have a ClassName attribute - For types with multiple concrete implementations (like phonological rules), both must have the same ClassName (e.g., both PhRegularRule) - For other types, same ClassName is sufficient - Prevents data corruption from merging incompatible object types """ # Check that both objects exist and have ClassName if not hasattr(survivor_obj, "ClassName"): return False, "Survivor object has no ClassName attribute" if not hasattr(victim_obj, "ClassName"): return False, "Victim object has no ClassName attribute" survivor_class = survivor_obj.ClassName victim_class = victim_obj.ClassName # Classes must match exactly if survivor_class != victim_class: return False, ( f"Cannot merge different classes: {victim_class} into {survivor_class}. " f"Objects must be of the same type." ) # For types with multiple concrete implementations, additional checks # could be added here. Currently, ClassName uniquely identifies the concrete type. return True, ""
[docs] def get_from_pos_from_msa(msa): """ Get the source Part of Speech from a derivational MSA. Only IMoDerivAffMsa has a FromPartOfSpeechRA property indicating the POS before derivation. This function returns None for all other MSA types. Args: msa: An MSA object (IMoMorphSynAnalysis or derived type). Returns: IPartOfSpeech: The source Part of Speech for derivational affixes, or None if not a derivational MSA or no source POS is set. Example:: for msa in entry.MorphoSyntaxAnalysesOC: from_pos = get_from_pos_from_msa(msa) to_pos = get_pos_from_msa(msa) if from_pos and to_pos: from_name = from_pos.Name.BestAnalysisAlternative.Text to_name = to_pos.Name.BestAnalysisAlternative.Text print(f"Derives: {from_name} -> {to_name}") Notes: - Only meaningful for MoDerivAffMsa objects - Returns None for MoStemMsa, MoInflAffMsa, MoUnclassifiedAffixMsa - Use in combination with get_pos_from_msa() to get both ends of a derivational relationship """ _ensure_interfaces() if not hasattr(msa, "ClassName"): return None if msa.ClassName != "MoDerivAffMsa": return None try: interface_type = _interface_cache.get("MoDerivAffMsa") if interface_type: concrete = interface_type(msa) return concrete.FromPartOfSpeechRA except Exception: pass return None
[docs] def get_common_properties(objects): """ Find properties that are available on ALL objects in a list. When working with collections of objects that may have different concrete types (e.g., mixed phonological rules), this function identifies which properties are safely accessible on all objects without type checking. This is useful for implementing filtering or display logic that works uniformly across all types. Args: objects: Iterable of LCM objects (all should have ClassName attribute). Returns: set: Property names that exist on ALL objects. Empty set if no common properties or if input is empty. Example:: from flexicon.code.lcm_casting import get_common_properties # Find properties available on all rule types rules = phonRuleOps.GetAll() common = get_common_properties(rules) print(common) # {'Name', 'Direction', 'StrucDescOS', ...} # These properties can be accessed safely on any rule for rule in rules: name = rule.Name direction = rule.Direction Notes: - Properties starting with '_' are excluded - Callable attributes (methods) are excluded - Returns intersection of properties across all objects - Empty list returns empty set (no intersection with all) - Useful before implementing collection-wide filters """ if not objects: return set() # Convert to list to allow multiple iterations obj_list = list(objects) if not obj_list: return set() # Get cast versions of all objects for comprehensive property access cast_objects = [cast_to_concrete(obj) for obj in obj_list] # Start with all properties from first object first_obj = cast_objects[0] common = set() for attr_name in dir(first_obj): # Skip private attributes and methods if attr_name.startswith("_"): continue # Check if this attribute exists on all other objects try: first_attr = getattr(first_obj, attr_name) # Skip callable attributes (methods) if callable(first_attr): continue # Check if all other objects have this property if all(hasattr(obj, attr_name) for obj in cast_objects[1:]): common.add(attr_name) except Exception: # Skip properties that fail to access pass return common
[docs] def get_concrete_type_properties(lcm_obj): """ Get properties that are unique to an object's concrete type. When you have an LCM object typed as a base interface (e.g., IPhSegmentRule), this function identifies which properties are specific to its concrete type (e.g., RightHandSidesOS for PhRegularRule). This is useful for introspection and for determining what type-specific capabilities an object has. Args: lcm_obj: An LCM object with a ClassName attribute. Returns: dict: Mapping of property names to their values, containing only properties on the concrete type that don't exist on a simple interface comparison. Empty dict if no unique properties. Example:: from flexicon.code.lcm_casting import get_concrete_type_properties # Get type-specific properties for a rule rule = phonRuleOps.GetAll()[0] unique_props = get_concrete_type_properties(rule) if rule.ClassName == 'PhRegularRule': print('RightHandSidesOS' in unique_props) # True print(unique_props['RightHandSidesOS']) # The actual RHS collection if rule.ClassName == 'PhMetathesisRule': print('StrucDescOS' in unique_props) # True Notes: - Returns empty dict if object has no ClassName attribute - Properties are returned as property_name -> value mappings - Private attributes (starting with _) are excluded - Callable attributes (methods) are excluded - Use with get_common_properties() to understand type diversity """ if not hasattr(lcm_obj, "ClassName"): return {} # Get the concrete type concrete = cast_to_concrete(lcm_obj) concrete_props = {} # Get all public, non-callable attributes from concrete type for attr_name in dir(concrete): # Skip private attributes if attr_name.startswith("_"): continue # Skip known system attributes if attr_name in ["ClassName", "Guid", "Hvo", "ClassID", "Owner", "OwningFlid"]: continue try: attr_value = getattr(concrete, attr_name) # Skip callable attributes (methods) if callable(attr_value): continue # Add this property concrete_props[attr_name] = attr_value except Exception: # Skip properties that fail to access pass return concrete_props