#
# ParserOperations.py
#
# Class: ParserOperations
# READ-ONLY access to the FieldWorks morphological parser for
# SIL Language and Culture Model (LCM) projects.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2026
#
# ---------------------------------------------------------------------------
# NOTHING FROM THE PARSER COMPONENT IS IMPORTED AT MODULE SCOPE.
#
# This is the whole of FR-003, and it is not a style preference. `import
# flexicon` must keep working on a machine whose parser component is missing,
# renamed or relocated -- such a machine degrades to "parser unavailable",
# never to a broken package import. Every reference to ParserCore below sits
# INSIDE a function, and tests/test_parser_structure.py (A1.3) asserts that
# by AST walk, in both directions: no module-scope parser import anywhere in
# the package, and at least one function-local one here.
# ---------------------------------------------------------------------------
import os
from dataclasses import dataclass
from typing import Optional
from ..BaseOperations import BaseOperations, OperationsMethod
from ..exceptions import FP_ParameterError, FP_RuntimeError
# ---------------------------------------------------------------------------
# What the facade binds, and where it looks
# ---------------------------------------------------------------------------
PARSER_COMPONENT = "ParserCore.dll"
DATA_MODEL_COMPONENT = "SIL.LCModel.dll"
PARSER_NAMESPACE = "SIL.FieldWorks.WordWorks.Parser"
PARSER_ASSEMBLY = "ParserCore"
#: Every member this facade calls, rendered the way the C# source spells it.
#: Verified against the real installed component by tier A2
#: (tests/test_parser_reflective.py) BEFORE any of the behaviour below was
#: written -- Constitution Principle I. `Reset()` and `IsUpToDate()` had
#: never been verified anywhere, in either repository, before that tier.
#:
#: Deliberately NOT the same list as FlexToolsMCP's
#: src/flextoolsmcp/server/parser_probe.py. The two diverge in both
#: directions and neither is a superset of the other: that module
#: additionally requires the filing member, which is the write spine and
#: which this read facade never binds; this list additionally requires the
#: reset and currency members, which that module does not cover. Each check
#: probes what its OWN surface binds, and CP2a changes zero lines of the
#: other one (FR-041, Decision D1).
REQUIRED_MEMBERS = (
"HCParser(LcmCache)",
"Update()",
"Reset()",
"IsUpToDate()",
"ParseWord(string)",
"ParseWordXml(string)",
"TraceWordXml(string, IEnumerable<int>)",
)
_CLR_PRIMITIVE_NAMES = {
"String": "string",
"Int32": "int",
"Boolean": "bool",
"Void": "void",
"Object": "object",
}
# ---------------------------------------------------------------------------
# ParserAvailability
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class ParserAvailability:
"""Whether this project can reach the parser, and why not if it cannot.
Returned, never raised. Asking whether the parser is reachable succeeds
on every machine, in every condition -- a missing component, a component
from another FieldWorks install, a component whose surface has moved, a
permissions error on the directory, a pythonnet failure. That is SC-001,
and it is the single property tier A1 exists to test.
This is the first degrading-with-reason return in flexicon. The package
otherwise has exactly two behaviours, raise or silently become None, and
neither is right here: a script that wants to know whether it can parse
should be able to ask without guarding the question in a try block.
Attributes:
available (bool): Whether the parser operations may be called.
reason (str): Why not, when `available` is False. Empty when
available. States WHAT WAS CHECKED and nothing more.
version (str or None): The detected component version, or None when
the component was never loaded. Reported, never compared.
Notes:
- `version` is REPORTED AND NEVER COMPARED AGAINST A MINIMUM
(FR-006). A version floor is the check that looks reasonable in
review and then refuses to run on the one machine whose
FieldWorks build number sorts the wrong way. The gate is the two
checks below, neither of which reads a number:
same-installation, and the bound surface exists. A standing AST
test on each side of the repository boundary asserts zero
comparisons (tests/test_parser_structure.py, A1.2).
- The same-installation check is DIRECTORY EQUALITY ONLY. See
`GetAvailability` for the limit of what that proves.
Example:
>>> status = project.Parser.GetAvailability()
>>> if not status.available:
... print(f"No parser: {status.reason}")
... else:
... print(f"Parser {status.version} ready")
Parser 9.3.10 ready
See Also:
ParserOperations.GetAvailability
"""
available: bool
reason: str
version: Optional[str] = None
# ---------------------------------------------------------------------------
# Resolution seams
#
# These two are module-level functions rather than expressions inlined into
# the probe for a reason that is testable rather than aesthetic: tier A1
# replaces them to simulate a component that cannot be resolved. A probe with
# no seam can only be observed on a machine that happens to be broken, which
# is not a test -- it is a machine. tests/test_parser_offline.py asserts both
# names exist, so they are contract, not incidental implementation.
# ---------------------------------------------------------------------------
def _parser_component_path():
"""Where ParserCore.dll is expected, per flexicon's own install resolution.
Uses FLExGlobals.FWCodeDir -- the registry-derived FieldWorks directory
that `import flexicon` has already resolved through FLExInit. Deliberately
not FlexToolsMCP's resolution: this repository does not import that one
(Decision D1), and the two checks stay independent on purpose.
"""
from .. import FLExGlobals
if not FLExGlobals.FWCodeDir:
FLExGlobals.InitialiseFWGlobals()
return os.path.join(os.path.normpath(FLExGlobals.FWCodeDir), PARSER_COMPONENT)
def _data_model_dir():
"""The directory the LOADED data model assembly actually came from.
"The data model in use" in FR-004 means the SIL.LCModel already resolved
into this process, not the one a directory happens to contain. Reading
the loaded assembly's own location is what makes the comparison in
`GetAvailability` non-trivial: an install directory pointing somewhere
other than where the data model was loaded from IS the foreign-install
case, and it is the failure no version floor would have caught.
Returns None when the assembly cannot be located, which the caller
reports as unavailable rather than treating as a match.
"""
try:
import System
for assembly in System.AppDomain.CurrentDomain.GetAssemblies():
if assembly.GetName().Name == "SIL.LCModel":
location = assembly.Location
if location:
return os.path.dirname(os.path.normpath(location))
except Exception:
return None
return None
def _format_clr_type(clr_type):
"""Render a .NET System.Type the way the C# source spells it."""
try:
if clr_type.IsGenericType:
import re
base = re.sub(r"`\d+", "", clr_type.Name)
args = ", ".join(_format_clr_type(a) for a in clr_type.GetGenericArguments())
return "%s<%s>" % (base, args)
except Exception:
pass
try:
if clr_type.IsArray:
return "%s[]" % _format_clr_type(clr_type.GetElementType())
except Exception:
pass
name = getattr(clr_type, "Name", str(clr_type))
return _CLR_PRIMITIVE_NAMES.get(name, name)
def _reflect_member_surface(component_path):
"""(present member names, detected version) for the component on disk.
Reflection only -- Assembly.LoadFile plus GetConstructors/GetMethods. No
cache is constructed, no grammar is loaded and no word is parsed, so
probing is free of side effects on the project.
"""
import System
from System.Reflection import BindingFlags
assembly = System.Reflection.Assembly.LoadFile(component_path)
name = assembly.GetName()
detected = "%d.%d.%d" % (name.Version.Major, name.Version.Minor, name.Version.Build)
flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly
present = set()
for clr_type in assembly.GetTypes():
if clr_type.Name != "HCParser":
continue
for ctor in clr_type.GetConstructors(flags):
params = ", ".join(_format_clr_type(p.ParameterType) for p in ctor.GetParameters())
present.add("%s(%s)" % (clr_type.Name, params))
for method in clr_type.GetMethods(flags):
params = ", ".join(_format_clr_type(p.ParameterType) for p in method.GetParameters())
present.add("%s(%s)" % (method.Name, params))
return present, detected
def _probe_availability():
"""Resolve availability. Never raises, whatever it hits.
The blanket except is deliberate and is the requirement, not laziness:
SC-001 says the ANSWER is still an answer when the probe itself fails.
A corrupt component, a pythonnet that will not load, a directory the
process cannot stat -- each produces an unavailable status carrying what
went wrong, because a caller who asked a question deserves a sentence
rather than a traceback.
"""
try:
component = _parser_component_path()
except Exception as exc:
return ParserAvailability(False, "the FieldWorks installation could not be resolved: %s" % exc)
try:
if not os.path.exists(component):
return ParserAvailability(
False,
"%s was not found at %s" % (PARSER_COMPONENT, component),
)
model_dir = _data_model_dir()
if model_dir is None:
return ParserAvailability(
False,
"the loaded %s could not be located, so the same-installation "
"check could not be made" % DATA_MODEL_COMPONENT,
)
component_dir = os.path.dirname(component)
if os.path.normcase(component_dir) != os.path.normcase(model_dir):
return ParserAvailability(
False,
"%s was found in %s but the data model in use was loaded from "
"%s; the two directories differ, so the component belongs to a "
"different FieldWorks installation"
% (PARSER_COMPONENT, component_dir, model_dir),
)
present, detected = _reflect_member_surface(component)
missing = sorted(set(REQUIRED_MEMBERS) - present)
if missing:
return ParserAvailability(
False,
"%s at %s does not declare %s"
% (PARSER_COMPONENT, component, ", ".join(missing)),
)
return ParserAvailability(True, "", detected)
except Exception as exc:
return ParserAvailability(
False,
"%s at %s could not be inspected: %s" % (PARSER_COMPONENT, component, exc),
)
# ---------------------------------------------------------------------------
# ParserOperations
# ---------------------------------------------------------------------------
[docs]
class ParserOperations(BaseOperations):
"""
READ-ONLY access to the FieldWorks morphological parser.
Reached as ``project.Parser``. Ask a word whether it parses, get the
parser's structured answer, or trace an attempt to see why it failed.
THIS SURFACE IS READ-ONLY BY CONSTRUCTION, AND THAT IS LOAD-BEARING.
There is no operation here that records, files or otherwise writes a
parse result back into the project, and there is no transaction and no
write-enable check anywhere in this module -- because there is nothing
to guard. If you are about to add a method that persists a parse, stop:
the read-only safety claim made by every caller of this surface rests on
that absence, and a standing test enumerates the public surface by set
equality to keep it true (tests/test_parser_offline.py, A1.4). Persisting
a parse is a later checkpoint's work and arrives with its own guards.
THE EXACT LIMIT OF THAT CLAIM, since a caller is entitled to know where
it stops. "Read-only" covers everything this class ADDS -- all six
methods above. It does not extend to the generic reordering helpers every
Operations class inherits from BaseOperations. Four of them (Sort,
MoveUp, MoveDown, MoveToIndex) are inert here because they route through
`_GetSequence`, which this class deliberately does not override. The rest
-- Swap, MoveBefore, MoveAfter, ApplySyncableProperties -- take their
targets as arguments and WILL write if you hand them writable objects.
None of them can record a parse result, and this class adds no new reach,
but `project.Parser.Swap(a, b)` is not a no-op and should not be read as
one.
DEGRADES, DOES NOT EXPLODE. On a machine where the parser component is
missing, relocated, or from a different FieldWorks installation,
importing flexicon still works and ``GetAvailability()`` answers with a
reason. Only CALLING an operation raises, and it raises with that same
reason. Ask first if you are not sure::
status = project.Parser.GetAvailability()
if not status.available:
report.Warning(status.reason)
ONE GRAMMAR, AND ITS CURRENCY IS ASKED, NEVER ASSUMED. At most one
loaded grammar is held at a time, for the project in use; switching
projects releases the previous one. Before every parse the parser is
ASKED whether its grammar is current, and a stale grammar is reloaded
before the word is parsed -- never after, and never from a local flag
that this class maintains, because a local flag is exactly what goes
stale when something else changes the model.
Usage::
from flexicon import FLExProject
project = FLExProject()
project.OpenProject("IndonesianHC-Complete", writeEnabled=False)
status = project.Parser.GetAvailability()
if status.available:
result = project.Parser.ParseWord("mengambil")
trace = project.Parser.TraceWordXml("mengambil")
project.CloseProject()
See Also:
ParserAvailability
"""
def __init__(self, project):
"""
Initialize ParserOperations with a FLExProject instance.
Args:
project: The FLExProject instance to operate on.
"""
super().__init__(project)
# Resolved once, lazily, on the first question asked. The probe is
# terminal per instance: a component that was absent a moment ago is
# not going to appear mid-script, and re-probing per call would make
# every parse pay for reflection.
self._availability = None
# The bound parser handle and the cache it was built for. Holding the
# cache alongside the handle is how "at most one grammar, for the
# project in use" is enforced without a registry: a handle built for
# a different cache is released rather than reused.
#
# These two names, with `_availability` above, are the documented
# seam tier A1 drives the currency path through. They are contract.
self._parser = None
self._parser_cache = None
# Whether the last operation left the component's morpher filtered to
# a subset of entries and rules. See _ClearAnyRestriction: this is not
# bookkeeping for its own sake, it is what stops a restricted trace
# from silently truncating the NEXT plain parse.
self._restricted = False
# --- Availability ---
@OperationsMethod
def GetAvailability(self):
"""
Report whether this project can reach the parser, and why not if it cannot.
Asking NEVER raises, on any machine, in any condition. A missing
component, a component from another FieldWorks install, a component
whose surface has moved, a permissions error, a pythonnet failure --
each produces a status carrying what went wrong.
Args:
None
Returns:
ParserAvailability: `available`, `reason`, and the detected
`version` (reported, never compared against a minimum).
Example:
>>> status = project.Parser.GetAvailability()
>>> print(status.available, status.version)
True 9.3.10
Notes:
- Two checks, and NEITHER READS A VERSION NUMBER (FR-006):
(1) the component resolves from the same directory the data
model in use was loaded from, and (2) every member this class
calls is present on it.
- WHAT CHECK 1 DOES NOT PROVE, stated here because the reason
string must not imply more than was tested: the test is
DIRECTORY EQUALITY ONLY. A foreign ParserCore.dll copied into
the correct FieldWorks directory passes it undetected. It
catches the accident -- two installs mixed across an
in-process boundary -- not the adversary.
- The member list checked here is deliberately NOT the same list
FlexToolsMCP's src/flextoolsmcp/server/parser_probe.py checks,
and the two are not drifting apart by accident. That module
probes the surface IT binds, which includes the member that
files a parse result; this class never binds that member, and
it additionally requires the reset and currency members, which
that module has no use for. Neither list is a superset of the
other and neither should be reconciled into the other
(Decision D1).
- The result is cached for the lifetime of this instance.
See Also:
ParserAvailability
"""
if self._availability is None:
self._availability = _probe_availability()
return self._availability
# --- Parsing ---
@OperationsMethod
def ParseWord(self, word):
"""
Parse a single word form and return the parser's result object.
Args:
word (str): The word form to parse, in the vernacular.
Returns:
ParseResult: The parser's own result. Analyses on it carry LIVE
references to the lexical objects they were built from --
IMoForm, IMoMorphSynAnalysis, ILexEntryInflType -- not their
text. A caller can go from a parse straight back to the entry
that produced it without a lookup.
Raises:
FP_RuntimeError: If the parser is unavailable, carrying the same
reason `GetAvailability()` would have reported.
Example:
>>> result = project.Parser.ParseWord("mengambil")
>>> print(result.Analyses.Count)
2
Notes:
- The grammar's currency is confirmed immediately before the
parse, and a stale grammar is reloaded first.
- Object identity is preserved here and NOT on the serialized
forms below: once the parser has written a document, the
objects in it survive only as integer identifiers.
See Also:
ParseWordXml, TraceWordXml, GetAvailability
"""
handle = self._CurrentHandle()
self._ClearAnyRestriction(handle, word)
return handle.ParseWord(word)
@OperationsMethod
def ParseWordXml(self, word):
"""
Parse a single word form and return the parser's structured document.
Args:
word (str): The word form to parse, in the vernacular.
Returns:
XDocument: The parser's serialized parse document.
Raises:
FP_RuntimeError: If the parser is unavailable, carrying the same
reason `GetAvailability()` would have reported.
Example:
>>> doc = project.Parser.ParseWordXml("mengambil")
>>> print(doc.Root.Name.LocalName)
Wordform
Notes:
- Lexical objects survive on this document as integer
identifiers, not as live references. Use `ParseWord` when you
need to reach the objects themselves.
- The grammar's currency is confirmed immediately before the
parse, and a stale grammar is reloaded first.
See Also:
ParseWord, TraceWordXml
"""
result = self._CurrentHandle().ParseWordXml(word)
# The component resets its own selectors on this path (it passes a
# null restriction through), so whatever a previous trace narrowed is
# open again once this returns.
self._restricted = False
return result
@OperationsMethod
def TraceWordXml(self, word, analyses=None):
"""
Trace a parse attempt, optionally restricted to specific analyses.
A trace explains an attempt: which rules fired, which failed, and
where a candidate was rejected. It is the expensive answer, and the
one worth asking for when a plain parse came back empty.
Args:
word (str): The word form to trace, in the vernacular.
analyses (iterable of int, optional): Identifiers of the
analyses to restrict the trace to. None traces without
restriction. An EMPTY sequence is refused -- see Raises.
Returns:
XDocument: The parser's serialized trace document.
Raises:
FP_RuntimeError: If the parser is unavailable, carrying the same
reason `GetAvailability()` would have reported.
FP_ParameterError: If `analyses` is an empty sequence. Pass None
for an unrestricted trace; an empty restriction would limit
the trace to no analyses at all.
Example:
>>> doc = project.Parser.TraceWordXml("mengambil")
>>> print(doc.Root.Name.LocalName)
Wordform
Notes:
- The restriction is passed through EXACTLY as given. This
method never substitutes a different restriction, never
widens one to an unrestricted search, and never reorders or
scores what it was handed. That is also why an empty
restriction is refused instead of being read as "no
restriction" -- widening it would answer a different question.
- A RESTRICTION OUTLIVES THE CALL, inside the component. The
component narrows its morpher's entry and rule selectors and
leaves them narrowed, and a plain parse does not reset them.
This class undoes that before the next plain parse, so callers
do not have to know; the cost is one extra parse, paid only
when a restricted trace is actually followed by a plain parse.
- Objects in the returned document are integer identifiers;
turning them back into lexical objects is a repository lookup
the caller makes, and this class deliberately does not do it
for them.
- The grammar's currency is confirmed immediately before the
trace, and a stale grammar is reloaded first.
See Also:
ParseWord, ParseWordXml
"""
restriction = self._AsIdentifierSequence(analyses)
handle = self._CurrentHandle()
# SET BEFORE THE CALL, NOT AFTER, AND THE ORDER IS THE WHOLE POINT.
# The component installs its selectors at the START of the call, so
# they are narrowed the moment it begins. If the parse then throws --
# a difficult form, a bad identifier in the restriction, a
# marshalling failure -- a flag set afterwards would never be set,
# _ClearAnyRestriction would return early, and the next plain parse
# would be served truncated: silently, indefinitely, which is
# verbatim the defect this whole mechanism exists to prevent.
# Setting it first fails the safe way. The worst case is one wasted
# clearing parse; the worst case the other way round is a wrong
# answer.
self._restricted = restriction is not None
return handle.TraceWordXml(word, restriction)
# --- Grammar lifetime ---
@OperationsMethod
def Reload(self):
"""
Discard the loaded grammar and load it again. Unconditionally.
Args:
None
Returns:
None
Raises:
FP_RuntimeError: If the parser is unavailable, carrying the same
reason `GetAvailability()` would have reported.
Example:
>>> project.Parser.Reload()
>>> print(project.Parser.IsUpToDate())
True
Notes:
- THIS IS TWO STEPS, RESET THEN UPDATE, AND THE ORDER MATTERS.
The component's own update is guarded by a condition that
short-circuits when it believes the model has not changed, so
a reload bound to a bare update would return having done
nothing and serve the next parse from the very grammar the
caller just asked to have discarded. The reset is what makes
the update unconditional. FieldWorks' own reload does the same
two steps in the same order.
- You rarely need to call this. Every parse already confirms
currency and reloads a stale grammar on its own; this is for
the case where you changed the model yourself and want the
cost paid now rather than on the next word.
See Also:
IsUpToDate
"""
handle = self._CurrentHandle(confirm_currency=False)
handle.Reset()
handle.Update()
# A reloaded grammar has open selectors, so nothing is left to undo.
# Kept in step deliberately: the flag is meant to TRACK the
# component's state, not merely to remember what this class last did.
self._restricted = False
@OperationsMethod
def IsUpToDate(self):
"""
Ask the parser whether its loaded grammar is current.
Args:
None
Returns:
bool: True if the loaded grammar matches the project's current
model, False if it is stale.
Raises:
FP_RuntimeError: If the parser is unavailable, carrying the same
reason `GetAvailability()` would have reported.
Example:
>>> print(project.Parser.IsUpToDate())
True
Notes:
- The question is put to the PARSER every time it is asked. This
class keeps no flag of its own, because a flag it maintained
would be right only until something outside this class changed
the model -- which is precisely the case the question exists
to detect.
See Also:
Reload
"""
return bool(self._CurrentHandle(confirm_currency=False).IsUpToDate())
# --- Internals ---
#
# Every call into the component below is bound POSITIONALLY, never by
# parameter name. The interface declares its parameter as `word` and the
# implementation spells it `form`, so a keyword call resolves against
# whichever of the two pythonnet happened to pick -- working on the
# author's machine and failing on someone else's. Positional binding
# makes the disagreement harmless, and tier A1 asserts it structurally
# rather than leaving it as a convention to remember.
def _Require(self):
"""Refuse with the availability reason, or return the status."""
status = self.GetAvailability()
if not status.available:
raise FP_RuntimeError(status.reason)
return status
def _ReleaseGrammar(self):
"""Drop the held grammar, discarding it first if we still can.
Best-effort by design: this runs when we are switching away from a
cache, and a handle whose project is already closing may refuse the
discard. Failing to tidy up an outgoing grammar must not break the
incoming one.
"""
handle = self._parser
self._parser = None
self._parser_cache = None
# The next handle is a fresh parser with open selectors.
self._restricted = False
if handle is None:
return
try:
handle.Reset()
except Exception:
pass
def _CurrentHandle(self, confirm_currency=True):
"""The bound parser for the project in use, with a current grammar.
Enforces both grammar-lifetime clauses in one place, which is why
every public operation goes through it and none reaches the
component directly: at most one grammar is held, for the cache in
use, and its currency is confirmed immediately before it is reused.
"""
self._Require()
cache = self.project.project
if self._parser is None or self._parser_cache is not cache:
# A handle built for another project is released before the new
# one is built, never alongside it -- "at most one" is a bound on
# what is held at any instant, not a tidy-up done afterwards.
self._ReleaseGrammar()
handle = self._ConstructParser(cache)
handle.Update()
self._parser = handle
self._parser_cache = cache
return handle
handle = self._parser
if confirm_currency and not handle.IsUpToDate():
handle.Reset()
handle.Update()
return handle
def _ConstructParser(self, cache):
"""Bind the component and construct a parser over `cache`.
The import is FUNCTION-LOCAL, and that is FR-003: loading the
component is triggered by USE, so a machine that never touches
project.Parser never needs ParserCore.dll to exist, and one whose
installation moved degrades to unavailable instead of failing to
import flexicon at all.
"""
import clr
clr.AddReference(PARSER_ASSEMBLY)
from SIL.FieldWorks.WordWorks.Parser import HCParser
return HCParser(cache)
def _ClearAnyRestriction(self, handle, word):
"""Undo a restriction a previous trace left on the component.
WHY THIS EXISTS -- verified against the component's source and
against a live project, not inferred. ParserCore's ParseToXml sets
m_morpher.LexEntrySelector / m_morpher.RuleSelector
from the restriction it was handed, at the START of every call, and
those selectors OUTLIVE the call. Plain ParseWord is the one entry
point that never touches them -- it calls the morpher directly. So a
restricted trace followed by a plain parse returns a truncated
answer, silently and indefinitely, until something else happens to
reset the selectors.
Observed live on IndonesianHC-Complete: a word parsing with one
analysis returns ZERO after any restricted trace, and keeps
returning zero, until an unrestricted call re-opens the selectors.
No exception, no warning -- just a wrong answer, which is the worst
shape a defect can take.
The reset uses the component's OWN path: ParseWordXml passes a null
restriction, which is what makes ParseToXml reopen both selectors.
It costs one extra parse, paid only when a restricted trace actually
preceded a plain parse, never on the common unrestricted route.
"""
if not self._restricted:
return
handle.ParseWordXml(word)
self._restricted = False
def _AsIdentifierSequence(self, analyses):
"""Render `analyses` as the restriction the component expects.
None means DO NOT RESTRICT, and the component spells that as null --
NOT as an empty sequence. The distinction is load-bearing and the
two are near-opposites: ParseToXml branches on
`selectTraceMorphs != null`, so a null reopens the selectors while
an EMPTY array installs a filter that admits nothing. Passing an
empty array for "no restriction" is therefore not a harmless
approximation -- it restricts the parser to nothing at all, and
leaves it that way.
An explicitly empty sequence is refused rather than quietly widened.
Restricting to no analyses can only ever produce no analyses, so it
is a caller error; and silently turning it into an unrestricted
search would be answering a different question than the one asked.
"""
if analyses is None:
return None
values = [int(item) for item in analyses]
if not values:
raise FP_ParameterError(
"analyses is empty. An empty restriction limits the trace to "
"no analyses at all, which can only return nothing. Pass None "
"to trace without restriction, or pass the analyses you want."
)
import System
sequence = System.Array[System.Int32](len(values))
for index, value in enumerate(values):
sequence[index] = value
return sequence