exploration.parsing

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2023-12-27
  • Purpose: Common code for parsing things, including conversions to custom string formats and JSON for some types.
   1"""
   2- Authors: Peter Mawhorter
   3- Consulted:
   4- Date: 2023-12-27
   5- Purpose: Common code for parsing things, including conversions to
   6    custom string formats and JSON for some types.
   7"""
   8
   9from typing import (
  10    Union, Dict, Optional, get_args, Tuple, List, cast, Set, TypeVar,
  11    Literal, TypeAlias, Generator, TypedDict, TextIO, Any, Callable,
  12    Type, Sequence
  13)
  14
  15import enum
  16import collections
  17import copy
  18import warnings
  19import json
  20import types
  21
  22import networkx  # type: ignore
  23
  24from . import base
  25from . import core
  26from . import utils
  27from . import commands
  28
  29
  30#----------------#
  31# Format Details #
  32#----------------#
  33
  34Lexeme = enum.IntEnum(
  35    "Lexeme",
  36    [
  37        'domainSeparator',
  38        'zoneSeparator',
  39        'partSeparator',
  40        'stateOn',
  41        'stateOff',
  42        'tokenCount',
  43        'effectCharges',
  44        'sepOrDelay',
  45        'consequenceSeparator',
  46        'inCommon',
  47        'isHidden',
  48        'skillLevel',
  49        'wigglyLine',
  50        'withDetails',
  51        'reciprocalSeparator',
  52        'mechanismSeparator',
  53        'openCurly',
  54        'closeCurly',
  55        'openParen',
  56        'closeParen',
  57        'angleLeft',
  58        'angleRight',
  59        'doubleQuestionmark',
  60        'ampersand',
  61        'orBar',
  62        'notMarker',
  63    ]
  64)
  65"""
  66These are the different separators, grouping characters, and keywords
  67used as part of parsing. The characters that are actually recognized are
  68defined as part of a `Format`.
  69"""
  70
  71Format = Dict[Lexeme, str]
  72"""
  73A journal format is specified using a dictionary with keys that denote
  74journal marker types and values which are one-to-several-character
  75strings indicating the markup used for that entry/info type.
  76"""
  77
  78DEFAULT_FORMAT: Format = {
  79    # Separator
  80    Lexeme.domainSeparator: '//',
  81    Lexeme.zoneSeparator: '::',
  82    Lexeme.partSeparator: '%%',
  83    Lexeme.stateOn: '=on',  # TODO :Lexing issue!
  84    Lexeme.stateOff: '=off',
  85    Lexeme.tokenCount: '*',
  86    Lexeme.effectCharges: '=',
  87    Lexeme.sepOrDelay: ',',
  88    Lexeme.consequenceSeparator: ';',
  89    Lexeme.inCommon: '+c',
  90    Lexeme.isHidden: '+h',
  91    Lexeme.skillLevel: '^',
  92    Lexeme.wigglyLine: '~',
  93    Lexeme.withDetails: '%',
  94    Lexeme.reciprocalSeparator: '/',
  95    Lexeme.mechanismSeparator: ':',
  96    Lexeme.openCurly: '{',
  97    Lexeme.closeCurly: '}',
  98    Lexeme.openParen: '(',
  99    Lexeme.closeParen: ')',
 100    Lexeme.angleLeft: '<',
 101    Lexeme.angleRight: '>',
 102    Lexeme.doubleQuestionmark: '??',
 103    Lexeme.ampersand: '&',
 104    Lexeme.orBar: '|',
 105    Lexeme.notMarker: '!',
 106}
 107"""
 108The default parsing format.
 109"""
 110
 111DEFAULT_EFFECT_NAMES: Dict[str, base.EffectType] = {
 112    x: x for x in get_args(base.EffectType)
 113}
 114"""
 115Default names for each effect type. Maps names to canonical effect type
 116strings. A different mapping could be used to allow for writing effect
 117names in another language, for example.
 118"""
 119
 120DEFAULT_FOCALIZATION_NAMES: Dict[str, base.DomainFocalization] = {
 121    x: x for x in get_args(base.DomainFocalization)
 122}
 123"""
 124Default names for each domain focalization type. Maps each focalization
 125type string to itself.
 126"""
 127
 128DEFAULT_SF_INDICATORS: Tuple[str, str] = ('s', 'f')
 129"""
 130Default characters used to indicate success/failure when transcribing a
 131`TransitionWithOutcomes`.
 132"""
 133
 134
 135#-------------------#
 136# Errors & Warnings #
 137#-------------------#
 138
 139class ParseWarning(Warning):
 140    """
 141    Represents a warning encountered when parsing something.
 142    """
 143    pass
 144
 145
 146class ParseError(ValueError):
 147    """
 148    Represents a error encountered when parsing.
 149    """
 150    pass
 151
 152
 153class DotParseError(ParseError):
 154    """
 155    An error raised during parsing when incorrectly-formatted graphviz
 156    "dot" data is provided. See `parseDot`.
 157    """
 158    pass
 159
 160
 161class InvalidFeatureSpecifierError(ParseError):
 162    """
 163    An error used when a feature specifier is in the wrong format.
 164    Errors with part specifiers also use this.
 165    """
 166
 167
 168#--------#
 169# Lexing #
 170#--------#
 171
 172LexedTokens: TypeAlias = List[Union[Lexeme, str]]
 173"""
 174When lexing, we pull apart a string into pieces, but when we recognize
 175lexemes, we use their integer IDs in the list instead of strings, so we
 176get a list that's a mix of ints and strings.
 177"""
 178
 179GroupedTokens: TypeAlias = List[Union[Lexeme, str, 'GroupedTokens']]
 180"""
 181Some parsing processes group tokens into sub-lists. This type represents
 182`LexedTokens` which might also contain sub-lists, to arbitrary depth.
 183"""
 184
 185GroupedRequirementParts: TypeAlias = List[
 186    Union[Lexeme, base.Requirement, 'GroupedRequirementParts']
 187]
 188"""
 189Another intermediate parsing result during requirement parsing: a list
 190of `base.Requirements` possibly with some sub-lists and/or `Lexeme`s
 191mixed in.
 192"""
 193
 194
 195def lex(
 196    characters: str,
 197    tokenMap: Optional[Dict[str, Lexeme]] = None
 198) -> LexedTokens:
 199    """
 200    Lexes a list of tokens from a characters string. Recognizes any
 201    special characters you provide in the token map, as well as
 202    collections of non-mapped characters. Recognizes double-quoted
 203    strings which can contain any of those (and which use
 204    backslash-escapes for internal double quotes) and includes quoted
 205    versions of those strings as tokens (any token string starting with a
 206    double quote will be such a string). Breaks tokens on whitespace
 207    outside of quotation marks, and ignores that whitespace.
 208
 209    Examples:
 210
 211    >>> lex('abc')
 212    ['abc']
 213    >>> lex('(abc)', {'(': 0, ')': 1})
 214    [0, 'abc', 1]
 215    >>> lex('{(abc)}', {'(': 0, ')': 1, '{': 2, '}': 3})
 216    [2, 0, 'abc', 1, 3]
 217    >>> lex('abc def')
 218    ['abc', 'def']
 219    >>> lex('abc   def')
 220    ['abc', 'def']
 221    >>> lex('abc \\n def')
 222    ['abc', 'def']
 223    >>> lex ('"quoted"')
 224    ['"quoted"']
 225    >>> lex ('"quoted  pair"')
 226    ['"quoted  pair"']
 227    >>> lex ('  oneWord | "two words"|"three  words words" ', {'|': 0})
 228    ['oneWord', 0, '"two words"', 0, '"three  words words"']
 229    >>> tokenMap = { c: i for (i, c) in enumerate("(){}~:;>,") }
 230    >>> tokenMap['::'] = 9
 231    >>> tokenMap['~~'] = 10
 232    >>> lex(
 233    ...     '{~~2:best(brains, brawn)>{set switch on}'
 234    ...     '{deactivate ,1; bounce}}',
 235    ...     tokenMap
 236    ... )
 237    [2, 10, '2', 5, 'best', 0, 'brains', 8, 'brawn', 1, 7, 2, 'set',\
 238 'switch', 'on', 3, 2, 'deactivate', 8, '1', 6, 'bounce', 3, 3]
 239    >>> lex('set where::mechanism state', tokenMap)
 240    ['set', 'where', 9, 'mechanism', 'state']
 241    >>> # Note r' doesn't take full effect 'cause we're in triple quotes
 242    >>> esc = r'"escape \\\\a"'
 243    >>> result = [ r'"escape \\\\a"' ]  # 'quoted' doubles the backslash
 244    >>> len(esc)
 245    12
 246    >>> len(result[0])
 247    12
 248    >>> lex(esc) == result
 249    True
 250    >>> quoteInQuote = r'before "hello \\\\ \\" goodbye"after'
 251    >>> # Note r' doesn't take full effect 'cause we're in triple quotes
 252    >>> expect = ['before', r'"hello \\\\ \\" goodbye"', 'after']
 253    >>> lex(quoteInQuote) == expect
 254    True
 255    >>> lex('O\\'Neill')
 256    ["O'Neill"]
 257    >>> lex('one "quote ')
 258    ['one', '"quote "']
 259    >>> lex('geo*15', {'*': 0})
 260    ['geo', 0, '15']
 261    """
 262    if tokenMap is None:
 263        tokenMap = {}
 264    tokenStarts: Dict[str, List[str]] = {}
 265    for key in sorted(tokenMap.keys(), key=lambda x: -len(x)):
 266        tokenStarts.setdefault(key[:1], []).append(key)
 267    tokens: LexedTokens = []
 268    sofar = ''
 269    inQuote = False
 270    escaped = False
 271    skip = 0
 272    for i in range(len(characters)):
 273        if skip > 0:
 274            skip -= 1
 275            continue
 276
 277        char = characters[i]
 278        if escaped:
 279            # TODO: Escape sequences?
 280            sofar += char
 281            escaped = False
 282
 283        elif char == '\\':
 284            if inQuote:
 285                escaped = True
 286            else:
 287                sofar += char
 288
 289        elif char == '"':
 290            if sofar != '':
 291                if inQuote:
 292                    tokens.append(utils.quoted(sofar))
 293                else:
 294                    tokens.append(sofar)
 295            sofar = ''
 296            inQuote = not inQuote
 297
 298        elif inQuote:
 299            sofar += char
 300
 301        elif char in tokenStarts:
 302            options = tokenStarts[char]
 303            hit: Optional[str] = None
 304            for possibility in options:
 305                lp = len(possibility)
 306                if (
 307                    (lp == 1 and char == possibility)
 308                or characters[i:i + lp] == possibility
 309                ):
 310                    hit = possibility
 311                    break
 312
 313            if hit is not None:
 314                if sofar != '':
 315                    tokens.append(sofar)
 316                tokens.append(tokenMap[possibility])
 317                sofar = ''
 318                skip = len(hit) - 1
 319            else:  # Not actually a recognized token
 320                sofar += char
 321
 322        elif char.isspace():
 323            if sofar != '':
 324                tokens.append(sofar)
 325            sofar = ''
 326
 327        else:
 328            sofar += char
 329
 330    if sofar != '':
 331        if inQuote:
 332            tokens.append(utils.quoted(sofar))
 333        else:
 334            tokens.append(sofar)
 335
 336    return tokens
 337
 338
 339def unLex(
 340    tokens: LexedTokens,
 341    tokenMap: Optional[Dict[str, Lexeme]] = None
 342) -> str:
 343    """
 344    Turns lexed stuff back into a string, substituting strings back into
 345    token spots by reversing the given token map. Adds quotation marks to
 346    complex tokens where necessary to prevent them from re-lexing into
 347    multiple tokens (but `lex` doesn't  remove those, so in some cases
 348    there's not a perfect round-trip unLex -> lex).
 349
 350    For example:
 351
 352    >>> unLex(['a', 'b'])
 353    'a b'
 354    >>> tokens = {'(': 0, ')': 1, '{': 2, '}': 3, '::': 4}
 355    >>> unLex([0, 'hi', 1], tokens)
 356    '(hi)'
 357    >>> unLex([0, 'visit', 'zone', 4, 'decision', 1], tokens)
 358    '(visit zone::decision)'
 359    >>> q = unLex(['a complex token', '\\'single\\' and "double" quotes'])
 360    >>> q  # unLex adds quotes
 361    '"a complex token" "\\'single\\' and \\\\"double\\\\" quotes"'
 362    >>> lex(q)  # Not the same as the original list
 363    ['"a complex token"', '"\\'single\\' and \\\\"double\\\\" quotes"']
 364    >>> lex(unLex(lex(q)))  # But further round-trips work
 365    ['"a complex token"', '"\\'single\\' and \\\\"double\\\\" quotes"']
 366
 367    TODO: Fix this:
 368    For now, it generates incorrect results when token combinations can
 369    be ambiguous. These ambiguous token combinations should not ever be
 370    generated by `lex` at least. For example:
 371
 372    >>> ambiguous = {':': 0, '::': 1}
 373    >>> u = unLex(['a', 0, 0, 'b'], ambiguous)
 374    >>> u
 375    'a::b'
 376    >>> l = lex(u, ambiguous)
 377    >>> l
 378    ['a', 1, 'b']
 379    >>> l == u
 380    False
 381    """
 382    if tokenMap is None:
 383        nTokens = 0
 384        revMap = {}
 385    else:
 386        nTokens = len(tokenMap)
 387        revMap = {y: x for (x, y) in tokenMap.items()}
 388
 389    prevRaw = False
 390    # TODO: add spaces where necessary to disambiguate token sequences...
 391    if len(revMap) != nTokens:
 392        warnings.warn(
 393            (
 394                "Irreversible token map! Two or more tokens have the same"
 395                " integer value."
 396            ),
 397            ParseWarning
 398        )
 399
 400    result = ""
 401    for item in tokens:
 402        if isinstance(item, int):
 403            try:
 404                result += revMap[item]
 405            except KeyError:
 406                raise ValueError(
 407                    f"Tokens list contains {item} but the token map"
 408                    f" does not have any entry which maps to {item}."
 409                )
 410            prevRaw = False
 411        elif isinstance(item, str):
 412            if prevRaw:
 413                result += ' '
 414            if len(lex(item)) > 1:
 415                result += utils.quoted(item)
 416            else:
 417                result += item
 418            prevRaw = True
 419        else:
 420            raise TypeError(
 421                f"Token list contained non-int non-str item:"
 422                f" {repr(item)}"
 423            )
 424
 425    return result
 426
 427
 428#-------------------#
 429# ParseFormat class #
 430#-------------------#
 431
 432def normalizeEnds(
 433    tokens: List,
 434    start: int,
 435    end: int
 436) -> Tuple[int, int, int]:
 437    """
 438    Given a tokens list and start & end integers, does some bounds
 439    checking and normalization on the integers: converts negative
 440    indices to positive indices, and raises an `IndexError` if they're
 441    out-of-bounds after conversion. Returns a tuple containing the
 442    normalized start & end indices, along with the number of tokens they
 443    cover.
 444    """
 445    totalTokens = len(tokens)
 446    if start < -len(tokens):
 447        raise IndexError(
 448            f"Negative start index out of bounds (got {start} for"
 449            f" {totalTokens} tokens)."
 450        )
 451    elif start >= totalTokens:
 452        raise IndexError(
 453            f"Start index out of bounds (got {start} for"
 454            f" {totalTokens} tokens)."
 455        )
 456    elif start < 0:
 457        start = totalTokens + start
 458
 459    if end < -len(tokens):
 460        raise IndexError(
 461            f"Negative end index out of bounds (got {end} for"
 462            f" {totalTokens} tokens)."
 463        )
 464    elif end >= totalTokens:
 465        raise IndexError(
 466            f"Start index out of bounds (got {end} for"
 467            f" {totalTokens} tokens)."
 468        )
 469    elif end < 0:
 470        end = totalTokens + end
 471
 472    if end >= len(tokens):
 473        end = len(tokens) - 1
 474
 475    return (start, end, (end - start) + 1)
 476
 477
 478def findSeparatedParts(
 479    tokens: LexedTokens,
 480    sep: Union[str, int],
 481    start: int = 0,
 482    end: int = -1,
 483    groupStart: Union[str, int, None] = None,
 484    groupEnd: Union[str, int, None] = None
 485) -> Generator[Tuple[int, int], None, None]:
 486    """
 487    Finds parts separated by a separator lexeme, such as ';' or ',', but
 488    ignoring separators nested within groupStart/groupEnd pairs (if
 489    those arguments are supplied). For each token sequence found, yields
 490    a tuple containing the start index and end index for that part, with
 491    separators not included in the parts.
 492
 493    If two separators appear in a row, the start/end pair will have a
 494    start index one after the end index.
 495
 496    If there are no separators, yields one pair containing the start and
 497    end of the entire tokens sequence.
 498
 499    Raises a `ParseError` if there are unbalanced grouping elements.
 500
 501    For example:
 502
 503    >>> list(findSeparatedParts(
 504    ...     [ 'one' ],
 505    ...     Lexeme.sepOrDelay,
 506    ...     0,
 507    ...     0,
 508    ...     Lexeme.openParen,
 509    ...     Lexeme.closeParen
 510    ... ))
 511    [(0, 0)]
 512    >>> list(findSeparatedParts(
 513    ...     [
 514    ...         'best',
 515    ...         Lexeme.openParen,
 516    ...         'chess',
 517    ...         Lexeme.sepOrDelay,
 518    ...         'checkers',
 519    ...         Lexeme.closeParen
 520    ...     ],
 521    ...     Lexeme.sepOrDelay,
 522    ...     2,
 523    ...     4,
 524    ...     Lexeme.openParen,
 525    ...     Lexeme.closeParen
 526    ... ))
 527    [(2, 2), (4, 4)]
 528    """
 529    start, end, n = normalizeEnds(tokens, start, end)
 530    level = 0
 531    thisStart = start
 532    for i in range(start, end + 1):
 533        token = tokens[i]
 534        if token == sep and level == 0:
 535            yield (thisStart, i - 1)
 536            thisStart = i + 1
 537        elif token == groupStart:
 538            level += 1
 539        elif token == groupEnd:
 540            level -= 1
 541            if level < 0:
 542                raise ParseError("Unbalanced grouping tokens.")
 543    if level < 0:
 544        raise ParseError("Unbalanced grouping tokens.")
 545    yield (thisStart, end)
 546
 547
 548K = TypeVar('K')
 549"Type variable for dictionary keys."
 550V = TypeVar('V')
 551"Type variable for dictionary values."
 552
 553
 554def checkCompleteness(
 555    name,
 556    mapping: Dict[K, V],
 557    keysSet: Optional[Set[K]] = None,
 558    valuesSet: Optional[Set[V]] = None
 559):
 560    """
 561    Checks that a dictionary has a certain exact set of keys (or
 562    values). Raises a `ValueError` if it finds an extra or missing key
 563    or value.
 564    """
 565    if keysSet is not None:
 566        for key in mapping.keys():
 567            if key not in keysSet:
 568                raise ValueError("{name} has extra key {repr(key)}.")
 569
 570        for key in keysSet:
 571            if key not in mapping:
 572                raise ValueError("{name} is missing key {repr(key)}.")
 573
 574    if valuesSet is not None:
 575        for value in mapping.values():
 576            if value not in valuesSet:
 577                raise ValueError("{name} has extra value {repr(value)}.")
 578
 579        checkVals = mapping.values()
 580        for value in valuesSet:
 581            if value not in checkVals:
 582                raise ValueError("{name} is missing value {repr(value)}.")
 583
 584
 585class ParseFormat:
 586    """
 587    A ParseFormat manages the mapping from markers to entry types and
 588    vice versa.
 589    """
 590    def __init__(
 591        self,
 592        formatDict: Format = DEFAULT_FORMAT,
 593        effectNames: Dict[str, base.EffectType] = DEFAULT_EFFECT_NAMES,
 594        focalizationNames: Dict[
 595            str,
 596            base.DomainFocalization
 597        ] = DEFAULT_FOCALIZATION_NAMES,
 598        successFailureIndicators: Tuple[str, str] = DEFAULT_SF_INDICATORS
 599    ):
 600        """
 601        Sets up the parsing format. Requires a `Format` dictionary to
 602        define the specifics. Raises a `ValueError` unless the keys of
 603        the `Format` dictionary exactly match the `Lexeme` values.
 604        """
 605        self.formatDict = formatDict
 606        self.effectNames = effectNames
 607        self.focalizationNames = focalizationNames
 608        if (
 609            len(successFailureIndicators) != 2
 610        or any(len(i) != 1 for i in successFailureIndicators)
 611        ):
 612            raise ValueError(
 613                f"Invalid success/failure indicators: must be a pair of"
 614                f" length-1 strings. Got: {successFailureIndicators!r}"
 615            )
 616        self.successIndicator, self.failureIndicator = (
 617            successFailureIndicators
 618        )
 619
 620        # Check completeness for each dictionary
 621        checkCompleteness('formatDict', self.formatDict, set(Lexeme))
 622        checkCompleteness(
 623            'effectNames',
 624            self.effectNames,
 625            valuesSet=set(get_args(base.EffectType))
 626        )
 627        checkCompleteness(
 628            'focalizationNames',
 629            self.focalizationNames,
 630            valuesSet=set(get_args(base.DomainFocalization))
 631        )
 632
 633        # Build some reverse lookup dictionaries for specific
 634        self.reverseFormat = {y: x for (x, y) in self.formatDict.items()}
 635
 636        # circumstances:
 637        self.effectModMap = {
 638            self.formatDict[x]: x
 639            for x in [
 640                Lexeme.effectCharges,
 641                Lexeme.sepOrDelay,
 642                Lexeme.inCommon,
 643                Lexeme.isHidden
 644            ]
 645        }
 646
 647    def lex(self, content: str) -> LexedTokens:
 648        """
 649        Applies `lex` using this format's lexeme mapping.
 650        """
 651        return lex(content, self.reverseFormat)
 652
 653    def onOff(self, word: str) -> Optional[bool]:
 654        """
 655        Parse an on/off indicator and returns a boolean (`True` for on
 656        and `False` for off). Returns `None` if the word isn't either
 657        the 'on' or the 'off' word. Generates a `ParseWarning`
 658        (and still returns `None`) if the word is a case-swapped version
 659        of the 'on' or 'off' word and is not equal to either of them.
 660        """
 661        onWord = self.formatDict[Lexeme.stateOn]
 662        offWord = self.formatDict[Lexeme.stateOff]
 663
 664        # Generate warning if we suspect a case error
 665        if (
 666            word.casefold() in (onWord, offWord)
 667        and word not in (onWord, offWord)
 668        ):
 669            warnings.warn(
 670                (
 671                    f"Word '{word}' cannot be interpreted as an on/off"
 672                    f" value, although it is almost one (the correct"
 673                    f" values are '{onWord}' and '{offWord}'."
 674                ),
 675                ParseWarning
 676            )
 677
 678        # return the appropriate value
 679        if word == onWord:
 680            return True
 681        elif word == offWord:
 682            return False
 683        else:
 684            return None
 685
 686    def matchingBrace(
 687        self,
 688        tokens: LexedTokens,
 689        where: int,
 690        opener: int = Lexeme.openCurly,
 691        closer: int = Lexeme.closeCurly
 692    ) -> int:
 693        """
 694        Returns the index within the given tokens list of the closing
 695        curly brace which matches the open brace at the specified index.
 696        You can specify custom `opener` and/or `closer` lexemes to find
 697        matching pairs of other things. Raises a `ParseError` if there
 698        is no opening brace at the specified index, or if there isn't a
 699        matching closing brace. Handles nested braces of the specified
 700        type.
 701
 702        Examples:
 703        >>> pf = ParseFormat()
 704        >>> ob = Lexeme.openCurly
 705        >>> cb = Lexeme.closeCurly
 706        >>> pf.matchingBrace([ob, cb], 0)
 707        1
 708        >>> pf.matchingBrace([ob, cb], 1)
 709        Traceback (most recent call last):
 710          ...
 711        exploration.parsing.ParseError: ...
 712        >>> pf.matchingBrace(['hi', ob, cb], 0)
 713        Traceback (most recent call last):
 714          ...
 715        exploration.parsing.ParseError: ...
 716        >>> pf.matchingBrace(['hi', ob, cb], 1)
 717        2
 718        >>> pf.matchingBrace(['hi', ob, 'lo', cb], 1)
 719        3
 720        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 1)
 721        Traceback (most recent call last):
 722          ...
 723        exploration.parsing.ParseError: ...
 724        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 0)
 725        3
 726        >>> pf.matchingBrace([ob, ob, cb, cb], 0)
 727        3
 728        >>> pf.matchingBrace([ob, ob, cb, cb], 1)
 729        2
 730        >>> pf.matchingBrace([ob, cb, ob, cb], 0)
 731        1
 732        >>> pf.matchingBrace([ob, cb, ob, cb], 2)
 733        3
 734        >>> pf.matchingBrace([ob, cb, cb, cb], 0)
 735        1
 736        >>> pf.matchingBrace([ob, ob, ob, cb], 0)
 737        Traceback (most recent call last):
 738          ...
 739        exploration.parsing.ParseError: ...
 740        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 0)
 741        7
 742        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 1)
 743        6
 744        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 2)
 745        Traceback (most recent call last):
 746          ...
 747        exploration.parsing.ParseError: ...
 748        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 3)
 749        4
 750        >>> op = Lexeme.openParen
 751        >>> cp = Lexeme.closeParen
 752        >>> pf.matchingBrace([ob, op, ob, cp], 1, op, cp)
 753        3
 754        """
 755        if where >= len(tokens):
 756            raise ParseError(
 757                f"Out-of-bounds brace start: index {where} with"
 758                f" {len(tokens)} tokens."
 759            )
 760        if tokens[where] != opener:
 761            raise ParseError(
 762                f"Can't find matching brace for token"
 763                f" {repr(tokens[where])} at index {where} because it's"
 764                f" not an open brace."
 765            )
 766
 767        level = 1
 768        for i in range(where + 1, len(tokens)):
 769            token = tokens[i]
 770            if token == opener:
 771                level += 1
 772            elif token == closer:
 773                level -= 1
 774                if level == 0:
 775                    return i
 776
 777        raise ParseError(
 778            f"Failed to find matching curly brace from index {where}."
 779        )
 780
 781    def parseFocalization(self, word: str) -> base.DomainFocalization:
 782        """
 783        Parses a focalization type for a domain, recognizing
 784        'domainFocalizationSingular', 'domainFocalizationPlural', and
 785        'domainFocalizationSpreading'.
 786        """
 787        try:
 788            return self.focalizationNames[word]
 789        except KeyError:
 790            raise ParseError(
 791                f"Invalid domain focalization name {repr(word)}. Valid"
 792                f" name are: {repr(list(self.focalizationNames))}'."
 793            )
 794
 795    def parseTagValue(self, value: str) -> base.TagValue:
 796        """
 797        Converts a string to a tag value, following these rules:
 798
 799        1. If the string is exactly one of 'None', 'True', or 'False', we
 800            convert it to the corresponding Python value.
 801        2. If the string can be converted to an integer without raising a
 802            ValueError, we use that integer.
 803        3. If the string can be converted to a float without raising a
 804            ValueError, we use that float.
 805        4. Otherwise, it remains a string.
 806
 807        Note that there is currently no syntax for using list, dictionary,
 808        Requirement, or Consequence tag values.
 809        TODO: Support those types?
 810
 811        Examples:
 812
 813        >>> pf = ParseFormat()
 814        >>> pf.parseTagValue('hi')
 815        'hi'
 816        >>> pf.parseTagValue('3')
 817        3
 818        >>> pf.parseTagValue('3.0')
 819        3.0
 820        >>> pf.parseTagValue('True')
 821        True
 822        >>> pf.parseTagValue('False')
 823        False
 824        >>> pf.parseTagValue('None') is None
 825        True
 826        >>> pf.parseTagValue('none')
 827        'none'
 828        """
 829        # TODO: Allow these keywords to be redefined?
 830        if value == 'True':
 831            return True
 832        elif value == 'False':
 833            return False
 834        elif value == 'None':
 835            return None
 836        else:
 837            try:
 838                return int(value)
 839            except ValueError:
 840                try:
 841                    return float(value)
 842                except ValueError:
 843                    return value
 844
 845    def unparseTagValue(self, value: base.TagValue) -> str:
 846        """
 847        Converts a tag value into a string that would be parsed back into a
 848        tag value via `parseTagValue`. Currently does not work for list,
 849        dictionary, Requirement, or Consequence values.
 850        TODO: Those
 851        """
 852        return str(value)
 853
 854    def hasZoneParts(self, name: str) -> bool:
 855        """
 856        Returns true if the specified name contains zone parts (using
 857        the `zoneSeparator`).
 858        """
 859        return self.formatDict[Lexeme.zoneSeparator] in name
 860
 861    def splitZone(
 862        self,
 863        name: str
 864    ) -> Tuple[List[base.Zone], base.DecisionName]:
 865        """
 866        Splits a decision name that includes zone information into the
 867        list-of-zones part and the decision part. If there is no zone
 868        information in the name, the list-of-zones will be an empty
 869        list.
 870        """
 871        sep = self.formatDict[Lexeme.zoneSeparator]
 872        parts = name.split(sep)
 873        return (list(parts[:-1]), parts[-1])
 874
 875    def prefixWithZone(
 876        self,
 877        name: base.DecisionName,
 878        zone: base.Zone
 879    ) -> base.DecisionName:
 880        """
 881        Returns the given decision name, prefixed with the given zone
 882        name. Does NOT check whether the decision name already includes
 883        a prefix or not.
 884        """
 885        return zone + self.formatDict[Lexeme.zoneSeparator] + name
 886
 887    def parseAnyTransitionFromTokens(
 888        self,
 889        tokens: LexedTokens,
 890        start: int = 0
 891    ) -> Tuple[base.TransitionWithOutcomes, int]:
 892        """
 893        Parses a `base.TransitionWithOutcomes` from a tokens list,
 894        accepting either a transition name or a transition name followed
 895        by a `Lexeme.withDetails` followed by a string of success and
 896        failure indicator characters. Returns a tuple containing a
 897        `base.TransitionWithOutcomes` and an integer indicating the end
 898        index of the parsed item within the tokens.
 899        """
 900        # Normalize start index so we can do index math
 901        if start < 0:
 902            useIndex = len(tokens) + start
 903        else:
 904            useIndex = start
 905
 906        try:
 907            first = tokens[useIndex]
 908        except IndexError:
 909            raise ParseError(
 910                f"Invalid token index: {start!r} among {len(tokens)}"
 911                f" tokens."
 912            )
 913
 914        if isinstance(first, Lexeme):
 915            raise ParseError(
 916                f"Expecting a transition name (possibly with a"
 917                f" success/failure indicator string) but first token is"
 918                f" {first!r}."
 919            )
 920
 921        try:
 922            second = tokens[useIndex + 1]
 923            third = tokens[useIndex + 2]
 924        except IndexError:
 925            return ((first, []), useIndex)
 926
 927        if second != Lexeme.withDetails or isinstance(third, Lexeme):
 928            return ((first, []), useIndex)
 929
 930        outcomes = []
 931        for char in third:
 932            if char == self.successIndicator:
 933                outcomes.append(True)
 934            elif char == self.failureIndicator:
 935                outcomes.append(False)
 936            else:
 937                return ((first, []), useIndex)
 938
 939        return ((first, outcomes), useIndex + 2)
 940
 941    def parseTransitionWithOutcomes(
 942        self,
 943        content: str
 944    ) -> base.TransitionWithOutcomes:
 945        """
 946        Takes a transition that may have outcomes listed as a series of
 947        s/f strings after a colon and returns the corresponding
 948        `TransitionWithOutcomes` tuple. Calls `lex` and then
 949        `parseAnyTransitionFromTokens`.
 950        """
 951        return self.parseAnyTransitionFromTokens(self.lex(content))[0]
 952
 953    def unparseTransitionWithOutocmes(
 954        self,
 955        transition: base.AnyTransition
 956    ) -> str:
 957        """
 958        Turns a `base.AnyTransition` back into a string that would parse
 959        to an equivalent `base.TransitionWithOutcomes` via
 960        `parseTransitionWithOutcomes`. If a bare `base.Transition` is
 961        given, returns a string that would result in a
 962        `base.TransitionWithOutcomes` that has an empty outcomes
 963        sequence.
 964        """
 965        if isinstance(transition, base.Transition):
 966            return transition
 967        elif (
 968            isinstance(transition, tuple)
 969        and len(transition) == 2
 970        and isinstance(transition[0], base.Transition)
 971        and isinstance(transition[1], list)
 972        and all(isinstance(sfi, bool) for sfi in transition[1])
 973        ):
 974            if len(transition[1]) == 0:
 975                return transition[0]
 976            else:
 977                result = transition[0] + self.formatDict[Lexeme.withDetails]
 978                for outcome in transition[1]:
 979                    if outcome:
 980                        result += self.successIndicator
 981                    else:
 982                        result += self.failureIndicator
 983                return result
 984        else:
 985            raise TypeError(
 986                f"Invalid AnyTransition: neither a string, nor a"
 987                f" length-2 tuple consisting of a string followed by a"
 988                f" list of booleans. Got: {transition!r}"
 989            )
 990
 991    def parseSpecificTransition(
 992        self,
 993        content: str
 994    ) -> Tuple[base.DecisionName, base.Transition]:
 995        """
 996        Splits a decision:transition pair to the decision and transition
 997        part, using a custom separator if one is defined.
 998        """
 999        sep = self.formatDict[Lexeme.withDetails]
1000        n = content.count(sep)
1001        if n == 0:
1002            raise ParseError(
1003                f"Cannot split '{content}' into a decision name and a"
1004                f" transition name (no separator '{sep}' found)."
1005            )
1006        elif n > 1:
1007            raise ParseError(
1008                f"Cannot split '{content}' into a decision name and a"
1009                f" transition name (too many ({n}) '{sep}' separators"
1010                f" found)."
1011            )
1012        else:
1013            return cast(
1014                Tuple[base.DecisionName, base.Transition],
1015                tuple(content.split(sep))
1016            )
1017
1018    def splitDirections(
1019        self,
1020        content: str
1021    ) -> Tuple[Optional[str], Optional[str]]:
1022        """
1023        Splits a piece of text using the 'Lexeme.reciprocalSeparator'
1024        into two pieces. If there is no separator, the second piece will
1025        be `None`; if either side of the separator is blank, that side
1026        will be `None`, and if there is more than one separator, a
1027        `ParseError` will be raised. Whitespace will be stripped from
1028        both sides of each result.
1029
1030        Examples:
1031
1032        >>> pf = ParseFormat()
1033        >>> pf.splitDirections('abc / def')
1034        ('abc', 'def')
1035        >>> pf.splitDirections('abc def ')
1036        ('abc def', None)
1037        >>> pf.splitDirections('abc def /')
1038        ('abc def', None)
1039        >>> pf.splitDirections('/abc def')
1040        (None, 'abc def')
1041        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
1042        Traceback (most recent call last):
1043          ...
1044        ParseError: ...
1045        """
1046        sep = self.formatDict[Lexeme.reciprocalSeparator]
1047        count = content.count(sep)
1048        if count > 1:
1049            raise ParseError(
1050                f"Too many split points ('{sep}') in content:"
1051                f" '{content}' (only one is allowed)."
1052            )
1053
1054        elif count == 1:
1055            before, after = content.split(sep)
1056            before = before.strip()
1057            after = after.strip()
1058            return (before or None, after or None)
1059
1060        else: # no split points
1061            stripped = content.strip()
1062            if stripped:
1063                return stripped, None
1064            else:
1065                return None, None
1066
1067    def parseItem(
1068        self,
1069        item: str
1070    ) -> Union[
1071        base.Capability,
1072        Tuple[base.Token, int],
1073        Tuple[base.MechanismName, base.MechanismState]
1074    ]:
1075        """
1076        Parses an item, which is a capability (just a string), a
1077        token-type*number pair (returned as a tuple with the number
1078        converted to an integer), or a mechanism-name:state pair
1079        (returned as a tuple with the state as a string). The
1080        'Lexeme.tokenCount' and `Lexeme.mechanismSeparator` format
1081        values determine the separators that this looks for.
1082        """
1083        tsep = self.formatDict[Lexeme.tokenCount]
1084        msep = self.formatDict[Lexeme.mechanismSeparator]
1085        if tsep in item:
1086            # It's a token w/ an associated count
1087            parts = item.split(tsep)
1088            if len(parts) != 2:
1089                raise ParseError(
1090                    f"Item '{item}' has a '{tsep}' but doesn't separate"
1091                    f" into a token type and a count."
1092                )
1093            typ, count = parts
1094            try:
1095                num = int(count)
1096            except ValueError:
1097                raise ParseError(
1098                    f"Item '{item}' has invalid token count '{count}'."
1099                )
1100
1101            return (typ, num)
1102        elif msep in item:
1103            parts = item.split(msep)
1104            mechanism = msep.join(parts[:-1])
1105            state = parts[-1]
1106            if mechanism.endswith(':'):
1107                # Just a zone-qualified name...
1108                return item
1109            else:
1110                return (mechanism, state)
1111        else:
1112            # It's just a capability
1113            return item
1114
1115    def unparseAnyDecision(self, decision: base.AnyDecisionSpecifier) -> str:
1116        """
1117        Turns any kind of decision specifier (ID,
1118        `base.DecisionSpecifier`, or name string) into a string that
1119        should parse back using `parseDecisionSpecifier`.
1120
1121        Raises a `TypeError` if given something that isn't a decision
1122        identifier.
1123
1124        For example:
1125
1126        >>> pf = ParseFormat()
1127        >>> pf.unparseAnyDecision(
1128        ...     base.DecisionSpecifier("domain", "zone", "D")
1129        ... )
1130        'domain//zone::D'
1131        >>> pf.unparseAnyDecision(3)
1132        '3'
1133        >>> pf.unparseAnyDecision('D')
1134        'D'
1135        >>> pf.unparseAnyDecision('domain//zone::D')
1136        'domain//zone::D'
1137        >>> pf.unparseAnyDecision([1, 2])
1138        Traceback (most recent call last):
1139        ...
1140        TypeError...
1141        """
1142        if isinstance(decision, base.DecisionSpecifier):
1143            return self.unparseDecisionSpecifier(decision)
1144        elif isinstance(decision, (base.DecisionID, base.DecisionName)):
1145            # leave as-is OR convert integer ID to string
1146            return str(decision)
1147        else:
1148            raise TypeError(
1149                "Unrecognized decision identifier type " + type(decision)
1150            )
1151
1152    def unparseDecisionSpecifier(self, spec: base.DecisionSpecifier) -> str:
1153        """
1154        Turns a decision specifier back into a string, which would be
1155        parsed as a decision specifier as part of various different
1156        things.
1157
1158        For example:
1159
1160        >>> pf = ParseFormat()
1161        >>> pf.unparseDecisionSpecifier(
1162        ...     base.DecisionSpecifier(None, None, 'where')
1163        ... )
1164        'where'
1165        >>> pf.unparseDecisionSpecifier(
1166        ...     base.DecisionSpecifier(None, 'zone', 'where')
1167        ... )
1168        'zone::where'
1169        >>> pf.unparseDecisionSpecifier(
1170        ...     base.DecisionSpecifier('domain', 'zone', 'where')
1171        ... )
1172        'domain//zone::where'
1173        >>> pf.unparseDecisionSpecifier(
1174        ...     base.DecisionSpecifier('domain', None, 'where')
1175        ... )
1176        'domain//where'
1177        """
1178        result = spec.name
1179        if spec.zone is not None:
1180            result = (
1181                spec.zone
1182              + self.formatDict[Lexeme.zoneSeparator]
1183              + result
1184            )
1185        if spec.domain is not None:
1186            result = (
1187                spec.domain
1188              + self.formatDict[Lexeme.domainSeparator]
1189              + result
1190            )
1191        return result
1192
1193    def unparseMechanismSpecifier(
1194        self,
1195        spec: base.MechanismSpecifier
1196    ) -> str:
1197        """
1198        Turns a mechanism specifier back into a string, which would be
1199        parsed as a mechanism specifier as part of various different
1200        things. Note that a mechanism specifier with a zone part but no
1201        decision part is not valid, since it would parse as a decision
1202        part instead.
1203
1204        For example:
1205
1206        >>> pf = ParseFormat()
1207        >>> pf.unparseMechanismSpecifier(
1208        ...     base.MechanismSpecifier(None, None, None, 'lever')
1209        ... )
1210        'lever'
1211        >>> pf.unparseMechanismSpecifier(
1212        ...     base.MechanismSpecifier('domain', 'zone', 'decision', 'door')
1213        ... )
1214        'domain//zone::decision::door'
1215        >>> pf.unparseMechanismSpecifier(
1216        ...     base.MechanismSpecifier('domain', None, None, 'door')
1217        ... )
1218        'domain//door'
1219        >>> pf.unparseMechanismSpecifier(
1220        ...     base.MechanismSpecifier(None, 'a', 'b', 'door')
1221        ... )
1222        'a::b::door'
1223        >>> pf.unparseMechanismSpecifier(
1224        ...     base.MechanismSpecifier(None, 'a', None, 'door')
1225        ... )
1226        Traceback (most recent call last):
1227        ...
1228        exploration.base.InvalidMechanismSpecifierError...
1229        >>> pf.unparseMechanismSpecifier(
1230        ...     base.MechanismSpecifier(None, None, 'a', 'door')
1231        ... )
1232        'a::door'
1233        >>> pf.unparseMechanismSpecifier(
1234        ...     base.MechanismSpecifier(None, None, 37, 'door')
1235        ... )
1236        '37::door'
1237        """
1238        if spec.decision is None and spec.zone is not None:
1239            raise base.InvalidMechanismSpecifierError(
1240                f"Mechanism specifier has a zone part but no decision"
1241                f" part; it cannot be unparsed since it would parse"
1242                f" differently:\n{spec}"
1243            )
1244        result = spec.name
1245        if spec.decision is not None:
1246            result = (
1247                str(spec.decision)
1248              + self.formatDict[Lexeme.zoneSeparator]
1249              + result
1250            )
1251        if spec.zone is not None:
1252            result = (
1253                spec.zone
1254              + self.formatDict[Lexeme.zoneSeparator]
1255              + result
1256            )
1257        if spec.domain is not None:
1258            result = (
1259                spec.domain
1260              + self.formatDict[Lexeme.domainSeparator]
1261              + result
1262            )
1263        return result
1264
1265    def effectType(self, effectMarker: str) -> Optional[base.EffectType]:
1266        """
1267        Returns the `base.EffectType` string corresponding to the
1268        given effect marker string. Returns `None` for an unrecognized
1269        marker.
1270        """
1271        return self.effectNames.get(effectMarker)
1272
1273    def parseCommandFromTokens(
1274        self,
1275        tokens: LexedTokens,
1276        start: int = 0,
1277        end: int = -1
1278    ) -> commands.Command:
1279        """
1280        Given tokens that specify a `commands.Command`, parses that
1281        command and returns it. Really just turns the tokens back into
1282        strings and calls `commands.command`.
1283
1284        For example:
1285
1286        >>> pf = ParseFormat()
1287        >>> t = ['val', '5']
1288        >>> c = commands.command(*t)
1289        >>> pf.parseCommandFromTokens(t) == c
1290        True
1291        >>> t = ['op', Lexeme.tokenCount, '$val', '$val']
1292        >>> c = commands.command('op', '*', '$val', '$val')
1293        >>> pf.parseCommandFromTokens(t) == c
1294        True
1295        """
1296        start, end, nTokens = normalizeEnds(tokens, start, end)
1297        args: List[str] = []
1298        for token in tokens[start:end + 1]:
1299            if isinstance(token, Lexeme):
1300                args.append(self.formatDict[token])
1301            else:
1302                args.append(token)
1303
1304        if len(args) == 0:
1305            raise ParseError(
1306                f"No arguments for command:\n{tokens[start:end + 1]}"
1307            )
1308        return commands.command(*args)
1309
1310    def unparseCommand(self, command: commands.Command) -> str:
1311        """
1312        Turns a `Command` back into the string that would produce that
1313        command when parsed using `parseCommandList`.
1314
1315        Note that the results will be more explicit in some cases than what
1316        `parseCommandList` would accept as input.
1317
1318        For example:
1319
1320        >>> pf = ParseFormat()
1321        >>> pf.unparseCommand(
1322        ...     commands.LiteralValue(command='val', value='5')
1323        ... )
1324        'val 5'
1325        >>> pf.unparseCommand(
1326        ...     commands.LiteralValue(command='val', value='"5"')
1327        ... )
1328        'val "5"'
1329        >>> pf.unparseCommand(
1330        ...     commands.EstablishCollection(
1331        ...         command='empty',
1332        ...         collection='list'
1333        ...     )
1334        ... )
1335        'empty list'
1336        >>> pf.unparseCommand(
1337        ...     commands.AppendValue(command='append', value='$_')
1338        ... )
1339        'append $_'
1340        """
1341        candidate = None
1342        for k, v in commands.COMMAND_SETUP.items():
1343            if v[0] == type(command):
1344                if candidate is None:
1345                    candidate = k
1346                else:
1347                    raise ValueError(
1348                        f"COMMAND_SETUP includes multiple keys with"
1349                        f" {type(command)} as their value type:"
1350                        f" '{candidate}' and '{k}'."
1351                    )
1352
1353        if candidate is None:
1354            raise ValueError(
1355                f"COMMAND_SETUP has no key with {type(command)} as its"
1356                f" value type."
1357            )
1358
1359        result = candidate
1360        for x in command[1:]:
1361            # TODO: Is this hack good enough?
1362            result += ' ' + str(x)
1363        return result
1364
1365    def unparseCommandList(self, commands: List[commands.Command]) -> str:
1366        """
1367        Takes a list of commands and returns a string that would parse
1368        into them using `parseOneEffectArg`. The result contains
1369        newlines and indentation to make it easier to read.
1370
1371        For example:
1372
1373        >>> pf = ParseFormat()
1374        >>> pf.unparseCommandList(
1375        ...     [commands.command('val', '5'), commands.command('pop')]
1376        ... )
1377        '{\\n  val 5;\\n  pop;\\n}'
1378        """
1379        result = self.formatDict[Lexeme.openCurly]
1380        for cmd in commands:
1381            result += f'\n  {self.unparseCommand(cmd)};'
1382        if len(commands) > 0:
1383            result += '\n'
1384        return result + self.formatDict[Lexeme.closeCurly]
1385
1386    def parseCommandListFromTokens(
1387        self,
1388        tokens: LexedTokens,
1389        start: int = 0
1390    ) -> Tuple[List[commands.Command], int]:
1391        """
1392        Parses a command list from a list of lexed tokens, which must
1393        start with `Lexeme.openCurly`. Returns the parsed command list
1394        as a list of `commands.Command` objects, along with the end
1395        index of that command list (which will be the matching curly
1396        brace.
1397        """
1398        end = self.matchingBrace(
1399            tokens,
1400            start,
1401            Lexeme.openCurly,
1402            Lexeme.closeCurly
1403        )
1404        parts = list(
1405            findSeparatedParts(
1406                tokens,
1407                Lexeme.consequenceSeparator,
1408                start + 1,
1409                end - 1,
1410                Lexeme.openCurly,
1411                Lexeme.closeCurly,
1412            )
1413        )
1414        return (
1415            [
1416                self.parseCommandFromTokens(tokens, fromIndex, toIndex)
1417                for fromIndex, toIndex in parts
1418                if fromIndex <= toIndex  # ignore empty parts
1419            ],
1420            end
1421        )
1422
1423    def parseOneEffectArg(
1424        self,
1425        tokens: LexedTokens,
1426        start: int = 0,
1427        limit: Optional[int] = None
1428    ) -> Tuple[
1429        Union[
1430            base.Capability,  # covers 'str' possibility
1431            Tuple[base.Token, base.TokenCount],
1432            Tuple[Literal['skill'], base.Skill, base.Level],
1433            Tuple[base.MechanismSpecifier, base.MechanismState],
1434            base.DecisionSpecifier,
1435            base.DecisionID,
1436            Literal[Lexeme.inCommon, Lexeme.isHidden],
1437            Tuple[Literal[Lexeme.sepOrDelay, Lexeme.effectCharges], int],
1438            List[commands.Command]
1439        ],
1440        int
1441    ]:
1442        """
1443        Looks at tokens starting at the specified position and parses
1444        one or more of them as an effect argument (an argument that
1445        could be given to `base.effect`). Looks at various key `Lexeme`s
1446        to determine which type to use.
1447
1448        Items in the tokens list beyond the specified limit will not be
1449        considered, even when they in theory could be grouped with items
1450        up to the limit into a more complex argument.
1451
1452        For example:
1453
1454        >>> pf = ParseFormat()
1455        >>> pf.parseOneEffectArg(['hi'])
1456        ('hi', 0)
1457        >>> pf.parseOneEffectArg(['hi'], 1)
1458        Traceback (most recent call last):
1459        ...
1460        IndexError...
1461        >>> pf.parseOneEffectArg(['hi', 'bye'])
1462        ('hi', 0)
1463        >>> pf.parseOneEffectArg(['hi', 'bye'], 1)
1464        ('bye', 1)
1465        >>> pf.parseOneEffectArg(
1466        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1467        ...     0
1468        ... )
1469        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1470 name='gate'), 'open'), 2)
1471        >>> pf.parseOneEffectArg(
1472        ...     ['set', 'gate', Lexeme.mechanismSeparator, 'open'],
1473        ...     1
1474        ... )
1475        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1476 name='gate'), 'open'), 3)
1477        >>> pf.parseOneEffectArg(
1478        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1479        ...     1
1480        ... )
1481        Traceback (most recent call last):
1482        ...
1483        exploration.parsing.ParseError...
1484        >>> pf.parseOneEffectArg(
1485        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1486        ...     2
1487        ... )
1488        ('open', 2)
1489        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, '10'], 0)
1490        (('gold', 10), 2)
1491        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, 'ten'], 0)
1492        Traceback (most recent call last):
1493        ...
1494        exploration.parsing.ParseError...
1495        >>> pf.parseOneEffectArg([Lexeme.inCommon], 0)
1496        (<Lexeme.inCommon: ...>, 0)
1497        >>> pf.parseOneEffectArg([Lexeme.isHidden], 0)
1498        (<Lexeme.isHidden: ...>, 0)
1499        >>> pf.parseOneEffectArg([Lexeme.tokenCount, '3'], 0)
1500        Traceback (most recent call last):
1501        ...
1502        exploration.parsing.ParseError...
1503        >>> pf.parseOneEffectArg([Lexeme.effectCharges, '3'], 0)
1504        ((<Lexeme.effectCharges: ...>, 3), 1)
1505        >>> pf.parseOneEffectArg([Lexeme.tokenCount, 3], 0)  # int is a lexeme
1506        Traceback (most recent call last):
1507        ...
1508        exploration.parsing.ParseError...
1509        >>> pf.parseOneEffectArg([Lexeme.sepOrDelay, '-2'], 0)
1510        ((<Lexeme.sepOrDelay: ...>, -2), 1)
1511        >>> pf.parseOneEffectArg(['agility', Lexeme.skillLevel, '3'], 0)
1512        (('skill', 'agility', 3), 2)
1513        >>> pf.parseOneEffectArg(
1514        ...     [
1515        ...         'main',
1516        ...         Lexeme.domainSeparator,
1517        ...         'zone',
1518        ...         Lexeme.zoneSeparator,
1519        ...         'decision',
1520        ...         Lexeme.zoneSeparator,
1521        ...         'compass',
1522        ...         Lexeme.mechanismSeparator,
1523        ...         'north',
1524        ...         'south',
1525        ...         'east',
1526        ...         'west'
1527        ...     ],
1528        ...     0
1529        ... )
1530        ((MechanismSpecifier(domain='main', zone='zone',\
1531 decision='decision', name='compass'), 'north'), 8)
1532        >>> pf.parseOneEffectArg(
1533        ...     [
1534        ...         'before',
1535        ...         'main',
1536        ...         Lexeme.domainSeparator,
1537        ...         'zone',
1538        ...         Lexeme.zoneSeparator,
1539        ...         'decision',
1540        ...         Lexeme.zoneSeparator,
1541        ...         'compass',
1542        ...         'north',
1543        ...         'south',
1544        ...         'east',
1545        ...         'west'
1546        ...     ],
1547        ...     1
1548        ... )  # a mechanism specifier without a state will become a
1549        ...    # decision specifier
1550        (DecisionSpecifier(domain='main', zone='zone',\
1551 name='decision'), 5)
1552        >>> tokens = [
1553        ...     'set',
1554        ...     'main',
1555        ...     Lexeme.domainSeparator,
1556        ...     'zone',
1557        ...     Lexeme.zoneSeparator,
1558        ...     'compass',
1559        ...     'north',
1560        ...     'bounce',
1561        ... ]
1562        >>> pf.parseOneEffectArg(tokens, 0)
1563        ('set', 0)
1564        >>> pf.parseDecisionSpecifierFromTokens(tokens, 1)
1565        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1566        >>> pf.parseOneEffectArg(tokens, 1)
1567        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1568        >>> pf.parseOneEffectArg(tokens, 6)
1569        ('north', 6)
1570        >>> pf.parseOneEffectArg(tokens, 7)
1571        ('bounce', 7)
1572        >>> pf.parseOneEffectArg(
1573        ...     [
1574        ...         "fort", Lexeme.zoneSeparator, "gate",
1575        ...             Lexeme.mechanismSeparator, "open",
1576        ...     ],
1577        ...     0
1578        ... )
1579        ((MechanismSpecifier(domain=None, zone=None, decision='fort',\
1580 name='gate'), 'open'), 4)
1581        >>> pf.parseOneEffectArg(
1582        ...     [Lexeme.openCurly, 'val', '5', Lexeme.closeCurly],
1583        ...     0
1584        ... ) == ([commands.command('val', '5')], 3)
1585        True
1586        >>> a = [
1587        ...     Lexeme.openCurly, 'val', '5', Lexeme.closeCurly,
1588        ...     Lexeme.openCurly, 'append', Lexeme.consequenceSeparator,
1589        ...     'pop', Lexeme.closeCurly
1590        ... ]
1591        >>> cl = [
1592        ...     [commands.command('val', '5')],
1593        ...     [commands.command('append'), commands.command('pop')]
1594        ... ]
1595        >>> pf.parseOneEffectArg(a, 0) == (cl[0], 3)
1596        True
1597        >>> pf.parseOneEffectArg(a, 4) == (cl[1], 8)
1598        True
1599        >>> pf.parseOneEffectArg(a, 1)
1600        ('val', 1)
1601        >>> pf.parseOneEffectArg(a, 2)
1602        ('5', 2)
1603        >>> pf.parseOneEffectArg(a, 3)
1604        Traceback (most recent call last):
1605        ...
1606        exploration.parsing.ParseError...
1607        """
1608        start, limit, nTokens = normalizeEnds(
1609            tokens,
1610            start,
1611            limit if limit is not None else -1
1612        )
1613        if nTokens == 0:
1614            raise ParseError("No effect arguments available.")
1615
1616        first = tokens[start]
1617
1618        if nTokens == 1:
1619            if first in (Lexeme.inCommon, Lexeme.isHidden):
1620                return (first, start)
1621            elif not isinstance(first, str):
1622                raise ParseError(
1623                    f"Only one token and it's a special character"
1624                    f" ({first} = {repr(self.formatDict[first])})"
1625                )
1626            else:
1627                return (cast(base.Capability, first), start)
1628
1629        assert (nTokens > 1)
1630
1631        second = tokens[start + 1]
1632
1633        # Command lists start with an open curly brace and effect
1634        # modifiers start with a Lexme, but nothing else may
1635        if first == Lexeme.openCurly:
1636            return self.parseCommandListFromTokens(tokens, start)
1637        elif first in (Lexeme.inCommon, Lexeme.isHidden):
1638            return (first, start)
1639        elif first in (Lexeme.sepOrDelay, Lexeme.effectCharges):
1640            if not isinstance(second, str):
1641                raise ParseError(
1642                    f"Token following a modifier that needs a count"
1643                    f" must be a string in tokens:"
1644                    f"\n{tokens[start:limit or len(tokens)]}"
1645                )
1646            try:
1647                val = int(second)
1648            except ValueError:
1649                raise ParseError(
1650                    f"Token following a modifier that needs a count"
1651                    f" must be convertible to an int:"
1652                    f"\n{tokens[start:limit or len(tokens)]}"
1653                )
1654
1655            first = cast(
1656                Literal[Lexeme.sepOrDelay, Lexeme.effectCharges],
1657                first
1658            )
1659            return ((first, val), start + 1)
1660        elif not isinstance(first, str):
1661            raise ParseError(
1662                f"First token must be a string unless it's a modifier"
1663                f" lexeme or command/reversion-set opener. Got:"
1664                f"\n{tokens[start:limit or len(tokens)]}"
1665            )
1666
1667        # If we have two strings in a row, then the first is our parsed
1668        # value alone and we'll parse the second separately.
1669        if isinstance(second, str):
1670            return (first, start)
1671        elif second in (Lexeme.inCommon, Lexeme.isHidden):
1672            return (first, start)
1673
1674        # Must have at least 3 tokens at this point, or else we need to
1675        # have the inCommon or isHidden lexeme second.
1676        if nTokens < 3:
1677            return (first, start)
1678
1679        third = tokens[start + 2]
1680        if not isinstance(third, str):
1681            return (first, start)
1682
1683        second = cast(Lexeme, second)
1684        third = cast(str, third)
1685
1686        if second in (Lexeme.tokenCount, Lexeme.skillLevel):
1687            try:
1688                num = int(third)
1689            except ValueError:
1690                raise ParseError(
1691                    f"Invalid effect tokens: count for Tokens or level"
1692                    f" for Skill must be convertible to an integer."
1693                    f"\n{tokens[start:limit + 1]}"
1694                )
1695            if second == Lexeme.tokenCount:
1696                return ((first, num), start + 2)  # token/count pair
1697            else:
1698                return (('skill', first, num), start + 2)  # token/count pair
1699
1700        elif second == Lexeme.mechanismSeparator:  # bare mechanism
1701            return (
1702                (
1703                    base.MechanismSpecifier(
1704                        domain=None,
1705                        zone=None,
1706                        decision=None,
1707                        name=first
1708                    ),
1709                    third
1710                ),
1711                start + 2
1712            )
1713
1714        elif second in (Lexeme.domainSeparator, Lexeme.zoneSeparator):
1715            try:
1716                mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
1717                    tokens,
1718                    start
1719                )  # works whether it's a mechanism or decision specifier...
1720            except ParseError:
1721                return self.parseDecisionSpecifierFromTokens(tokens, start)
1722            if mEnd + 2 > limit:
1723                # No room for following mechanism separator + state
1724                return self.parseDecisionSpecifierFromTokens(tokens, start)
1725            sep = tokens[mEnd + 1]
1726            after = tokens[mEnd + 2]
1727            if sep == Lexeme.mechanismSeparator:
1728                if not isinstance(after, str):
1729                    raise ParseError(
1730                        f"Mechanism separator not followed by state:"
1731                        f"\n{tokens[start]}"
1732                    )
1733                return ((mSpec, after), mEnd + 2)
1734            else:
1735                # No mechanism separator afterwards
1736                return self.parseDecisionSpecifierFromTokens(tokens, start)
1737
1738        else:  # unrecognized as a longer combo
1739            return (first, start)
1740
1741    def coalesceEffectArgs(
1742        self,
1743        tokens: LexedTokens,
1744        start: int = 0,
1745        end: int = -1
1746    ) -> Tuple[
1747        List[  # List of effect args
1748            Union[
1749                base.Capability,  # covers 'str' possibility
1750                Tuple[base.Token, base.TokenCount],
1751                Tuple[Literal['skill'], base.Skill, base.Level],
1752                Tuple[base.MechanismSpecifier, base.MechanismState],
1753                base.DecisionSpecifier,
1754                List[commands.Command],
1755                Set[str]
1756            ]
1757        ],
1758        Tuple[  # Slots for modifiers: common/hidden/charges/delay
1759            Optional[bool],
1760            Optional[bool],
1761            Optional[int],
1762            Optional[int],
1763        ]
1764    ]:
1765        """
1766        Given a region of a lexed tokens list which contains one or more
1767        effect arguments, combines token sequences representing things
1768        like capabilities, mechanism states, token counts, and skill
1769        levels, representing these using the tuples that would be passed
1770        to `base.effect`. Returns a tuple with two elements:
1771
1772        - First, a list that contains several different kinds of
1773            objects, each of which is distinguishable by its type or
1774            part of its value.
1775        - Next, a tuple with four entires for common, hidden, charges,
1776            and/or delay values based on the presence of modifier
1777            sequences. Any or all of these may be `None` if the relevant
1778            modifier was not present (the usual case).
1779
1780        For example:
1781
1782        >>> pf = ParseFormat()
1783        >>> pf.coalesceEffectArgs(["jump"])
1784        (['jump'], (None, None, None, None))
1785        >>> pf.coalesceEffectArgs(["coin", Lexeme.tokenCount, "3", "fly"])
1786        ([('coin', 3), 'fly'], (None, None, None, None))
1787        >>> pf.coalesceEffectArgs(
1788        ...     [
1789        ...         "fort", Lexeme.zoneSeparator, "gate",
1790        ...             Lexeme.mechanismSeparator, "open"
1791        ...     ]
1792        ... )
1793        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1794 name='gate'), 'open')], (None, None, None, None))
1795        >>> pf.coalesceEffectArgs(
1796        ...     [
1797        ...         "main", Lexeme.domainSeparator, "cliff"
1798        ...     ]
1799        ... )
1800        ([DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1801 (None, None, None, None))
1802        >>> pf.coalesceEffectArgs(
1803        ...     [
1804        ...         "door", Lexeme.mechanismSeparator, "open"
1805        ...     ]
1806        ... )
1807        ([(MechanismSpecifier(domain=None, zone=None, decision=None,\
1808 name='door'), 'open')], (None, None, None, None))
1809        >>> pf.coalesceEffectArgs(
1810        ...     [
1811        ...         "fort", Lexeme.zoneSeparator, "gate",
1812        ...             Lexeme.mechanismSeparator, "open",
1813        ...         "canJump",
1814        ...         "coins", Lexeme.tokenCount, "3",
1815        ...         Lexeme.inCommon,
1816        ...         "agility", Lexeme.skillLevel, "-1",
1817        ...         Lexeme.sepOrDelay, "0",
1818        ...         "main", Lexeme.domainSeparator, "cliff"
1819        ...     ]
1820        ... )
1821        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1822 name='gate'), 'open'), 'canJump', ('coins', 3), ('skill', 'agility', -1),\
1823 DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1824 (True, None, None, 0))
1825        >>> pf.coalesceEffectArgs(["bounce", Lexeme.isHidden])
1826        (['bounce'], (None, True, None, None))
1827        >>> pf.coalesceEffectArgs(
1828        ...     ["goto", "3", Lexeme.inCommon, Lexeme.isHidden]
1829        ... )
1830        (['goto', '3'], (True, True, None, None))
1831        """
1832        start, end, nTokens = normalizeEnds(tokens, start, end)
1833        where = start
1834        result: List[  # List of effect args
1835            Union[
1836                base.Capability,  # covers 'str' possibility
1837                Tuple[base.Token, base.TokenCount],
1838                Tuple[Literal['skill'], base.Skill, base.Level],
1839                Tuple[base.MechanismSpecifier, base.MechanismState],
1840                base.DecisionSpecifier,
1841                List[commands.Command],
1842                Set[str]
1843            ]
1844        ] = []
1845        inCommon: Optional[bool] = None
1846        isHidden: Optional[bool] = None
1847        charges: Optional[int] = None
1848        delay: Optional[int] = None
1849        while where <= end:
1850            following, thisEnd = self.parseOneEffectArg(tokens, where, end)
1851            if following == Lexeme.inCommon:
1852                if inCommon is not None:
1853                    raise ParseError(
1854                        f"In-common effect modifier specified more than"
1855                        f" once in effect args:"
1856                        f"\n{tokens[start:end + 1]}"
1857                    )
1858                inCommon = True
1859            elif following == Lexeme.isHidden:
1860                if isHidden is not None:
1861                    raise ParseError(
1862                        f"Is-hidden effect modifier specified more than"
1863                        f" once in effect args:"
1864                        f"\n{tokens[start:end + 1]}"
1865                    )
1866                isHidden = True
1867            elif (
1868                isinstance(following, tuple)
1869            and len(following) == 2
1870            and following[0] in (Lexeme.effectCharges, Lexeme.sepOrDelay)
1871            and isinstance(following[1], int)
1872            ):
1873                if following[0] == Lexeme.effectCharges:
1874                    if charges is not None:
1875                        raise ParseError(
1876                            f"Charges effect modifier specified more than"
1877                            f" once in effect args:"
1878                            f"\n{tokens[start:end + 1]}"
1879                        )
1880                    charges = following[1]
1881                else:
1882                    if delay is not None:
1883                        raise ParseError(
1884                            f"Delay effect modifier specified more than"
1885                            f" once in effect args:"
1886                            f"\n{tokens[start:end + 1]}"
1887                        )
1888                    delay = following[1]
1889            elif (
1890                    isinstance(following, base.Capability)
1891                 or (
1892                    isinstance(following, tuple)
1893                and len(following) == 2
1894                and isinstance(following[0], base.Token)
1895                and isinstance(following[1], base.TokenCount)
1896                ) or (
1897                    isinstance(following, tuple)
1898                and len(following) == 3
1899                and following[0] == 'skill'
1900                and isinstance(following[1], base.Skill)
1901                and isinstance(following[2], base.Level)
1902                ) or (
1903                    isinstance(following, tuple)
1904                and len(following) == 2
1905                and isinstance(following[0], base.MechanismSpecifier)
1906                and isinstance(following[1], base.MechanismState)
1907                ) or (
1908                    isinstance(following, base.DecisionSpecifier)
1909                ) or (
1910                    isinstance(following, list)
1911                and all(isinstance(item, tuple) for item in following)
1912                    # TODO: Stricter command list check here?
1913                ) or (
1914                    isinstance(following, set)
1915                and all(isinstance(item, str) for item in following)
1916                )
1917            ):
1918                result.append(following)
1919            else:
1920                raise ParseError(f"Invalid coalesced argument: {following}")
1921            where = thisEnd + 1
1922
1923        return (result, (inCommon, isHidden, charges, delay))
1924
1925    def parseEffectFromTokens(
1926        self,
1927        tokens: LexedTokens,
1928        start: int = 0,
1929        end: int = -1
1930    ) -> base.Effect:
1931        """
1932        Given a region of a list of lexed tokens specifying an effect,
1933        returns the `Effect` object that those tokens specify.
1934        """
1935        start, end, nTokens = normalizeEnds(tokens, start, end)
1936
1937        # Check for empty list
1938        if nTokens == 0:
1939            raise ParseError(
1940                "Effect must include at least a type."
1941            )
1942
1943        firstPart = tokens[start]
1944
1945        if isinstance(firstPart, Lexeme):
1946            raise ParseError(
1947                f"First part of effect must be an effect type. Got"
1948                f" {firstPart} ({repr(self.formatDict[firstPart])})."
1949            )
1950
1951        firstPart = cast(str, firstPart)
1952
1953        # Get the effect type
1954        fType = self.effectType(firstPart)
1955
1956        if fType is None:
1957            raise ParseError(
1958                f"Unrecognized effect type {firstPart!r}. Check the"
1959                f" EffectType entries in the effect names dictionary."
1960            )
1961
1962        if start + 1 > end:  # No tokens left: set empty args
1963            groupedArgs: List[
1964                Union[
1965                    base.Capability,  # covers 'str' possibility
1966                    Tuple[base.Token, base.TokenCount],
1967                    Tuple[Literal['skill'], base.Skill, base.Level],
1968                    Tuple[base.MechanismSpecifier, base.MechanismState],
1969                    base.DecisionSpecifier,
1970                    List[commands.Command],
1971                    Set[str]
1972                ]
1973            ] = []
1974            modifiers: Tuple[
1975                Optional[bool],
1976                Optional[bool],
1977                Optional[int],
1978                Optional[int]
1979            ] = (None, None, None, None)
1980        else:  # Coalesce remaining tokens if there are any
1981            groupedArgs, modifiers = self.coalesceEffectArgs(
1982                tokens,
1983                start + 1,
1984                end
1985            )
1986
1987        # Set up arguments for base.effect and handle modifiers first
1988        args: Dict[
1989            str,
1990            Union[
1991                None,
1992                base.ContextSpecifier,
1993                base.Capability,
1994                Tuple[base.Token, base.TokenCount],
1995                Tuple[Literal['skill'], base.Skill, base.Level],
1996                Tuple[base.MechanismSpecifier, base.MechanismState],
1997                Tuple[base.MechanismSpecifier, List[base.MechanismState]],
1998                List[base.Capability],
1999                base.AnyDecisionSpecifier,
2000                Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
2001                bool,
2002                int,
2003                base.SaveSlot,
2004                Tuple[base.SaveSlot, Set[str]]
2005            ]
2006        ] = {}
2007        if modifiers[0]:
2008            args['applyTo'] = 'common'
2009        if modifiers[1]:
2010            args['hidden'] = True
2011        else:
2012            args['hidden'] = False
2013        if modifiers[2] is not None:
2014            args['charges'] = modifiers[2]
2015        if modifiers[3] is not None:
2016            args['delay'] = modifiers[3]
2017
2018        # Now handle the main effect-type-based argument
2019        if fType in ("gain", "lose"):
2020            if len(groupedArgs) != 1:
2021                raise ParseError(
2022                    f"'{fType}' effect must have exactly one grouped"
2023                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2024                )
2025            thing = groupedArgs[0]
2026            if isinstance(thing, tuple):
2027                if len(thing) == 2:
2028                    if (
2029                        not isinstance(thing[0], base.Token)
2030                     or not isinstance(thing[1], base.TokenCount)
2031                    ):
2032                        raise ParseError(
2033                            f"'{fType}' effect grouped arg pair must be a"
2034                            f" (token, amount) pair. Got:\n{thing}"
2035                        )
2036                elif len(thing) == 3:
2037                    if (
2038                        thing[0] != 'skill'
2039                     or not isinstance(thing[1], base.Skill)
2040                     or not isinstance(thing[2], base.Level)
2041                    ):
2042                        raise ParseError(
2043                            f"'{fType}' effect grouped arg pair must be a"
2044                            f" (token, amount) pair. Got:\n{thing}"
2045                        )
2046                else:
2047                    raise ParseError(
2048                        f"'{fType}' effect grouped arg tuple must have"
2049                        f" length 2 or 3. Got (length {len(thing)}):\n{thing}"
2050                    )
2051            elif not isinstance(thing, base.Capability):
2052                raise ParseError(
2053                    f"'{fType}' effect grouped arg must be a capability"
2054                    f" or a (token, amount) tuple. Got:\n{thing}"
2055                )
2056            args[fType] = thing
2057            return base.effect(**args)  # type:ignore
2058
2059        elif fType == "set":
2060            if len(groupedArgs) != 1:
2061                raise ParseError(
2062                    f"'{fType}' effect must have exactly one grouped"
2063                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2064                )
2065            setVal = groupedArgs[0]
2066            if not isinstance(
2067                setVal,
2068                tuple
2069            ):
2070                raise ParseError(
2071                    f"'{fType}' effect grouped arg must be a tuple. Got:"
2072                    f"\n{setVal}"
2073                )
2074            if len(setVal) == 2:
2075                setWhat, setTo = setVal
2076                if (
2077                    isinstance(setWhat, base.Token)
2078                and isinstance(setTo, base.TokenCount)
2079                ) or (
2080                    isinstance(setWhat, base.MechanismSpecifier)
2081                and isinstance(setTo, base.MechanismState)
2082                ):
2083                    args[fType] = setVal
2084                    return base.effect(**args)  # type:ignore
2085                else:
2086                    raise ParseError(
2087                        f"Invalid '{fType}' effect grouped args:"
2088                        f"\n{groupedArgs}"
2089                    )
2090            elif len(setVal) == 3:
2091                indicator, whichSkill, setTo = setVal
2092                if (
2093                    indicator == 'skill'
2094                and isinstance(whichSkill, base.Skill)
2095                and isinstance(setTo, base.Level)
2096                ):
2097                    args[fType] = setVal
2098                    return base.effect(**args)  # type:ignore
2099                else:
2100                    raise ParseError(
2101                        f"Invalid '{fType}' effect grouped args (not a"
2102                        f" skill):\n{groupedArgs}"
2103                    )
2104            else:
2105                raise ParseError(
2106                    f"Invalid '{fType}' effect grouped args (wrong"
2107                    f" length tuple):\n{groupedArgs}"
2108                )
2109
2110        elif fType == "toggle":
2111            if len(groupedArgs) == 0:
2112                raise ParseError(
2113                    f"'{fType}' effect must have at least one grouped"
2114                    f" argument. Got:\n{groupedArgs}"
2115                )
2116            if (
2117                isinstance(groupedArgs[0], tuple)
2118            and len(groupedArgs[0]) == 2
2119            and isinstance(groupedArgs[0][0], base.MechanismSpecifier)
2120            and isinstance(groupedArgs[0][1], base.MechanismState)
2121            and all(
2122                    isinstance(a, base.MechanismState)
2123                    for a in groupedArgs[1:]
2124                )
2125            ):  # a mechanism toggle
2126                args[fType] = (
2127                    groupedArgs[0][0],
2128                    cast(
2129                        List[base.MechanismState],
2130                        [groupedArgs[0][1]] + groupedArgs[1:]
2131                    )
2132                )
2133                return base.effect(**args)  # type:ignore
2134            elif all(isinstance(a, base.Capability) for a in groupedArgs):
2135                # a capability toggle
2136                args[fType] = cast(List[base.Capability], groupedArgs)
2137                return base.effect(**args)  # type:ignore
2138            else:
2139                raise ParseError(
2140                    f"Invalid arguments for '{fType}' effect. Got:"
2141                    f"\n{groupedArgs}"
2142                )
2143
2144        elif fType in ("bounce", "deactivate"):
2145            if len(groupedArgs) != 0:
2146                raise ParseError(
2147                    f"'{fType}' effect may not include any"
2148                    f" arguments. Got {len(groupedArgs)}):"
2149                    f"\n{groupedArgs}"
2150                )
2151            args[fType] = True
2152            return base.effect(**args)  # type:ignore
2153
2154        elif fType == "follow":
2155            if len(groupedArgs) != 1:
2156                raise ParseError(
2157                    f"'{fType}' effect must include exactly one"
2158                    f" argument. Got {len(groupedArgs)}):"
2159                    f"\n{groupedArgs}"
2160                )
2161
2162            transition = groupedArgs[0]
2163            if not isinstance(transition, base.Transition):
2164                raise ParseError(
2165                    f"Invalid argument for '{fType}' effect. Needed a"
2166                    f" transition but got:\n{groupedArgs}"
2167                )
2168            args[fType] = transition
2169            return base.effect(**args)  # type:ignore
2170
2171        elif fType == "edit":
2172            if len(groupedArgs) == 0:
2173                raise ParseError(
2174                    "An 'edit' effect requires at least one argument."
2175                )
2176            for i, arg in enumerate(groupedArgs):
2177                if not isinstance(arg, list):
2178                    raise ParseError(
2179                        f"'edit' effect argument {i} is not a sub-list:"
2180                        f"\n  {arg!r}"
2181                        f"\nAmong arguments:"
2182                        f"\n  {groupedArgs}"
2183                    )
2184                for j, cmd in enumerate(arg):
2185                    if not isinstance(cmd, tuple):
2186                        raise ParseError(
2187                            f"'edit' effect argument {i} contains"
2188                            f" non-tuple part {j}:"
2189                            f"\n  {cmd!r}"
2190                            f"\nAmong arguments:"
2191                            f"\n  {groupedArgs}"
2192                        )
2193
2194            args[fType] = groupedArgs  # type:ignore
2195            return base.effect(**args)  # type:ignore
2196
2197        elif fType == "goto":
2198            if len(groupedArgs) not in (1, 2):
2199                raise ParseError(
2200                    f"A 'goto' effect must include either one or two"
2201                    f" grouped arguments. Got {len(groupedArgs)}:"
2202                    f"\n{groupedArgs}"
2203                )
2204
2205            first = groupedArgs[0]
2206            if not isinstance(
2207                first,
2208                (base.DecisionName, base.DecisionSpecifier)
2209            ):
2210                raise ParseError(
2211                    f"'{fType}' effect must first specify a destination"
2212                    f" decision. Got:\n{groupedArgs}"
2213                )
2214
2215            # Check if it's really a decision ID
2216            dSpec: base.AnyDecisionSpecifier
2217            if isinstance(first, base.DecisionName):
2218                try:
2219                    dSpec = int(first)
2220                except ValueError:
2221                    dSpec = first
2222            else:
2223                dSpec = first
2224
2225            if len(groupedArgs) == 2:
2226                second = groupedArgs[1]
2227                if not isinstance(second, base.FocalPointName):
2228                    raise ParseError(
2229                        f"'{fType}' effect must have a focal point name"
2230                        f" if it has a second part. Got:\n{groupedArgs}"
2231                    )
2232                args[fType] = (dSpec, second)
2233            else:
2234                args[fType] = dSpec
2235
2236            return base.effect(**args)  # type:ignore
2237
2238        elif fType == "save":
2239            if len(groupedArgs) not in (0, 1):
2240                raise ParseError(
2241                    f"'{fType}' effect must include exactly zero or one"
2242                    f" argument(s). Got {len(groupedArgs)}):"
2243                    f"\n{groupedArgs}"
2244                )
2245
2246            if len(groupedArgs) == 1:
2247                slot = groupedArgs[0]
2248            else:
2249                slot = base.DEFAULT_SAVE_SLOT
2250            if not isinstance(slot, base.SaveSlot):
2251                raise ParseError(
2252                    f"Invalid argument for '{fType}' effect. Needed a"
2253                    f" save slot but got:\n{groupedArgs}"
2254                )
2255            args[fType] = slot
2256            return base.effect(**args)  # type:ignore
2257
2258        else:
2259            raise ParseError(f"Invalid effect type: '{fType}'.")
2260
2261    def parseEffect(self, effectStr: str) -> base.Effect:
2262        """
2263        Works like `parseEffectFromTokens` but starts with a raw string.
2264        For example:
2265
2266        >>> pf = ParseFormat()
2267        >>> pf.parseEffect("gain jump") == base.effect(gain='jump')
2268        True
2269        >>> pf.parseEffect("set door:open") == base.effect(
2270        ...     set=(
2271        ...         base.MechanismSpecifier(None, None, None, 'door'),
2272        ...         'open'
2273        ...     )
2274        ... )
2275        True
2276        >>> pf.parseEffect("set coins*10") == base.effect(set=('coins', 10))
2277        True
2278        >>> pf.parseEffect("set agility^3") == base.effect(
2279        ...     set=('skill', 'agility', 3)
2280        ... )
2281        True
2282        """
2283        return self.parseEffectFromTokens(self.lex(effectStr))
2284
2285    def unparseEffect(self, effect: base.Effect) -> str:
2286        """
2287        The opposite of `parseEffect`; turns an effect back into a
2288        string reprensentation.
2289
2290        For example:
2291
2292        >>> pf = ParseFormat()
2293        >>> e = {
2294        ...     "type": "gain",
2295        ...     "applyTo": "active",
2296        ...     "value": "flight",
2297        ...     "delay": None,
2298        ...     "charges": None,
2299        ...     "hidden": False
2300        ... }
2301        >>> pf.unparseEffect(e)
2302        'gain flight'
2303        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2304        True
2305        >>> s = 'gain flight'
2306        >>> pf.unparseEffect(pf.parseEffect(s)) == s
2307        True
2308        >>> s2 = '  gain\\nflight'
2309        >>> pf.unparseEffect(pf.parseEffect(s2)) == s
2310        True
2311        >>> e = {
2312        ...     "type": "gain",
2313        ...     "applyTo": "active",
2314        ...     "value": ("gold", 5),
2315        ...     "delay": 1,
2316        ...     "charges": 2,
2317        ...     "hidden": False
2318        ... }
2319        >>> pf.unparseEffect(e)
2320        'gain gold*5 ,1 =2'
2321        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2322        True
2323        >>> e = {
2324        ...     "type": "set",
2325        ...     "applyTo": "active",
2326        ...     "value": (
2327        ...         base.MechanismSpecifier(None, None, None, "gears"),
2328        ...         "on"
2329        ...     ),
2330        ...     "delay": None,
2331        ...     "charges": 1,
2332        ...     "hidden": False
2333        ... }
2334        >>> pf.unparseEffect(e)
2335        'set gears:on =1'
2336        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2337        True
2338        >>> e = {
2339        ...     "type": "toggle",
2340        ...     "applyTo": "active",
2341        ...     "value": ["red", "blue"],
2342        ...     "delay": None,
2343        ...     "charges": None,
2344        ...     "hidden": False
2345        ... }
2346        >>> pf.unparseEffect(e)
2347        'toggle red blue'
2348        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2349        True
2350        >>> e = {
2351        ...     "type": "toggle",
2352        ...     "applyTo": "active",
2353        ...     "value": (
2354        ...         base.MechanismSpecifier(None, None, None, "switch"),
2355        ...         ["on", "off"]
2356        ...     ),
2357        ...     "delay": None,
2358        ...     "charges": None,
2359        ...     "hidden": False
2360        ... }
2361        >>> pf.unparseEffect(e)
2362        'toggle switch:on off'
2363        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2364        True
2365        >>> e = {
2366        ...     "type": "deactivate",
2367        ...     "applyTo": "active",
2368        ...     "value": None,
2369        ...     "delay": 2,
2370        ...     "charges": None,
2371        ...     "hidden": False
2372        ... }
2373        >>> pf.unparseEffect(e)
2374        'deactivate ,2'
2375        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2376        True
2377        >>> e = {
2378        ...     "type": "goto",
2379        ...     "applyTo": "common",
2380        ...     "value": 3,
2381        ...     "delay": None,
2382        ...     "charges": None,
2383        ...     "hidden": False
2384        ... }
2385        >>> pf.unparseEffect(e)
2386        'goto 3 +c'
2387        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2388        True
2389        >>> e = {
2390        ...     "type": "goto",
2391        ...     "applyTo": "common",
2392        ...     "value": 3,
2393        ...     "delay": None,
2394        ...     "charges": None,
2395        ...     "hidden": True
2396        ... }
2397        >>> pf.unparseEffect(e)
2398        'goto 3 +c +h'
2399        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2400        True
2401        >>> e = {
2402        ...     "type": "goto",
2403        ...     "applyTo": "active",
2404        ...     "value": 'home',
2405        ...     "delay": None,
2406        ...     "charges": None,
2407        ...     "hidden": False
2408        ... }
2409        >>> pf.unparseEffect(e)
2410        'goto home'
2411        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2412        True
2413        >>> e = base.effect(edit=[
2414        ...     [
2415        ...         commands.command('val', '5'),
2416        ...         commands.command('empty', 'list'),
2417        ...         commands.command('append', '$_')
2418        ...     ],
2419        ...     [
2420        ...         commands.command('val', '11'),
2421        ...         commands.command('assign', 'var', '$_'),
2422        ...         commands.command('op', '+', '$var', '$var')
2423        ...     ],
2424        ... ])
2425        >>> pf.unparseEffect(e)
2426        'edit {\\n  val 5;\\n  empty list;\\n  append $_;\\n}\
2427 {\\n  val 11;\\n  assign var $_;\\n  op + $var $var;\\n}'
2428        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2429        True
2430        >>> e = base.effect(set=('coins', 3))
2431        >>> pf.unparseEffect(e)
2432        'set coins*3'
2433        >>> e = base.effect(set=('skill', 'mashing', 3))
2434        >>> pf.unparseEffect(e)
2435        'set mashing^3'
2436        """
2437        result: List[str] = []
2438
2439        # Reverse the effect type into a marker
2440        eType = effect['type']
2441        for key, val in self.effectNames.items():
2442            if val == eType:
2443                if len(result) != 0:
2444                    raise ParseError(
2445                        f"Effect map contains multiple matching entries"
2446                        f"for effect type '{effect['type']}':"
2447                        f" '{result[0]}' and '{key}'"
2448                    )
2449                result.append(key)
2450                # Don't break 'cause we'd like to check uniqueness
2451
2452        eVal = effect['value']
2453        if eType in ('gain', 'lose'):
2454            eVal = cast(Union[base.Capability, Tuple[base.Token, int]], eVal)
2455            if isinstance(eVal, str):  # a capability
2456                result.append(eVal)
2457            else:  # a token
2458                result.append(
2459                    eVal[0]
2460                  + self.formatDict[Lexeme.tokenCount]
2461                  + str(eVal[1])
2462                )
2463        elif eType == 'set':
2464            eVal = cast(
2465                # TODO: Add skill level setting here & elsewhere
2466                Union[
2467                    Tuple[base.Token, base.TokenCount],
2468                    Tuple[base.MechanismSpecifier, base.MechanismState]
2469                ],
2470                eVal
2471            )
2472            if len(eVal) not in (2, 3):
2473                raise ValueError(
2474                    f"'set' effect has value with length other than 2"
2475                    f" or 3:\n  {repr(effect)}"
2476                )
2477            if len(eVal) == 3:
2478                if eVal[0] != "skill":
2479                    raise ValueError(
2480                        f"'set' effect with length-3 value doesn't"
2481                        f" start with string 'skill':\n  {repr(effect)}"
2482                    )
2483                result.append(
2484                    eVal[1]
2485                  + self.formatDict[Lexeme.skillLevel]
2486                  + str(eVal[2])
2487                )
2488            elif isinstance(eVal[1], int):  # a token count
2489                result.append(
2490                    eVal[0]
2491                  + self.formatDict[Lexeme.tokenCount]
2492                  + str(eVal[1])
2493                )
2494            else:  # a mechanism
2495                if isinstance(eVal[0], base.MechanismSpecifier):
2496                    mSpec = self.unparseMechanismSpecifier(eVal[0])
2497                elif isinstance(eVal[0], base.MechanismID):
2498                    # TODO: Specify mechanism by decision name +
2499                    # mechanism name? Would require threading through a
2500                    # DecisionGraph and using mechanismDetails
2501                    mSpec = "" + eVal[0]
2502                else:
2503                    assert isinstance(eVal[0], base.MechanismName)
2504                    mSpec = eVal[0]
2505                result.append(
2506                    mSpec
2507                  + self.formatDict[Lexeme.mechanismSeparator]
2508                  + eVal[1]
2509                )
2510        elif eType == 'toggle':
2511            if isinstance(eVal, tuple):  # mechanism states
2512                tSpec, states = cast(
2513                    Tuple[
2514                        base.AnyMechanismSpecifier,
2515                        List[base.MechanismState]
2516                    ],
2517                    eVal
2518                )
2519                firstState = states[0]
2520                restStates = states[1:]
2521                if isinstance(tSpec, base.MechanismSpecifier):
2522                    mStr = self.unparseMechanismSpecifier(tSpec)
2523                else:
2524                    # Could be ID or name
2525                    mStr = str(tSpec)
2526                result.append(
2527                    mStr
2528                  + self.formatDict[Lexeme.mechanismSeparator]
2529                  + firstState
2530                )
2531                result.extend(restStates)
2532            else:  # capabilities
2533                assert isinstance(eVal, list)
2534                eVal = cast(List[base.Capability], eVal)
2535                result.extend(eVal)
2536        elif eType in ('deactivate', 'bounce'):
2537            if eVal is not None:
2538                raise ValueError(
2539                    f"'{eType}' effect has non-None value:"
2540                    f"\n  {repr(effect)}"
2541                )
2542        elif eType == 'follow':
2543            eVal = cast(base.Token, eVal)
2544            result.append(eVal)
2545        elif eType == 'edit':
2546            eVal = cast(List[List[commands.Command]], eVal)
2547            if len(eVal) == 0:
2548                result[-1] = '{}'
2549            else:
2550                for cmdList in eVal:
2551                    result.append(
2552                        self.unparseCommandList(cmdList)
2553                    )
2554        elif eType == 'goto':
2555            if (
2556                isinstance(eVal, tuple)
2557            and len(eVal) == 2
2558            and isinstance(eVal[1], base.FocalPointName)
2559            ):
2560                result.append(
2561                    self.unparseAnyDecision(
2562                        cast(base.AnyDecisionSpecifier, eVal[0])
2563                    )
2564                )
2565                result.append(eVal[1])
2566            else:
2567                assert isinstance(
2568                    eVal,
2569                    (base.DecisionID, base.DecisionSpecifier, str)
2570                )
2571                result.append(self.unparseAnyDecision(eVal))
2572        elif eType == 'save':
2573            # It's just a string naming the save slot
2574            eVal = cast(str, eVal)
2575            result.append(eVal)
2576        else:
2577            raise ValueError(
2578                f"Unrecognized effect type '{eType}' in effect:"
2579                f"\n  {repr(effect)}"
2580            )
2581
2582        # Add modifier strings
2583        if effect['applyTo'] == 'common':
2584            result.append(self.formatDict[Lexeme.inCommon])
2585
2586        if effect['hidden']:
2587            result.append(self.formatDict[Lexeme.isHidden])
2588
2589        dVal = effect['delay']
2590        if dVal is not None:
2591            result.append(
2592                self.formatDict[Lexeme.sepOrDelay] + str(dVal)
2593            )
2594
2595        cVal = effect['charges']
2596        if cVal is not None:
2597            result.append(
2598                self.formatDict[Lexeme.effectCharges] + str(cVal)
2599            )
2600
2601        joined = ''
2602        before = False
2603        for r in result:
2604            if (
2605                r.startswith(' ')
2606             or r.startswith('\n')
2607             or r.endswith(' ')
2608             or r.endswith('\n')
2609            ):
2610                joined += r
2611                before = False
2612            else:
2613                joined += (' ' if before else '') + r
2614                before = True
2615        return joined
2616
2617    def parseDecisionSpecifierFromTokens(
2618        self,
2619        tokens: LexedTokens,
2620        start: int = 0
2621    ) -> Tuple[Union[base.DecisionSpecifier, int], int]:
2622        """
2623        Parses a decision specifier starting at the specified position
2624        in the given tokens list. No ending position is specified, but
2625        instead this function returns a tuple containing the parsed
2626        `base.DecisionSpecifier` along with an index in the tokens list
2627        where the end of the specifier was found.
2628
2629        For example:
2630
2631        >>> pf = ParseFormat()
2632        >>> pf.parseDecisionSpecifierFromTokens(['m'])
2633        (DecisionSpecifier(domain=None, zone=None, name='m'), 0)
2634        >>> pf.parseDecisionSpecifierFromTokens(['12'])  # ID specifier
2635        (12, 0)
2636        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'])
2637        (DecisionSpecifier(domain=None, zone=None, name='a'), 0)
2638        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'], 1)
2639        (DecisionSpecifier(domain=None, zone=None, name='m'), 1)
2640        >>> pf.parseDecisionSpecifierFromTokens(
2641        ...     ['a', Lexeme.domainSeparator, 'm']
2642        ... )
2643        (DecisionSpecifier(domain='a', zone=None, name='m'), 2)
2644        >>> pf.parseDecisionSpecifierFromTokens(
2645        ...     ['a', Lexeme.zoneSeparator, 'm']
2646        ... )
2647        (DecisionSpecifier(domain=None, zone='a', name='m'), 2)
2648        >>> pf.parseDecisionSpecifierFromTokens(
2649        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
2650        ... )
2651        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2652        >>> pf.parseDecisionSpecifierFromTokens(
2653        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
2654        ... )
2655        (DecisionSpecifier(domain='a', zone='b', name='m'), 4)
2656        >>> pf.parseDecisionSpecifierFromTokens(
2657        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
2658        ... )
2659        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2660        >>> pf.parseDecisionSpecifierFromTokens(  # ID-style name w/ zone
2661        ...     ['a', Lexeme.zoneSeparator, '5'],
2662        ... )
2663        Traceback (most recent call last):
2664        ...
2665        exploration.base.InvalidDecisionSpecifierError...
2666        >>> pf.parseDecisionSpecifierFromTokens(
2667        ...     ['d', Lexeme.domainSeparator, '123']
2668        ... )
2669        Traceback (most recent call last):
2670        ...
2671        exploration.base.InvalidDecisionSpecifierError...
2672        >>> pf.parseDecisionSpecifierFromTokens(
2673        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2674        ...     1
2675        ... )
2676        Traceback (most recent call last):
2677        ...
2678        exploration.parsing.ParseError...
2679        >>> pf.parseDecisionSpecifierFromTokens(
2680        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2681        ...     2
2682        ... )
2683        (DecisionSpecifier(domain='b', zone=None, name='m'), 4)
2684        >>> pf.parseDecisionSpecifierFromTokens(
2685        ...     [
2686        ...         'a',
2687        ...         Lexeme.domainSeparator,
2688        ...         'b',
2689        ...         Lexeme.zoneSeparator,
2690        ...         'c',
2691        ...         Lexeme.zoneSeparator,
2692        ...         'm'
2693        ...     ]
2694        ... )
2695        (DecisionSpecifier(domain='a', zone='b', name='c'), 4)
2696        >>> pf.parseDecisionSpecifierFromTokens(
2697        ...     [
2698        ...         'a',
2699        ...         Lexeme.domainSeparator,
2700        ...         'b',
2701        ...         Lexeme.zoneSeparator,
2702        ...         'c',
2703        ...         Lexeme.zoneSeparator,
2704        ...         'm'
2705        ...     ],
2706        ...     2
2707        ... )
2708        (DecisionSpecifier(domain=None, zone='b', name='c'), 4)
2709        >>> pf.parseDecisionSpecifierFromTokens(
2710        ...     [
2711        ...         'a',
2712        ...         Lexeme.domainSeparator,
2713        ...         'b',
2714        ...         Lexeme.zoneSeparator,
2715        ...         'c',
2716        ...         Lexeme.zoneSeparator,
2717        ...         'm'
2718        ...     ],
2719        ...     4
2720        ... )
2721        (DecisionSpecifier(domain=None, zone='c', name='m'), 6)
2722        >>> pf.parseDecisionSpecifierFromTokens(
2723        ...     [
2724        ...         'set',
2725        ...         'main',
2726        ...         Lexeme.domainSeparator,
2727        ...         'zone',
2728        ...         Lexeme.zoneSeparator,
2729        ...         'compass',
2730        ...         'north',
2731        ...         'bounce',
2732        ...     ],
2733        ...     1
2734        ... )
2735        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
2736        """
2737        # Check bounds & normalize start index
2738        nTokens = len(tokens)
2739        if start < -nTokens:
2740            raise IndexError(
2741                f"Invalid start index {start} for {nTokens} tokens (too"
2742                f" negative)."
2743            )
2744        elif start >= nTokens:
2745            raise IndexError(
2746                f"Invalid start index {start} for {nTokens} tokens (too"
2747                f" big)."
2748            )
2749        elif start < 0:
2750            start = nTokens + start
2751
2752        assert (start < nTokens)
2753
2754        first = tokens[start]
2755        if not isinstance(first, str):
2756            raise ParseError(
2757                f"Invalid domain specifier (must start with a name or"
2758                f" id; got: {first} = {self.formatDict[first]})."
2759            )
2760
2761        ds = base.DecisionSpecifier(None, None, first)
2762        result = (base.idOrDecisionSpecifier(ds), start)
2763
2764        domain = None
2765        zoneOrDecision = None
2766
2767        if start + 1 >= nTokens:  # at end of tokens
2768            return result
2769
2770        firstSep = tokens[start + 1]
2771        if firstSep == Lexeme.domainSeparator:
2772            domain = first
2773        elif firstSep == Lexeme.zoneSeparator:
2774            zoneOrDecision = first
2775        else:
2776            return result
2777
2778        if start + 2 >= nTokens:
2779            return result
2780
2781        second = tokens[start + 2]
2782        if isinstance(second, Lexeme):
2783            return result
2784
2785        ds = base.DecisionSpecifier(domain, zoneOrDecision, second)
2786        result = (base.idOrDecisionSpecifier(ds), start + 2)
2787
2788        if start + 3 >= nTokens:
2789            return result
2790
2791        secondSep = tokens[start + 3]
2792        if start + 4 >= nTokens:
2793            return result
2794
2795        third = tokens[start + 4]
2796        if secondSep == Lexeme.zoneSeparator:
2797            if zoneOrDecision is not None:  # two in a row
2798                return result
2799            else:
2800                if not isinstance(third, base.DecisionName):
2801                    return result
2802                else:
2803                    zoneOrDecision = second
2804        else:
2805            return result
2806
2807        if isinstance(third, Lexeme):
2808            return result
2809
2810        ds = base.DecisionSpecifier(domain, zoneOrDecision, third)
2811        return (base.idOrDecisionSpecifier(ds), start + 4)
2812
2813    def parseDecisionSpecifier(
2814        self,
2815        specString: str
2816    ) -> Union[base.DecisionID, base.DecisionSpecifier]:
2817        """
2818        Parses a full `DecisionSpecifier` from a single string. Can
2819        parse integer decision IDs in string form, and returns a
2820        `DecisionID` in that case, otherwise returns a
2821        `DecisionSpecifier`. Assumes that all int-convertible strings
2822        are decision IDs, so it cannot deal with feature names which are
2823        just numbers.
2824
2825        For example:
2826
2827        >>> pf = ParseFormat()
2828        >>> pf.parseDecisionSpecifier('example')
2829        DecisionSpecifier(domain=None, zone=None, name='example')
2830        >>> pf.parseDecisionSpecifier('outer::example')
2831        DecisionSpecifier(domain=None, zone='outer', name='example')
2832        >>> pf.parseDecisionSpecifier('domain//region::feature')
2833        DecisionSpecifier(domain='domain', zone='region', name='feature')
2834        >>> pf.parseDecisionSpecifier('123')
2835        123
2836        >>> pf.parseDecisionSpecifier('region::domain//feature')
2837        Traceback (most recent call last):
2838        ...
2839        exploration.base.InvalidDecisionSpecifierError...
2840        >>> pf.parseDecisionSpecifier('domain1//domain2//feature')
2841        Traceback (most recent call last):
2842        ...
2843        exploration.base.InvalidDecisionSpecifierError...
2844        >>> pf.parseDecisionSpecifier('domain//123')
2845        Traceback (most recent call last):
2846        ...
2847        exploration.base.InvalidDecisionSpecifierError...
2848        >>> pf.parseDecisionSpecifier('region::123')
2849        Traceback (most recent call last):
2850        ...
2851        exploration.base.InvalidDecisionSpecifierError...
2852        """
2853        try:
2854            return int(specString)
2855        except ValueError:
2856            tokens = self.lex(specString)
2857            result, end = self.parseDecisionSpecifierFromTokens(tokens)
2858            if end != len(tokens) - 1:
2859                raise base.InvalidDecisionSpecifierError(
2860                    f"Junk after end of decision specifier:"
2861                    f"\n{tokens[end + 1:]}"
2862                )
2863            return result
2864
2865    def parseFeatureSpecifierFromTokens(
2866        self,
2867        tokens: LexedTokens,
2868        start: int = 0,
2869        limit: int = -1
2870    ) -> Tuple[base.FeatureSpecifier, int]:
2871        """
2872        Parses a `FeatureSpecifier` starting from the specified part of
2873        a tokens list. Returns a tuple containing the feature specifier
2874        and the end position of the end of the feature specifier.
2875
2876        Can parse integer feature IDs in string form, as well as nested
2877        feature specifiers and plain feature specifiers. Assumes that
2878        all int-convertible strings are feature IDs, so it cannot deal
2879        with feature names which are just numbers.
2880
2881        For example:
2882
2883        >>> pf = ParseFormat()
2884        >>> pf.parseFeatureSpecifierFromTokens(['example'])
2885        (FeatureSpecifier(domain=None, within=[], feature='example',\
2886 part=None), 0)
2887        >>> pf.parseFeatureSpecifierFromTokens(['example1', 'example2'], 1)
2888        (FeatureSpecifier(domain=None, within=[], feature='example2',\
2889 part=None), 1)
2890        >>> pf.parseFeatureSpecifierFromTokens(
2891        ...     [
2892        ...         'domain',
2893        ...         Lexeme.domainSeparator,
2894        ...         'region',
2895        ...         Lexeme.zoneSeparator,
2896        ...         'feature',
2897        ...         Lexeme.partSeparator,
2898        ...         'part'
2899        ...     ]
2900        ... )
2901        (FeatureSpecifier(domain='domain', within=['region'],\
2902 feature='feature', part='part'), 6)
2903        >>> pf.parseFeatureSpecifierFromTokens(
2904        ...     [
2905        ...         'outerRegion',
2906        ...         Lexeme.zoneSeparator,
2907        ...         'midRegion',
2908        ...         Lexeme.zoneSeparator,
2909        ...         'innerRegion',
2910        ...         Lexeme.zoneSeparator,
2911        ...         'feature'
2912        ...     ]
2913        ... )
2914        (FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
2915 'innerRegion'], feature='feature', part=None), 6)
2916        >>> pf.parseFeatureSpecifierFromTokens(
2917        ...     [
2918        ...         'outerRegion',
2919        ...         Lexeme.zoneSeparator,
2920        ...         'midRegion',
2921        ...         Lexeme.zoneSeparator,
2922        ...         'innerRegion',
2923        ...         Lexeme.zoneSeparator,
2924        ...         'feature'
2925        ...     ],
2926        ...     1
2927        ... )
2928        Traceback (most recent call last):
2929        ...
2930        exploration.parsing.InvalidFeatureSpecifierError...
2931        >>> pf.parseFeatureSpecifierFromTokens(
2932        ...     [
2933        ...         'outerRegion',
2934        ...         Lexeme.zoneSeparator,
2935        ...         'midRegion',
2936        ...         Lexeme.zoneSeparator,
2937        ...         'innerRegion',
2938        ...         Lexeme.zoneSeparator,
2939        ...         'feature'
2940        ...     ],
2941        ...     2
2942        ... )
2943        (FeatureSpecifier(domain=None, within=['midRegion', 'innerRegion'],\
2944 feature='feature', part=None), 6)
2945        >>> pf.parseFeatureSpecifierFromTokens(
2946        ...     [
2947        ...         'outerRegion',
2948        ...         Lexeme.zoneSeparator,
2949        ...         'feature',
2950        ...         Lexeme.domainSeparator,
2951        ...         'after',
2952        ...     ]
2953        ... )
2954        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2955 feature='feature', part=None), 2)
2956        >>> pf.parseFeatureSpecifierFromTokens(
2957        ...     [
2958        ...         'outerRegion',
2959        ...         Lexeme.zoneSeparator,
2960        ...         'feature',
2961        ...         Lexeme.domainSeparator,
2962        ...         'after',
2963        ...     ],
2964        ...     2
2965        ... )
2966        (FeatureSpecifier(domain='feature', within=[], feature='after',\
2967 part=None), 4)
2968        >>> # Including a limit:
2969        >>> pf.parseFeatureSpecifierFromTokens(
2970        ...     [
2971        ...         'outerRegion',
2972        ...         Lexeme.zoneSeparator,
2973        ...         'midRegion',
2974        ...         Lexeme.zoneSeparator,
2975        ...         'feature',
2976        ...     ],
2977        ...     0,
2978        ...     2
2979        ... )
2980        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2981 feature='midRegion', part=None), 2)
2982        >>> pf.parseFeatureSpecifierFromTokens(
2983        ...     [
2984        ...         'outerRegion',
2985        ...         Lexeme.zoneSeparator,
2986        ...         'midRegion',
2987        ...         Lexeme.zoneSeparator,
2988        ...         'feature',
2989        ...     ],
2990        ...     0,
2991        ...     0
2992        ... )
2993        (FeatureSpecifier(domain=None, within=[], feature='outerRegion',\
2994 part=None), 0)
2995        >>> pf.parseFeatureSpecifierFromTokens(
2996        ...     [
2997        ...         'region',
2998        ...         Lexeme.zoneSeparator,
2999        ...         Lexeme.zoneSeparator,
3000        ...         'feature',
3001        ...     ]
3002        ... )
3003        (FeatureSpecifier(domain=None, within=[], feature='region',\
3004 part=None), 0)
3005        """
3006        start, limit, nTokens = normalizeEnds(tokens, start, limit)
3007
3008        if nTokens == 0:
3009            raise InvalidFeatureSpecifierError(
3010                "Can't parse a feature specifier from 0 tokens."
3011            )
3012        first = tokens[start]
3013        if isinstance(first, Lexeme):
3014            raise InvalidFeatureSpecifierError(
3015                f"Feature specifier can't begin with a special token."
3016                f"Got:\n{tokens[start:limit + 1]}"
3017            )
3018
3019        if nTokens in (1, 2):
3020            # 2 tokens isn't enough for a second part
3021            fs = base.FeatureSpecifier(
3022                domain=None,
3023                within=[],
3024                feature=first,
3025                part=None
3026            )
3027            return (base.normalizeFeatureSpecifier(fs), start)
3028
3029        firstSep = tokens[start + 1]
3030        secondPart = tokens[start + 2]
3031
3032        if (
3033            firstSep not in (
3034                Lexeme.domainSeparator,
3035                Lexeme.zoneSeparator,
3036                Lexeme.partSeparator
3037            )
3038         or not isinstance(secondPart, str)
3039        ):
3040            # Following tokens won't work out
3041            fs = base.FeatureSpecifier(
3042                domain=None,
3043                within=[],
3044                feature=first,
3045                part=None
3046            )
3047            return (base.normalizeFeatureSpecifier(fs), start)
3048
3049        if firstSep == Lexeme.domainSeparator:
3050            if start + 2 > limit:
3051                return (
3052                    base.FeatureSpecifier(
3053                        domain=first,
3054                        within=[],
3055                        feature=secondPart,
3056                        part=None
3057                    ),
3058                    start + 2
3059                )
3060            else:
3061                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3062                    tokens,
3063                    start + 2,
3064                    limit
3065                )
3066                if rest.domain is not None:  # two domainSeparators in a row
3067                    fs = base.FeatureSpecifier(
3068                        domain=first,
3069                        within=[],
3070                        feature=rest.domain,
3071                        part=None
3072                    )
3073                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3074                else:
3075                    fs = base.FeatureSpecifier(
3076                        domain=first,
3077                        within=rest.within,
3078                        feature=rest.feature,
3079                        part=rest.part
3080                    )
3081                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3082
3083        elif firstSep == Lexeme.zoneSeparator:
3084            if start + 2 > limit:
3085                fs = base.FeatureSpecifier(
3086                    domain=None,
3087                    within=[first],
3088                    feature=secondPart,
3089                    part=None
3090                )
3091                return (base.normalizeFeatureSpecifier(fs), start + 2)
3092            else:
3093                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3094                    tokens,
3095                    start + 2,
3096                    limit
3097                )
3098                if rest.domain is not None:  # domain sep after zone sep
3099                    fs = base.FeatureSpecifier(
3100                        domain=None,
3101                        within=[first],
3102                        feature=rest.domain,
3103                        part=None
3104                    )
3105                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3106                else:
3107                    within = [first]
3108                    within.extend(rest.within)
3109                    fs = base.FeatureSpecifier(
3110                        domain=None,
3111                        within=within,
3112                        feature=rest.feature,
3113                        part=rest.part
3114                    )
3115                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3116
3117        else:  # must be partSeparator
3118            fs = base.FeatureSpecifier(
3119                domain=None,
3120                within=[],
3121                feature=first,
3122                part=secondPart
3123            )
3124            return (base.normalizeFeatureSpecifier(fs), start + 2)
3125
3126    def parseFeatureSpecifier(self, specString: str) -> base.FeatureSpecifier:
3127        """
3128        Parses a full `FeatureSpecifier` from a single string. See
3129        `parseFeatureSpecifierFromTokens`.
3130
3131        >>> pf = ParseFormat()
3132        >>> pf.parseFeatureSpecifier('example')
3133        FeatureSpecifier(domain=None, within=[], feature='example', part=None)
3134        >>> pf.parseFeatureSpecifier('outer::example')
3135        FeatureSpecifier(domain=None, within=['outer'], feature='example',\
3136 part=None)
3137        >>> pf.parseFeatureSpecifier('example%%middle')
3138        FeatureSpecifier(domain=None, within=[], feature='example',\
3139 part='middle')
3140        >>> pf.parseFeatureSpecifier('domain//region::feature%%part')
3141        FeatureSpecifier(domain='domain', within=['region'],\
3142 feature='feature', part='part')
3143        >>> pf.parseFeatureSpecifier(
3144        ...     'outerRegion::midRegion::innerRegion::feature'
3145        ... )
3146        FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
3147 'innerRegion'], feature='feature', part=None)
3148        >>> pf.parseFeatureSpecifier('region::domain//feature')
3149        Traceback (most recent call last):
3150        ...
3151        exploration.parsing.InvalidFeatureSpecifierError...
3152        >>> pf.parseFeatureSpecifier('feature%%part1%%part2')
3153        Traceback (most recent call last):
3154        ...
3155        exploration.parsing.InvalidFeatureSpecifierError...
3156        >>> pf.parseFeatureSpecifier('domain1//domain2//feature')
3157        Traceback (most recent call last):
3158        ...
3159        exploration.parsing.InvalidFeatureSpecifierError...
3160        >>> # TODO: Issue warnings for these...
3161        >>> pf.parseFeatureSpecifier('domain//123')  # domain discarded
3162        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3163        >>> pf.parseFeatureSpecifier('region::123')  # zone discarded
3164        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3165        >>> pf.parseFeatureSpecifier('123%%part')
3166        FeatureSpecifier(domain=None, within=[], feature=123, part='part')
3167        """
3168        tokens = self.lex(specString)
3169        result, rEnd = self.parseFeatureSpecifierFromTokens(tokens)
3170        if rEnd != len(tokens) - 1:
3171            raise InvalidFeatureSpecifierError(
3172                f"Feature specifier has extra stuff at end:"
3173                f" {tokens[rEnd + 1:]}"
3174            )
3175        else:
3176            return result
3177
3178    def normalizeFeatureSpecifier(
3179        self,
3180        spec: base.AnyFeatureSpecifier
3181    ) -> base.FeatureSpecifier:
3182        """
3183        Normalizes any kind of feature specifier into an official
3184        `FeatureSpecifier` tuple.
3185
3186        For example:
3187
3188        >>> pf = ParseFormat()
3189        >>> pf.normalizeFeatureSpecifier('town')
3190        FeatureSpecifier(domain=None, within=[], feature='town', part=None)
3191        >>> pf.normalizeFeatureSpecifier(5)
3192        FeatureSpecifier(domain=None, within=[], feature=5, part=None)
3193        >>> pf.parseFeatureSpecifierFromTokens(
3194        ...     [
3195        ...         'domain',
3196        ...         Lexeme.domainSeparator,
3197        ...         'region',
3198        ...         Lexeme.zoneSeparator,
3199        ...         'feature',
3200        ...         Lexeme.partSeparator,
3201        ...         'part'
3202        ...     ]
3203        ... )
3204        (FeatureSpecifier(domain='domain', within=['region'],\
3205 feature='feature', part='part'), 6)
3206        >>> pf.normalizeFeatureSpecifier('dom//one::two::three%%middle')
3207        FeatureSpecifier(domain='dom', within=['one', 'two'],\
3208 feature='three', part='middle')
3209        >>> pf.normalizeFeatureSpecifier(
3210        ...   base.FeatureSpecifier(None, ['region'], 'place', None)
3211        ... )
3212        FeatureSpecifier(domain=None, within=['region'], feature='place',\
3213 part=None)
3214        >>> fs = base.FeatureSpecifier(None, [], 'place', None)
3215        >>> ns = pf.normalizeFeatureSpecifier(fs)
3216        >>> ns is fs  # Doesn't create unnecessary clones
3217        True
3218        """
3219        if isinstance(spec, base.FeatureSpecifier):
3220            return spec
3221        elif isinstance(spec, base.FeatureID):
3222            return base.FeatureSpecifier(None, [], spec, None)
3223        elif isinstance(spec, str):
3224            return self.parseFeatureSpecifier(spec)
3225        else:
3226            raise TypeError(f"Invalid feature specifier type: '{type(spec)}'")
3227
3228    def unparseChallenge(self, challenge: base.Challenge) -> str:
3229        """
3230        Turns a `base.Challenge` into a string that can be turned back
3231        into an equivalent challenge by `parseChallenge`. For example:
3232
3233        >>> pf = ParseFormat()
3234        >>> c = base.challenge(
3235        ...     skills=base.BestSkill('brains', 'brawn'),
3236        ...     level=2,
3237        ...     success=[base.effect(set=('switch', 'on'))],
3238        ...     failure=[
3239        ...         base.effect(deactivate=True, delay=1),
3240        ...         base.effect(bounce=True)
3241        ...     ],
3242        ...     outcome=True
3243        ... )
3244        >>> r = pf.unparseChallenge(c)
3245        >>> r
3246        '<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}'
3247        >>> pf.parseChallenge(r) == c
3248        True
3249        >>> c2 = base.challenge(
3250        ...     skills=base.CombinedSkill(
3251        ...         -2,
3252        ...         base.ConditionalSkill(
3253        ...             base.ReqCapability('tough'),
3254        ...             base.BestSkill(1),
3255        ...             base.BestSkill(-1)
3256        ...         )
3257        ...     ),
3258        ...     level=-2,
3259        ...     success=[base.effect(gain='orb')],
3260        ...     failure=[],
3261        ...     outcome=None
3262        ... )
3263        >>> r2 = pf.unparseChallenge(c2)
3264        >>> r2
3265        '<-2>sum(-2, if(tough, best(1), best(-1))){gain orb}{}'
3266        >>> # TODO: let this parse through without BestSkills...
3267        >>> pf.parseChallenge(r2) == c2
3268        True
3269        """
3270        lt = self.formatDict[Lexeme.angleLeft]
3271        gt = self.formatDict[Lexeme.angleRight]
3272        result = (
3273            lt + str(challenge['level']) + gt
3274          + challenge['skills'].unparse()
3275        )
3276        if challenge['outcome'] is True:
3277            result += gt
3278        result += self.unparseConsequence(challenge['success'])
3279        if challenge['outcome'] is False:
3280            result += gt
3281        result += self.unparseConsequence(challenge['failure'])
3282        return result
3283
3284    def unparseCondition(self, condition: base.Condition) -> str:
3285        """
3286        Given a `base.Condition` returns a string that would result in
3287        that condition if given to `parseCondition`. For example:
3288
3289        >>> pf = ParseFormat()
3290        >>> c = base.condition(
3291        ...     condition=base.ReqAny([
3292        ...         base.ReqCapability('brawny'),
3293        ...         base.ReqNot(base.ReqTokens('weights', 3))
3294        ...     ]),
3295        ...     consequence=[base.effect(gain='power')]
3296        ... )
3297        >>> r = pf.unparseCondition(c)
3298        >>> r
3299        '??((brawny|!(weights*3))){gain power}{}'
3300        >>> pf.parseCondition(r) == c
3301        True
3302        """
3303        return (
3304            self.formatDict[Lexeme.doubleQuestionmark]
3305          + self.formatDict[Lexeme.openParen]
3306          + condition['condition'].unparse()
3307          + self.formatDict[Lexeme.closeParen]
3308          + self.unparseConsequence(condition['consequence'])
3309          + self.unparseConsequence(condition['alternative'])
3310        )
3311
3312    def unparseConsequence(self, consequence: base.Consequence) -> str:
3313        """
3314        Given a `base.Consequence`, returns a string encoding of it,
3315        using the same format that `parseConsequence` will parse. Uses
3316        function-call-like syntax and curly braces to denote different
3317        sub-consequences. See also `SkillCombination.unparse` and
3318        `Requirement.unparse` For example:
3319
3320        >>> pf = ParseFormat()
3321        >>> c = [base.effect(gain='one'), base.effect(lose='one')]
3322        >>> pf.unparseConsequence(c)
3323        '{gain one; lose one}'
3324        >>> c = [
3325        ...     base.challenge(
3326        ...         skills=base.BestSkill('brains', 'brawn'),
3327        ...         level=2,
3328        ...         success=[base.effect(set=('switch', 'on'))],
3329        ...         failure=[
3330        ...             base.effect(deactivate=True, delay=1),
3331        ...             base.effect(bounce=True)
3332        ...         ],
3333        ...         outcome=True
3334        ...     )
3335        ... ]
3336        >>> pf.unparseConsequence(c)
3337        '{<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}}'
3338        >>> c[0]['outcome'] = False
3339        >>> pf.unparseConsequence(c)
3340        '{<2>best(brains, brawn){set switch:on}>{deactivate ,1; bounce}}'
3341        >>> c[0]['outcome'] = None
3342        >>> pf.unparseConsequence(c)
3343        '{<2>best(brains, brawn){set switch:on}{deactivate ,1; bounce}}'
3344        >>> c = [
3345        ...     base.condition(
3346        ...         condition=base.ReqAny([
3347        ...             base.ReqCapability('brawny'),
3348        ...             base.ReqNot(base.ReqTokens('weights', 3))
3349        ...         ]),
3350        ...         consequence=[
3351        ...             base.challenge(
3352        ...                 skills=base.CombinedSkill('brains', 'brawn'),
3353        ...                 level=3,
3354        ...                 success=[base.effect(goto='home')],
3355        ...                 failure=[base.effect(bounce=True)],
3356        ...                 outcome=None
3357        ...             )
3358        ...         ]  # no alternative -> empty list
3359        ...     )
3360        ... ]
3361        >>> pf.unparseConsequence(c)
3362        '{??((brawny|!(weights*3))){\
3363<3>sum(brains, brawn){goto home}{bounce}}{}}'
3364        >>> c = [base.effect(gain='if(power){gain "mimic"}')]
3365        >>> # TODO: Make this work!
3366        >>> # pf.unparseConsequence(c)
3367
3368        '{gain "if(power){gain \\\\"mimic\\\\"}"}'
3369        """
3370        result = self.formatDict[Lexeme.openCurly]
3371        for item in consequence:
3372            if 'skills' in item:  # a Challenge
3373                item = cast(base.Challenge, item)
3374                result += self.unparseChallenge(item)
3375
3376            elif 'value' in item:  # an Effect
3377                item = cast(base.Effect, item)
3378                result += self.unparseEffect(item)
3379
3380            elif 'condition' in item:  # a Condition
3381                item = cast(base.Condition, item)
3382                result += self.unparseCondition(item)
3383
3384            else:  # bad dict
3385                raise TypeError(
3386                    f"Invalid consequence: items in the list must be"
3387                    f" Effects, Challenges, or Conditions (got a dictionary"
3388                    f" without 'skills', 'value', or 'condition' keys)."
3389                    f"\nGot item: {repr(item)}"
3390                )
3391            result += '; '
3392
3393        if result.endswith('; '):
3394            result = result[:-2]
3395
3396        return result + self.formatDict[Lexeme.closeCurly]
3397
3398    def parseMechanismSpecifierFromTokens(
3399        self,
3400        tokens: LexedTokens,
3401        start: int = 0
3402    ) -> Tuple[base.MechanismSpecifier, int]:
3403        """
3404        Parses a mechanism specifier starting at the specified position
3405        in the given tokens list. No ending position is specified, but
3406        instead this function returns a tuple containing the parsed
3407        `base.MechanismSpecifier` along with an index in the tokens list
3408        where the end of the specifier was found.
3409
3410        For example:
3411
3412        >>> pf = ParseFormat()
3413        >>> pf.parseMechanismSpecifierFromTokens(['m'])
3414        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3415 name='m'), 0)
3416        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'])
3417        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3418 name='a'), 0)
3419        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'], 1)
3420        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3421 name='m'), 1)
3422        >>> pf.parseMechanismSpecifierFromTokens(
3423        ...     ['a', Lexeme.domainSeparator, 'm']
3424        ... )
3425        (MechanismSpecifier(domain='a', zone=None, decision=None,\
3426 name='m'), 2)
3427        >>> pf.parseMechanismSpecifierFromTokens(
3428        ...     ['a', Lexeme.zoneSeparator, 'm']
3429        ... )
3430        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3431 name='m'), 2)
3432        >>> pf.parseMechanismSpecifierFromTokens(
3433        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
3434        ... )
3435        (MechanismSpecifier(domain=None, zone='a', decision='b',\
3436 name='m'), 4)
3437        >>> pf.parseMechanismSpecifierFromTokens(
3438        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
3439        ... )
3440        (MechanismSpecifier(domain='a', zone=None, decision='b',\
3441 name='m'), 4)
3442        >>> pf.parseMechanismSpecifierFromTokens(
3443        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
3444        ... )
3445        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3446 name='b'), 2)
3447        >>> pf.parseMechanismSpecifierFromTokens(
3448        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3449        ...     1
3450        ... )
3451        Traceback (most recent call last):
3452        ...
3453        exploration.parsing.ParseError...
3454        >>> pf.parseMechanismSpecifierFromTokens(
3455        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3456        ...     2
3457        ... )
3458        (MechanismSpecifier(domain='b', zone=None, decision=None,\
3459 name='m'), 4)
3460        >>> pf.parseMechanismSpecifierFromTokens(
3461        ...     [
3462        ...         'a',
3463        ...         Lexeme.domainSeparator,
3464        ...         'b',
3465        ...         Lexeme.zoneSeparator,
3466        ...         'c',
3467        ...         Lexeme.zoneSeparator,
3468        ...         'm'
3469        ...     ]
3470        ... )
3471        (MechanismSpecifier(domain='a', zone='b', decision='c', name='m'), 6)
3472        >>> pf.parseMechanismSpecifierFromTokens(
3473        ...     [
3474        ...         'a',
3475        ...         Lexeme.domainSeparator,
3476        ...         'b',
3477        ...         Lexeme.zoneSeparator,
3478        ...         'c',
3479        ...         Lexeme.zoneSeparator,
3480        ...         'm'
3481        ...     ],
3482        ...     2
3483        ... )
3484        (MechanismSpecifier(domain=None, zone='b', decision='c',\
3485 name='m'), 6)
3486        >>> pf.parseMechanismSpecifierFromTokens(
3487        ...     [
3488        ...         'a',
3489        ...         Lexeme.domainSeparator,
3490        ...         'b',
3491        ...         Lexeme.zoneSeparator,
3492        ...         'c',
3493        ...         Lexeme.zoneSeparator,
3494        ...         'm'
3495        ...     ],
3496        ...     4
3497        ... )
3498        (MechanismSpecifier(domain=None, zone=None, decision='c',\
3499 name='m'), 6)
3500        >>> pf.parseMechanismSpecifierFromTokens(
3501        ...     [
3502        ...         'roomB',
3503        ...         Lexeme.zoneSeparator,
3504        ...         'switch',
3505        ...         Lexeme.mechanismSeparator,
3506        ...         'on'
3507        ...     ]
3508        ... )
3509        (MechanismSpecifier(domain=None, zone=None, decision='roomB',\
3510 name='switch'), 2)
3511        >>> pf.parseMechanismSpecifierFromTokens(
3512        ...     [
3513        ...         '500',
3514        ...         Lexeme.zoneSeparator,
3515        ...         'm'
3516        ...     ],
3517        ...     0
3518        ... )
3519        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3520 name='m'), 2)
3521        >>> pf.parseMechanismSpecifierFromTokens(
3522        ...     [
3523        ...         '500',
3524        ...         Lexeme.zoneSeparator,
3525        ...         Lexeme.zoneSeparator,
3526        ...         'm'
3527        ...     ],
3528        ...     0
3529        ... )
3530        Traceback (most recent call last):
3531        ...
3532        exploration.parsing.ParseError...
3533        >>> pf.parseMechanismSpecifierFromTokens(
3534        ...     [
3535        ...         '500',
3536        ...         Lexeme.zoneSeparator,
3537        ...         'm',
3538        ...         Lexeme.mechanismSeparator,
3539        ...         'on'
3540        ...     ],
3541        ...     0
3542        ... )
3543        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3544 name='m'), 2)
3545        >>> pf.parseMechanismSpecifierFromTokens(
3546        ...     [
3547        ...         '500',
3548        ...         Lexeme.zoneSeparator,
3549        ...         'd',
3550        ...         Lexeme.zoneSeparator,
3551        ...         'm'
3552        ...     ],
3553        ...     0
3554        ... )
3555        (MechanismSpecifier(domain=None, zone='500', decision='d',\
3556 name='m'), 4)
3557        """
3558        start, tEnd, nLeft = normalizeEnds(tokens, start, -1)
3559
3560        try:
3561            dSpec, dEnd = self.parseDecisionSpecifierFromTokens(
3562                tokens,
3563                start
3564            )
3565        except ParseError:
3566            raise ParseError(
3567                "Failed to parse mechanism specifier couldn't parse"
3568                " initial mechanism name."
3569            )
3570
3571        # Note: This doesn't normally happen because the mechanism name
3572        # makes it seem like the integer decision ID is really a zone
3573        # name.
3574        if isinstance(dSpec, int):
3575            sep = tokens[dEnd + 1]
3576            after = tokens[dEnd + 2]
3577
3578            if sep == Lexeme.zoneSeparator and not isinstance(after, Lexeme):
3579                return (
3580                    base.MechanismSpecifier(
3581                        domain=None,
3582                        zone=None,
3583                        decision=dSpec,
3584                        name=after
3585                    ),
3586                    dEnd + 2
3587                )
3588            else:
3589                raise ParseError(
3590                    f"Invalid mechanism specifier: got a decision ID"
3591                    f" NOT followed by a zone separator and mechanism"
3592                    f" name. Got: {tokens[start:]}"
3593                )
3594
3595        mDomain = dSpec.domain
3596        if dEnd == tEnd or dEnd == tEnd - 1:
3597            if dSpec.zone is not None:
3598                try:
3599                    # Case for integer "zone" -> integer decision ID
3600                    zID = int(dSpec.zone)
3601                    return (
3602                        base.MechanismSpecifier(
3603                            domain=None,
3604                            zone=None,
3605                            decision=zID,
3606                            name=dSpec.name
3607                        ),
3608                        dEnd
3609                    )
3610                except ValueError:
3611                    pass
3612            return (
3613                base.MechanismSpecifier(
3614                    domain=mDomain,
3615                    zone=None,
3616                    decision=dSpec.zone,
3617                    name=dSpec.name
3618                ),
3619                dEnd
3620            )
3621
3622        sep = tokens[dEnd + 1]
3623        after = tokens[dEnd + 2]
3624
3625        mDec: Optional[Union[base.DecisionName, base.DecisionID]]
3626        if sep == Lexeme.zoneSeparator:
3627            if isinstance(after, Lexeme):
3628                mZone = None
3629                mDec = dSpec.zone
3630                mName = dSpec.name
3631                mEnd = dEnd
3632            else:
3633                mZone = dSpec.zone
3634                mDec = dSpec.name
3635                mName = after
3636                mEnd = dEnd + 2
3637        else:
3638            mZone = None
3639            mDec = dSpec.zone
3640            mName = dSpec.name
3641            mEnd = dEnd
3642
3643        # Treat numerical decision "names" as decision IDs
3644        if mDec is not None:
3645            try:
3646                mDec = int(mDec)
3647                if mDomain is not None or mZone is not None:
3648                    raise ParseError(
3649                        f"Invalid mechanism specifier: got a numerical"
3650                        f" decision ID but also a domain and/or zone."
3651                        f" Got: {tokens[start:]}"
3652                    )
3653            except ValueError:
3654                pass
3655
3656        return (
3657            base.MechanismSpecifier(
3658                domain=mDomain,
3659                zone=mZone,
3660                decision=mDec,
3661                name=mName
3662            ),
3663            mEnd
3664        )
3665
3666    def groupReqTokens(
3667        self,
3668        tokens: LexedTokens,
3669        start: int = 0,
3670        end: int = -1
3671    ) -> GroupedTokens:
3672        """
3673        Groups tokens for a requirement, stripping out all parentheses
3674        but replacing parenthesized expressions with sub-lists of tokens.
3675
3676        For example:
3677
3678        >>> pf = ParseFormat()
3679        >>> pf.groupReqTokens(['jump'])
3680        ['jump']
3681        >>> pf.groupReqTokens([Lexeme.openParen, 'jump'])
3682        Traceback (most recent call last):
3683        ...
3684        exploration.parsing.ParseError...
3685        >>> pf.groupReqTokens([Lexeme.closeParen, 'jump'])
3686        Traceback (most recent call last):
3687        ...
3688        exploration.parsing.ParseError...
3689        >>> pf.groupReqTokens(['jump', Lexeme.closeParen])
3690        Traceback (most recent call last):
3691        ...
3692        exploration.parsing.ParseError...
3693        >>> pf.groupReqTokens([Lexeme.openParen, 'jump', Lexeme.closeParen])
3694        [['jump']]
3695        >>> pf.groupReqTokens(
3696        ...     [
3697        ...         Lexeme.openParen,
3698        ...         'jump',
3699        ...         Lexeme.orBar,
3700        ...         'climb',
3701        ...         Lexeme.closeParen,
3702        ...         Lexeme.ampersand,
3703        ...         'crawl',
3704        ...     ]
3705        ... )
3706        [['jump', <Lexeme.orBar: ...>, 'climb'], <Lexeme.ampersand: ...>,\
3707 'crawl']
3708        """
3709        start, end, nTokens = normalizeEnds(tokens, start, end)
3710        if nTokens == 0:
3711            raise ParseError("Ran out of tokens.")
3712
3713        resultsStack: List[GroupedTokens] = [[]]
3714        here = start
3715        while here <= end:
3716            token = tokens[here]
3717            here += 1
3718            if token == Lexeme.closeParen:
3719                if len(resultsStack) == 1:
3720                    raise ParseError(
3721                        f"Too many closing parens at index {here - 1}"
3722                        f" in:\n{tokens[start:end + 1]}"
3723                    )
3724                else:
3725                    closed = resultsStack.pop()
3726                    resultsStack[-1].append(closed)
3727            elif token == Lexeme.openParen:
3728                resultsStack.append([])
3729            else:
3730                resultsStack[-1].append(token)
3731        if len(resultsStack) != 1:
3732            raise ParseError(
3733                f"Mismatched parentheses in tokens:"
3734                f"\n{tokens[start:end + 1]}"
3735            )
3736        return resultsStack[0]
3737
3738    def groupReqTokensByPrecedence(
3739        self,
3740        tokenGroups: GroupedTokens
3741    ) -> GroupedRequirementParts:
3742        """
3743        Re-groups requirement tokens that have been grouped using
3744        `groupReqTokens` according to operator precedence, effectively
3745        creating an equivalent result which would have been obtained by
3746        `groupReqTokens` if all possible non-redundant explicit
3747        parentheses had been included.
3748
3749        Also turns each leaf part into a `Requirement`.
3750
3751        TODO: Make this actually reasonably efficient T_T
3752
3753        Examples:
3754
3755        >>> pf = ParseFormat()
3756        >>> r = pf.parseRequirement('capability&roomB::switch:on')
3757        >>> pf.groupReqTokensByPrecedence(
3758        ...     [
3759        ...         ['jump', Lexeme.orBar, 'climb'],
3760        ...         Lexeme.ampersand,
3761        ...         Lexeme.notMarker,
3762        ...         'coin',
3763        ...         Lexeme.tokenCount,
3764        ...         '3'
3765        ...     ]
3766        ... )
3767        [\
3768[\
3769[[ReqCapability('jump'), <Lexeme.orBar: ...>, ReqCapability('climb')]],\
3770 <Lexeme.ampersand: ...>,\
3771 [<Lexeme.notMarker: ...>, ReqTokens('coin', 3)]\
3772]\
3773]
3774        """
3775        subgrouped: List[Union[Lexeme, str, GroupedRequirementParts]] = []
3776        # First recursively group all parenthesized expressions
3777        for i, item in enumerate(tokenGroups):
3778            if isinstance(item, list):
3779                subgrouped.append(self.groupReqTokensByPrecedence(item))
3780            else:
3781                subgrouped.append(item)
3782
3783        # Now process all leaf requirements
3784        leavesConverted: GroupedRequirementParts = []
3785        i = 0
3786        while i < len(subgrouped):
3787            gItem = subgrouped[i]
3788
3789            if isinstance(gItem, list):
3790                leavesConverted.append(gItem)
3791            elif isinstance(gItem, Lexeme):
3792                leavesConverted.append(gItem)
3793            elif i == len(subgrouped) - 1:
3794                if isinstance(gItem, Lexeme):
3795                    raise ParseError(
3796                        f"Lexeme at end of requirement. Grouped tokens:"
3797                        f"\n{tokenGroups}"
3798                    )
3799                else:
3800                    assert isinstance(gItem, str)
3801                    if gItem == 'X':
3802                        leavesConverted.append(base.ReqImpossible())
3803                    elif gItem == 'O':
3804                        leavesConverted.append(base.ReqNothing())
3805                    else:
3806                        leavesConverted.append(base.ReqCapability(gItem))
3807            else:
3808                assert isinstance(gItem, str)
3809                try:
3810                    # TODO: Avoid list copy here...
3811                    couldBeMechanismSpecifier: LexedTokens = []
3812                    for ii in range(i, len(subgrouped)):
3813                        lexemeOrStr = subgrouped[ii]
3814                        if isinstance(lexemeOrStr, (Lexeme, str)):
3815                            couldBeMechanismSpecifier.append(lexemeOrStr)
3816                        else:
3817                            break
3818                    mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
3819                        couldBeMechanismSpecifier
3820                    )
3821                    mEnd += i
3822                    if (
3823                        mEnd >= len(subgrouped) - 2
3824                     or subgrouped[mEnd + 1] != Lexeme.mechanismSeparator
3825                    ):
3826                        raise ParseError("Not a mechanism requirement.")
3827
3828                    mState = subgrouped[mEnd + 2]
3829                    if not isinstance(mState, base.MechanismState):
3830                        raise ParseError("Not a mechanism requirement.")
3831                    leavesConverted.append(base.ReqMechanism(mSpec, mState))
3832                    i = mEnd + 2  # + 1 will happen automatically below
3833                except ParseError:
3834                    following = subgrouped[i + 1]
3835                    if following in (
3836                        Lexeme.tokenCount,
3837                        Lexeme.mechanismSeparator,
3838                        Lexeme.wigglyLine,
3839                        Lexeme.skillLevel
3840                    ):
3841                        if (
3842                            i == len(subgrouped) - 2
3843                         or isinstance(subgrouped[i + 2], Lexeme)
3844                        ):
3845                            if following == Lexeme.wigglyLine:
3846                                # Default tag value is 1
3847                                leavesConverted.append(base.ReqTag(gItem, 1))
3848                                i += 1  # another +1 automatic below
3849                            else:
3850                                raise ParseError(
3851                                    f"Lexeme at end of requirement. Grouped"
3852                                    f" tokens:\n{tokenGroups}"
3853                                )
3854                        else:
3855                            afterwards = subgrouped[i + 2]
3856                            if not isinstance(afterwards, str):
3857                                raise ParseError(
3858                                    f"Lexeme after token/mechanism/tag/skill"
3859                                    f" separator at index {i}."
3860                                    f" Grouped tokens:\n{tokenGroups}"
3861                                )
3862                            i += 2  # another +1 automatic below
3863                            if following == Lexeme.tokenCount:
3864                                try:
3865                                    tCount = int(afterwards)
3866                                except ValueError:
3867                                    raise ParseError(
3868                                        f"Token count could not be"
3869                                        f" parsed as an integer:"
3870                                        f" {afterwards!r}. Grouped"
3871                                        f" tokens:\n{tokenGroups}"
3872                                    )
3873                                leavesConverted.append(
3874                                    base.ReqTokens(gItem, tCount)
3875                                )
3876                            elif following == Lexeme.mechanismSeparator:
3877                                leavesConverted.append(
3878                                    base.ReqMechanism(gItem, afterwards)
3879                                )
3880                            elif following == Lexeme.wigglyLine:
3881                                tVal = self.parseTagValue(afterwards)
3882                                leavesConverted.append(
3883                                    base.ReqTag(gItem, tVal)
3884                                )
3885                            else:
3886                                assert following == Lexeme.skillLevel
3887                                try:
3888                                    sLevel = int(afterwards)
3889                                except ValueError:
3890                                    raise ParseError(
3891                                        f"Skill level could not be"
3892                                        f" parsed as an integer:"
3893                                        f" {afterwards!r}. Grouped"
3894                                        f" tokens:\n{tokenGroups}"
3895                                    )
3896                                leavesConverted.append(
3897                                    base.ReqLevel(gItem, sLevel)
3898                                )
3899                    else:
3900                        if gItem == 'X':
3901                            leavesConverted.append(base.ReqImpossible())
3902                        elif gItem == 'O':
3903                            leavesConverted.append(base.ReqNothing())
3904                        else:
3905                            leavesConverted.append(
3906                                base.ReqCapability(gItem)
3907                            )
3908
3909            # Finally, increment our index:
3910            i += 1
3911
3912        # Now group all NOT operators
3913        i = 0
3914        notsGrouped: GroupedRequirementParts = []
3915        while i < len(leavesConverted):
3916            leafItem = leavesConverted[i]
3917            group = []
3918            while leafItem == Lexeme.notMarker:
3919                group.append(leafItem)
3920                i += 1
3921                if i >= len(leavesConverted):
3922                    raise ParseError(
3923                        f"NOT at end of tokens:\n{leavesConverted}"
3924                    )
3925                leafItem = leavesConverted[i]
3926            if group == []:
3927                notsGrouped.append(leafItem)
3928                i += 1
3929            else:
3930                group.append(leafItem)
3931                i += 1
3932                notsGrouped.append(group)
3933
3934        # Next group all AND operators
3935        i = 0
3936        andsGrouped: GroupedRequirementParts = []
3937        while i < len(notsGrouped):
3938            notGroupItem = notsGrouped[i]
3939            if notGroupItem == Lexeme.ampersand:
3940                if i == len(notsGrouped) - 1:
3941                    raise ParseError(
3942                        f"AND at end of group in tokens:"
3943                        f"\n{tokenGroups}"
3944                        f"Which had been grouped into:"
3945                        f"\n{notsGrouped}"
3946                    )
3947                itemAfter = notsGrouped[i + 1]
3948                if isinstance(itemAfter, Lexeme):
3949                    raise ParseError(
3950                        f"Lexeme after AND in of group in tokens:"
3951                        f"\n{tokenGroups}"
3952                        f"Which had been grouped into:"
3953                        f"\n{notsGrouped}"
3954                    )
3955                assert isinstance(itemAfter, (base.Requirement, list))
3956                prev = andsGrouped[-1]
3957                if (
3958                    isinstance(prev, list)
3959                and len(prev) > 2
3960                and prev[1] == Lexeme.ampersand
3961                ):
3962                    prev.extend(notsGrouped[i:i + 2])
3963                    i += 1  # with an extra +1 below
3964                else:
3965                    andsGrouped.append(
3966                        [andsGrouped.pop()] + notsGrouped[i:i + 2]
3967                    )
3968                    i += 1 # extra +1 below
3969            else:
3970                andsGrouped.append(notGroupItem)
3971            i += 1
3972
3973        # Finally check that we only have OR operators left over
3974        i = 0
3975        finalResult: GroupedRequirementParts = []
3976        while i < len(andsGrouped):
3977            andGroupItem = andsGrouped[i]
3978            if andGroupItem == Lexeme.orBar:
3979                if i == len(andsGrouped) - 1:
3980                    raise ParseError(
3981                        f"OR at end of group in tokens:"
3982                        f"\n{tokenGroups}"
3983                        f"Which had been grouped into:"
3984                        f"\n{andsGrouped}"
3985                    )
3986                itemAfter = andsGrouped[i + 1]
3987                if isinstance(itemAfter, Lexeme):
3988                    raise ParseError(
3989                        f"Lexeme after OR in of group in tokens:"
3990                        f"\n{tokenGroups}"
3991                        f"Which had been grouped into:"
3992                        f"\n{andsGrouped}"
3993                    )
3994                assert isinstance(itemAfter, (base.Requirement, list))
3995                prev = finalResult[-1]
3996                if (
3997                    isinstance(prev, list)
3998                and len(prev) > 2
3999                and prev[1] == Lexeme.orBar
4000                ):
4001                    prev.extend(andsGrouped[i:i + 2])
4002                    i += 1  # with an extra +1 below
4003                else:
4004                    finalResult.append(
4005                        [finalResult.pop()] + andsGrouped[i:i + 2]
4006                    )
4007                    i += 1 # extra +1 below
4008            elif isinstance(andGroupItem, Lexeme):
4009                raise ParseError(
4010                    f"Leftover lexeme when grouping ORs at index {i}"
4011                    f" in grouped tokens:\n{andsGrouped}"
4012                    f"\nOriginal tokens were:\n{tokenGroups}"
4013                )
4014            else:
4015                finalResult.append(andGroupItem)
4016            i += 1
4017
4018        return finalResult
4019
4020    def parseRequirementFromRegroupedTokens(
4021        self,
4022        reqGroups: GroupedRequirementParts
4023    ) -> base.Requirement:
4024        """
4025        Recursive parser that works once tokens have been turned into
4026        requirements at the leaves and grouped by operator precedence
4027        otherwise (see `groupReqTokensByPrecedence`).
4028
4029        TODO: Simplify by just doing this while grouping... ?
4030        """
4031        if len(reqGroups) == 0:
4032            raise ParseError("Ran out of tokens.")
4033
4034        elif len(reqGroups) == 1:
4035            only = reqGroups[0]
4036            if isinstance(only, list):
4037                return self.parseRequirementFromRegroupedTokens(only)
4038            elif isinstance(only, base.Requirement):
4039                return only
4040            else:
4041                raise ParseError(f"Invalid singleton group:\n{only}")
4042        elif reqGroups[0] == Lexeme.notMarker:
4043            if (
4044                not all(x == Lexeme.notMarker for x in reqGroups[:-1])
4045             or not isinstance(reqGroups[-1], (list, base.Requirement))
4046            ):
4047                raise ParseError(f"Invalid negation group:\n{reqGroups}")
4048            result = reqGroups[-1]
4049            if isinstance(result, list):
4050                result = self.parseRequirementFromRegroupedTokens(result)
4051            assert isinstance(result, base.Requirement)
4052            for i in range(len(reqGroups) - 1):
4053                result = base.ReqNot(result)
4054            return result
4055        elif len(reqGroups) % 2 == 0:
4056            raise ParseError(f"Even-length non-negation group:\n{reqGroups}")
4057        else:
4058            if (
4059                reqGroups[1] not in (Lexeme.ampersand, Lexeme.orBar)
4060             or not all(
4061                    reqGroups[i] == reqGroups[1]
4062                    for i in range(1, len(reqGroups), 2)
4063                )
4064            ):
4065                raise ParseError(
4066                    f"Inconsistent operator(s) in group:\n{reqGroups}"
4067                )
4068            op = reqGroups[1]
4069            operands = [
4070                (
4071                    self.parseRequirementFromRegroupedTokens(x)
4072                    if isinstance(x, list)
4073                    else x
4074                )
4075                for x in reqGroups[::2]
4076            ]
4077            if not all(isinstance(x, base.Requirement) for x in operands):
4078                raise ParseError(
4079                    f"Item not reducible to Requirement in AND group:"
4080                    f"\n{reqGroups}"
4081                )
4082            reqSequence = cast(Sequence[base.Requirement], operands)
4083            if op == Lexeme.ampersand:
4084                return base.ReqAll(reqSequence).flatten()
4085            else:
4086                assert op == Lexeme.orBar
4087                return base.ReqAny(reqSequence).flatten()
4088
4089    def parseRequirementFromGroupedTokens(
4090        self,
4091        tokenGroups: GroupedTokens
4092    ) -> base.Requirement:
4093        """
4094        Parses a `base.Requirement` from a pre-grouped tokens list (see
4095        `groupReqTokens`). Uses the 'orBar', 'ampersand', 'notMarker',
4096        'tokenCount', and 'mechanismSeparator' `Lexeme`s to provide
4097        'or', 'and', and 'not' operators along with distinguishing
4098        between capabilities, tokens, and mechanisms.
4099
4100        Precedence ordering is not, then and, then or, but you are
4101        encouraged to use parentheses for explicit grouping (the
4102        'openParen' and 'closeParen' `Lexeme`s, although these must be
4103        handled by `groupReqTokens` so this function won't see them
4104        directly).
4105
4106        You can also use 'X' (without quotes) for a never-satisfied
4107        requirement, and 'O' (without quotes) for an always-satisfied
4108        requirement.
4109
4110        Note that when '!' is applied to a token requirement it flips
4111        the sense of the integer from 'must have at least this many' to
4112        'must have strictly less than this many'.
4113
4114        Raises a `ParseError` if the grouped tokens it is given cannot
4115        be parsed as a `Requirement`.
4116
4117        Examples:
4118
4119        >>> pf = ParseFormat()
4120        >>> pf.parseRequirementFromGroupedTokens(['capability'])
4121        ReqCapability('capability')
4122        >>> pf.parseRequirementFromGroupedTokens(
4123        ...     ['token', Lexeme.tokenCount, '3']
4124        ... )
4125        ReqTokens('token', 3)
4126        >>> pf.parseRequirementFromGroupedTokens(
4127        ...     ['mechanism', Lexeme.mechanismSeparator, 'state']
4128        ... )
4129        ReqMechanism('mechanism', 'state')
4130        >>> pf.parseRequirementFromGroupedTokens(
4131        ...     ['capability', Lexeme.orBar, 'token',
4132        ...      Lexeme.tokenCount, '3']
4133        ... )
4134        ReqAny([ReqCapability('capability'), ReqTokens('token', 3)])
4135        >>> pf.parseRequirementFromGroupedTokens(
4136        ...     ['one', Lexeme.ampersand, 'two', Lexeme.orBar, 'three']
4137        ... )
4138        ReqAny([ReqAll([ReqCapability('one'), ReqCapability('two')]),\
4139 ReqCapability('three')])
4140        >>> pf.parseRequirementFromGroupedTokens(
4141        ...     [
4142        ...         'one',
4143        ...         Lexeme.ampersand,
4144        ...         [
4145        ...              'two',
4146        ...              Lexeme.orBar,
4147        ...              'three'
4148        ...         ]
4149        ...     ]
4150        ... )
4151        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4152 ReqCapability('three')])])
4153        >>> pf.parseRequirementFromTokens(['X'])
4154        ReqImpossible()
4155        >>> pf.parseRequirementFromTokens(['O'])
4156        ReqNothing()
4157        >>> pf.parseRequirementFromTokens(
4158        ...     [Lexeme.openParen, 'O', Lexeme.closeParen]
4159        ... )
4160        ReqNothing()
4161        """
4162        if len(tokenGroups) == 0:
4163            raise ParseError("Ran out of tokens.")
4164
4165        reGrouped = self.groupReqTokensByPrecedence(tokenGroups)
4166
4167        return self.parseRequirementFromRegroupedTokens(reGrouped)
4168
4169    def parseRequirementFromTokens(
4170        self,
4171        tokens: LexedTokens,
4172        start: int = 0,
4173        end: int = -1
4174    ) -> base.Requirement:
4175        """
4176        Parses a requirement from `LexedTokens` by grouping them first
4177        and then using `parseRequirementFromGroupedTokens`.
4178
4179        For example:
4180
4181        >>> pf = ParseFormat()
4182        >>> pf.parseRequirementFromTokens(
4183        ...     [
4184        ...         'one',
4185        ...         Lexeme.ampersand,
4186        ...         Lexeme.openParen,
4187        ...         'two',
4188        ...         Lexeme.orBar,
4189        ...         'three',
4190        ...         Lexeme.closeParen
4191        ...     ]
4192        ... )
4193        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4194 ReqCapability('three')])])
4195        """
4196        grouped = self.groupReqTokens(tokens, start, end)
4197        return self.parseRequirementFromGroupedTokens(grouped)
4198
4199    def parseRequirement(self, encoded: str) -> base.Requirement:
4200        """
4201        Parses a `base.Requirement` from a string by calling `lex` and
4202        then feeding it into `ParseFormat.parseRequirementFromTokens`.
4203        As stated in `parseRequirementFromTokens`, the precedence
4204        binding order is NOT, then AND, then OR.
4205
4206        For example:
4207
4208        >>> pf = ParseFormat()
4209        >>> pf.parseRequirement('! coin * 3')
4210        ReqNot(ReqTokens('coin', 3))
4211        >>> pf.parseRequirement(
4212        ...     '  oneWord | "two words"|"three  words words" '
4213        ... )
4214        ReqAny([ReqCapability('oneWord'), ReqCapability('"two words"'),\
4215 ReqCapability('"three  words words"')])
4216        >>> pf.parseRequirement('words-with-dashes')
4217        ReqCapability('words-with-dashes')
4218        >>> r = pf.parseRequirement('capability&roomB::switch:on')
4219        >>> r
4220        ReqAll([ReqCapability('capability'),\
4221 ReqMechanism(MechanismSpecifier(domain=None, zone=None, decision='roomB',\
4222 name='switch'), 'on')])
4223        >>> r.unparse()
4224        '(capability&roomB::switch:on)'
4225        >>> pf.parseRequirement('!!!one')
4226        ReqNot(ReqNot(ReqNot(ReqCapability('one'))))
4227        >>> pf.parseRequirement('domain//zone::where::mechanism:state')
4228        ReqMechanism(MechanismSpecifier(domain='domain', zone='zone',\
4229 decision='where', name='mechanism'), 'state')
4230        >>> pf.parseRequirement('domain//mechanism:state')
4231        ReqMechanism(MechanismSpecifier(domain='domain', zone=None,\
4232 decision=None, name='mechanism'), 'state')
4233        >>> pf.parseRequirement('where::mechanism:state')
4234        ReqMechanism(MechanismSpecifier(domain=None, zone=None,\
4235 decision='where', name='mechanism'), 'state')
4236        >>> pf.parseRequirement('zone::where::mechanism:state')
4237        ReqMechanism(MechanismSpecifier(domain=None, zone='zone',\
4238 decision='where', name='mechanism'), 'state')
4239        >>> pf.parseRequirement('tag~')
4240        ReqTag('tag', 1)
4241        >>> pf.parseRequirement('tag~&tag2~')
4242        ReqAll([ReqTag('tag', 1), ReqTag('tag2', 1)])
4243        >>> pf.parseRequirement('tag~value|tag~3|tag~3.5|skill^3')
4244        ReqAny([ReqTag('tag', 'value'), ReqTag('tag', 3),\
4245 ReqTag('tag', 3.5), ReqLevel('skill', 3)])
4246        >>> pf.parseRequirement('tag~True|tag~False|tag~None')
4247        ReqAny([ReqTag('tag', True), ReqTag('tag', False), ReqTag('tag', None)])
4248
4249        Precedence examples:
4250
4251        >>> pf.parseRequirement('A|B&C')
4252        ReqAny([ReqCapability('A'), ReqAll([ReqCapability('B'),\
4253 ReqCapability('C')])])
4254        >>> pf.parseRequirement('A&B|C')
4255        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4256 ReqCapability('C')])
4257        >>> pf.parseRequirement('(A&B)|C')
4258        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4259 ReqCapability('C')])
4260        >>> pf.parseRequirement('(A&B|C)&D')
4261        ReqAll([ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4262 ReqCapability('C')]), ReqCapability('D')])
4263
4264        Error examples:
4265
4266        >>> pf.parseRequirement('one ! Word')
4267        Traceback (most recent call last):
4268        ...
4269        exploration.parsing.ParseError...
4270        >>> pf.parseRequirement('a|')
4271        Traceback (most recent call last):
4272        ...
4273        exploration.parsing.ParseError...
4274        >>> pf.parseRequirement('b!')
4275        Traceback (most recent call last):
4276        ...
4277        exploration.parsing.ParseError...
4278        >>> pf.parseRequirement('*emph*')
4279        Traceback (most recent call last):
4280        ...
4281        exploration.parsing.ParseError...
4282        >>> pf.parseRequirement('one&&two')
4283        Traceback (most recent call last):
4284        ...
4285        exploration.parsing.ParseError...
4286        >>> pf.parseRequirement('one!|two')
4287        Traceback (most recent call last):
4288        ...
4289        exploration.parsing.ParseError...
4290        >>> pf.parseRequirement('one*two')
4291        Traceback (most recent call last):
4292        ...
4293        exploration.parsing.ParseError...
4294        >>> pf.parseRequirement('one*')
4295        Traceback (most recent call last):
4296        ...
4297        exploration.parsing.ParseError...
4298        >>> pf.parseRequirement('()')
4299        Traceback (most recent call last):
4300        ...
4301        exploration.parsing.ParseError...
4302        >>> pf.parseRequirement('(one)*3')
4303        Traceback (most recent call last):
4304        ...
4305        exploration.parsing.ParseError...
4306        >>> pf.parseRequirement('a:')
4307        Traceback (most recent call last):
4308        ...
4309        exploration.parsing.ParseError...
4310        >>> pf.parseRequirement('a:b:c')
4311        Traceback (most recent call last):
4312        ...
4313        exploration.parsing.ParseError...
4314        >>> pf.parseRequirement('where::capability')
4315        Traceback (most recent call last):
4316        ...
4317        exploration.parsing.ParseError...
4318        """
4319        return self.parseRequirementFromTokens(
4320            lex(encoded, self.reverseFormat)
4321        )
4322
4323    def parseSkillCombinationFromTokens(
4324        self,
4325        tokens: LexedTokens,
4326        start: int = 0,
4327        end: int = -1
4328    ) -> Union[base.Skill, base.SkillCombination]:
4329        """
4330        Parses a skill combination from the specified range within the
4331        given tokens list. If just a single string token is selected, it
4332        will be returned as a `base.BestSkill` with just that skill
4333        inside.
4334
4335        For example:
4336
4337        >>> pf = ParseFormat()
4338        >>> pf.parseSkillCombinationFromTokens(['climbing'])
4339        BestSkill('climbing')
4340        >>> tokens = [
4341        ...     'best',
4342        ...     Lexeme.openParen,
4343        ...     'brains',
4344        ...     Lexeme.sepOrDelay,
4345        ...     'brawn',
4346        ...     Lexeme.closeParen,
4347        ... ]
4348        >>> pf.parseSkillCombinationFromTokens(tokens)
4349        BestSkill('brains', 'brawn')
4350        >>> tokens[2] = '3'  # not a lexeme so it's a string
4351        >>> pf.parseSkillCombinationFromTokens(tokens)
4352        BestSkill(3, 'brawn')
4353        >>> tokens = [
4354        ...     Lexeme.wigglyLine,
4355        ...     Lexeme.wigglyLine,
4356        ...     'yes',
4357        ... ]
4358        >>> pf.parseSkillCombinationFromTokens(tokens)
4359        InverseSkill(InverseSkill('yes'))
4360        """
4361        start, end, nTokens = normalizeEnds(tokens, start, end)
4362
4363        first = tokens[start]
4364        if nTokens == 1:
4365            if isinstance(first, base.Skill):
4366                try:
4367                    level = int(first)
4368                    return base.BestSkill(level)
4369                except ValueError:
4370                    return base.BestSkill(first)
4371            else:
4372                raise ParseError(
4373                    "Invalid SkillCombination:\n{tokens[start:end + 1]"
4374                )
4375
4376        if first == Lexeme.wigglyLine:
4377            inv = self.parseSkillCombinationFromTokens(
4378                tokens,
4379                start + 1,
4380                end
4381            )
4382            if isinstance(inv, base.BestSkill) and len(inv.skills) == 1:
4383                return base.InverseSkill(inv.skills[0])
4384            else:
4385                return base.InverseSkill(inv)
4386
4387        second = tokens[start + 1]
4388        if second != Lexeme.openParen:
4389            raise ParseError(
4390                f"Invalid SkillCombination (missing paren):"
4391                f"\n{tokens[start:end + 1]}"
4392            )
4393
4394        parenEnd = self.matchingBrace(
4395            tokens,
4396            start + 1,
4397            Lexeme.openParen,
4398            Lexeme.closeParen
4399        )
4400        if parenEnd != end:
4401            raise ParseError(
4402                f"Extra junk after SkillCombination:"
4403                f"\n{tokens[parenEnd + 1:end + 1]}"
4404            )
4405
4406        if first == 'if':
4407            parts = list(
4408                findSeparatedParts(
4409                    tokens,
4410                    Lexeme.sepOrDelay,
4411                    start + 2,
4412                    end - 1,
4413                    Lexeme.openParen,
4414                    Lexeme.closeParen
4415                )
4416            )
4417            if len(parts) != 3:
4418                raise ParseError(
4419                    f"Wrong number of parts for ConditionalSkill (needs"
4420                    f" 3, got {len(parts)}:"
4421                    f"\n{tokens[start + 2:end]}"
4422                )
4423            reqStart, reqEnd = parts[0]
4424            ifStart, ifEnd = parts[1]
4425            elseStart, elseEnd = parts[2]
4426            return base.ConditionalSkill(
4427                self.parseRequirementFromTokens(tokens, reqStart, reqEnd),
4428                self.parseSkillCombinationFromTokens(tokens, ifStart, ifEnd),
4429                self.parseSkillCombinationFromTokens(
4430                    tokens,
4431                    elseStart,
4432                    elseEnd
4433                ),
4434            )
4435        elif first in ('sum', 'best', 'worst'):
4436            make: type[base.SkillCombination]
4437            if first == 'sum':
4438                make = base.CombinedSkill
4439            elif first == 'best':
4440                make = base.BestSkill
4441            else:
4442                make = base.WorstSkill
4443
4444            subs = []
4445            for partStart, partEnd in findSeparatedParts(
4446                tokens,
4447                Lexeme.sepOrDelay,
4448                start + 2,
4449                end - 1,
4450                Lexeme.openParen,
4451                Lexeme.closeParen
4452            ):
4453                sub = self.parseSkillCombinationFromTokens(
4454                    tokens,
4455                    partStart,
4456                    partEnd
4457                )
4458                if (
4459                    isinstance(sub, base.BestSkill)
4460                and len(sub.skills) == 1
4461                ):
4462                    subs.append(sub.skills[0])
4463                else:
4464                    subs.append(sub)
4465
4466            return make(*subs)
4467        else:
4468            raise ParseError(
4469                "Invalid SkillCombination:\n{tokens[start:end + 1]"
4470            )
4471
4472    def parseSkillCombination(
4473        self,
4474        encoded: str
4475    ) -> base.SkillCombination:
4476        """
4477        Parses a `SkillCombination` from a string. Calls `lex` and then
4478        `parseSkillCombinationFromTokens`.
4479        """
4480        result = self.parseSkillCombinationFromTokens(
4481            lex(encoded, self.reverseFormat)
4482        )
4483        if not isinstance(result, base.SkillCombination):
4484            return base.BestSkill(result)
4485        else:
4486            return result
4487
4488    def parseConditionFromTokens(
4489        self,
4490        tokens: LexedTokens,
4491        start: int = 0,
4492        end: int = -1
4493    ) -> base.Condition:
4494        """
4495        Parses a `base.Condition` from a lexed tokens list. For example:
4496
4497        >>> pf = ParseFormat()
4498        >>> tokens = [
4499        ...     Lexeme.doubleQuestionmark,
4500        ...     Lexeme.openParen,
4501        ...     "fire",
4502        ...     Lexeme.ampersand,
4503        ...     "water",
4504        ...     Lexeme.closeParen,
4505        ...     Lexeme.openCurly,
4506        ...     "gain",
4507        ...     "wind",
4508        ...     Lexeme.closeCurly,
4509        ...     Lexeme.openCurly,
4510        ...     Lexeme.closeCurly,
4511        ... ]
4512        >>> pf.parseConditionFromTokens(tokens) == base.condition(
4513        ...     condition=base.ReqAll([
4514        ...         base.ReqCapability('fire'),
4515        ...         base.ReqCapability('water')
4516        ...     ]),
4517        ...     consequence=[base.effect(gain='wind')]
4518        ... )
4519        True
4520        """
4521        start, end, nTokens = normalizeEnds(tokens, start, end)
4522        if nTokens < 8:
4523            raise ParseError(
4524                f"A Condition requires at least 8 tokens (got {nTokens})."
4525            )
4526        if tokens[start] != Lexeme.doubleQuestionmark:
4527            raise ParseError(
4528                f"A Condition must start with"
4529                f" {repr(self.formatDict[Lexeme.doubleQuestionmark])}"
4530            )
4531        try:
4532            consequenceStart = tokens.index(Lexeme.openCurly, start)
4533        except ValueError:
4534            raise ParseError("A condition must include a consequence block.")
4535        consequenceEnd = self.matchingBrace(tokens, consequenceStart)
4536        altStart = consequenceEnd + 1
4537        altEnd = self.matchingBrace(tokens, altStart)
4538
4539        if altEnd != end:
4540            raise ParseError(
4541                f"Junk after condition:\n{tokens[altEnd + 1: end + 1]}"
4542            )
4543
4544        return base.condition(
4545            condition=self.parseRequirementFromTokens(
4546                tokens,
4547                start + 1,
4548                consequenceStart - 1
4549            ),
4550            consequence=self.parseConsequenceFromTokens(
4551                tokens,
4552                consequenceStart,
4553                consequenceEnd
4554            ),
4555            alternative=self.parseConsequenceFromTokens(
4556                tokens,
4557                altStart,
4558                altEnd
4559            )
4560        )
4561
4562    def parseCondition(
4563        self,
4564        encoded: str
4565    ) -> base.Condition:
4566        """
4567        Lexes the given string and then calls `parseConditionFromTokens`
4568        to return a `base.Condition`.
4569        """
4570        return self.parseConditionFromTokens(
4571            lex(encoded, self.reverseFormat)
4572        )
4573
4574    def parseChallengeFromTokens(
4575        self,
4576        tokens: LexedTokens,
4577        start: int = 0,
4578        end: int = -1
4579    ) -> base.Challenge:
4580        """
4581        Parses a `base.Challenge` from a lexed tokens list.
4582
4583        For example:
4584
4585        >>> pf = ParseFormat()
4586        >>> tokens = [
4587        ...     Lexeme.angleLeft,
4588        ...     '2',
4589        ...     Lexeme.angleRight,
4590        ...     'best',
4591        ...     Lexeme.openParen,
4592        ...     "chess",
4593        ...     Lexeme.sepOrDelay,
4594        ...     "checkers",
4595        ...     Lexeme.closeParen,
4596        ...     Lexeme.openCurly,
4597        ...     "gain",
4598        ...     "coin",
4599        ...     Lexeme.tokenCount,
4600        ...     "5",
4601        ...     Lexeme.closeCurly,
4602        ...     Lexeme.angleRight,
4603        ...     Lexeme.openCurly,
4604        ...     "lose",
4605        ...     "coin",
4606        ...     Lexeme.tokenCount,
4607        ...     "5",
4608        ...     Lexeme.closeCurly,
4609        ... ]
4610        >>> c = pf.parseChallengeFromTokens(tokens)
4611        >>> c['skills'] == base.BestSkill('chess', 'checkers')
4612        True
4613        >>> c['level']
4614        2
4615        >>> c['success'] == [base.effect(gain=('coin', 5))]
4616        True
4617        >>> c['failure'] == [base.effect(lose=('coin', 5))]
4618        True
4619        >>> c['outcome']
4620        False
4621        >>> c == base.challenge(
4622        ...     skills=base.BestSkill('chess', 'checkers'),
4623        ...     level=2,
4624        ...     success=[base.effect(gain=('coin', 5))],
4625        ...     failure=[base.effect(lose=('coin', 5))],
4626        ...     outcome=False
4627        ... )
4628        True
4629        >>> t2 = ['hi'] + tokens + ['bye']  # parsing only part of the list
4630        >>> c == pf.parseChallengeFromTokens(t2, 1, -2)
4631        True
4632        """
4633        start, end, nTokens = normalizeEnds(tokens, start, end)
4634        if nTokens < 8:
4635            raise ParseError(
4636                f"Not enough tokens for a challenge: {nTokens}"
4637            )
4638        if tokens[start] != Lexeme.angleLeft:
4639            raise ParseError(
4640                f"Challenge must start with"
4641                f" {repr(self.formatDict[Lexeme.angleLeft])}"
4642            )
4643        levelStr = tokens[start + 1]
4644        if isinstance(levelStr, Lexeme):
4645            raise ParseError(
4646                f"Challenge must start with a level in angle brackets"
4647                f" (got {repr(self.formatDict[levelStr])})."
4648            )
4649        if tokens[start + 2] != Lexeme.angleRight:
4650            raise ParseError(
4651                f"Challenge must include"
4652                f" {repr(self.formatDict[Lexeme.angleRight])} after"
4653                f" the level."
4654            )
4655        try:
4656            level = int(levelStr)
4657        except ValueError:
4658            raise ParseError(
4659                f"Challenge level must be an integer (got"
4660                f" {repr(tokens[start + 1])}."
4661            )
4662        try:
4663            successStart = tokens.index(Lexeme.openCurly, start)
4664            skillsEnd = successStart - 1
4665        except ValueError:
4666            raise ParseError("A challenge must include a consequence block.")
4667
4668        outcome: Optional[bool] = None
4669        if tokens[skillsEnd] == Lexeme.angleRight:
4670            skillsEnd -= 1
4671            outcome = True
4672        successEnd = self.matchingBrace(tokens, successStart)
4673        failStart = successEnd + 1
4674        if tokens[failStart] == Lexeme.angleRight:
4675            failStart += 1
4676            if outcome is not None:
4677                raise ParseError(
4678                    "Cannot indicate both success and failure as"
4679                    " outcomes in a challenge."
4680                )
4681            outcome = False
4682        failEnd = self.matchingBrace(tokens, failStart)
4683
4684        if failEnd != end:
4685            raise ParseError(
4686                f"Junk after condition:\n{tokens[failEnd + 1:end + 1]}"
4687            )
4688
4689        skills = self.parseSkillCombinationFromTokens(
4690            tokens,
4691            start + 3,
4692            skillsEnd
4693        )
4694        if isinstance(skills, base.Skill):
4695            skills = base.BestSkill(skills)
4696
4697        return base.challenge(
4698            level=level,
4699            outcome=outcome,
4700            skills=skills,
4701            success=self.parseConsequenceFromTokens(
4702                tokens[successStart:successEnd + 1]
4703            ),
4704            failure=self.parseConsequenceFromTokens(
4705                tokens[failStart:failEnd + 1]
4706            )
4707        )
4708
4709    def parseChallenge(
4710        self,
4711        encoded: str
4712    ) -> base.Challenge:
4713        """
4714        Lexes the given string and then calls `parseChallengeFromTokens`
4715        to return a `base.Challenge`.
4716        """
4717        return self.parseChallengeFromTokens(
4718            lex(encoded, self.reverseFormat)
4719        )
4720
4721    def parseConsequenceFromTokens(
4722        self,
4723        tokens: LexedTokens,
4724        start: int = 0,
4725        end: int = -1
4726    ) -> base.Consequence:
4727        """
4728        Parses a consequence from a lexed token list. If start and/or end
4729        are specified, only processes the part of the list between those
4730        two indices (inclusive). Use `lex` to turn a string into a
4731        `LexedTokens` list (or use `ParseFormat.parseConsequence` which
4732        does that for you).
4733
4734        An example:
4735
4736        >>> pf = ParseFormat()
4737        >>> tokens = [
4738        ...     Lexeme.openCurly,
4739        ...     'gain',
4740        ...     'power',
4741        ...     Lexeme.closeCurly
4742        ... ]
4743        >>> c = pf.parseConsequenceFromTokens(tokens)
4744        >>> c == [base.effect(gain='power')]
4745        True
4746        >>> tokens.append('hi')
4747        >>> c == pf.parseConsequenceFromTokens(tokens, end=-2)
4748        True
4749        >>> c == pf.parseConsequenceFromTokens(tokens, end=3)
4750        True
4751        """
4752        start, end, nTokens = normalizeEnds(tokens, start, end)
4753
4754        if nTokens < 2:
4755            raise ParseError("Consequence must have at least two tokens.")
4756
4757        if tokens[start] != Lexeme.openCurly:
4758            raise ParseError(
4759                f"Consequence must start with an open curly brace:"
4760                f" {repr(self.formatDict[Lexeme.openCurly])}."
4761            )
4762
4763        if tokens[end] != Lexeme.closeCurly:
4764            raise ParseError(
4765                f"Consequence must end with a closing curly brace:"
4766                f" {repr(self.formatDict[Lexeme.closeCurly])}."
4767            )
4768
4769        if nTokens == 2:
4770            return []
4771
4772        result: base.Consequence = []
4773        for partStart, partEnd in findSeparatedParts(
4774            tokens,
4775            Lexeme.consequenceSeparator,
4776            start + 1,
4777            end - 1,
4778            Lexeme.openCurly,
4779            Lexeme.closeCurly
4780        ):
4781            if partEnd - partStart < 0:
4782                raise ParseError("Empty consequence part.")
4783            if tokens[partStart] == Lexeme.angleLeft:  # a challenge
4784                result.append(
4785                    self.parseChallengeFromTokens(
4786                        tokens,
4787                        partStart,
4788                        partEnd
4789                    )
4790                )
4791            elif tokens[partStart] == Lexeme.doubleQuestionmark:  # condition
4792                result.append(
4793                    self.parseConditionFromTokens(
4794                        tokens,
4795                        partStart,
4796                        partEnd
4797                    )
4798                )
4799            else:  # Must be an effect
4800                result.append(
4801                    self.parseEffectFromTokens(
4802                        tokens,
4803                        partStart,
4804                        partEnd
4805                    )
4806                )
4807
4808        return result
4809
4810    def parseConsequence(self, encoded: str) -> base.Consequence:
4811        """
4812        Parses a consequence from a string. Uses `lex` and
4813        `ParseFormat.parseConsequenceFromTokens`. For example:
4814
4815        >>> pf = ParseFormat()
4816        >>> c = pf.parseConsequence(
4817        ...   '{gain power}'
4818        ... )
4819        >>> c == [base.effect(gain='power')]
4820        True
4821        >>> pf.unparseConsequence(c)
4822        '{gain power}'
4823        >>> c = pf.parseConsequence(
4824        ...     '{\\n'
4825        ...     '    ??(brawny|!weights*3){\\n'
4826        ...     '        <3>sum(brains, brawn){goto home}>{bounce}\\n'
4827        ...     '    }{};\\n'
4828        ...     '    lose coin*1\\n'
4829        ...     '}'
4830        ... )
4831        >>> len(c)
4832        2
4833        >>> c[0]['condition'] == base.ReqAny([
4834        ...     base.ReqCapability('brawny'),
4835        ...     base.ReqNot(base.ReqTokens('weights', 3))
4836        ... ])
4837        True
4838        >>> len(c[0]['consequence'])
4839        1
4840        >>> len(c[0]['alternative'])
4841        0
4842        >>> cons = c[0]['consequence'][0]
4843        >>> cons['skills'] == base.CombinedSkill('brains', 'brawn')
4844        True
4845        >>> cons['level']
4846        3
4847        >>> len(cons['success'])
4848        1
4849        >>> len(cons['failure'])
4850        1
4851        >>> cons['success'][0] == base.effect(goto='home')
4852        True
4853        >>> cons['failure'][0] == base.effect(bounce=True)
4854        True
4855        >>> cons['outcome'] = False
4856        >>> c[0] == base.condition(
4857        ...     condition=base.ReqAny([
4858        ...         base.ReqCapability('brawny'),
4859        ...         base.ReqNot(base.ReqTokens('weights', 3))
4860        ...     ]),
4861        ...     consequence=[
4862        ...         base.challenge(
4863        ...             skills=base.CombinedSkill('brains', 'brawn'),
4864        ...             level=3,
4865        ...             success=[base.effect(goto='home')],
4866        ...             failure=[base.effect(bounce=True)],
4867        ...             outcome=False
4868        ...         )
4869        ...     ]
4870        ... )
4871        True
4872        >>> c[1] == base.effect(lose=('coin', 1))
4873        True
4874        """
4875        return self.parseConsequenceFromTokens(
4876            lex(encoded, self.reverseFormat)
4877        )
4878
4879
4880#---------------------#
4881# Graphviz dot format #
4882#---------------------#
4883
4884class ParsedDotGraph(TypedDict):
4885    """
4886    Represents a parsed `graphviz` dot-format graph consisting of nodes,
4887    edges, and subgraphs, with attributes attached to nodes and/or
4888    edges. An intermediate format during conversion to a full
4889    `DecisionGraph`. Includes the following slots:
4890
4891    - `'nodes'`: A list of tuples each holding a node ID followed by a
4892        list of name/value attribute pairs.
4893    - `'edges'`: A list of tuples each holding a from-ID, a to-ID,
4894        and then a list of name/value attribute pairs.
4895    - `'attrs'`: A list of tuples each holding a name/value attribute
4896        pair for graph-level attributes.
4897    - `'subgraphs'`: A list of subgraphs (each a tuple with a subgraph
4898        name and then another dictionary in the same format as this
4899        one).
4900    """
4901    nodes: List[Tuple[int, List[Tuple[str, str]]]]
4902    edges: List[Tuple[int, int, List[Tuple[str, str]]]]
4903    attrs: List[Tuple[str, str]]
4904    subgraphs: List[Tuple[str, 'ParsedDotGraph']]
4905
4906
4907def parseSimpleDotAttrs(fragment: str) -> List[Tuple[str, str]]:
4908    """
4909    Given a string fragment that starts with '[' and ends with ']',
4910    parses a simple attribute list in `graphviz` dot format from that
4911    fragment, returning a list of name/value attribute tuples. Raises a
4912    `DotParseError` if the fragment doesn't have the right format.
4913
4914    Examples:
4915
4916    >>> parseSimpleDotAttrs('[ name=value ]')
4917    [('name', 'value')]
4918    >>> parseSimpleDotAttrs('[ a=b c=d e=f ]')
4919    [('a', 'b'), ('c', 'd'), ('e', 'f')]
4920    >>> parseSimpleDotAttrs('[ a=b "c d"="e f" ]')
4921    [('a', 'b'), ('c d', 'e f')]
4922    >>> parseSimpleDotAttrs('[a=b "c d"="e f"]')
4923    [('a', 'b'), ('c d', 'e f')]
4924    >>> parseSimpleDotAttrs('[ a=b "c d"="e f"')
4925    Traceback (most recent call last):
4926    ...
4927    exploration.parsing.DotParseError...
4928    >>> parseSimpleDotAttrs('a=b "c d"="e f" ]')
4929    Traceback (most recent call last):
4930    ...
4931    exploration.parsing.DotParseError...
4932    >>> parseSimpleDotAttrs('[ a b=c ]')
4933    Traceback (most recent call last):
4934    ...
4935    exploration.parsing.DotParseError...
4936    >>> parseSimpleDotAttrs('[ a=b c ]')
4937    Traceback (most recent call last):
4938    ...
4939    exploration.parsing.DotParseError...
4940    >>> parseSimpleDotAttrs('[ name="value" ]')
4941    [('name', 'value')]
4942    >>> parseSimpleDotAttrs('[ name="\\\\"value\\\\"" ]')
4943    [('name', '"value"')]
4944    """
4945    if not fragment.startswith('[') or not fragment.endswith(']'):
4946        raise DotParseError(
4947            f"Simple attrs fragment missing delimiters:"
4948            f"\n  {repr(fragment)}"
4949        )
4950    result = []
4951    rest = fragment[1:-1].strip()
4952    while rest:
4953        # Get possibly-quoted attribute name:
4954        if rest.startswith('"'):
4955            try:
4956                aName, rest = utils.unquoted(rest)
4957            except ValueError:
4958                raise DotParseError(
4959                    f"Malformed quoted attribute name in"
4960                    f" fragment:\n  {repr(fragment)}"
4961                )
4962            rest = rest.lstrip()
4963            if not rest.startswith('='):
4964                raise DotParseError(
4965                    f"Missing '=' in attribute block in"
4966                    f" fragment:\n  {repr(fragment)}"
4967                )
4968            rest = rest[1:].lstrip()
4969        else:
4970            try:
4971                eqInd = rest.index('=')
4972            except ValueError:
4973                raise DotParseError(
4974                    f"Missing '=' in attribute block in"
4975                    f" fragment:\n  {repr(fragment)}"
4976                )
4977            aName = rest[:eqInd]
4978            if ' ' in aName:
4979                raise DotParseError(
4980                    f"Malformed unquoted attribute name"
4981                    f" {repr(aName)} in fragment:"
4982                    f"\n  {repr(fragment)}"
4983                )
4984            rest = rest[eqInd + 1:].lstrip()
4985
4986        # Get possibly-quoted attribute value:
4987        if rest.startswith('"'):
4988            try:
4989                aVal, rest = utils.unquoted(rest)
4990            except ValueError:
4991                raise DotParseError(
4992                    f"Malformed quoted attribute value in"
4993                    f" fragment:\n  {repr(fragment)}"
4994                )
4995            rest = rest.lstrip()
4996        else:
4997            try:
4998                spInd = rest.index(' ')
4999            except ValueError:
5000                spInd = len(rest)
5001            aVal = rest[:spInd]
5002            rest = rest[spInd:].lstrip()
5003
5004        # Append this attribute pair and continue parsing
5005        result.append((aName, aVal))
5006
5007    return result
5008
5009
5010def parseDotNode(
5011    nodeLine: str
5012) -> Tuple[int, Union[bool, List[Tuple[str, str]]]]:
5013    """
5014    Given a line of text from a `graphviz` dot-format graph
5015    (possibly ending in an '[' to indicate attributes to follow, or
5016    possible including a '[ ... ]' block with attributes in-line),
5017    parses it as a node declaration, returning the ID of the node,
5018    along with a boolean indicating whether attributes follow or
5019    not. If an inline attribute block is present, the second member
5020    of the tuple will be a list of attribute name/value pairs. In
5021    that case, all attribute names and values must either be quoted
5022    or not include spaces.
5023    Examples:
5024
5025    >>> parseDotNode('1')
5026    (1, False)
5027    >>> parseDotNode(' 1 [ ')
5028    (1, True)
5029    >>> parseDotNode(' 1 [ a=b "c d"="e f" ] ')
5030    (1, [('a', 'b'), ('c d', 'e f')])
5031    >>> parseDotNode(' 3 [ name="A = \\\\"grate:open\\\\"" ]')
5032    (3, [('name', 'A = "grate:open"')])
5033    >>> parseDotNode('  "1"[')
5034    (1, True)
5035    >>> parseDotNode('  100[')
5036    (100, True)
5037    >>> parseDotNode('  1 2')
5038    Traceback (most recent call last):
5039    ...
5040    exploration.parsing.DotParseError...
5041    >>> parseDotNode('  1 [ 2')
5042    Traceback (most recent call last):
5043    ...
5044    exploration.parsing.DotParseError...
5045    >>> parseDotNode('  1 2')
5046    Traceback (most recent call last):
5047    ...
5048    exploration.parsing.DotParseError...
5049    >>> parseDotNode('  1 [ junk not=attrs ]')
5050    Traceback (most recent call last):
5051    ...
5052    exploration.parsing.DotParseError...
5053    >>> parseDotNode('  \\n')
5054    Traceback (most recent call last):
5055    ...
5056    exploration.parsing.DotParseError...
5057    """
5058    stripped = nodeLine.strip()
5059    if len(stripped) == 0:
5060        raise DotParseError(
5061            "Empty node in dot graph on line:\n  {repr(nodeLine)}"
5062        )
5063    hasAttrs: Union[bool, List[Tuple[str, str]]] = False
5064    if stripped.startswith('"'):
5065        nodeName, rest = utils.unquoted(stripped)
5066        rest = rest.strip()
5067        if rest == '[':
5068            hasAttrs = True
5069        elif rest.startswith('[') and rest.endswith(']'):
5070            hasAttrs = parseSimpleDotAttrs(rest)
5071        elif rest:
5072            raise DotParseError(
5073                f"Extra junk {repr(rest)} after node on line:"
5074                f"\n {repr(nodeLine)}"
5075            )
5076
5077    else:
5078        if stripped.endswith('['):
5079            hasAttrs = True
5080            stripped = stripped[:-1].rstrip()
5081        elif stripped.endswith(']'):
5082            try:
5083                # TODO: Why did this used to be rindex? Was that
5084                # important in some case? (That doesn't work since the
5085                # value may contain a quoted open bracket).
5086                attrStart = stripped.index('[')
5087            except ValueError:
5088                raise DotParseError(
5089                    f"Unmatched ']' on line:\n  {repr(nodeLine)}"
5090                )
5091            hasAttrs = parseSimpleDotAttrs(
5092                stripped[attrStart:]
5093            )
5094            stripped = stripped[:attrStart].rstrip()
5095
5096        if ' ' in stripped:
5097            raise DotParseError(
5098                f"Unquoted multi-word node on line:\n  {repr(nodeLine)}"
5099            )
5100        else:
5101            nodeName = stripped
5102
5103    try:
5104        nodeID = int(nodeName)
5105    except ValueError:
5106        raise DotParseError(
5107            f"Node name f{repr(nodeName)} is not an integer on"
5108            f" line:\n {repr(nodeLine)}"
5109        )
5110
5111    return (nodeID, hasAttrs)
5112
5113
5114def parseDotAttr(attrLine: str) -> Tuple[str, str]:
5115    """
5116    Given a line of text from a `graphviz` dot-format graph, parses
5117    it as an attribute (maybe-quoted-attr-name =
5118    maybe-quoted-attr-value). Returns the (maybe-unquoted) attr-name
5119    and the (maybe-unquoted) attr-value as a pair of strings. Raises
5120    a `DotParseError` if the line cannot be parsed as an attribute.
5121    Examples:
5122
5123    >>> parseDotAttr("a=b")
5124    ('a', 'b')
5125    >>> parseDotAttr("  a = b ")
5126    ('a', 'b')
5127    >>> parseDotAttr('"a" = "b"')
5128    ('a', 'b')
5129    >>> parseDotAttr('"a" -> "b"')
5130    Traceback (most recent call last):
5131    ...
5132    exploration.parsing.DotParseError...
5133    >>> parseDotAttr('"a" = "b" c')
5134    Traceback (most recent call last):
5135    ...
5136    exploration.parsing.DotParseError...
5137    >>> parseDotAttr('a')
5138    Traceback (most recent call last):
5139    ...
5140    exploration.parsing.DotParseError...
5141    >>> parseDotAttr('')
5142    Traceback (most recent call last):
5143    ...
5144    exploration.parsing.DotParseError...
5145    >>> parseDotAttr('0 [ name="A" ]')
5146    Traceback (most recent call last):
5147    ...
5148    exploration.parsing.DotParseError...
5149    """
5150    stripped = attrLine.lstrip()
5151    if len(stripped) == 0:
5152        raise DotParseError(
5153            "Empty attribute in dot graph on line:\n  {repr(attrLine)}"
5154        )
5155    if stripped.endswith(']') or stripped.endswith('['):
5156        raise DotParseError(
5157            f"Node attribute ends in '[' or ']' on line:"
5158            f"\n  {repr(attrLine)}"
5159        )
5160    if stripped.startswith('"'):
5161        try:
5162            attrName, rest = utils.unquoted(stripped)
5163        except ValueError:
5164            raise DotParseError(
5165                f"Unmatched quotes in line:\n  {repr(attrLine)}"
5166            )
5167        rest = rest.lstrip()
5168        if len(rest) == 0 or rest[0] != '=':
5169            raise DotParseError(
5170                f"No equals sign following attribute name on"
5171                f" line:\n  {repr(attrLine)}"
5172            )
5173        rest = rest[1:].lstrip()
5174    else:
5175        try:
5176            eqInd = stripped.index('=')
5177        except ValueError:
5178            raise DotParseError(
5179                f"No equals sign in attribute line:"
5180                f"\n  {repr(attrLine)}"
5181            )
5182        attrName = stripped[:eqInd].rstrip()
5183        rest = stripped[eqInd + 1:].lstrip()
5184
5185    if rest[0] == '"':
5186        try:
5187            attrVal, rest = utils.unquoted(rest)
5188        except ValueError:
5189            raise DotParseError(
5190                f"Unmatched quotes in line:\n  {repr(attrLine)}"
5191            )
5192        if rest.strip():
5193            raise DotParseError(
5194                f"Junk after attribute on line:"
5195                f"\n  {repr(attrLine)}"
5196            )
5197    else:
5198        attrVal = rest.rstrip()
5199
5200    return attrName, attrVal
5201
5202
5203def parseDotEdge(edgeLine: str) -> Tuple[int, int, bool]:
5204    """
5205    Given a line of text from a `graphviz` dot-format graph, parses
5206    it as an edge (fromID -> toID). Returns a tuple containing the
5207    from ID, the to ID, and a boolean indicating whether attributes
5208    follow the edge on subsequent lines (true if the line ends with
5209    '['). Raises a `DotParseError` if the line cannot be parsed as
5210    an edge pair. Examples:
5211
5212    >>> parseDotEdge("1 -> 2")
5213    (1, 2, False)
5214    >>> parseDotEdge("  1 -> 2 ")
5215    (1, 2, False)
5216    >>> parseDotEdge('"1" -> "2"')
5217    (1, 2, False)
5218    >>> parseDotEdge('"1" -> "2" [')
5219    (1, 2, True)
5220    >>> parseDotEdge("a -> b")
5221    Traceback (most recent call last):
5222    ...
5223    exploration.parsing.DotParseError...
5224    >>> parseDotEdge('"1" = "1"')
5225    Traceback (most recent call last):
5226    ...
5227    exploration.parsing.DotParseError...
5228    >>> parseDotEdge('"1" -> "2" c')
5229    Traceback (most recent call last):
5230    ...
5231    exploration.parsing.DotParseError...
5232    >>> parseDotEdge('1')
5233    Traceback (most recent call last):
5234    ...
5235    exploration.parsing.DotParseError...
5236    >>> parseDotEdge('')
5237    Traceback (most recent call last):
5238    ...
5239    exploration.parsing.DotParseError...
5240    """
5241    stripped = edgeLine.lstrip()
5242    if len(stripped) == 0:
5243        raise DotParseError(
5244            "Empty edge in dot graph on line:\n  {repr(edgeLine)}"
5245        )
5246    if stripped.startswith('"'):
5247        try:
5248            fromStr, rest = utils.unquoted(stripped)
5249        except ValueError:
5250            raise DotParseError(
5251                f"Unmatched quotes in line:\n  {repr(edgeLine)}"
5252            )
5253        rest = rest.lstrip()
5254        if rest[:2] != '->':
5255            raise DotParseError(
5256                f"No arrow sign following source name on"
5257                f" line:\n  {repr(edgeLine)}"
5258            )
5259        rest = rest[2:].lstrip()
5260    else:
5261        try:
5262            arrowInd = stripped.index('->')
5263        except ValueError:
5264            raise DotParseError(
5265                f"No arrow in edge line:"
5266                f"\n  {repr(edgeLine)}"
5267            )
5268        fromStr = stripped[:arrowInd].rstrip()
5269        rest = stripped[arrowInd + 2:].lstrip()
5270        if ' ' in fromStr:
5271            raise DotParseError(
5272                f"Unquoted multi-word edge source on line:"
5273                f"\n  {repr(edgeLine)}"
5274            )
5275
5276    hasAttrs = False
5277    if rest[0] == '"':
5278        try:
5279            toStr, rest = utils.unquoted(rest)
5280        except ValueError:
5281            raise DotParseError(
5282                f"Unmatched quotes in line:\n  {repr(edgeLine)}"
5283            )
5284        stripped = rest.strip()
5285        if stripped == '[':
5286            hasAttrs = True
5287        elif stripped:
5288            raise DotParseError(
5289                f"Junk after edge on line:"
5290                f"\n  {repr(edgeLine)}"
5291            )
5292    else:
5293        toStr = rest.rstrip()
5294        if toStr.endswith('['):
5295            toStr = toStr[:-1].rstrip()
5296            hasAttrs = True
5297        if ' ' in toStr:
5298            raise DotParseError(
5299                f"Unquoted multi-word edge destination on line:"
5300                f"\n  {repr(edgeLine)}"
5301            )
5302
5303    try:
5304        fromID = int(fromStr)
5305    except ValueError:
5306        raise DotParseError(
5307            f"Invalid 'from' ID: {repr(fromStr)} on line:"
5308            f"\n  {repr(edgeLine)}"
5309        )
5310
5311    try:
5312        toID = int(toStr)
5313    except ValueError:
5314        raise DotParseError(
5315            f"Invalid 'to' ID: {repr(toStr)} on line:"
5316            f"\n  {repr(edgeLine)}"
5317        )
5318
5319    return (fromID, toID, hasAttrs)
5320
5321
5322def parseDotAttrList(
5323    lines: List[str]
5324) -> Tuple[List[Tuple[str, str]], List[str]]:
5325    """
5326    Given a list of lines of text from a `graphviz` dot-format
5327    graph which starts with an attribute line, parses multiple
5328    attribute lines until a line containing just ']' is found.
5329    Returns a list of the parsed name/value attribute pair tuples,
5330    along with a list of remaining unparsed strings (not counting
5331    the closing ']' line). Raises a `DotParseError` if it finds a
5332    non-attribute line or if it fails to find a closing ']' line.
5333    Examples:
5334
5335    >>> parseDotAttrList([
5336    ...     'a=b\\n',
5337    ...     'c=d\\n',
5338    ...     ']\\n',
5339    ... ])
5340    ([('a', 'b'), ('c', 'd')], [])
5341    >>> parseDotAttrList([
5342    ...     'a=b',
5343    ...     'c=d',
5344    ...     '  ]',
5345    ...     'more',
5346    ...     'lines',
5347    ... ])
5348    ([('a', 'b'), ('c', 'd')], ['more', 'lines'])
5349    >>> parseDotAttrList([
5350    ...     'a=b',
5351    ...     'c=d',
5352    ... ])
5353    Traceback (most recent call last):
5354    ...
5355    exploration.parsing.DotParseError...
5356    """
5357    index = 0
5358    found = []
5359    while index < len(lines):
5360        thisLine = lines[index]
5361        try:
5362            found.append(parseDotAttr(thisLine))
5363        except DotParseError:
5364            if thisLine.strip() == ']':
5365                return (found, lines[index + 1:])
5366            else:
5367                raise DotParseError(
5368                    f"Could not parse attribute from line:"
5369                    f"\n  {repr(thisLine)}"
5370                    f"\nAttributes block starts on line:"
5371                    f"\n  {repr(lines[0])}"
5372                )
5373        index += 1
5374
5375    raise DotParseError(
5376        f"No list terminator (']') for attributes starting on line:"
5377        f"\n  {repr(lines[0])}"
5378    )
5379
5380
5381def parseDotSubgraphStart(line: str) -> str:
5382    """
5383    Parses the start of a subgraph from a line of a graph file. The
5384    line must start with the word 'subgraph' and then have a name,
5385    followed by a '{' at the end of the line. Raises a
5386    `DotParseError` if this format doesn't match. Examples:
5387
5388    >>> parseDotSubgraphStart('subgraph A {')
5389    'A'
5390    >>> parseDotSubgraphStart('subgraph A B {')
5391    Traceback (most recent call last):
5392    ...
5393    exploration.parsing.DotParseError...
5394    >>> parseDotSubgraphStart('subgraph "A B" {')
5395    'A B'
5396    >>> parseDotSubgraphStart('subgraph A')
5397    Traceback (most recent call last):
5398    ...
5399    exploration.parsing.DotParseError...
5400    """
5401    stripped = line.strip()
5402    if len(stripped) == 0:
5403        raise DotParseError(
5404            f"Empty line where subgraph was expected:"
5405            f"\n  {repr(line)}"
5406        )
5407
5408    if not stripped.startswith('subgraph '):
5409        raise DotParseError(
5410            f"Subgraph doesn't start with 'subgraph' on line:"
5411            f"\n  {repr(line)}"
5412        )
5413
5414    stripped = stripped[9:]
5415    if stripped.startswith('"'):
5416        try:
5417            name, rest = utils.unquoted(stripped)
5418        except ValueError:
5419            raise DotParseError(
5420                f"Malformed quotes on subgraph line:\n {repr(line)}"
5421            )
5422        if rest.strip() != '{':
5423            raise DotParseError(
5424                f"Junk or missing '{{' on subgraph line:\n {repr(line)}"
5425            )
5426    else:
5427        parts = stripped.split()
5428        if len(parts) != 2 or parts[1] != '{':
5429            raise DotParseError(
5430                f"Junk or missing '{{' on subgraph line:\n {repr(line)}"
5431            )
5432        name, _ = parts
5433
5434    return name
5435
5436
5437def parseDotGraphContents(
5438    lines: List[str]
5439) -> Tuple[ParsedDotGraph, List[str]]:
5440    """
5441    Given a list of lines from a `graphviz` dot-format string,
5442    parses the list as the contents of a graph (or subgraph),
5443    stopping when it reaches a line that just contains '}'. Raises a
5444    `DotParseError` if it cannot do so or if the terminator is
5445    missing. Returns a tuple containing the parsed graph data (see
5446    `ParsedDotGraph` and the list of remaining lines after the
5447    terminator. Recursively parses subgraphs. Example:
5448
5449    >>> bits = parseDotGraphContents([
5450    ...     '"graph attr"=1',
5451    ...     '1 [',
5452    ...     '  attr=value',
5453    ...     ']',
5454    ...     '1 -> 2 [',
5455    ...     '  fullLabel="to_B"',
5456    ...     '  quality=number',
5457    ...     ']',
5458    ...     'subgraph name {',
5459    ...     '  300',
5460    ...     '  400',
5461    ...     '  300 -> 400 [',
5462    ...     '    fullLabel=forward',
5463    ...     '  ]',
5464    ...     '}',
5465    ...     '}',
5466    ... ])
5467    >>> len(bits)
5468    2
5469    >>> g = bits[0]
5470    >>> bits[1]
5471    []
5472    >>> sorted(g.keys())
5473    ['attrs', 'edges', 'nodes', 'subgraphs']
5474    >>> g['nodes']
5475    [(1, [('attr', 'value')])]
5476    >>> g['edges']
5477    [(1, 2, [('fullLabel', 'to_B'), ('quality', 'number')])]
5478    >>> g['attrs']
5479    [('graph attr', '1')]
5480    >>> sgs = g['subgraphs']
5481    >>> len(sgs)
5482    1
5483    >>> len(sgs[0])
5484    2
5485    >>> sgs[0][0]
5486    'name'
5487    >>> sg = sgs[0][1]
5488    >>> sorted(sg.keys())
5489    ['attrs', 'edges', 'nodes', 'subgraphs']
5490    >>> sg["nodes"]
5491    [(300, []), (400, [])]
5492    >>> sg["edges"]
5493    [(300, 400, [('fullLabel', 'forward')])]
5494    >>> sg["attrs"]
5495    []
5496    >>> sg["subgraphs"]
5497    []
5498    """
5499    result: ParsedDotGraph = {
5500        'nodes': [],
5501        'edges': [],
5502        'attrs': [],
5503        'subgraphs': [],
5504    }
5505    index = 0
5506    remainder = None
5507    # Consider each line:
5508    while index < len(lines):
5509        # Grab line and pre-increment index
5510        thisLine = lines[index]
5511        index += 1
5512
5513        # Check for } first because it could be parsed as a node
5514        stripped = thisLine.strip()
5515        if stripped == '}':
5516            remainder = lines[index:]
5517            break
5518        elif stripped == '':  # ignore blank lines
5519            continue
5520
5521        # Cascading parsing attempts, since the possibilities are
5522        # mostly mutually exclusive.
5523        # TODO: Node/attr confusion with = in a node name?
5524        try:
5525            attrName, attrVal = parseDotAttr(thisLine)
5526            result['attrs'].append((attrName, attrVal))
5527        except DotParseError:
5528            try:
5529                fromNode, toNode, hasEAttrs = parseDotEdge(
5530                    thisLine
5531                )
5532                if hasEAttrs:
5533                    attrs, rest = parseDotAttrList(
5534                        lines[index:]
5535                    )
5536                    # Restart to process rest
5537                    lines = rest
5538                    index = 0
5539                else:
5540                    attrs = []
5541                result['edges'].append((fromNode, toNode, attrs))
5542            except DotParseError:
5543                try:
5544                    nodeName, hasNAttrs = parseDotNode(
5545                        thisLine
5546                    )
5547                    if hasNAttrs is True:
5548                        attrs, rest = parseDotAttrList(
5549                            lines[index:]
5550                        )
5551                        # Restart to process rest
5552                        lines = rest
5553                        index = 0
5554                    elif hasNAttrs:
5555                        attrs = hasNAttrs
5556                    else:
5557                        attrs = []
5558                    result['nodes'].append((nodeName, attrs))
5559                except DotParseError:
5560                    try:
5561                        subName = parseDotSubgraphStart(
5562                            thisLine
5563                        )
5564                        subStuff, rest = \
5565                            parseDotGraphContents(
5566                                lines[index:]
5567                            )
5568                        result['subgraphs'].append((subName, subStuff))
5569                        # Restart to process rest
5570                        lines = rest
5571                        index = 0
5572                    except DotParseError:
5573                        raise DotParseError(
5574                            f"Unrecognizable graph line (possibly"
5575                            f" beginning of unfinished structure):"
5576                            f"\n  {repr(thisLine)}"
5577                        )
5578    if remainder is None:
5579        raise DotParseError(
5580            f"Graph (or subgraph) is missing closing '}}'. Starts"
5581            f" on line:\n  {repr(lines[0])}"
5582        )
5583    else:
5584        return (result, remainder)
5585
5586
5587def parseDot(
5588    dotStr: str,
5589    parseFormat: ParseFormat = ParseFormat()
5590) -> core.DecisionGraph:
5591    """
5592    Converts a `graphviz` dot-format string into a `core.DecisionGraph`.
5593    A custom `ParseFormat` may be specified if desired; the default
5594    `ParseFormat` is used if not. Note that this relies on specific
5595    indentation schemes used by `toDot` so a hand-edited dot-format
5596    graph will probably not work. A `DotParseError` is raised if the
5597    provided string can't be parsed. Example
5598
5599    >>> parseDotNode(' 3 [ label="A = \\\\"grate:open\\\\"" ]')
5600    (3, [('label', 'A = "grate:open"')])
5601    >>> sg = '''\
5602    ... subgraph __requirements__ {
5603    ...   3 [ label="A = \\\\"grate:open\\\\"" ]
5604    ...   4 [ label="B = \\\\"!(helmet)\\\\"" ]
5605    ...   5 [ label="C = \\\\"helmet\\\\"" ]
5606    ... }'''
5607    >>> parseDotGraphContents(sg.splitlines()[1:])
5608    ({'nodes': [(3, [('label', 'A = "grate:open"')]),\
5609 (4, [('label', 'B = "!(helmet)"')]), (5, [('label', 'C = "helmet"')])],\
5610 'edges': [], 'attrs': [], 'subgraphs': []}, [])
5611    >>> from . import core
5612    >>> dg = core.DecisionGraph.example('simple')
5613    >>> encoded = toDot(dg)
5614    >>> reconstructed = parseDot(encoded)
5615    >>> for diff in dg.listDifferences(reconstructed):
5616    ...     print(diff)
5617    >>> reconstructed == dg
5618    True
5619    >>> dg = core.DecisionGraph.example('abc')
5620    >>> encoded = toDot(dg)
5621    >>> reconstructed = parseDot(encoded)
5622    >>> for diff in dg.listDifferences(reconstructed):
5623    ...     print(diff)
5624    >>> reconstructed == dg
5625    True
5626    >>> tg = core.DecisionGraph()
5627    >>> tg.addDecision('A')
5628    0
5629    >>> tg.addDecision('B')
5630    1
5631    >>> tg.addTransition('A', 'up', 'B', 'down')
5632    >>> same = parseDot('''
5633    ... digraph {
5634    ...     0 [ name=A label=A ]
5635    ...       0 -> 1 [
5636    ...         label=up
5637    ...         fullLabel=up
5638    ...         reciprocal=down
5639    ...       ]
5640    ...     1 [ name=B label=B ]
5641    ...       1 -> 0 [
5642    ...         label=down
5643    ...         fullLabel=down
5644    ...         reciprocal=up
5645    ...       ]
5646    ... }''')
5647    >>> for diff in tg.listDifferences(same):
5648    ...     print(diff)
5649    >>> same == tg
5650    True
5651    >>> pf = ParseFormat()
5652    >>> tg.setTransitionRequirement('A', 'up', pf.parseRequirement('one|two'))
5653    >>> tg.setConsequence(
5654    ...     'B',
5655    ...     'down',
5656    ...     [base.effect(gain="one")]
5657    ... )
5658    >>> test = parseDot('''
5659    ...   digraph {
5660    ...     0 [ name="A = \\\\"one|two\\\\"" label="A = \\\\"one|two\\\\"" ]
5661    ...   }
5662    ... ''')
5663    >>> list(test.nodes)
5664    [0]
5665    >>> test.nodes[0]['name']
5666    'A = "one|two"'
5667    >>> eff = (
5668    ...   r'"A = \\"[{\\\\\\"type\\\\\\": \\\\\\"gain\\\\\\",'
5669    ...   r' \\\\\\"applyTo\\\\\\": \\\\\\"active\\\\\\",'
5670    ...   r' \\\\\\"value\\\\\\": \\\\\\"one\\\\\\",'
5671    ...   r' \\\\\\"charges\\\\\\": null, \\\\\\"hidden\\\\\\": false,'
5672    ...   r' \\\\\\"delay\\\\\\": null}]\\""'
5673    ... )
5674    >>> utils.unquoted(eff)[1]
5675    ''
5676    >>> test2 = parseDot(
5677    ...     'digraph {\\n 0 [ name=' + eff + ' label=' + eff + ' ]\\n}'
5678    ... )
5679    >>> s = test2.nodes[0]['name']
5680    >>> s[:25]
5681    'A = "[{\\\\"type\\\\": \\\\"gain\\\\"'
5682    >>> s[25:50]
5683    ', \\\\"applyTo\\\\": \\\\"active\\\\"'
5684    >>> s[50:70]
5685    ', \\\\"value\\\\": \\\\"one\\\\"'
5686    >>> s[70:89]
5687    ', \\\\"charges\\\\": null'
5688    >>> s[89:108]
5689    ', \\\\"hidden\\\\": false'
5690    >>> s[108:]
5691    ', \\\\"delay\\\\": null}]"'
5692    >>> ae = s[s.index('=') + 1:].strip()
5693    >>> uq, after = utils.unquoted(ae)
5694    >>> after
5695    ''
5696    >>> fromJSON(uq) == [base.effect(gain="one")]
5697    True
5698    >>> same = parseDot('''
5699    ... digraph {
5700    ...   0 [ name=A label=A ]
5701    ...     0 -> 1 [
5702    ...       label=up
5703    ...       fullLabel=up
5704    ...       reciprocal=down
5705    ...       req=A
5706    ...     ]
5707    ...   1 [ name=B label=B ]
5708    ...     1 -> 0 [
5709    ...       label=down
5710    ...       fullLabel=down
5711    ...       reciprocal=up
5712    ...       consequence=A
5713    ...     ]
5714    ...   subgraph __requirements__ {
5715    ...     2 [ label="A = \\\\"one|two\\\\"" ]
5716    ...   }
5717    ...   subgraph __consequences__ {
5718    ...     3 [ label=''' + eff + ''' ]
5719    ...   }
5720    ... }''')
5721    >>> c = {'tags': {}, 'annotations': [], 'reciprocal': 'up', 'consequence': [{'type': 'gain', 'applyTo': 'active', 'value': 'one', 'delay': None, 'charges': None}]}['consequence']  # noqa
5722
5723    >>> for diff in tg.listDifferences(same):
5724    ...     print(diff)
5725    >>> same == tg
5726    True
5727    """
5728    lines = dotStr.splitlines()
5729    while lines[0].strip() == '':
5730        lines.pop(0)
5731    if lines.pop(0).strip() != "digraph {":
5732        raise DotParseError("Input doesn't begin with 'digraph {'.")
5733
5734    # Create our result
5735    result = core.DecisionGraph()
5736
5737    # Parse to intermediate graph data structure
5738    graphStuff, remaining = parseDotGraphContents(lines)
5739    if remaining:
5740        if len(remaining) <= 4:
5741            junk = '\n  '.join(repr(line) for line in remaining)
5742        else:
5743            junk = '\n  '.join(repr(line) for line in remaining[:4])
5744            junk += '\n  ...'
5745        raise DotParseError("Extra junk after graph:\n  {junk}")
5746
5747    # Sort out subgraphs to find legends
5748    zoneSubs = []
5749    reqLegend = None
5750    consequenceLegend = None
5751    mechanismLegend = None
5752    for sub in graphStuff['subgraphs']:
5753        if sub[0] == '__requirements__':
5754            reqLegend = sub[1]
5755        elif sub[0] == '__consequences__':
5756            consequenceLegend = sub[1]
5757        elif sub[0] == '__mechanisms__':
5758            mechanismLegend = sub[1]
5759        else:
5760            zoneSubs.append(sub)
5761
5762    # Build out our mapping from requirement abbreviations to actual
5763    # requirement objects
5764    reqMap: Dict[str, base.Requirement] = {}
5765    if reqLegend is not None:
5766        if reqLegend['edges']:
5767            raise DotParseError(
5768                f"Requirements legend subgraph has edges:"
5769                f"\n  {repr(reqLegend['edges'])}"
5770                f"\n(It should only have nodes.)"
5771            )
5772        if reqLegend['attrs']:
5773            raise DotParseError(
5774                f"Requirements legend subgraph has attributes:"
5775                f"\n  {repr(reqLegend['attrs'])}"
5776                f"\n(It should only have nodes.)"
5777            )
5778        if reqLegend['subgraphs']:
5779            raise DotParseError(
5780                f"Requirements legend subgraph has subgraphs:"
5781                f"\n  {repr(reqLegend['subgraphs'])}"
5782                f"\n(It should only have nodes.)"
5783            )
5784        for node, attrs in reqLegend['nodes']:
5785            if not attrs:
5786                raise DotParseError(
5787                    f"Node in requirements legend missing attributes:"
5788                    f"\n  {repr(attrs)}"
5789                )
5790            if len(attrs) != 1:
5791                raise DotParseError(
5792                    f"Node in requirements legend has multiple"
5793                    f" attributes:\n  {repr(attrs)}"
5794                )
5795            reqStr = attrs[0][1]
5796            try:
5797                eqInd = reqStr.index('=')
5798            except ValueError:
5799                raise DotParseError(
5800                    f"Missing '=' in requirement specifier:"
5801                    f"\n  {repr(reqStr)}"
5802                )
5803            ab = reqStr[:eqInd].rstrip()
5804            encoded = reqStr[eqInd + 1:].lstrip()
5805            try:
5806                encVal, empty = utils.unquoted(encoded)
5807            except ValueError:
5808                raise DotParseError(
5809                    f"Invalid quoted requirement value:"
5810                    f"\n  {repr(encoded)}"
5811                )
5812            if empty.strip():
5813                raise DotParseError(
5814                    f"Extra junk after requirement value:"
5815                    f"\n  {repr(empty)}"
5816                )
5817            try:
5818                req = parseFormat.parseRequirement(encVal)
5819            except ValueError:
5820                raise DotParseError(
5821                    f"Invalid encoded requirement in requirements"
5822                    f" legend:\n  {repr(encVal)}"
5823                )
5824            if ab in reqMap:
5825                raise DotParseError(
5826                    f"Abbreviation '{ab}' was defined multiple"
5827                    f" times in requirements legend."
5828                )
5829            reqMap[ab] = req
5830
5831    # Build out our mapping from consequence abbreviations to actual
5832    # consequence lists
5833    consequenceMap: Dict[str, base.Consequence] = {}
5834    if consequenceLegend is not None:
5835        if consequenceLegend['edges']:
5836            raise DotParseError(
5837                f"Consequences legend subgraph has edges:"
5838                f"\n  {repr(consequenceLegend['edges'])}"
5839                f"\n(It should only have nodes.)"
5840            )
5841        if consequenceLegend['attrs']:
5842            raise DotParseError(
5843                f"Consequences legend subgraph has attributes:"
5844                f"\n  {repr(consequenceLegend['attrs'])}"
5845                f"\n(It should only have nodes.)"
5846            )
5847        if consequenceLegend['subgraphs']:
5848            raise DotParseError(
5849                f"Consequences legend subgraph has subgraphs:"
5850                f"\n  {repr(consequenceLegend['subgraphs'])}"
5851                f"\n(It should only have nodes.)"
5852            )
5853        for node, attrs in consequenceLegend['nodes']:
5854            if not attrs:
5855                raise DotParseError(
5856                    f"Node in consequence legend missing attributes:"
5857                    f"\n  {repr(attrs)}"
5858                )
5859            if len(attrs) != 1:
5860                raise DotParseError(
5861                    f"Node in consequences legend has multiple"
5862                    f" attributes:\n  {repr(attrs)}"
5863                )
5864            consStr = attrs[0][1]
5865            try:
5866                eqInd = consStr.index('=')
5867            except ValueError:
5868                raise DotParseError(
5869                    f"Missing '=' in consequence string:"
5870                    f"\n  {repr(consStr)}"
5871                )
5872            ab = consStr[:eqInd].rstrip()
5873            encoded = consStr[eqInd + 1:].lstrip()
5874            try:
5875                encVal, empty = utils.unquoted(encoded)
5876            except ValueError:
5877                raise DotParseError(
5878                    f"Invalid quoted consequence value:"
5879                    f"\n  {repr(encoded)}"
5880                )
5881            if empty.strip():
5882                raise DotParseError(
5883                    f"Extra junk after consequence value:"
5884                    f"\n  {repr(empty)}"
5885                )
5886            try:
5887                consequences = fromJSON(encVal)
5888            except json.decoder.JSONDecodeError:
5889                raise DotParseError(
5890                    f"Invalid encoded consequence in requirements"
5891                    f" legend:\n  {repr(encVal)}"
5892                )
5893            if ab in consequenceMap:
5894                raise DotParseError(
5895                    f"Abbreviation '{ab}' was defined multiple"
5896                    f" times in effects legend."
5897                )
5898            consequenceMap[ab] = consequences
5899
5900    # Reconstruct mechanisms
5901    if mechanismLegend is not None:
5902        if mechanismLegend['edges']:
5903            raise DotParseError(
5904                f"Mechanisms legend subgraph has edges:"
5905                f"\n  {repr(mechanismLegend['edges'])}"
5906                f"\n(It should only have nodes.)"
5907            )
5908        if mechanismLegend['attrs']:
5909            raise DotParseError(
5910                f"Mechanisms legend subgraph has attributes:"
5911                f"\n  {repr(mechanismLegend['attrs'])}"
5912                f"\n(It should only have nodes.)"
5913            )
5914        if mechanismLegend['subgraphs']:
5915            raise DotParseError(
5916                f"Mechanisms legend subgraph has subgraphs:"
5917                f"\n  {repr(mechanismLegend['subgraphs'])}"
5918                f"\n(It should only have nodes.)"
5919            )
5920        for node, attrs in mechanismLegend['nodes']:
5921            if not attrs:
5922                raise DotParseError(
5923                    f"Node in mechanisms legend missing attributes:"
5924                    f"\n  {repr(attrs)}"
5925                )
5926            if len(attrs) != 1:
5927                raise DotParseError(
5928                    f"Node in mechanisms legend has multiple"
5929                    f" attributes:\n  {repr(attrs)}"
5930                )
5931            mechStr = attrs[0][1]
5932            try:
5933                atInd = mechStr.index('@')
5934                colonInd = mechStr.index(':')
5935            except ValueError:
5936                raise DotParseError(
5937                    f"Missing '@' or ':' in mechanism string:"
5938                    f"\n  {repr(mechStr)}"
5939                )
5940            if atInd > colonInd:
5941                raise DotParseError(
5942                    f"':' after '@' in mechanism string:"
5943                    f"\n  {repr(mechStr)}"
5944                )
5945            mID: base.MechanismID
5946            where: Optional[base.DecisionID]
5947            mName: base.MechanismName
5948            try:
5949                mID = int(mechStr[:atInd].rstrip())
5950            except ValueError:
5951                raise DotParseError(
5952                    f"Invalid mechanism ID in mechanism string:"
5953                    f"\n  {repr(mechStr)}"
5954                )
5955            try:
5956                whereStr = mechStr[atInd + 1:colonInd].strip()
5957                if whereStr == "None":
5958                    where = None
5959                else:
5960                    where = int(whereStr)
5961            except ValueError:
5962                raise DotParseError(
5963                    f"Invalid mechanism location in mechanism string:"
5964                    f"\n  {repr(mechStr)}"
5965                )
5966            mName, rest = utils.unquoted(mechStr[colonInd + 1:].lstrip())
5967            if rest.strip():
5968                raise DotParseError(
5969                    f"Junk after mechanism name in mechanism string:"
5970                    f"\n  {repr(mechStr)}"
5971                )
5972            result.mechanisms[mID] = (where, mName)
5973            if where is None:
5974                result.globalMechanisms[mName] = mID
5975
5976    # Add zones to the graph based on parent info
5977    # Map from zones to children we should add to them once all
5978    # zones are created:
5979    zoneChildMap: Dict[str, List[str]] = {}
5980    for prefixedName, graphData in zoneSubs:
5981        # Chop off cluster_ or _ prefix:
5982        zoneName = prefixedName[prefixedName.index('_') + 1:]
5983        if graphData['edges']:
5984            raise DotParseError(
5985                f"Zone subgraph for zone {repr(zoneName)} has edges:"
5986                f"\n  {repr(graphData['edges'])}"
5987                f"\n(It should only have nodes and attributes.)"
5988            )
5989        if graphData['subgraphs']:
5990            raise DotParseError(
5991                f"Zone subgraph for zone {repr(zoneName)} has"
5992                f" subgraphs:"
5993                f"\n  {repr(graphData['subgraphs'])}"
5994                f"\n(It should only have nodes and attributes.)"
5995            )
5996        # Note: we ignore nodes as that info is used for
5997        # visualization but is redundant with the zone parent info
5998        # stored in nodes, and it would be tricky to tease apart
5999        # direct vs. indirect relationships from merged info.
6000        parents = None
6001        level = None
6002        for attr, aVal in graphData['attrs']:
6003            if attr == 'parents':
6004                try:
6005                    parents = set(fromJSON(aVal))
6006                except json.decoder.JSONDecodeError:
6007                    raise DotParseError(
6008                        f"Invalid parents JSON in zone subgraph for"
6009                        f" zone '{zoneName}':\n  {repr(aVal)}"
6010                    )
6011            elif attr == 'level':
6012                try:
6013                    level = int(aVal)
6014                except ValueError:
6015                    raise DotParseError(
6016                        f"Invalid level in zone subgraph for"
6017                        f" zone '{zoneName}':\n  {repr(aVal)}"
6018                    )
6019            elif attr == 'label':
6020                pass  # name already extracted from the subgraph name
6021
6022            else:
6023                raise DotParseError(
6024                    f"Unexpected attribute '{attr}' in zone"
6025                    f" subgraph for zone '{zoneName}'"
6026                )
6027        if parents is None:
6028            raise DotParseError(
6029                f"No parents attribute for zone '{zoneName}'."
6030                f" Graph is:\n  {repr(graphData)}"
6031            )
6032        if level is None:
6033            raise DotParseError(
6034                f"No level attribute for zone '{zoneName}'."
6035                f" Graph is:\n  {repr(graphData)}"
6036            )
6037
6038        # Add ourself to our parents in the child map
6039        for parent in parents:
6040            zoneChildMap.setdefault(parent, []).append(zoneName)
6041
6042        # Create this zone
6043        result.createZone(zoneName, level)
6044
6045    # Add zone parent/child relationships
6046    for parent, children in zoneChildMap.items():
6047        for child in children:
6048            result.addZoneToZone(child, parent)
6049
6050    # Add nodes to the graph
6051    for (node, attrs) in graphStuff['nodes']:
6052        name: Optional[str] = None
6053        annotations = []
6054        tags: Dict[base.Tag, base.TagValue] = {}
6055        zones = []
6056        for attr, aVal in attrs:
6057            if attr == 'name':  # it's the name
6058                name = aVal
6059            elif attr == 'label':  # zone + name; redundant
6060                pass
6061            elif attr.startswith('t_'):  # it's a tag
6062                tagName = attr[2:]
6063                try:
6064                    tagAny = fromJSON(aVal)
6065                except json.decoder.JSONDecodeError:
6066                    raise DotParseError(
6067                        f"Error in JSON for tag attr '{attr}' of node"
6068                        f" '{node}'"
6069                    )
6070                if isinstance(tagAny, base.TagValueTypes):
6071                    tagVal: base.TagValue = cast(base.TagValue, tagAny)
6072                else:
6073                    raise DotParseError(
6074                        f"JSON for tag value encodes disallowed tag"
6075                        f" value of type {type(tagAny)}. Value is:"
6076                        f"\n  {repr(tagAny)}"
6077                    )
6078                tags[tagName] = tagVal
6079            elif attr.startswith('z_'):  # it's a zone
6080                zones.append(attr[2:])
6081            elif attr == 'annotations':  # It's the annotations
6082                try:
6083                    annotations = fromJSON(aVal)
6084                except json.decoder.JSONDecodeError:
6085                    raise DotParseError(
6086                        f"Bad JSON in attribute '{attr}' of node"
6087                        f" '{node}'"
6088                    )
6089            else:
6090                raise DotParseError(
6091                    f"Unrecognized node attribute '{attr}' for node"
6092                    f" '{node}'"
6093                )
6094
6095        # TODO: Domains here?
6096        if name is None:
6097            raise DotParseError(f"Node '{node}' does not have a name.")
6098
6099        result.addIdentifiedDecision(
6100            node,
6101            name,
6102            tags=tags,
6103            annotations=annotations
6104        )
6105        for zone in zones:
6106            try:
6107                result.addDecisionToZone(node, zone)
6108            except core.MissingZoneError:
6109                raise DotParseError(
6110                    f"Zone '{zone}' for node {node} does not"
6111                    f" exist."
6112                )
6113
6114    # Add mechanisms to each node:
6115    for (mID, (where, mName)) in result.mechanisms.items():
6116        mPool = result.nodes[where].setdefault('mechanisms', {})
6117        if mName in mPool:
6118            raise DotParseError(
6119                f"Multiple mechanisms named {mName!r} at"
6120                f" decision {where}."
6121            )
6122        mPool[mName] = mID
6123
6124    # Reciprocals to double-check once all edges are added
6125    recipChecks: Dict[
6126        Tuple[base.DecisionID, base.Transition],
6127        base.Transition
6128    ] = {}
6129
6130    # Add each edge
6131    for (source, dest, attrs) in graphStuff['edges']:
6132        annotations = []
6133        tags = {}
6134        label = None
6135        requirements = None
6136        consequence = None
6137        reciprocal = None
6138        for attr, aVal in attrs:
6139            if attr.startswith('t_'):
6140                try:
6141                    tags[attr[2:]] = fromJSON(aVal)
6142                except json.decoder.JSONDecodeError:
6143                    raise DotParseError(
6144                        f"Invalid JSON in edge tag '{attr}' for edge"
6145                        f"from '{source}' to '{dest}':"
6146                        f"\n  {repr(aVal)}"
6147                    )
6148            elif attr == "label":  # We ignore the short-label
6149                pass
6150            elif attr == "fullLabel":  # This is our transition name
6151                label = aVal
6152            elif attr == "reciprocal":
6153                reciprocal = aVal
6154            elif attr == "req":
6155                reqAbbr = aVal
6156                if reqAbbr not in reqMap:
6157                    raise DotParseError(
6158                        f"Edge from '{source}' to '{dest}' has"
6159                        f" requirement abbreviation '{reqAbbr}'"
6160                        f" but that abbreviation was not listed"
6161                        f" in the '__requirements__' subgraph."
6162                    )
6163                requirements = reqMap[reqAbbr]
6164            elif attr == "consequence":
6165                consequenceAbbr = aVal
6166                if consequenceAbbr not in reqMap:
6167                    raise DotParseError(
6168                        f"Edge from '{source}' to '{dest}' has"
6169                        f" consequence abbreviation"
6170                        f" '{consequenceAbbr}' but that"
6171                        f" abbreviation was not listed in the"
6172                        f" '__consequences__' subgraph."
6173                    )
6174                consequence = consequenceMap[consequenceAbbr]
6175            elif attr == "annotations":
6176                try:
6177                    annotations = fromJSON(aVal)
6178                except json.decoder.JSONDecodeError:
6179                    raise DotParseError(
6180                        f"Invalid JSON in edge annotations for"
6181                        f" edge from '{source}' to '{dest}':"
6182                        f"\n  {repr(aVal)}"
6183                    )
6184            else:
6185                raise DotParseError(
6186                    f"Unrecognized edge attribute '{attr}' for edge"
6187                    f" from '{source}' to '{dest}'"
6188                )
6189
6190        if label is None:
6191            raise DotParseError(
6192                f"Edge from '{source}' to '{dest}' is missing"
6193                f" a 'fullLabel' attribute."
6194            )
6195
6196        # Add the requested transition
6197        result.addTransition(
6198            source,
6199            label,
6200            dest,
6201            tags=tags,
6202            annotations=annotations,
6203            requires=requirements,  # None works here
6204            consequence=consequence  # None works here
6205        )
6206        # Either we're first or our reciprocal is, so this will only
6207        # trigger for one of the pair
6208        if reciprocal is not None:
6209            recipDest = result.getDestination(dest, reciprocal)
6210            if recipDest is None:
6211                recipChecks[(source, label)] = reciprocal
6212                # we'll get set as a reciprocal when that edge is
6213                # instantiated, we hope, but let's check that later
6214            elif recipDest != source:
6215                raise DotParseError(
6216                    f"Transition '{label}' from '{source}' to"
6217                    f" '{dest}' lists reciprocal '{reciprocal}'"
6218                    f" but that transition from '{dest}' goes to"
6219                    f" '{recipDest}', not '{source}'."
6220                )
6221            else:
6222                # At this point we know the reciprocal edge exists
6223                # and has the appropriate destination (our source).
6224                # No need to check for a pre-existing reciprocal as
6225                # this edge is newly created and cannot already have
6226                # a reciprocal assigned.
6227                result.setReciprocal(source, label, reciprocal)
6228
6229    # Double-check skipped reciprocals
6230    for ((source, transition), reciprocal) in recipChecks.items():
6231        actual = result.getReciprocal(source, transition)
6232        if actual != reciprocal:
6233            raise DotParseError(
6234                f"Transition '{transition}' from '{source}' was"
6235                f" expecting to have reciprocal '{reciprocal}' but"
6236                f" all edges have been processed and its reciprocal"
6237                f" is {repr(actual)}."
6238            )
6239
6240    # Finally get graph-level attribute values
6241    for (name, value) in graphStuff['attrs']:
6242        if name == "unknownCount":
6243            try:
6244                result.unknownCount = int(value)
6245            except ValueError:
6246                raise DotParseError(
6247                    f"Invalid 'unknownCount' value {repr(value)}."
6248                )
6249        elif name == "nextID":
6250            try:
6251                result.nextID = int(value)
6252            except ValueError:
6253                raise DotParseError(
6254                    f"Invalid 'nextID' value:"
6255                    f"\n  {repr(value)}"
6256                )
6257            collisionCourse = [x for x in result if x >= result.nextID]
6258            if len(collisionCourse) > 0:
6259                raise DotParseError(
6260                    f"Next ID {value} is wrong because the graph"
6261                    f" already contains one or more node(s) with"
6262                    f" ID(s) that is/are at least that large:"
6263                    f" {collisionCourse}"
6264                )
6265        elif name == "nextMechanismID":
6266            try:
6267                result.nextMechanismID = int(value)
6268            except ValueError:
6269                raise DotParseError(
6270                    f"Invalid 'nextMechanismID' value:"
6271                    f"\n  {repr(value)}"
6272                )
6273        elif name in (
6274            "equivalences",
6275            "reversionTypes",
6276            "mechanisms",
6277            "globalMechanisms",
6278            "nameLookup"
6279        ):
6280            try:
6281                setattr(result, name, fromJSON(value))
6282            except json.decoder.JSONDecodeError:
6283                raise DotParseError(
6284                    f"Invalid JSON in '{name}' attribute:"
6285                    f"\n  {repr(value)}"
6286                )
6287        else:
6288            raise DotParseError(
6289                f"Graph has unexpected attribute '{name}'."
6290            )
6291
6292    # Final check for mechanism ID value after both mechanism ID and
6293    # mechanisms dictionary have been parsed:
6294    leftBehind = [
6295        x
6296        for x in result.mechanisms
6297        if x >= result.nextMechanismID
6298    ]
6299    if len(leftBehind) > 0:
6300        raise DotParseError(
6301            f"Next mechanism ID {value} is wrong because"
6302            f" the graph already contains one or more"
6303            f" node(s) with ID(s) that is/are at least that"
6304            f" large: {leftBehind}"
6305        )
6306
6307    # And we're done!
6308    return result
6309
6310
6311def toDot(
6312    graph: core.DecisionGraph,
6313    clusterLevels: Union[str, List[int]] = [0]
6314) -> str:
6315    """
6316    Converts the decision graph into a "dot"-format string suitable
6317    for processing by `graphviz`.
6318
6319    See [the dot language
6320    specification](https://graphviz.org/doc/info/lang.html) for more
6321    detail on the syntax we convert to.
6322
6323    If `clusterLevels` is given, it should be either the string '*',
6324    or a list of integers. '*' means that all zone levels should be
6325    cluster-style subgraphs, while a list of integers specifies that
6326    zones at those levels should be cluster-style subgraphs. This
6327    will prefix the subgraph names with 'cluster_' instead of just
6328    '_'.
6329
6330    TODO: Check edge cases for quotes in capability names, tag names,
6331    transition names, annotations, etc.
6332
6333    TODO: At least colons not allowed in tag names!
6334
6335    TODO: Spaces in decision/transition names? Other special
6336    characters in those names?
6337    """
6338    # Set up result including unknownCount and nextID
6339    result = (
6340        f"digraph {{"
6341        f"\n  unknownCount={graph.unknownCount}"
6342        f"\n  nextID={graph.nextID}"
6343        f"\n  nextMechanismID={graph.nextMechanismID}"
6344        f"\n"
6345    )
6346
6347    # Dictionaries for using letters to substitute for unique
6348    # requirements/consequences found throughout the graph. Keys are
6349    # quoted requirement or consequence reprs, and values are
6350    # abbreviation strings for them.
6351    currentReqKey = utils.nextAbbrKey(None)
6352    currentEffectKey = utils.nextAbbrKey(None)
6353    reqKeys: Dict[str, str] = {}
6354    consequenceKeys: Dict[str, str] = {}
6355
6356    # Add all decision and transition info
6357    decision: base.DecisionID  # TODO: Fix Multidigraph type stubs
6358    for decision in graph.nodes:
6359        nodeInfo = graph.nodes[decision]
6360        tags = nodeInfo.get('tags', {})
6361        annotations = toJSON(nodeInfo.get('annotations', []))
6362        zones = nodeInfo.get('zones', set())
6363        nodeAttrs = f"\n    name={utils.quoted(nodeInfo['name'])}"
6364        immediateZones = [z for z in zones if graph.zoneHierarchyLevel(z) == 0]
6365        if len(immediateZones) > 0:
6366            useZone = sorted(immediateZones)[0]
6367            # TODO: Don't hardcode :: here?
6368            withZone = useZone + "::" + nodeInfo['name']
6369            nodeAttrs += f"\n    label={utils.quoted(withZone)}"
6370        else:
6371            nodeAttrs += f"\n    label={utils.quoted(nodeInfo['name'])}"
6372        for tag, value in tags.items():
6373            rep = utils.quoted(toJSON(value))
6374            nodeAttrs += f"\n    t_{tag}={rep}"
6375        for z in sorted(zones):
6376            nodeAttrs += f"\n    z_{z}=1"
6377        if annotations:
6378            nodeAttrs += '\n    annotations=' + utils.quoted(annotations)
6379
6380        result += f'\n  {decision} [{nodeAttrs}\n  ]'
6381
6382        for (transition, destination) in graph._byEdge[decision].items():
6383            edgeAttrs = (
6384                '\n      label='
6385              + utils.quoted(utils.abbr(transition))
6386            )
6387            edgeAttrs += (
6388                '\n      fullLabel='
6389              + utils.quoted(transition)
6390            )
6391            reciprocal = graph.getReciprocal(decision, transition)
6392            if reciprocal is not None:
6393                edgeAttrs += (
6394                    '\n      reciprocal='
6395                  + utils.quoted(reciprocal)
6396                )
6397            info = graph.edges[
6398                decision,  # type:ignore
6399                destination,
6400                transition
6401            ]
6402            if 'requirement' in info:
6403                # Get string rep for requirement
6404                rep = utils.quoted(info['requirement'].unparse())
6405                # Get assigned abbreviation or assign one
6406                if rep in reqKeys:
6407                    ab = reqKeys[rep]
6408                else:
6409                    ab = currentReqKey
6410                    reqKeys[rep] = ab
6411                    currentReqKey = utils.nextAbbrKey(currentReqKey)
6412                # Add abbreviation as edge attribute
6413                edgeAttrs += f'\n      req={ab}'
6414            if 'consequence' in info:
6415                # Get string representation of consequences
6416                rep = utils.quoted(
6417                    toJSON(info['consequence'])
6418                )
6419                # Get abbreviation for that or assign one:
6420                if rep in consequenceKeys:
6421                    ab = consequenceKeys[rep]
6422                else:
6423                    ab = currentEffectKey
6424                    consequenceKeys[rep] = ab
6425                    currentEffectKey = utils.nextAbbrKey(
6426                        currentEffectKey
6427                    )
6428                # Add abbreviation as an edge attribute
6429                edgeAttrs += f'\n      consequence={ab}'
6430            for (tag, value) in info["tags"].items():
6431                # Get string representation of tag value
6432                rep = utils.quoted(toJSON(value))
6433                # Add edge attribute for tag
6434                edgeAttrs += f'\n      t_{tag}={rep}'
6435            if 'annotations' in info:
6436                edgeAttrs += (
6437                    '\n      annotations='
6438                  + utils.quoted(toJSON(info['annotations']))
6439                )
6440            result += f'\n    {decision} -> {destination}'
6441            result += f' [{edgeAttrs}\n    ]'
6442
6443    # Add zone info as subgraph structure
6444    for z, zinfo in graph.zones.items():
6445        parents = utils.quoted(toJSON(sorted(zinfo.parents)))
6446        if clusterLevels == '*' or zinfo.level in clusterLevels:
6447            zName = "cluster_" + z
6448        else:
6449            zName = '_' + z
6450        zoneSubgraph = f'\n  subgraph {utils.quoted(zName)} {{'
6451        zoneSubgraph += f'\n    label={z}'
6452        zoneSubgraph += f'\n    level={zinfo.level}'
6453        zoneSubgraph += f'\n    parents={parents}'
6454        for decision in sorted(graph.allDecisionsInZone(z)):
6455            zoneSubgraph += f'\n    {decision}'
6456        zoneSubgraph += '\n  }'
6457        result += zoneSubgraph
6458
6459    # Add equivalences, mechanisms, etc.
6460    for attr in [
6461        "equivalences",
6462        "reversionTypes",
6463        "mechanisms",
6464        "globalMechanisms",
6465        "nameLookup"
6466    ]:
6467        aRep = utils.quoted(toJSON(getattr(graph, attr)))
6468        result += f'\n  {attr}={aRep}'
6469
6470    # Add legend subgraphs to represent abbreviations
6471    useID = graph.nextID
6472    if reqKeys:
6473        result += '\n  subgraph __requirements__ {'
6474        for rrepr, ab in reqKeys.items():
6475            nStr = utils.quoted(ab + ' = ' + rrepr)
6476            result += (
6477                f"\n    {useID} [ label={nStr} ]"
6478            )
6479            useID += 1
6480        result += '\n  }'
6481
6482    if consequenceKeys:
6483        result += '\n  subgraph __consequences__ {'
6484        for erepr, ab in consequenceKeys.items():
6485            nStr = utils.quoted(ab + ' = ' + erepr)
6486            result += (
6487                f"\n    {useID} [ label={nStr} ]"
6488            )
6489            useID += 1
6490        result += '\n  }'
6491
6492    if graph.mechanisms:
6493        result += '\n  subgraph __mechanisms__ {'
6494        mID: base.MechanismID
6495        mWhere: Optional[base.DecisionID]
6496        mName: base.MechanismName
6497        for (mID, (mWhere, mName)) in graph.mechanisms.items():
6498            qName = utils.quoted(mName)
6499            nStr = utils.quoted(f"{mID}@{mWhere}:{qName}")
6500            result += (
6501                f"\n    {useID} [ label={nStr} ]"
6502            )
6503            useID += 1
6504        result += '\n  }'
6505
6506    result += "\n}\n"
6507    return result
6508
6509
6510#------#
6511# JSON #
6512#------#
6513
6514T = TypeVar("T")
6515"Type var for `loadCustom`."
6516
6517
6518def loadCustom(stream: TextIO, loadAs: Type[T]) -> T:
6519    """
6520    Loads a new JSON-encodable object from the JSON data in the
6521    given text stream (e.g., a file open in read mode). See
6522    `CustomJSONDecoder` for details on the format and which object types
6523    are supported.
6524
6525    This casts the result to the specified type, but errors out with a
6526    `TypeError` if it doesn't match.
6527    """
6528    result = json.load(stream, cls=CustomJSONDecoder)
6529    if isinstance(result, loadAs):
6530        return result
6531    else:
6532        raise TypeError(
6533            f"Expected to load a {loadAs} but got a {type(result)}."
6534        )
6535
6536
6537def saveCustom(
6538    toSave: Union[  # TODO: More in this union?
6539        base.MetricSpace,
6540        core.DecisionGraph,
6541        core.DiscreteExploration,
6542    ],
6543    stream: TextIO
6544) -> None:
6545    """
6546    Saves a JSON-encodable object as JSON into the given text stream
6547    (e.g., a file open in writing mode). See `CustomJSONEncoder` for
6548    details on the format and which types are supported..
6549    """
6550    json.dump(toSave, stream, cls=CustomJSONEncoder)
6551
6552
6553def toJSON(obj: Any) -> str:
6554    """
6555    Defines the standard object -> JSON operation using the
6556    `CustomJSONEncoder` as well as not using `sort_keys`.
6557    """
6558    return CustomJSONEncoder(sort_keys=False).encode(obj)
6559
6560
6561def fromJSON(encoded: str) -> Any:
6562    """
6563    Defines the standard JSON -> object operation using
6564    `CustomJSONDecoder`.
6565    """
6566    return json.loads(encoded, cls=CustomJSONDecoder)
6567
6568
6569class CustomJSONEncoder(json.JSONEncoder):
6570    """
6571    A custom JSON encoder that has special protocols for handling the
6572    smae objects that `CustomJSONDecoder` decodes. It handles these
6573    objects specially so that they can be decoded back to their original
6574    form.
6575
6576    Examples:
6577
6578    >>> from . import core
6579    >>> tupList = [(1, 1), (2, 2)]
6580    >>> encTup = toJSON(tupList)
6581    >>> encTup
6582    '[{"^^d": "t", "values": [1, 1]}, {"^^d": "t", "values": [2, 2]}]'
6583    >>> fromJSON(encTup) == tupList
6584    True
6585    >>> dg = core.DecisionGraph.example('simple')
6586    >>> fromJSON(toJSON(dg)) == dg
6587    True
6588    >>> dg = core.DecisionGraph.example('abc')
6589    >>> zi = dg.getZoneInfo('upZone')
6590    >>> zi
6591    ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={},\
6592 annotations=[])
6593    >>> zj = toJSON(zi)
6594    >>> zj
6595    '{"^^d": "nt", "name": "ZoneInfo", "values":\
6596 {"level": 1, "parents": {"^^d": "s", "values": []},\
6597 "contents": {"^^d": "s", "values": ["zoneA"]}, "tags": {},\
6598 "annotations": []}}'
6599    >>> fromJSON(toJSON(zi))
6600    ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={},\
6601 annotations=[])
6602    >>> fromJSON(toJSON(zi)) == zi
6603    True
6604    >>> toJSON({'a': 'b', 1: 2})
6605    '{"^^d": "d", "items": [["a", "b"], [1, 2]]}'
6606    >>> toJSON(((1, 2), (3, 4)))
6607    '{"^^d": "t", "values": [{"^^d": "t", "values": [1, 2]},\
6608 {"^^d": "t", "values": [3, 4]}]}'
6609    >>> toJSON(base.effect(set=('grate', 'open')))
6610    '{"type": "set", "applyTo": "active",\
6611 "value": {"^^d": "t",\
6612 "values": [{"^^d": "nt", "name": "MechanismSpecifier",\
6613 "values": {"domain": null, "zone": null, "decision": null, "name": "grate"}},\
6614 "open"]}, "delay": null, "charges": null, "hidden": false}'
6615    >>> j = toJSON(dg)
6616    >>> expected = (
6617    ... '{"^^d": "DG",'
6618    ... ' "props": {},'
6619    ... ' "node_links": {"directed": true,'
6620    ... ' "multigraph": true,'
6621    ... ' "graph": {},'
6622    ... ' "nodes": ['
6623    ... '{"name": "A", "domain": "main", "tags": {},'
6624    ... ' "annotations": ["This is a multi-word \\\\"annotation.\\\\""],'
6625    ... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
6626    ... ' "mechanisms": {"grate": 0},'
6627    ... ' "id": 0'
6628    ... '},'
6629    ... ' {'
6630    ... '"name": "B",'
6631    ... ' "domain": "main",'
6632    ... ' "tags": {"b": 1, "tag2": "\\\\"value\\\\""},'
6633    ... ' "annotations": [],'
6634    ... ' "zones": {"^^d": "s", "values": ["zoneB"]},'
6635    ... ' "id": 1'
6636    ... '},'
6637    ... ' {'
6638    ... '"name": "C",'
6639    ... ' "domain": "main",'
6640    ... ' "tags": {"aw\\\\"ful": "ha\\'ha"},'
6641    ... ' "annotations": [],'
6642    ... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
6643    ... ' "id": 2'
6644    ... '}'
6645    ... '],'
6646    ... ' "links": ['
6647    ... '{'
6648    ... '"tags": {},'
6649    ... ' "annotations": [],'
6650    ... ' "reciprocal": "right",'
6651    ... ' "source": 0,'
6652    ... ' "target": 1,'
6653    ... ' "key": "left"'
6654    ... '},'
6655    ... ' {'
6656    ... '"tags": {},'
6657    ... ' "annotations": [],'
6658    ... ' "reciprocal": "up_right",'
6659    ... ' "requirement": {"^^d": "R", "value": "grate:open"},'
6660    ... ' "source": 0,'
6661    ... ' "target": 1,'
6662    ... ' "key": "up_left"'
6663    ... '},'
6664    ... ' {'
6665    ... '"tags": {},'
6666    ... ' "annotations": ["Transition \\'annotation.\\'"],'
6667    ... ' "reciprocal": "up",'
6668    ... ' "source": 0,'
6669    ... ' "target": 2,'
6670    ... ' "key": "down"'
6671    ... '},'
6672    ... ' {'
6673    ... '"tags": {},'
6674    ... ' "annotations": [],'
6675    ... ' "reciprocal": "left",'
6676    ... ' "source": 1,'
6677    ... ' "target": 0,'
6678    ... ' "key": "right"'
6679    ... '},'
6680    ... ' {'
6681    ... '"tags": {},'
6682    ... ' "annotations": [],'
6683    ... ' "reciprocal": "up_left",'
6684    ... ' "requirement": {"^^d": "R", "value": "grate:open"},'
6685    ... ' "source": 1,'
6686    ... ' "target": 0,'
6687    ... ' "key": "up_right"'
6688    ... '},'
6689    ... ' {'
6690    ... '"tags": {"fast": 1},'
6691    ... ' "annotations": [],'
6692    ... ' "reciprocal": "down",'
6693    ... ' "source": 2,'
6694    ... ' "target": 0,'
6695    ... ' "key": "up"'
6696    ... '},'
6697    ... ' {'
6698    ... '"tags": {},'
6699    ... ' "annotations": [],'
6700    ... ' "requirement": {"^^d": "R", "value": "!(helmet)"},'
6701    ... ' "consequence": ['
6702    ... '{'
6703    ... '"type": "gain", "applyTo": "active", "value": "helmet",'
6704    ... ' "delay": null, "charges": null, "hidden": false'
6705    ... '},'
6706    ... ' {'
6707    ... '"type": "deactivate",'
6708    ... ' "applyTo": "active", "value": null,'
6709    ... ' "delay": 3, "charges": null, "hidden": false'
6710    ... '}'
6711    ... '],'
6712    ... ' "source": 2,'
6713    ... ' "target": 2,'
6714    ... ' "key": "grab_helmet"'
6715    ... '},'
6716    ... ' {'
6717    ... '"tags": {},'
6718    ... ' "annotations": [],'
6719    ... ' "requirement": {"^^d": "R", "value": "helmet"},'
6720    ... ' "consequence": ['
6721    ... '{"type": "lose", "applyTo": "active", "value": "helmet",'
6722    ... ' "delay": null, "charges": null, "hidden": false},'
6723    ... ' {"type": "gain", "applyTo": "active",'
6724    ... ' "value": {"^^d": "t", "values": ["token", 1]},'
6725    ... ' "delay": null, "charges": null, "hidden": false'
6726    ... '},'
6727    ... ' {"condition":'
6728    ... ' {"^^d": "R", "value": "token*2"},'
6729    ... ' "consequence": ['
6730    ... '{"type": "set", "applyTo": "active",'
6731    ... ' "value": {"^^d": "t", "values": ['
6732    ... '{"^^d": "nt", "name": "MechanismSpecifier",'
6733    ... ' "values": {"domain": null, "zone": null, "decision": null,'
6734    ... ' "name": "grate"}}, "open"]},'
6735    ... ' "delay": null, "charges": null, "hidden": false'
6736    ... '},'
6737    ... ' {"type": "deactivate", "applyTo": "active", "value": null,'
6738    ... ' "delay": null, "charges": null, "hidden": false'
6739    ... '}'
6740    ... '],'
6741    ... ' "alternative": []'
6742    ... '}'
6743    ... '],'
6744    ... ' "source": 2,'
6745    ... ' "target": 2,'
6746    ... ' "key": "pull_lever"'
6747    ... '}'
6748    ... ']'
6749    ... '},'
6750    ... ' "_byEdge": {"^^d": "d", "items":'
6751    ... ' [[0, {"left": 1, "up_left": 1, "down": 2}],'
6752    ... ' [1, {"right": 0, "up_right": 0}],'
6753    ... ' [2, {"up": 0, "grab_helmet": 2, "pull_lever": 2}]]},'
6754    ... ' "zones": {"zoneA":'
6755    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6756    ... ' "values": {'
6757    ... '"level": 0,'
6758    ... ' "parents": {"^^d": "s", "values": ["upZone"]},'
6759    ... ' "contents": {"^^d": "s", "values": [0, 2]},'
6760    ... ' "tags": {},'
6761    ... ' "annotations": []'
6762    ... '}'
6763    ... '},'
6764    ... ' "zoneB":'
6765    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6766    ... ' "values": {'
6767    ... '"level": 0,'
6768    ... ' "parents": {"^^d": "s", "values": []},'
6769    ... ' "contents": {"^^d": "s", "values": [1]},'
6770    ... ' "tags": {},'
6771    ... ' "annotations": []'
6772    ... '}'
6773    ... '},'
6774    ... ' "upZone":'
6775    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6776    ... ' "values": {'
6777    ... '"level": 1,'
6778    ... ' "parents": {"^^d": "s", "values": []},'
6779    ... ' "contents": {"^^d": "s", "values": ["zoneA"]},'
6780    ... ' "tags": {},'
6781    ... ' "annotations": []'
6782    ... '}'
6783    ... '}'
6784    ... '},'
6785    ... ' "unknownCount": 0,'
6786    ... ' "equivalences": {"^^d": "d", "items": ['
6787    ... '[{"^^d": "t", "values": [0, "open"]},'
6788    ... ' {"^^d": "s", "values": ['
6789    ... '{"^^d": "R", "value": "helmet"}]}]'
6790    ... ']},'
6791    ... ' "reversionTypes": {},'
6792    ... ' "nextID": 3,'
6793    ... ' "nextMechanismID": 1,'
6794    ... ' "mechanisms": {"^^d": "d", "items": ['
6795    ... '[0, {"^^d": "t", "values": [0, "grate"]}]]},'
6796    ... ' "globalMechanisms": {},'
6797    ... ' "nameLookup": {"A": [0], "B": [1], "C": [2]}'
6798    ... '}'
6799    ... )
6800    >>> for i in range(len(j)):
6801    ...     if j[i] != expected[i:i+1]:
6802    ...         print(
6803    ...             'exp: ' + expected[i-10:i+50] + '\\ngot: ' + j[i-10:i+50]
6804    ...         )
6805    ...         break
6806    >>> j == expected
6807    True
6808    >>> rec = fromJSON(j)
6809    >>> rec.nodes == dg.nodes
6810    True
6811    >>> rec.edges == dg.edges
6812    True
6813    >>> rec.unknownCount == dg.unknownCount
6814    True
6815    >>> rec.equivalences == dg.equivalences
6816    True
6817    >>> rec.reversionTypes == dg.reversionTypes
6818    True
6819    >>> rec._byEdge == dg._byEdge
6820    True
6821    >>> rec.zones == dg.zones
6822    True
6823    >>> for diff in dg.listDifferences(rec):
6824    ...     print(diff)
6825    >>> rec == dg
6826    True
6827
6828    `base.MetricSpace` example:
6829
6830    >>> ms = base.MetricSpace("test")
6831    >>> ms.addPoint([2, 3])
6832    0
6833    >>> ms.addPoint([2, 7, 0])
6834    1
6835    >>> ms.addPoint([2, 7])
6836    2
6837    >>> toJSON(ms)
6838    '{"^^d": "MS", "name": "test",\
6839 "points": {"^^d": "d", "items": [[0, [2, 3]], [1, [2, 7,\
6840 0]], [2, [2, 7]]]}, "lastID": 2}'
6841    >>> ms.removePoint(0)
6842    >>> ms.removePoint(1)
6843    >>> ms.removePoint(2)
6844    >>> toJSON(ms)
6845    '{"^^d": "MS", "name": "test", "points": {}, "lastID": 2}'
6846    >>> ms.addPoint([5, 6])
6847    3
6848    >>> ms.addPoint([7, 8])
6849    4
6850    >>> toJSON(ms)
6851    '{"^^d": "MS", "name": "test",\
6852 "points": {"^^d": "d", "items": [[3, [5, 6]], [4, [7, 8]]]}, "lastID": 4}'
6853
6854    # TODO: more examples, including one for a DiscreteExploration
6855    """
6856
6857    def default(self, o: Any) -> Any:
6858        """
6859        Re-writes objects for encoding. We re-write the following
6860        objects:
6861
6862        - `set`
6863        - `dict` (if the keys aren't all strings)
6864        - `tuple`/`namedtuple`
6865        - `ZoneInfo`
6866        - `Requirement`
6867        - `SkillCombination`
6868        - `DecisionGraph`
6869        - `DiscreteExploration`
6870        - `MetricSpace`
6871
6872        TODO: FeatureGraph...
6873        """
6874        if isinstance(o, list):
6875            return [self.default(x) for x in o]
6876
6877        elif isinstance(o, set):
6878            return {
6879                '^^d': 's',
6880                'values': sorted(
6881                    [self.default(e) for e in o],
6882                    key=lambda x: str(x)
6883                )
6884            }
6885
6886        elif isinstance(o, dict):
6887            if all(isinstance(k, str) for k in o):
6888                return {
6889                    k: self.default(v)
6890                    for k, v in o.items()
6891                }
6892            else:
6893                return {
6894                    '^^d': 'd',
6895                    'items': [
6896                        [self.default(k), self.default(v)]
6897                        for (k, v) in o.items()
6898                    ]
6899                }
6900
6901        elif isinstance(o, tuple):
6902            if hasattr(o, '_fields') and hasattr(o, '_asdict'):
6903                # Named tuple
6904                return {
6905                    '^^d': 'nt',
6906                    'name': o.__class__.__name__,
6907                    'values': {
6908                        k: self.default(v)
6909                        for k, v in o._asdict().items()
6910                    }
6911                }
6912            else:
6913                # Normal tuple
6914                return {
6915                    '^^d': 't',
6916                    "values": [self.default(e) for e in o]
6917                }
6918
6919        elif isinstance(o, base.Requirement):
6920            return {
6921                '^^d': 'R',
6922                'value': o.unparse()
6923            }
6924
6925        elif isinstance(o, base.SkillCombination):
6926            return {
6927                '^^d': 'SC',
6928                'value': o.unparse()
6929            }
6930        # TODO: Consequence, Condition, Challenge, and Effect here?
6931
6932        elif isinstance(o, core.DecisionGraph):
6933            return {
6934                '^^d': 'DG',
6935                'props': self.default(o.graph),  # type:ignore [attr-defined]
6936                'node_links': self.default(
6937                    networkx.node_link_data(o, edges="links") # type: ignore
6938                    # TODO: Fix networkx stubs
6939                ),
6940                '_byEdge': self.default(o._byEdge),
6941                'zones': self.default(o.zones),
6942                'unknownCount': o.unknownCount,
6943                'equivalences': self.default(o.equivalences),
6944                'reversionTypes': self.default(o.reversionTypes),
6945                'nextID': o.nextID,
6946                'nextMechanismID': o.nextMechanismID,
6947                'mechanisms': self.default(o.mechanisms),
6948                'globalMechanisms': self.default(o.globalMechanisms),
6949                'nameLookup': self.default(o.nameLookup)
6950            }
6951
6952        elif isinstance(o, core.DiscreteExploration):
6953            return {
6954                '^^d': 'DE',
6955                'situations': self.default(o.situations)
6956            }
6957
6958        elif isinstance(o, base.MetricSpace):
6959            return {
6960                '^^d': 'MS',
6961                'name': o.name,
6962                'points': self.default(o.points),
6963                'lastID': o.lastID()
6964            }
6965
6966        else:
6967            return o
6968
6969    def encode(self, o: Any) -> str:
6970        """
6971        Custom encode function since we need to override behavior for
6972        tuples and dicts.
6973        """
6974        if isinstance(o, (tuple, dict, set)):
6975            o = self.default(o)
6976        elif isinstance(o, list):
6977            o = [self.default(x) for x in o]
6978
6979        try:
6980            return super().encode(o)
6981        except TypeError:
6982            return super().encode(self.default(o))
6983
6984    def iterencode(
6985        self,
6986        o: Any,
6987        _one_shot: bool = False
6988    ) -> Generator[str, None, None]:
6989        """
6990        Custom iterencode function since we need to override behavior for
6991        tuples and dicts.
6992        """
6993        if isinstance(o, (tuple, dict)):
6994            o = self.default(o)
6995
6996        yield from super().iterencode(o, _one_shot=_one_shot)
6997
6998
6999class CustomJSONDecoder(json.JSONDecoder):
7000    """
7001    A custom JSON decoder that has special protocols for handling
7002    several types, including:
7003
7004    - `set`
7005    - `tuple` & `namedtuple`
7006    - `dict` (where keys aren't all strings)
7007    - `Requirement`
7008    - `SkillCombination`
7009    - `DecisionGraph`
7010    - `DiscreteExploration`
7011    - `MetricSpace`
7012
7013    Used by `toJSON`
7014
7015    When initializing it, you can st a custom parse format by supplying
7016    a 'parseFormat' keyword argument; by default a standard
7017    `ParseFormat` will be used.
7018
7019    Examples:
7020
7021    >>> r = base.ReqAny([
7022    ...     base.ReqCapability('power'),
7023    ...     base.ReqTokens('money', 5)
7024    ... ])
7025    >>> s = toJSON(r)
7026    >>> s
7027    '{"^^d": "R", "value": "(power|money*5)"}'
7028    >>> l = fromJSON(s)
7029    >>> r == l
7030    True
7031    >>> o = {1, 2, 'hi'}
7032    >>> s = toJSON(o)
7033    >>> s
7034    '{"^^d": "s", "values": [1, 2, "hi"]}'
7035    >>> l = fromJSON(s)
7036    >>> o == l
7037    True
7038    >>> zi = base.ZoneInfo(1, set(), set(), {}, [])
7039    >>> s = toJSON(zi)
7040    >>> c = (
7041    ... '{"^^d": "nt", "name": "ZoneInfo", "values": {'
7042    ... '"level": 1,'
7043    ... ' "parents": {"^^d": "s", "values": []},'
7044    ... ' "contents": {"^^d": "s", "values": []},'
7045    ... ' "tags": {},'
7046    ... ' "annotations": []'
7047    ... '}}'
7048    ... )
7049    >>> s == c
7050    True
7051    >>> setm = base.effect(set=("door", "open"))
7052    >>> s = toJSON(setm)
7053    >>> f = fromJSON(s)
7054    >>> f == setm
7055    True
7056    >>> pf = ParseFormat()
7057    >>> pf.unparseEffect(f)
7058    'set door:open'
7059    >>> pf.unparseEffect(f) == pf.unparseEffect(setm)
7060    True
7061    >>> g = core.DecisionGraph()
7062    >>> g.addDecision('A')
7063    0
7064    >>> g.addDecision('B')
7065    1
7066    >>> g.addTransition('A', 'up', 'B', 'down')
7067    >>> g2 = fromJSON(toJSON(g))
7068    >>> g2.destinationsFrom('A')
7069    {'up': 1}
7070    >>> g2.destinationsFrom('B')
7071    {'down': 0}
7072    >>> g2.addDecision('C')
7073    2
7074    >>> g2.addTransition('A', 'right', 'C', 'left')
7075    >>> g2.destinationsFrom('A')
7076    {'up': 1, 'right': 2}
7077    >>> g2.destinationsFrom('C')
7078    {'left': 0}
7079
7080    TODO: SkillCombination example
7081    """
7082    def __init__(self, *args, **kwargs):
7083        if 'object_hook' in kwargs:
7084            outerHook = kwargs['object_hook']
7085            kwargs['object_hook'] = (
7086                lambda o: outerHook(self.unpack(o))
7087            )
7088            # TODO: What if it's a positional argument? :(
7089        else:
7090            kwargs['object_hook'] = lambda o: self.unpack(o)
7091
7092        if 'parseFormat' in kwargs:
7093            self.parseFormat = kwargs['parseFormat']
7094            del kwargs['parseFormat']
7095        else:
7096            self.parseFormat = ParseFormat()
7097
7098        super().__init__(*args, **kwargs)
7099
7100    def unpack(self, obj: Any) -> Any:
7101        """
7102        Unpacks an object; used as the `object_hook` for decoding.
7103        """
7104        if '^^d' in obj:
7105            asType = obj['^^d']
7106            if asType == 't':
7107                return tuple(obj['values'])
7108
7109            elif asType == 'nt':
7110                g = globals()
7111                name = obj['name']
7112                values = obj['values']
7113                # Use an existing global namedtuple class if there is
7114                # one that goes by the specified name, so that we don't
7115                # create too many spurious equivalent namedtuple
7116                # classes. But fall back on creating a new namedtuple
7117                # class if we need to:
7118                ntClass = g.get(name)
7119                if (
7120                    ntClass is None
7121                 or not issubclass(ntClass, tuple)
7122                 or not hasattr(ntClass, '_asdict')
7123                ):
7124                    # Now try again specifically in the base module where
7125                    # most of our nametuples are defined (TODO: NOT this
7126                    # hack..., but it does make isinstance work...)
7127                    ntClass = getattr(base, name, None)
7128                    if (
7129                        ntClass is None
7130                     or not issubclass(ntClass, tuple)
7131                     or not hasattr(ntClass, '_asdict')
7132                    ):
7133                        # TODO: cache these...
7134                        ntClass = collections.namedtuple(  # type: ignore
7135                            name,
7136                            values.keys()
7137                        )
7138                ntClass = cast(Callable, ntClass)
7139                return ntClass(**values)
7140
7141            elif asType == 's':
7142                return set(obj['values'])
7143
7144            elif asType == 'd':
7145                return dict(obj['items'])
7146
7147            elif asType == 'R':
7148                return self.parseFormat.parseRequirement(obj['value'])
7149
7150            elif asType == 'SC':
7151                return self.parseFormat.parseSkillCombination(obj['value'])
7152
7153            elif asType == 'E':
7154                return self.parseFormat.parseEffect(obj['value'])
7155
7156            elif asType == 'Ch':
7157                return self.parseFormat.parseChallenge(obj['value'])
7158
7159            elif asType == 'Cd':
7160                return self.parseFormat.parseCondition(obj['value'])
7161
7162            elif asType == 'Cq':
7163                return self.parseFormat.parseConsequence(obj['value'])
7164
7165            elif asType == 'DG':
7166                baseGraph: networkx.MultiDiGraph = networkx.node_link_graph(
7167                    obj['node_links'],
7168                    edges="links"
7169                )  # type: ignore
7170                # TODO: Fix networkx stubs
7171                graphResult = core.DecisionGraph()
7172                # Copy over instance attributes minus internals
7173                # TODO: Do we need internals?
7174                for (attr, val) in baseGraph.__dict__.items():
7175                    # Note: __dict__ over dir() here to avoid attributes
7176                    # of type and get just attributes of instance
7177                    if attr == "name":  # name will get copied below
7178                        continue
7179                    if not attr.startswith('__') or not attr.endswith('__'):
7180                        setattr(
7181                            graphResult,
7182                            attr,
7183                            copy.deepcopy(val)
7184                            # TODO: Does this copying disentangle too
7185                            # much? Which values even get copied this
7186                            # way?
7187                        )
7188
7189                if baseGraph.name != '':
7190                    graphResult.name = baseGraph.name
7191                graphResult.graph.update(obj['props'])  # type:ignore [attr-defined]  # noqa
7192                storedByEdge = obj['_byEdge']
7193                graphResult._byEdge = {
7194                    int(k): storedByEdge[k]
7195                    for k in storedByEdge
7196                }
7197                graphResult.zones = obj['zones']
7198                graphResult.unknownCount = obj['unknownCount']
7199                graphResult.equivalences = obj['equivalences']
7200                graphResult.reversionTypes = obj['reversionTypes']
7201                graphResult.nextID = obj.get('nextID')
7202                # Old code didn't store nextID; we extrapolate if necessary
7203                if graphResult.nextID is None:
7204                    graphResult.nextID = max(graphResult.nodes) + 1
7205                graphResult.nextMechanismID = obj['nextMechanismID']
7206                graphResult.mechanisms = {
7207                    int(k): v
7208                    for k, v in
7209                    obj['mechanisms'].items()
7210                }
7211                graphResult.globalMechanisms = obj['globalMechanisms']
7212                graphResult.nameLookup = obj['nameLookup']
7213                return graphResult
7214
7215            elif asType == 'DE':
7216                exResult = core.DiscreteExploration()
7217                exResult.situations = obj['situations']
7218                return exResult
7219
7220            elif asType == 'MS':
7221                msResult = base.MetricSpace(obj['name'])
7222                msResult.points = obj['points']
7223                msResult.nextID = obj['lastID'] + 1
7224                return msResult
7225
7226            else:
7227                raise NotImplementedError(
7228                    f"No special handling has been defined for"
7229                    f" decoding type '{asType}'."
7230                )
7231
7232        else:
7233            return obj
class Lexeme(enum.IntEnum):

These are the different separators, grouping characters, and keywords used as part of parsing. The characters that are actually recognized are defined as part of a Format.

domainSeparator = <Lexeme.domainSeparator: 1>
zoneSeparator = <Lexeme.zoneSeparator: 2>
partSeparator = <Lexeme.partSeparator: 3>
stateOn = <Lexeme.stateOn: 4>
stateOff = <Lexeme.stateOff: 5>
tokenCount = <Lexeme.tokenCount: 6>
effectCharges = <Lexeme.effectCharges: 7>
sepOrDelay = <Lexeme.sepOrDelay: 8>
consequenceSeparator = <Lexeme.consequenceSeparator: 9>
inCommon = <Lexeme.inCommon: 10>
isHidden = <Lexeme.isHidden: 11>
skillLevel = <Lexeme.skillLevel: 12>
wigglyLine = <Lexeme.wigglyLine: 13>
withDetails = <Lexeme.withDetails: 14>
reciprocalSeparator = <Lexeme.reciprocalSeparator: 15>
mechanismSeparator = <Lexeme.mechanismSeparator: 16>
openCurly = <Lexeme.openCurly: 17>
closeCurly = <Lexeme.closeCurly: 18>
openParen = <Lexeme.openParen: 19>
closeParen = <Lexeme.closeParen: 20>
angleLeft = <Lexeme.angleLeft: 21>
angleRight = <Lexeme.angleRight: 22>
doubleQuestionmark = <Lexeme.doubleQuestionmark: 23>
ampersand = <Lexeme.ampersand: 24>
orBar = <Lexeme.orBar: 25>
notMarker = <Lexeme.notMarker: 26>
Inherited Members
enum.Enum
name
value
builtins.int
conjugate
bit_length
bit_count
to_bytes
from_bytes
as_integer_ratio
real
imag
numerator
denominator
Format = typing.Dict[Lexeme, str]

A journal format is specified using a dictionary with keys that denote journal marker types and values which are one-to-several-character strings indicating the markup used for that entry/info type.

DEFAULT_FORMAT: Dict[Lexeme, str] = {<Lexeme.domainSeparator: 1>: '//', <Lexeme.zoneSeparator: 2>: '::', <Lexeme.partSeparator: 3>: '%%', <Lexeme.stateOn: 4>: '=on', <Lexeme.stateOff: 5>: '=off', <Lexeme.tokenCount: 6>: '*', <Lexeme.effectCharges: 7>: '=', <Lexeme.sepOrDelay: 8>: ',', <Lexeme.consequenceSeparator: 9>: ';', <Lexeme.inCommon: 10>: '+c', <Lexeme.isHidden: 11>: '+h', <Lexeme.skillLevel: 12>: '^', <Lexeme.wigglyLine: 13>: '~', <Lexeme.withDetails: 14>: '%', <Lexeme.reciprocalSeparator: 15>: '/', <Lexeme.mechanismSeparator: 16>: ':', <Lexeme.openCurly: 17>: '{', <Lexeme.closeCurly: 18>: '}', <Lexeme.openParen: 19>: '(', <Lexeme.closeParen: 20>: ')', <Lexeme.angleLeft: 21>: '<', <Lexeme.angleRight: 22>: '>', <Lexeme.doubleQuestionmark: 23>: '??', <Lexeme.ampersand: 24>: '&', <Lexeme.orBar: 25>: '|', <Lexeme.notMarker: 26>: '!'}

The default parsing format.

DEFAULT_EFFECT_NAMES: Dict[str, Literal['gain', 'lose', 'set', 'toggle', 'deactivate', 'edit', 'goto', 'bounce', 'follow', 'save']] = {'gain': 'gain', 'lose': 'lose', 'set': 'set', 'toggle': 'toggle', 'deactivate': 'deactivate', 'edit': 'edit', 'goto': 'goto', 'bounce': 'bounce', 'follow': 'follow', 'save': 'save'}

Default names for each effect type. Maps names to canonical effect type strings. A different mapping could be used to allow for writing effect names in another language, for example.

DEFAULT_FOCALIZATION_NAMES: Dict[str, Literal['singular', 'plural', 'spreading']] = {'singular': 'singular', 'plural': 'plural', 'spreading': 'spreading'}

Default names for each domain focalization type. Maps each focalization type string to itself.

DEFAULT_SF_INDICATORS: Tuple[str, str] = ('s', 'f')

Default characters used to indicate success/failure when transcribing a TransitionWithOutcomes.

class ParseWarning(builtins.Warning):
140class ParseWarning(Warning):
141    """
142    Represents a warning encountered when parsing something.
143    """
144    pass

Represents a warning encountered when parsing something.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class ParseError(builtins.ValueError):
147class ParseError(ValueError):
148    """
149    Represents a error encountered when parsing.
150    """
151    pass

Represents a error encountered when parsing.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DotParseError(ParseError):
154class DotParseError(ParseError):
155    """
156    An error raised during parsing when incorrectly-formatted graphviz
157    "dot" data is provided. See `parseDot`.
158    """
159    pass

An error raised during parsing when incorrectly-formatted graphviz "dot" data is provided. See parseDot.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InvalidFeatureSpecifierError(ParseError):
162class InvalidFeatureSpecifierError(ParseError):
163    """
164    An error used when a feature specifier is in the wrong format.
165    Errors with part specifiers also use this.
166    """

An error used when a feature specifier is in the wrong format. Errors with part specifiers also use this.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
LexedTokens: TypeAlias = List[Union[Lexeme, str]]

When lexing, we pull apart a string into pieces, but when we recognize lexemes, we use their integer IDs in the list instead of strings, so we get a list that's a mix of ints and strings.

GroupedTokens: TypeAlias = List[Union[Lexeme, str, ForwardRef('GroupedTokens')]]

Some parsing processes group tokens into sub-lists. This type represents LexedTokens which might also contain sub-lists, to arbitrary depth.

GroupedRequirementParts: TypeAlias = List[Union[Lexeme, exploration.base.Requirement, ForwardRef('GroupedRequirementParts')]]

Another intermediate parsing result during requirement parsing: a list of base.Requirements possibly with some sub-lists and/or Lexemes mixed in.

def lex( characters: str, tokenMap: Optional[Dict[str, Lexeme]] = None) -> List[Union[Lexeme, str]]:
196def lex(
197    characters: str,
198    tokenMap: Optional[Dict[str, Lexeme]] = None
199) -> LexedTokens:
200    """
201    Lexes a list of tokens from a characters string. Recognizes any
202    special characters you provide in the token map, as well as
203    collections of non-mapped characters. Recognizes double-quoted
204    strings which can contain any of those (and which use
205    backslash-escapes for internal double quotes) and includes quoted
206    versions of those strings as tokens (any token string starting with a
207    double quote will be such a string). Breaks tokens on whitespace
208    outside of quotation marks, and ignores that whitespace.
209
210    Examples:
211
212    >>> lex('abc')
213    ['abc']
214    >>> lex('(abc)', {'(': 0, ')': 1})
215    [0, 'abc', 1]
216    >>> lex('{(abc)}', {'(': 0, ')': 1, '{': 2, '}': 3})
217    [2, 0, 'abc', 1, 3]
218    >>> lex('abc def')
219    ['abc', 'def']
220    >>> lex('abc   def')
221    ['abc', 'def']
222    >>> lex('abc \\n def')
223    ['abc', 'def']
224    >>> lex ('"quoted"')
225    ['"quoted"']
226    >>> lex ('"quoted  pair"')
227    ['"quoted  pair"']
228    >>> lex ('  oneWord | "two words"|"three  words words" ', {'|': 0})
229    ['oneWord', 0, '"two words"', 0, '"three  words words"']
230    >>> tokenMap = { c: i for (i, c) in enumerate("(){}~:;>,") }
231    >>> tokenMap['::'] = 9
232    >>> tokenMap['~~'] = 10
233    >>> lex(
234    ...     '{~~2:best(brains, brawn)>{set switch on}'
235    ...     '{deactivate ,1; bounce}}',
236    ...     tokenMap
237    ... )
238    [2, 10, '2', 5, 'best', 0, 'brains', 8, 'brawn', 1, 7, 2, 'set',\
239 'switch', 'on', 3, 2, 'deactivate', 8, '1', 6, 'bounce', 3, 3]
240    >>> lex('set where::mechanism state', tokenMap)
241    ['set', 'where', 9, 'mechanism', 'state']
242    >>> # Note r' doesn't take full effect 'cause we're in triple quotes
243    >>> esc = r'"escape \\\\a"'
244    >>> result = [ r'"escape \\\\a"' ]  # 'quoted' doubles the backslash
245    >>> len(esc)
246    12
247    >>> len(result[0])
248    12
249    >>> lex(esc) == result
250    True
251    >>> quoteInQuote = r'before "hello \\\\ \\" goodbye"after'
252    >>> # Note r' doesn't take full effect 'cause we're in triple quotes
253    >>> expect = ['before', r'"hello \\\\ \\" goodbye"', 'after']
254    >>> lex(quoteInQuote) == expect
255    True
256    >>> lex('O\\'Neill')
257    ["O'Neill"]
258    >>> lex('one "quote ')
259    ['one', '"quote "']
260    >>> lex('geo*15', {'*': 0})
261    ['geo', 0, '15']
262    """
263    if tokenMap is None:
264        tokenMap = {}
265    tokenStarts: Dict[str, List[str]] = {}
266    for key in sorted(tokenMap.keys(), key=lambda x: -len(x)):
267        tokenStarts.setdefault(key[:1], []).append(key)
268    tokens: LexedTokens = []
269    sofar = ''
270    inQuote = False
271    escaped = False
272    skip = 0
273    for i in range(len(characters)):
274        if skip > 0:
275            skip -= 1
276            continue
277
278        char = characters[i]
279        if escaped:
280            # TODO: Escape sequences?
281            sofar += char
282            escaped = False
283
284        elif char == '\\':
285            if inQuote:
286                escaped = True
287            else:
288                sofar += char
289
290        elif char == '"':
291            if sofar != '':
292                if inQuote:
293                    tokens.append(utils.quoted(sofar))
294                else:
295                    tokens.append(sofar)
296            sofar = ''
297            inQuote = not inQuote
298
299        elif inQuote:
300            sofar += char
301
302        elif char in tokenStarts:
303            options = tokenStarts[char]
304            hit: Optional[str] = None
305            for possibility in options:
306                lp = len(possibility)
307                if (
308                    (lp == 1 and char == possibility)
309                or characters[i:i + lp] == possibility
310                ):
311                    hit = possibility
312                    break
313
314            if hit is not None:
315                if sofar != '':
316                    tokens.append(sofar)
317                tokens.append(tokenMap[possibility])
318                sofar = ''
319                skip = len(hit) - 1
320            else:  # Not actually a recognized token
321                sofar += char
322
323        elif char.isspace():
324            if sofar != '':
325                tokens.append(sofar)
326            sofar = ''
327
328        else:
329            sofar += char
330
331    if sofar != '':
332        if inQuote:
333            tokens.append(utils.quoted(sofar))
334        else:
335            tokens.append(sofar)
336
337    return tokens

Lexes a list of tokens from a characters string. Recognizes any special characters you provide in the token map, as well as collections of non-mapped characters. Recognizes double-quoted strings which can contain any of those (and which use backslash-escapes for internal double quotes) and includes quoted versions of those strings as tokens (any token string starting with a double quote will be such a string). Breaks tokens on whitespace outside of quotation marks, and ignores that whitespace.

Examples:

>>> lex('abc')
['abc']
>>> lex('(abc)', {'(': 0, ')': 1})
[0, 'abc', 1]
>>> lex('{(abc)}', {'(': 0, ')': 1, '{': 2, '}': 3})
[2, 0, 'abc', 1, 3]
>>> lex('abc def')
['abc', 'def']
>>> lex('abc   def')
['abc', 'def']
>>> lex('abc \n def')
['abc', 'def']
>>> lex ('"quoted"')
['"quoted"']
>>> lex ('"quoted  pair"')
['"quoted  pair"']
>>> lex ('  oneWord | "two words"|"three  words words" ', {'|': 0})
['oneWord', 0, '"two words"', 0, '"three  words words"']
>>> tokenMap = { c: i for (i, c) in enumerate("(){}~:;>,") }
>>> tokenMap['::'] = 9
>>> tokenMap['~~'] = 10
>>> lex(
...     '{~~2:best(brains, brawn)>{set switch on}'
...     '{deactivate ,1; bounce}}',
...     tokenMap
... )
[2, 10, '2', 5, 'best', 0, 'brains', 8, 'brawn', 1, 7, 2, 'set', 'switch', 'on', 3, 2, 'deactivate', 8, '1', 6, 'bounce', 3, 3]
>>> lex('set where::mechanism state', tokenMap)
['set', 'where', 9, 'mechanism', 'state']
>>> # Note r' doesn't take full effect 'cause we're in triple quotes
>>> esc = r'"escape \\a"'
>>> result = [ r'"escape \\a"' ]  # 'quoted' doubles the backslash
>>> len(esc)
12
>>> len(result[0])
12
>>> lex(esc) == result
True
>>> quoteInQuote = r'before "hello \\ \" goodbye"after'
>>> # Note r' doesn't take full effect 'cause we're in triple quotes
>>> expect = ['before', r'"hello \\ \" goodbye"', 'after']
>>> lex(quoteInQuote) == expect
True
>>> lex('O\'Neill')
["O'Neill"]
>>> lex('one "quote ')
['one', '"quote "']
>>> lex('geo*15', {'*': 0})
['geo', 0, '15']
def unLex( tokens: List[Union[Lexeme, str]], tokenMap: Optional[Dict[str, Lexeme]] = None) -> str:
340def unLex(
341    tokens: LexedTokens,
342    tokenMap: Optional[Dict[str, Lexeme]] = None
343) -> str:
344    """
345    Turns lexed stuff back into a string, substituting strings back into
346    token spots by reversing the given token map. Adds quotation marks to
347    complex tokens where necessary to prevent them from re-lexing into
348    multiple tokens (but `lex` doesn't  remove those, so in some cases
349    there's not a perfect round-trip unLex -> lex).
350
351    For example:
352
353    >>> unLex(['a', 'b'])
354    'a b'
355    >>> tokens = {'(': 0, ')': 1, '{': 2, '}': 3, '::': 4}
356    >>> unLex([0, 'hi', 1], tokens)
357    '(hi)'
358    >>> unLex([0, 'visit', 'zone', 4, 'decision', 1], tokens)
359    '(visit zone::decision)'
360    >>> q = unLex(['a complex token', '\\'single\\' and "double" quotes'])
361    >>> q  # unLex adds quotes
362    '"a complex token" "\\'single\\' and \\\\"double\\\\" quotes"'
363    >>> lex(q)  # Not the same as the original list
364    ['"a complex token"', '"\\'single\\' and \\\\"double\\\\" quotes"']
365    >>> lex(unLex(lex(q)))  # But further round-trips work
366    ['"a complex token"', '"\\'single\\' and \\\\"double\\\\" quotes"']
367
368    TODO: Fix this:
369    For now, it generates incorrect results when token combinations can
370    be ambiguous. These ambiguous token combinations should not ever be
371    generated by `lex` at least. For example:
372
373    >>> ambiguous = {':': 0, '::': 1}
374    >>> u = unLex(['a', 0, 0, 'b'], ambiguous)
375    >>> u
376    'a::b'
377    >>> l = lex(u, ambiguous)
378    >>> l
379    ['a', 1, 'b']
380    >>> l == u
381    False
382    """
383    if tokenMap is None:
384        nTokens = 0
385        revMap = {}
386    else:
387        nTokens = len(tokenMap)
388        revMap = {y: x for (x, y) in tokenMap.items()}
389
390    prevRaw = False
391    # TODO: add spaces where necessary to disambiguate token sequences...
392    if len(revMap) != nTokens:
393        warnings.warn(
394            (
395                "Irreversible token map! Two or more tokens have the same"
396                " integer value."
397            ),
398            ParseWarning
399        )
400
401    result = ""
402    for item in tokens:
403        if isinstance(item, int):
404            try:
405                result += revMap[item]
406            except KeyError:
407                raise ValueError(
408                    f"Tokens list contains {item} but the token map"
409                    f" does not have any entry which maps to {item}."
410                )
411            prevRaw = False
412        elif isinstance(item, str):
413            if prevRaw:
414                result += ' '
415            if len(lex(item)) > 1:
416                result += utils.quoted(item)
417            else:
418                result += item
419            prevRaw = True
420        else:
421            raise TypeError(
422                f"Token list contained non-int non-str item:"
423                f" {repr(item)}"
424            )
425
426    return result

Turns lexed stuff back into a string, substituting strings back into token spots by reversing the given token map. Adds quotation marks to complex tokens where necessary to prevent them from re-lexing into multiple tokens (but lex doesn't remove those, so in some cases there's not a perfect round-trip unLex -> lex).

For example:

>>> unLex(['a', 'b'])
'a b'
>>> tokens = {'(': 0, ')': 1, '{': 2, '}': 3, '::': 4}
>>> unLex([0, 'hi', 1], tokens)
'(hi)'
>>> unLex([0, 'visit', 'zone', 4, 'decision', 1], tokens)
'(visit zone::decision)'
>>> q = unLex(['a complex token', '\'single\' and "double" quotes'])
>>> q  # unLex adds quotes
'"a complex token" "\'single\' and \\"double\\" quotes"'
>>> lex(q)  # Not the same as the original list
['"a complex token"', '"\'single\' and \\"double\\" quotes"']
>>> lex(unLex(lex(q)))  # But further round-trips work
['"a complex token"', '"\'single\' and \\"double\\" quotes"']

TODO: Fix this: For now, it generates incorrect results when token combinations can be ambiguous. These ambiguous token combinations should not ever be generated by lex at least. For example:

>>> ambiguous = {':': 0, '::': 1}
>>> u = unLex(['a', 0, 0, 'b'], ambiguous)
>>> u
'a::b'
>>> l = lex(u, ambiguous)
>>> l
['a', 1, 'b']
>>> l == u
False
def normalizeEnds(tokens: List, start: int, end: int) -> Tuple[int, int, int]:
433def normalizeEnds(
434    tokens: List,
435    start: int,
436    end: int
437) -> Tuple[int, int, int]:
438    """
439    Given a tokens list and start & end integers, does some bounds
440    checking and normalization on the integers: converts negative
441    indices to positive indices, and raises an `IndexError` if they're
442    out-of-bounds after conversion. Returns a tuple containing the
443    normalized start & end indices, along with the number of tokens they
444    cover.
445    """
446    totalTokens = len(tokens)
447    if start < -len(tokens):
448        raise IndexError(
449            f"Negative start index out of bounds (got {start} for"
450            f" {totalTokens} tokens)."
451        )
452    elif start >= totalTokens:
453        raise IndexError(
454            f"Start index out of bounds (got {start} for"
455            f" {totalTokens} tokens)."
456        )
457    elif start < 0:
458        start = totalTokens + start
459
460    if end < -len(tokens):
461        raise IndexError(
462            f"Negative end index out of bounds (got {end} for"
463            f" {totalTokens} tokens)."
464        )
465    elif end >= totalTokens:
466        raise IndexError(
467            f"Start index out of bounds (got {end} for"
468            f" {totalTokens} tokens)."
469        )
470    elif end < 0:
471        end = totalTokens + end
472
473    if end >= len(tokens):
474        end = len(tokens) - 1
475
476    return (start, end, (end - start) + 1)

Given a tokens list and start & end integers, does some bounds checking and normalization on the integers: converts negative indices to positive indices, and raises an IndexError if they're out-of-bounds after conversion. Returns a tuple containing the normalized start & end indices, along with the number of tokens they cover.

def findSeparatedParts( tokens: List[Union[Lexeme, str]], sep: Union[str, int], start: int = 0, end: int = -1, groupStart: Union[str, int, NoneType] = None, groupEnd: Union[str, int, NoneType] = None) -> Generator[Tuple[int, int], NoneType, NoneType]:
479def findSeparatedParts(
480    tokens: LexedTokens,
481    sep: Union[str, int],
482    start: int = 0,
483    end: int = -1,
484    groupStart: Union[str, int, None] = None,
485    groupEnd: Union[str, int, None] = None
486) -> Generator[Tuple[int, int], None, None]:
487    """
488    Finds parts separated by a separator lexeme, such as ';' or ',', but
489    ignoring separators nested within groupStart/groupEnd pairs (if
490    those arguments are supplied). For each token sequence found, yields
491    a tuple containing the start index and end index for that part, with
492    separators not included in the parts.
493
494    If two separators appear in a row, the start/end pair will have a
495    start index one after the end index.
496
497    If there are no separators, yields one pair containing the start and
498    end of the entire tokens sequence.
499
500    Raises a `ParseError` if there are unbalanced grouping elements.
501
502    For example:
503
504    >>> list(findSeparatedParts(
505    ...     [ 'one' ],
506    ...     Lexeme.sepOrDelay,
507    ...     0,
508    ...     0,
509    ...     Lexeme.openParen,
510    ...     Lexeme.closeParen
511    ... ))
512    [(0, 0)]
513    >>> list(findSeparatedParts(
514    ...     [
515    ...         'best',
516    ...         Lexeme.openParen,
517    ...         'chess',
518    ...         Lexeme.sepOrDelay,
519    ...         'checkers',
520    ...         Lexeme.closeParen
521    ...     ],
522    ...     Lexeme.sepOrDelay,
523    ...     2,
524    ...     4,
525    ...     Lexeme.openParen,
526    ...     Lexeme.closeParen
527    ... ))
528    [(2, 2), (4, 4)]
529    """
530    start, end, n = normalizeEnds(tokens, start, end)
531    level = 0
532    thisStart = start
533    for i in range(start, end + 1):
534        token = tokens[i]
535        if token == sep and level == 0:
536            yield (thisStart, i - 1)
537            thisStart = i + 1
538        elif token == groupStart:
539            level += 1
540        elif token == groupEnd:
541            level -= 1
542            if level < 0:
543                raise ParseError("Unbalanced grouping tokens.")
544    if level < 0:
545        raise ParseError("Unbalanced grouping tokens.")
546    yield (thisStart, end)

Finds parts separated by a separator lexeme, such as ';' or ',', but ignoring separators nested within groupStart/groupEnd pairs (if those arguments are supplied). For each token sequence found, yields a tuple containing the start index and end index for that part, with separators not included in the parts.

If two separators appear in a row, the start/end pair will have a start index one after the end index.

If there are no separators, yields one pair containing the start and end of the entire tokens sequence.

Raises a ParseError if there are unbalanced grouping elements.

For example:

>>> list(findSeparatedParts(
...     [ 'one' ],
...     Lexeme.sepOrDelay,
...     0,
...     0,
...     Lexeme.openParen,
...     Lexeme.closeParen
... ))
[(0, 0)]
>>> list(findSeparatedParts(
...     [
...         'best',
...         Lexeme.openParen,
...         'chess',
...         Lexeme.sepOrDelay,
...         'checkers',
...         Lexeme.closeParen
...     ],
...     Lexeme.sepOrDelay,
...     2,
...     4,
...     Lexeme.openParen,
...     Lexeme.closeParen
... ))
[(2, 2), (4, 4)]
K = ~K

Type variable for dictionary keys.

V = ~V

Type variable for dictionary values.

def checkCompleteness( name, mapping: Dict[~K, ~V], keysSet: Optional[Set[~K]] = None, valuesSet: Optional[Set[~V]] = None):
555def checkCompleteness(
556    name,
557    mapping: Dict[K, V],
558    keysSet: Optional[Set[K]] = None,
559    valuesSet: Optional[Set[V]] = None
560):
561    """
562    Checks that a dictionary has a certain exact set of keys (or
563    values). Raises a `ValueError` if it finds an extra or missing key
564    or value.
565    """
566    if keysSet is not None:
567        for key in mapping.keys():
568            if key not in keysSet:
569                raise ValueError("{name} has extra key {repr(key)}.")
570
571        for key in keysSet:
572            if key not in mapping:
573                raise ValueError("{name} is missing key {repr(key)}.")
574
575    if valuesSet is not None:
576        for value in mapping.values():
577            if value not in valuesSet:
578                raise ValueError("{name} has extra value {repr(value)}.")
579
580        checkVals = mapping.values()
581        for value in valuesSet:
582            if value not in checkVals:
583                raise ValueError("{name} is missing value {repr(value)}.")

Checks that a dictionary has a certain exact set of keys (or values). Raises a ValueError if it finds an extra or missing key or value.

class ParseFormat:
 586class ParseFormat:
 587    """
 588    A ParseFormat manages the mapping from markers to entry types and
 589    vice versa.
 590    """
 591    def __init__(
 592        self,
 593        formatDict: Format = DEFAULT_FORMAT,
 594        effectNames: Dict[str, base.EffectType] = DEFAULT_EFFECT_NAMES,
 595        focalizationNames: Dict[
 596            str,
 597            base.DomainFocalization
 598        ] = DEFAULT_FOCALIZATION_NAMES,
 599        successFailureIndicators: Tuple[str, str] = DEFAULT_SF_INDICATORS
 600    ):
 601        """
 602        Sets up the parsing format. Requires a `Format` dictionary to
 603        define the specifics. Raises a `ValueError` unless the keys of
 604        the `Format` dictionary exactly match the `Lexeme` values.
 605        """
 606        self.formatDict = formatDict
 607        self.effectNames = effectNames
 608        self.focalizationNames = focalizationNames
 609        if (
 610            len(successFailureIndicators) != 2
 611        or any(len(i) != 1 for i in successFailureIndicators)
 612        ):
 613            raise ValueError(
 614                f"Invalid success/failure indicators: must be a pair of"
 615                f" length-1 strings. Got: {successFailureIndicators!r}"
 616            )
 617        self.successIndicator, self.failureIndicator = (
 618            successFailureIndicators
 619        )
 620
 621        # Check completeness for each dictionary
 622        checkCompleteness('formatDict', self.formatDict, set(Lexeme))
 623        checkCompleteness(
 624            'effectNames',
 625            self.effectNames,
 626            valuesSet=set(get_args(base.EffectType))
 627        )
 628        checkCompleteness(
 629            'focalizationNames',
 630            self.focalizationNames,
 631            valuesSet=set(get_args(base.DomainFocalization))
 632        )
 633
 634        # Build some reverse lookup dictionaries for specific
 635        self.reverseFormat = {y: x for (x, y) in self.formatDict.items()}
 636
 637        # circumstances:
 638        self.effectModMap = {
 639            self.formatDict[x]: x
 640            for x in [
 641                Lexeme.effectCharges,
 642                Lexeme.sepOrDelay,
 643                Lexeme.inCommon,
 644                Lexeme.isHidden
 645            ]
 646        }
 647
 648    def lex(self, content: str) -> LexedTokens:
 649        """
 650        Applies `lex` using this format's lexeme mapping.
 651        """
 652        return lex(content, self.reverseFormat)
 653
 654    def onOff(self, word: str) -> Optional[bool]:
 655        """
 656        Parse an on/off indicator and returns a boolean (`True` for on
 657        and `False` for off). Returns `None` if the word isn't either
 658        the 'on' or the 'off' word. Generates a `ParseWarning`
 659        (and still returns `None`) if the word is a case-swapped version
 660        of the 'on' or 'off' word and is not equal to either of them.
 661        """
 662        onWord = self.formatDict[Lexeme.stateOn]
 663        offWord = self.formatDict[Lexeme.stateOff]
 664
 665        # Generate warning if we suspect a case error
 666        if (
 667            word.casefold() in (onWord, offWord)
 668        and word not in (onWord, offWord)
 669        ):
 670            warnings.warn(
 671                (
 672                    f"Word '{word}' cannot be interpreted as an on/off"
 673                    f" value, although it is almost one (the correct"
 674                    f" values are '{onWord}' and '{offWord}'."
 675                ),
 676                ParseWarning
 677            )
 678
 679        # return the appropriate value
 680        if word == onWord:
 681            return True
 682        elif word == offWord:
 683            return False
 684        else:
 685            return None
 686
 687    def matchingBrace(
 688        self,
 689        tokens: LexedTokens,
 690        where: int,
 691        opener: int = Lexeme.openCurly,
 692        closer: int = Lexeme.closeCurly
 693    ) -> int:
 694        """
 695        Returns the index within the given tokens list of the closing
 696        curly brace which matches the open brace at the specified index.
 697        You can specify custom `opener` and/or `closer` lexemes to find
 698        matching pairs of other things. Raises a `ParseError` if there
 699        is no opening brace at the specified index, or if there isn't a
 700        matching closing brace. Handles nested braces of the specified
 701        type.
 702
 703        Examples:
 704        >>> pf = ParseFormat()
 705        >>> ob = Lexeme.openCurly
 706        >>> cb = Lexeme.closeCurly
 707        >>> pf.matchingBrace([ob, cb], 0)
 708        1
 709        >>> pf.matchingBrace([ob, cb], 1)
 710        Traceback (most recent call last):
 711          ...
 712        exploration.parsing.ParseError: ...
 713        >>> pf.matchingBrace(['hi', ob, cb], 0)
 714        Traceback (most recent call last):
 715          ...
 716        exploration.parsing.ParseError: ...
 717        >>> pf.matchingBrace(['hi', ob, cb], 1)
 718        2
 719        >>> pf.matchingBrace(['hi', ob, 'lo', cb], 1)
 720        3
 721        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 1)
 722        Traceback (most recent call last):
 723          ...
 724        exploration.parsing.ParseError: ...
 725        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 0)
 726        3
 727        >>> pf.matchingBrace([ob, ob, cb, cb], 0)
 728        3
 729        >>> pf.matchingBrace([ob, ob, cb, cb], 1)
 730        2
 731        >>> pf.matchingBrace([ob, cb, ob, cb], 0)
 732        1
 733        >>> pf.matchingBrace([ob, cb, ob, cb], 2)
 734        3
 735        >>> pf.matchingBrace([ob, cb, cb, cb], 0)
 736        1
 737        >>> pf.matchingBrace([ob, ob, ob, cb], 0)
 738        Traceback (most recent call last):
 739          ...
 740        exploration.parsing.ParseError: ...
 741        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 0)
 742        7
 743        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 1)
 744        6
 745        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 2)
 746        Traceback (most recent call last):
 747          ...
 748        exploration.parsing.ParseError: ...
 749        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 3)
 750        4
 751        >>> op = Lexeme.openParen
 752        >>> cp = Lexeme.closeParen
 753        >>> pf.matchingBrace([ob, op, ob, cp], 1, op, cp)
 754        3
 755        """
 756        if where >= len(tokens):
 757            raise ParseError(
 758                f"Out-of-bounds brace start: index {where} with"
 759                f" {len(tokens)} tokens."
 760            )
 761        if tokens[where] != opener:
 762            raise ParseError(
 763                f"Can't find matching brace for token"
 764                f" {repr(tokens[where])} at index {where} because it's"
 765                f" not an open brace."
 766            )
 767
 768        level = 1
 769        for i in range(where + 1, len(tokens)):
 770            token = tokens[i]
 771            if token == opener:
 772                level += 1
 773            elif token == closer:
 774                level -= 1
 775                if level == 0:
 776                    return i
 777
 778        raise ParseError(
 779            f"Failed to find matching curly brace from index {where}."
 780        )
 781
 782    def parseFocalization(self, word: str) -> base.DomainFocalization:
 783        """
 784        Parses a focalization type for a domain, recognizing
 785        'domainFocalizationSingular', 'domainFocalizationPlural', and
 786        'domainFocalizationSpreading'.
 787        """
 788        try:
 789            return self.focalizationNames[word]
 790        except KeyError:
 791            raise ParseError(
 792                f"Invalid domain focalization name {repr(word)}. Valid"
 793                f" name are: {repr(list(self.focalizationNames))}'."
 794            )
 795
 796    def parseTagValue(self, value: str) -> base.TagValue:
 797        """
 798        Converts a string to a tag value, following these rules:
 799
 800        1. If the string is exactly one of 'None', 'True', or 'False', we
 801            convert it to the corresponding Python value.
 802        2. If the string can be converted to an integer without raising a
 803            ValueError, we use that integer.
 804        3. If the string can be converted to a float without raising a
 805            ValueError, we use that float.
 806        4. Otherwise, it remains a string.
 807
 808        Note that there is currently no syntax for using list, dictionary,
 809        Requirement, or Consequence tag values.
 810        TODO: Support those types?
 811
 812        Examples:
 813
 814        >>> pf = ParseFormat()
 815        >>> pf.parseTagValue('hi')
 816        'hi'
 817        >>> pf.parseTagValue('3')
 818        3
 819        >>> pf.parseTagValue('3.0')
 820        3.0
 821        >>> pf.parseTagValue('True')
 822        True
 823        >>> pf.parseTagValue('False')
 824        False
 825        >>> pf.parseTagValue('None') is None
 826        True
 827        >>> pf.parseTagValue('none')
 828        'none'
 829        """
 830        # TODO: Allow these keywords to be redefined?
 831        if value == 'True':
 832            return True
 833        elif value == 'False':
 834            return False
 835        elif value == 'None':
 836            return None
 837        else:
 838            try:
 839                return int(value)
 840            except ValueError:
 841                try:
 842                    return float(value)
 843                except ValueError:
 844                    return value
 845
 846    def unparseTagValue(self, value: base.TagValue) -> str:
 847        """
 848        Converts a tag value into a string that would be parsed back into a
 849        tag value via `parseTagValue`. Currently does not work for list,
 850        dictionary, Requirement, or Consequence values.
 851        TODO: Those
 852        """
 853        return str(value)
 854
 855    def hasZoneParts(self, name: str) -> bool:
 856        """
 857        Returns true if the specified name contains zone parts (using
 858        the `zoneSeparator`).
 859        """
 860        return self.formatDict[Lexeme.zoneSeparator] in name
 861
 862    def splitZone(
 863        self,
 864        name: str
 865    ) -> Tuple[List[base.Zone], base.DecisionName]:
 866        """
 867        Splits a decision name that includes zone information into the
 868        list-of-zones part and the decision part. If there is no zone
 869        information in the name, the list-of-zones will be an empty
 870        list.
 871        """
 872        sep = self.formatDict[Lexeme.zoneSeparator]
 873        parts = name.split(sep)
 874        return (list(parts[:-1]), parts[-1])
 875
 876    def prefixWithZone(
 877        self,
 878        name: base.DecisionName,
 879        zone: base.Zone
 880    ) -> base.DecisionName:
 881        """
 882        Returns the given decision name, prefixed with the given zone
 883        name. Does NOT check whether the decision name already includes
 884        a prefix or not.
 885        """
 886        return zone + self.formatDict[Lexeme.zoneSeparator] + name
 887
 888    def parseAnyTransitionFromTokens(
 889        self,
 890        tokens: LexedTokens,
 891        start: int = 0
 892    ) -> Tuple[base.TransitionWithOutcomes, int]:
 893        """
 894        Parses a `base.TransitionWithOutcomes` from a tokens list,
 895        accepting either a transition name or a transition name followed
 896        by a `Lexeme.withDetails` followed by a string of success and
 897        failure indicator characters. Returns a tuple containing a
 898        `base.TransitionWithOutcomes` and an integer indicating the end
 899        index of the parsed item within the tokens.
 900        """
 901        # Normalize start index so we can do index math
 902        if start < 0:
 903            useIndex = len(tokens) + start
 904        else:
 905            useIndex = start
 906
 907        try:
 908            first = tokens[useIndex]
 909        except IndexError:
 910            raise ParseError(
 911                f"Invalid token index: {start!r} among {len(tokens)}"
 912                f" tokens."
 913            )
 914
 915        if isinstance(first, Lexeme):
 916            raise ParseError(
 917                f"Expecting a transition name (possibly with a"
 918                f" success/failure indicator string) but first token is"
 919                f" {first!r}."
 920            )
 921
 922        try:
 923            second = tokens[useIndex + 1]
 924            third = tokens[useIndex + 2]
 925        except IndexError:
 926            return ((first, []), useIndex)
 927
 928        if second != Lexeme.withDetails or isinstance(third, Lexeme):
 929            return ((first, []), useIndex)
 930
 931        outcomes = []
 932        for char in third:
 933            if char == self.successIndicator:
 934                outcomes.append(True)
 935            elif char == self.failureIndicator:
 936                outcomes.append(False)
 937            else:
 938                return ((first, []), useIndex)
 939
 940        return ((first, outcomes), useIndex + 2)
 941
 942    def parseTransitionWithOutcomes(
 943        self,
 944        content: str
 945    ) -> base.TransitionWithOutcomes:
 946        """
 947        Takes a transition that may have outcomes listed as a series of
 948        s/f strings after a colon and returns the corresponding
 949        `TransitionWithOutcomes` tuple. Calls `lex` and then
 950        `parseAnyTransitionFromTokens`.
 951        """
 952        return self.parseAnyTransitionFromTokens(self.lex(content))[0]
 953
 954    def unparseTransitionWithOutocmes(
 955        self,
 956        transition: base.AnyTransition
 957    ) -> str:
 958        """
 959        Turns a `base.AnyTransition` back into a string that would parse
 960        to an equivalent `base.TransitionWithOutcomes` via
 961        `parseTransitionWithOutcomes`. If a bare `base.Transition` is
 962        given, returns a string that would result in a
 963        `base.TransitionWithOutcomes` that has an empty outcomes
 964        sequence.
 965        """
 966        if isinstance(transition, base.Transition):
 967            return transition
 968        elif (
 969            isinstance(transition, tuple)
 970        and len(transition) == 2
 971        and isinstance(transition[0], base.Transition)
 972        and isinstance(transition[1], list)
 973        and all(isinstance(sfi, bool) for sfi in transition[1])
 974        ):
 975            if len(transition[1]) == 0:
 976                return transition[0]
 977            else:
 978                result = transition[0] + self.formatDict[Lexeme.withDetails]
 979                for outcome in transition[1]:
 980                    if outcome:
 981                        result += self.successIndicator
 982                    else:
 983                        result += self.failureIndicator
 984                return result
 985        else:
 986            raise TypeError(
 987                f"Invalid AnyTransition: neither a string, nor a"
 988                f" length-2 tuple consisting of a string followed by a"
 989                f" list of booleans. Got: {transition!r}"
 990            )
 991
 992    def parseSpecificTransition(
 993        self,
 994        content: str
 995    ) -> Tuple[base.DecisionName, base.Transition]:
 996        """
 997        Splits a decision:transition pair to the decision and transition
 998        part, using a custom separator if one is defined.
 999        """
1000        sep = self.formatDict[Lexeme.withDetails]
1001        n = content.count(sep)
1002        if n == 0:
1003            raise ParseError(
1004                f"Cannot split '{content}' into a decision name and a"
1005                f" transition name (no separator '{sep}' found)."
1006            )
1007        elif n > 1:
1008            raise ParseError(
1009                f"Cannot split '{content}' into a decision name and a"
1010                f" transition name (too many ({n}) '{sep}' separators"
1011                f" found)."
1012            )
1013        else:
1014            return cast(
1015                Tuple[base.DecisionName, base.Transition],
1016                tuple(content.split(sep))
1017            )
1018
1019    def splitDirections(
1020        self,
1021        content: str
1022    ) -> Tuple[Optional[str], Optional[str]]:
1023        """
1024        Splits a piece of text using the 'Lexeme.reciprocalSeparator'
1025        into two pieces. If there is no separator, the second piece will
1026        be `None`; if either side of the separator is blank, that side
1027        will be `None`, and if there is more than one separator, a
1028        `ParseError` will be raised. Whitespace will be stripped from
1029        both sides of each result.
1030
1031        Examples:
1032
1033        >>> pf = ParseFormat()
1034        >>> pf.splitDirections('abc / def')
1035        ('abc', 'def')
1036        >>> pf.splitDirections('abc def ')
1037        ('abc def', None)
1038        >>> pf.splitDirections('abc def /')
1039        ('abc def', None)
1040        >>> pf.splitDirections('/abc def')
1041        (None, 'abc def')
1042        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
1043        Traceback (most recent call last):
1044          ...
1045        ParseError: ...
1046        """
1047        sep = self.formatDict[Lexeme.reciprocalSeparator]
1048        count = content.count(sep)
1049        if count > 1:
1050            raise ParseError(
1051                f"Too many split points ('{sep}') in content:"
1052                f" '{content}' (only one is allowed)."
1053            )
1054
1055        elif count == 1:
1056            before, after = content.split(sep)
1057            before = before.strip()
1058            after = after.strip()
1059            return (before or None, after or None)
1060
1061        else: # no split points
1062            stripped = content.strip()
1063            if stripped:
1064                return stripped, None
1065            else:
1066                return None, None
1067
1068    def parseItem(
1069        self,
1070        item: str
1071    ) -> Union[
1072        base.Capability,
1073        Tuple[base.Token, int],
1074        Tuple[base.MechanismName, base.MechanismState]
1075    ]:
1076        """
1077        Parses an item, which is a capability (just a string), a
1078        token-type*number pair (returned as a tuple with the number
1079        converted to an integer), or a mechanism-name:state pair
1080        (returned as a tuple with the state as a string). The
1081        'Lexeme.tokenCount' and `Lexeme.mechanismSeparator` format
1082        values determine the separators that this looks for.
1083        """
1084        tsep = self.formatDict[Lexeme.tokenCount]
1085        msep = self.formatDict[Lexeme.mechanismSeparator]
1086        if tsep in item:
1087            # It's a token w/ an associated count
1088            parts = item.split(tsep)
1089            if len(parts) != 2:
1090                raise ParseError(
1091                    f"Item '{item}' has a '{tsep}' but doesn't separate"
1092                    f" into a token type and a count."
1093                )
1094            typ, count = parts
1095            try:
1096                num = int(count)
1097            except ValueError:
1098                raise ParseError(
1099                    f"Item '{item}' has invalid token count '{count}'."
1100                )
1101
1102            return (typ, num)
1103        elif msep in item:
1104            parts = item.split(msep)
1105            mechanism = msep.join(parts[:-1])
1106            state = parts[-1]
1107            if mechanism.endswith(':'):
1108                # Just a zone-qualified name...
1109                return item
1110            else:
1111                return (mechanism, state)
1112        else:
1113            # It's just a capability
1114            return item
1115
1116    def unparseAnyDecision(self, decision: base.AnyDecisionSpecifier) -> str:
1117        """
1118        Turns any kind of decision specifier (ID,
1119        `base.DecisionSpecifier`, or name string) into a string that
1120        should parse back using `parseDecisionSpecifier`.
1121
1122        Raises a `TypeError` if given something that isn't a decision
1123        identifier.
1124
1125        For example:
1126
1127        >>> pf = ParseFormat()
1128        >>> pf.unparseAnyDecision(
1129        ...     base.DecisionSpecifier("domain", "zone", "D")
1130        ... )
1131        'domain//zone::D'
1132        >>> pf.unparseAnyDecision(3)
1133        '3'
1134        >>> pf.unparseAnyDecision('D')
1135        'D'
1136        >>> pf.unparseAnyDecision('domain//zone::D')
1137        'domain//zone::D'
1138        >>> pf.unparseAnyDecision([1, 2])
1139        Traceback (most recent call last):
1140        ...
1141        TypeError...
1142        """
1143        if isinstance(decision, base.DecisionSpecifier):
1144            return self.unparseDecisionSpecifier(decision)
1145        elif isinstance(decision, (base.DecisionID, base.DecisionName)):
1146            # leave as-is OR convert integer ID to string
1147            return str(decision)
1148        else:
1149            raise TypeError(
1150                "Unrecognized decision identifier type " + type(decision)
1151            )
1152
1153    def unparseDecisionSpecifier(self, spec: base.DecisionSpecifier) -> str:
1154        """
1155        Turns a decision specifier back into a string, which would be
1156        parsed as a decision specifier as part of various different
1157        things.
1158
1159        For example:
1160
1161        >>> pf = ParseFormat()
1162        >>> pf.unparseDecisionSpecifier(
1163        ...     base.DecisionSpecifier(None, None, 'where')
1164        ... )
1165        'where'
1166        >>> pf.unparseDecisionSpecifier(
1167        ...     base.DecisionSpecifier(None, 'zone', 'where')
1168        ... )
1169        'zone::where'
1170        >>> pf.unparseDecisionSpecifier(
1171        ...     base.DecisionSpecifier('domain', 'zone', 'where')
1172        ... )
1173        'domain//zone::where'
1174        >>> pf.unparseDecisionSpecifier(
1175        ...     base.DecisionSpecifier('domain', None, 'where')
1176        ... )
1177        'domain//where'
1178        """
1179        result = spec.name
1180        if spec.zone is not None:
1181            result = (
1182                spec.zone
1183              + self.formatDict[Lexeme.zoneSeparator]
1184              + result
1185            )
1186        if spec.domain is not None:
1187            result = (
1188                spec.domain
1189              + self.formatDict[Lexeme.domainSeparator]
1190              + result
1191            )
1192        return result
1193
1194    def unparseMechanismSpecifier(
1195        self,
1196        spec: base.MechanismSpecifier
1197    ) -> str:
1198        """
1199        Turns a mechanism specifier back into a string, which would be
1200        parsed as a mechanism specifier as part of various different
1201        things. Note that a mechanism specifier with a zone part but no
1202        decision part is not valid, since it would parse as a decision
1203        part instead.
1204
1205        For example:
1206
1207        >>> pf = ParseFormat()
1208        >>> pf.unparseMechanismSpecifier(
1209        ...     base.MechanismSpecifier(None, None, None, 'lever')
1210        ... )
1211        'lever'
1212        >>> pf.unparseMechanismSpecifier(
1213        ...     base.MechanismSpecifier('domain', 'zone', 'decision', 'door')
1214        ... )
1215        'domain//zone::decision::door'
1216        >>> pf.unparseMechanismSpecifier(
1217        ...     base.MechanismSpecifier('domain', None, None, 'door')
1218        ... )
1219        'domain//door'
1220        >>> pf.unparseMechanismSpecifier(
1221        ...     base.MechanismSpecifier(None, 'a', 'b', 'door')
1222        ... )
1223        'a::b::door'
1224        >>> pf.unparseMechanismSpecifier(
1225        ...     base.MechanismSpecifier(None, 'a', None, 'door')
1226        ... )
1227        Traceback (most recent call last):
1228        ...
1229        exploration.base.InvalidMechanismSpecifierError...
1230        >>> pf.unparseMechanismSpecifier(
1231        ...     base.MechanismSpecifier(None, None, 'a', 'door')
1232        ... )
1233        'a::door'
1234        >>> pf.unparseMechanismSpecifier(
1235        ...     base.MechanismSpecifier(None, None, 37, 'door')
1236        ... )
1237        '37::door'
1238        """
1239        if spec.decision is None and spec.zone is not None:
1240            raise base.InvalidMechanismSpecifierError(
1241                f"Mechanism specifier has a zone part but no decision"
1242                f" part; it cannot be unparsed since it would parse"
1243                f" differently:\n{spec}"
1244            )
1245        result = spec.name
1246        if spec.decision is not None:
1247            result = (
1248                str(spec.decision)
1249              + self.formatDict[Lexeme.zoneSeparator]
1250              + result
1251            )
1252        if spec.zone is not None:
1253            result = (
1254                spec.zone
1255              + self.formatDict[Lexeme.zoneSeparator]
1256              + result
1257            )
1258        if spec.domain is not None:
1259            result = (
1260                spec.domain
1261              + self.formatDict[Lexeme.domainSeparator]
1262              + result
1263            )
1264        return result
1265
1266    def effectType(self, effectMarker: str) -> Optional[base.EffectType]:
1267        """
1268        Returns the `base.EffectType` string corresponding to the
1269        given effect marker string. Returns `None` for an unrecognized
1270        marker.
1271        """
1272        return self.effectNames.get(effectMarker)
1273
1274    def parseCommandFromTokens(
1275        self,
1276        tokens: LexedTokens,
1277        start: int = 0,
1278        end: int = -1
1279    ) -> commands.Command:
1280        """
1281        Given tokens that specify a `commands.Command`, parses that
1282        command and returns it. Really just turns the tokens back into
1283        strings and calls `commands.command`.
1284
1285        For example:
1286
1287        >>> pf = ParseFormat()
1288        >>> t = ['val', '5']
1289        >>> c = commands.command(*t)
1290        >>> pf.parseCommandFromTokens(t) == c
1291        True
1292        >>> t = ['op', Lexeme.tokenCount, '$val', '$val']
1293        >>> c = commands.command('op', '*', '$val', '$val')
1294        >>> pf.parseCommandFromTokens(t) == c
1295        True
1296        """
1297        start, end, nTokens = normalizeEnds(tokens, start, end)
1298        args: List[str] = []
1299        for token in tokens[start:end + 1]:
1300            if isinstance(token, Lexeme):
1301                args.append(self.formatDict[token])
1302            else:
1303                args.append(token)
1304
1305        if len(args) == 0:
1306            raise ParseError(
1307                f"No arguments for command:\n{tokens[start:end + 1]}"
1308            )
1309        return commands.command(*args)
1310
1311    def unparseCommand(self, command: commands.Command) -> str:
1312        """
1313        Turns a `Command` back into the string that would produce that
1314        command when parsed using `parseCommandList`.
1315
1316        Note that the results will be more explicit in some cases than what
1317        `parseCommandList` would accept as input.
1318
1319        For example:
1320
1321        >>> pf = ParseFormat()
1322        >>> pf.unparseCommand(
1323        ...     commands.LiteralValue(command='val', value='5')
1324        ... )
1325        'val 5'
1326        >>> pf.unparseCommand(
1327        ...     commands.LiteralValue(command='val', value='"5"')
1328        ... )
1329        'val "5"'
1330        >>> pf.unparseCommand(
1331        ...     commands.EstablishCollection(
1332        ...         command='empty',
1333        ...         collection='list'
1334        ...     )
1335        ... )
1336        'empty list'
1337        >>> pf.unparseCommand(
1338        ...     commands.AppendValue(command='append', value='$_')
1339        ... )
1340        'append $_'
1341        """
1342        candidate = None
1343        for k, v in commands.COMMAND_SETUP.items():
1344            if v[0] == type(command):
1345                if candidate is None:
1346                    candidate = k
1347                else:
1348                    raise ValueError(
1349                        f"COMMAND_SETUP includes multiple keys with"
1350                        f" {type(command)} as their value type:"
1351                        f" '{candidate}' and '{k}'."
1352                    )
1353
1354        if candidate is None:
1355            raise ValueError(
1356                f"COMMAND_SETUP has no key with {type(command)} as its"
1357                f" value type."
1358            )
1359
1360        result = candidate
1361        for x in command[1:]:
1362            # TODO: Is this hack good enough?
1363            result += ' ' + str(x)
1364        return result
1365
1366    def unparseCommandList(self, commands: List[commands.Command]) -> str:
1367        """
1368        Takes a list of commands and returns a string that would parse
1369        into them using `parseOneEffectArg`. The result contains
1370        newlines and indentation to make it easier to read.
1371
1372        For example:
1373
1374        >>> pf = ParseFormat()
1375        >>> pf.unparseCommandList(
1376        ...     [commands.command('val', '5'), commands.command('pop')]
1377        ... )
1378        '{\\n  val 5;\\n  pop;\\n}'
1379        """
1380        result = self.formatDict[Lexeme.openCurly]
1381        for cmd in commands:
1382            result += f'\n  {self.unparseCommand(cmd)};'
1383        if len(commands) > 0:
1384            result += '\n'
1385        return result + self.formatDict[Lexeme.closeCurly]
1386
1387    def parseCommandListFromTokens(
1388        self,
1389        tokens: LexedTokens,
1390        start: int = 0
1391    ) -> Tuple[List[commands.Command], int]:
1392        """
1393        Parses a command list from a list of lexed tokens, which must
1394        start with `Lexeme.openCurly`. Returns the parsed command list
1395        as a list of `commands.Command` objects, along with the end
1396        index of that command list (which will be the matching curly
1397        brace.
1398        """
1399        end = self.matchingBrace(
1400            tokens,
1401            start,
1402            Lexeme.openCurly,
1403            Lexeme.closeCurly
1404        )
1405        parts = list(
1406            findSeparatedParts(
1407                tokens,
1408                Lexeme.consequenceSeparator,
1409                start + 1,
1410                end - 1,
1411                Lexeme.openCurly,
1412                Lexeme.closeCurly,
1413            )
1414        )
1415        return (
1416            [
1417                self.parseCommandFromTokens(tokens, fromIndex, toIndex)
1418                for fromIndex, toIndex in parts
1419                if fromIndex <= toIndex  # ignore empty parts
1420            ],
1421            end
1422        )
1423
1424    def parseOneEffectArg(
1425        self,
1426        tokens: LexedTokens,
1427        start: int = 0,
1428        limit: Optional[int] = None
1429    ) -> Tuple[
1430        Union[
1431            base.Capability,  # covers 'str' possibility
1432            Tuple[base.Token, base.TokenCount],
1433            Tuple[Literal['skill'], base.Skill, base.Level],
1434            Tuple[base.MechanismSpecifier, base.MechanismState],
1435            base.DecisionSpecifier,
1436            base.DecisionID,
1437            Literal[Lexeme.inCommon, Lexeme.isHidden],
1438            Tuple[Literal[Lexeme.sepOrDelay, Lexeme.effectCharges], int],
1439            List[commands.Command]
1440        ],
1441        int
1442    ]:
1443        """
1444        Looks at tokens starting at the specified position and parses
1445        one or more of them as an effect argument (an argument that
1446        could be given to `base.effect`). Looks at various key `Lexeme`s
1447        to determine which type to use.
1448
1449        Items in the tokens list beyond the specified limit will not be
1450        considered, even when they in theory could be grouped with items
1451        up to the limit into a more complex argument.
1452
1453        For example:
1454
1455        >>> pf = ParseFormat()
1456        >>> pf.parseOneEffectArg(['hi'])
1457        ('hi', 0)
1458        >>> pf.parseOneEffectArg(['hi'], 1)
1459        Traceback (most recent call last):
1460        ...
1461        IndexError...
1462        >>> pf.parseOneEffectArg(['hi', 'bye'])
1463        ('hi', 0)
1464        >>> pf.parseOneEffectArg(['hi', 'bye'], 1)
1465        ('bye', 1)
1466        >>> pf.parseOneEffectArg(
1467        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1468        ...     0
1469        ... )
1470        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1471 name='gate'), 'open'), 2)
1472        >>> pf.parseOneEffectArg(
1473        ...     ['set', 'gate', Lexeme.mechanismSeparator, 'open'],
1474        ...     1
1475        ... )
1476        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1477 name='gate'), 'open'), 3)
1478        >>> pf.parseOneEffectArg(
1479        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1480        ...     1
1481        ... )
1482        Traceback (most recent call last):
1483        ...
1484        exploration.parsing.ParseError...
1485        >>> pf.parseOneEffectArg(
1486        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1487        ...     2
1488        ... )
1489        ('open', 2)
1490        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, '10'], 0)
1491        (('gold', 10), 2)
1492        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, 'ten'], 0)
1493        Traceback (most recent call last):
1494        ...
1495        exploration.parsing.ParseError...
1496        >>> pf.parseOneEffectArg([Lexeme.inCommon], 0)
1497        (<Lexeme.inCommon: ...>, 0)
1498        >>> pf.parseOneEffectArg([Lexeme.isHidden], 0)
1499        (<Lexeme.isHidden: ...>, 0)
1500        >>> pf.parseOneEffectArg([Lexeme.tokenCount, '3'], 0)
1501        Traceback (most recent call last):
1502        ...
1503        exploration.parsing.ParseError...
1504        >>> pf.parseOneEffectArg([Lexeme.effectCharges, '3'], 0)
1505        ((<Lexeme.effectCharges: ...>, 3), 1)
1506        >>> pf.parseOneEffectArg([Lexeme.tokenCount, 3], 0)  # int is a lexeme
1507        Traceback (most recent call last):
1508        ...
1509        exploration.parsing.ParseError...
1510        >>> pf.parseOneEffectArg([Lexeme.sepOrDelay, '-2'], 0)
1511        ((<Lexeme.sepOrDelay: ...>, -2), 1)
1512        >>> pf.parseOneEffectArg(['agility', Lexeme.skillLevel, '3'], 0)
1513        (('skill', 'agility', 3), 2)
1514        >>> pf.parseOneEffectArg(
1515        ...     [
1516        ...         'main',
1517        ...         Lexeme.domainSeparator,
1518        ...         'zone',
1519        ...         Lexeme.zoneSeparator,
1520        ...         'decision',
1521        ...         Lexeme.zoneSeparator,
1522        ...         'compass',
1523        ...         Lexeme.mechanismSeparator,
1524        ...         'north',
1525        ...         'south',
1526        ...         'east',
1527        ...         'west'
1528        ...     ],
1529        ...     0
1530        ... )
1531        ((MechanismSpecifier(domain='main', zone='zone',\
1532 decision='decision', name='compass'), 'north'), 8)
1533        >>> pf.parseOneEffectArg(
1534        ...     [
1535        ...         'before',
1536        ...         'main',
1537        ...         Lexeme.domainSeparator,
1538        ...         'zone',
1539        ...         Lexeme.zoneSeparator,
1540        ...         'decision',
1541        ...         Lexeme.zoneSeparator,
1542        ...         'compass',
1543        ...         'north',
1544        ...         'south',
1545        ...         'east',
1546        ...         'west'
1547        ...     ],
1548        ...     1
1549        ... )  # a mechanism specifier without a state will become a
1550        ...    # decision specifier
1551        (DecisionSpecifier(domain='main', zone='zone',\
1552 name='decision'), 5)
1553        >>> tokens = [
1554        ...     'set',
1555        ...     'main',
1556        ...     Lexeme.domainSeparator,
1557        ...     'zone',
1558        ...     Lexeme.zoneSeparator,
1559        ...     'compass',
1560        ...     'north',
1561        ...     'bounce',
1562        ... ]
1563        >>> pf.parseOneEffectArg(tokens, 0)
1564        ('set', 0)
1565        >>> pf.parseDecisionSpecifierFromTokens(tokens, 1)
1566        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1567        >>> pf.parseOneEffectArg(tokens, 1)
1568        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1569        >>> pf.parseOneEffectArg(tokens, 6)
1570        ('north', 6)
1571        >>> pf.parseOneEffectArg(tokens, 7)
1572        ('bounce', 7)
1573        >>> pf.parseOneEffectArg(
1574        ...     [
1575        ...         "fort", Lexeme.zoneSeparator, "gate",
1576        ...             Lexeme.mechanismSeparator, "open",
1577        ...     ],
1578        ...     0
1579        ... )
1580        ((MechanismSpecifier(domain=None, zone=None, decision='fort',\
1581 name='gate'), 'open'), 4)
1582        >>> pf.parseOneEffectArg(
1583        ...     [Lexeme.openCurly, 'val', '5', Lexeme.closeCurly],
1584        ...     0
1585        ... ) == ([commands.command('val', '5')], 3)
1586        True
1587        >>> a = [
1588        ...     Lexeme.openCurly, 'val', '5', Lexeme.closeCurly,
1589        ...     Lexeme.openCurly, 'append', Lexeme.consequenceSeparator,
1590        ...     'pop', Lexeme.closeCurly
1591        ... ]
1592        >>> cl = [
1593        ...     [commands.command('val', '5')],
1594        ...     [commands.command('append'), commands.command('pop')]
1595        ... ]
1596        >>> pf.parseOneEffectArg(a, 0) == (cl[0], 3)
1597        True
1598        >>> pf.parseOneEffectArg(a, 4) == (cl[1], 8)
1599        True
1600        >>> pf.parseOneEffectArg(a, 1)
1601        ('val', 1)
1602        >>> pf.parseOneEffectArg(a, 2)
1603        ('5', 2)
1604        >>> pf.parseOneEffectArg(a, 3)
1605        Traceback (most recent call last):
1606        ...
1607        exploration.parsing.ParseError...
1608        """
1609        start, limit, nTokens = normalizeEnds(
1610            tokens,
1611            start,
1612            limit if limit is not None else -1
1613        )
1614        if nTokens == 0:
1615            raise ParseError("No effect arguments available.")
1616
1617        first = tokens[start]
1618
1619        if nTokens == 1:
1620            if first in (Lexeme.inCommon, Lexeme.isHidden):
1621                return (first, start)
1622            elif not isinstance(first, str):
1623                raise ParseError(
1624                    f"Only one token and it's a special character"
1625                    f" ({first} = {repr(self.formatDict[first])})"
1626                )
1627            else:
1628                return (cast(base.Capability, first), start)
1629
1630        assert (nTokens > 1)
1631
1632        second = tokens[start + 1]
1633
1634        # Command lists start with an open curly brace and effect
1635        # modifiers start with a Lexme, but nothing else may
1636        if first == Lexeme.openCurly:
1637            return self.parseCommandListFromTokens(tokens, start)
1638        elif first in (Lexeme.inCommon, Lexeme.isHidden):
1639            return (first, start)
1640        elif first in (Lexeme.sepOrDelay, Lexeme.effectCharges):
1641            if not isinstance(second, str):
1642                raise ParseError(
1643                    f"Token following a modifier that needs a count"
1644                    f" must be a string in tokens:"
1645                    f"\n{tokens[start:limit or len(tokens)]}"
1646                )
1647            try:
1648                val = int(second)
1649            except ValueError:
1650                raise ParseError(
1651                    f"Token following a modifier that needs a count"
1652                    f" must be convertible to an int:"
1653                    f"\n{tokens[start:limit or len(tokens)]}"
1654                )
1655
1656            first = cast(
1657                Literal[Lexeme.sepOrDelay, Lexeme.effectCharges],
1658                first
1659            )
1660            return ((first, val), start + 1)
1661        elif not isinstance(first, str):
1662            raise ParseError(
1663                f"First token must be a string unless it's a modifier"
1664                f" lexeme or command/reversion-set opener. Got:"
1665                f"\n{tokens[start:limit or len(tokens)]}"
1666            )
1667
1668        # If we have two strings in a row, then the first is our parsed
1669        # value alone and we'll parse the second separately.
1670        if isinstance(second, str):
1671            return (first, start)
1672        elif second in (Lexeme.inCommon, Lexeme.isHidden):
1673            return (first, start)
1674
1675        # Must have at least 3 tokens at this point, or else we need to
1676        # have the inCommon or isHidden lexeme second.
1677        if nTokens < 3:
1678            return (first, start)
1679
1680        third = tokens[start + 2]
1681        if not isinstance(third, str):
1682            return (first, start)
1683
1684        second = cast(Lexeme, second)
1685        third = cast(str, third)
1686
1687        if second in (Lexeme.tokenCount, Lexeme.skillLevel):
1688            try:
1689                num = int(third)
1690            except ValueError:
1691                raise ParseError(
1692                    f"Invalid effect tokens: count for Tokens or level"
1693                    f" for Skill must be convertible to an integer."
1694                    f"\n{tokens[start:limit + 1]}"
1695                )
1696            if second == Lexeme.tokenCount:
1697                return ((first, num), start + 2)  # token/count pair
1698            else:
1699                return (('skill', first, num), start + 2)  # token/count pair
1700
1701        elif second == Lexeme.mechanismSeparator:  # bare mechanism
1702            return (
1703                (
1704                    base.MechanismSpecifier(
1705                        domain=None,
1706                        zone=None,
1707                        decision=None,
1708                        name=first
1709                    ),
1710                    third
1711                ),
1712                start + 2
1713            )
1714
1715        elif second in (Lexeme.domainSeparator, Lexeme.zoneSeparator):
1716            try:
1717                mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
1718                    tokens,
1719                    start
1720                )  # works whether it's a mechanism or decision specifier...
1721            except ParseError:
1722                return self.parseDecisionSpecifierFromTokens(tokens, start)
1723            if mEnd + 2 > limit:
1724                # No room for following mechanism separator + state
1725                return self.parseDecisionSpecifierFromTokens(tokens, start)
1726            sep = tokens[mEnd + 1]
1727            after = tokens[mEnd + 2]
1728            if sep == Lexeme.mechanismSeparator:
1729                if not isinstance(after, str):
1730                    raise ParseError(
1731                        f"Mechanism separator not followed by state:"
1732                        f"\n{tokens[start]}"
1733                    )
1734                return ((mSpec, after), mEnd + 2)
1735            else:
1736                # No mechanism separator afterwards
1737                return self.parseDecisionSpecifierFromTokens(tokens, start)
1738
1739        else:  # unrecognized as a longer combo
1740            return (first, start)
1741
1742    def coalesceEffectArgs(
1743        self,
1744        tokens: LexedTokens,
1745        start: int = 0,
1746        end: int = -1
1747    ) -> Tuple[
1748        List[  # List of effect args
1749            Union[
1750                base.Capability,  # covers 'str' possibility
1751                Tuple[base.Token, base.TokenCount],
1752                Tuple[Literal['skill'], base.Skill, base.Level],
1753                Tuple[base.MechanismSpecifier, base.MechanismState],
1754                base.DecisionSpecifier,
1755                List[commands.Command],
1756                Set[str]
1757            ]
1758        ],
1759        Tuple[  # Slots for modifiers: common/hidden/charges/delay
1760            Optional[bool],
1761            Optional[bool],
1762            Optional[int],
1763            Optional[int],
1764        ]
1765    ]:
1766        """
1767        Given a region of a lexed tokens list which contains one or more
1768        effect arguments, combines token sequences representing things
1769        like capabilities, mechanism states, token counts, and skill
1770        levels, representing these using the tuples that would be passed
1771        to `base.effect`. Returns a tuple with two elements:
1772
1773        - First, a list that contains several different kinds of
1774            objects, each of which is distinguishable by its type or
1775            part of its value.
1776        - Next, a tuple with four entires for common, hidden, charges,
1777            and/or delay values based on the presence of modifier
1778            sequences. Any or all of these may be `None` if the relevant
1779            modifier was not present (the usual case).
1780
1781        For example:
1782
1783        >>> pf = ParseFormat()
1784        >>> pf.coalesceEffectArgs(["jump"])
1785        (['jump'], (None, None, None, None))
1786        >>> pf.coalesceEffectArgs(["coin", Lexeme.tokenCount, "3", "fly"])
1787        ([('coin', 3), 'fly'], (None, None, None, None))
1788        >>> pf.coalesceEffectArgs(
1789        ...     [
1790        ...         "fort", Lexeme.zoneSeparator, "gate",
1791        ...             Lexeme.mechanismSeparator, "open"
1792        ...     ]
1793        ... )
1794        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1795 name='gate'), 'open')], (None, None, None, None))
1796        >>> pf.coalesceEffectArgs(
1797        ...     [
1798        ...         "main", Lexeme.domainSeparator, "cliff"
1799        ...     ]
1800        ... )
1801        ([DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1802 (None, None, None, None))
1803        >>> pf.coalesceEffectArgs(
1804        ...     [
1805        ...         "door", Lexeme.mechanismSeparator, "open"
1806        ...     ]
1807        ... )
1808        ([(MechanismSpecifier(domain=None, zone=None, decision=None,\
1809 name='door'), 'open')], (None, None, None, None))
1810        >>> pf.coalesceEffectArgs(
1811        ...     [
1812        ...         "fort", Lexeme.zoneSeparator, "gate",
1813        ...             Lexeme.mechanismSeparator, "open",
1814        ...         "canJump",
1815        ...         "coins", Lexeme.tokenCount, "3",
1816        ...         Lexeme.inCommon,
1817        ...         "agility", Lexeme.skillLevel, "-1",
1818        ...         Lexeme.sepOrDelay, "0",
1819        ...         "main", Lexeme.domainSeparator, "cliff"
1820        ...     ]
1821        ... )
1822        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1823 name='gate'), 'open'), 'canJump', ('coins', 3), ('skill', 'agility', -1),\
1824 DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1825 (True, None, None, 0))
1826        >>> pf.coalesceEffectArgs(["bounce", Lexeme.isHidden])
1827        (['bounce'], (None, True, None, None))
1828        >>> pf.coalesceEffectArgs(
1829        ...     ["goto", "3", Lexeme.inCommon, Lexeme.isHidden]
1830        ... )
1831        (['goto', '3'], (True, True, None, None))
1832        """
1833        start, end, nTokens = normalizeEnds(tokens, start, end)
1834        where = start
1835        result: List[  # List of effect args
1836            Union[
1837                base.Capability,  # covers 'str' possibility
1838                Tuple[base.Token, base.TokenCount],
1839                Tuple[Literal['skill'], base.Skill, base.Level],
1840                Tuple[base.MechanismSpecifier, base.MechanismState],
1841                base.DecisionSpecifier,
1842                List[commands.Command],
1843                Set[str]
1844            ]
1845        ] = []
1846        inCommon: Optional[bool] = None
1847        isHidden: Optional[bool] = None
1848        charges: Optional[int] = None
1849        delay: Optional[int] = None
1850        while where <= end:
1851            following, thisEnd = self.parseOneEffectArg(tokens, where, end)
1852            if following == Lexeme.inCommon:
1853                if inCommon is not None:
1854                    raise ParseError(
1855                        f"In-common effect modifier specified more than"
1856                        f" once in effect args:"
1857                        f"\n{tokens[start:end + 1]}"
1858                    )
1859                inCommon = True
1860            elif following == Lexeme.isHidden:
1861                if isHidden is not None:
1862                    raise ParseError(
1863                        f"Is-hidden effect modifier specified more than"
1864                        f" once in effect args:"
1865                        f"\n{tokens[start:end + 1]}"
1866                    )
1867                isHidden = True
1868            elif (
1869                isinstance(following, tuple)
1870            and len(following) == 2
1871            and following[0] in (Lexeme.effectCharges, Lexeme.sepOrDelay)
1872            and isinstance(following[1], int)
1873            ):
1874                if following[0] == Lexeme.effectCharges:
1875                    if charges is not None:
1876                        raise ParseError(
1877                            f"Charges effect modifier specified more than"
1878                            f" once in effect args:"
1879                            f"\n{tokens[start:end + 1]}"
1880                        )
1881                    charges = following[1]
1882                else:
1883                    if delay is not None:
1884                        raise ParseError(
1885                            f"Delay effect modifier specified more than"
1886                            f" once in effect args:"
1887                            f"\n{tokens[start:end + 1]}"
1888                        )
1889                    delay = following[1]
1890            elif (
1891                    isinstance(following, base.Capability)
1892                 or (
1893                    isinstance(following, tuple)
1894                and len(following) == 2
1895                and isinstance(following[0], base.Token)
1896                and isinstance(following[1], base.TokenCount)
1897                ) or (
1898                    isinstance(following, tuple)
1899                and len(following) == 3
1900                and following[0] == 'skill'
1901                and isinstance(following[1], base.Skill)
1902                and isinstance(following[2], base.Level)
1903                ) or (
1904                    isinstance(following, tuple)
1905                and len(following) == 2
1906                and isinstance(following[0], base.MechanismSpecifier)
1907                and isinstance(following[1], base.MechanismState)
1908                ) or (
1909                    isinstance(following, base.DecisionSpecifier)
1910                ) or (
1911                    isinstance(following, list)
1912                and all(isinstance(item, tuple) for item in following)
1913                    # TODO: Stricter command list check here?
1914                ) or (
1915                    isinstance(following, set)
1916                and all(isinstance(item, str) for item in following)
1917                )
1918            ):
1919                result.append(following)
1920            else:
1921                raise ParseError(f"Invalid coalesced argument: {following}")
1922            where = thisEnd + 1
1923
1924        return (result, (inCommon, isHidden, charges, delay))
1925
1926    def parseEffectFromTokens(
1927        self,
1928        tokens: LexedTokens,
1929        start: int = 0,
1930        end: int = -1
1931    ) -> base.Effect:
1932        """
1933        Given a region of a list of lexed tokens specifying an effect,
1934        returns the `Effect` object that those tokens specify.
1935        """
1936        start, end, nTokens = normalizeEnds(tokens, start, end)
1937
1938        # Check for empty list
1939        if nTokens == 0:
1940            raise ParseError(
1941                "Effect must include at least a type."
1942            )
1943
1944        firstPart = tokens[start]
1945
1946        if isinstance(firstPart, Lexeme):
1947            raise ParseError(
1948                f"First part of effect must be an effect type. Got"
1949                f" {firstPart} ({repr(self.formatDict[firstPart])})."
1950            )
1951
1952        firstPart = cast(str, firstPart)
1953
1954        # Get the effect type
1955        fType = self.effectType(firstPart)
1956
1957        if fType is None:
1958            raise ParseError(
1959                f"Unrecognized effect type {firstPart!r}. Check the"
1960                f" EffectType entries in the effect names dictionary."
1961            )
1962
1963        if start + 1 > end:  # No tokens left: set empty args
1964            groupedArgs: List[
1965                Union[
1966                    base.Capability,  # covers 'str' possibility
1967                    Tuple[base.Token, base.TokenCount],
1968                    Tuple[Literal['skill'], base.Skill, base.Level],
1969                    Tuple[base.MechanismSpecifier, base.MechanismState],
1970                    base.DecisionSpecifier,
1971                    List[commands.Command],
1972                    Set[str]
1973                ]
1974            ] = []
1975            modifiers: Tuple[
1976                Optional[bool],
1977                Optional[bool],
1978                Optional[int],
1979                Optional[int]
1980            ] = (None, None, None, None)
1981        else:  # Coalesce remaining tokens if there are any
1982            groupedArgs, modifiers = self.coalesceEffectArgs(
1983                tokens,
1984                start + 1,
1985                end
1986            )
1987
1988        # Set up arguments for base.effect and handle modifiers first
1989        args: Dict[
1990            str,
1991            Union[
1992                None,
1993                base.ContextSpecifier,
1994                base.Capability,
1995                Tuple[base.Token, base.TokenCount],
1996                Tuple[Literal['skill'], base.Skill, base.Level],
1997                Tuple[base.MechanismSpecifier, base.MechanismState],
1998                Tuple[base.MechanismSpecifier, List[base.MechanismState]],
1999                List[base.Capability],
2000                base.AnyDecisionSpecifier,
2001                Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
2002                bool,
2003                int,
2004                base.SaveSlot,
2005                Tuple[base.SaveSlot, Set[str]]
2006            ]
2007        ] = {}
2008        if modifiers[0]:
2009            args['applyTo'] = 'common'
2010        if modifiers[1]:
2011            args['hidden'] = True
2012        else:
2013            args['hidden'] = False
2014        if modifiers[2] is not None:
2015            args['charges'] = modifiers[2]
2016        if modifiers[3] is not None:
2017            args['delay'] = modifiers[3]
2018
2019        # Now handle the main effect-type-based argument
2020        if fType in ("gain", "lose"):
2021            if len(groupedArgs) != 1:
2022                raise ParseError(
2023                    f"'{fType}' effect must have exactly one grouped"
2024                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2025                )
2026            thing = groupedArgs[0]
2027            if isinstance(thing, tuple):
2028                if len(thing) == 2:
2029                    if (
2030                        not isinstance(thing[0], base.Token)
2031                     or not isinstance(thing[1], base.TokenCount)
2032                    ):
2033                        raise ParseError(
2034                            f"'{fType}' effect grouped arg pair must be a"
2035                            f" (token, amount) pair. Got:\n{thing}"
2036                        )
2037                elif len(thing) == 3:
2038                    if (
2039                        thing[0] != 'skill'
2040                     or not isinstance(thing[1], base.Skill)
2041                     or not isinstance(thing[2], base.Level)
2042                    ):
2043                        raise ParseError(
2044                            f"'{fType}' effect grouped arg pair must be a"
2045                            f" (token, amount) pair. Got:\n{thing}"
2046                        )
2047                else:
2048                    raise ParseError(
2049                        f"'{fType}' effect grouped arg tuple must have"
2050                        f" length 2 or 3. Got (length {len(thing)}):\n{thing}"
2051                    )
2052            elif not isinstance(thing, base.Capability):
2053                raise ParseError(
2054                    f"'{fType}' effect grouped arg must be a capability"
2055                    f" or a (token, amount) tuple. Got:\n{thing}"
2056                )
2057            args[fType] = thing
2058            return base.effect(**args)  # type:ignore
2059
2060        elif fType == "set":
2061            if len(groupedArgs) != 1:
2062                raise ParseError(
2063                    f"'{fType}' effect must have exactly one grouped"
2064                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2065                )
2066            setVal = groupedArgs[0]
2067            if not isinstance(
2068                setVal,
2069                tuple
2070            ):
2071                raise ParseError(
2072                    f"'{fType}' effect grouped arg must be a tuple. Got:"
2073                    f"\n{setVal}"
2074                )
2075            if len(setVal) == 2:
2076                setWhat, setTo = setVal
2077                if (
2078                    isinstance(setWhat, base.Token)
2079                and isinstance(setTo, base.TokenCount)
2080                ) or (
2081                    isinstance(setWhat, base.MechanismSpecifier)
2082                and isinstance(setTo, base.MechanismState)
2083                ):
2084                    args[fType] = setVal
2085                    return base.effect(**args)  # type:ignore
2086                else:
2087                    raise ParseError(
2088                        f"Invalid '{fType}' effect grouped args:"
2089                        f"\n{groupedArgs}"
2090                    )
2091            elif len(setVal) == 3:
2092                indicator, whichSkill, setTo = setVal
2093                if (
2094                    indicator == 'skill'
2095                and isinstance(whichSkill, base.Skill)
2096                and isinstance(setTo, base.Level)
2097                ):
2098                    args[fType] = setVal
2099                    return base.effect(**args)  # type:ignore
2100                else:
2101                    raise ParseError(
2102                        f"Invalid '{fType}' effect grouped args (not a"
2103                        f" skill):\n{groupedArgs}"
2104                    )
2105            else:
2106                raise ParseError(
2107                    f"Invalid '{fType}' effect grouped args (wrong"
2108                    f" length tuple):\n{groupedArgs}"
2109                )
2110
2111        elif fType == "toggle":
2112            if len(groupedArgs) == 0:
2113                raise ParseError(
2114                    f"'{fType}' effect must have at least one grouped"
2115                    f" argument. Got:\n{groupedArgs}"
2116                )
2117            if (
2118                isinstance(groupedArgs[0], tuple)
2119            and len(groupedArgs[0]) == 2
2120            and isinstance(groupedArgs[0][0], base.MechanismSpecifier)
2121            and isinstance(groupedArgs[0][1], base.MechanismState)
2122            and all(
2123                    isinstance(a, base.MechanismState)
2124                    for a in groupedArgs[1:]
2125                )
2126            ):  # a mechanism toggle
2127                args[fType] = (
2128                    groupedArgs[0][0],
2129                    cast(
2130                        List[base.MechanismState],
2131                        [groupedArgs[0][1]] + groupedArgs[1:]
2132                    )
2133                )
2134                return base.effect(**args)  # type:ignore
2135            elif all(isinstance(a, base.Capability) for a in groupedArgs):
2136                # a capability toggle
2137                args[fType] = cast(List[base.Capability], groupedArgs)
2138                return base.effect(**args)  # type:ignore
2139            else:
2140                raise ParseError(
2141                    f"Invalid arguments for '{fType}' effect. Got:"
2142                    f"\n{groupedArgs}"
2143                )
2144
2145        elif fType in ("bounce", "deactivate"):
2146            if len(groupedArgs) != 0:
2147                raise ParseError(
2148                    f"'{fType}' effect may not include any"
2149                    f" arguments. Got {len(groupedArgs)}):"
2150                    f"\n{groupedArgs}"
2151                )
2152            args[fType] = True
2153            return base.effect(**args)  # type:ignore
2154
2155        elif fType == "follow":
2156            if len(groupedArgs) != 1:
2157                raise ParseError(
2158                    f"'{fType}' effect must include exactly one"
2159                    f" argument. Got {len(groupedArgs)}):"
2160                    f"\n{groupedArgs}"
2161                )
2162
2163            transition = groupedArgs[0]
2164            if not isinstance(transition, base.Transition):
2165                raise ParseError(
2166                    f"Invalid argument for '{fType}' effect. Needed a"
2167                    f" transition but got:\n{groupedArgs}"
2168                )
2169            args[fType] = transition
2170            return base.effect(**args)  # type:ignore
2171
2172        elif fType == "edit":
2173            if len(groupedArgs) == 0:
2174                raise ParseError(
2175                    "An 'edit' effect requires at least one argument."
2176                )
2177            for i, arg in enumerate(groupedArgs):
2178                if not isinstance(arg, list):
2179                    raise ParseError(
2180                        f"'edit' effect argument {i} is not a sub-list:"
2181                        f"\n  {arg!r}"
2182                        f"\nAmong arguments:"
2183                        f"\n  {groupedArgs}"
2184                    )
2185                for j, cmd in enumerate(arg):
2186                    if not isinstance(cmd, tuple):
2187                        raise ParseError(
2188                            f"'edit' effect argument {i} contains"
2189                            f" non-tuple part {j}:"
2190                            f"\n  {cmd!r}"
2191                            f"\nAmong arguments:"
2192                            f"\n  {groupedArgs}"
2193                        )
2194
2195            args[fType] = groupedArgs  # type:ignore
2196            return base.effect(**args)  # type:ignore
2197
2198        elif fType == "goto":
2199            if len(groupedArgs) not in (1, 2):
2200                raise ParseError(
2201                    f"A 'goto' effect must include either one or two"
2202                    f" grouped arguments. Got {len(groupedArgs)}:"
2203                    f"\n{groupedArgs}"
2204                )
2205
2206            first = groupedArgs[0]
2207            if not isinstance(
2208                first,
2209                (base.DecisionName, base.DecisionSpecifier)
2210            ):
2211                raise ParseError(
2212                    f"'{fType}' effect must first specify a destination"
2213                    f" decision. Got:\n{groupedArgs}"
2214                )
2215
2216            # Check if it's really a decision ID
2217            dSpec: base.AnyDecisionSpecifier
2218            if isinstance(first, base.DecisionName):
2219                try:
2220                    dSpec = int(first)
2221                except ValueError:
2222                    dSpec = first
2223            else:
2224                dSpec = first
2225
2226            if len(groupedArgs) == 2:
2227                second = groupedArgs[1]
2228                if not isinstance(second, base.FocalPointName):
2229                    raise ParseError(
2230                        f"'{fType}' effect must have a focal point name"
2231                        f" if it has a second part. Got:\n{groupedArgs}"
2232                    )
2233                args[fType] = (dSpec, second)
2234            else:
2235                args[fType] = dSpec
2236
2237            return base.effect(**args)  # type:ignore
2238
2239        elif fType == "save":
2240            if len(groupedArgs) not in (0, 1):
2241                raise ParseError(
2242                    f"'{fType}' effect must include exactly zero or one"
2243                    f" argument(s). Got {len(groupedArgs)}):"
2244                    f"\n{groupedArgs}"
2245                )
2246
2247            if len(groupedArgs) == 1:
2248                slot = groupedArgs[0]
2249            else:
2250                slot = base.DEFAULT_SAVE_SLOT
2251            if not isinstance(slot, base.SaveSlot):
2252                raise ParseError(
2253                    f"Invalid argument for '{fType}' effect. Needed a"
2254                    f" save slot but got:\n{groupedArgs}"
2255                )
2256            args[fType] = slot
2257            return base.effect(**args)  # type:ignore
2258
2259        else:
2260            raise ParseError(f"Invalid effect type: '{fType}'.")
2261
2262    def parseEffect(self, effectStr: str) -> base.Effect:
2263        """
2264        Works like `parseEffectFromTokens` but starts with a raw string.
2265        For example:
2266
2267        >>> pf = ParseFormat()
2268        >>> pf.parseEffect("gain jump") == base.effect(gain='jump')
2269        True
2270        >>> pf.parseEffect("set door:open") == base.effect(
2271        ...     set=(
2272        ...         base.MechanismSpecifier(None, None, None, 'door'),
2273        ...         'open'
2274        ...     )
2275        ... )
2276        True
2277        >>> pf.parseEffect("set coins*10") == base.effect(set=('coins', 10))
2278        True
2279        >>> pf.parseEffect("set agility^3") == base.effect(
2280        ...     set=('skill', 'agility', 3)
2281        ... )
2282        True
2283        """
2284        return self.parseEffectFromTokens(self.lex(effectStr))
2285
2286    def unparseEffect(self, effect: base.Effect) -> str:
2287        """
2288        The opposite of `parseEffect`; turns an effect back into a
2289        string reprensentation.
2290
2291        For example:
2292
2293        >>> pf = ParseFormat()
2294        >>> e = {
2295        ...     "type": "gain",
2296        ...     "applyTo": "active",
2297        ...     "value": "flight",
2298        ...     "delay": None,
2299        ...     "charges": None,
2300        ...     "hidden": False
2301        ... }
2302        >>> pf.unparseEffect(e)
2303        'gain flight'
2304        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2305        True
2306        >>> s = 'gain flight'
2307        >>> pf.unparseEffect(pf.parseEffect(s)) == s
2308        True
2309        >>> s2 = '  gain\\nflight'
2310        >>> pf.unparseEffect(pf.parseEffect(s2)) == s
2311        True
2312        >>> e = {
2313        ...     "type": "gain",
2314        ...     "applyTo": "active",
2315        ...     "value": ("gold", 5),
2316        ...     "delay": 1,
2317        ...     "charges": 2,
2318        ...     "hidden": False
2319        ... }
2320        >>> pf.unparseEffect(e)
2321        'gain gold*5 ,1 =2'
2322        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2323        True
2324        >>> e = {
2325        ...     "type": "set",
2326        ...     "applyTo": "active",
2327        ...     "value": (
2328        ...         base.MechanismSpecifier(None, None, None, "gears"),
2329        ...         "on"
2330        ...     ),
2331        ...     "delay": None,
2332        ...     "charges": 1,
2333        ...     "hidden": False
2334        ... }
2335        >>> pf.unparseEffect(e)
2336        'set gears:on =1'
2337        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2338        True
2339        >>> e = {
2340        ...     "type": "toggle",
2341        ...     "applyTo": "active",
2342        ...     "value": ["red", "blue"],
2343        ...     "delay": None,
2344        ...     "charges": None,
2345        ...     "hidden": False
2346        ... }
2347        >>> pf.unparseEffect(e)
2348        'toggle red blue'
2349        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2350        True
2351        >>> e = {
2352        ...     "type": "toggle",
2353        ...     "applyTo": "active",
2354        ...     "value": (
2355        ...         base.MechanismSpecifier(None, None, None, "switch"),
2356        ...         ["on", "off"]
2357        ...     ),
2358        ...     "delay": None,
2359        ...     "charges": None,
2360        ...     "hidden": False
2361        ... }
2362        >>> pf.unparseEffect(e)
2363        'toggle switch:on off'
2364        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2365        True
2366        >>> e = {
2367        ...     "type": "deactivate",
2368        ...     "applyTo": "active",
2369        ...     "value": None,
2370        ...     "delay": 2,
2371        ...     "charges": None,
2372        ...     "hidden": False
2373        ... }
2374        >>> pf.unparseEffect(e)
2375        'deactivate ,2'
2376        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2377        True
2378        >>> e = {
2379        ...     "type": "goto",
2380        ...     "applyTo": "common",
2381        ...     "value": 3,
2382        ...     "delay": None,
2383        ...     "charges": None,
2384        ...     "hidden": False
2385        ... }
2386        >>> pf.unparseEffect(e)
2387        'goto 3 +c'
2388        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2389        True
2390        >>> e = {
2391        ...     "type": "goto",
2392        ...     "applyTo": "common",
2393        ...     "value": 3,
2394        ...     "delay": None,
2395        ...     "charges": None,
2396        ...     "hidden": True
2397        ... }
2398        >>> pf.unparseEffect(e)
2399        'goto 3 +c +h'
2400        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2401        True
2402        >>> e = {
2403        ...     "type": "goto",
2404        ...     "applyTo": "active",
2405        ...     "value": 'home',
2406        ...     "delay": None,
2407        ...     "charges": None,
2408        ...     "hidden": False
2409        ... }
2410        >>> pf.unparseEffect(e)
2411        'goto home'
2412        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2413        True
2414        >>> e = base.effect(edit=[
2415        ...     [
2416        ...         commands.command('val', '5'),
2417        ...         commands.command('empty', 'list'),
2418        ...         commands.command('append', '$_')
2419        ...     ],
2420        ...     [
2421        ...         commands.command('val', '11'),
2422        ...         commands.command('assign', 'var', '$_'),
2423        ...         commands.command('op', '+', '$var', '$var')
2424        ...     ],
2425        ... ])
2426        >>> pf.unparseEffect(e)
2427        'edit {\\n  val 5;\\n  empty list;\\n  append $_;\\n}\
2428 {\\n  val 11;\\n  assign var $_;\\n  op + $var $var;\\n}'
2429        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2430        True
2431        >>> e = base.effect(set=('coins', 3))
2432        >>> pf.unparseEffect(e)
2433        'set coins*3'
2434        >>> e = base.effect(set=('skill', 'mashing', 3))
2435        >>> pf.unparseEffect(e)
2436        'set mashing^3'
2437        """
2438        result: List[str] = []
2439
2440        # Reverse the effect type into a marker
2441        eType = effect['type']
2442        for key, val in self.effectNames.items():
2443            if val == eType:
2444                if len(result) != 0:
2445                    raise ParseError(
2446                        f"Effect map contains multiple matching entries"
2447                        f"for effect type '{effect['type']}':"
2448                        f" '{result[0]}' and '{key}'"
2449                    )
2450                result.append(key)
2451                # Don't break 'cause we'd like to check uniqueness
2452
2453        eVal = effect['value']
2454        if eType in ('gain', 'lose'):
2455            eVal = cast(Union[base.Capability, Tuple[base.Token, int]], eVal)
2456            if isinstance(eVal, str):  # a capability
2457                result.append(eVal)
2458            else:  # a token
2459                result.append(
2460                    eVal[0]
2461                  + self.formatDict[Lexeme.tokenCount]
2462                  + str(eVal[1])
2463                )
2464        elif eType == 'set':
2465            eVal = cast(
2466                # TODO: Add skill level setting here & elsewhere
2467                Union[
2468                    Tuple[base.Token, base.TokenCount],
2469                    Tuple[base.MechanismSpecifier, base.MechanismState]
2470                ],
2471                eVal
2472            )
2473            if len(eVal) not in (2, 3):
2474                raise ValueError(
2475                    f"'set' effect has value with length other than 2"
2476                    f" or 3:\n  {repr(effect)}"
2477                )
2478            if len(eVal) == 3:
2479                if eVal[0] != "skill":
2480                    raise ValueError(
2481                        f"'set' effect with length-3 value doesn't"
2482                        f" start with string 'skill':\n  {repr(effect)}"
2483                    )
2484                result.append(
2485                    eVal[1]
2486                  + self.formatDict[Lexeme.skillLevel]
2487                  + str(eVal[2])
2488                )
2489            elif isinstance(eVal[1], int):  # a token count
2490                result.append(
2491                    eVal[0]
2492                  + self.formatDict[Lexeme.tokenCount]
2493                  + str(eVal[1])
2494                )
2495            else:  # a mechanism
2496                if isinstance(eVal[0], base.MechanismSpecifier):
2497                    mSpec = self.unparseMechanismSpecifier(eVal[0])
2498                elif isinstance(eVal[0], base.MechanismID):
2499                    # TODO: Specify mechanism by decision name +
2500                    # mechanism name? Would require threading through a
2501                    # DecisionGraph and using mechanismDetails
2502                    mSpec = "" + eVal[0]
2503                else:
2504                    assert isinstance(eVal[0], base.MechanismName)
2505                    mSpec = eVal[0]
2506                result.append(
2507                    mSpec
2508                  + self.formatDict[Lexeme.mechanismSeparator]
2509                  + eVal[1]
2510                )
2511        elif eType == 'toggle':
2512            if isinstance(eVal, tuple):  # mechanism states
2513                tSpec, states = cast(
2514                    Tuple[
2515                        base.AnyMechanismSpecifier,
2516                        List[base.MechanismState]
2517                    ],
2518                    eVal
2519                )
2520                firstState = states[0]
2521                restStates = states[1:]
2522                if isinstance(tSpec, base.MechanismSpecifier):
2523                    mStr = self.unparseMechanismSpecifier(tSpec)
2524                else:
2525                    # Could be ID or name
2526                    mStr = str(tSpec)
2527                result.append(
2528                    mStr
2529                  + self.formatDict[Lexeme.mechanismSeparator]
2530                  + firstState
2531                )
2532                result.extend(restStates)
2533            else:  # capabilities
2534                assert isinstance(eVal, list)
2535                eVal = cast(List[base.Capability], eVal)
2536                result.extend(eVal)
2537        elif eType in ('deactivate', 'bounce'):
2538            if eVal is not None:
2539                raise ValueError(
2540                    f"'{eType}' effect has non-None value:"
2541                    f"\n  {repr(effect)}"
2542                )
2543        elif eType == 'follow':
2544            eVal = cast(base.Token, eVal)
2545            result.append(eVal)
2546        elif eType == 'edit':
2547            eVal = cast(List[List[commands.Command]], eVal)
2548            if len(eVal) == 0:
2549                result[-1] = '{}'
2550            else:
2551                for cmdList in eVal:
2552                    result.append(
2553                        self.unparseCommandList(cmdList)
2554                    )
2555        elif eType == 'goto':
2556            if (
2557                isinstance(eVal, tuple)
2558            and len(eVal) == 2
2559            and isinstance(eVal[1], base.FocalPointName)
2560            ):
2561                result.append(
2562                    self.unparseAnyDecision(
2563                        cast(base.AnyDecisionSpecifier, eVal[0])
2564                    )
2565                )
2566                result.append(eVal[1])
2567            else:
2568                assert isinstance(
2569                    eVal,
2570                    (base.DecisionID, base.DecisionSpecifier, str)
2571                )
2572                result.append(self.unparseAnyDecision(eVal))
2573        elif eType == 'save':
2574            # It's just a string naming the save slot
2575            eVal = cast(str, eVal)
2576            result.append(eVal)
2577        else:
2578            raise ValueError(
2579                f"Unrecognized effect type '{eType}' in effect:"
2580                f"\n  {repr(effect)}"
2581            )
2582
2583        # Add modifier strings
2584        if effect['applyTo'] == 'common':
2585            result.append(self.formatDict[Lexeme.inCommon])
2586
2587        if effect['hidden']:
2588            result.append(self.formatDict[Lexeme.isHidden])
2589
2590        dVal = effect['delay']
2591        if dVal is not None:
2592            result.append(
2593                self.formatDict[Lexeme.sepOrDelay] + str(dVal)
2594            )
2595
2596        cVal = effect['charges']
2597        if cVal is not None:
2598            result.append(
2599                self.formatDict[Lexeme.effectCharges] + str(cVal)
2600            )
2601
2602        joined = ''
2603        before = False
2604        for r in result:
2605            if (
2606                r.startswith(' ')
2607             or r.startswith('\n')
2608             or r.endswith(' ')
2609             or r.endswith('\n')
2610            ):
2611                joined += r
2612                before = False
2613            else:
2614                joined += (' ' if before else '') + r
2615                before = True
2616        return joined
2617
2618    def parseDecisionSpecifierFromTokens(
2619        self,
2620        tokens: LexedTokens,
2621        start: int = 0
2622    ) -> Tuple[Union[base.DecisionSpecifier, int], int]:
2623        """
2624        Parses a decision specifier starting at the specified position
2625        in the given tokens list. No ending position is specified, but
2626        instead this function returns a tuple containing the parsed
2627        `base.DecisionSpecifier` along with an index in the tokens list
2628        where the end of the specifier was found.
2629
2630        For example:
2631
2632        >>> pf = ParseFormat()
2633        >>> pf.parseDecisionSpecifierFromTokens(['m'])
2634        (DecisionSpecifier(domain=None, zone=None, name='m'), 0)
2635        >>> pf.parseDecisionSpecifierFromTokens(['12'])  # ID specifier
2636        (12, 0)
2637        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'])
2638        (DecisionSpecifier(domain=None, zone=None, name='a'), 0)
2639        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'], 1)
2640        (DecisionSpecifier(domain=None, zone=None, name='m'), 1)
2641        >>> pf.parseDecisionSpecifierFromTokens(
2642        ...     ['a', Lexeme.domainSeparator, 'm']
2643        ... )
2644        (DecisionSpecifier(domain='a', zone=None, name='m'), 2)
2645        >>> pf.parseDecisionSpecifierFromTokens(
2646        ...     ['a', Lexeme.zoneSeparator, 'm']
2647        ... )
2648        (DecisionSpecifier(domain=None, zone='a', name='m'), 2)
2649        >>> pf.parseDecisionSpecifierFromTokens(
2650        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
2651        ... )
2652        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2653        >>> pf.parseDecisionSpecifierFromTokens(
2654        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
2655        ... )
2656        (DecisionSpecifier(domain='a', zone='b', name='m'), 4)
2657        >>> pf.parseDecisionSpecifierFromTokens(
2658        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
2659        ... )
2660        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2661        >>> pf.parseDecisionSpecifierFromTokens(  # ID-style name w/ zone
2662        ...     ['a', Lexeme.zoneSeparator, '5'],
2663        ... )
2664        Traceback (most recent call last):
2665        ...
2666        exploration.base.InvalidDecisionSpecifierError...
2667        >>> pf.parseDecisionSpecifierFromTokens(
2668        ...     ['d', Lexeme.domainSeparator, '123']
2669        ... )
2670        Traceback (most recent call last):
2671        ...
2672        exploration.base.InvalidDecisionSpecifierError...
2673        >>> pf.parseDecisionSpecifierFromTokens(
2674        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2675        ...     1
2676        ... )
2677        Traceback (most recent call last):
2678        ...
2679        exploration.parsing.ParseError...
2680        >>> pf.parseDecisionSpecifierFromTokens(
2681        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2682        ...     2
2683        ... )
2684        (DecisionSpecifier(domain='b', zone=None, name='m'), 4)
2685        >>> pf.parseDecisionSpecifierFromTokens(
2686        ...     [
2687        ...         'a',
2688        ...         Lexeme.domainSeparator,
2689        ...         'b',
2690        ...         Lexeme.zoneSeparator,
2691        ...         'c',
2692        ...         Lexeme.zoneSeparator,
2693        ...         'm'
2694        ...     ]
2695        ... )
2696        (DecisionSpecifier(domain='a', zone='b', name='c'), 4)
2697        >>> pf.parseDecisionSpecifierFromTokens(
2698        ...     [
2699        ...         'a',
2700        ...         Lexeme.domainSeparator,
2701        ...         'b',
2702        ...         Lexeme.zoneSeparator,
2703        ...         'c',
2704        ...         Lexeme.zoneSeparator,
2705        ...         'm'
2706        ...     ],
2707        ...     2
2708        ... )
2709        (DecisionSpecifier(domain=None, zone='b', name='c'), 4)
2710        >>> pf.parseDecisionSpecifierFromTokens(
2711        ...     [
2712        ...         'a',
2713        ...         Lexeme.domainSeparator,
2714        ...         'b',
2715        ...         Lexeme.zoneSeparator,
2716        ...         'c',
2717        ...         Lexeme.zoneSeparator,
2718        ...         'm'
2719        ...     ],
2720        ...     4
2721        ... )
2722        (DecisionSpecifier(domain=None, zone='c', name='m'), 6)
2723        >>> pf.parseDecisionSpecifierFromTokens(
2724        ...     [
2725        ...         'set',
2726        ...         'main',
2727        ...         Lexeme.domainSeparator,
2728        ...         'zone',
2729        ...         Lexeme.zoneSeparator,
2730        ...         'compass',
2731        ...         'north',
2732        ...         'bounce',
2733        ...     ],
2734        ...     1
2735        ... )
2736        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
2737        """
2738        # Check bounds & normalize start index
2739        nTokens = len(tokens)
2740        if start < -nTokens:
2741            raise IndexError(
2742                f"Invalid start index {start} for {nTokens} tokens (too"
2743                f" negative)."
2744            )
2745        elif start >= nTokens:
2746            raise IndexError(
2747                f"Invalid start index {start} for {nTokens} tokens (too"
2748                f" big)."
2749            )
2750        elif start < 0:
2751            start = nTokens + start
2752
2753        assert (start < nTokens)
2754
2755        first = tokens[start]
2756        if not isinstance(first, str):
2757            raise ParseError(
2758                f"Invalid domain specifier (must start with a name or"
2759                f" id; got: {first} = {self.formatDict[first]})."
2760            )
2761
2762        ds = base.DecisionSpecifier(None, None, first)
2763        result = (base.idOrDecisionSpecifier(ds), start)
2764
2765        domain = None
2766        zoneOrDecision = None
2767
2768        if start + 1 >= nTokens:  # at end of tokens
2769            return result
2770
2771        firstSep = tokens[start + 1]
2772        if firstSep == Lexeme.domainSeparator:
2773            domain = first
2774        elif firstSep == Lexeme.zoneSeparator:
2775            zoneOrDecision = first
2776        else:
2777            return result
2778
2779        if start + 2 >= nTokens:
2780            return result
2781
2782        second = tokens[start + 2]
2783        if isinstance(second, Lexeme):
2784            return result
2785
2786        ds = base.DecisionSpecifier(domain, zoneOrDecision, second)
2787        result = (base.idOrDecisionSpecifier(ds), start + 2)
2788
2789        if start + 3 >= nTokens:
2790            return result
2791
2792        secondSep = tokens[start + 3]
2793        if start + 4 >= nTokens:
2794            return result
2795
2796        third = tokens[start + 4]
2797        if secondSep == Lexeme.zoneSeparator:
2798            if zoneOrDecision is not None:  # two in a row
2799                return result
2800            else:
2801                if not isinstance(third, base.DecisionName):
2802                    return result
2803                else:
2804                    zoneOrDecision = second
2805        else:
2806            return result
2807
2808        if isinstance(third, Lexeme):
2809            return result
2810
2811        ds = base.DecisionSpecifier(domain, zoneOrDecision, third)
2812        return (base.idOrDecisionSpecifier(ds), start + 4)
2813
2814    def parseDecisionSpecifier(
2815        self,
2816        specString: str
2817    ) -> Union[base.DecisionID, base.DecisionSpecifier]:
2818        """
2819        Parses a full `DecisionSpecifier` from a single string. Can
2820        parse integer decision IDs in string form, and returns a
2821        `DecisionID` in that case, otherwise returns a
2822        `DecisionSpecifier`. Assumes that all int-convertible strings
2823        are decision IDs, so it cannot deal with feature names which are
2824        just numbers.
2825
2826        For example:
2827
2828        >>> pf = ParseFormat()
2829        >>> pf.parseDecisionSpecifier('example')
2830        DecisionSpecifier(domain=None, zone=None, name='example')
2831        >>> pf.parseDecisionSpecifier('outer::example')
2832        DecisionSpecifier(domain=None, zone='outer', name='example')
2833        >>> pf.parseDecisionSpecifier('domain//region::feature')
2834        DecisionSpecifier(domain='domain', zone='region', name='feature')
2835        >>> pf.parseDecisionSpecifier('123')
2836        123
2837        >>> pf.parseDecisionSpecifier('region::domain//feature')
2838        Traceback (most recent call last):
2839        ...
2840        exploration.base.InvalidDecisionSpecifierError...
2841        >>> pf.parseDecisionSpecifier('domain1//domain2//feature')
2842        Traceback (most recent call last):
2843        ...
2844        exploration.base.InvalidDecisionSpecifierError...
2845        >>> pf.parseDecisionSpecifier('domain//123')
2846        Traceback (most recent call last):
2847        ...
2848        exploration.base.InvalidDecisionSpecifierError...
2849        >>> pf.parseDecisionSpecifier('region::123')
2850        Traceback (most recent call last):
2851        ...
2852        exploration.base.InvalidDecisionSpecifierError...
2853        """
2854        try:
2855            return int(specString)
2856        except ValueError:
2857            tokens = self.lex(specString)
2858            result, end = self.parseDecisionSpecifierFromTokens(tokens)
2859            if end != len(tokens) - 1:
2860                raise base.InvalidDecisionSpecifierError(
2861                    f"Junk after end of decision specifier:"
2862                    f"\n{tokens[end + 1:]}"
2863                )
2864            return result
2865
2866    def parseFeatureSpecifierFromTokens(
2867        self,
2868        tokens: LexedTokens,
2869        start: int = 0,
2870        limit: int = -1
2871    ) -> Tuple[base.FeatureSpecifier, int]:
2872        """
2873        Parses a `FeatureSpecifier` starting from the specified part of
2874        a tokens list. Returns a tuple containing the feature specifier
2875        and the end position of the end of the feature specifier.
2876
2877        Can parse integer feature IDs in string form, as well as nested
2878        feature specifiers and plain feature specifiers. Assumes that
2879        all int-convertible strings are feature IDs, so it cannot deal
2880        with feature names which are just numbers.
2881
2882        For example:
2883
2884        >>> pf = ParseFormat()
2885        >>> pf.parseFeatureSpecifierFromTokens(['example'])
2886        (FeatureSpecifier(domain=None, within=[], feature='example',\
2887 part=None), 0)
2888        >>> pf.parseFeatureSpecifierFromTokens(['example1', 'example2'], 1)
2889        (FeatureSpecifier(domain=None, within=[], feature='example2',\
2890 part=None), 1)
2891        >>> pf.parseFeatureSpecifierFromTokens(
2892        ...     [
2893        ...         'domain',
2894        ...         Lexeme.domainSeparator,
2895        ...         'region',
2896        ...         Lexeme.zoneSeparator,
2897        ...         'feature',
2898        ...         Lexeme.partSeparator,
2899        ...         'part'
2900        ...     ]
2901        ... )
2902        (FeatureSpecifier(domain='domain', within=['region'],\
2903 feature='feature', part='part'), 6)
2904        >>> pf.parseFeatureSpecifierFromTokens(
2905        ...     [
2906        ...         'outerRegion',
2907        ...         Lexeme.zoneSeparator,
2908        ...         'midRegion',
2909        ...         Lexeme.zoneSeparator,
2910        ...         'innerRegion',
2911        ...         Lexeme.zoneSeparator,
2912        ...         'feature'
2913        ...     ]
2914        ... )
2915        (FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
2916 'innerRegion'], feature='feature', part=None), 6)
2917        >>> pf.parseFeatureSpecifierFromTokens(
2918        ...     [
2919        ...         'outerRegion',
2920        ...         Lexeme.zoneSeparator,
2921        ...         'midRegion',
2922        ...         Lexeme.zoneSeparator,
2923        ...         'innerRegion',
2924        ...         Lexeme.zoneSeparator,
2925        ...         'feature'
2926        ...     ],
2927        ...     1
2928        ... )
2929        Traceback (most recent call last):
2930        ...
2931        exploration.parsing.InvalidFeatureSpecifierError...
2932        >>> pf.parseFeatureSpecifierFromTokens(
2933        ...     [
2934        ...         'outerRegion',
2935        ...         Lexeme.zoneSeparator,
2936        ...         'midRegion',
2937        ...         Lexeme.zoneSeparator,
2938        ...         'innerRegion',
2939        ...         Lexeme.zoneSeparator,
2940        ...         'feature'
2941        ...     ],
2942        ...     2
2943        ... )
2944        (FeatureSpecifier(domain=None, within=['midRegion', 'innerRegion'],\
2945 feature='feature', part=None), 6)
2946        >>> pf.parseFeatureSpecifierFromTokens(
2947        ...     [
2948        ...         'outerRegion',
2949        ...         Lexeme.zoneSeparator,
2950        ...         'feature',
2951        ...         Lexeme.domainSeparator,
2952        ...         'after',
2953        ...     ]
2954        ... )
2955        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2956 feature='feature', part=None), 2)
2957        >>> pf.parseFeatureSpecifierFromTokens(
2958        ...     [
2959        ...         'outerRegion',
2960        ...         Lexeme.zoneSeparator,
2961        ...         'feature',
2962        ...         Lexeme.domainSeparator,
2963        ...         'after',
2964        ...     ],
2965        ...     2
2966        ... )
2967        (FeatureSpecifier(domain='feature', within=[], feature='after',\
2968 part=None), 4)
2969        >>> # Including a limit:
2970        >>> pf.parseFeatureSpecifierFromTokens(
2971        ...     [
2972        ...         'outerRegion',
2973        ...         Lexeme.zoneSeparator,
2974        ...         'midRegion',
2975        ...         Lexeme.zoneSeparator,
2976        ...         'feature',
2977        ...     ],
2978        ...     0,
2979        ...     2
2980        ... )
2981        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2982 feature='midRegion', part=None), 2)
2983        >>> pf.parseFeatureSpecifierFromTokens(
2984        ...     [
2985        ...         'outerRegion',
2986        ...         Lexeme.zoneSeparator,
2987        ...         'midRegion',
2988        ...         Lexeme.zoneSeparator,
2989        ...         'feature',
2990        ...     ],
2991        ...     0,
2992        ...     0
2993        ... )
2994        (FeatureSpecifier(domain=None, within=[], feature='outerRegion',\
2995 part=None), 0)
2996        >>> pf.parseFeatureSpecifierFromTokens(
2997        ...     [
2998        ...         'region',
2999        ...         Lexeme.zoneSeparator,
3000        ...         Lexeme.zoneSeparator,
3001        ...         'feature',
3002        ...     ]
3003        ... )
3004        (FeatureSpecifier(domain=None, within=[], feature='region',\
3005 part=None), 0)
3006        """
3007        start, limit, nTokens = normalizeEnds(tokens, start, limit)
3008
3009        if nTokens == 0:
3010            raise InvalidFeatureSpecifierError(
3011                "Can't parse a feature specifier from 0 tokens."
3012            )
3013        first = tokens[start]
3014        if isinstance(first, Lexeme):
3015            raise InvalidFeatureSpecifierError(
3016                f"Feature specifier can't begin with a special token."
3017                f"Got:\n{tokens[start:limit + 1]}"
3018            )
3019
3020        if nTokens in (1, 2):
3021            # 2 tokens isn't enough for a second part
3022            fs = base.FeatureSpecifier(
3023                domain=None,
3024                within=[],
3025                feature=first,
3026                part=None
3027            )
3028            return (base.normalizeFeatureSpecifier(fs), start)
3029
3030        firstSep = tokens[start + 1]
3031        secondPart = tokens[start + 2]
3032
3033        if (
3034            firstSep not in (
3035                Lexeme.domainSeparator,
3036                Lexeme.zoneSeparator,
3037                Lexeme.partSeparator
3038            )
3039         or not isinstance(secondPart, str)
3040        ):
3041            # Following tokens won't work out
3042            fs = base.FeatureSpecifier(
3043                domain=None,
3044                within=[],
3045                feature=first,
3046                part=None
3047            )
3048            return (base.normalizeFeatureSpecifier(fs), start)
3049
3050        if firstSep == Lexeme.domainSeparator:
3051            if start + 2 > limit:
3052                return (
3053                    base.FeatureSpecifier(
3054                        domain=first,
3055                        within=[],
3056                        feature=secondPart,
3057                        part=None
3058                    ),
3059                    start + 2
3060                )
3061            else:
3062                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3063                    tokens,
3064                    start + 2,
3065                    limit
3066                )
3067                if rest.domain is not None:  # two domainSeparators in a row
3068                    fs = base.FeatureSpecifier(
3069                        domain=first,
3070                        within=[],
3071                        feature=rest.domain,
3072                        part=None
3073                    )
3074                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3075                else:
3076                    fs = base.FeatureSpecifier(
3077                        domain=first,
3078                        within=rest.within,
3079                        feature=rest.feature,
3080                        part=rest.part
3081                    )
3082                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3083
3084        elif firstSep == Lexeme.zoneSeparator:
3085            if start + 2 > limit:
3086                fs = base.FeatureSpecifier(
3087                    domain=None,
3088                    within=[first],
3089                    feature=secondPart,
3090                    part=None
3091                )
3092                return (base.normalizeFeatureSpecifier(fs), start + 2)
3093            else:
3094                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3095                    tokens,
3096                    start + 2,
3097                    limit
3098                )
3099                if rest.domain is not None:  # domain sep after zone sep
3100                    fs = base.FeatureSpecifier(
3101                        domain=None,
3102                        within=[first],
3103                        feature=rest.domain,
3104                        part=None
3105                    )
3106                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3107                else:
3108                    within = [first]
3109                    within.extend(rest.within)
3110                    fs = base.FeatureSpecifier(
3111                        domain=None,
3112                        within=within,
3113                        feature=rest.feature,
3114                        part=rest.part
3115                    )
3116                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3117
3118        else:  # must be partSeparator
3119            fs = base.FeatureSpecifier(
3120                domain=None,
3121                within=[],
3122                feature=first,
3123                part=secondPart
3124            )
3125            return (base.normalizeFeatureSpecifier(fs), start + 2)
3126
3127    def parseFeatureSpecifier(self, specString: str) -> base.FeatureSpecifier:
3128        """
3129        Parses a full `FeatureSpecifier` from a single string. See
3130        `parseFeatureSpecifierFromTokens`.
3131
3132        >>> pf = ParseFormat()
3133        >>> pf.parseFeatureSpecifier('example')
3134        FeatureSpecifier(domain=None, within=[], feature='example', part=None)
3135        >>> pf.parseFeatureSpecifier('outer::example')
3136        FeatureSpecifier(domain=None, within=['outer'], feature='example',\
3137 part=None)
3138        >>> pf.parseFeatureSpecifier('example%%middle')
3139        FeatureSpecifier(domain=None, within=[], feature='example',\
3140 part='middle')
3141        >>> pf.parseFeatureSpecifier('domain//region::feature%%part')
3142        FeatureSpecifier(domain='domain', within=['region'],\
3143 feature='feature', part='part')
3144        >>> pf.parseFeatureSpecifier(
3145        ...     'outerRegion::midRegion::innerRegion::feature'
3146        ... )
3147        FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
3148 'innerRegion'], feature='feature', part=None)
3149        >>> pf.parseFeatureSpecifier('region::domain//feature')
3150        Traceback (most recent call last):
3151        ...
3152        exploration.parsing.InvalidFeatureSpecifierError...
3153        >>> pf.parseFeatureSpecifier('feature%%part1%%part2')
3154        Traceback (most recent call last):
3155        ...
3156        exploration.parsing.InvalidFeatureSpecifierError...
3157        >>> pf.parseFeatureSpecifier('domain1//domain2//feature')
3158        Traceback (most recent call last):
3159        ...
3160        exploration.parsing.InvalidFeatureSpecifierError...
3161        >>> # TODO: Issue warnings for these...
3162        >>> pf.parseFeatureSpecifier('domain//123')  # domain discarded
3163        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3164        >>> pf.parseFeatureSpecifier('region::123')  # zone discarded
3165        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3166        >>> pf.parseFeatureSpecifier('123%%part')
3167        FeatureSpecifier(domain=None, within=[], feature=123, part='part')
3168        """
3169        tokens = self.lex(specString)
3170        result, rEnd = self.parseFeatureSpecifierFromTokens(tokens)
3171        if rEnd != len(tokens) - 1:
3172            raise InvalidFeatureSpecifierError(
3173                f"Feature specifier has extra stuff at end:"
3174                f" {tokens[rEnd + 1:]}"
3175            )
3176        else:
3177            return result
3178
3179    def normalizeFeatureSpecifier(
3180        self,
3181        spec: base.AnyFeatureSpecifier
3182    ) -> base.FeatureSpecifier:
3183        """
3184        Normalizes any kind of feature specifier into an official
3185        `FeatureSpecifier` tuple.
3186
3187        For example:
3188
3189        >>> pf = ParseFormat()
3190        >>> pf.normalizeFeatureSpecifier('town')
3191        FeatureSpecifier(domain=None, within=[], feature='town', part=None)
3192        >>> pf.normalizeFeatureSpecifier(5)
3193        FeatureSpecifier(domain=None, within=[], feature=5, part=None)
3194        >>> pf.parseFeatureSpecifierFromTokens(
3195        ...     [
3196        ...         'domain',
3197        ...         Lexeme.domainSeparator,
3198        ...         'region',
3199        ...         Lexeme.zoneSeparator,
3200        ...         'feature',
3201        ...         Lexeme.partSeparator,
3202        ...         'part'
3203        ...     ]
3204        ... )
3205        (FeatureSpecifier(domain='domain', within=['region'],\
3206 feature='feature', part='part'), 6)
3207        >>> pf.normalizeFeatureSpecifier('dom//one::two::three%%middle')
3208        FeatureSpecifier(domain='dom', within=['one', 'two'],\
3209 feature='three', part='middle')
3210        >>> pf.normalizeFeatureSpecifier(
3211        ...   base.FeatureSpecifier(None, ['region'], 'place', None)
3212        ... )
3213        FeatureSpecifier(domain=None, within=['region'], feature='place',\
3214 part=None)
3215        >>> fs = base.FeatureSpecifier(None, [], 'place', None)
3216        >>> ns = pf.normalizeFeatureSpecifier(fs)
3217        >>> ns is fs  # Doesn't create unnecessary clones
3218        True
3219        """
3220        if isinstance(spec, base.FeatureSpecifier):
3221            return spec
3222        elif isinstance(spec, base.FeatureID):
3223            return base.FeatureSpecifier(None, [], spec, None)
3224        elif isinstance(spec, str):
3225            return self.parseFeatureSpecifier(spec)
3226        else:
3227            raise TypeError(f"Invalid feature specifier type: '{type(spec)}'")
3228
3229    def unparseChallenge(self, challenge: base.Challenge) -> str:
3230        """
3231        Turns a `base.Challenge` into a string that can be turned back
3232        into an equivalent challenge by `parseChallenge`. For example:
3233
3234        >>> pf = ParseFormat()
3235        >>> c = base.challenge(
3236        ...     skills=base.BestSkill('brains', 'brawn'),
3237        ...     level=2,
3238        ...     success=[base.effect(set=('switch', 'on'))],
3239        ...     failure=[
3240        ...         base.effect(deactivate=True, delay=1),
3241        ...         base.effect(bounce=True)
3242        ...     ],
3243        ...     outcome=True
3244        ... )
3245        >>> r = pf.unparseChallenge(c)
3246        >>> r
3247        '<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}'
3248        >>> pf.parseChallenge(r) == c
3249        True
3250        >>> c2 = base.challenge(
3251        ...     skills=base.CombinedSkill(
3252        ...         -2,
3253        ...         base.ConditionalSkill(
3254        ...             base.ReqCapability('tough'),
3255        ...             base.BestSkill(1),
3256        ...             base.BestSkill(-1)
3257        ...         )
3258        ...     ),
3259        ...     level=-2,
3260        ...     success=[base.effect(gain='orb')],
3261        ...     failure=[],
3262        ...     outcome=None
3263        ... )
3264        >>> r2 = pf.unparseChallenge(c2)
3265        >>> r2
3266        '<-2>sum(-2, if(tough, best(1), best(-1))){gain orb}{}'
3267        >>> # TODO: let this parse through without BestSkills...
3268        >>> pf.parseChallenge(r2) == c2
3269        True
3270        """
3271        lt = self.formatDict[Lexeme.angleLeft]
3272        gt = self.formatDict[Lexeme.angleRight]
3273        result = (
3274            lt + str(challenge['level']) + gt
3275          + challenge['skills'].unparse()
3276        )
3277        if challenge['outcome'] is True:
3278            result += gt
3279        result += self.unparseConsequence(challenge['success'])
3280        if challenge['outcome'] is False:
3281            result += gt
3282        result += self.unparseConsequence(challenge['failure'])
3283        return result
3284
3285    def unparseCondition(self, condition: base.Condition) -> str:
3286        """
3287        Given a `base.Condition` returns a string that would result in
3288        that condition if given to `parseCondition`. For example:
3289
3290        >>> pf = ParseFormat()
3291        >>> c = base.condition(
3292        ...     condition=base.ReqAny([
3293        ...         base.ReqCapability('brawny'),
3294        ...         base.ReqNot(base.ReqTokens('weights', 3))
3295        ...     ]),
3296        ...     consequence=[base.effect(gain='power')]
3297        ... )
3298        >>> r = pf.unparseCondition(c)
3299        >>> r
3300        '??((brawny|!(weights*3))){gain power}{}'
3301        >>> pf.parseCondition(r) == c
3302        True
3303        """
3304        return (
3305            self.formatDict[Lexeme.doubleQuestionmark]
3306          + self.formatDict[Lexeme.openParen]
3307          + condition['condition'].unparse()
3308          + self.formatDict[Lexeme.closeParen]
3309          + self.unparseConsequence(condition['consequence'])
3310          + self.unparseConsequence(condition['alternative'])
3311        )
3312
3313    def unparseConsequence(self, consequence: base.Consequence) -> str:
3314        """
3315        Given a `base.Consequence`, returns a string encoding of it,
3316        using the same format that `parseConsequence` will parse. Uses
3317        function-call-like syntax and curly braces to denote different
3318        sub-consequences. See also `SkillCombination.unparse` and
3319        `Requirement.unparse` For example:
3320
3321        >>> pf = ParseFormat()
3322        >>> c = [base.effect(gain='one'), base.effect(lose='one')]
3323        >>> pf.unparseConsequence(c)
3324        '{gain one; lose one}'
3325        >>> c = [
3326        ...     base.challenge(
3327        ...         skills=base.BestSkill('brains', 'brawn'),
3328        ...         level=2,
3329        ...         success=[base.effect(set=('switch', 'on'))],
3330        ...         failure=[
3331        ...             base.effect(deactivate=True, delay=1),
3332        ...             base.effect(bounce=True)
3333        ...         ],
3334        ...         outcome=True
3335        ...     )
3336        ... ]
3337        >>> pf.unparseConsequence(c)
3338        '{<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}}'
3339        >>> c[0]['outcome'] = False
3340        >>> pf.unparseConsequence(c)
3341        '{<2>best(brains, brawn){set switch:on}>{deactivate ,1; bounce}}'
3342        >>> c[0]['outcome'] = None
3343        >>> pf.unparseConsequence(c)
3344        '{<2>best(brains, brawn){set switch:on}{deactivate ,1; bounce}}'
3345        >>> c = [
3346        ...     base.condition(
3347        ...         condition=base.ReqAny([
3348        ...             base.ReqCapability('brawny'),
3349        ...             base.ReqNot(base.ReqTokens('weights', 3))
3350        ...         ]),
3351        ...         consequence=[
3352        ...             base.challenge(
3353        ...                 skills=base.CombinedSkill('brains', 'brawn'),
3354        ...                 level=3,
3355        ...                 success=[base.effect(goto='home')],
3356        ...                 failure=[base.effect(bounce=True)],
3357        ...                 outcome=None
3358        ...             )
3359        ...         ]  # no alternative -> empty list
3360        ...     )
3361        ... ]
3362        >>> pf.unparseConsequence(c)
3363        '{??((brawny|!(weights*3))){\
3364<3>sum(brains, brawn){goto home}{bounce}}{}}'
3365        >>> c = [base.effect(gain='if(power){gain "mimic"}')]
3366        >>> # TODO: Make this work!
3367        >>> # pf.unparseConsequence(c)
3368
3369        '{gain "if(power){gain \\\\"mimic\\\\"}"}'
3370        """
3371        result = self.formatDict[Lexeme.openCurly]
3372        for item in consequence:
3373            if 'skills' in item:  # a Challenge
3374                item = cast(base.Challenge, item)
3375                result += self.unparseChallenge(item)
3376
3377            elif 'value' in item:  # an Effect
3378                item = cast(base.Effect, item)
3379                result += self.unparseEffect(item)
3380
3381            elif 'condition' in item:  # a Condition
3382                item = cast(base.Condition, item)
3383                result += self.unparseCondition(item)
3384
3385            else:  # bad dict
3386                raise TypeError(
3387                    f"Invalid consequence: items in the list must be"
3388                    f" Effects, Challenges, or Conditions (got a dictionary"
3389                    f" without 'skills', 'value', or 'condition' keys)."
3390                    f"\nGot item: {repr(item)}"
3391                )
3392            result += '; '
3393
3394        if result.endswith('; '):
3395            result = result[:-2]
3396
3397        return result + self.formatDict[Lexeme.closeCurly]
3398
3399    def parseMechanismSpecifierFromTokens(
3400        self,
3401        tokens: LexedTokens,
3402        start: int = 0
3403    ) -> Tuple[base.MechanismSpecifier, int]:
3404        """
3405        Parses a mechanism specifier starting at the specified position
3406        in the given tokens list. No ending position is specified, but
3407        instead this function returns a tuple containing the parsed
3408        `base.MechanismSpecifier` along with an index in the tokens list
3409        where the end of the specifier was found.
3410
3411        For example:
3412
3413        >>> pf = ParseFormat()
3414        >>> pf.parseMechanismSpecifierFromTokens(['m'])
3415        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3416 name='m'), 0)
3417        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'])
3418        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3419 name='a'), 0)
3420        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'], 1)
3421        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3422 name='m'), 1)
3423        >>> pf.parseMechanismSpecifierFromTokens(
3424        ...     ['a', Lexeme.domainSeparator, 'm']
3425        ... )
3426        (MechanismSpecifier(domain='a', zone=None, decision=None,\
3427 name='m'), 2)
3428        >>> pf.parseMechanismSpecifierFromTokens(
3429        ...     ['a', Lexeme.zoneSeparator, 'm']
3430        ... )
3431        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3432 name='m'), 2)
3433        >>> pf.parseMechanismSpecifierFromTokens(
3434        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
3435        ... )
3436        (MechanismSpecifier(domain=None, zone='a', decision='b',\
3437 name='m'), 4)
3438        >>> pf.parseMechanismSpecifierFromTokens(
3439        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
3440        ... )
3441        (MechanismSpecifier(domain='a', zone=None, decision='b',\
3442 name='m'), 4)
3443        >>> pf.parseMechanismSpecifierFromTokens(
3444        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
3445        ... )
3446        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3447 name='b'), 2)
3448        >>> pf.parseMechanismSpecifierFromTokens(
3449        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3450        ...     1
3451        ... )
3452        Traceback (most recent call last):
3453        ...
3454        exploration.parsing.ParseError...
3455        >>> pf.parseMechanismSpecifierFromTokens(
3456        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3457        ...     2
3458        ... )
3459        (MechanismSpecifier(domain='b', zone=None, decision=None,\
3460 name='m'), 4)
3461        >>> pf.parseMechanismSpecifierFromTokens(
3462        ...     [
3463        ...         'a',
3464        ...         Lexeme.domainSeparator,
3465        ...         'b',
3466        ...         Lexeme.zoneSeparator,
3467        ...         'c',
3468        ...         Lexeme.zoneSeparator,
3469        ...         'm'
3470        ...     ]
3471        ... )
3472        (MechanismSpecifier(domain='a', zone='b', decision='c', name='m'), 6)
3473        >>> pf.parseMechanismSpecifierFromTokens(
3474        ...     [
3475        ...         'a',
3476        ...         Lexeme.domainSeparator,
3477        ...         'b',
3478        ...         Lexeme.zoneSeparator,
3479        ...         'c',
3480        ...         Lexeme.zoneSeparator,
3481        ...         'm'
3482        ...     ],
3483        ...     2
3484        ... )
3485        (MechanismSpecifier(domain=None, zone='b', decision='c',\
3486 name='m'), 6)
3487        >>> pf.parseMechanismSpecifierFromTokens(
3488        ...     [
3489        ...         'a',
3490        ...         Lexeme.domainSeparator,
3491        ...         'b',
3492        ...         Lexeme.zoneSeparator,
3493        ...         'c',
3494        ...         Lexeme.zoneSeparator,
3495        ...         'm'
3496        ...     ],
3497        ...     4
3498        ... )
3499        (MechanismSpecifier(domain=None, zone=None, decision='c',\
3500 name='m'), 6)
3501        >>> pf.parseMechanismSpecifierFromTokens(
3502        ...     [
3503        ...         'roomB',
3504        ...         Lexeme.zoneSeparator,
3505        ...         'switch',
3506        ...         Lexeme.mechanismSeparator,
3507        ...         'on'
3508        ...     ]
3509        ... )
3510        (MechanismSpecifier(domain=None, zone=None, decision='roomB',\
3511 name='switch'), 2)
3512        >>> pf.parseMechanismSpecifierFromTokens(
3513        ...     [
3514        ...         '500',
3515        ...         Lexeme.zoneSeparator,
3516        ...         'm'
3517        ...     ],
3518        ...     0
3519        ... )
3520        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3521 name='m'), 2)
3522        >>> pf.parseMechanismSpecifierFromTokens(
3523        ...     [
3524        ...         '500',
3525        ...         Lexeme.zoneSeparator,
3526        ...         Lexeme.zoneSeparator,
3527        ...         'm'
3528        ...     ],
3529        ...     0
3530        ... )
3531        Traceback (most recent call last):
3532        ...
3533        exploration.parsing.ParseError...
3534        >>> pf.parseMechanismSpecifierFromTokens(
3535        ...     [
3536        ...         '500',
3537        ...         Lexeme.zoneSeparator,
3538        ...         'm',
3539        ...         Lexeme.mechanismSeparator,
3540        ...         'on'
3541        ...     ],
3542        ...     0
3543        ... )
3544        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3545 name='m'), 2)
3546        >>> pf.parseMechanismSpecifierFromTokens(
3547        ...     [
3548        ...         '500',
3549        ...         Lexeme.zoneSeparator,
3550        ...         'd',
3551        ...         Lexeme.zoneSeparator,
3552        ...         'm'
3553        ...     ],
3554        ...     0
3555        ... )
3556        (MechanismSpecifier(domain=None, zone='500', decision='d',\
3557 name='m'), 4)
3558        """
3559        start, tEnd, nLeft = normalizeEnds(tokens, start, -1)
3560
3561        try:
3562            dSpec, dEnd = self.parseDecisionSpecifierFromTokens(
3563                tokens,
3564                start
3565            )
3566        except ParseError:
3567            raise ParseError(
3568                "Failed to parse mechanism specifier couldn't parse"
3569                " initial mechanism name."
3570            )
3571
3572        # Note: This doesn't normally happen because the mechanism name
3573        # makes it seem like the integer decision ID is really a zone
3574        # name.
3575        if isinstance(dSpec, int):
3576            sep = tokens[dEnd + 1]
3577            after = tokens[dEnd + 2]
3578
3579            if sep == Lexeme.zoneSeparator and not isinstance(after, Lexeme):
3580                return (
3581                    base.MechanismSpecifier(
3582                        domain=None,
3583                        zone=None,
3584                        decision=dSpec,
3585                        name=after
3586                    ),
3587                    dEnd + 2
3588                )
3589            else:
3590                raise ParseError(
3591                    f"Invalid mechanism specifier: got a decision ID"
3592                    f" NOT followed by a zone separator and mechanism"
3593                    f" name. Got: {tokens[start:]}"
3594                )
3595
3596        mDomain = dSpec.domain
3597        if dEnd == tEnd or dEnd == tEnd - 1:
3598            if dSpec.zone is not None:
3599                try:
3600                    # Case for integer "zone" -> integer decision ID
3601                    zID = int(dSpec.zone)
3602                    return (
3603                        base.MechanismSpecifier(
3604                            domain=None,
3605                            zone=None,
3606                            decision=zID,
3607                            name=dSpec.name
3608                        ),
3609                        dEnd
3610                    )
3611                except ValueError:
3612                    pass
3613            return (
3614                base.MechanismSpecifier(
3615                    domain=mDomain,
3616                    zone=None,
3617                    decision=dSpec.zone,
3618                    name=dSpec.name
3619                ),
3620                dEnd
3621            )
3622
3623        sep = tokens[dEnd + 1]
3624        after = tokens[dEnd + 2]
3625
3626        mDec: Optional[Union[base.DecisionName, base.DecisionID]]
3627        if sep == Lexeme.zoneSeparator:
3628            if isinstance(after, Lexeme):
3629                mZone = None
3630                mDec = dSpec.zone
3631                mName = dSpec.name
3632                mEnd = dEnd
3633            else:
3634                mZone = dSpec.zone
3635                mDec = dSpec.name
3636                mName = after
3637                mEnd = dEnd + 2
3638        else:
3639            mZone = None
3640            mDec = dSpec.zone
3641            mName = dSpec.name
3642            mEnd = dEnd
3643
3644        # Treat numerical decision "names" as decision IDs
3645        if mDec is not None:
3646            try:
3647                mDec = int(mDec)
3648                if mDomain is not None or mZone is not None:
3649                    raise ParseError(
3650                        f"Invalid mechanism specifier: got a numerical"
3651                        f" decision ID but also a domain and/or zone."
3652                        f" Got: {tokens[start:]}"
3653                    )
3654            except ValueError:
3655                pass
3656
3657        return (
3658            base.MechanismSpecifier(
3659                domain=mDomain,
3660                zone=mZone,
3661                decision=mDec,
3662                name=mName
3663            ),
3664            mEnd
3665        )
3666
3667    def groupReqTokens(
3668        self,
3669        tokens: LexedTokens,
3670        start: int = 0,
3671        end: int = -1
3672    ) -> GroupedTokens:
3673        """
3674        Groups tokens for a requirement, stripping out all parentheses
3675        but replacing parenthesized expressions with sub-lists of tokens.
3676
3677        For example:
3678
3679        >>> pf = ParseFormat()
3680        >>> pf.groupReqTokens(['jump'])
3681        ['jump']
3682        >>> pf.groupReqTokens([Lexeme.openParen, 'jump'])
3683        Traceback (most recent call last):
3684        ...
3685        exploration.parsing.ParseError...
3686        >>> pf.groupReqTokens([Lexeme.closeParen, 'jump'])
3687        Traceback (most recent call last):
3688        ...
3689        exploration.parsing.ParseError...
3690        >>> pf.groupReqTokens(['jump', Lexeme.closeParen])
3691        Traceback (most recent call last):
3692        ...
3693        exploration.parsing.ParseError...
3694        >>> pf.groupReqTokens([Lexeme.openParen, 'jump', Lexeme.closeParen])
3695        [['jump']]
3696        >>> pf.groupReqTokens(
3697        ...     [
3698        ...         Lexeme.openParen,
3699        ...         'jump',
3700        ...         Lexeme.orBar,
3701        ...         'climb',
3702        ...         Lexeme.closeParen,
3703        ...         Lexeme.ampersand,
3704        ...         'crawl',
3705        ...     ]
3706        ... )
3707        [['jump', <Lexeme.orBar: ...>, 'climb'], <Lexeme.ampersand: ...>,\
3708 'crawl']
3709        """
3710        start, end, nTokens = normalizeEnds(tokens, start, end)
3711        if nTokens == 0:
3712            raise ParseError("Ran out of tokens.")
3713
3714        resultsStack: List[GroupedTokens] = [[]]
3715        here = start
3716        while here <= end:
3717            token = tokens[here]
3718            here += 1
3719            if token == Lexeme.closeParen:
3720                if len(resultsStack) == 1:
3721                    raise ParseError(
3722                        f"Too many closing parens at index {here - 1}"
3723                        f" in:\n{tokens[start:end + 1]}"
3724                    )
3725                else:
3726                    closed = resultsStack.pop()
3727                    resultsStack[-1].append(closed)
3728            elif token == Lexeme.openParen:
3729                resultsStack.append([])
3730            else:
3731                resultsStack[-1].append(token)
3732        if len(resultsStack) != 1:
3733            raise ParseError(
3734                f"Mismatched parentheses in tokens:"
3735                f"\n{tokens[start:end + 1]}"
3736            )
3737        return resultsStack[0]
3738
3739    def groupReqTokensByPrecedence(
3740        self,
3741        tokenGroups: GroupedTokens
3742    ) -> GroupedRequirementParts:
3743        """
3744        Re-groups requirement tokens that have been grouped using
3745        `groupReqTokens` according to operator precedence, effectively
3746        creating an equivalent result which would have been obtained by
3747        `groupReqTokens` if all possible non-redundant explicit
3748        parentheses had been included.
3749
3750        Also turns each leaf part into a `Requirement`.
3751
3752        TODO: Make this actually reasonably efficient T_T
3753
3754        Examples:
3755
3756        >>> pf = ParseFormat()
3757        >>> r = pf.parseRequirement('capability&roomB::switch:on')
3758        >>> pf.groupReqTokensByPrecedence(
3759        ...     [
3760        ...         ['jump', Lexeme.orBar, 'climb'],
3761        ...         Lexeme.ampersand,
3762        ...         Lexeme.notMarker,
3763        ...         'coin',
3764        ...         Lexeme.tokenCount,
3765        ...         '3'
3766        ...     ]
3767        ... )
3768        [\
3769[\
3770[[ReqCapability('jump'), <Lexeme.orBar: ...>, ReqCapability('climb')]],\
3771 <Lexeme.ampersand: ...>,\
3772 [<Lexeme.notMarker: ...>, ReqTokens('coin', 3)]\
3773]\
3774]
3775        """
3776        subgrouped: List[Union[Lexeme, str, GroupedRequirementParts]] = []
3777        # First recursively group all parenthesized expressions
3778        for i, item in enumerate(tokenGroups):
3779            if isinstance(item, list):
3780                subgrouped.append(self.groupReqTokensByPrecedence(item))
3781            else:
3782                subgrouped.append(item)
3783
3784        # Now process all leaf requirements
3785        leavesConverted: GroupedRequirementParts = []
3786        i = 0
3787        while i < len(subgrouped):
3788            gItem = subgrouped[i]
3789
3790            if isinstance(gItem, list):
3791                leavesConverted.append(gItem)
3792            elif isinstance(gItem, Lexeme):
3793                leavesConverted.append(gItem)
3794            elif i == len(subgrouped) - 1:
3795                if isinstance(gItem, Lexeme):
3796                    raise ParseError(
3797                        f"Lexeme at end of requirement. Grouped tokens:"
3798                        f"\n{tokenGroups}"
3799                    )
3800                else:
3801                    assert isinstance(gItem, str)
3802                    if gItem == 'X':
3803                        leavesConverted.append(base.ReqImpossible())
3804                    elif gItem == 'O':
3805                        leavesConverted.append(base.ReqNothing())
3806                    else:
3807                        leavesConverted.append(base.ReqCapability(gItem))
3808            else:
3809                assert isinstance(gItem, str)
3810                try:
3811                    # TODO: Avoid list copy here...
3812                    couldBeMechanismSpecifier: LexedTokens = []
3813                    for ii in range(i, len(subgrouped)):
3814                        lexemeOrStr = subgrouped[ii]
3815                        if isinstance(lexemeOrStr, (Lexeme, str)):
3816                            couldBeMechanismSpecifier.append(lexemeOrStr)
3817                        else:
3818                            break
3819                    mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
3820                        couldBeMechanismSpecifier
3821                    )
3822                    mEnd += i
3823                    if (
3824                        mEnd >= len(subgrouped) - 2
3825                     or subgrouped[mEnd + 1] != Lexeme.mechanismSeparator
3826                    ):
3827                        raise ParseError("Not a mechanism requirement.")
3828
3829                    mState = subgrouped[mEnd + 2]
3830                    if not isinstance(mState, base.MechanismState):
3831                        raise ParseError("Not a mechanism requirement.")
3832                    leavesConverted.append(base.ReqMechanism(mSpec, mState))
3833                    i = mEnd + 2  # + 1 will happen automatically below
3834                except ParseError:
3835                    following = subgrouped[i + 1]
3836                    if following in (
3837                        Lexeme.tokenCount,
3838                        Lexeme.mechanismSeparator,
3839                        Lexeme.wigglyLine,
3840                        Lexeme.skillLevel
3841                    ):
3842                        if (
3843                            i == len(subgrouped) - 2
3844                         or isinstance(subgrouped[i + 2], Lexeme)
3845                        ):
3846                            if following == Lexeme.wigglyLine:
3847                                # Default tag value is 1
3848                                leavesConverted.append(base.ReqTag(gItem, 1))
3849                                i += 1  # another +1 automatic below
3850                            else:
3851                                raise ParseError(
3852                                    f"Lexeme at end of requirement. Grouped"
3853                                    f" tokens:\n{tokenGroups}"
3854                                )
3855                        else:
3856                            afterwards = subgrouped[i + 2]
3857                            if not isinstance(afterwards, str):
3858                                raise ParseError(
3859                                    f"Lexeme after token/mechanism/tag/skill"
3860                                    f" separator at index {i}."
3861                                    f" Grouped tokens:\n{tokenGroups}"
3862                                )
3863                            i += 2  # another +1 automatic below
3864                            if following == Lexeme.tokenCount:
3865                                try:
3866                                    tCount = int(afterwards)
3867                                except ValueError:
3868                                    raise ParseError(
3869                                        f"Token count could not be"
3870                                        f" parsed as an integer:"
3871                                        f" {afterwards!r}. Grouped"
3872                                        f" tokens:\n{tokenGroups}"
3873                                    )
3874                                leavesConverted.append(
3875                                    base.ReqTokens(gItem, tCount)
3876                                )
3877                            elif following == Lexeme.mechanismSeparator:
3878                                leavesConverted.append(
3879                                    base.ReqMechanism(gItem, afterwards)
3880                                )
3881                            elif following == Lexeme.wigglyLine:
3882                                tVal = self.parseTagValue(afterwards)
3883                                leavesConverted.append(
3884                                    base.ReqTag(gItem, tVal)
3885                                )
3886                            else:
3887                                assert following == Lexeme.skillLevel
3888                                try:
3889                                    sLevel = int(afterwards)
3890                                except ValueError:
3891                                    raise ParseError(
3892                                        f"Skill level could not be"
3893                                        f" parsed as an integer:"
3894                                        f" {afterwards!r}. Grouped"
3895                                        f" tokens:\n{tokenGroups}"
3896                                    )
3897                                leavesConverted.append(
3898                                    base.ReqLevel(gItem, sLevel)
3899                                )
3900                    else:
3901                        if gItem == 'X':
3902                            leavesConverted.append(base.ReqImpossible())
3903                        elif gItem == 'O':
3904                            leavesConverted.append(base.ReqNothing())
3905                        else:
3906                            leavesConverted.append(
3907                                base.ReqCapability(gItem)
3908                            )
3909
3910            # Finally, increment our index:
3911            i += 1
3912
3913        # Now group all NOT operators
3914        i = 0
3915        notsGrouped: GroupedRequirementParts = []
3916        while i < len(leavesConverted):
3917            leafItem = leavesConverted[i]
3918            group = []
3919            while leafItem == Lexeme.notMarker:
3920                group.append(leafItem)
3921                i += 1
3922                if i >= len(leavesConverted):
3923                    raise ParseError(
3924                        f"NOT at end of tokens:\n{leavesConverted}"
3925                    )
3926                leafItem = leavesConverted[i]
3927            if group == []:
3928                notsGrouped.append(leafItem)
3929                i += 1
3930            else:
3931                group.append(leafItem)
3932                i += 1
3933                notsGrouped.append(group)
3934
3935        # Next group all AND operators
3936        i = 0
3937        andsGrouped: GroupedRequirementParts = []
3938        while i < len(notsGrouped):
3939            notGroupItem = notsGrouped[i]
3940            if notGroupItem == Lexeme.ampersand:
3941                if i == len(notsGrouped) - 1:
3942                    raise ParseError(
3943                        f"AND at end of group in tokens:"
3944                        f"\n{tokenGroups}"
3945                        f"Which had been grouped into:"
3946                        f"\n{notsGrouped}"
3947                    )
3948                itemAfter = notsGrouped[i + 1]
3949                if isinstance(itemAfter, Lexeme):
3950                    raise ParseError(
3951                        f"Lexeme after AND in of group in tokens:"
3952                        f"\n{tokenGroups}"
3953                        f"Which had been grouped into:"
3954                        f"\n{notsGrouped}"
3955                    )
3956                assert isinstance(itemAfter, (base.Requirement, list))
3957                prev = andsGrouped[-1]
3958                if (
3959                    isinstance(prev, list)
3960                and len(prev) > 2
3961                and prev[1] == Lexeme.ampersand
3962                ):
3963                    prev.extend(notsGrouped[i:i + 2])
3964                    i += 1  # with an extra +1 below
3965                else:
3966                    andsGrouped.append(
3967                        [andsGrouped.pop()] + notsGrouped[i:i + 2]
3968                    )
3969                    i += 1 # extra +1 below
3970            else:
3971                andsGrouped.append(notGroupItem)
3972            i += 1
3973
3974        # Finally check that we only have OR operators left over
3975        i = 0
3976        finalResult: GroupedRequirementParts = []
3977        while i < len(andsGrouped):
3978            andGroupItem = andsGrouped[i]
3979            if andGroupItem == Lexeme.orBar:
3980                if i == len(andsGrouped) - 1:
3981                    raise ParseError(
3982                        f"OR at end of group in tokens:"
3983                        f"\n{tokenGroups}"
3984                        f"Which had been grouped into:"
3985                        f"\n{andsGrouped}"
3986                    )
3987                itemAfter = andsGrouped[i + 1]
3988                if isinstance(itemAfter, Lexeme):
3989                    raise ParseError(
3990                        f"Lexeme after OR in of group in tokens:"
3991                        f"\n{tokenGroups}"
3992                        f"Which had been grouped into:"
3993                        f"\n{andsGrouped}"
3994                    )
3995                assert isinstance(itemAfter, (base.Requirement, list))
3996                prev = finalResult[-1]
3997                if (
3998                    isinstance(prev, list)
3999                and len(prev) > 2
4000                and prev[1] == Lexeme.orBar
4001                ):
4002                    prev.extend(andsGrouped[i:i + 2])
4003                    i += 1  # with an extra +1 below
4004                else:
4005                    finalResult.append(
4006                        [finalResult.pop()] + andsGrouped[i:i + 2]
4007                    )
4008                    i += 1 # extra +1 below
4009            elif isinstance(andGroupItem, Lexeme):
4010                raise ParseError(
4011                    f"Leftover lexeme when grouping ORs at index {i}"
4012                    f" in grouped tokens:\n{andsGrouped}"
4013                    f"\nOriginal tokens were:\n{tokenGroups}"
4014                )
4015            else:
4016                finalResult.append(andGroupItem)
4017            i += 1
4018
4019        return finalResult
4020
4021    def parseRequirementFromRegroupedTokens(
4022        self,
4023        reqGroups: GroupedRequirementParts
4024    ) -> base.Requirement:
4025        """
4026        Recursive parser that works once tokens have been turned into
4027        requirements at the leaves and grouped by operator precedence
4028        otherwise (see `groupReqTokensByPrecedence`).
4029
4030        TODO: Simplify by just doing this while grouping... ?
4031        """
4032        if len(reqGroups) == 0:
4033            raise ParseError("Ran out of tokens.")
4034
4035        elif len(reqGroups) == 1:
4036            only = reqGroups[0]
4037            if isinstance(only, list):
4038                return self.parseRequirementFromRegroupedTokens(only)
4039            elif isinstance(only, base.Requirement):
4040                return only
4041            else:
4042                raise ParseError(f"Invalid singleton group:\n{only}")
4043        elif reqGroups[0] == Lexeme.notMarker:
4044            if (
4045                not all(x == Lexeme.notMarker for x in reqGroups[:-1])
4046             or not isinstance(reqGroups[-1], (list, base.Requirement))
4047            ):
4048                raise ParseError(f"Invalid negation group:\n{reqGroups}")
4049            result = reqGroups[-1]
4050            if isinstance(result, list):
4051                result = self.parseRequirementFromRegroupedTokens(result)
4052            assert isinstance(result, base.Requirement)
4053            for i in range(len(reqGroups) - 1):
4054                result = base.ReqNot(result)
4055            return result
4056        elif len(reqGroups) % 2 == 0:
4057            raise ParseError(f"Even-length non-negation group:\n{reqGroups}")
4058        else:
4059            if (
4060                reqGroups[1] not in (Lexeme.ampersand, Lexeme.orBar)
4061             or not all(
4062                    reqGroups[i] == reqGroups[1]
4063                    for i in range(1, len(reqGroups), 2)
4064                )
4065            ):
4066                raise ParseError(
4067                    f"Inconsistent operator(s) in group:\n{reqGroups}"
4068                )
4069            op = reqGroups[1]
4070            operands = [
4071                (
4072                    self.parseRequirementFromRegroupedTokens(x)
4073                    if isinstance(x, list)
4074                    else x
4075                )
4076                for x in reqGroups[::2]
4077            ]
4078            if not all(isinstance(x, base.Requirement) for x in operands):
4079                raise ParseError(
4080                    f"Item not reducible to Requirement in AND group:"
4081                    f"\n{reqGroups}"
4082                )
4083            reqSequence = cast(Sequence[base.Requirement], operands)
4084            if op == Lexeme.ampersand:
4085                return base.ReqAll(reqSequence).flatten()
4086            else:
4087                assert op == Lexeme.orBar
4088                return base.ReqAny(reqSequence).flatten()
4089
4090    def parseRequirementFromGroupedTokens(
4091        self,
4092        tokenGroups: GroupedTokens
4093    ) -> base.Requirement:
4094        """
4095        Parses a `base.Requirement` from a pre-grouped tokens list (see
4096        `groupReqTokens`). Uses the 'orBar', 'ampersand', 'notMarker',
4097        'tokenCount', and 'mechanismSeparator' `Lexeme`s to provide
4098        'or', 'and', and 'not' operators along with distinguishing
4099        between capabilities, tokens, and mechanisms.
4100
4101        Precedence ordering is not, then and, then or, but you are
4102        encouraged to use parentheses for explicit grouping (the
4103        'openParen' and 'closeParen' `Lexeme`s, although these must be
4104        handled by `groupReqTokens` so this function won't see them
4105        directly).
4106
4107        You can also use 'X' (without quotes) for a never-satisfied
4108        requirement, and 'O' (without quotes) for an always-satisfied
4109        requirement.
4110
4111        Note that when '!' is applied to a token requirement it flips
4112        the sense of the integer from 'must have at least this many' to
4113        'must have strictly less than this many'.
4114
4115        Raises a `ParseError` if the grouped tokens it is given cannot
4116        be parsed as a `Requirement`.
4117
4118        Examples:
4119
4120        >>> pf = ParseFormat()
4121        >>> pf.parseRequirementFromGroupedTokens(['capability'])
4122        ReqCapability('capability')
4123        >>> pf.parseRequirementFromGroupedTokens(
4124        ...     ['token', Lexeme.tokenCount, '3']
4125        ... )
4126        ReqTokens('token', 3)
4127        >>> pf.parseRequirementFromGroupedTokens(
4128        ...     ['mechanism', Lexeme.mechanismSeparator, 'state']
4129        ... )
4130        ReqMechanism('mechanism', 'state')
4131        >>> pf.parseRequirementFromGroupedTokens(
4132        ...     ['capability', Lexeme.orBar, 'token',
4133        ...      Lexeme.tokenCount, '3']
4134        ... )
4135        ReqAny([ReqCapability('capability'), ReqTokens('token', 3)])
4136        >>> pf.parseRequirementFromGroupedTokens(
4137        ...     ['one', Lexeme.ampersand, 'two', Lexeme.orBar, 'three']
4138        ... )
4139        ReqAny([ReqAll([ReqCapability('one'), ReqCapability('two')]),\
4140 ReqCapability('three')])
4141        >>> pf.parseRequirementFromGroupedTokens(
4142        ...     [
4143        ...         'one',
4144        ...         Lexeme.ampersand,
4145        ...         [
4146        ...              'two',
4147        ...              Lexeme.orBar,
4148        ...              'three'
4149        ...         ]
4150        ...     ]
4151        ... )
4152        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4153 ReqCapability('three')])])
4154        >>> pf.parseRequirementFromTokens(['X'])
4155        ReqImpossible()
4156        >>> pf.parseRequirementFromTokens(['O'])
4157        ReqNothing()
4158        >>> pf.parseRequirementFromTokens(
4159        ...     [Lexeme.openParen, 'O', Lexeme.closeParen]
4160        ... )
4161        ReqNothing()
4162        """
4163        if len(tokenGroups) == 0:
4164            raise ParseError("Ran out of tokens.")
4165
4166        reGrouped = self.groupReqTokensByPrecedence(tokenGroups)
4167
4168        return self.parseRequirementFromRegroupedTokens(reGrouped)
4169
4170    def parseRequirementFromTokens(
4171        self,
4172        tokens: LexedTokens,
4173        start: int = 0,
4174        end: int = -1
4175    ) -> base.Requirement:
4176        """
4177        Parses a requirement from `LexedTokens` by grouping them first
4178        and then using `parseRequirementFromGroupedTokens`.
4179
4180        For example:
4181
4182        >>> pf = ParseFormat()
4183        >>> pf.parseRequirementFromTokens(
4184        ...     [
4185        ...         'one',
4186        ...         Lexeme.ampersand,
4187        ...         Lexeme.openParen,
4188        ...         'two',
4189        ...         Lexeme.orBar,
4190        ...         'three',
4191        ...         Lexeme.closeParen
4192        ...     ]
4193        ... )
4194        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4195 ReqCapability('three')])])
4196        """
4197        grouped = self.groupReqTokens(tokens, start, end)
4198        return self.parseRequirementFromGroupedTokens(grouped)
4199
4200    def parseRequirement(self, encoded: str) -> base.Requirement:
4201        """
4202        Parses a `base.Requirement` from a string by calling `lex` and
4203        then feeding it into `ParseFormat.parseRequirementFromTokens`.
4204        As stated in `parseRequirementFromTokens`, the precedence
4205        binding order is NOT, then AND, then OR.
4206
4207        For example:
4208
4209        >>> pf = ParseFormat()
4210        >>> pf.parseRequirement('! coin * 3')
4211        ReqNot(ReqTokens('coin', 3))
4212        >>> pf.parseRequirement(
4213        ...     '  oneWord | "two words"|"three  words words" '
4214        ... )
4215        ReqAny([ReqCapability('oneWord'), ReqCapability('"two words"'),\
4216 ReqCapability('"three  words words"')])
4217        >>> pf.parseRequirement('words-with-dashes')
4218        ReqCapability('words-with-dashes')
4219        >>> r = pf.parseRequirement('capability&roomB::switch:on')
4220        >>> r
4221        ReqAll([ReqCapability('capability'),\
4222 ReqMechanism(MechanismSpecifier(domain=None, zone=None, decision='roomB',\
4223 name='switch'), 'on')])
4224        >>> r.unparse()
4225        '(capability&roomB::switch:on)'
4226        >>> pf.parseRequirement('!!!one')
4227        ReqNot(ReqNot(ReqNot(ReqCapability('one'))))
4228        >>> pf.parseRequirement('domain//zone::where::mechanism:state')
4229        ReqMechanism(MechanismSpecifier(domain='domain', zone='zone',\
4230 decision='where', name='mechanism'), 'state')
4231        >>> pf.parseRequirement('domain//mechanism:state')
4232        ReqMechanism(MechanismSpecifier(domain='domain', zone=None,\
4233 decision=None, name='mechanism'), 'state')
4234        >>> pf.parseRequirement('where::mechanism:state')
4235        ReqMechanism(MechanismSpecifier(domain=None, zone=None,\
4236 decision='where', name='mechanism'), 'state')
4237        >>> pf.parseRequirement('zone::where::mechanism:state')
4238        ReqMechanism(MechanismSpecifier(domain=None, zone='zone',\
4239 decision='where', name='mechanism'), 'state')
4240        >>> pf.parseRequirement('tag~')
4241        ReqTag('tag', 1)
4242        >>> pf.parseRequirement('tag~&tag2~')
4243        ReqAll([ReqTag('tag', 1), ReqTag('tag2', 1)])
4244        >>> pf.parseRequirement('tag~value|tag~3|tag~3.5|skill^3')
4245        ReqAny([ReqTag('tag', 'value'), ReqTag('tag', 3),\
4246 ReqTag('tag', 3.5), ReqLevel('skill', 3)])
4247        >>> pf.parseRequirement('tag~True|tag~False|tag~None')
4248        ReqAny([ReqTag('tag', True), ReqTag('tag', False), ReqTag('tag', None)])
4249
4250        Precedence examples:
4251
4252        >>> pf.parseRequirement('A|B&C')
4253        ReqAny([ReqCapability('A'), ReqAll([ReqCapability('B'),\
4254 ReqCapability('C')])])
4255        >>> pf.parseRequirement('A&B|C')
4256        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4257 ReqCapability('C')])
4258        >>> pf.parseRequirement('(A&B)|C')
4259        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4260 ReqCapability('C')])
4261        >>> pf.parseRequirement('(A&B|C)&D')
4262        ReqAll([ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4263 ReqCapability('C')]), ReqCapability('D')])
4264
4265        Error examples:
4266
4267        >>> pf.parseRequirement('one ! Word')
4268        Traceback (most recent call last):
4269        ...
4270        exploration.parsing.ParseError...
4271        >>> pf.parseRequirement('a|')
4272        Traceback (most recent call last):
4273        ...
4274        exploration.parsing.ParseError...
4275        >>> pf.parseRequirement('b!')
4276        Traceback (most recent call last):
4277        ...
4278        exploration.parsing.ParseError...
4279        >>> pf.parseRequirement('*emph*')
4280        Traceback (most recent call last):
4281        ...
4282        exploration.parsing.ParseError...
4283        >>> pf.parseRequirement('one&&two')
4284        Traceback (most recent call last):
4285        ...
4286        exploration.parsing.ParseError...
4287        >>> pf.parseRequirement('one!|two')
4288        Traceback (most recent call last):
4289        ...
4290        exploration.parsing.ParseError...
4291        >>> pf.parseRequirement('one*two')
4292        Traceback (most recent call last):
4293        ...
4294        exploration.parsing.ParseError...
4295        >>> pf.parseRequirement('one*')
4296        Traceback (most recent call last):
4297        ...
4298        exploration.parsing.ParseError...
4299        >>> pf.parseRequirement('()')
4300        Traceback (most recent call last):
4301        ...
4302        exploration.parsing.ParseError...
4303        >>> pf.parseRequirement('(one)*3')
4304        Traceback (most recent call last):
4305        ...
4306        exploration.parsing.ParseError...
4307        >>> pf.parseRequirement('a:')
4308        Traceback (most recent call last):
4309        ...
4310        exploration.parsing.ParseError...
4311        >>> pf.parseRequirement('a:b:c')
4312        Traceback (most recent call last):
4313        ...
4314        exploration.parsing.ParseError...
4315        >>> pf.parseRequirement('where::capability')
4316        Traceback (most recent call last):
4317        ...
4318        exploration.parsing.ParseError...
4319        """
4320        return self.parseRequirementFromTokens(
4321            lex(encoded, self.reverseFormat)
4322        )
4323
4324    def parseSkillCombinationFromTokens(
4325        self,
4326        tokens: LexedTokens,
4327        start: int = 0,
4328        end: int = -1
4329    ) -> Union[base.Skill, base.SkillCombination]:
4330        """
4331        Parses a skill combination from the specified range within the
4332        given tokens list. If just a single string token is selected, it
4333        will be returned as a `base.BestSkill` with just that skill
4334        inside.
4335
4336        For example:
4337
4338        >>> pf = ParseFormat()
4339        >>> pf.parseSkillCombinationFromTokens(['climbing'])
4340        BestSkill('climbing')
4341        >>> tokens = [
4342        ...     'best',
4343        ...     Lexeme.openParen,
4344        ...     'brains',
4345        ...     Lexeme.sepOrDelay,
4346        ...     'brawn',
4347        ...     Lexeme.closeParen,
4348        ... ]
4349        >>> pf.parseSkillCombinationFromTokens(tokens)
4350        BestSkill('brains', 'brawn')
4351        >>> tokens[2] = '3'  # not a lexeme so it's a string
4352        >>> pf.parseSkillCombinationFromTokens(tokens)
4353        BestSkill(3, 'brawn')
4354        >>> tokens = [
4355        ...     Lexeme.wigglyLine,
4356        ...     Lexeme.wigglyLine,
4357        ...     'yes',
4358        ... ]
4359        >>> pf.parseSkillCombinationFromTokens(tokens)
4360        InverseSkill(InverseSkill('yes'))
4361        """
4362        start, end, nTokens = normalizeEnds(tokens, start, end)
4363
4364        first = tokens[start]
4365        if nTokens == 1:
4366            if isinstance(first, base.Skill):
4367                try:
4368                    level = int(first)
4369                    return base.BestSkill(level)
4370                except ValueError:
4371                    return base.BestSkill(first)
4372            else:
4373                raise ParseError(
4374                    "Invalid SkillCombination:\n{tokens[start:end + 1]"
4375                )
4376
4377        if first == Lexeme.wigglyLine:
4378            inv = self.parseSkillCombinationFromTokens(
4379                tokens,
4380                start + 1,
4381                end
4382            )
4383            if isinstance(inv, base.BestSkill) and len(inv.skills) == 1:
4384                return base.InverseSkill(inv.skills[0])
4385            else:
4386                return base.InverseSkill(inv)
4387
4388        second = tokens[start + 1]
4389        if second != Lexeme.openParen:
4390            raise ParseError(
4391                f"Invalid SkillCombination (missing paren):"
4392                f"\n{tokens[start:end + 1]}"
4393            )
4394
4395        parenEnd = self.matchingBrace(
4396            tokens,
4397            start + 1,
4398            Lexeme.openParen,
4399            Lexeme.closeParen
4400        )
4401        if parenEnd != end:
4402            raise ParseError(
4403                f"Extra junk after SkillCombination:"
4404                f"\n{tokens[parenEnd + 1:end + 1]}"
4405            )
4406
4407        if first == 'if':
4408            parts = list(
4409                findSeparatedParts(
4410                    tokens,
4411                    Lexeme.sepOrDelay,
4412                    start + 2,
4413                    end - 1,
4414                    Lexeme.openParen,
4415                    Lexeme.closeParen
4416                )
4417            )
4418            if len(parts) != 3:
4419                raise ParseError(
4420                    f"Wrong number of parts for ConditionalSkill (needs"
4421                    f" 3, got {len(parts)}:"
4422                    f"\n{tokens[start + 2:end]}"
4423                )
4424            reqStart, reqEnd = parts[0]
4425            ifStart, ifEnd = parts[1]
4426            elseStart, elseEnd = parts[2]
4427            return base.ConditionalSkill(
4428                self.parseRequirementFromTokens(tokens, reqStart, reqEnd),
4429                self.parseSkillCombinationFromTokens(tokens, ifStart, ifEnd),
4430                self.parseSkillCombinationFromTokens(
4431                    tokens,
4432                    elseStart,
4433                    elseEnd
4434                ),
4435            )
4436        elif first in ('sum', 'best', 'worst'):
4437            make: type[base.SkillCombination]
4438            if first == 'sum':
4439                make = base.CombinedSkill
4440            elif first == 'best':
4441                make = base.BestSkill
4442            else:
4443                make = base.WorstSkill
4444
4445            subs = []
4446            for partStart, partEnd in findSeparatedParts(
4447                tokens,
4448                Lexeme.sepOrDelay,
4449                start + 2,
4450                end - 1,
4451                Lexeme.openParen,
4452                Lexeme.closeParen
4453            ):
4454                sub = self.parseSkillCombinationFromTokens(
4455                    tokens,
4456                    partStart,
4457                    partEnd
4458                )
4459                if (
4460                    isinstance(sub, base.BestSkill)
4461                and len(sub.skills) == 1
4462                ):
4463                    subs.append(sub.skills[0])
4464                else:
4465                    subs.append(sub)
4466
4467            return make(*subs)
4468        else:
4469            raise ParseError(
4470                "Invalid SkillCombination:\n{tokens[start:end + 1]"
4471            )
4472
4473    def parseSkillCombination(
4474        self,
4475        encoded: str
4476    ) -> base.SkillCombination:
4477        """
4478        Parses a `SkillCombination` from a string. Calls `lex` and then
4479        `parseSkillCombinationFromTokens`.
4480        """
4481        result = self.parseSkillCombinationFromTokens(
4482            lex(encoded, self.reverseFormat)
4483        )
4484        if not isinstance(result, base.SkillCombination):
4485            return base.BestSkill(result)
4486        else:
4487            return result
4488
4489    def parseConditionFromTokens(
4490        self,
4491        tokens: LexedTokens,
4492        start: int = 0,
4493        end: int = -1
4494    ) -> base.Condition:
4495        """
4496        Parses a `base.Condition` from a lexed tokens list. For example:
4497
4498        >>> pf = ParseFormat()
4499        >>> tokens = [
4500        ...     Lexeme.doubleQuestionmark,
4501        ...     Lexeme.openParen,
4502        ...     "fire",
4503        ...     Lexeme.ampersand,
4504        ...     "water",
4505        ...     Lexeme.closeParen,
4506        ...     Lexeme.openCurly,
4507        ...     "gain",
4508        ...     "wind",
4509        ...     Lexeme.closeCurly,
4510        ...     Lexeme.openCurly,
4511        ...     Lexeme.closeCurly,
4512        ... ]
4513        >>> pf.parseConditionFromTokens(tokens) == base.condition(
4514        ...     condition=base.ReqAll([
4515        ...         base.ReqCapability('fire'),
4516        ...         base.ReqCapability('water')
4517        ...     ]),
4518        ...     consequence=[base.effect(gain='wind')]
4519        ... )
4520        True
4521        """
4522        start, end, nTokens = normalizeEnds(tokens, start, end)
4523        if nTokens < 8:
4524            raise ParseError(
4525                f"A Condition requires at least 8 tokens (got {nTokens})."
4526            )
4527        if tokens[start] != Lexeme.doubleQuestionmark:
4528            raise ParseError(
4529                f"A Condition must start with"
4530                f" {repr(self.formatDict[Lexeme.doubleQuestionmark])}"
4531            )
4532        try:
4533            consequenceStart = tokens.index(Lexeme.openCurly, start)
4534        except ValueError:
4535            raise ParseError("A condition must include a consequence block.")
4536        consequenceEnd = self.matchingBrace(tokens, consequenceStart)
4537        altStart = consequenceEnd + 1
4538        altEnd = self.matchingBrace(tokens, altStart)
4539
4540        if altEnd != end:
4541            raise ParseError(
4542                f"Junk after condition:\n{tokens[altEnd + 1: end + 1]}"
4543            )
4544
4545        return base.condition(
4546            condition=self.parseRequirementFromTokens(
4547                tokens,
4548                start + 1,
4549                consequenceStart - 1
4550            ),
4551            consequence=self.parseConsequenceFromTokens(
4552                tokens,
4553                consequenceStart,
4554                consequenceEnd
4555            ),
4556            alternative=self.parseConsequenceFromTokens(
4557                tokens,
4558                altStart,
4559                altEnd
4560            )
4561        )
4562
4563    def parseCondition(
4564        self,
4565        encoded: str
4566    ) -> base.Condition:
4567        """
4568        Lexes the given string and then calls `parseConditionFromTokens`
4569        to return a `base.Condition`.
4570        """
4571        return self.parseConditionFromTokens(
4572            lex(encoded, self.reverseFormat)
4573        )
4574
4575    def parseChallengeFromTokens(
4576        self,
4577        tokens: LexedTokens,
4578        start: int = 0,
4579        end: int = -1
4580    ) -> base.Challenge:
4581        """
4582        Parses a `base.Challenge` from a lexed tokens list.
4583
4584        For example:
4585
4586        >>> pf = ParseFormat()
4587        >>> tokens = [
4588        ...     Lexeme.angleLeft,
4589        ...     '2',
4590        ...     Lexeme.angleRight,
4591        ...     'best',
4592        ...     Lexeme.openParen,
4593        ...     "chess",
4594        ...     Lexeme.sepOrDelay,
4595        ...     "checkers",
4596        ...     Lexeme.closeParen,
4597        ...     Lexeme.openCurly,
4598        ...     "gain",
4599        ...     "coin",
4600        ...     Lexeme.tokenCount,
4601        ...     "5",
4602        ...     Lexeme.closeCurly,
4603        ...     Lexeme.angleRight,
4604        ...     Lexeme.openCurly,
4605        ...     "lose",
4606        ...     "coin",
4607        ...     Lexeme.tokenCount,
4608        ...     "5",
4609        ...     Lexeme.closeCurly,
4610        ... ]
4611        >>> c = pf.parseChallengeFromTokens(tokens)
4612        >>> c['skills'] == base.BestSkill('chess', 'checkers')
4613        True
4614        >>> c['level']
4615        2
4616        >>> c['success'] == [base.effect(gain=('coin', 5))]
4617        True
4618        >>> c['failure'] == [base.effect(lose=('coin', 5))]
4619        True
4620        >>> c['outcome']
4621        False
4622        >>> c == base.challenge(
4623        ...     skills=base.BestSkill('chess', 'checkers'),
4624        ...     level=2,
4625        ...     success=[base.effect(gain=('coin', 5))],
4626        ...     failure=[base.effect(lose=('coin', 5))],
4627        ...     outcome=False
4628        ... )
4629        True
4630        >>> t2 = ['hi'] + tokens + ['bye']  # parsing only part of the list
4631        >>> c == pf.parseChallengeFromTokens(t2, 1, -2)
4632        True
4633        """
4634        start, end, nTokens = normalizeEnds(tokens, start, end)
4635        if nTokens < 8:
4636            raise ParseError(
4637                f"Not enough tokens for a challenge: {nTokens}"
4638            )
4639        if tokens[start] != Lexeme.angleLeft:
4640            raise ParseError(
4641                f"Challenge must start with"
4642                f" {repr(self.formatDict[Lexeme.angleLeft])}"
4643            )
4644        levelStr = tokens[start + 1]
4645        if isinstance(levelStr, Lexeme):
4646            raise ParseError(
4647                f"Challenge must start with a level in angle brackets"
4648                f" (got {repr(self.formatDict[levelStr])})."
4649            )
4650        if tokens[start + 2] != Lexeme.angleRight:
4651            raise ParseError(
4652                f"Challenge must include"
4653                f" {repr(self.formatDict[Lexeme.angleRight])} after"
4654                f" the level."
4655            )
4656        try:
4657            level = int(levelStr)
4658        except ValueError:
4659            raise ParseError(
4660                f"Challenge level must be an integer (got"
4661                f" {repr(tokens[start + 1])}."
4662            )
4663        try:
4664            successStart = tokens.index(Lexeme.openCurly, start)
4665            skillsEnd = successStart - 1
4666        except ValueError:
4667            raise ParseError("A challenge must include a consequence block.")
4668
4669        outcome: Optional[bool] = None
4670        if tokens[skillsEnd] == Lexeme.angleRight:
4671            skillsEnd -= 1
4672            outcome = True
4673        successEnd = self.matchingBrace(tokens, successStart)
4674        failStart = successEnd + 1
4675        if tokens[failStart] == Lexeme.angleRight:
4676            failStart += 1
4677            if outcome is not None:
4678                raise ParseError(
4679                    "Cannot indicate both success and failure as"
4680                    " outcomes in a challenge."
4681                )
4682            outcome = False
4683        failEnd = self.matchingBrace(tokens, failStart)
4684
4685        if failEnd != end:
4686            raise ParseError(
4687                f"Junk after condition:\n{tokens[failEnd + 1:end + 1]}"
4688            )
4689
4690        skills = self.parseSkillCombinationFromTokens(
4691            tokens,
4692            start + 3,
4693            skillsEnd
4694        )
4695        if isinstance(skills, base.Skill):
4696            skills = base.BestSkill(skills)
4697
4698        return base.challenge(
4699            level=level,
4700            outcome=outcome,
4701            skills=skills,
4702            success=self.parseConsequenceFromTokens(
4703                tokens[successStart:successEnd + 1]
4704            ),
4705            failure=self.parseConsequenceFromTokens(
4706                tokens[failStart:failEnd + 1]
4707            )
4708        )
4709
4710    def parseChallenge(
4711        self,
4712        encoded: str
4713    ) -> base.Challenge:
4714        """
4715        Lexes the given string and then calls `parseChallengeFromTokens`
4716        to return a `base.Challenge`.
4717        """
4718        return self.parseChallengeFromTokens(
4719            lex(encoded, self.reverseFormat)
4720        )
4721
4722    def parseConsequenceFromTokens(
4723        self,
4724        tokens: LexedTokens,
4725        start: int = 0,
4726        end: int = -1
4727    ) -> base.Consequence:
4728        """
4729        Parses a consequence from a lexed token list. If start and/or end
4730        are specified, only processes the part of the list between those
4731        two indices (inclusive). Use `lex` to turn a string into a
4732        `LexedTokens` list (or use `ParseFormat.parseConsequence` which
4733        does that for you).
4734
4735        An example:
4736
4737        >>> pf = ParseFormat()
4738        >>> tokens = [
4739        ...     Lexeme.openCurly,
4740        ...     'gain',
4741        ...     'power',
4742        ...     Lexeme.closeCurly
4743        ... ]
4744        >>> c = pf.parseConsequenceFromTokens(tokens)
4745        >>> c == [base.effect(gain='power')]
4746        True
4747        >>> tokens.append('hi')
4748        >>> c == pf.parseConsequenceFromTokens(tokens, end=-2)
4749        True
4750        >>> c == pf.parseConsequenceFromTokens(tokens, end=3)
4751        True
4752        """
4753        start, end, nTokens = normalizeEnds(tokens, start, end)
4754
4755        if nTokens < 2:
4756            raise ParseError("Consequence must have at least two tokens.")
4757
4758        if tokens[start] != Lexeme.openCurly:
4759            raise ParseError(
4760                f"Consequence must start with an open curly brace:"
4761                f" {repr(self.formatDict[Lexeme.openCurly])}."
4762            )
4763
4764        if tokens[end] != Lexeme.closeCurly:
4765            raise ParseError(
4766                f"Consequence must end with a closing curly brace:"
4767                f" {repr(self.formatDict[Lexeme.closeCurly])}."
4768            )
4769
4770        if nTokens == 2:
4771            return []
4772
4773        result: base.Consequence = []
4774        for partStart, partEnd in findSeparatedParts(
4775            tokens,
4776            Lexeme.consequenceSeparator,
4777            start + 1,
4778            end - 1,
4779            Lexeme.openCurly,
4780            Lexeme.closeCurly
4781        ):
4782            if partEnd - partStart < 0:
4783                raise ParseError("Empty consequence part.")
4784            if tokens[partStart] == Lexeme.angleLeft:  # a challenge
4785                result.append(
4786                    self.parseChallengeFromTokens(
4787                        tokens,
4788                        partStart,
4789                        partEnd
4790                    )
4791                )
4792            elif tokens[partStart] == Lexeme.doubleQuestionmark:  # condition
4793                result.append(
4794                    self.parseConditionFromTokens(
4795                        tokens,
4796                        partStart,
4797                        partEnd
4798                    )
4799                )
4800            else:  # Must be an effect
4801                result.append(
4802                    self.parseEffectFromTokens(
4803                        tokens,
4804                        partStart,
4805                        partEnd
4806                    )
4807                )
4808
4809        return result
4810
4811    def parseConsequence(self, encoded: str) -> base.Consequence:
4812        """
4813        Parses a consequence from a string. Uses `lex` and
4814        `ParseFormat.parseConsequenceFromTokens`. For example:
4815
4816        >>> pf = ParseFormat()
4817        >>> c = pf.parseConsequence(
4818        ...   '{gain power}'
4819        ... )
4820        >>> c == [base.effect(gain='power')]
4821        True
4822        >>> pf.unparseConsequence(c)
4823        '{gain power}'
4824        >>> c = pf.parseConsequence(
4825        ...     '{\\n'
4826        ...     '    ??(brawny|!weights*3){\\n'
4827        ...     '        <3>sum(brains, brawn){goto home}>{bounce}\\n'
4828        ...     '    }{};\\n'
4829        ...     '    lose coin*1\\n'
4830        ...     '}'
4831        ... )
4832        >>> len(c)
4833        2
4834        >>> c[0]['condition'] == base.ReqAny([
4835        ...     base.ReqCapability('brawny'),
4836        ...     base.ReqNot(base.ReqTokens('weights', 3))
4837        ... ])
4838        True
4839        >>> len(c[0]['consequence'])
4840        1
4841        >>> len(c[0]['alternative'])
4842        0
4843        >>> cons = c[0]['consequence'][0]
4844        >>> cons['skills'] == base.CombinedSkill('brains', 'brawn')
4845        True
4846        >>> cons['level']
4847        3
4848        >>> len(cons['success'])
4849        1
4850        >>> len(cons['failure'])
4851        1
4852        >>> cons['success'][0] == base.effect(goto='home')
4853        True
4854        >>> cons['failure'][0] == base.effect(bounce=True)
4855        True
4856        >>> cons['outcome'] = False
4857        >>> c[0] == base.condition(
4858        ...     condition=base.ReqAny([
4859        ...         base.ReqCapability('brawny'),
4860        ...         base.ReqNot(base.ReqTokens('weights', 3))
4861        ...     ]),
4862        ...     consequence=[
4863        ...         base.challenge(
4864        ...             skills=base.CombinedSkill('brains', 'brawn'),
4865        ...             level=3,
4866        ...             success=[base.effect(goto='home')],
4867        ...             failure=[base.effect(bounce=True)],
4868        ...             outcome=False
4869        ...         )
4870        ...     ]
4871        ... )
4872        True
4873        >>> c[1] == base.effect(lose=('coin', 1))
4874        True
4875        """
4876        return self.parseConsequenceFromTokens(
4877            lex(encoded, self.reverseFormat)
4878        )

A ParseFormat manages the mapping from markers to entry types and vice versa.

ParseFormat( formatDict: Dict[Lexeme, str] = {<Lexeme.domainSeparator: 1>: '//', <Lexeme.zoneSeparator: 2>: '::', <Lexeme.partSeparator: 3>: '%%', <Lexeme.stateOn: 4>: '=on', <Lexeme.stateOff: 5>: '=off', <Lexeme.tokenCount: 6>: '*', <Lexeme.effectCharges: 7>: '=', <Lexeme.sepOrDelay: 8>: ',', <Lexeme.consequenceSeparator: 9>: ';', <Lexeme.inCommon: 10>: '+c', <Lexeme.isHidden: 11>: '+h', <Lexeme.skillLevel: 12>: '^', <Lexeme.wigglyLine: 13>: '~', <Lexeme.withDetails: 14>: '%', <Lexeme.reciprocalSeparator: 15>: '/', <Lexeme.mechanismSeparator: 16>: ':', <Lexeme.openCurly: 17>: '{', <Lexeme.closeCurly: 18>: '}', <Lexeme.openParen: 19>: '(', <Lexeme.closeParen: 20>: ')', <Lexeme.angleLeft: 21>: '<', <Lexeme.angleRight: 22>: '>', <Lexeme.doubleQuestionmark: 23>: '??', <Lexeme.ampersand: 24>: '&', <Lexeme.orBar: 25>: '|', <Lexeme.notMarker: 26>: '!'}, effectNames: Dict[str, Literal['gain', 'lose', 'set', 'toggle', 'deactivate', 'edit', 'goto', 'bounce', 'follow', 'save']] = {'gain': 'gain', 'lose': 'lose', 'set': 'set', 'toggle': 'toggle', 'deactivate': 'deactivate', 'edit': 'edit', 'goto': 'goto', 'bounce': 'bounce', 'follow': 'follow', 'save': 'save'}, focalizationNames: Dict[str, Literal['singular', 'plural', 'spreading']] = {'singular': 'singular', 'plural': 'plural', 'spreading': 'spreading'}, successFailureIndicators: Tuple[str, str] = ('s', 'f'))
591    def __init__(
592        self,
593        formatDict: Format = DEFAULT_FORMAT,
594        effectNames: Dict[str, base.EffectType] = DEFAULT_EFFECT_NAMES,
595        focalizationNames: Dict[
596            str,
597            base.DomainFocalization
598        ] = DEFAULT_FOCALIZATION_NAMES,
599        successFailureIndicators: Tuple[str, str] = DEFAULT_SF_INDICATORS
600    ):
601        """
602        Sets up the parsing format. Requires a `Format` dictionary to
603        define the specifics. Raises a `ValueError` unless the keys of
604        the `Format` dictionary exactly match the `Lexeme` values.
605        """
606        self.formatDict = formatDict
607        self.effectNames = effectNames
608        self.focalizationNames = focalizationNames
609        if (
610            len(successFailureIndicators) != 2
611        or any(len(i) != 1 for i in successFailureIndicators)
612        ):
613            raise ValueError(
614                f"Invalid success/failure indicators: must be a pair of"
615                f" length-1 strings. Got: {successFailureIndicators!r}"
616            )
617        self.successIndicator, self.failureIndicator = (
618            successFailureIndicators
619        )
620
621        # Check completeness for each dictionary
622        checkCompleteness('formatDict', self.formatDict, set(Lexeme))
623        checkCompleteness(
624            'effectNames',
625            self.effectNames,
626            valuesSet=set(get_args(base.EffectType))
627        )
628        checkCompleteness(
629            'focalizationNames',
630            self.focalizationNames,
631            valuesSet=set(get_args(base.DomainFocalization))
632        )
633
634        # Build some reverse lookup dictionaries for specific
635        self.reverseFormat = {y: x for (x, y) in self.formatDict.items()}
636
637        # circumstances:
638        self.effectModMap = {
639            self.formatDict[x]: x
640            for x in [
641                Lexeme.effectCharges,
642                Lexeme.sepOrDelay,
643                Lexeme.inCommon,
644                Lexeme.isHidden
645            ]
646        }

Sets up the parsing format. Requires a Format dictionary to define the specifics. Raises a ValueError unless the keys of the Format dictionary exactly match the Lexeme values.

formatDict
effectNames
focalizationNames
reverseFormat
effectModMap
def lex(self, content: str) -> List[Union[Lexeme, str]]:
648    def lex(self, content: str) -> LexedTokens:
649        """
650        Applies `lex` using this format's lexeme mapping.
651        """
652        return lex(content, self.reverseFormat)

Applies lex using this format's lexeme mapping.

def onOff(self, word: str) -> Optional[bool]:
654    def onOff(self, word: str) -> Optional[bool]:
655        """
656        Parse an on/off indicator and returns a boolean (`True` for on
657        and `False` for off). Returns `None` if the word isn't either
658        the 'on' or the 'off' word. Generates a `ParseWarning`
659        (and still returns `None`) if the word is a case-swapped version
660        of the 'on' or 'off' word and is not equal to either of them.
661        """
662        onWord = self.formatDict[Lexeme.stateOn]
663        offWord = self.formatDict[Lexeme.stateOff]
664
665        # Generate warning if we suspect a case error
666        if (
667            word.casefold() in (onWord, offWord)
668        and word not in (onWord, offWord)
669        ):
670            warnings.warn(
671                (
672                    f"Word '{word}' cannot be interpreted as an on/off"
673                    f" value, although it is almost one (the correct"
674                    f" values are '{onWord}' and '{offWord}'."
675                ),
676                ParseWarning
677            )
678
679        # return the appropriate value
680        if word == onWord:
681            return True
682        elif word == offWord:
683            return False
684        else:
685            return None

Parse an on/off indicator and returns a boolean (True for on and False for off). Returns None if the word isn't either the 'on' or the 'off' word. Generates a ParseWarning (and still returns None) if the word is a case-swapped version of the 'on' or 'off' word and is not equal to either of them.

def matchingBrace( self, tokens: List[Union[Lexeme, str]], where: int, opener: int = <Lexeme.openCurly: 17>, closer: int = <Lexeme.closeCurly: 18>) -> int:
687    def matchingBrace(
688        self,
689        tokens: LexedTokens,
690        where: int,
691        opener: int = Lexeme.openCurly,
692        closer: int = Lexeme.closeCurly
693    ) -> int:
694        """
695        Returns the index within the given tokens list of the closing
696        curly brace which matches the open brace at the specified index.
697        You can specify custom `opener` and/or `closer` lexemes to find
698        matching pairs of other things. Raises a `ParseError` if there
699        is no opening brace at the specified index, or if there isn't a
700        matching closing brace. Handles nested braces of the specified
701        type.
702
703        Examples:
704        >>> pf = ParseFormat()
705        >>> ob = Lexeme.openCurly
706        >>> cb = Lexeme.closeCurly
707        >>> pf.matchingBrace([ob, cb], 0)
708        1
709        >>> pf.matchingBrace([ob, cb], 1)
710        Traceback (most recent call last):
711          ...
712        exploration.parsing.ParseError: ...
713        >>> pf.matchingBrace(['hi', ob, cb], 0)
714        Traceback (most recent call last):
715          ...
716        exploration.parsing.ParseError: ...
717        >>> pf.matchingBrace(['hi', ob, cb], 1)
718        2
719        >>> pf.matchingBrace(['hi', ob, 'lo', cb], 1)
720        3
721        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 1)
722        Traceback (most recent call last):
723          ...
724        exploration.parsing.ParseError: ...
725        >>> pf.matchingBrace([ob, 'hi', 'lo', cb], 0)
726        3
727        >>> pf.matchingBrace([ob, ob, cb, cb], 0)
728        3
729        >>> pf.matchingBrace([ob, ob, cb, cb], 1)
730        2
731        >>> pf.matchingBrace([ob, cb, ob, cb], 0)
732        1
733        >>> pf.matchingBrace([ob, cb, ob, cb], 2)
734        3
735        >>> pf.matchingBrace([ob, cb, cb, cb], 0)
736        1
737        >>> pf.matchingBrace([ob, ob, ob, cb], 0)
738        Traceback (most recent call last):
739          ...
740        exploration.parsing.ParseError: ...
741        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 0)
742        7
743        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 1)
744        6
745        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 2)
746        Traceback (most recent call last):
747          ...
748        exploration.parsing.ParseError: ...
749        >>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 3)
750        4
751        >>> op = Lexeme.openParen
752        >>> cp = Lexeme.closeParen
753        >>> pf.matchingBrace([ob, op, ob, cp], 1, op, cp)
754        3
755        """
756        if where >= len(tokens):
757            raise ParseError(
758                f"Out-of-bounds brace start: index {where} with"
759                f" {len(tokens)} tokens."
760            )
761        if tokens[where] != opener:
762            raise ParseError(
763                f"Can't find matching brace for token"
764                f" {repr(tokens[where])} at index {where} because it's"
765                f" not an open brace."
766            )
767
768        level = 1
769        for i in range(where + 1, len(tokens)):
770            token = tokens[i]
771            if token == opener:
772                level += 1
773            elif token == closer:
774                level -= 1
775                if level == 0:
776                    return i
777
778        raise ParseError(
779            f"Failed to find matching curly brace from index {where}."
780        )

Returns the index within the given tokens list of the closing curly brace which matches the open brace at the specified index. You can specify custom opener and/or closer lexemes to find matching pairs of other things. Raises a ParseError if there is no opening brace at the specified index, or if there isn't a matching closing brace. Handles nested braces of the specified type.

Examples:

>>> pf = ParseFormat()
>>> ob = Lexeme.openCurly
>>> cb = Lexeme.closeCurly
>>> pf.matchingBrace([ob, cb], 0)
1
>>> pf.matchingBrace([ob, cb], 1)
Traceback (most recent call last):
  ...
ParseError: ...
>>> pf.matchingBrace(['hi', ob, cb], 0)
Traceback (most recent call last):
  ...
ParseError: ...
>>> pf.matchingBrace(['hi', ob, cb], 1)
2
>>> pf.matchingBrace(['hi', ob, 'lo', cb], 1)
3
>>> pf.matchingBrace([ob, 'hi', 'lo', cb], 1)
Traceback (most recent call last):
  ...
ParseError: ...
>>> pf.matchingBrace([ob, 'hi', 'lo', cb], 0)
3
>>> pf.matchingBrace([ob, ob, cb, cb], 0)
3
>>> pf.matchingBrace([ob, ob, cb, cb], 1)
2
>>> pf.matchingBrace([ob, cb, ob, cb], 0)
1
>>> pf.matchingBrace([ob, cb, ob, cb], 2)
3
>>> pf.matchingBrace([ob, cb, cb, cb], 0)
1
>>> pf.matchingBrace([ob, ob, ob, cb], 0)
Traceback (most recent call last):
  ...
ParseError: ...
>>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 0)
7
>>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 1)
6
>>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 2)
Traceback (most recent call last):
  ...
ParseError: ...
>>> pf.matchingBrace([ob, ob, 'hi', ob, cb, 'lo', cb, cb], 3)
4
>>> op = Lexeme.openParen
>>> cp = Lexeme.closeParen
>>> pf.matchingBrace([ob, op, ob, cp], 1, op, cp)
3
def parseFocalization(self, word: str) -> Literal['singular', 'plural', 'spreading']:
782    def parseFocalization(self, word: str) -> base.DomainFocalization:
783        """
784        Parses a focalization type for a domain, recognizing
785        'domainFocalizationSingular', 'domainFocalizationPlural', and
786        'domainFocalizationSpreading'.
787        """
788        try:
789            return self.focalizationNames[word]
790        except KeyError:
791            raise ParseError(
792                f"Invalid domain focalization name {repr(word)}. Valid"
793                f" name are: {repr(list(self.focalizationNames))}'."
794            )

Parses a focalization type for a domain, recognizing 'domainFocalizationSingular', 'domainFocalizationPlural', and 'domainFocalizationSpreading'.

def parseTagValue( self, value: str) -> Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]:
796    def parseTagValue(self, value: str) -> base.TagValue:
797        """
798        Converts a string to a tag value, following these rules:
799
800        1. If the string is exactly one of 'None', 'True', or 'False', we
801            convert it to the corresponding Python value.
802        2. If the string can be converted to an integer without raising a
803            ValueError, we use that integer.
804        3. If the string can be converted to a float without raising a
805            ValueError, we use that float.
806        4. Otherwise, it remains a string.
807
808        Note that there is currently no syntax for using list, dictionary,
809        Requirement, or Consequence tag values.
810        TODO: Support those types?
811
812        Examples:
813
814        >>> pf = ParseFormat()
815        >>> pf.parseTagValue('hi')
816        'hi'
817        >>> pf.parseTagValue('3')
818        3
819        >>> pf.parseTagValue('3.0')
820        3.0
821        >>> pf.parseTagValue('True')
822        True
823        >>> pf.parseTagValue('False')
824        False
825        >>> pf.parseTagValue('None') is None
826        True
827        >>> pf.parseTagValue('none')
828        'none'
829        """
830        # TODO: Allow these keywords to be redefined?
831        if value == 'True':
832            return True
833        elif value == 'False':
834            return False
835        elif value == 'None':
836            return None
837        else:
838            try:
839                return int(value)
840            except ValueError:
841                try:
842                    return float(value)
843                except ValueError:
844                    return value

Converts a string to a tag value, following these rules:

  1. If the string is exactly one of 'None', 'True', or 'False', we convert it to the corresponding Python value.
  2. If the string can be converted to an integer without raising a ValueError, we use that integer.
  3. If the string can be converted to a float without raising a ValueError, we use that float.
  4. Otherwise, it remains a string.

Note that there is currently no syntax for using list, dictionary, Requirement, or Consequence tag values. TODO: Support those types?

Examples:

>>> pf = ParseFormat()
>>> pf.parseTagValue('hi')
'hi'
>>> pf.parseTagValue('3')
3
>>> pf.parseTagValue('3.0')
3.0
>>> pf.parseTagValue('True')
True
>>> pf.parseTagValue('False')
False
>>> pf.parseTagValue('None') is None
True
>>> pf.parseTagValue('none')
'none'
def unparseTagValue( self, value: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]) -> str:
846    def unparseTagValue(self, value: base.TagValue) -> str:
847        """
848        Converts a tag value into a string that would be parsed back into a
849        tag value via `parseTagValue`. Currently does not work for list,
850        dictionary, Requirement, or Consequence values.
851        TODO: Those
852        """
853        return str(value)

Converts a tag value into a string that would be parsed back into a tag value via parseTagValue. Currently does not work for list, dictionary, Requirement, or Consequence values. TODO: Those

def hasZoneParts(self, name: str) -> bool:
855    def hasZoneParts(self, name: str) -> bool:
856        """
857        Returns true if the specified name contains zone parts (using
858        the `zoneSeparator`).
859        """
860        return self.formatDict[Lexeme.zoneSeparator] in name

Returns true if the specified name contains zone parts (using the zoneSeparator).

def splitZone(self, name: str) -> Tuple[List[str], str]:
862    def splitZone(
863        self,
864        name: str
865    ) -> Tuple[List[base.Zone], base.DecisionName]:
866        """
867        Splits a decision name that includes zone information into the
868        list-of-zones part and the decision part. If there is no zone
869        information in the name, the list-of-zones will be an empty
870        list.
871        """
872        sep = self.formatDict[Lexeme.zoneSeparator]
873        parts = name.split(sep)
874        return (list(parts[:-1]), parts[-1])

Splits a decision name that includes zone information into the list-of-zones part and the decision part. If there is no zone information in the name, the list-of-zones will be an empty list.

def prefixWithZone(self, name: str, zone: str) -> str:
876    def prefixWithZone(
877        self,
878        name: base.DecisionName,
879        zone: base.Zone
880    ) -> base.DecisionName:
881        """
882        Returns the given decision name, prefixed with the given zone
883        name. Does NOT check whether the decision name already includes
884        a prefix or not.
885        """
886        return zone + self.formatDict[Lexeme.zoneSeparator] + name

Returns the given decision name, prefixed with the given zone name. Does NOT check whether the decision name already includes a prefix or not.

def parseAnyTransitionFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0) -> Tuple[Tuple[str, List[bool]], int]:
888    def parseAnyTransitionFromTokens(
889        self,
890        tokens: LexedTokens,
891        start: int = 0
892    ) -> Tuple[base.TransitionWithOutcomes, int]:
893        """
894        Parses a `base.TransitionWithOutcomes` from a tokens list,
895        accepting either a transition name or a transition name followed
896        by a `Lexeme.withDetails` followed by a string of success and
897        failure indicator characters. Returns a tuple containing a
898        `base.TransitionWithOutcomes` and an integer indicating the end
899        index of the parsed item within the tokens.
900        """
901        # Normalize start index so we can do index math
902        if start < 0:
903            useIndex = len(tokens) + start
904        else:
905            useIndex = start
906
907        try:
908            first = tokens[useIndex]
909        except IndexError:
910            raise ParseError(
911                f"Invalid token index: {start!r} among {len(tokens)}"
912                f" tokens."
913            )
914
915        if isinstance(first, Lexeme):
916            raise ParseError(
917                f"Expecting a transition name (possibly with a"
918                f" success/failure indicator string) but first token is"
919                f" {first!r}."
920            )
921
922        try:
923            second = tokens[useIndex + 1]
924            third = tokens[useIndex + 2]
925        except IndexError:
926            return ((first, []), useIndex)
927
928        if second != Lexeme.withDetails or isinstance(third, Lexeme):
929            return ((first, []), useIndex)
930
931        outcomes = []
932        for char in third:
933            if char == self.successIndicator:
934                outcomes.append(True)
935            elif char == self.failureIndicator:
936                outcomes.append(False)
937            else:
938                return ((first, []), useIndex)
939
940        return ((first, outcomes), useIndex + 2)

Parses a base.TransitionWithOutcomes from a tokens list, accepting either a transition name or a transition name followed by a Lexeme.withDetails followed by a string of success and failure indicator characters. Returns a tuple containing a base.TransitionWithOutcomes and an integer indicating the end index of the parsed item within the tokens.

def parseTransitionWithOutcomes(self, content: str) -> Tuple[str, List[bool]]:
942    def parseTransitionWithOutcomes(
943        self,
944        content: str
945    ) -> base.TransitionWithOutcomes:
946        """
947        Takes a transition that may have outcomes listed as a series of
948        s/f strings after a colon and returns the corresponding
949        `TransitionWithOutcomes` tuple. Calls `lex` and then
950        `parseAnyTransitionFromTokens`.
951        """
952        return self.parseAnyTransitionFromTokens(self.lex(content))[0]

Takes a transition that may have outcomes listed as a series of s/f strings after a colon and returns the corresponding TransitionWithOutcomes tuple. Calls lex and then parseAnyTransitionFromTokens.

def unparseTransitionWithOutocmes(self, transition: Union[str, Tuple[str, List[bool]]]) -> str:
954    def unparseTransitionWithOutocmes(
955        self,
956        transition: base.AnyTransition
957    ) -> str:
958        """
959        Turns a `base.AnyTransition` back into a string that would parse
960        to an equivalent `base.TransitionWithOutcomes` via
961        `parseTransitionWithOutcomes`. If a bare `base.Transition` is
962        given, returns a string that would result in a
963        `base.TransitionWithOutcomes` that has an empty outcomes
964        sequence.
965        """
966        if isinstance(transition, base.Transition):
967            return transition
968        elif (
969            isinstance(transition, tuple)
970        and len(transition) == 2
971        and isinstance(transition[0], base.Transition)
972        and isinstance(transition[1], list)
973        and all(isinstance(sfi, bool) for sfi in transition[1])
974        ):
975            if len(transition[1]) == 0:
976                return transition[0]
977            else:
978                result = transition[0] + self.formatDict[Lexeme.withDetails]
979                for outcome in transition[1]:
980                    if outcome:
981                        result += self.successIndicator
982                    else:
983                        result += self.failureIndicator
984                return result
985        else:
986            raise TypeError(
987                f"Invalid AnyTransition: neither a string, nor a"
988                f" length-2 tuple consisting of a string followed by a"
989                f" list of booleans. Got: {transition!r}"
990            )

Turns a base.AnyTransition back into a string that would parse to an equivalent base.TransitionWithOutcomes via parseTransitionWithOutcomes. If a bare base.Transition is given, returns a string that would result in a base.TransitionWithOutcomes that has an empty outcomes sequence.

def parseSpecificTransition(self, content: str) -> Tuple[str, str]:
 992    def parseSpecificTransition(
 993        self,
 994        content: str
 995    ) -> Tuple[base.DecisionName, base.Transition]:
 996        """
 997        Splits a decision:transition pair to the decision and transition
 998        part, using a custom separator if one is defined.
 999        """
1000        sep = self.formatDict[Lexeme.withDetails]
1001        n = content.count(sep)
1002        if n == 0:
1003            raise ParseError(
1004                f"Cannot split '{content}' into a decision name and a"
1005                f" transition name (no separator '{sep}' found)."
1006            )
1007        elif n > 1:
1008            raise ParseError(
1009                f"Cannot split '{content}' into a decision name and a"
1010                f" transition name (too many ({n}) '{sep}' separators"
1011                f" found)."
1012            )
1013        else:
1014            return cast(
1015                Tuple[base.DecisionName, base.Transition],
1016                tuple(content.split(sep))
1017            )

Splits a decision:transition pair to the decision and transition part, using a custom separator if one is defined.

def splitDirections(self, content: str) -> Tuple[Optional[str], Optional[str]]:
1019    def splitDirections(
1020        self,
1021        content: str
1022    ) -> Tuple[Optional[str], Optional[str]]:
1023        """
1024        Splits a piece of text using the 'Lexeme.reciprocalSeparator'
1025        into two pieces. If there is no separator, the second piece will
1026        be `None`; if either side of the separator is blank, that side
1027        will be `None`, and if there is more than one separator, a
1028        `ParseError` will be raised. Whitespace will be stripped from
1029        both sides of each result.
1030
1031        Examples:
1032
1033        >>> pf = ParseFormat()
1034        >>> pf.splitDirections('abc / def')
1035        ('abc', 'def')
1036        >>> pf.splitDirections('abc def ')
1037        ('abc def', None)
1038        >>> pf.splitDirections('abc def /')
1039        ('abc def', None)
1040        >>> pf.splitDirections('/abc def')
1041        (None, 'abc def')
1042        >>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
1043        Traceback (most recent call last):
1044          ...
1045        ParseError: ...
1046        """
1047        sep = self.formatDict[Lexeme.reciprocalSeparator]
1048        count = content.count(sep)
1049        if count > 1:
1050            raise ParseError(
1051                f"Too many split points ('{sep}') in content:"
1052                f" '{content}' (only one is allowed)."
1053            )
1054
1055        elif count == 1:
1056            before, after = content.split(sep)
1057            before = before.strip()
1058            after = after.strip()
1059            return (before or None, after or None)
1060
1061        else: # no split points
1062            stripped = content.strip()
1063            if stripped:
1064                return stripped, None
1065            else:
1066                return None, None

Splits a piece of text using the 'Lexeme.reciprocalSeparator' into two pieces. If there is no separator, the second piece will be None; if either side of the separator is blank, that side will be None, and if there is more than one separator, a ParseError will be raised. Whitespace will be stripped from both sides of each result.

Examples:

>>> pf = ParseFormat()
>>> pf.splitDirections('abc / def')
('abc', 'def')
>>> pf.splitDirections('abc def ')
('abc def', None)
>>> pf.splitDirections('abc def /')
('abc def', None)
>>> pf.splitDirections('/abc def')
(None, 'abc def')
>>> pf.splitDirections('a/b/c') # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
  ...
ParseError: ...
def parseItem(self, item: str) -> Union[str, Tuple[str, int], Tuple[str, str]]:
1068    def parseItem(
1069        self,
1070        item: str
1071    ) -> Union[
1072        base.Capability,
1073        Tuple[base.Token, int],
1074        Tuple[base.MechanismName, base.MechanismState]
1075    ]:
1076        """
1077        Parses an item, which is a capability (just a string), a
1078        token-type*number pair (returned as a tuple with the number
1079        converted to an integer), or a mechanism-name:state pair
1080        (returned as a tuple with the state as a string). The
1081        'Lexeme.tokenCount' and `Lexeme.mechanismSeparator` format
1082        values determine the separators that this looks for.
1083        """
1084        tsep = self.formatDict[Lexeme.tokenCount]
1085        msep = self.formatDict[Lexeme.mechanismSeparator]
1086        if tsep in item:
1087            # It's a token w/ an associated count
1088            parts = item.split(tsep)
1089            if len(parts) != 2:
1090                raise ParseError(
1091                    f"Item '{item}' has a '{tsep}' but doesn't separate"
1092                    f" into a token type and a count."
1093                )
1094            typ, count = parts
1095            try:
1096                num = int(count)
1097            except ValueError:
1098                raise ParseError(
1099                    f"Item '{item}' has invalid token count '{count}'."
1100                )
1101
1102            return (typ, num)
1103        elif msep in item:
1104            parts = item.split(msep)
1105            mechanism = msep.join(parts[:-1])
1106            state = parts[-1]
1107            if mechanism.endswith(':'):
1108                # Just a zone-qualified name...
1109                return item
1110            else:
1111                return (mechanism, state)
1112        else:
1113            # It's just a capability
1114            return item

Parses an item, which is a capability (just a string), a token-type*number pair (returned as a tuple with the number converted to an integer), or a mechanism-name:state pair (returned as a tuple with the state as a string). The 'Lexeme.tokenCount' and Lexeme.mechanismSeparator format values determine the separators that this looks for.

def unparseAnyDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> str:
1116    def unparseAnyDecision(self, decision: base.AnyDecisionSpecifier) -> str:
1117        """
1118        Turns any kind of decision specifier (ID,
1119        `base.DecisionSpecifier`, or name string) into a string that
1120        should parse back using `parseDecisionSpecifier`.
1121
1122        Raises a `TypeError` if given something that isn't a decision
1123        identifier.
1124
1125        For example:
1126
1127        >>> pf = ParseFormat()
1128        >>> pf.unparseAnyDecision(
1129        ...     base.DecisionSpecifier("domain", "zone", "D")
1130        ... )
1131        'domain//zone::D'
1132        >>> pf.unparseAnyDecision(3)
1133        '3'
1134        >>> pf.unparseAnyDecision('D')
1135        'D'
1136        >>> pf.unparseAnyDecision('domain//zone::D')
1137        'domain//zone::D'
1138        >>> pf.unparseAnyDecision([1, 2])
1139        Traceback (most recent call last):
1140        ...
1141        TypeError...
1142        """
1143        if isinstance(decision, base.DecisionSpecifier):
1144            return self.unparseDecisionSpecifier(decision)
1145        elif isinstance(decision, (base.DecisionID, base.DecisionName)):
1146            # leave as-is OR convert integer ID to string
1147            return str(decision)
1148        else:
1149            raise TypeError(
1150                "Unrecognized decision identifier type " + type(decision)
1151            )

Turns any kind of decision specifier (ID, base.DecisionSpecifier, or name string) into a string that should parse back using parseDecisionSpecifier.

Raises a TypeError if given something that isn't a decision identifier.

For example:

>>> pf = ParseFormat()
>>> pf.unparseAnyDecision(
...     base.DecisionSpecifier("domain", "zone", "D")
... )
'domain//zone::D'
>>> pf.unparseAnyDecision(3)
'3'
>>> pf.unparseAnyDecision('D')
'D'
>>> pf.unparseAnyDecision('domain//zone::D')
'domain//zone::D'
>>> pf.unparseAnyDecision([1, 2])
Traceback (most recent call last):
...
TypeError...
def unparseDecisionSpecifier(self, spec: exploration.base.DecisionSpecifier) -> str:
1153    def unparseDecisionSpecifier(self, spec: base.DecisionSpecifier) -> str:
1154        """
1155        Turns a decision specifier back into a string, which would be
1156        parsed as a decision specifier as part of various different
1157        things.
1158
1159        For example:
1160
1161        >>> pf = ParseFormat()
1162        >>> pf.unparseDecisionSpecifier(
1163        ...     base.DecisionSpecifier(None, None, 'where')
1164        ... )
1165        'where'
1166        >>> pf.unparseDecisionSpecifier(
1167        ...     base.DecisionSpecifier(None, 'zone', 'where')
1168        ... )
1169        'zone::where'
1170        >>> pf.unparseDecisionSpecifier(
1171        ...     base.DecisionSpecifier('domain', 'zone', 'where')
1172        ... )
1173        'domain//zone::where'
1174        >>> pf.unparseDecisionSpecifier(
1175        ...     base.DecisionSpecifier('domain', None, 'where')
1176        ... )
1177        'domain//where'
1178        """
1179        result = spec.name
1180        if spec.zone is not None:
1181            result = (
1182                spec.zone
1183              + self.formatDict[Lexeme.zoneSeparator]
1184              + result
1185            )
1186        if spec.domain is not None:
1187            result = (
1188                spec.domain
1189              + self.formatDict[Lexeme.domainSeparator]
1190              + result
1191            )
1192        return result

Turns a decision specifier back into a string, which would be parsed as a decision specifier as part of various different things.

For example:

>>> pf = ParseFormat()
>>> pf.unparseDecisionSpecifier(
...     base.DecisionSpecifier(None, None, 'where')
... )
'where'
>>> pf.unparseDecisionSpecifier(
...     base.DecisionSpecifier(None, 'zone', 'where')
... )
'zone::where'
>>> pf.unparseDecisionSpecifier(
...     base.DecisionSpecifier('domain', 'zone', 'where')
... )
'domain//zone::where'
>>> pf.unparseDecisionSpecifier(
...     base.DecisionSpecifier('domain', None, 'where')
... )
'domain//where'
def unparseMechanismSpecifier(self, spec: exploration.base.MechanismSpecifier) -> str:
1194    def unparseMechanismSpecifier(
1195        self,
1196        spec: base.MechanismSpecifier
1197    ) -> str:
1198        """
1199        Turns a mechanism specifier back into a string, which would be
1200        parsed as a mechanism specifier as part of various different
1201        things. Note that a mechanism specifier with a zone part but no
1202        decision part is not valid, since it would parse as a decision
1203        part instead.
1204
1205        For example:
1206
1207        >>> pf = ParseFormat()
1208        >>> pf.unparseMechanismSpecifier(
1209        ...     base.MechanismSpecifier(None, None, None, 'lever')
1210        ... )
1211        'lever'
1212        >>> pf.unparseMechanismSpecifier(
1213        ...     base.MechanismSpecifier('domain', 'zone', 'decision', 'door')
1214        ... )
1215        'domain//zone::decision::door'
1216        >>> pf.unparseMechanismSpecifier(
1217        ...     base.MechanismSpecifier('domain', None, None, 'door')
1218        ... )
1219        'domain//door'
1220        >>> pf.unparseMechanismSpecifier(
1221        ...     base.MechanismSpecifier(None, 'a', 'b', 'door')
1222        ... )
1223        'a::b::door'
1224        >>> pf.unparseMechanismSpecifier(
1225        ...     base.MechanismSpecifier(None, 'a', None, 'door')
1226        ... )
1227        Traceback (most recent call last):
1228        ...
1229        exploration.base.InvalidMechanismSpecifierError...
1230        >>> pf.unparseMechanismSpecifier(
1231        ...     base.MechanismSpecifier(None, None, 'a', 'door')
1232        ... )
1233        'a::door'
1234        >>> pf.unparseMechanismSpecifier(
1235        ...     base.MechanismSpecifier(None, None, 37, 'door')
1236        ... )
1237        '37::door'
1238        """
1239        if spec.decision is None and spec.zone is not None:
1240            raise base.InvalidMechanismSpecifierError(
1241                f"Mechanism specifier has a zone part but no decision"
1242                f" part; it cannot be unparsed since it would parse"
1243                f" differently:\n{spec}"
1244            )
1245        result = spec.name
1246        if spec.decision is not None:
1247            result = (
1248                str(spec.decision)
1249              + self.formatDict[Lexeme.zoneSeparator]
1250              + result
1251            )
1252        if spec.zone is not None:
1253            result = (
1254                spec.zone
1255              + self.formatDict[Lexeme.zoneSeparator]
1256              + result
1257            )
1258        if spec.domain is not None:
1259            result = (
1260                spec.domain
1261              + self.formatDict[Lexeme.domainSeparator]
1262              + result
1263            )
1264        return result

Turns a mechanism specifier back into a string, which would be parsed as a mechanism specifier as part of various different things. Note that a mechanism specifier with a zone part but no decision part is not valid, since it would parse as a decision part instead.

For example:

>>> pf = ParseFormat()
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier(None, None, None, 'lever')
... )
'lever'
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier('domain', 'zone', 'decision', 'door')
... )
'domain//zone::decision::door'
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier('domain', None, None, 'door')
... )
'domain//door'
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier(None, 'a', 'b', 'door')
... )
'a::b::door'
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier(None, 'a', None, 'door')
... )
Traceback (most recent call last):
...
exploration.base.InvalidMechanismSpecifierError...
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier(None, None, 'a', 'door')
... )
'a::door'
>>> pf.unparseMechanismSpecifier(
...     base.MechanismSpecifier(None, None, 37, 'door')
... )
'37::door'
def effectType( self, effectMarker: str) -> Optional[Literal['gain', 'lose', 'set', 'toggle', 'deactivate', 'edit', 'goto', 'bounce', 'follow', 'save']]:
1266    def effectType(self, effectMarker: str) -> Optional[base.EffectType]:
1267        """
1268        Returns the `base.EffectType` string corresponding to the
1269        given effect marker string. Returns `None` for an unrecognized
1270        marker.
1271        """
1272        return self.effectNames.get(effectMarker)

Returns the base.EffectType string corresponding to the given effect marker string. Returns None for an unrecognized marker.

1274    def parseCommandFromTokens(
1275        self,
1276        tokens: LexedTokens,
1277        start: int = 0,
1278        end: int = -1
1279    ) -> commands.Command:
1280        """
1281        Given tokens that specify a `commands.Command`, parses that
1282        command and returns it. Really just turns the tokens back into
1283        strings and calls `commands.command`.
1284
1285        For example:
1286
1287        >>> pf = ParseFormat()
1288        >>> t = ['val', '5']
1289        >>> c = commands.command(*t)
1290        >>> pf.parseCommandFromTokens(t) == c
1291        True
1292        >>> t = ['op', Lexeme.tokenCount, '$val', '$val']
1293        >>> c = commands.command('op', '*', '$val', '$val')
1294        >>> pf.parseCommandFromTokens(t) == c
1295        True
1296        """
1297        start, end, nTokens = normalizeEnds(tokens, start, end)
1298        args: List[str] = []
1299        for token in tokens[start:end + 1]:
1300            if isinstance(token, Lexeme):
1301                args.append(self.formatDict[token])
1302            else:
1303                args.append(token)
1304
1305        if len(args) == 0:
1306            raise ParseError(
1307                f"No arguments for command:\n{tokens[start:end + 1]}"
1308            )
1309        return commands.command(*args)

Given tokens that specify a commands.Command, parses that command and returns it. Really just turns the tokens back into strings and calls commands.command.

For example:

>>> pf = ParseFormat()
>>> t = ['val', '5']
>>> c = commands.command(*t)
>>> pf.parseCommandFromTokens(t) == c
True
>>> t = ['op', Lexeme.tokenCount, '$val', '$val']
>>> c = commands.command('op', '*', '$val', '$val')
>>> pf.parseCommandFromTokens(t) == c
True
1311    def unparseCommand(self, command: commands.Command) -> str:
1312        """
1313        Turns a `Command` back into the string that would produce that
1314        command when parsed using `parseCommandList`.
1315
1316        Note that the results will be more explicit in some cases than what
1317        `parseCommandList` would accept as input.
1318
1319        For example:
1320
1321        >>> pf = ParseFormat()
1322        >>> pf.unparseCommand(
1323        ...     commands.LiteralValue(command='val', value='5')
1324        ... )
1325        'val 5'
1326        >>> pf.unparseCommand(
1327        ...     commands.LiteralValue(command='val', value='"5"')
1328        ... )
1329        'val "5"'
1330        >>> pf.unparseCommand(
1331        ...     commands.EstablishCollection(
1332        ...         command='empty',
1333        ...         collection='list'
1334        ...     )
1335        ... )
1336        'empty list'
1337        >>> pf.unparseCommand(
1338        ...     commands.AppendValue(command='append', value='$_')
1339        ... )
1340        'append $_'
1341        """
1342        candidate = None
1343        for k, v in commands.COMMAND_SETUP.items():
1344            if v[0] == type(command):
1345                if candidate is None:
1346                    candidate = k
1347                else:
1348                    raise ValueError(
1349                        f"COMMAND_SETUP includes multiple keys with"
1350                        f" {type(command)} as their value type:"
1351                        f" '{candidate}' and '{k}'."
1352                    )
1353
1354        if candidate is None:
1355            raise ValueError(
1356                f"COMMAND_SETUP has no key with {type(command)} as its"
1357                f" value type."
1358            )
1359
1360        result = candidate
1361        for x in command[1:]:
1362            # TODO: Is this hack good enough?
1363            result += ' ' + str(x)
1364        return result

Turns a Command back into the string that would produce that command when parsed using parseCommandList.

Note that the results will be more explicit in some cases than what parseCommandList would accept as input.

For example:

>>> pf = ParseFormat()
>>> pf.unparseCommand(
...     commands.LiteralValue(command='val', value='5')
... )
'val 5'
>>> pf.unparseCommand(
...     commands.LiteralValue(command='val', value='"5"')
... )
'val "5"'
>>> pf.unparseCommand(
...     commands.EstablishCollection(
...         command='empty',
...         collection='list'
...     )
... )
'empty list'
>>> pf.unparseCommand(
...     commands.AppendValue(command='append', value='$_')
... )
'append $_'
1366    def unparseCommandList(self, commands: List[commands.Command]) -> str:
1367        """
1368        Takes a list of commands and returns a string that would parse
1369        into them using `parseOneEffectArg`. The result contains
1370        newlines and indentation to make it easier to read.
1371
1372        For example:
1373
1374        >>> pf = ParseFormat()
1375        >>> pf.unparseCommandList(
1376        ...     [commands.command('val', '5'), commands.command('pop')]
1377        ... )
1378        '{\\n  val 5;\\n  pop;\\n}'
1379        """
1380        result = self.formatDict[Lexeme.openCurly]
1381        for cmd in commands:
1382            result += f'\n  {self.unparseCommand(cmd)};'
1383        if len(commands) > 0:
1384            result += '\n'
1385        return result + self.formatDict[Lexeme.closeCurly]

Takes a list of commands and returns a string that would parse into them using parseOneEffectArg. The result contains newlines and indentation to make it easier to read.

For example:

>>> pf = ParseFormat()
>>> pf.unparseCommandList(
...     [commands.command('val', '5'), commands.command('pop')]
... )
'{\n  val 5;\n  pop;\n}'
1387    def parseCommandListFromTokens(
1388        self,
1389        tokens: LexedTokens,
1390        start: int = 0
1391    ) -> Tuple[List[commands.Command], int]:
1392        """
1393        Parses a command list from a list of lexed tokens, which must
1394        start with `Lexeme.openCurly`. Returns the parsed command list
1395        as a list of `commands.Command` objects, along with the end
1396        index of that command list (which will be the matching curly
1397        brace.
1398        """
1399        end = self.matchingBrace(
1400            tokens,
1401            start,
1402            Lexeme.openCurly,
1403            Lexeme.closeCurly
1404        )
1405        parts = list(
1406            findSeparatedParts(
1407                tokens,
1408                Lexeme.consequenceSeparator,
1409                start + 1,
1410                end - 1,
1411                Lexeme.openCurly,
1412                Lexeme.closeCurly,
1413            )
1414        )
1415        return (
1416            [
1417                self.parseCommandFromTokens(tokens, fromIndex, toIndex)
1418                for fromIndex, toIndex in parts
1419                if fromIndex <= toIndex  # ignore empty parts
1420            ],
1421            end
1422        )

Parses a command list from a list of lexed tokens, which must start with Lexeme.openCurly. Returns the parsed command list as a list of commands.Command objects, along with the end index of that command list (which will be the matching curly brace.

1424    def parseOneEffectArg(
1425        self,
1426        tokens: LexedTokens,
1427        start: int = 0,
1428        limit: Optional[int] = None
1429    ) -> Tuple[
1430        Union[
1431            base.Capability,  # covers 'str' possibility
1432            Tuple[base.Token, base.TokenCount],
1433            Tuple[Literal['skill'], base.Skill, base.Level],
1434            Tuple[base.MechanismSpecifier, base.MechanismState],
1435            base.DecisionSpecifier,
1436            base.DecisionID,
1437            Literal[Lexeme.inCommon, Lexeme.isHidden],
1438            Tuple[Literal[Lexeme.sepOrDelay, Lexeme.effectCharges], int],
1439            List[commands.Command]
1440        ],
1441        int
1442    ]:
1443        """
1444        Looks at tokens starting at the specified position and parses
1445        one or more of them as an effect argument (an argument that
1446        could be given to `base.effect`). Looks at various key `Lexeme`s
1447        to determine which type to use.
1448
1449        Items in the tokens list beyond the specified limit will not be
1450        considered, even when they in theory could be grouped with items
1451        up to the limit into a more complex argument.
1452
1453        For example:
1454
1455        >>> pf = ParseFormat()
1456        >>> pf.parseOneEffectArg(['hi'])
1457        ('hi', 0)
1458        >>> pf.parseOneEffectArg(['hi'], 1)
1459        Traceback (most recent call last):
1460        ...
1461        IndexError...
1462        >>> pf.parseOneEffectArg(['hi', 'bye'])
1463        ('hi', 0)
1464        >>> pf.parseOneEffectArg(['hi', 'bye'], 1)
1465        ('bye', 1)
1466        >>> pf.parseOneEffectArg(
1467        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1468        ...     0
1469        ... )
1470        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1471 name='gate'), 'open'), 2)
1472        >>> pf.parseOneEffectArg(
1473        ...     ['set', 'gate', Lexeme.mechanismSeparator, 'open'],
1474        ...     1
1475        ... )
1476        ((MechanismSpecifier(domain=None, zone=None, decision=None,\
1477 name='gate'), 'open'), 3)
1478        >>> pf.parseOneEffectArg(
1479        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1480        ...     1
1481        ... )
1482        Traceback (most recent call last):
1483        ...
1484        exploration.parsing.ParseError...
1485        >>> pf.parseOneEffectArg(
1486        ...     ['gate', Lexeme.mechanismSeparator, 'open'],
1487        ...     2
1488        ... )
1489        ('open', 2)
1490        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, '10'], 0)
1491        (('gold', 10), 2)
1492        >>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, 'ten'], 0)
1493        Traceback (most recent call last):
1494        ...
1495        exploration.parsing.ParseError...
1496        >>> pf.parseOneEffectArg([Lexeme.inCommon], 0)
1497        (<Lexeme.inCommon: ...>, 0)
1498        >>> pf.parseOneEffectArg([Lexeme.isHidden], 0)
1499        (<Lexeme.isHidden: ...>, 0)
1500        >>> pf.parseOneEffectArg([Lexeme.tokenCount, '3'], 0)
1501        Traceback (most recent call last):
1502        ...
1503        exploration.parsing.ParseError...
1504        >>> pf.parseOneEffectArg([Lexeme.effectCharges, '3'], 0)
1505        ((<Lexeme.effectCharges: ...>, 3), 1)
1506        >>> pf.parseOneEffectArg([Lexeme.tokenCount, 3], 0)  # int is a lexeme
1507        Traceback (most recent call last):
1508        ...
1509        exploration.parsing.ParseError...
1510        >>> pf.parseOneEffectArg([Lexeme.sepOrDelay, '-2'], 0)
1511        ((<Lexeme.sepOrDelay: ...>, -2), 1)
1512        >>> pf.parseOneEffectArg(['agility', Lexeme.skillLevel, '3'], 0)
1513        (('skill', 'agility', 3), 2)
1514        >>> pf.parseOneEffectArg(
1515        ...     [
1516        ...         'main',
1517        ...         Lexeme.domainSeparator,
1518        ...         'zone',
1519        ...         Lexeme.zoneSeparator,
1520        ...         'decision',
1521        ...         Lexeme.zoneSeparator,
1522        ...         'compass',
1523        ...         Lexeme.mechanismSeparator,
1524        ...         'north',
1525        ...         'south',
1526        ...         'east',
1527        ...         'west'
1528        ...     ],
1529        ...     0
1530        ... )
1531        ((MechanismSpecifier(domain='main', zone='zone',\
1532 decision='decision', name='compass'), 'north'), 8)
1533        >>> pf.parseOneEffectArg(
1534        ...     [
1535        ...         'before',
1536        ...         'main',
1537        ...         Lexeme.domainSeparator,
1538        ...         'zone',
1539        ...         Lexeme.zoneSeparator,
1540        ...         'decision',
1541        ...         Lexeme.zoneSeparator,
1542        ...         'compass',
1543        ...         'north',
1544        ...         'south',
1545        ...         'east',
1546        ...         'west'
1547        ...     ],
1548        ...     1
1549        ... )  # a mechanism specifier without a state will become a
1550        ...    # decision specifier
1551        (DecisionSpecifier(domain='main', zone='zone',\
1552 name='decision'), 5)
1553        >>> tokens = [
1554        ...     'set',
1555        ...     'main',
1556        ...     Lexeme.domainSeparator,
1557        ...     'zone',
1558        ...     Lexeme.zoneSeparator,
1559        ...     'compass',
1560        ...     'north',
1561        ...     'bounce',
1562        ... ]
1563        >>> pf.parseOneEffectArg(tokens, 0)
1564        ('set', 0)
1565        >>> pf.parseDecisionSpecifierFromTokens(tokens, 1)
1566        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1567        >>> pf.parseOneEffectArg(tokens, 1)
1568        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
1569        >>> pf.parseOneEffectArg(tokens, 6)
1570        ('north', 6)
1571        >>> pf.parseOneEffectArg(tokens, 7)
1572        ('bounce', 7)
1573        >>> pf.parseOneEffectArg(
1574        ...     [
1575        ...         "fort", Lexeme.zoneSeparator, "gate",
1576        ...             Lexeme.mechanismSeparator, "open",
1577        ...     ],
1578        ...     0
1579        ... )
1580        ((MechanismSpecifier(domain=None, zone=None, decision='fort',\
1581 name='gate'), 'open'), 4)
1582        >>> pf.parseOneEffectArg(
1583        ...     [Lexeme.openCurly, 'val', '5', Lexeme.closeCurly],
1584        ...     0
1585        ... ) == ([commands.command('val', '5')], 3)
1586        True
1587        >>> a = [
1588        ...     Lexeme.openCurly, 'val', '5', Lexeme.closeCurly,
1589        ...     Lexeme.openCurly, 'append', Lexeme.consequenceSeparator,
1590        ...     'pop', Lexeme.closeCurly
1591        ... ]
1592        >>> cl = [
1593        ...     [commands.command('val', '5')],
1594        ...     [commands.command('append'), commands.command('pop')]
1595        ... ]
1596        >>> pf.parseOneEffectArg(a, 0) == (cl[0], 3)
1597        True
1598        >>> pf.parseOneEffectArg(a, 4) == (cl[1], 8)
1599        True
1600        >>> pf.parseOneEffectArg(a, 1)
1601        ('val', 1)
1602        >>> pf.parseOneEffectArg(a, 2)
1603        ('5', 2)
1604        >>> pf.parseOneEffectArg(a, 3)
1605        Traceback (most recent call last):
1606        ...
1607        exploration.parsing.ParseError...
1608        """
1609        start, limit, nTokens = normalizeEnds(
1610            tokens,
1611            start,
1612            limit if limit is not None else -1
1613        )
1614        if nTokens == 0:
1615            raise ParseError("No effect arguments available.")
1616
1617        first = tokens[start]
1618
1619        if nTokens == 1:
1620            if first in (Lexeme.inCommon, Lexeme.isHidden):
1621                return (first, start)
1622            elif not isinstance(first, str):
1623                raise ParseError(
1624                    f"Only one token and it's a special character"
1625                    f" ({first} = {repr(self.formatDict[first])})"
1626                )
1627            else:
1628                return (cast(base.Capability, first), start)
1629
1630        assert (nTokens > 1)
1631
1632        second = tokens[start + 1]
1633
1634        # Command lists start with an open curly brace and effect
1635        # modifiers start with a Lexme, but nothing else may
1636        if first == Lexeme.openCurly:
1637            return self.parseCommandListFromTokens(tokens, start)
1638        elif first in (Lexeme.inCommon, Lexeme.isHidden):
1639            return (first, start)
1640        elif first in (Lexeme.sepOrDelay, Lexeme.effectCharges):
1641            if not isinstance(second, str):
1642                raise ParseError(
1643                    f"Token following a modifier that needs a count"
1644                    f" must be a string in tokens:"
1645                    f"\n{tokens[start:limit or len(tokens)]}"
1646                )
1647            try:
1648                val = int(second)
1649            except ValueError:
1650                raise ParseError(
1651                    f"Token following a modifier that needs a count"
1652                    f" must be convertible to an int:"
1653                    f"\n{tokens[start:limit or len(tokens)]}"
1654                )
1655
1656            first = cast(
1657                Literal[Lexeme.sepOrDelay, Lexeme.effectCharges],
1658                first
1659            )
1660            return ((first, val), start + 1)
1661        elif not isinstance(first, str):
1662            raise ParseError(
1663                f"First token must be a string unless it's a modifier"
1664                f" lexeme or command/reversion-set opener. Got:"
1665                f"\n{tokens[start:limit or len(tokens)]}"
1666            )
1667
1668        # If we have two strings in a row, then the first is our parsed
1669        # value alone and we'll parse the second separately.
1670        if isinstance(second, str):
1671            return (first, start)
1672        elif second in (Lexeme.inCommon, Lexeme.isHidden):
1673            return (first, start)
1674
1675        # Must have at least 3 tokens at this point, or else we need to
1676        # have the inCommon or isHidden lexeme second.
1677        if nTokens < 3:
1678            return (first, start)
1679
1680        third = tokens[start + 2]
1681        if not isinstance(third, str):
1682            return (first, start)
1683
1684        second = cast(Lexeme, second)
1685        third = cast(str, third)
1686
1687        if second in (Lexeme.tokenCount, Lexeme.skillLevel):
1688            try:
1689                num = int(third)
1690            except ValueError:
1691                raise ParseError(
1692                    f"Invalid effect tokens: count for Tokens or level"
1693                    f" for Skill must be convertible to an integer."
1694                    f"\n{tokens[start:limit + 1]}"
1695                )
1696            if second == Lexeme.tokenCount:
1697                return ((first, num), start + 2)  # token/count pair
1698            else:
1699                return (('skill', first, num), start + 2)  # token/count pair
1700
1701        elif second == Lexeme.mechanismSeparator:  # bare mechanism
1702            return (
1703                (
1704                    base.MechanismSpecifier(
1705                        domain=None,
1706                        zone=None,
1707                        decision=None,
1708                        name=first
1709                    ),
1710                    third
1711                ),
1712                start + 2
1713            )
1714
1715        elif second in (Lexeme.domainSeparator, Lexeme.zoneSeparator):
1716            try:
1717                mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
1718                    tokens,
1719                    start
1720                )  # works whether it's a mechanism or decision specifier...
1721            except ParseError:
1722                return self.parseDecisionSpecifierFromTokens(tokens, start)
1723            if mEnd + 2 > limit:
1724                # No room for following mechanism separator + state
1725                return self.parseDecisionSpecifierFromTokens(tokens, start)
1726            sep = tokens[mEnd + 1]
1727            after = tokens[mEnd + 2]
1728            if sep == Lexeme.mechanismSeparator:
1729                if not isinstance(after, str):
1730                    raise ParseError(
1731                        f"Mechanism separator not followed by state:"
1732                        f"\n{tokens[start]}"
1733                    )
1734                return ((mSpec, after), mEnd + 2)
1735            else:
1736                # No mechanism separator afterwards
1737                return self.parseDecisionSpecifierFromTokens(tokens, start)
1738
1739        else:  # unrecognized as a longer combo
1740            return (first, start)

Looks at tokens starting at the specified position and parses one or more of them as an effect argument (an argument that could be given to base.effect). Looks at various key Lexemes to determine which type to use.

Items in the tokens list beyond the specified limit will not be considered, even when they in theory could be grouped with items up to the limit into a more complex argument.

For example:

>>> pf = ParseFormat()
>>> pf.parseOneEffectArg(['hi'])
('hi', 0)
>>> pf.parseOneEffectArg(['hi'], 1)
Traceback (most recent call last):
...
IndexError...
>>> pf.parseOneEffectArg(['hi', 'bye'])
('hi', 0)
>>> pf.parseOneEffectArg(['hi', 'bye'], 1)
('bye', 1)
>>> pf.parseOneEffectArg(
...     ['gate', Lexeme.mechanismSeparator, 'open'],
...     0
... )
((MechanismSpecifier(domain=None, zone=None, decision=None, name='gate'), 'open'), 2)
>>> pf.parseOneEffectArg(
...     ['set', 'gate', Lexeme.mechanismSeparator, 'open'],
...     1
... )
((MechanismSpecifier(domain=None, zone=None, decision=None, name='gate'), 'open'), 3)
>>> pf.parseOneEffectArg(
...     ['gate', Lexeme.mechanismSeparator, 'open'],
...     1
... )
Traceback (most recent call last):
...
ParseError...
>>> pf.parseOneEffectArg(
...     ['gate', Lexeme.mechanismSeparator, 'open'],
...     2
... )
('open', 2)
>>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, '10'], 0)
(('gold', 10), 2)
>>> pf.parseOneEffectArg(['gold', Lexeme.tokenCount, 'ten'], 0)
Traceback (most recent call last):
...
ParseError...
>>> pf.parseOneEffectArg([Lexeme.inCommon], 0)
(<Lexeme.inCommon: ...>, 0)
>>> pf.parseOneEffectArg([Lexeme.isHidden], 0)
(<Lexeme.isHidden: ...>, 0)
>>> pf.parseOneEffectArg([Lexeme.tokenCount, '3'], 0)
Traceback (most recent call last):
...
ParseError...
>>> pf.parseOneEffectArg([Lexeme.effectCharges, '3'], 0)
((<Lexeme.effectCharges: ...>, 3), 1)
>>> pf.parseOneEffectArg([Lexeme.tokenCount, 3], 0)  # int is a lexeme
Traceback (most recent call last):
...
ParseError...
>>> pf.parseOneEffectArg([Lexeme.sepOrDelay, '-2'], 0)
((<Lexeme.sepOrDelay: ...>, -2), 1)
>>> pf.parseOneEffectArg(['agility', Lexeme.skillLevel, '3'], 0)
(('skill', 'agility', 3), 2)
>>> pf.parseOneEffectArg(
...     [
...         'main',
...         Lexeme.domainSeparator,
...         'zone',
...         Lexeme.zoneSeparator,
...         'decision',
...         Lexeme.zoneSeparator,
...         'compass',
...         Lexeme.mechanismSeparator,
...         'north',
...         'south',
...         'east',
...         'west'
...     ],
...     0
... )
((MechanismSpecifier(domain='main', zone='zone', decision='decision', name='compass'), 'north'), 8)
>>> pf.parseOneEffectArg(
...     [
...         'before',
...         'main',
...         Lexeme.domainSeparator,
...         'zone',
...         Lexeme.zoneSeparator,
...         'decision',
...         Lexeme.zoneSeparator,
...         'compass',
...         'north',
...         'south',
...         'east',
...         'west'
...     ],
...     1
... )  # a mechanism specifier without a state will become a
...    # decision specifier
(DecisionSpecifier(domain='main', zone='zone', name='decision'), 5)
>>> tokens = [
...     'set',
...     'main',
...     Lexeme.domainSeparator,
...     'zone',
...     Lexeme.zoneSeparator,
...     'compass',
...     'north',
...     'bounce',
... ]
>>> pf.parseOneEffectArg(tokens, 0)
('set', 0)
>>> pf.parseDecisionSpecifierFromTokens(tokens, 1)
(DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
>>> pf.parseOneEffectArg(tokens, 1)
(DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
>>> pf.parseOneEffectArg(tokens, 6)
('north', 6)
>>> pf.parseOneEffectArg(tokens, 7)
('bounce', 7)
>>> pf.parseOneEffectArg(
...     [
...         "fort", Lexeme.zoneSeparator, "gate",
...             Lexeme.mechanismSeparator, "open",
...     ],
...     0
... )
((MechanismSpecifier(domain=None, zone=None, decision='fort', name='gate'), 'open'), 4)
>>> pf.parseOneEffectArg(
...     [Lexeme.openCurly, 'val', '5', Lexeme.closeCurly],
...     0
... ) == ([commands.command('val', '5')], 3)
True
>>> a = [
...     Lexeme.openCurly, 'val', '5', Lexeme.closeCurly,
...     Lexeme.openCurly, 'append', Lexeme.consequenceSeparator,
...     'pop', Lexeme.closeCurly
... ]
>>> cl = [
...     [commands.command('val', '5')],
...     [commands.command('append'), commands.command('pop')]
... ]
>>> pf.parseOneEffectArg(a, 0) == (cl[0], 3)
True
>>> pf.parseOneEffectArg(a, 4) == (cl[1], 8)
True
>>> pf.parseOneEffectArg(a, 1)
('val', 1)
>>> pf.parseOneEffectArg(a, 2)
('5', 2)
>>> pf.parseOneEffectArg(a, 3)
Traceback (most recent call last):
...
ParseError...
1742    def coalesceEffectArgs(
1743        self,
1744        tokens: LexedTokens,
1745        start: int = 0,
1746        end: int = -1
1747    ) -> Tuple[
1748        List[  # List of effect args
1749            Union[
1750                base.Capability,  # covers 'str' possibility
1751                Tuple[base.Token, base.TokenCount],
1752                Tuple[Literal['skill'], base.Skill, base.Level],
1753                Tuple[base.MechanismSpecifier, base.MechanismState],
1754                base.DecisionSpecifier,
1755                List[commands.Command],
1756                Set[str]
1757            ]
1758        ],
1759        Tuple[  # Slots for modifiers: common/hidden/charges/delay
1760            Optional[bool],
1761            Optional[bool],
1762            Optional[int],
1763            Optional[int],
1764        ]
1765    ]:
1766        """
1767        Given a region of a lexed tokens list which contains one or more
1768        effect arguments, combines token sequences representing things
1769        like capabilities, mechanism states, token counts, and skill
1770        levels, representing these using the tuples that would be passed
1771        to `base.effect`. Returns a tuple with two elements:
1772
1773        - First, a list that contains several different kinds of
1774            objects, each of which is distinguishable by its type or
1775            part of its value.
1776        - Next, a tuple with four entires for common, hidden, charges,
1777            and/or delay values based on the presence of modifier
1778            sequences. Any or all of these may be `None` if the relevant
1779            modifier was not present (the usual case).
1780
1781        For example:
1782
1783        >>> pf = ParseFormat()
1784        >>> pf.coalesceEffectArgs(["jump"])
1785        (['jump'], (None, None, None, None))
1786        >>> pf.coalesceEffectArgs(["coin", Lexeme.tokenCount, "3", "fly"])
1787        ([('coin', 3), 'fly'], (None, None, None, None))
1788        >>> pf.coalesceEffectArgs(
1789        ...     [
1790        ...         "fort", Lexeme.zoneSeparator, "gate",
1791        ...             Lexeme.mechanismSeparator, "open"
1792        ...     ]
1793        ... )
1794        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1795 name='gate'), 'open')], (None, None, None, None))
1796        >>> pf.coalesceEffectArgs(
1797        ...     [
1798        ...         "main", Lexeme.domainSeparator, "cliff"
1799        ...     ]
1800        ... )
1801        ([DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1802 (None, None, None, None))
1803        >>> pf.coalesceEffectArgs(
1804        ...     [
1805        ...         "door", Lexeme.mechanismSeparator, "open"
1806        ...     ]
1807        ... )
1808        ([(MechanismSpecifier(domain=None, zone=None, decision=None,\
1809 name='door'), 'open')], (None, None, None, None))
1810        >>> pf.coalesceEffectArgs(
1811        ...     [
1812        ...         "fort", Lexeme.zoneSeparator, "gate",
1813        ...             Lexeme.mechanismSeparator, "open",
1814        ...         "canJump",
1815        ...         "coins", Lexeme.tokenCount, "3",
1816        ...         Lexeme.inCommon,
1817        ...         "agility", Lexeme.skillLevel, "-1",
1818        ...         Lexeme.sepOrDelay, "0",
1819        ...         "main", Lexeme.domainSeparator, "cliff"
1820        ...     ]
1821        ... )
1822        ([(MechanismSpecifier(domain=None, zone=None, decision='fort',\
1823 name='gate'), 'open'), 'canJump', ('coins', 3), ('skill', 'agility', -1),\
1824 DecisionSpecifier(domain='main', zone=None, name='cliff')],\
1825 (True, None, None, 0))
1826        >>> pf.coalesceEffectArgs(["bounce", Lexeme.isHidden])
1827        (['bounce'], (None, True, None, None))
1828        >>> pf.coalesceEffectArgs(
1829        ...     ["goto", "3", Lexeme.inCommon, Lexeme.isHidden]
1830        ... )
1831        (['goto', '3'], (True, True, None, None))
1832        """
1833        start, end, nTokens = normalizeEnds(tokens, start, end)
1834        where = start
1835        result: List[  # List of effect args
1836            Union[
1837                base.Capability,  # covers 'str' possibility
1838                Tuple[base.Token, base.TokenCount],
1839                Tuple[Literal['skill'], base.Skill, base.Level],
1840                Tuple[base.MechanismSpecifier, base.MechanismState],
1841                base.DecisionSpecifier,
1842                List[commands.Command],
1843                Set[str]
1844            ]
1845        ] = []
1846        inCommon: Optional[bool] = None
1847        isHidden: Optional[bool] = None
1848        charges: Optional[int] = None
1849        delay: Optional[int] = None
1850        while where <= end:
1851            following, thisEnd = self.parseOneEffectArg(tokens, where, end)
1852            if following == Lexeme.inCommon:
1853                if inCommon is not None:
1854                    raise ParseError(
1855                        f"In-common effect modifier specified more than"
1856                        f" once in effect args:"
1857                        f"\n{tokens[start:end + 1]}"
1858                    )
1859                inCommon = True
1860            elif following == Lexeme.isHidden:
1861                if isHidden is not None:
1862                    raise ParseError(
1863                        f"Is-hidden effect modifier specified more than"
1864                        f" once in effect args:"
1865                        f"\n{tokens[start:end + 1]}"
1866                    )
1867                isHidden = True
1868            elif (
1869                isinstance(following, tuple)
1870            and len(following) == 2
1871            and following[0] in (Lexeme.effectCharges, Lexeme.sepOrDelay)
1872            and isinstance(following[1], int)
1873            ):
1874                if following[0] == Lexeme.effectCharges:
1875                    if charges is not None:
1876                        raise ParseError(
1877                            f"Charges effect modifier specified more than"
1878                            f" once in effect args:"
1879                            f"\n{tokens[start:end + 1]}"
1880                        )
1881                    charges = following[1]
1882                else:
1883                    if delay is not None:
1884                        raise ParseError(
1885                            f"Delay effect modifier specified more than"
1886                            f" once in effect args:"
1887                            f"\n{tokens[start:end + 1]}"
1888                        )
1889                    delay = following[1]
1890            elif (
1891                    isinstance(following, base.Capability)
1892                 or (
1893                    isinstance(following, tuple)
1894                and len(following) == 2
1895                and isinstance(following[0], base.Token)
1896                and isinstance(following[1], base.TokenCount)
1897                ) or (
1898                    isinstance(following, tuple)
1899                and len(following) == 3
1900                and following[0] == 'skill'
1901                and isinstance(following[1], base.Skill)
1902                and isinstance(following[2], base.Level)
1903                ) or (
1904                    isinstance(following, tuple)
1905                and len(following) == 2
1906                and isinstance(following[0], base.MechanismSpecifier)
1907                and isinstance(following[1], base.MechanismState)
1908                ) or (
1909                    isinstance(following, base.DecisionSpecifier)
1910                ) or (
1911                    isinstance(following, list)
1912                and all(isinstance(item, tuple) for item in following)
1913                    # TODO: Stricter command list check here?
1914                ) or (
1915                    isinstance(following, set)
1916                and all(isinstance(item, str) for item in following)
1917                )
1918            ):
1919                result.append(following)
1920            else:
1921                raise ParseError(f"Invalid coalesced argument: {following}")
1922            where = thisEnd + 1
1923
1924        return (result, (inCommon, isHidden, charges, delay))

Given a region of a lexed tokens list which contains one or more effect arguments, combines token sequences representing things like capabilities, mechanism states, token counts, and skill levels, representing these using the tuples that would be passed to base.effect. Returns a tuple with two elements:

  • First, a list that contains several different kinds of objects, each of which is distinguishable by its type or part of its value.
  • Next, a tuple with four entires for common, hidden, charges, and/or delay values based on the presence of modifier sequences. Any or all of these may be None if the relevant modifier was not present (the usual case).

For example:

>>> pf = ParseFormat()
>>> pf.coalesceEffectArgs(["jump"])
(['jump'], (None, None, None, None))
>>> pf.coalesceEffectArgs(["coin", Lexeme.tokenCount, "3", "fly"])
([('coin', 3), 'fly'], (None, None, None, None))
>>> pf.coalesceEffectArgs(
...     [
...         "fort", Lexeme.zoneSeparator, "gate",
...             Lexeme.mechanismSeparator, "open"
...     ]
... )
([(MechanismSpecifier(domain=None, zone=None, decision='fort', name='gate'), 'open')], (None, None, None, None))
>>> pf.coalesceEffectArgs(
...     [
...         "main", Lexeme.domainSeparator, "cliff"
...     ]
... )
([DecisionSpecifier(domain='main', zone=None, name='cliff')], (None, None, None, None))
>>> pf.coalesceEffectArgs(
...     [
...         "door", Lexeme.mechanismSeparator, "open"
...     ]
... )
([(MechanismSpecifier(domain=None, zone=None, decision=None, name='door'), 'open')], (None, None, None, None))
>>> pf.coalesceEffectArgs(
...     [
...         "fort", Lexeme.zoneSeparator, "gate",
...             Lexeme.mechanismSeparator, "open",
...         "canJump",
...         "coins", Lexeme.tokenCount, "3",
...         Lexeme.inCommon,
...         "agility", Lexeme.skillLevel, "-1",
...         Lexeme.sepOrDelay, "0",
...         "main", Lexeme.domainSeparator, "cliff"
...     ]
... )
([(MechanismSpecifier(domain=None, zone=None, decision='fort', name='gate'), 'open'), 'canJump', ('coins', 3), ('skill', 'agility', -1), DecisionSpecifier(domain='main', zone=None, name='cliff')], (True, None, None, 0))
>>> pf.coalesceEffectArgs(["bounce", Lexeme.isHidden])
(['bounce'], (None, True, None, None))
>>> pf.coalesceEffectArgs(
...     ["goto", "3", Lexeme.inCommon, Lexeme.isHidden]
... )
(['goto', '3'], (True, True, None, None))
def parseEffectFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> exploration.base.Effect:
1926    def parseEffectFromTokens(
1927        self,
1928        tokens: LexedTokens,
1929        start: int = 0,
1930        end: int = -1
1931    ) -> base.Effect:
1932        """
1933        Given a region of a list of lexed tokens specifying an effect,
1934        returns the `Effect` object that those tokens specify.
1935        """
1936        start, end, nTokens = normalizeEnds(tokens, start, end)
1937
1938        # Check for empty list
1939        if nTokens == 0:
1940            raise ParseError(
1941                "Effect must include at least a type."
1942            )
1943
1944        firstPart = tokens[start]
1945
1946        if isinstance(firstPart, Lexeme):
1947            raise ParseError(
1948                f"First part of effect must be an effect type. Got"
1949                f" {firstPart} ({repr(self.formatDict[firstPart])})."
1950            )
1951
1952        firstPart = cast(str, firstPart)
1953
1954        # Get the effect type
1955        fType = self.effectType(firstPart)
1956
1957        if fType is None:
1958            raise ParseError(
1959                f"Unrecognized effect type {firstPart!r}. Check the"
1960                f" EffectType entries in the effect names dictionary."
1961            )
1962
1963        if start + 1 > end:  # No tokens left: set empty args
1964            groupedArgs: List[
1965                Union[
1966                    base.Capability,  # covers 'str' possibility
1967                    Tuple[base.Token, base.TokenCount],
1968                    Tuple[Literal['skill'], base.Skill, base.Level],
1969                    Tuple[base.MechanismSpecifier, base.MechanismState],
1970                    base.DecisionSpecifier,
1971                    List[commands.Command],
1972                    Set[str]
1973                ]
1974            ] = []
1975            modifiers: Tuple[
1976                Optional[bool],
1977                Optional[bool],
1978                Optional[int],
1979                Optional[int]
1980            ] = (None, None, None, None)
1981        else:  # Coalesce remaining tokens if there are any
1982            groupedArgs, modifiers = self.coalesceEffectArgs(
1983                tokens,
1984                start + 1,
1985                end
1986            )
1987
1988        # Set up arguments for base.effect and handle modifiers first
1989        args: Dict[
1990            str,
1991            Union[
1992                None,
1993                base.ContextSpecifier,
1994                base.Capability,
1995                Tuple[base.Token, base.TokenCount],
1996                Tuple[Literal['skill'], base.Skill, base.Level],
1997                Tuple[base.MechanismSpecifier, base.MechanismState],
1998                Tuple[base.MechanismSpecifier, List[base.MechanismState]],
1999                List[base.Capability],
2000                base.AnyDecisionSpecifier,
2001                Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
2002                bool,
2003                int,
2004                base.SaveSlot,
2005                Tuple[base.SaveSlot, Set[str]]
2006            ]
2007        ] = {}
2008        if modifiers[0]:
2009            args['applyTo'] = 'common'
2010        if modifiers[1]:
2011            args['hidden'] = True
2012        else:
2013            args['hidden'] = False
2014        if modifiers[2] is not None:
2015            args['charges'] = modifiers[2]
2016        if modifiers[3] is not None:
2017            args['delay'] = modifiers[3]
2018
2019        # Now handle the main effect-type-based argument
2020        if fType in ("gain", "lose"):
2021            if len(groupedArgs) != 1:
2022                raise ParseError(
2023                    f"'{fType}' effect must have exactly one grouped"
2024                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2025                )
2026            thing = groupedArgs[0]
2027            if isinstance(thing, tuple):
2028                if len(thing) == 2:
2029                    if (
2030                        not isinstance(thing[0], base.Token)
2031                     or not isinstance(thing[1], base.TokenCount)
2032                    ):
2033                        raise ParseError(
2034                            f"'{fType}' effect grouped arg pair must be a"
2035                            f" (token, amount) pair. Got:\n{thing}"
2036                        )
2037                elif len(thing) == 3:
2038                    if (
2039                        thing[0] != 'skill'
2040                     or not isinstance(thing[1], base.Skill)
2041                     or not isinstance(thing[2], base.Level)
2042                    ):
2043                        raise ParseError(
2044                            f"'{fType}' effect grouped arg pair must be a"
2045                            f" (token, amount) pair. Got:\n{thing}"
2046                        )
2047                else:
2048                    raise ParseError(
2049                        f"'{fType}' effect grouped arg tuple must have"
2050                        f" length 2 or 3. Got (length {len(thing)}):\n{thing}"
2051                    )
2052            elif not isinstance(thing, base.Capability):
2053                raise ParseError(
2054                    f"'{fType}' effect grouped arg must be a capability"
2055                    f" or a (token, amount) tuple. Got:\n{thing}"
2056                )
2057            args[fType] = thing
2058            return base.effect(**args)  # type:ignore
2059
2060        elif fType == "set":
2061            if len(groupedArgs) != 1:
2062                raise ParseError(
2063                    f"'{fType}' effect must have exactly one grouped"
2064                    f" argument (got {len(groupedArgs)}:\n{groupedArgs}"
2065                )
2066            setVal = groupedArgs[0]
2067            if not isinstance(
2068                setVal,
2069                tuple
2070            ):
2071                raise ParseError(
2072                    f"'{fType}' effect grouped arg must be a tuple. Got:"
2073                    f"\n{setVal}"
2074                )
2075            if len(setVal) == 2:
2076                setWhat, setTo = setVal
2077                if (
2078                    isinstance(setWhat, base.Token)
2079                and isinstance(setTo, base.TokenCount)
2080                ) or (
2081                    isinstance(setWhat, base.MechanismSpecifier)
2082                and isinstance(setTo, base.MechanismState)
2083                ):
2084                    args[fType] = setVal
2085                    return base.effect(**args)  # type:ignore
2086                else:
2087                    raise ParseError(
2088                        f"Invalid '{fType}' effect grouped args:"
2089                        f"\n{groupedArgs}"
2090                    )
2091            elif len(setVal) == 3:
2092                indicator, whichSkill, setTo = setVal
2093                if (
2094                    indicator == 'skill'
2095                and isinstance(whichSkill, base.Skill)
2096                and isinstance(setTo, base.Level)
2097                ):
2098                    args[fType] = setVal
2099                    return base.effect(**args)  # type:ignore
2100                else:
2101                    raise ParseError(
2102                        f"Invalid '{fType}' effect grouped args (not a"
2103                        f" skill):\n{groupedArgs}"
2104                    )
2105            else:
2106                raise ParseError(
2107                    f"Invalid '{fType}' effect grouped args (wrong"
2108                    f" length tuple):\n{groupedArgs}"
2109                )
2110
2111        elif fType == "toggle":
2112            if len(groupedArgs) == 0:
2113                raise ParseError(
2114                    f"'{fType}' effect must have at least one grouped"
2115                    f" argument. Got:\n{groupedArgs}"
2116                )
2117            if (
2118                isinstance(groupedArgs[0], tuple)
2119            and len(groupedArgs[0]) == 2
2120            and isinstance(groupedArgs[0][0], base.MechanismSpecifier)
2121            and isinstance(groupedArgs[0][1], base.MechanismState)
2122            and all(
2123                    isinstance(a, base.MechanismState)
2124                    for a in groupedArgs[1:]
2125                )
2126            ):  # a mechanism toggle
2127                args[fType] = (
2128                    groupedArgs[0][0],
2129                    cast(
2130                        List[base.MechanismState],
2131                        [groupedArgs[0][1]] + groupedArgs[1:]
2132                    )
2133                )
2134                return base.effect(**args)  # type:ignore
2135            elif all(isinstance(a, base.Capability) for a in groupedArgs):
2136                # a capability toggle
2137                args[fType] = cast(List[base.Capability], groupedArgs)
2138                return base.effect(**args)  # type:ignore
2139            else:
2140                raise ParseError(
2141                    f"Invalid arguments for '{fType}' effect. Got:"
2142                    f"\n{groupedArgs}"
2143                )
2144
2145        elif fType in ("bounce", "deactivate"):
2146            if len(groupedArgs) != 0:
2147                raise ParseError(
2148                    f"'{fType}' effect may not include any"
2149                    f" arguments. Got {len(groupedArgs)}):"
2150                    f"\n{groupedArgs}"
2151                )
2152            args[fType] = True
2153            return base.effect(**args)  # type:ignore
2154
2155        elif fType == "follow":
2156            if len(groupedArgs) != 1:
2157                raise ParseError(
2158                    f"'{fType}' effect must include exactly one"
2159                    f" argument. Got {len(groupedArgs)}):"
2160                    f"\n{groupedArgs}"
2161                )
2162
2163            transition = groupedArgs[0]
2164            if not isinstance(transition, base.Transition):
2165                raise ParseError(
2166                    f"Invalid argument for '{fType}' effect. Needed a"
2167                    f" transition but got:\n{groupedArgs}"
2168                )
2169            args[fType] = transition
2170            return base.effect(**args)  # type:ignore
2171
2172        elif fType == "edit":
2173            if len(groupedArgs) == 0:
2174                raise ParseError(
2175                    "An 'edit' effect requires at least one argument."
2176                )
2177            for i, arg in enumerate(groupedArgs):
2178                if not isinstance(arg, list):
2179                    raise ParseError(
2180                        f"'edit' effect argument {i} is not a sub-list:"
2181                        f"\n  {arg!r}"
2182                        f"\nAmong arguments:"
2183                        f"\n  {groupedArgs}"
2184                    )
2185                for j, cmd in enumerate(arg):
2186                    if not isinstance(cmd, tuple):
2187                        raise ParseError(
2188                            f"'edit' effect argument {i} contains"
2189                            f" non-tuple part {j}:"
2190                            f"\n  {cmd!r}"
2191                            f"\nAmong arguments:"
2192                            f"\n  {groupedArgs}"
2193                        )
2194
2195            args[fType] = groupedArgs  # type:ignore
2196            return base.effect(**args)  # type:ignore
2197
2198        elif fType == "goto":
2199            if len(groupedArgs) not in (1, 2):
2200                raise ParseError(
2201                    f"A 'goto' effect must include either one or two"
2202                    f" grouped arguments. Got {len(groupedArgs)}:"
2203                    f"\n{groupedArgs}"
2204                )
2205
2206            first = groupedArgs[0]
2207            if not isinstance(
2208                first,
2209                (base.DecisionName, base.DecisionSpecifier)
2210            ):
2211                raise ParseError(
2212                    f"'{fType}' effect must first specify a destination"
2213                    f" decision. Got:\n{groupedArgs}"
2214                )
2215
2216            # Check if it's really a decision ID
2217            dSpec: base.AnyDecisionSpecifier
2218            if isinstance(first, base.DecisionName):
2219                try:
2220                    dSpec = int(first)
2221                except ValueError:
2222                    dSpec = first
2223            else:
2224                dSpec = first
2225
2226            if len(groupedArgs) == 2:
2227                second = groupedArgs[1]
2228                if not isinstance(second, base.FocalPointName):
2229                    raise ParseError(
2230                        f"'{fType}' effect must have a focal point name"
2231                        f" if it has a second part. Got:\n{groupedArgs}"
2232                    )
2233                args[fType] = (dSpec, second)
2234            else:
2235                args[fType] = dSpec
2236
2237            return base.effect(**args)  # type:ignore
2238
2239        elif fType == "save":
2240            if len(groupedArgs) not in (0, 1):
2241                raise ParseError(
2242                    f"'{fType}' effect must include exactly zero or one"
2243                    f" argument(s). Got {len(groupedArgs)}):"
2244                    f"\n{groupedArgs}"
2245                )
2246
2247            if len(groupedArgs) == 1:
2248                slot = groupedArgs[0]
2249            else:
2250                slot = base.DEFAULT_SAVE_SLOT
2251            if not isinstance(slot, base.SaveSlot):
2252                raise ParseError(
2253                    f"Invalid argument for '{fType}' effect. Needed a"
2254                    f" save slot but got:\n{groupedArgs}"
2255                )
2256            args[fType] = slot
2257            return base.effect(**args)  # type:ignore
2258
2259        else:
2260            raise ParseError(f"Invalid effect type: '{fType}'.")

Given a region of a list of lexed tokens specifying an effect, returns the Effect object that those tokens specify.

def parseEffect(self, effectStr: str) -> exploration.base.Effect:
2262    def parseEffect(self, effectStr: str) -> base.Effect:
2263        """
2264        Works like `parseEffectFromTokens` but starts with a raw string.
2265        For example:
2266
2267        >>> pf = ParseFormat()
2268        >>> pf.parseEffect("gain jump") == base.effect(gain='jump')
2269        True
2270        >>> pf.parseEffect("set door:open") == base.effect(
2271        ...     set=(
2272        ...         base.MechanismSpecifier(None, None, None, 'door'),
2273        ...         'open'
2274        ...     )
2275        ... )
2276        True
2277        >>> pf.parseEffect("set coins*10") == base.effect(set=('coins', 10))
2278        True
2279        >>> pf.parseEffect("set agility^3") == base.effect(
2280        ...     set=('skill', 'agility', 3)
2281        ... )
2282        True
2283        """
2284        return self.parseEffectFromTokens(self.lex(effectStr))

Works like parseEffectFromTokens but starts with a raw string. For example:

>>> pf = ParseFormat()
>>> pf.parseEffect("gain jump") == base.effect(gain='jump')
True
>>> pf.parseEffect("set door:open") == base.effect(
...     set=(
...         base.MechanismSpecifier(None, None, None, 'door'),
...         'open'
...     )
... )
True
>>> pf.parseEffect("set coins*10") == base.effect(set=('coins', 10))
True
>>> pf.parseEffect("set agility^3") == base.effect(
...     set=('skill', 'agility', 3)
... )
True
def unparseEffect(self, effect: exploration.base.Effect) -> str:
2286    def unparseEffect(self, effect: base.Effect) -> str:
2287        """
2288        The opposite of `parseEffect`; turns an effect back into a
2289        string reprensentation.
2290
2291        For example:
2292
2293        >>> pf = ParseFormat()
2294        >>> e = {
2295        ...     "type": "gain",
2296        ...     "applyTo": "active",
2297        ...     "value": "flight",
2298        ...     "delay": None,
2299        ...     "charges": None,
2300        ...     "hidden": False
2301        ... }
2302        >>> pf.unparseEffect(e)
2303        'gain flight'
2304        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2305        True
2306        >>> s = 'gain flight'
2307        >>> pf.unparseEffect(pf.parseEffect(s)) == s
2308        True
2309        >>> s2 = '  gain\\nflight'
2310        >>> pf.unparseEffect(pf.parseEffect(s2)) == s
2311        True
2312        >>> e = {
2313        ...     "type": "gain",
2314        ...     "applyTo": "active",
2315        ...     "value": ("gold", 5),
2316        ...     "delay": 1,
2317        ...     "charges": 2,
2318        ...     "hidden": False
2319        ... }
2320        >>> pf.unparseEffect(e)
2321        'gain gold*5 ,1 =2'
2322        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2323        True
2324        >>> e = {
2325        ...     "type": "set",
2326        ...     "applyTo": "active",
2327        ...     "value": (
2328        ...         base.MechanismSpecifier(None, None, None, "gears"),
2329        ...         "on"
2330        ...     ),
2331        ...     "delay": None,
2332        ...     "charges": 1,
2333        ...     "hidden": False
2334        ... }
2335        >>> pf.unparseEffect(e)
2336        'set gears:on =1'
2337        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2338        True
2339        >>> e = {
2340        ...     "type": "toggle",
2341        ...     "applyTo": "active",
2342        ...     "value": ["red", "blue"],
2343        ...     "delay": None,
2344        ...     "charges": None,
2345        ...     "hidden": False
2346        ... }
2347        >>> pf.unparseEffect(e)
2348        'toggle red blue'
2349        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2350        True
2351        >>> e = {
2352        ...     "type": "toggle",
2353        ...     "applyTo": "active",
2354        ...     "value": (
2355        ...         base.MechanismSpecifier(None, None, None, "switch"),
2356        ...         ["on", "off"]
2357        ...     ),
2358        ...     "delay": None,
2359        ...     "charges": None,
2360        ...     "hidden": False
2361        ... }
2362        >>> pf.unparseEffect(e)
2363        'toggle switch:on off'
2364        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2365        True
2366        >>> e = {
2367        ...     "type": "deactivate",
2368        ...     "applyTo": "active",
2369        ...     "value": None,
2370        ...     "delay": 2,
2371        ...     "charges": None,
2372        ...     "hidden": False
2373        ... }
2374        >>> pf.unparseEffect(e)
2375        'deactivate ,2'
2376        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2377        True
2378        >>> e = {
2379        ...     "type": "goto",
2380        ...     "applyTo": "common",
2381        ...     "value": 3,
2382        ...     "delay": None,
2383        ...     "charges": None,
2384        ...     "hidden": False
2385        ... }
2386        >>> pf.unparseEffect(e)
2387        'goto 3 +c'
2388        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2389        True
2390        >>> e = {
2391        ...     "type": "goto",
2392        ...     "applyTo": "common",
2393        ...     "value": 3,
2394        ...     "delay": None,
2395        ...     "charges": None,
2396        ...     "hidden": True
2397        ... }
2398        >>> pf.unparseEffect(e)
2399        'goto 3 +c +h'
2400        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2401        True
2402        >>> e = {
2403        ...     "type": "goto",
2404        ...     "applyTo": "active",
2405        ...     "value": 'home',
2406        ...     "delay": None,
2407        ...     "charges": None,
2408        ...     "hidden": False
2409        ... }
2410        >>> pf.unparseEffect(e)
2411        'goto home'
2412        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2413        True
2414        >>> e = base.effect(edit=[
2415        ...     [
2416        ...         commands.command('val', '5'),
2417        ...         commands.command('empty', 'list'),
2418        ...         commands.command('append', '$_')
2419        ...     ],
2420        ...     [
2421        ...         commands.command('val', '11'),
2422        ...         commands.command('assign', 'var', '$_'),
2423        ...         commands.command('op', '+', '$var', '$var')
2424        ...     ],
2425        ... ])
2426        >>> pf.unparseEffect(e)
2427        'edit {\\n  val 5;\\n  empty list;\\n  append $_;\\n}\
2428 {\\n  val 11;\\n  assign var $_;\\n  op + $var $var;\\n}'
2429        >>> pf.parseEffect(pf.unparseEffect(e)) == e
2430        True
2431        >>> e = base.effect(set=('coins', 3))
2432        >>> pf.unparseEffect(e)
2433        'set coins*3'
2434        >>> e = base.effect(set=('skill', 'mashing', 3))
2435        >>> pf.unparseEffect(e)
2436        'set mashing^3'
2437        """
2438        result: List[str] = []
2439
2440        # Reverse the effect type into a marker
2441        eType = effect['type']
2442        for key, val in self.effectNames.items():
2443            if val == eType:
2444                if len(result) != 0:
2445                    raise ParseError(
2446                        f"Effect map contains multiple matching entries"
2447                        f"for effect type '{effect['type']}':"
2448                        f" '{result[0]}' and '{key}'"
2449                    )
2450                result.append(key)
2451                # Don't break 'cause we'd like to check uniqueness
2452
2453        eVal = effect['value']
2454        if eType in ('gain', 'lose'):
2455            eVal = cast(Union[base.Capability, Tuple[base.Token, int]], eVal)
2456            if isinstance(eVal, str):  # a capability
2457                result.append(eVal)
2458            else:  # a token
2459                result.append(
2460                    eVal[0]
2461                  + self.formatDict[Lexeme.tokenCount]
2462                  + str(eVal[1])
2463                )
2464        elif eType == 'set':
2465            eVal = cast(
2466                # TODO: Add skill level setting here & elsewhere
2467                Union[
2468                    Tuple[base.Token, base.TokenCount],
2469                    Tuple[base.MechanismSpecifier, base.MechanismState]
2470                ],
2471                eVal
2472            )
2473            if len(eVal) not in (2, 3):
2474                raise ValueError(
2475                    f"'set' effect has value with length other than 2"
2476                    f" or 3:\n  {repr(effect)}"
2477                )
2478            if len(eVal) == 3:
2479                if eVal[0] != "skill":
2480                    raise ValueError(
2481                        f"'set' effect with length-3 value doesn't"
2482                        f" start with string 'skill':\n  {repr(effect)}"
2483                    )
2484                result.append(
2485                    eVal[1]
2486                  + self.formatDict[Lexeme.skillLevel]
2487                  + str(eVal[2])
2488                )
2489            elif isinstance(eVal[1], int):  # a token count
2490                result.append(
2491                    eVal[0]
2492                  + self.formatDict[Lexeme.tokenCount]
2493                  + str(eVal[1])
2494                )
2495            else:  # a mechanism
2496                if isinstance(eVal[0], base.MechanismSpecifier):
2497                    mSpec = self.unparseMechanismSpecifier(eVal[0])
2498                elif isinstance(eVal[0], base.MechanismID):
2499                    # TODO: Specify mechanism by decision name +
2500                    # mechanism name? Would require threading through a
2501                    # DecisionGraph and using mechanismDetails
2502                    mSpec = "" + eVal[0]
2503                else:
2504                    assert isinstance(eVal[0], base.MechanismName)
2505                    mSpec = eVal[0]
2506                result.append(
2507                    mSpec
2508                  + self.formatDict[Lexeme.mechanismSeparator]
2509                  + eVal[1]
2510                )
2511        elif eType == 'toggle':
2512            if isinstance(eVal, tuple):  # mechanism states
2513                tSpec, states = cast(
2514                    Tuple[
2515                        base.AnyMechanismSpecifier,
2516                        List[base.MechanismState]
2517                    ],
2518                    eVal
2519                )
2520                firstState = states[0]
2521                restStates = states[1:]
2522                if isinstance(tSpec, base.MechanismSpecifier):
2523                    mStr = self.unparseMechanismSpecifier(tSpec)
2524                else:
2525                    # Could be ID or name
2526                    mStr = str(tSpec)
2527                result.append(
2528                    mStr
2529                  + self.formatDict[Lexeme.mechanismSeparator]
2530                  + firstState
2531                )
2532                result.extend(restStates)
2533            else:  # capabilities
2534                assert isinstance(eVal, list)
2535                eVal = cast(List[base.Capability], eVal)
2536                result.extend(eVal)
2537        elif eType in ('deactivate', 'bounce'):
2538            if eVal is not None:
2539                raise ValueError(
2540                    f"'{eType}' effect has non-None value:"
2541                    f"\n  {repr(effect)}"
2542                )
2543        elif eType == 'follow':
2544            eVal = cast(base.Token, eVal)
2545            result.append(eVal)
2546        elif eType == 'edit':
2547            eVal = cast(List[List[commands.Command]], eVal)
2548            if len(eVal) == 0:
2549                result[-1] = '{}'
2550            else:
2551                for cmdList in eVal:
2552                    result.append(
2553                        self.unparseCommandList(cmdList)
2554                    )
2555        elif eType == 'goto':
2556            if (
2557                isinstance(eVal, tuple)
2558            and len(eVal) == 2
2559            and isinstance(eVal[1], base.FocalPointName)
2560            ):
2561                result.append(
2562                    self.unparseAnyDecision(
2563                        cast(base.AnyDecisionSpecifier, eVal[0])
2564                    )
2565                )
2566                result.append(eVal[1])
2567            else:
2568                assert isinstance(
2569                    eVal,
2570                    (base.DecisionID, base.DecisionSpecifier, str)
2571                )
2572                result.append(self.unparseAnyDecision(eVal))
2573        elif eType == 'save':
2574            # It's just a string naming the save slot
2575            eVal = cast(str, eVal)
2576            result.append(eVal)
2577        else:
2578            raise ValueError(
2579                f"Unrecognized effect type '{eType}' in effect:"
2580                f"\n  {repr(effect)}"
2581            )
2582
2583        # Add modifier strings
2584        if effect['applyTo'] == 'common':
2585            result.append(self.formatDict[Lexeme.inCommon])
2586
2587        if effect['hidden']:
2588            result.append(self.formatDict[Lexeme.isHidden])
2589
2590        dVal = effect['delay']
2591        if dVal is not None:
2592            result.append(
2593                self.formatDict[Lexeme.sepOrDelay] + str(dVal)
2594            )
2595
2596        cVal = effect['charges']
2597        if cVal is not None:
2598            result.append(
2599                self.formatDict[Lexeme.effectCharges] + str(cVal)
2600            )
2601
2602        joined = ''
2603        before = False
2604        for r in result:
2605            if (
2606                r.startswith(' ')
2607             or r.startswith('\n')
2608             or r.endswith(' ')
2609             or r.endswith('\n')
2610            ):
2611                joined += r
2612                before = False
2613            else:
2614                joined += (' ' if before else '') + r
2615                before = True
2616        return joined

The opposite of parseEffect; turns an effect back into a string reprensentation.

For example:

>>> pf = ParseFormat()
>>> e = {
...     "type": "gain",
...     "applyTo": "active",
...     "value": "flight",
...     "delay": None,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'gain flight'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> s = 'gain flight'
>>> pf.unparseEffect(pf.parseEffect(s)) == s
True
>>> s2 = '  gain\nflight'
>>> pf.unparseEffect(pf.parseEffect(s2)) == s
True
>>> e = {
...     "type": "gain",
...     "applyTo": "active",
...     "value": ("gold", 5),
...     "delay": 1,
...     "charges": 2,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'gain gold*5 ,1 =2'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "set",
...     "applyTo": "active",
...     "value": (
...         base.MechanismSpecifier(None, None, None, "gears"),
...         "on"
...     ),
...     "delay": None,
...     "charges": 1,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'set gears:on =1'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "toggle",
...     "applyTo": "active",
...     "value": ["red", "blue"],
...     "delay": None,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'toggle red blue'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "toggle",
...     "applyTo": "active",
...     "value": (
...         base.MechanismSpecifier(None, None, None, "switch"),
...         ["on", "off"]
...     ),
...     "delay": None,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'toggle switch:on off'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "deactivate",
...     "applyTo": "active",
...     "value": None,
...     "delay": 2,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'deactivate ,2'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "goto",
...     "applyTo": "common",
...     "value": 3,
...     "delay": None,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'goto 3 +c'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "goto",
...     "applyTo": "common",
...     "value": 3,
...     "delay": None,
...     "charges": None,
...     "hidden": True
... }
>>> pf.unparseEffect(e)
'goto 3 +c +h'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = {
...     "type": "goto",
...     "applyTo": "active",
...     "value": 'home',
...     "delay": None,
...     "charges": None,
...     "hidden": False
... }
>>> pf.unparseEffect(e)
'goto home'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = base.effect(edit=[
...     [
...         commands.command('val', '5'),
...         commands.command('empty', 'list'),
...         commands.command('append', '$_')
...     ],
...     [
...         commands.command('val', '11'),
...         commands.command('assign', 'var', '$_'),
...         commands.command('op', '+', '$var', '$var')
...     ],
... ])
>>> pf.unparseEffect(e)
'edit {\n  val 5;\n  empty list;\n  append $_;\n} {\n  val 11;\n  assign var $_;\n  op + $var $var;\n}'
>>> pf.parseEffect(pf.unparseEffect(e)) == e
True
>>> e = base.effect(set=('coins', 3))
>>> pf.unparseEffect(e)
'set coins*3'
>>> e = base.effect(set=('skill', 'mashing', 3))
>>> pf.unparseEffect(e)
'set mashing^3'
def parseDecisionSpecifierFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0) -> Tuple[Union[exploration.base.DecisionSpecifier, int], int]:
2618    def parseDecisionSpecifierFromTokens(
2619        self,
2620        tokens: LexedTokens,
2621        start: int = 0
2622    ) -> Tuple[Union[base.DecisionSpecifier, int], int]:
2623        """
2624        Parses a decision specifier starting at the specified position
2625        in the given tokens list. No ending position is specified, but
2626        instead this function returns a tuple containing the parsed
2627        `base.DecisionSpecifier` along with an index in the tokens list
2628        where the end of the specifier was found.
2629
2630        For example:
2631
2632        >>> pf = ParseFormat()
2633        >>> pf.parseDecisionSpecifierFromTokens(['m'])
2634        (DecisionSpecifier(domain=None, zone=None, name='m'), 0)
2635        >>> pf.parseDecisionSpecifierFromTokens(['12'])  # ID specifier
2636        (12, 0)
2637        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'])
2638        (DecisionSpecifier(domain=None, zone=None, name='a'), 0)
2639        >>> pf.parseDecisionSpecifierFromTokens(['a', 'm'], 1)
2640        (DecisionSpecifier(domain=None, zone=None, name='m'), 1)
2641        >>> pf.parseDecisionSpecifierFromTokens(
2642        ...     ['a', Lexeme.domainSeparator, 'm']
2643        ... )
2644        (DecisionSpecifier(domain='a', zone=None, name='m'), 2)
2645        >>> pf.parseDecisionSpecifierFromTokens(
2646        ...     ['a', Lexeme.zoneSeparator, 'm']
2647        ... )
2648        (DecisionSpecifier(domain=None, zone='a', name='m'), 2)
2649        >>> pf.parseDecisionSpecifierFromTokens(
2650        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
2651        ... )
2652        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2653        >>> pf.parseDecisionSpecifierFromTokens(
2654        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
2655        ... )
2656        (DecisionSpecifier(domain='a', zone='b', name='m'), 4)
2657        >>> pf.parseDecisionSpecifierFromTokens(
2658        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
2659        ... )
2660        (DecisionSpecifier(domain=None, zone='a', name='b'), 2)
2661        >>> pf.parseDecisionSpecifierFromTokens(  # ID-style name w/ zone
2662        ...     ['a', Lexeme.zoneSeparator, '5'],
2663        ... )
2664        Traceback (most recent call last):
2665        ...
2666        exploration.base.InvalidDecisionSpecifierError...
2667        >>> pf.parseDecisionSpecifierFromTokens(
2668        ...     ['d', Lexeme.domainSeparator, '123']
2669        ... )
2670        Traceback (most recent call last):
2671        ...
2672        exploration.base.InvalidDecisionSpecifierError...
2673        >>> pf.parseDecisionSpecifierFromTokens(
2674        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2675        ...     1
2676        ... )
2677        Traceback (most recent call last):
2678        ...
2679        exploration.parsing.ParseError...
2680        >>> pf.parseDecisionSpecifierFromTokens(
2681        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
2682        ...     2
2683        ... )
2684        (DecisionSpecifier(domain='b', zone=None, name='m'), 4)
2685        >>> pf.parseDecisionSpecifierFromTokens(
2686        ...     [
2687        ...         'a',
2688        ...         Lexeme.domainSeparator,
2689        ...         'b',
2690        ...         Lexeme.zoneSeparator,
2691        ...         'c',
2692        ...         Lexeme.zoneSeparator,
2693        ...         'm'
2694        ...     ]
2695        ... )
2696        (DecisionSpecifier(domain='a', zone='b', name='c'), 4)
2697        >>> pf.parseDecisionSpecifierFromTokens(
2698        ...     [
2699        ...         'a',
2700        ...         Lexeme.domainSeparator,
2701        ...         'b',
2702        ...         Lexeme.zoneSeparator,
2703        ...         'c',
2704        ...         Lexeme.zoneSeparator,
2705        ...         'm'
2706        ...     ],
2707        ...     2
2708        ... )
2709        (DecisionSpecifier(domain=None, zone='b', name='c'), 4)
2710        >>> pf.parseDecisionSpecifierFromTokens(
2711        ...     [
2712        ...         'a',
2713        ...         Lexeme.domainSeparator,
2714        ...         'b',
2715        ...         Lexeme.zoneSeparator,
2716        ...         'c',
2717        ...         Lexeme.zoneSeparator,
2718        ...         'm'
2719        ...     ],
2720        ...     4
2721        ... )
2722        (DecisionSpecifier(domain=None, zone='c', name='m'), 6)
2723        >>> pf.parseDecisionSpecifierFromTokens(
2724        ...     [
2725        ...         'set',
2726        ...         'main',
2727        ...         Lexeme.domainSeparator,
2728        ...         'zone',
2729        ...         Lexeme.zoneSeparator,
2730        ...         'compass',
2731        ...         'north',
2732        ...         'bounce',
2733        ...     ],
2734        ...     1
2735        ... )
2736        (DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
2737        """
2738        # Check bounds & normalize start index
2739        nTokens = len(tokens)
2740        if start < -nTokens:
2741            raise IndexError(
2742                f"Invalid start index {start} for {nTokens} tokens (too"
2743                f" negative)."
2744            )
2745        elif start >= nTokens:
2746            raise IndexError(
2747                f"Invalid start index {start} for {nTokens} tokens (too"
2748                f" big)."
2749            )
2750        elif start < 0:
2751            start = nTokens + start
2752
2753        assert (start < nTokens)
2754
2755        first = tokens[start]
2756        if not isinstance(first, str):
2757            raise ParseError(
2758                f"Invalid domain specifier (must start with a name or"
2759                f" id; got: {first} = {self.formatDict[first]})."
2760            )
2761
2762        ds = base.DecisionSpecifier(None, None, first)
2763        result = (base.idOrDecisionSpecifier(ds), start)
2764
2765        domain = None
2766        zoneOrDecision = None
2767
2768        if start + 1 >= nTokens:  # at end of tokens
2769            return result
2770
2771        firstSep = tokens[start + 1]
2772        if firstSep == Lexeme.domainSeparator:
2773            domain = first
2774        elif firstSep == Lexeme.zoneSeparator:
2775            zoneOrDecision = first
2776        else:
2777            return result
2778
2779        if start + 2 >= nTokens:
2780            return result
2781
2782        second = tokens[start + 2]
2783        if isinstance(second, Lexeme):
2784            return result
2785
2786        ds = base.DecisionSpecifier(domain, zoneOrDecision, second)
2787        result = (base.idOrDecisionSpecifier(ds), start + 2)
2788
2789        if start + 3 >= nTokens:
2790            return result
2791
2792        secondSep = tokens[start + 3]
2793        if start + 4 >= nTokens:
2794            return result
2795
2796        third = tokens[start + 4]
2797        if secondSep == Lexeme.zoneSeparator:
2798            if zoneOrDecision is not None:  # two in a row
2799                return result
2800            else:
2801                if not isinstance(third, base.DecisionName):
2802                    return result
2803                else:
2804                    zoneOrDecision = second
2805        else:
2806            return result
2807
2808        if isinstance(third, Lexeme):
2809            return result
2810
2811        ds = base.DecisionSpecifier(domain, zoneOrDecision, third)
2812        return (base.idOrDecisionSpecifier(ds), start + 4)

Parses a decision specifier starting at the specified position in the given tokens list. No ending position is specified, but instead this function returns a tuple containing the parsed base.DecisionSpecifier along with an index in the tokens list where the end of the specifier was found.

For example:

>>> pf = ParseFormat()
>>> pf.parseDecisionSpecifierFromTokens(['m'])
(DecisionSpecifier(domain=None, zone=None, name='m'), 0)
>>> pf.parseDecisionSpecifierFromTokens(['12'])  # ID specifier
(12, 0)
>>> pf.parseDecisionSpecifierFromTokens(['a', 'm'])
(DecisionSpecifier(domain=None, zone=None, name='a'), 0)
>>> pf.parseDecisionSpecifierFromTokens(['a', 'm'], 1)
(DecisionSpecifier(domain=None, zone=None, name='m'), 1)
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.domainSeparator, 'm']
... )
(DecisionSpecifier(domain='a', zone=None, name='m'), 2)
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'm']
... )
(DecisionSpecifier(domain=None, zone='a', name='m'), 2)
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
... )
(DecisionSpecifier(domain=None, zone='a', name='b'), 2)
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
... )
(DecisionSpecifier(domain='a', zone='b', name='m'), 4)
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
... )
(DecisionSpecifier(domain=None, zone='a', name='b'), 2)
>>> pf.parseDecisionSpecifierFromTokens(  # ID-style name w/ zone
...     ['a', Lexeme.zoneSeparator, '5'],
... )
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
>>> pf.parseDecisionSpecifierFromTokens(
...     ['d', Lexeme.domainSeparator, '123']
... )
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
...     1
... )
Traceback (most recent call last):
...
ParseError...
>>> pf.parseDecisionSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
...     2
... )
(DecisionSpecifier(domain='b', zone=None, name='m'), 4)
>>> pf.parseDecisionSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ]
... )
(DecisionSpecifier(domain='a', zone='b', name='c'), 4)
>>> pf.parseDecisionSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     2
... )
(DecisionSpecifier(domain=None, zone='b', name='c'), 4)
>>> pf.parseDecisionSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     4
... )
(DecisionSpecifier(domain=None, zone='c', name='m'), 6)
>>> pf.parseDecisionSpecifierFromTokens(
...     [
...         'set',
...         'main',
...         Lexeme.domainSeparator,
...         'zone',
...         Lexeme.zoneSeparator,
...         'compass',
...         'north',
...         'bounce',
...     ],
...     1
... )
(DecisionSpecifier(domain='main', zone='zone', name='compass'), 5)
def parseDecisionSpecifier(self, specString: str) -> Union[int, exploration.base.DecisionSpecifier]:
2814    def parseDecisionSpecifier(
2815        self,
2816        specString: str
2817    ) -> Union[base.DecisionID, base.DecisionSpecifier]:
2818        """
2819        Parses a full `DecisionSpecifier` from a single string. Can
2820        parse integer decision IDs in string form, and returns a
2821        `DecisionID` in that case, otherwise returns a
2822        `DecisionSpecifier`. Assumes that all int-convertible strings
2823        are decision IDs, so it cannot deal with feature names which are
2824        just numbers.
2825
2826        For example:
2827
2828        >>> pf = ParseFormat()
2829        >>> pf.parseDecisionSpecifier('example')
2830        DecisionSpecifier(domain=None, zone=None, name='example')
2831        >>> pf.parseDecisionSpecifier('outer::example')
2832        DecisionSpecifier(domain=None, zone='outer', name='example')
2833        >>> pf.parseDecisionSpecifier('domain//region::feature')
2834        DecisionSpecifier(domain='domain', zone='region', name='feature')
2835        >>> pf.parseDecisionSpecifier('123')
2836        123
2837        >>> pf.parseDecisionSpecifier('region::domain//feature')
2838        Traceback (most recent call last):
2839        ...
2840        exploration.base.InvalidDecisionSpecifierError...
2841        >>> pf.parseDecisionSpecifier('domain1//domain2//feature')
2842        Traceback (most recent call last):
2843        ...
2844        exploration.base.InvalidDecisionSpecifierError...
2845        >>> pf.parseDecisionSpecifier('domain//123')
2846        Traceback (most recent call last):
2847        ...
2848        exploration.base.InvalidDecisionSpecifierError...
2849        >>> pf.parseDecisionSpecifier('region::123')
2850        Traceback (most recent call last):
2851        ...
2852        exploration.base.InvalidDecisionSpecifierError...
2853        """
2854        try:
2855            return int(specString)
2856        except ValueError:
2857            tokens = self.lex(specString)
2858            result, end = self.parseDecisionSpecifierFromTokens(tokens)
2859            if end != len(tokens) - 1:
2860                raise base.InvalidDecisionSpecifierError(
2861                    f"Junk after end of decision specifier:"
2862                    f"\n{tokens[end + 1:]}"
2863                )
2864            return result

Parses a full DecisionSpecifier from a single string. Can parse integer decision IDs in string form, and returns a DecisionID in that case, otherwise returns a DecisionSpecifier. Assumes that all int-convertible strings are decision IDs, so it cannot deal with feature names which are just numbers.

For example:

>>> pf = ParseFormat()
>>> pf.parseDecisionSpecifier('example')
DecisionSpecifier(domain=None, zone=None, name='example')
>>> pf.parseDecisionSpecifier('outer::example')
DecisionSpecifier(domain=None, zone='outer', name='example')
>>> pf.parseDecisionSpecifier('domain//region::feature')
DecisionSpecifier(domain='domain', zone='region', name='feature')
>>> pf.parseDecisionSpecifier('123')
123
>>> pf.parseDecisionSpecifier('region::domain//feature')
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
>>> pf.parseDecisionSpecifier('domain1//domain2//feature')
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
>>> pf.parseDecisionSpecifier('domain//123')
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
>>> pf.parseDecisionSpecifier('region::123')
Traceback (most recent call last):
...
exploration.base.InvalidDecisionSpecifierError...
def parseFeatureSpecifierFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, limit: int = -1) -> Tuple[exploration.base.FeatureSpecifier, int]:
2866    def parseFeatureSpecifierFromTokens(
2867        self,
2868        tokens: LexedTokens,
2869        start: int = 0,
2870        limit: int = -1
2871    ) -> Tuple[base.FeatureSpecifier, int]:
2872        """
2873        Parses a `FeatureSpecifier` starting from the specified part of
2874        a tokens list. Returns a tuple containing the feature specifier
2875        and the end position of the end of the feature specifier.
2876
2877        Can parse integer feature IDs in string form, as well as nested
2878        feature specifiers and plain feature specifiers. Assumes that
2879        all int-convertible strings are feature IDs, so it cannot deal
2880        with feature names which are just numbers.
2881
2882        For example:
2883
2884        >>> pf = ParseFormat()
2885        >>> pf.parseFeatureSpecifierFromTokens(['example'])
2886        (FeatureSpecifier(domain=None, within=[], feature='example',\
2887 part=None), 0)
2888        >>> pf.parseFeatureSpecifierFromTokens(['example1', 'example2'], 1)
2889        (FeatureSpecifier(domain=None, within=[], feature='example2',\
2890 part=None), 1)
2891        >>> pf.parseFeatureSpecifierFromTokens(
2892        ...     [
2893        ...         'domain',
2894        ...         Lexeme.domainSeparator,
2895        ...         'region',
2896        ...         Lexeme.zoneSeparator,
2897        ...         'feature',
2898        ...         Lexeme.partSeparator,
2899        ...         'part'
2900        ...     ]
2901        ... )
2902        (FeatureSpecifier(domain='domain', within=['region'],\
2903 feature='feature', part='part'), 6)
2904        >>> pf.parseFeatureSpecifierFromTokens(
2905        ...     [
2906        ...         'outerRegion',
2907        ...         Lexeme.zoneSeparator,
2908        ...         'midRegion',
2909        ...         Lexeme.zoneSeparator,
2910        ...         'innerRegion',
2911        ...         Lexeme.zoneSeparator,
2912        ...         'feature'
2913        ...     ]
2914        ... )
2915        (FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
2916 'innerRegion'], feature='feature', part=None), 6)
2917        >>> pf.parseFeatureSpecifierFromTokens(
2918        ...     [
2919        ...         'outerRegion',
2920        ...         Lexeme.zoneSeparator,
2921        ...         'midRegion',
2922        ...         Lexeme.zoneSeparator,
2923        ...         'innerRegion',
2924        ...         Lexeme.zoneSeparator,
2925        ...         'feature'
2926        ...     ],
2927        ...     1
2928        ... )
2929        Traceback (most recent call last):
2930        ...
2931        exploration.parsing.InvalidFeatureSpecifierError...
2932        >>> pf.parseFeatureSpecifierFromTokens(
2933        ...     [
2934        ...         'outerRegion',
2935        ...         Lexeme.zoneSeparator,
2936        ...         'midRegion',
2937        ...         Lexeme.zoneSeparator,
2938        ...         'innerRegion',
2939        ...         Lexeme.zoneSeparator,
2940        ...         'feature'
2941        ...     ],
2942        ...     2
2943        ... )
2944        (FeatureSpecifier(domain=None, within=['midRegion', 'innerRegion'],\
2945 feature='feature', part=None), 6)
2946        >>> pf.parseFeatureSpecifierFromTokens(
2947        ...     [
2948        ...         'outerRegion',
2949        ...         Lexeme.zoneSeparator,
2950        ...         'feature',
2951        ...         Lexeme.domainSeparator,
2952        ...         'after',
2953        ...     ]
2954        ... )
2955        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2956 feature='feature', part=None), 2)
2957        >>> pf.parseFeatureSpecifierFromTokens(
2958        ...     [
2959        ...         'outerRegion',
2960        ...         Lexeme.zoneSeparator,
2961        ...         'feature',
2962        ...         Lexeme.domainSeparator,
2963        ...         'after',
2964        ...     ],
2965        ...     2
2966        ... )
2967        (FeatureSpecifier(domain='feature', within=[], feature='after',\
2968 part=None), 4)
2969        >>> # Including a limit:
2970        >>> pf.parseFeatureSpecifierFromTokens(
2971        ...     [
2972        ...         'outerRegion',
2973        ...         Lexeme.zoneSeparator,
2974        ...         'midRegion',
2975        ...         Lexeme.zoneSeparator,
2976        ...         'feature',
2977        ...     ],
2978        ...     0,
2979        ...     2
2980        ... )
2981        (FeatureSpecifier(domain=None, within=['outerRegion'],\
2982 feature='midRegion', part=None), 2)
2983        >>> pf.parseFeatureSpecifierFromTokens(
2984        ...     [
2985        ...         'outerRegion',
2986        ...         Lexeme.zoneSeparator,
2987        ...         'midRegion',
2988        ...         Lexeme.zoneSeparator,
2989        ...         'feature',
2990        ...     ],
2991        ...     0,
2992        ...     0
2993        ... )
2994        (FeatureSpecifier(domain=None, within=[], feature='outerRegion',\
2995 part=None), 0)
2996        >>> pf.parseFeatureSpecifierFromTokens(
2997        ...     [
2998        ...         'region',
2999        ...         Lexeme.zoneSeparator,
3000        ...         Lexeme.zoneSeparator,
3001        ...         'feature',
3002        ...     ]
3003        ... )
3004        (FeatureSpecifier(domain=None, within=[], feature='region',\
3005 part=None), 0)
3006        """
3007        start, limit, nTokens = normalizeEnds(tokens, start, limit)
3008
3009        if nTokens == 0:
3010            raise InvalidFeatureSpecifierError(
3011                "Can't parse a feature specifier from 0 tokens."
3012            )
3013        first = tokens[start]
3014        if isinstance(first, Lexeme):
3015            raise InvalidFeatureSpecifierError(
3016                f"Feature specifier can't begin with a special token."
3017                f"Got:\n{tokens[start:limit + 1]}"
3018            )
3019
3020        if nTokens in (1, 2):
3021            # 2 tokens isn't enough for a second part
3022            fs = base.FeatureSpecifier(
3023                domain=None,
3024                within=[],
3025                feature=first,
3026                part=None
3027            )
3028            return (base.normalizeFeatureSpecifier(fs), start)
3029
3030        firstSep = tokens[start + 1]
3031        secondPart = tokens[start + 2]
3032
3033        if (
3034            firstSep not in (
3035                Lexeme.domainSeparator,
3036                Lexeme.zoneSeparator,
3037                Lexeme.partSeparator
3038            )
3039         or not isinstance(secondPart, str)
3040        ):
3041            # Following tokens won't work out
3042            fs = base.FeatureSpecifier(
3043                domain=None,
3044                within=[],
3045                feature=first,
3046                part=None
3047            )
3048            return (base.normalizeFeatureSpecifier(fs), start)
3049
3050        if firstSep == Lexeme.domainSeparator:
3051            if start + 2 > limit:
3052                return (
3053                    base.FeatureSpecifier(
3054                        domain=first,
3055                        within=[],
3056                        feature=secondPart,
3057                        part=None
3058                    ),
3059                    start + 2
3060                )
3061            else:
3062                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3063                    tokens,
3064                    start + 2,
3065                    limit
3066                )
3067                if rest.domain is not None:  # two domainSeparators in a row
3068                    fs = base.FeatureSpecifier(
3069                        domain=first,
3070                        within=[],
3071                        feature=rest.domain,
3072                        part=None
3073                    )
3074                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3075                else:
3076                    fs = base.FeatureSpecifier(
3077                        domain=first,
3078                        within=rest.within,
3079                        feature=rest.feature,
3080                        part=rest.part
3081                    )
3082                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3083
3084        elif firstSep == Lexeme.zoneSeparator:
3085            if start + 2 > limit:
3086                fs = base.FeatureSpecifier(
3087                    domain=None,
3088                    within=[first],
3089                    feature=secondPart,
3090                    part=None
3091                )
3092                return (base.normalizeFeatureSpecifier(fs), start + 2)
3093            else:
3094                rest, restEnd = self.parseFeatureSpecifierFromTokens(
3095                    tokens,
3096                    start + 2,
3097                    limit
3098                )
3099                if rest.domain is not None:  # domain sep after zone sep
3100                    fs = base.FeatureSpecifier(
3101                        domain=None,
3102                        within=[first],
3103                        feature=rest.domain,
3104                        part=None
3105                    )
3106                    return (base.normalizeFeatureSpecifier(fs), start + 2)
3107                else:
3108                    within = [first]
3109                    within.extend(rest.within)
3110                    fs = base.FeatureSpecifier(
3111                        domain=None,
3112                        within=within,
3113                        feature=rest.feature,
3114                        part=rest.part
3115                    )
3116                    return (base.normalizeFeatureSpecifier(fs), restEnd)
3117
3118        else:  # must be partSeparator
3119            fs = base.FeatureSpecifier(
3120                domain=None,
3121                within=[],
3122                feature=first,
3123                part=secondPart
3124            )
3125            return (base.normalizeFeatureSpecifier(fs), start + 2)

Parses a FeatureSpecifier starting from the specified part of a tokens list. Returns a tuple containing the feature specifier and the end position of the end of the feature specifier.

Can parse integer feature IDs in string form, as well as nested feature specifiers and plain feature specifiers. Assumes that all int-convertible strings are feature IDs, so it cannot deal with feature names which are just numbers.

For example:

>>> pf = ParseFormat()
>>> pf.parseFeatureSpecifierFromTokens(['example'])
(FeatureSpecifier(domain=None, within=[], feature='example', part=None), 0)
>>> pf.parseFeatureSpecifierFromTokens(['example1', 'example2'], 1)
(FeatureSpecifier(domain=None, within=[], feature='example2', part=None), 1)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'domain',
...         Lexeme.domainSeparator,
...         'region',
...         Lexeme.zoneSeparator,
...         'feature',
...         Lexeme.partSeparator,
...         'part'
...     ]
... )
(FeatureSpecifier(domain='domain', within=['region'], feature='feature', part='part'), 6)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'midRegion',
...         Lexeme.zoneSeparator,
...         'innerRegion',
...         Lexeme.zoneSeparator,
...         'feature'
...     ]
... )
(FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion', 'innerRegion'], feature='feature', part=None), 6)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'midRegion',
...         Lexeme.zoneSeparator,
...         'innerRegion',
...         Lexeme.zoneSeparator,
...         'feature'
...     ],
...     1
... )
Traceback (most recent call last):
...
InvalidFeatureSpecifierError...
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'midRegion',
...         Lexeme.zoneSeparator,
...         'innerRegion',
...         Lexeme.zoneSeparator,
...         'feature'
...     ],
...     2
... )
(FeatureSpecifier(domain=None, within=['midRegion', 'innerRegion'], feature='feature', part=None), 6)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'feature',
...         Lexeme.domainSeparator,
...         'after',
...     ]
... )
(FeatureSpecifier(domain=None, within=['outerRegion'], feature='feature', part=None), 2)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'feature',
...         Lexeme.domainSeparator,
...         'after',
...     ],
...     2
... )
(FeatureSpecifier(domain='feature', within=[], feature='after', part=None), 4)
>>> # Including a limit:
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'midRegion',
...         Lexeme.zoneSeparator,
...         'feature',
...     ],
...     0,
...     2
... )
(FeatureSpecifier(domain=None, within=['outerRegion'], feature='midRegion', part=None), 2)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'outerRegion',
...         Lexeme.zoneSeparator,
...         'midRegion',
...         Lexeme.zoneSeparator,
...         'feature',
...     ],
...     0,
...     0
... )
(FeatureSpecifier(domain=None, within=[], feature='outerRegion', part=None), 0)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'region',
...         Lexeme.zoneSeparator,
...         Lexeme.zoneSeparator,
...         'feature',
...     ]
... )
(FeatureSpecifier(domain=None, within=[], feature='region', part=None), 0)
def parseFeatureSpecifier(self, specString: str) -> exploration.base.FeatureSpecifier:
3127    def parseFeatureSpecifier(self, specString: str) -> base.FeatureSpecifier:
3128        """
3129        Parses a full `FeatureSpecifier` from a single string. See
3130        `parseFeatureSpecifierFromTokens`.
3131
3132        >>> pf = ParseFormat()
3133        >>> pf.parseFeatureSpecifier('example')
3134        FeatureSpecifier(domain=None, within=[], feature='example', part=None)
3135        >>> pf.parseFeatureSpecifier('outer::example')
3136        FeatureSpecifier(domain=None, within=['outer'], feature='example',\
3137 part=None)
3138        >>> pf.parseFeatureSpecifier('example%%middle')
3139        FeatureSpecifier(domain=None, within=[], feature='example',\
3140 part='middle')
3141        >>> pf.parseFeatureSpecifier('domain//region::feature%%part')
3142        FeatureSpecifier(domain='domain', within=['region'],\
3143 feature='feature', part='part')
3144        >>> pf.parseFeatureSpecifier(
3145        ...     'outerRegion::midRegion::innerRegion::feature'
3146        ... )
3147        FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion',\
3148 'innerRegion'], feature='feature', part=None)
3149        >>> pf.parseFeatureSpecifier('region::domain//feature')
3150        Traceback (most recent call last):
3151        ...
3152        exploration.parsing.InvalidFeatureSpecifierError...
3153        >>> pf.parseFeatureSpecifier('feature%%part1%%part2')
3154        Traceback (most recent call last):
3155        ...
3156        exploration.parsing.InvalidFeatureSpecifierError...
3157        >>> pf.parseFeatureSpecifier('domain1//domain2//feature')
3158        Traceback (most recent call last):
3159        ...
3160        exploration.parsing.InvalidFeatureSpecifierError...
3161        >>> # TODO: Issue warnings for these...
3162        >>> pf.parseFeatureSpecifier('domain//123')  # domain discarded
3163        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3164        >>> pf.parseFeatureSpecifier('region::123')  # zone discarded
3165        FeatureSpecifier(domain=None, within=[], feature=123, part=None)
3166        >>> pf.parseFeatureSpecifier('123%%part')
3167        FeatureSpecifier(domain=None, within=[], feature=123, part='part')
3168        """
3169        tokens = self.lex(specString)
3170        result, rEnd = self.parseFeatureSpecifierFromTokens(tokens)
3171        if rEnd != len(tokens) - 1:
3172            raise InvalidFeatureSpecifierError(
3173                f"Feature specifier has extra stuff at end:"
3174                f" {tokens[rEnd + 1:]}"
3175            )
3176        else:
3177            return result

Parses a full FeatureSpecifier from a single string. See parseFeatureSpecifierFromTokens.

>>> pf = ParseFormat()
>>> pf.parseFeatureSpecifier('example')
FeatureSpecifier(domain=None, within=[], feature='example', part=None)
>>> pf.parseFeatureSpecifier('outer::example')
FeatureSpecifier(domain=None, within=['outer'], feature='example', part=None)
>>> pf.parseFeatureSpecifier('example%%middle')
FeatureSpecifier(domain=None, within=[], feature='example', part='middle')
>>> pf.parseFeatureSpecifier('domain//region::feature%%part')
FeatureSpecifier(domain='domain', within=['region'], feature='feature', part='part')
>>> pf.parseFeatureSpecifier(
...     'outerRegion::midRegion::innerRegion::feature'
... )
FeatureSpecifier(domain=None, within=['outerRegion', 'midRegion', 'innerRegion'], feature='feature', part=None)
>>> pf.parseFeatureSpecifier('region::domain//feature')
Traceback (most recent call last):
...
InvalidFeatureSpecifierError...
>>> pf.parseFeatureSpecifier('feature%%part1%%part2')
Traceback (most recent call last):
...
InvalidFeatureSpecifierError...
>>> pf.parseFeatureSpecifier('domain1//domain2//feature')
Traceback (most recent call last):
...
InvalidFeatureSpecifierError...
>>> # TODO: Issue warnings for these...
>>> pf.parseFeatureSpecifier('domain//123')  # domain discarded
FeatureSpecifier(domain=None, within=[], feature=123, part=None)
>>> pf.parseFeatureSpecifier('region::123')  # zone discarded
FeatureSpecifier(domain=None, within=[], feature=123, part=None)
>>> pf.parseFeatureSpecifier('123%%part')
FeatureSpecifier(domain=None, within=[], feature=123, part='part')
def normalizeFeatureSpecifier( self, spec: Union[int, str, exploration.base.FeatureSpecifier]) -> exploration.base.FeatureSpecifier:
3179    def normalizeFeatureSpecifier(
3180        self,
3181        spec: base.AnyFeatureSpecifier
3182    ) -> base.FeatureSpecifier:
3183        """
3184        Normalizes any kind of feature specifier into an official
3185        `FeatureSpecifier` tuple.
3186
3187        For example:
3188
3189        >>> pf = ParseFormat()
3190        >>> pf.normalizeFeatureSpecifier('town')
3191        FeatureSpecifier(domain=None, within=[], feature='town', part=None)
3192        >>> pf.normalizeFeatureSpecifier(5)
3193        FeatureSpecifier(domain=None, within=[], feature=5, part=None)
3194        >>> pf.parseFeatureSpecifierFromTokens(
3195        ...     [
3196        ...         'domain',
3197        ...         Lexeme.domainSeparator,
3198        ...         'region',
3199        ...         Lexeme.zoneSeparator,
3200        ...         'feature',
3201        ...         Lexeme.partSeparator,
3202        ...         'part'
3203        ...     ]
3204        ... )
3205        (FeatureSpecifier(domain='domain', within=['region'],\
3206 feature='feature', part='part'), 6)
3207        >>> pf.normalizeFeatureSpecifier('dom//one::two::three%%middle')
3208        FeatureSpecifier(domain='dom', within=['one', 'two'],\
3209 feature='three', part='middle')
3210        >>> pf.normalizeFeatureSpecifier(
3211        ...   base.FeatureSpecifier(None, ['region'], 'place', None)
3212        ... )
3213        FeatureSpecifier(domain=None, within=['region'], feature='place',\
3214 part=None)
3215        >>> fs = base.FeatureSpecifier(None, [], 'place', None)
3216        >>> ns = pf.normalizeFeatureSpecifier(fs)
3217        >>> ns is fs  # Doesn't create unnecessary clones
3218        True
3219        """
3220        if isinstance(spec, base.FeatureSpecifier):
3221            return spec
3222        elif isinstance(spec, base.FeatureID):
3223            return base.FeatureSpecifier(None, [], spec, None)
3224        elif isinstance(spec, str):
3225            return self.parseFeatureSpecifier(spec)
3226        else:
3227            raise TypeError(f"Invalid feature specifier type: '{type(spec)}'")

Normalizes any kind of feature specifier into an official FeatureSpecifier tuple.

For example:

>>> pf = ParseFormat()
>>> pf.normalizeFeatureSpecifier('town')
FeatureSpecifier(domain=None, within=[], feature='town', part=None)
>>> pf.normalizeFeatureSpecifier(5)
FeatureSpecifier(domain=None, within=[], feature=5, part=None)
>>> pf.parseFeatureSpecifierFromTokens(
...     [
...         'domain',
...         Lexeme.domainSeparator,
...         'region',
...         Lexeme.zoneSeparator,
...         'feature',
...         Lexeme.partSeparator,
...         'part'
...     ]
... )
(FeatureSpecifier(domain='domain', within=['region'], feature='feature', part='part'), 6)
>>> pf.normalizeFeatureSpecifier('dom//one::two::three%%middle')
FeatureSpecifier(domain='dom', within=['one', 'two'], feature='three', part='middle')
>>> pf.normalizeFeatureSpecifier(
...   base.FeatureSpecifier(None, ['region'], 'place', None)
... )
FeatureSpecifier(domain=None, within=['region'], feature='place', part=None)
>>> fs = base.FeatureSpecifier(None, [], 'place', None)
>>> ns = pf.normalizeFeatureSpecifier(fs)
>>> ns is fs  # Doesn't create unnecessary clones
True
def unparseChallenge(self, challenge: exploration.base.Challenge) -> str:
3229    def unparseChallenge(self, challenge: base.Challenge) -> str:
3230        """
3231        Turns a `base.Challenge` into a string that can be turned back
3232        into an equivalent challenge by `parseChallenge`. For example:
3233
3234        >>> pf = ParseFormat()
3235        >>> c = base.challenge(
3236        ...     skills=base.BestSkill('brains', 'brawn'),
3237        ...     level=2,
3238        ...     success=[base.effect(set=('switch', 'on'))],
3239        ...     failure=[
3240        ...         base.effect(deactivate=True, delay=1),
3241        ...         base.effect(bounce=True)
3242        ...     ],
3243        ...     outcome=True
3244        ... )
3245        >>> r = pf.unparseChallenge(c)
3246        >>> r
3247        '<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}'
3248        >>> pf.parseChallenge(r) == c
3249        True
3250        >>> c2 = base.challenge(
3251        ...     skills=base.CombinedSkill(
3252        ...         -2,
3253        ...         base.ConditionalSkill(
3254        ...             base.ReqCapability('tough'),
3255        ...             base.BestSkill(1),
3256        ...             base.BestSkill(-1)
3257        ...         )
3258        ...     ),
3259        ...     level=-2,
3260        ...     success=[base.effect(gain='orb')],
3261        ...     failure=[],
3262        ...     outcome=None
3263        ... )
3264        >>> r2 = pf.unparseChallenge(c2)
3265        >>> r2
3266        '<-2>sum(-2, if(tough, best(1), best(-1))){gain orb}{}'
3267        >>> # TODO: let this parse through without BestSkills...
3268        >>> pf.parseChallenge(r2) == c2
3269        True
3270        """
3271        lt = self.formatDict[Lexeme.angleLeft]
3272        gt = self.formatDict[Lexeme.angleRight]
3273        result = (
3274            lt + str(challenge['level']) + gt
3275          + challenge['skills'].unparse()
3276        )
3277        if challenge['outcome'] is True:
3278            result += gt
3279        result += self.unparseConsequence(challenge['success'])
3280        if challenge['outcome'] is False:
3281            result += gt
3282        result += self.unparseConsequence(challenge['failure'])
3283        return result

Turns a base.Challenge into a string that can be turned back into an equivalent challenge by parseChallenge. For example:

>>> pf = ParseFormat()
>>> c = base.challenge(
...     skills=base.BestSkill('brains', 'brawn'),
...     level=2,
...     success=[base.effect(set=('switch', 'on'))],
...     failure=[
...         base.effect(deactivate=True, delay=1),
...         base.effect(bounce=True)
...     ],
...     outcome=True
... )
>>> r = pf.unparseChallenge(c)
>>> r
'<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}'
>>> pf.parseChallenge(r) == c
True
>>> c2 = base.challenge(
...     skills=base.CombinedSkill(
...         -2,
...         base.ConditionalSkill(
...             base.ReqCapability('tough'),
...             base.BestSkill(1),
...             base.BestSkill(-1)
...         )
...     ),
...     level=-2,
...     success=[base.effect(gain='orb')],
...     failure=[],
...     outcome=None
... )
>>> r2 = pf.unparseChallenge(c2)
>>> r2
'<-2>sum(-2, if(tough, best(1), best(-1))){gain orb}{}'
>>> # TODO: let this parse through without BestSkills...
>>> pf.parseChallenge(r2) == c2
True
def unparseCondition(self, condition: exploration.base.Condition) -> str:
3285    def unparseCondition(self, condition: base.Condition) -> str:
3286        """
3287        Given a `base.Condition` returns a string that would result in
3288        that condition if given to `parseCondition`. For example:
3289
3290        >>> pf = ParseFormat()
3291        >>> c = base.condition(
3292        ...     condition=base.ReqAny([
3293        ...         base.ReqCapability('brawny'),
3294        ...         base.ReqNot(base.ReqTokens('weights', 3))
3295        ...     ]),
3296        ...     consequence=[base.effect(gain='power')]
3297        ... )
3298        >>> r = pf.unparseCondition(c)
3299        >>> r
3300        '??((brawny|!(weights*3))){gain power}{}'
3301        >>> pf.parseCondition(r) == c
3302        True
3303        """
3304        return (
3305            self.formatDict[Lexeme.doubleQuestionmark]
3306          + self.formatDict[Lexeme.openParen]
3307          + condition['condition'].unparse()
3308          + self.formatDict[Lexeme.closeParen]
3309          + self.unparseConsequence(condition['consequence'])
3310          + self.unparseConsequence(condition['alternative'])
3311        )

Given a base.Condition returns a string that would result in that condition if given to parseCondition. For example:

>>> pf = ParseFormat()
>>> c = base.condition(
...     condition=base.ReqAny([
...         base.ReqCapability('brawny'),
...         base.ReqNot(base.ReqTokens('weights', 3))
...     ]),
...     consequence=[base.effect(gain='power')]
... )
>>> r = pf.unparseCondition(c)
>>> r
'??((brawny|!(weights*3))){gain power}{}'
>>> pf.parseCondition(r) == c
True
def unparseConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> str:
3313    def unparseConsequence(self, consequence: base.Consequence) -> str:
3314        """
3315        Given a `base.Consequence`, returns a string encoding of it,
3316        using the same format that `parseConsequence` will parse. Uses
3317        function-call-like syntax and curly braces to denote different
3318        sub-consequences. See also `SkillCombination.unparse` and
3319        `Requirement.unparse` For example:
3320
3321        >>> pf = ParseFormat()
3322        >>> c = [base.effect(gain='one'), base.effect(lose='one')]
3323        >>> pf.unparseConsequence(c)
3324        '{gain one; lose one}'
3325        >>> c = [
3326        ...     base.challenge(
3327        ...         skills=base.BestSkill('brains', 'brawn'),
3328        ...         level=2,
3329        ...         success=[base.effect(set=('switch', 'on'))],
3330        ...         failure=[
3331        ...             base.effect(deactivate=True, delay=1),
3332        ...             base.effect(bounce=True)
3333        ...         ],
3334        ...         outcome=True
3335        ...     )
3336        ... ]
3337        >>> pf.unparseConsequence(c)
3338        '{<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}}'
3339        >>> c[0]['outcome'] = False
3340        >>> pf.unparseConsequence(c)
3341        '{<2>best(brains, brawn){set switch:on}>{deactivate ,1; bounce}}'
3342        >>> c[0]['outcome'] = None
3343        >>> pf.unparseConsequence(c)
3344        '{<2>best(brains, brawn){set switch:on}{deactivate ,1; bounce}}'
3345        >>> c = [
3346        ...     base.condition(
3347        ...         condition=base.ReqAny([
3348        ...             base.ReqCapability('brawny'),
3349        ...             base.ReqNot(base.ReqTokens('weights', 3))
3350        ...         ]),
3351        ...         consequence=[
3352        ...             base.challenge(
3353        ...                 skills=base.CombinedSkill('brains', 'brawn'),
3354        ...                 level=3,
3355        ...                 success=[base.effect(goto='home')],
3356        ...                 failure=[base.effect(bounce=True)],
3357        ...                 outcome=None
3358        ...             )
3359        ...         ]  # no alternative -> empty list
3360        ...     )
3361        ... ]
3362        >>> pf.unparseConsequence(c)
3363        '{??((brawny|!(weights*3))){\
3364<3>sum(brains, brawn){goto home}{bounce}}{}}'
3365        >>> c = [base.effect(gain='if(power){gain "mimic"}')]
3366        >>> # TODO: Make this work!
3367        >>> # pf.unparseConsequence(c)
3368
3369        '{gain "if(power){gain \\\\"mimic\\\\"}"}'
3370        """
3371        result = self.formatDict[Lexeme.openCurly]
3372        for item in consequence:
3373            if 'skills' in item:  # a Challenge
3374                item = cast(base.Challenge, item)
3375                result += self.unparseChallenge(item)
3376
3377            elif 'value' in item:  # an Effect
3378                item = cast(base.Effect, item)
3379                result += self.unparseEffect(item)
3380
3381            elif 'condition' in item:  # a Condition
3382                item = cast(base.Condition, item)
3383                result += self.unparseCondition(item)
3384
3385            else:  # bad dict
3386                raise TypeError(
3387                    f"Invalid consequence: items in the list must be"
3388                    f" Effects, Challenges, or Conditions (got a dictionary"
3389                    f" without 'skills', 'value', or 'condition' keys)."
3390                    f"\nGot item: {repr(item)}"
3391                )
3392            result += '; '
3393
3394        if result.endswith('; '):
3395            result = result[:-2]
3396
3397        return result + self.formatDict[Lexeme.closeCurly]

Given a base.Consequence, returns a string encoding of it, using the same format that parseConsequence will parse. Uses function-call-like syntax and curly braces to denote different sub-consequences. See also SkillCombination.unparse and Requirement.unparse For example:

>>> pf = ParseFormat()
>>> c = [base.effect(gain='one'), base.effect(lose='one')]
>>> pf.unparseConsequence(c)
'{gain one; lose one}'
>>> c = [
...     base.challenge(
...         skills=base.BestSkill('brains', 'brawn'),
...         level=2,
...         success=[base.effect(set=('switch', 'on'))],
...         failure=[
...             base.effect(deactivate=True, delay=1),
...             base.effect(bounce=True)
...         ],
...         outcome=True
...     )
... ]
>>> pf.unparseConsequence(c)
'{<2>best(brains, brawn)>{set switch:on}{deactivate ,1; bounce}}'
>>> c[0]['outcome'] = False
>>> pf.unparseConsequence(c)
'{<2>best(brains, brawn){set switch:on}>{deactivate ,1; bounce}}'
>>> c[0]['outcome'] = None
>>> pf.unparseConsequence(c)
'{<2>best(brains, brawn){set switch:on}{deactivate ,1; bounce}}'
>>> c = [
...     base.condition(
...         condition=base.ReqAny([
...             base.ReqCapability('brawny'),
...             base.ReqNot(base.ReqTokens('weights', 3))
...         ]),
...         consequence=[
...             base.challenge(
...                 skills=base.CombinedSkill('brains', 'brawn'),
...                 level=3,
...                 success=[base.effect(goto='home')],
...                 failure=[base.effect(bounce=True)],
...                 outcome=None
...             )
...         ]  # no alternative -> empty list
...     )
... ]
>>> pf.unparseConsequence(c)
'{??((brawny|!(weights*3))){<3>sum(brains, brawn){goto home}{bounce}}{}}'
>>> c = [base.effect(gain='if(power){gain "mimic"}')]
>>> # TODO: Make this work!
>>> # pf.unparseConsequence(c)

'{gain "if(power){gain \"mimic\"}"}'

def parseMechanismSpecifierFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0) -> Tuple[exploration.base.MechanismSpecifier, int]:
3399    def parseMechanismSpecifierFromTokens(
3400        self,
3401        tokens: LexedTokens,
3402        start: int = 0
3403    ) -> Tuple[base.MechanismSpecifier, int]:
3404        """
3405        Parses a mechanism specifier starting at the specified position
3406        in the given tokens list. No ending position is specified, but
3407        instead this function returns a tuple containing the parsed
3408        `base.MechanismSpecifier` along with an index in the tokens list
3409        where the end of the specifier was found.
3410
3411        For example:
3412
3413        >>> pf = ParseFormat()
3414        >>> pf.parseMechanismSpecifierFromTokens(['m'])
3415        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3416 name='m'), 0)
3417        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'])
3418        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3419 name='a'), 0)
3420        >>> pf.parseMechanismSpecifierFromTokens(['a', 'm'], 1)
3421        (MechanismSpecifier(domain=None, zone=None, decision=None,\
3422 name='m'), 1)
3423        >>> pf.parseMechanismSpecifierFromTokens(
3424        ...     ['a', Lexeme.domainSeparator, 'm']
3425        ... )
3426        (MechanismSpecifier(domain='a', zone=None, decision=None,\
3427 name='m'), 2)
3428        >>> pf.parseMechanismSpecifierFromTokens(
3429        ...     ['a', Lexeme.zoneSeparator, 'm']
3430        ... )
3431        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3432 name='m'), 2)
3433        >>> pf.parseMechanismSpecifierFromTokens(
3434        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
3435        ... )
3436        (MechanismSpecifier(domain=None, zone='a', decision='b',\
3437 name='m'), 4)
3438        >>> pf.parseMechanismSpecifierFromTokens(
3439        ...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
3440        ... )
3441        (MechanismSpecifier(domain='a', zone=None, decision='b',\
3442 name='m'), 4)
3443        >>> pf.parseMechanismSpecifierFromTokens(
3444        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
3445        ... )
3446        (MechanismSpecifier(domain=None, zone=None, decision='a',\
3447 name='b'), 2)
3448        >>> pf.parseMechanismSpecifierFromTokens(
3449        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3450        ...     1
3451        ... )
3452        Traceback (most recent call last):
3453        ...
3454        exploration.parsing.ParseError...
3455        >>> pf.parseMechanismSpecifierFromTokens(
3456        ...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
3457        ...     2
3458        ... )
3459        (MechanismSpecifier(domain='b', zone=None, decision=None,\
3460 name='m'), 4)
3461        >>> pf.parseMechanismSpecifierFromTokens(
3462        ...     [
3463        ...         'a',
3464        ...         Lexeme.domainSeparator,
3465        ...         'b',
3466        ...         Lexeme.zoneSeparator,
3467        ...         'c',
3468        ...         Lexeme.zoneSeparator,
3469        ...         'm'
3470        ...     ]
3471        ... )
3472        (MechanismSpecifier(domain='a', zone='b', decision='c', name='m'), 6)
3473        >>> pf.parseMechanismSpecifierFromTokens(
3474        ...     [
3475        ...         'a',
3476        ...         Lexeme.domainSeparator,
3477        ...         'b',
3478        ...         Lexeme.zoneSeparator,
3479        ...         'c',
3480        ...         Lexeme.zoneSeparator,
3481        ...         'm'
3482        ...     ],
3483        ...     2
3484        ... )
3485        (MechanismSpecifier(domain=None, zone='b', decision='c',\
3486 name='m'), 6)
3487        >>> pf.parseMechanismSpecifierFromTokens(
3488        ...     [
3489        ...         'a',
3490        ...         Lexeme.domainSeparator,
3491        ...         'b',
3492        ...         Lexeme.zoneSeparator,
3493        ...         'c',
3494        ...         Lexeme.zoneSeparator,
3495        ...         'm'
3496        ...     ],
3497        ...     4
3498        ... )
3499        (MechanismSpecifier(domain=None, zone=None, decision='c',\
3500 name='m'), 6)
3501        >>> pf.parseMechanismSpecifierFromTokens(
3502        ...     [
3503        ...         'roomB',
3504        ...         Lexeme.zoneSeparator,
3505        ...         'switch',
3506        ...         Lexeme.mechanismSeparator,
3507        ...         'on'
3508        ...     ]
3509        ... )
3510        (MechanismSpecifier(domain=None, zone=None, decision='roomB',\
3511 name='switch'), 2)
3512        >>> pf.parseMechanismSpecifierFromTokens(
3513        ...     [
3514        ...         '500',
3515        ...         Lexeme.zoneSeparator,
3516        ...         'm'
3517        ...     ],
3518        ...     0
3519        ... )
3520        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3521 name='m'), 2)
3522        >>> pf.parseMechanismSpecifierFromTokens(
3523        ...     [
3524        ...         '500',
3525        ...         Lexeme.zoneSeparator,
3526        ...         Lexeme.zoneSeparator,
3527        ...         'm'
3528        ...     ],
3529        ...     0
3530        ... )
3531        Traceback (most recent call last):
3532        ...
3533        exploration.parsing.ParseError...
3534        >>> pf.parseMechanismSpecifierFromTokens(
3535        ...     [
3536        ...         '500',
3537        ...         Lexeme.zoneSeparator,
3538        ...         'm',
3539        ...         Lexeme.mechanismSeparator,
3540        ...         'on'
3541        ...     ],
3542        ...     0
3543        ... )
3544        (MechanismSpecifier(domain=None, zone=None, decision=500,\
3545 name='m'), 2)
3546        >>> pf.parseMechanismSpecifierFromTokens(
3547        ...     [
3548        ...         '500',
3549        ...         Lexeme.zoneSeparator,
3550        ...         'd',
3551        ...         Lexeme.zoneSeparator,
3552        ...         'm'
3553        ...     ],
3554        ...     0
3555        ... )
3556        (MechanismSpecifier(domain=None, zone='500', decision='d',\
3557 name='m'), 4)
3558        """
3559        start, tEnd, nLeft = normalizeEnds(tokens, start, -1)
3560
3561        try:
3562            dSpec, dEnd = self.parseDecisionSpecifierFromTokens(
3563                tokens,
3564                start
3565            )
3566        except ParseError:
3567            raise ParseError(
3568                "Failed to parse mechanism specifier couldn't parse"
3569                " initial mechanism name."
3570            )
3571
3572        # Note: This doesn't normally happen because the mechanism name
3573        # makes it seem like the integer decision ID is really a zone
3574        # name.
3575        if isinstance(dSpec, int):
3576            sep = tokens[dEnd + 1]
3577            after = tokens[dEnd + 2]
3578
3579            if sep == Lexeme.zoneSeparator and not isinstance(after, Lexeme):
3580                return (
3581                    base.MechanismSpecifier(
3582                        domain=None,
3583                        zone=None,
3584                        decision=dSpec,
3585                        name=after
3586                    ),
3587                    dEnd + 2
3588                )
3589            else:
3590                raise ParseError(
3591                    f"Invalid mechanism specifier: got a decision ID"
3592                    f" NOT followed by a zone separator and mechanism"
3593                    f" name. Got: {tokens[start:]}"
3594                )
3595
3596        mDomain = dSpec.domain
3597        if dEnd == tEnd or dEnd == tEnd - 1:
3598            if dSpec.zone is not None:
3599                try:
3600                    # Case for integer "zone" -> integer decision ID
3601                    zID = int(dSpec.zone)
3602                    return (
3603                        base.MechanismSpecifier(
3604                            domain=None,
3605                            zone=None,
3606                            decision=zID,
3607                            name=dSpec.name
3608                        ),
3609                        dEnd
3610                    )
3611                except ValueError:
3612                    pass
3613            return (
3614                base.MechanismSpecifier(
3615                    domain=mDomain,
3616                    zone=None,
3617                    decision=dSpec.zone,
3618                    name=dSpec.name
3619                ),
3620                dEnd
3621            )
3622
3623        sep = tokens[dEnd + 1]
3624        after = tokens[dEnd + 2]
3625
3626        mDec: Optional[Union[base.DecisionName, base.DecisionID]]
3627        if sep == Lexeme.zoneSeparator:
3628            if isinstance(after, Lexeme):
3629                mZone = None
3630                mDec = dSpec.zone
3631                mName = dSpec.name
3632                mEnd = dEnd
3633            else:
3634                mZone = dSpec.zone
3635                mDec = dSpec.name
3636                mName = after
3637                mEnd = dEnd + 2
3638        else:
3639            mZone = None
3640            mDec = dSpec.zone
3641            mName = dSpec.name
3642            mEnd = dEnd
3643
3644        # Treat numerical decision "names" as decision IDs
3645        if mDec is not None:
3646            try:
3647                mDec = int(mDec)
3648                if mDomain is not None or mZone is not None:
3649                    raise ParseError(
3650                        f"Invalid mechanism specifier: got a numerical"
3651                        f" decision ID but also a domain and/or zone."
3652                        f" Got: {tokens[start:]}"
3653                    )
3654            except ValueError:
3655                pass
3656
3657        return (
3658            base.MechanismSpecifier(
3659                domain=mDomain,
3660                zone=mZone,
3661                decision=mDec,
3662                name=mName
3663            ),
3664            mEnd
3665        )

Parses a mechanism specifier starting at the specified position in the given tokens list. No ending position is specified, but instead this function returns a tuple containing the parsed base.MechanismSpecifier along with an index in the tokens list where the end of the specifier was found.

For example:

>>> pf = ParseFormat()
>>> pf.parseMechanismSpecifierFromTokens(['m'])
(MechanismSpecifier(domain=None, zone=None, decision=None, name='m'), 0)
>>> pf.parseMechanismSpecifierFromTokens(['a', 'm'])
(MechanismSpecifier(domain=None, zone=None, decision=None, name='a'), 0)
>>> pf.parseMechanismSpecifierFromTokens(['a', 'm'], 1)
(MechanismSpecifier(domain=None, zone=None, decision=None, name='m'), 1)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.domainSeparator, 'm']
... )
(MechanismSpecifier(domain='a', zone=None, decision=None, name='m'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'm']
... )
(MechanismSpecifier(domain=None, zone=None, decision='a', name='m'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.zoneSeparator, 'm']
... )
(MechanismSpecifier(domain=None, zone='a', decision='b', name='m'), 4)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.domainSeparator, 'b', Lexeme.zoneSeparator, 'm']
... )
(MechanismSpecifier(domain='a', zone=None, decision='b', name='m'), 4)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm']
... )
(MechanismSpecifier(domain=None, zone=None, decision='a', name='b'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
...     1
... )
Traceback (most recent call last):
...
ParseError...
>>> pf.parseMechanismSpecifierFromTokens(
...     ['a', Lexeme.zoneSeparator, 'b', Lexeme.domainSeparator, 'm'],
...     2
... )
(MechanismSpecifier(domain='b', zone=None, decision=None, name='m'), 4)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ]
... )
(MechanismSpecifier(domain='a', zone='b', decision='c', name='m'), 6)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     2
... )
(MechanismSpecifier(domain=None, zone='b', decision='c', name='m'), 6)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         'a',
...         Lexeme.domainSeparator,
...         'b',
...         Lexeme.zoneSeparator,
...         'c',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     4
... )
(MechanismSpecifier(domain=None, zone=None, decision='c', name='m'), 6)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         'roomB',
...         Lexeme.zoneSeparator,
...         'switch',
...         Lexeme.mechanismSeparator,
...         'on'
...     ]
... )
(MechanismSpecifier(domain=None, zone=None, decision='roomB', name='switch'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         '500',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     0
... )
(MechanismSpecifier(domain=None, zone=None, decision=500, name='m'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         '500',
...         Lexeme.zoneSeparator,
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     0
... )
Traceback (most recent call last):
...
ParseError...
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         '500',
...         Lexeme.zoneSeparator,
...         'm',
...         Lexeme.mechanismSeparator,
...         'on'
...     ],
...     0
... )
(MechanismSpecifier(domain=None, zone=None, decision=500, name='m'), 2)
>>> pf.parseMechanismSpecifierFromTokens(
...     [
...         '500',
...         Lexeme.zoneSeparator,
...         'd',
...         Lexeme.zoneSeparator,
...         'm'
...     ],
...     0
... )
(MechanismSpecifier(domain=None, zone='500', decision='d', name='m'), 4)
def groupReqTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> List[Union[Lexeme, str, List[Union[Lexeme, str, ForwardRef('GroupedTokens')]]]]:
3667    def groupReqTokens(
3668        self,
3669        tokens: LexedTokens,
3670        start: int = 0,
3671        end: int = -1
3672    ) -> GroupedTokens:
3673        """
3674        Groups tokens for a requirement, stripping out all parentheses
3675        but replacing parenthesized expressions with sub-lists of tokens.
3676
3677        For example:
3678
3679        >>> pf = ParseFormat()
3680        >>> pf.groupReqTokens(['jump'])
3681        ['jump']
3682        >>> pf.groupReqTokens([Lexeme.openParen, 'jump'])
3683        Traceback (most recent call last):
3684        ...
3685        exploration.parsing.ParseError...
3686        >>> pf.groupReqTokens([Lexeme.closeParen, 'jump'])
3687        Traceback (most recent call last):
3688        ...
3689        exploration.parsing.ParseError...
3690        >>> pf.groupReqTokens(['jump', Lexeme.closeParen])
3691        Traceback (most recent call last):
3692        ...
3693        exploration.parsing.ParseError...
3694        >>> pf.groupReqTokens([Lexeme.openParen, 'jump', Lexeme.closeParen])
3695        [['jump']]
3696        >>> pf.groupReqTokens(
3697        ...     [
3698        ...         Lexeme.openParen,
3699        ...         'jump',
3700        ...         Lexeme.orBar,
3701        ...         'climb',
3702        ...         Lexeme.closeParen,
3703        ...         Lexeme.ampersand,
3704        ...         'crawl',
3705        ...     ]
3706        ... )
3707        [['jump', <Lexeme.orBar: ...>, 'climb'], <Lexeme.ampersand: ...>,\
3708 'crawl']
3709        """
3710        start, end, nTokens = normalizeEnds(tokens, start, end)
3711        if nTokens == 0:
3712            raise ParseError("Ran out of tokens.")
3713
3714        resultsStack: List[GroupedTokens] = [[]]
3715        here = start
3716        while here <= end:
3717            token = tokens[here]
3718            here += 1
3719            if token == Lexeme.closeParen:
3720                if len(resultsStack) == 1:
3721                    raise ParseError(
3722                        f"Too many closing parens at index {here - 1}"
3723                        f" in:\n{tokens[start:end + 1]}"
3724                    )
3725                else:
3726                    closed = resultsStack.pop()
3727                    resultsStack[-1].append(closed)
3728            elif token == Lexeme.openParen:
3729                resultsStack.append([])
3730            else:
3731                resultsStack[-1].append(token)
3732        if len(resultsStack) != 1:
3733            raise ParseError(
3734                f"Mismatched parentheses in tokens:"
3735                f"\n{tokens[start:end + 1]}"
3736            )
3737        return resultsStack[0]

Groups tokens for a requirement, stripping out all parentheses but replacing parenthesized expressions with sub-lists of tokens.

For example:

>>> pf = ParseFormat()
>>> pf.groupReqTokens(['jump'])
['jump']
>>> pf.groupReqTokens([Lexeme.openParen, 'jump'])
Traceback (most recent call last):
...
ParseError...
>>> pf.groupReqTokens([Lexeme.closeParen, 'jump'])
Traceback (most recent call last):
...
ParseError...
>>> pf.groupReqTokens(['jump', Lexeme.closeParen])
Traceback (most recent call last):
...
ParseError...
>>> pf.groupReqTokens([Lexeme.openParen, 'jump', Lexeme.closeParen])
[['jump']]
>>> pf.groupReqTokens(
...     [
...         Lexeme.openParen,
...         'jump',
...         Lexeme.orBar,
...         'climb',
...         Lexeme.closeParen,
...         Lexeme.ampersand,
...         'crawl',
...     ]
... )
[['jump', <Lexeme.orBar: ...>, 'climb'], <Lexeme.ampersand: ...>, 'crawl']
def groupReqTokensByPrecedence( self, tokenGroups: List[Union[Lexeme, str, List[Union[Lexeme, str, ForwardRef('GroupedTokens')]]]]) -> List[Union[Lexeme, exploration.base.Requirement, List[Union[Lexeme, exploration.base.Requirement, ForwardRef('GroupedRequirementParts')]]]]:
3739    def groupReqTokensByPrecedence(
3740        self,
3741        tokenGroups: GroupedTokens
3742    ) -> GroupedRequirementParts:
3743        """
3744        Re-groups requirement tokens that have been grouped using
3745        `groupReqTokens` according to operator precedence, effectively
3746        creating an equivalent result which would have been obtained by
3747        `groupReqTokens` if all possible non-redundant explicit
3748        parentheses had been included.
3749
3750        Also turns each leaf part into a `Requirement`.
3751
3752        TODO: Make this actually reasonably efficient T_T
3753
3754        Examples:
3755
3756        >>> pf = ParseFormat()
3757        >>> r = pf.parseRequirement('capability&roomB::switch:on')
3758        >>> pf.groupReqTokensByPrecedence(
3759        ...     [
3760        ...         ['jump', Lexeme.orBar, 'climb'],
3761        ...         Lexeme.ampersand,
3762        ...         Lexeme.notMarker,
3763        ...         'coin',
3764        ...         Lexeme.tokenCount,
3765        ...         '3'
3766        ...     ]
3767        ... )
3768        [\
3769[\
3770[[ReqCapability('jump'), <Lexeme.orBar: ...>, ReqCapability('climb')]],\
3771 <Lexeme.ampersand: ...>,\
3772 [<Lexeme.notMarker: ...>, ReqTokens('coin', 3)]\
3773]\
3774]
3775        """
3776        subgrouped: List[Union[Lexeme, str, GroupedRequirementParts]] = []
3777        # First recursively group all parenthesized expressions
3778        for i, item in enumerate(tokenGroups):
3779            if isinstance(item, list):
3780                subgrouped.append(self.groupReqTokensByPrecedence(item))
3781            else:
3782                subgrouped.append(item)
3783
3784        # Now process all leaf requirements
3785        leavesConverted: GroupedRequirementParts = []
3786        i = 0
3787        while i < len(subgrouped):
3788            gItem = subgrouped[i]
3789
3790            if isinstance(gItem, list):
3791                leavesConverted.append(gItem)
3792            elif isinstance(gItem, Lexeme):
3793                leavesConverted.append(gItem)
3794            elif i == len(subgrouped) - 1:
3795                if isinstance(gItem, Lexeme):
3796                    raise ParseError(
3797                        f"Lexeme at end of requirement. Grouped tokens:"
3798                        f"\n{tokenGroups}"
3799                    )
3800                else:
3801                    assert isinstance(gItem, str)
3802                    if gItem == 'X':
3803                        leavesConverted.append(base.ReqImpossible())
3804                    elif gItem == 'O':
3805                        leavesConverted.append(base.ReqNothing())
3806                    else:
3807                        leavesConverted.append(base.ReqCapability(gItem))
3808            else:
3809                assert isinstance(gItem, str)
3810                try:
3811                    # TODO: Avoid list copy here...
3812                    couldBeMechanismSpecifier: LexedTokens = []
3813                    for ii in range(i, len(subgrouped)):
3814                        lexemeOrStr = subgrouped[ii]
3815                        if isinstance(lexemeOrStr, (Lexeme, str)):
3816                            couldBeMechanismSpecifier.append(lexemeOrStr)
3817                        else:
3818                            break
3819                    mSpec, mEnd = self.parseMechanismSpecifierFromTokens(
3820                        couldBeMechanismSpecifier
3821                    )
3822                    mEnd += i
3823                    if (
3824                        mEnd >= len(subgrouped) - 2
3825                     or subgrouped[mEnd + 1] != Lexeme.mechanismSeparator
3826                    ):
3827                        raise ParseError("Not a mechanism requirement.")
3828
3829                    mState = subgrouped[mEnd + 2]
3830                    if not isinstance(mState, base.MechanismState):
3831                        raise ParseError("Not a mechanism requirement.")
3832                    leavesConverted.append(base.ReqMechanism(mSpec, mState))
3833                    i = mEnd + 2  # + 1 will happen automatically below
3834                except ParseError:
3835                    following = subgrouped[i + 1]
3836                    if following in (
3837                        Lexeme.tokenCount,
3838                        Lexeme.mechanismSeparator,
3839                        Lexeme.wigglyLine,
3840                        Lexeme.skillLevel
3841                    ):
3842                        if (
3843                            i == len(subgrouped) - 2
3844                         or isinstance(subgrouped[i + 2], Lexeme)
3845                        ):
3846                            if following == Lexeme.wigglyLine:
3847                                # Default tag value is 1
3848                                leavesConverted.append(base.ReqTag(gItem, 1))
3849                                i += 1  # another +1 automatic below
3850                            else:
3851                                raise ParseError(
3852                                    f"Lexeme at end of requirement. Grouped"
3853                                    f" tokens:\n{tokenGroups}"
3854                                )
3855                        else:
3856                            afterwards = subgrouped[i + 2]
3857                            if not isinstance(afterwards, str):
3858                                raise ParseError(
3859                                    f"Lexeme after token/mechanism/tag/skill"
3860                                    f" separator at index {i}."
3861                                    f" Grouped tokens:\n{tokenGroups}"
3862                                )
3863                            i += 2  # another +1 automatic below
3864                            if following == Lexeme.tokenCount:
3865                                try:
3866                                    tCount = int(afterwards)
3867                                except ValueError:
3868                                    raise ParseError(
3869                                        f"Token count could not be"
3870                                        f" parsed as an integer:"
3871                                        f" {afterwards!r}. Grouped"
3872                                        f" tokens:\n{tokenGroups}"
3873                                    )
3874                                leavesConverted.append(
3875                                    base.ReqTokens(gItem, tCount)
3876                                )
3877                            elif following == Lexeme.mechanismSeparator:
3878                                leavesConverted.append(
3879                                    base.ReqMechanism(gItem, afterwards)
3880                                )
3881                            elif following == Lexeme.wigglyLine:
3882                                tVal = self.parseTagValue(afterwards)
3883                                leavesConverted.append(
3884                                    base.ReqTag(gItem, tVal)
3885                                )
3886                            else:
3887                                assert following == Lexeme.skillLevel
3888                                try:
3889                                    sLevel = int(afterwards)
3890                                except ValueError:
3891                                    raise ParseError(
3892                                        f"Skill level could not be"
3893                                        f" parsed as an integer:"
3894                                        f" {afterwards!r}. Grouped"
3895                                        f" tokens:\n{tokenGroups}"
3896                                    )
3897                                leavesConverted.append(
3898                                    base.ReqLevel(gItem, sLevel)
3899                                )
3900                    else:
3901                        if gItem == 'X':
3902                            leavesConverted.append(base.ReqImpossible())
3903                        elif gItem == 'O':
3904                            leavesConverted.append(base.ReqNothing())
3905                        else:
3906                            leavesConverted.append(
3907                                base.ReqCapability(gItem)
3908                            )
3909
3910            # Finally, increment our index:
3911            i += 1
3912
3913        # Now group all NOT operators
3914        i = 0
3915        notsGrouped: GroupedRequirementParts = []
3916        while i < len(leavesConverted):
3917            leafItem = leavesConverted[i]
3918            group = []
3919            while leafItem == Lexeme.notMarker:
3920                group.append(leafItem)
3921                i += 1
3922                if i >= len(leavesConverted):
3923                    raise ParseError(
3924                        f"NOT at end of tokens:\n{leavesConverted}"
3925                    )
3926                leafItem = leavesConverted[i]
3927            if group == []:
3928                notsGrouped.append(leafItem)
3929                i += 1
3930            else:
3931                group.append(leafItem)
3932                i += 1
3933                notsGrouped.append(group)
3934
3935        # Next group all AND operators
3936        i = 0
3937        andsGrouped: GroupedRequirementParts = []
3938        while i < len(notsGrouped):
3939            notGroupItem = notsGrouped[i]
3940            if notGroupItem == Lexeme.ampersand:
3941                if i == len(notsGrouped) - 1:
3942                    raise ParseError(
3943                        f"AND at end of group in tokens:"
3944                        f"\n{tokenGroups}"
3945                        f"Which had been grouped into:"
3946                        f"\n{notsGrouped}"
3947                    )
3948                itemAfter = notsGrouped[i + 1]
3949                if isinstance(itemAfter, Lexeme):
3950                    raise ParseError(
3951                        f"Lexeme after AND in of group in tokens:"
3952                        f"\n{tokenGroups}"
3953                        f"Which had been grouped into:"
3954                        f"\n{notsGrouped}"
3955                    )
3956                assert isinstance(itemAfter, (base.Requirement, list))
3957                prev = andsGrouped[-1]
3958                if (
3959                    isinstance(prev, list)
3960                and len(prev) > 2
3961                and prev[1] == Lexeme.ampersand
3962                ):
3963                    prev.extend(notsGrouped[i:i + 2])
3964                    i += 1  # with an extra +1 below
3965                else:
3966                    andsGrouped.append(
3967                        [andsGrouped.pop()] + notsGrouped[i:i + 2]
3968                    )
3969                    i += 1 # extra +1 below
3970            else:
3971                andsGrouped.append(notGroupItem)
3972            i += 1
3973
3974        # Finally check that we only have OR operators left over
3975        i = 0
3976        finalResult: GroupedRequirementParts = []
3977        while i < len(andsGrouped):
3978            andGroupItem = andsGrouped[i]
3979            if andGroupItem == Lexeme.orBar:
3980                if i == len(andsGrouped) - 1:
3981                    raise ParseError(
3982                        f"OR at end of group in tokens:"
3983                        f"\n{tokenGroups}"
3984                        f"Which had been grouped into:"
3985                        f"\n{andsGrouped}"
3986                    )
3987                itemAfter = andsGrouped[i + 1]
3988                if isinstance(itemAfter, Lexeme):
3989                    raise ParseError(
3990                        f"Lexeme after OR in of group in tokens:"
3991                        f"\n{tokenGroups}"
3992                        f"Which had been grouped into:"
3993                        f"\n{andsGrouped}"
3994                    )
3995                assert isinstance(itemAfter, (base.Requirement, list))
3996                prev = finalResult[-1]
3997                if (
3998                    isinstance(prev, list)
3999                and len(prev) > 2
4000                and prev[1] == Lexeme.orBar
4001                ):
4002                    prev.extend(andsGrouped[i:i + 2])
4003                    i += 1  # with an extra +1 below
4004                else:
4005                    finalResult.append(
4006                        [finalResult.pop()] + andsGrouped[i:i + 2]
4007                    )
4008                    i += 1 # extra +1 below
4009            elif isinstance(andGroupItem, Lexeme):
4010                raise ParseError(
4011                    f"Leftover lexeme when grouping ORs at index {i}"
4012                    f" in grouped tokens:\n{andsGrouped}"
4013                    f"\nOriginal tokens were:\n{tokenGroups}"
4014                )
4015            else:
4016                finalResult.append(andGroupItem)
4017            i += 1
4018
4019        return finalResult

Re-groups requirement tokens that have been grouped using groupReqTokens according to operator precedence, effectively creating an equivalent result which would have been obtained by groupReqTokens if all possible non-redundant explicit parentheses had been included.

Also turns each leaf part into a Requirement.

TODO: Make this actually reasonably efficient T_T

Examples:

>>> pf = ParseFormat()
>>> r = pf.parseRequirement('capability&roomB::switch:on')
>>> pf.groupReqTokensByPrecedence(
...     [
...         ['jump', Lexeme.orBar, 'climb'],
...         Lexeme.ampersand,
...         Lexeme.notMarker,
...         'coin',
...         Lexeme.tokenCount,
...         '3'
...     ]
... )
[[[[ReqCapability('jump'), <Lexeme.orBar: ...>, ReqCapability('climb')]], <Lexeme.ampersand: ...>, [<Lexeme.notMarker: ...>, ReqTokens('coin', 3)]]]
def parseRequirementFromRegroupedTokens( self, reqGroups: List[Union[Lexeme, exploration.base.Requirement, List[Union[Lexeme, exploration.base.Requirement, ForwardRef('GroupedRequirementParts')]]]]) -> exploration.base.Requirement:
4021    def parseRequirementFromRegroupedTokens(
4022        self,
4023        reqGroups: GroupedRequirementParts
4024    ) -> base.Requirement:
4025        """
4026        Recursive parser that works once tokens have been turned into
4027        requirements at the leaves and grouped by operator precedence
4028        otherwise (see `groupReqTokensByPrecedence`).
4029
4030        TODO: Simplify by just doing this while grouping... ?
4031        """
4032        if len(reqGroups) == 0:
4033            raise ParseError("Ran out of tokens.")
4034
4035        elif len(reqGroups) == 1:
4036            only = reqGroups[0]
4037            if isinstance(only, list):
4038                return self.parseRequirementFromRegroupedTokens(only)
4039            elif isinstance(only, base.Requirement):
4040                return only
4041            else:
4042                raise ParseError(f"Invalid singleton group:\n{only}")
4043        elif reqGroups[0] == Lexeme.notMarker:
4044            if (
4045                not all(x == Lexeme.notMarker for x in reqGroups[:-1])
4046             or not isinstance(reqGroups[-1], (list, base.Requirement))
4047            ):
4048                raise ParseError(f"Invalid negation group:\n{reqGroups}")
4049            result = reqGroups[-1]
4050            if isinstance(result, list):
4051                result = self.parseRequirementFromRegroupedTokens(result)
4052            assert isinstance(result, base.Requirement)
4053            for i in range(len(reqGroups) - 1):
4054                result = base.ReqNot(result)
4055            return result
4056        elif len(reqGroups) % 2 == 0:
4057            raise ParseError(f"Even-length non-negation group:\n{reqGroups}")
4058        else:
4059            if (
4060                reqGroups[1] not in (Lexeme.ampersand, Lexeme.orBar)
4061             or not all(
4062                    reqGroups[i] == reqGroups[1]
4063                    for i in range(1, len(reqGroups), 2)
4064                )
4065            ):
4066                raise ParseError(
4067                    f"Inconsistent operator(s) in group:\n{reqGroups}"
4068                )
4069            op = reqGroups[1]
4070            operands = [
4071                (
4072                    self.parseRequirementFromRegroupedTokens(x)
4073                    if isinstance(x, list)
4074                    else x
4075                )
4076                for x in reqGroups[::2]
4077            ]
4078            if not all(isinstance(x, base.Requirement) for x in operands):
4079                raise ParseError(
4080                    f"Item not reducible to Requirement in AND group:"
4081                    f"\n{reqGroups}"
4082                )
4083            reqSequence = cast(Sequence[base.Requirement], operands)
4084            if op == Lexeme.ampersand:
4085                return base.ReqAll(reqSequence).flatten()
4086            else:
4087                assert op == Lexeme.orBar
4088                return base.ReqAny(reqSequence).flatten()

Recursive parser that works once tokens have been turned into requirements at the leaves and grouped by operator precedence otherwise (see groupReqTokensByPrecedence).

TODO: Simplify by just doing this while grouping... ?

def parseRequirementFromGroupedTokens( self, tokenGroups: List[Union[Lexeme, str, List[Union[Lexeme, str, ForwardRef('GroupedTokens')]]]]) -> exploration.base.Requirement:
4090    def parseRequirementFromGroupedTokens(
4091        self,
4092        tokenGroups: GroupedTokens
4093    ) -> base.Requirement:
4094        """
4095        Parses a `base.Requirement` from a pre-grouped tokens list (see
4096        `groupReqTokens`). Uses the 'orBar', 'ampersand', 'notMarker',
4097        'tokenCount', and 'mechanismSeparator' `Lexeme`s to provide
4098        'or', 'and', and 'not' operators along with distinguishing
4099        between capabilities, tokens, and mechanisms.
4100
4101        Precedence ordering is not, then and, then or, but you are
4102        encouraged to use parentheses for explicit grouping (the
4103        'openParen' and 'closeParen' `Lexeme`s, although these must be
4104        handled by `groupReqTokens` so this function won't see them
4105        directly).
4106
4107        You can also use 'X' (without quotes) for a never-satisfied
4108        requirement, and 'O' (without quotes) for an always-satisfied
4109        requirement.
4110
4111        Note that when '!' is applied to a token requirement it flips
4112        the sense of the integer from 'must have at least this many' to
4113        'must have strictly less than this many'.
4114
4115        Raises a `ParseError` if the grouped tokens it is given cannot
4116        be parsed as a `Requirement`.
4117
4118        Examples:
4119
4120        >>> pf = ParseFormat()
4121        >>> pf.parseRequirementFromGroupedTokens(['capability'])
4122        ReqCapability('capability')
4123        >>> pf.parseRequirementFromGroupedTokens(
4124        ...     ['token', Lexeme.tokenCount, '3']
4125        ... )
4126        ReqTokens('token', 3)
4127        >>> pf.parseRequirementFromGroupedTokens(
4128        ...     ['mechanism', Lexeme.mechanismSeparator, 'state']
4129        ... )
4130        ReqMechanism('mechanism', 'state')
4131        >>> pf.parseRequirementFromGroupedTokens(
4132        ...     ['capability', Lexeme.orBar, 'token',
4133        ...      Lexeme.tokenCount, '3']
4134        ... )
4135        ReqAny([ReqCapability('capability'), ReqTokens('token', 3)])
4136        >>> pf.parseRequirementFromGroupedTokens(
4137        ...     ['one', Lexeme.ampersand, 'two', Lexeme.orBar, 'three']
4138        ... )
4139        ReqAny([ReqAll([ReqCapability('one'), ReqCapability('two')]),\
4140 ReqCapability('three')])
4141        >>> pf.parseRequirementFromGroupedTokens(
4142        ...     [
4143        ...         'one',
4144        ...         Lexeme.ampersand,
4145        ...         [
4146        ...              'two',
4147        ...              Lexeme.orBar,
4148        ...              'three'
4149        ...         ]
4150        ...     ]
4151        ... )
4152        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4153 ReqCapability('three')])])
4154        >>> pf.parseRequirementFromTokens(['X'])
4155        ReqImpossible()
4156        >>> pf.parseRequirementFromTokens(['O'])
4157        ReqNothing()
4158        >>> pf.parseRequirementFromTokens(
4159        ...     [Lexeme.openParen, 'O', Lexeme.closeParen]
4160        ... )
4161        ReqNothing()
4162        """
4163        if len(tokenGroups) == 0:
4164            raise ParseError("Ran out of tokens.")
4165
4166        reGrouped = self.groupReqTokensByPrecedence(tokenGroups)
4167
4168        return self.parseRequirementFromRegroupedTokens(reGrouped)

Parses a base.Requirement from a pre-grouped tokens list (see groupReqTokens). Uses the 'orBar', 'ampersand', 'notMarker', 'tokenCount', and 'mechanismSeparator' Lexemes to provide 'or', 'and', and 'not' operators along with distinguishing between capabilities, tokens, and mechanisms.

Precedence ordering is not, then and, then or, but you are encouraged to use parentheses for explicit grouping (the 'openParen' and 'closeParen' Lexemes, although these must be handled by groupReqTokens so this function won't see them directly).

You can also use 'X' (without quotes) for a never-satisfied requirement, and 'O' (without quotes) for an always-satisfied requirement.

Note that when '!' is applied to a token requirement it flips the sense of the integer from 'must have at least this many' to 'must have strictly less than this many'.

Raises a ParseError if the grouped tokens it is given cannot be parsed as a Requirement.

Examples:

>>> pf = ParseFormat()
>>> pf.parseRequirementFromGroupedTokens(['capability'])
ReqCapability('capability')
>>> pf.parseRequirementFromGroupedTokens(
...     ['token', Lexeme.tokenCount, '3']
... )
ReqTokens('token', 3)
>>> pf.parseRequirementFromGroupedTokens(
...     ['mechanism', Lexeme.mechanismSeparator, 'state']
... )
ReqMechanism('mechanism', 'state')
>>> pf.parseRequirementFromGroupedTokens(
...     ['capability', Lexeme.orBar, 'token',
...      Lexeme.tokenCount, '3']
... )
ReqAny([ReqCapability('capability'), ReqTokens('token', 3)])
>>> pf.parseRequirementFromGroupedTokens(
...     ['one', Lexeme.ampersand, 'two', Lexeme.orBar, 'three']
... )
ReqAny([ReqAll([ReqCapability('one'), ReqCapability('two')]), ReqCapability('three')])
>>> pf.parseRequirementFromGroupedTokens(
...     [
...         'one',
...         Lexeme.ampersand,
...         [
...              'two',
...              Lexeme.orBar,
...              'three'
...         ]
...     ]
... )
ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'), ReqCapability('three')])])
>>> pf.parseRequirementFromTokens(['X'])
ReqImpossible()
>>> pf.parseRequirementFromTokens(['O'])
ReqNothing()
>>> pf.parseRequirementFromTokens(
...     [Lexeme.openParen, 'O', Lexeme.closeParen]
... )
ReqNothing()
def parseRequirementFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> exploration.base.Requirement:
4170    def parseRequirementFromTokens(
4171        self,
4172        tokens: LexedTokens,
4173        start: int = 0,
4174        end: int = -1
4175    ) -> base.Requirement:
4176        """
4177        Parses a requirement from `LexedTokens` by grouping them first
4178        and then using `parseRequirementFromGroupedTokens`.
4179
4180        For example:
4181
4182        >>> pf = ParseFormat()
4183        >>> pf.parseRequirementFromTokens(
4184        ...     [
4185        ...         'one',
4186        ...         Lexeme.ampersand,
4187        ...         Lexeme.openParen,
4188        ...         'two',
4189        ...         Lexeme.orBar,
4190        ...         'three',
4191        ...         Lexeme.closeParen
4192        ...     ]
4193        ... )
4194        ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'),\
4195 ReqCapability('three')])])
4196        """
4197        grouped = self.groupReqTokens(tokens, start, end)
4198        return self.parseRequirementFromGroupedTokens(grouped)

Parses a requirement from LexedTokens by grouping them first and then using parseRequirementFromGroupedTokens.

For example:

>>> pf = ParseFormat()
>>> pf.parseRequirementFromTokens(
...     [
...         'one',
...         Lexeme.ampersand,
...         Lexeme.openParen,
...         'two',
...         Lexeme.orBar,
...         'three',
...         Lexeme.closeParen
...     ]
... )
ReqAll([ReqCapability('one'), ReqAny([ReqCapability('two'), ReqCapability('three')])])
def parseRequirement(self, encoded: str) -> exploration.base.Requirement:
4200    def parseRequirement(self, encoded: str) -> base.Requirement:
4201        """
4202        Parses a `base.Requirement` from a string by calling `lex` and
4203        then feeding it into `ParseFormat.parseRequirementFromTokens`.
4204        As stated in `parseRequirementFromTokens`, the precedence
4205        binding order is NOT, then AND, then OR.
4206
4207        For example:
4208
4209        >>> pf = ParseFormat()
4210        >>> pf.parseRequirement('! coin * 3')
4211        ReqNot(ReqTokens('coin', 3))
4212        >>> pf.parseRequirement(
4213        ...     '  oneWord | "two words"|"three  words words" '
4214        ... )
4215        ReqAny([ReqCapability('oneWord'), ReqCapability('"two words"'),\
4216 ReqCapability('"three  words words"')])
4217        >>> pf.parseRequirement('words-with-dashes')
4218        ReqCapability('words-with-dashes')
4219        >>> r = pf.parseRequirement('capability&roomB::switch:on')
4220        >>> r
4221        ReqAll([ReqCapability('capability'),\
4222 ReqMechanism(MechanismSpecifier(domain=None, zone=None, decision='roomB',\
4223 name='switch'), 'on')])
4224        >>> r.unparse()
4225        '(capability&roomB::switch:on)'
4226        >>> pf.parseRequirement('!!!one')
4227        ReqNot(ReqNot(ReqNot(ReqCapability('one'))))
4228        >>> pf.parseRequirement('domain//zone::where::mechanism:state')
4229        ReqMechanism(MechanismSpecifier(domain='domain', zone='zone',\
4230 decision='where', name='mechanism'), 'state')
4231        >>> pf.parseRequirement('domain//mechanism:state')
4232        ReqMechanism(MechanismSpecifier(domain='domain', zone=None,\
4233 decision=None, name='mechanism'), 'state')
4234        >>> pf.parseRequirement('where::mechanism:state')
4235        ReqMechanism(MechanismSpecifier(domain=None, zone=None,\
4236 decision='where', name='mechanism'), 'state')
4237        >>> pf.parseRequirement('zone::where::mechanism:state')
4238        ReqMechanism(MechanismSpecifier(domain=None, zone='zone',\
4239 decision='where', name='mechanism'), 'state')
4240        >>> pf.parseRequirement('tag~')
4241        ReqTag('tag', 1)
4242        >>> pf.parseRequirement('tag~&tag2~')
4243        ReqAll([ReqTag('tag', 1), ReqTag('tag2', 1)])
4244        >>> pf.parseRequirement('tag~value|tag~3|tag~3.5|skill^3')
4245        ReqAny([ReqTag('tag', 'value'), ReqTag('tag', 3),\
4246 ReqTag('tag', 3.5), ReqLevel('skill', 3)])
4247        >>> pf.parseRequirement('tag~True|tag~False|tag~None')
4248        ReqAny([ReqTag('tag', True), ReqTag('tag', False), ReqTag('tag', None)])
4249
4250        Precedence examples:
4251
4252        >>> pf.parseRequirement('A|B&C')
4253        ReqAny([ReqCapability('A'), ReqAll([ReqCapability('B'),\
4254 ReqCapability('C')])])
4255        >>> pf.parseRequirement('A&B|C')
4256        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4257 ReqCapability('C')])
4258        >>> pf.parseRequirement('(A&B)|C')
4259        ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4260 ReqCapability('C')])
4261        >>> pf.parseRequirement('(A&B|C)&D')
4262        ReqAll([ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]),\
4263 ReqCapability('C')]), ReqCapability('D')])
4264
4265        Error examples:
4266
4267        >>> pf.parseRequirement('one ! Word')
4268        Traceback (most recent call last):
4269        ...
4270        exploration.parsing.ParseError...
4271        >>> pf.parseRequirement('a|')
4272        Traceback (most recent call last):
4273        ...
4274        exploration.parsing.ParseError...
4275        >>> pf.parseRequirement('b!')
4276        Traceback (most recent call last):
4277        ...
4278        exploration.parsing.ParseError...
4279        >>> pf.parseRequirement('*emph*')
4280        Traceback (most recent call last):
4281        ...
4282        exploration.parsing.ParseError...
4283        >>> pf.parseRequirement('one&&two')
4284        Traceback (most recent call last):
4285        ...
4286        exploration.parsing.ParseError...
4287        >>> pf.parseRequirement('one!|two')
4288        Traceback (most recent call last):
4289        ...
4290        exploration.parsing.ParseError...
4291        >>> pf.parseRequirement('one*two')
4292        Traceback (most recent call last):
4293        ...
4294        exploration.parsing.ParseError...
4295        >>> pf.parseRequirement('one*')
4296        Traceback (most recent call last):
4297        ...
4298        exploration.parsing.ParseError...
4299        >>> pf.parseRequirement('()')
4300        Traceback (most recent call last):
4301        ...
4302        exploration.parsing.ParseError...
4303        >>> pf.parseRequirement('(one)*3')
4304        Traceback (most recent call last):
4305        ...
4306        exploration.parsing.ParseError...
4307        >>> pf.parseRequirement('a:')
4308        Traceback (most recent call last):
4309        ...
4310        exploration.parsing.ParseError...
4311        >>> pf.parseRequirement('a:b:c')
4312        Traceback (most recent call last):
4313        ...
4314        exploration.parsing.ParseError...
4315        >>> pf.parseRequirement('where::capability')
4316        Traceback (most recent call last):
4317        ...
4318        exploration.parsing.ParseError...
4319        """
4320        return self.parseRequirementFromTokens(
4321            lex(encoded, self.reverseFormat)
4322        )

Parses a base.Requirement from a string by calling lex and then feeding it into ParseFormat.parseRequirementFromTokens. As stated in parseRequirementFromTokens, the precedence binding order is NOT, then AND, then OR.

For example:

>>> pf = ParseFormat()
>>> pf.parseRequirement('! coin * 3')
ReqNot(ReqTokens('coin', 3))
>>> pf.parseRequirement(
...     '  oneWord | "two words"|"three  words words" '
... )
ReqAny([ReqCapability('oneWord'), ReqCapability('"two words"'), ReqCapability('"three  words words"')])
>>> pf.parseRequirement('words-with-dashes')
ReqCapability('words-with-dashes')
>>> r = pf.parseRequirement('capability&roomB::switch:on')
>>> r
ReqAll([ReqCapability('capability'), ReqMechanism(MechanismSpecifier(domain=None, zone=None, decision='roomB', name='switch'), 'on')])
>>> r.unparse()
'(capability&roomB::switch:on)'
>>> pf.parseRequirement('!!!one')
ReqNot(ReqNot(ReqNot(ReqCapability('one'))))
>>> pf.parseRequirement('domain//zone::where::mechanism:state')
ReqMechanism(MechanismSpecifier(domain='domain', zone='zone', decision='where', name='mechanism'), 'state')
>>> pf.parseRequirement('domain//mechanism:state')
ReqMechanism(MechanismSpecifier(domain='domain', zone=None, decision=None, name='mechanism'), 'state')
>>> pf.parseRequirement('where::mechanism:state')
ReqMechanism(MechanismSpecifier(domain=None, zone=None, decision='where', name='mechanism'), 'state')
>>> pf.parseRequirement('zone::where::mechanism:state')
ReqMechanism(MechanismSpecifier(domain=None, zone='zone', decision='where', name='mechanism'), 'state')
>>> pf.parseRequirement('tag~')
ReqTag('tag', 1)
>>> pf.parseRequirement('tag~&tag2~')
ReqAll([ReqTag('tag', 1), ReqTag('tag2', 1)])
>>> pf.parseRequirement('tag~value|tag~3|tag~3.5|skill^3')
ReqAny([ReqTag('tag', 'value'), ReqTag('tag', 3), ReqTag('tag', 3.5), ReqLevel('skill', 3)])
>>> pf.parseRequirement('tag~True|tag~False|tag~None')
ReqAny([ReqTag('tag', True), ReqTag('tag', False), ReqTag('tag', None)])

Precedence examples:

>>> pf.parseRequirement('A|B&C')
ReqAny([ReqCapability('A'), ReqAll([ReqCapability('B'), ReqCapability('C')])])
>>> pf.parseRequirement('A&B|C')
ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]), ReqCapability('C')])
>>> pf.parseRequirement('(A&B)|C')
ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]), ReqCapability('C')])
>>> pf.parseRequirement('(A&B|C)&D')
ReqAll([ReqAny([ReqAll([ReqCapability('A'), ReqCapability('B')]), ReqCapability('C')]), ReqCapability('D')])

Error examples:

>>> pf.parseRequirement('one ! Word')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('a|')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('b!')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('*emph*')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('one&&two')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('one!|two')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('one*two')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('one*')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('()')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('(one)*3')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('a:')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('a:b:c')
Traceback (most recent call last):
...
ParseError...
>>> pf.parseRequirement('where::capability')
Traceback (most recent call last):
...
ParseError...
def parseSkillCombinationFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> Union[str, exploration.base.SkillCombination]:
4324    def parseSkillCombinationFromTokens(
4325        self,
4326        tokens: LexedTokens,
4327        start: int = 0,
4328        end: int = -1
4329    ) -> Union[base.Skill, base.SkillCombination]:
4330        """
4331        Parses a skill combination from the specified range within the
4332        given tokens list. If just a single string token is selected, it
4333        will be returned as a `base.BestSkill` with just that skill
4334        inside.
4335
4336        For example:
4337
4338        >>> pf = ParseFormat()
4339        >>> pf.parseSkillCombinationFromTokens(['climbing'])
4340        BestSkill('climbing')
4341        >>> tokens = [
4342        ...     'best',
4343        ...     Lexeme.openParen,
4344        ...     'brains',
4345        ...     Lexeme.sepOrDelay,
4346        ...     'brawn',
4347        ...     Lexeme.closeParen,
4348        ... ]
4349        >>> pf.parseSkillCombinationFromTokens(tokens)
4350        BestSkill('brains', 'brawn')
4351        >>> tokens[2] = '3'  # not a lexeme so it's a string
4352        >>> pf.parseSkillCombinationFromTokens(tokens)
4353        BestSkill(3, 'brawn')
4354        >>> tokens = [
4355        ...     Lexeme.wigglyLine,
4356        ...     Lexeme.wigglyLine,
4357        ...     'yes',
4358        ... ]
4359        >>> pf.parseSkillCombinationFromTokens(tokens)
4360        InverseSkill(InverseSkill('yes'))
4361        """
4362        start, end, nTokens = normalizeEnds(tokens, start, end)
4363
4364        first = tokens[start]
4365        if nTokens == 1:
4366            if isinstance(first, base.Skill):
4367                try:
4368                    level = int(first)
4369                    return base.BestSkill(level)
4370                except ValueError:
4371                    return base.BestSkill(first)
4372            else:
4373                raise ParseError(
4374                    "Invalid SkillCombination:\n{tokens[start:end + 1]"
4375                )
4376
4377        if first == Lexeme.wigglyLine:
4378            inv = self.parseSkillCombinationFromTokens(
4379                tokens,
4380                start + 1,
4381                end
4382            )
4383            if isinstance(inv, base.BestSkill) and len(inv.skills) == 1:
4384                return base.InverseSkill(inv.skills[0])
4385            else:
4386                return base.InverseSkill(inv)
4387
4388        second = tokens[start + 1]
4389        if second != Lexeme.openParen:
4390            raise ParseError(
4391                f"Invalid SkillCombination (missing paren):"
4392                f"\n{tokens[start:end + 1]}"
4393            )
4394
4395        parenEnd = self.matchingBrace(
4396            tokens,
4397            start + 1,
4398            Lexeme.openParen,
4399            Lexeme.closeParen
4400        )
4401        if parenEnd != end:
4402            raise ParseError(
4403                f"Extra junk after SkillCombination:"
4404                f"\n{tokens[parenEnd + 1:end + 1]}"
4405            )
4406
4407        if first == 'if':
4408            parts = list(
4409                findSeparatedParts(
4410                    tokens,
4411                    Lexeme.sepOrDelay,
4412                    start + 2,
4413                    end - 1,
4414                    Lexeme.openParen,
4415                    Lexeme.closeParen
4416                )
4417            )
4418            if len(parts) != 3:
4419                raise ParseError(
4420                    f"Wrong number of parts for ConditionalSkill (needs"
4421                    f" 3, got {len(parts)}:"
4422                    f"\n{tokens[start + 2:end]}"
4423                )
4424            reqStart, reqEnd = parts[0]
4425            ifStart, ifEnd = parts[1]
4426            elseStart, elseEnd = parts[2]
4427            return base.ConditionalSkill(
4428                self.parseRequirementFromTokens(tokens, reqStart, reqEnd),
4429                self.parseSkillCombinationFromTokens(tokens, ifStart, ifEnd),
4430                self.parseSkillCombinationFromTokens(
4431                    tokens,
4432                    elseStart,
4433                    elseEnd
4434                ),
4435            )
4436        elif first in ('sum', 'best', 'worst'):
4437            make: type[base.SkillCombination]
4438            if first == 'sum':
4439                make = base.CombinedSkill
4440            elif first == 'best':
4441                make = base.BestSkill
4442            else:
4443                make = base.WorstSkill
4444
4445            subs = []
4446            for partStart, partEnd in findSeparatedParts(
4447                tokens,
4448                Lexeme.sepOrDelay,
4449                start + 2,
4450                end - 1,
4451                Lexeme.openParen,
4452                Lexeme.closeParen
4453            ):
4454                sub = self.parseSkillCombinationFromTokens(
4455                    tokens,
4456                    partStart,
4457                    partEnd
4458                )
4459                if (
4460                    isinstance(sub, base.BestSkill)
4461                and len(sub.skills) == 1
4462                ):
4463                    subs.append(sub.skills[0])
4464                else:
4465                    subs.append(sub)
4466
4467            return make(*subs)
4468        else:
4469            raise ParseError(
4470                "Invalid SkillCombination:\n{tokens[start:end + 1]"
4471            )

Parses a skill combination from the specified range within the given tokens list. If just a single string token is selected, it will be returned as a base.BestSkill with just that skill inside.

For example:

>>> pf = ParseFormat()
>>> pf.parseSkillCombinationFromTokens(['climbing'])
BestSkill('climbing')
>>> tokens = [
...     'best',
...     Lexeme.openParen,
...     'brains',
...     Lexeme.sepOrDelay,
...     'brawn',
...     Lexeme.closeParen,
... ]
>>> pf.parseSkillCombinationFromTokens(tokens)
BestSkill('brains', 'brawn')
>>> tokens[2] = '3'  # not a lexeme so it's a string
>>> pf.parseSkillCombinationFromTokens(tokens)
BestSkill(3, 'brawn')
>>> tokens = [
...     Lexeme.wigglyLine,
...     Lexeme.wigglyLine,
...     'yes',
... ]
>>> pf.parseSkillCombinationFromTokens(tokens)
InverseSkill(InverseSkill('yes'))
def parseSkillCombination(self, encoded: str) -> exploration.base.SkillCombination:
4473    def parseSkillCombination(
4474        self,
4475        encoded: str
4476    ) -> base.SkillCombination:
4477        """
4478        Parses a `SkillCombination` from a string. Calls `lex` and then
4479        `parseSkillCombinationFromTokens`.
4480        """
4481        result = self.parseSkillCombinationFromTokens(
4482            lex(encoded, self.reverseFormat)
4483        )
4484        if not isinstance(result, base.SkillCombination):
4485            return base.BestSkill(result)
4486        else:
4487            return result

Parses a SkillCombination from a string. Calls lex and then parseSkillCombinationFromTokens.

def parseConditionFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> exploration.base.Condition:
4489    def parseConditionFromTokens(
4490        self,
4491        tokens: LexedTokens,
4492        start: int = 0,
4493        end: int = -1
4494    ) -> base.Condition:
4495        """
4496        Parses a `base.Condition` from a lexed tokens list. For example:
4497
4498        >>> pf = ParseFormat()
4499        >>> tokens = [
4500        ...     Lexeme.doubleQuestionmark,
4501        ...     Lexeme.openParen,
4502        ...     "fire",
4503        ...     Lexeme.ampersand,
4504        ...     "water",
4505        ...     Lexeme.closeParen,
4506        ...     Lexeme.openCurly,
4507        ...     "gain",
4508        ...     "wind",
4509        ...     Lexeme.closeCurly,
4510        ...     Lexeme.openCurly,
4511        ...     Lexeme.closeCurly,
4512        ... ]
4513        >>> pf.parseConditionFromTokens(tokens) == base.condition(
4514        ...     condition=base.ReqAll([
4515        ...         base.ReqCapability('fire'),
4516        ...         base.ReqCapability('water')
4517        ...     ]),
4518        ...     consequence=[base.effect(gain='wind')]
4519        ... )
4520        True
4521        """
4522        start, end, nTokens = normalizeEnds(tokens, start, end)
4523        if nTokens < 8:
4524            raise ParseError(
4525                f"A Condition requires at least 8 tokens (got {nTokens})."
4526            )
4527        if tokens[start] != Lexeme.doubleQuestionmark:
4528            raise ParseError(
4529                f"A Condition must start with"
4530                f" {repr(self.formatDict[Lexeme.doubleQuestionmark])}"
4531            )
4532        try:
4533            consequenceStart = tokens.index(Lexeme.openCurly, start)
4534        except ValueError:
4535            raise ParseError("A condition must include a consequence block.")
4536        consequenceEnd = self.matchingBrace(tokens, consequenceStart)
4537        altStart = consequenceEnd + 1
4538        altEnd = self.matchingBrace(tokens, altStart)
4539
4540        if altEnd != end:
4541            raise ParseError(
4542                f"Junk after condition:\n{tokens[altEnd + 1: end + 1]}"
4543            )
4544
4545        return base.condition(
4546            condition=self.parseRequirementFromTokens(
4547                tokens,
4548                start + 1,
4549                consequenceStart - 1
4550            ),
4551            consequence=self.parseConsequenceFromTokens(
4552                tokens,
4553                consequenceStart,
4554                consequenceEnd
4555            ),
4556            alternative=self.parseConsequenceFromTokens(
4557                tokens,
4558                altStart,
4559                altEnd
4560            )
4561        )

Parses a base.Condition from a lexed tokens list. For example:

>>> pf = ParseFormat()
>>> tokens = [
...     Lexeme.doubleQuestionmark,
...     Lexeme.openParen,
...     "fire",
...     Lexeme.ampersand,
...     "water",
...     Lexeme.closeParen,
...     Lexeme.openCurly,
...     "gain",
...     "wind",
...     Lexeme.closeCurly,
...     Lexeme.openCurly,
...     Lexeme.closeCurly,
... ]
>>> pf.parseConditionFromTokens(tokens) == base.condition(
...     condition=base.ReqAll([
...         base.ReqCapability('fire'),
...         base.ReqCapability('water')
...     ]),
...     consequence=[base.effect(gain='wind')]
... )
True
def parseCondition(self, encoded: str) -> exploration.base.Condition:
4563    def parseCondition(
4564        self,
4565        encoded: str
4566    ) -> base.Condition:
4567        """
4568        Lexes the given string and then calls `parseConditionFromTokens`
4569        to return a `base.Condition`.
4570        """
4571        return self.parseConditionFromTokens(
4572            lex(encoded, self.reverseFormat)
4573        )

Lexes the given string and then calls parseConditionFromTokens to return a base.Condition.

def parseChallengeFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> exploration.base.Challenge:
4575    def parseChallengeFromTokens(
4576        self,
4577        tokens: LexedTokens,
4578        start: int = 0,
4579        end: int = -1
4580    ) -> base.Challenge:
4581        """
4582        Parses a `base.Challenge` from a lexed tokens list.
4583
4584        For example:
4585
4586        >>> pf = ParseFormat()
4587        >>> tokens = [
4588        ...     Lexeme.angleLeft,
4589        ...     '2',
4590        ...     Lexeme.angleRight,
4591        ...     'best',
4592        ...     Lexeme.openParen,
4593        ...     "chess",
4594        ...     Lexeme.sepOrDelay,
4595        ...     "checkers",
4596        ...     Lexeme.closeParen,
4597        ...     Lexeme.openCurly,
4598        ...     "gain",
4599        ...     "coin",
4600        ...     Lexeme.tokenCount,
4601        ...     "5",
4602        ...     Lexeme.closeCurly,
4603        ...     Lexeme.angleRight,
4604        ...     Lexeme.openCurly,
4605        ...     "lose",
4606        ...     "coin",
4607        ...     Lexeme.tokenCount,
4608        ...     "5",
4609        ...     Lexeme.closeCurly,
4610        ... ]
4611        >>> c = pf.parseChallengeFromTokens(tokens)
4612        >>> c['skills'] == base.BestSkill('chess', 'checkers')
4613        True
4614        >>> c['level']
4615        2
4616        >>> c['success'] == [base.effect(gain=('coin', 5))]
4617        True
4618        >>> c['failure'] == [base.effect(lose=('coin', 5))]
4619        True
4620        >>> c['outcome']
4621        False
4622        >>> c == base.challenge(
4623        ...     skills=base.BestSkill('chess', 'checkers'),
4624        ...     level=2,
4625        ...     success=[base.effect(gain=('coin', 5))],
4626        ...     failure=[base.effect(lose=('coin', 5))],
4627        ...     outcome=False
4628        ... )
4629        True
4630        >>> t2 = ['hi'] + tokens + ['bye']  # parsing only part of the list
4631        >>> c == pf.parseChallengeFromTokens(t2, 1, -2)
4632        True
4633        """
4634        start, end, nTokens = normalizeEnds(tokens, start, end)
4635        if nTokens < 8:
4636            raise ParseError(
4637                f"Not enough tokens for a challenge: {nTokens}"
4638            )
4639        if tokens[start] != Lexeme.angleLeft:
4640            raise ParseError(
4641                f"Challenge must start with"
4642                f" {repr(self.formatDict[Lexeme.angleLeft])}"
4643            )
4644        levelStr = tokens[start + 1]
4645        if isinstance(levelStr, Lexeme):
4646            raise ParseError(
4647                f"Challenge must start with a level in angle brackets"
4648                f" (got {repr(self.formatDict[levelStr])})."
4649            )
4650        if tokens[start + 2] != Lexeme.angleRight:
4651            raise ParseError(
4652                f"Challenge must include"
4653                f" {repr(self.formatDict[Lexeme.angleRight])} after"
4654                f" the level."
4655            )
4656        try:
4657            level = int(levelStr)
4658        except ValueError:
4659            raise ParseError(
4660                f"Challenge level must be an integer (got"
4661                f" {repr(tokens[start + 1])}."
4662            )
4663        try:
4664            successStart = tokens.index(Lexeme.openCurly, start)
4665            skillsEnd = successStart - 1
4666        except ValueError:
4667            raise ParseError("A challenge must include a consequence block.")
4668
4669        outcome: Optional[bool] = None
4670        if tokens[skillsEnd] == Lexeme.angleRight:
4671            skillsEnd -= 1
4672            outcome = True
4673        successEnd = self.matchingBrace(tokens, successStart)
4674        failStart = successEnd + 1
4675        if tokens[failStart] == Lexeme.angleRight:
4676            failStart += 1
4677            if outcome is not None:
4678                raise ParseError(
4679                    "Cannot indicate both success and failure as"
4680                    " outcomes in a challenge."
4681                )
4682            outcome = False
4683        failEnd = self.matchingBrace(tokens, failStart)
4684
4685        if failEnd != end:
4686            raise ParseError(
4687                f"Junk after condition:\n{tokens[failEnd + 1:end + 1]}"
4688            )
4689
4690        skills = self.parseSkillCombinationFromTokens(
4691            tokens,
4692            start + 3,
4693            skillsEnd
4694        )
4695        if isinstance(skills, base.Skill):
4696            skills = base.BestSkill(skills)
4697
4698        return base.challenge(
4699            level=level,
4700            outcome=outcome,
4701            skills=skills,
4702            success=self.parseConsequenceFromTokens(
4703                tokens[successStart:successEnd + 1]
4704            ),
4705            failure=self.parseConsequenceFromTokens(
4706                tokens[failStart:failEnd + 1]
4707            )
4708        )

Parses a base.Challenge from a lexed tokens list.

For example:

>>> pf = ParseFormat()
>>> tokens = [
...     Lexeme.angleLeft,
...     '2',
...     Lexeme.angleRight,
...     'best',
...     Lexeme.openParen,
...     "chess",
...     Lexeme.sepOrDelay,
...     "checkers",
...     Lexeme.closeParen,
...     Lexeme.openCurly,
...     "gain",
...     "coin",
...     Lexeme.tokenCount,
...     "5",
...     Lexeme.closeCurly,
...     Lexeme.angleRight,
...     Lexeme.openCurly,
...     "lose",
...     "coin",
...     Lexeme.tokenCount,
...     "5",
...     Lexeme.closeCurly,
... ]
>>> c = pf.parseChallengeFromTokens(tokens)
>>> c['skills'] == base.BestSkill('chess', 'checkers')
True
>>> c['level']
2
>>> c['success'] == [base.effect(gain=('coin', 5))]
True
>>> c['failure'] == [base.effect(lose=('coin', 5))]
True
>>> c['outcome']
False
>>> c == base.challenge(
...     skills=base.BestSkill('chess', 'checkers'),
...     level=2,
...     success=[base.effect(gain=('coin', 5))],
...     failure=[base.effect(lose=('coin', 5))],
...     outcome=False
... )
True
>>> t2 = ['hi'] + tokens + ['bye']  # parsing only part of the list
>>> c == pf.parseChallengeFromTokens(t2, 1, -2)
True
def parseChallenge(self, encoded: str) -> exploration.base.Challenge:
4710    def parseChallenge(
4711        self,
4712        encoded: str
4713    ) -> base.Challenge:
4714        """
4715        Lexes the given string and then calls `parseChallengeFromTokens`
4716        to return a `base.Challenge`.
4717        """
4718        return self.parseChallengeFromTokens(
4719            lex(encoded, self.reverseFormat)
4720        )

Lexes the given string and then calls parseChallengeFromTokens to return a base.Challenge.

def parseConsequenceFromTokens( self, tokens: List[Union[Lexeme, str]], start: int = 0, end: int = -1) -> List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]:
4722    def parseConsequenceFromTokens(
4723        self,
4724        tokens: LexedTokens,
4725        start: int = 0,
4726        end: int = -1
4727    ) -> base.Consequence:
4728        """
4729        Parses a consequence from a lexed token list. If start and/or end
4730        are specified, only processes the part of the list between those
4731        two indices (inclusive). Use `lex` to turn a string into a
4732        `LexedTokens` list (or use `ParseFormat.parseConsequence` which
4733        does that for you).
4734
4735        An example:
4736
4737        >>> pf = ParseFormat()
4738        >>> tokens = [
4739        ...     Lexeme.openCurly,
4740        ...     'gain',
4741        ...     'power',
4742        ...     Lexeme.closeCurly
4743        ... ]
4744        >>> c = pf.parseConsequenceFromTokens(tokens)
4745        >>> c == [base.effect(gain='power')]
4746        True
4747        >>> tokens.append('hi')
4748        >>> c == pf.parseConsequenceFromTokens(tokens, end=-2)
4749        True
4750        >>> c == pf.parseConsequenceFromTokens(tokens, end=3)
4751        True
4752        """
4753        start, end, nTokens = normalizeEnds(tokens, start, end)
4754
4755        if nTokens < 2:
4756            raise ParseError("Consequence must have at least two tokens.")
4757
4758        if tokens[start] != Lexeme.openCurly:
4759            raise ParseError(
4760                f"Consequence must start with an open curly brace:"
4761                f" {repr(self.formatDict[Lexeme.openCurly])}."
4762            )
4763
4764        if tokens[end] != Lexeme.closeCurly:
4765            raise ParseError(
4766                f"Consequence must end with a closing curly brace:"
4767                f" {repr(self.formatDict[Lexeme.closeCurly])}."
4768            )
4769
4770        if nTokens == 2:
4771            return []
4772
4773        result: base.Consequence = []
4774        for partStart, partEnd in findSeparatedParts(
4775            tokens,
4776            Lexeme.consequenceSeparator,
4777            start + 1,
4778            end - 1,
4779            Lexeme.openCurly,
4780            Lexeme.closeCurly
4781        ):
4782            if partEnd - partStart < 0:
4783                raise ParseError("Empty consequence part.")
4784            if tokens[partStart] == Lexeme.angleLeft:  # a challenge
4785                result.append(
4786                    self.parseChallengeFromTokens(
4787                        tokens,
4788                        partStart,
4789                        partEnd
4790                    )
4791                )
4792            elif tokens[partStart] == Lexeme.doubleQuestionmark:  # condition
4793                result.append(
4794                    self.parseConditionFromTokens(
4795                        tokens,
4796                        partStart,
4797                        partEnd
4798                    )
4799                )
4800            else:  # Must be an effect
4801                result.append(
4802                    self.parseEffectFromTokens(
4803                        tokens,
4804                        partStart,
4805                        partEnd
4806                    )
4807                )
4808
4809        return result

Parses a consequence from a lexed token list. If start and/or end are specified, only processes the part of the list between those two indices (inclusive). Use lex to turn a string into a LexedTokens list (or use ParseFormat.parseConsequence which does that for you).

An example:

>>> pf = ParseFormat()
>>> tokens = [
...     Lexeme.openCurly,
...     'gain',
...     'power',
...     Lexeme.closeCurly
... ]
>>> c = pf.parseConsequenceFromTokens(tokens)
>>> c == [base.effect(gain='power')]
True
>>> tokens.append('hi')
>>> c == pf.parseConsequenceFromTokens(tokens, end=-2)
True
>>> c == pf.parseConsequenceFromTokens(tokens, end=3)
True
def parseConsequence( self, encoded: str) -> List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]:
4811    def parseConsequence(self, encoded: str) -> base.Consequence:
4812        """
4813        Parses a consequence from a string. Uses `lex` and
4814        `ParseFormat.parseConsequenceFromTokens`. For example:
4815
4816        >>> pf = ParseFormat()
4817        >>> c = pf.parseConsequence(
4818        ...   '{gain power}'
4819        ... )
4820        >>> c == [base.effect(gain='power')]
4821        True
4822        >>> pf.unparseConsequence(c)
4823        '{gain power}'
4824        >>> c = pf.parseConsequence(
4825        ...     '{\\n'
4826        ...     '    ??(brawny|!weights*3){\\n'
4827        ...     '        <3>sum(brains, brawn){goto home}>{bounce}\\n'
4828        ...     '    }{};\\n'
4829        ...     '    lose coin*1\\n'
4830        ...     '}'
4831        ... )
4832        >>> len(c)
4833        2
4834        >>> c[0]['condition'] == base.ReqAny([
4835        ...     base.ReqCapability('brawny'),
4836        ...     base.ReqNot(base.ReqTokens('weights', 3))
4837        ... ])
4838        True
4839        >>> len(c[0]['consequence'])
4840        1
4841        >>> len(c[0]['alternative'])
4842        0
4843        >>> cons = c[0]['consequence'][0]
4844        >>> cons['skills'] == base.CombinedSkill('brains', 'brawn')
4845        True
4846        >>> cons['level']
4847        3
4848        >>> len(cons['success'])
4849        1
4850        >>> len(cons['failure'])
4851        1
4852        >>> cons['success'][0] == base.effect(goto='home')
4853        True
4854        >>> cons['failure'][0] == base.effect(bounce=True)
4855        True
4856        >>> cons['outcome'] = False
4857        >>> c[0] == base.condition(
4858        ...     condition=base.ReqAny([
4859        ...         base.ReqCapability('brawny'),
4860        ...         base.ReqNot(base.ReqTokens('weights', 3))
4861        ...     ]),
4862        ...     consequence=[
4863        ...         base.challenge(
4864        ...             skills=base.CombinedSkill('brains', 'brawn'),
4865        ...             level=3,
4866        ...             success=[base.effect(goto='home')],
4867        ...             failure=[base.effect(bounce=True)],
4868        ...             outcome=False
4869        ...         )
4870        ...     ]
4871        ... )
4872        True
4873        >>> c[1] == base.effect(lose=('coin', 1))
4874        True
4875        """
4876        return self.parseConsequenceFromTokens(
4877            lex(encoded, self.reverseFormat)
4878        )

Parses a consequence from a string. Uses lex and ParseFormat.parseConsequenceFromTokens. For example:

>>> pf = ParseFormat()
>>> c = pf.parseConsequence(
...   '{gain power}'
... )
>>> c == [base.effect(gain='power')]
True
>>> pf.unparseConsequence(c)
'{gain power}'
>>> c = pf.parseConsequence(
...     '{\n'
...     '    ??(brawny|!weights*3){\n'
...     '        <3>sum(brains, brawn){goto home}>{bounce}\n'
...     '    }{};\n'
...     '    lose coin*1\n'
...     '}'
... )
>>> len(c)
2
>>> c[0]['condition'] == base.ReqAny([
...     base.ReqCapability('brawny'),
...     base.ReqNot(base.ReqTokens('weights', 3))
... ])
True
>>> len(c[0]['consequence'])
1
>>> len(c[0]['alternative'])
0
>>> cons = c[0]['consequence'][0]
>>> cons['skills'] == base.CombinedSkill('brains', 'brawn')
True
>>> cons['level']
3
>>> len(cons['success'])
1
>>> len(cons['failure'])
1
>>> cons['success'][0] == base.effect(goto='home')
True
>>> cons['failure'][0] == base.effect(bounce=True)
True
>>> cons['outcome'] = False
>>> c[0] == base.condition(
...     condition=base.ReqAny([
...         base.ReqCapability('brawny'),
...         base.ReqNot(base.ReqTokens('weights', 3))
...     ]),
...     consequence=[
...         base.challenge(
...             skills=base.CombinedSkill('brains', 'brawn'),
...             level=3,
...             success=[base.effect(goto='home')],
...             failure=[base.effect(bounce=True)],
...             outcome=False
...         )
...     ]
... )
True
>>> c[1] == base.effect(lose=('coin', 1))
True
class ParsedDotGraph(typing.TypedDict):
4885class ParsedDotGraph(TypedDict):
4886    """
4887    Represents a parsed `graphviz` dot-format graph consisting of nodes,
4888    edges, and subgraphs, with attributes attached to nodes and/or
4889    edges. An intermediate format during conversion to a full
4890    `DecisionGraph`. Includes the following slots:
4891
4892    - `'nodes'`: A list of tuples each holding a node ID followed by a
4893        list of name/value attribute pairs.
4894    - `'edges'`: A list of tuples each holding a from-ID, a to-ID,
4895        and then a list of name/value attribute pairs.
4896    - `'attrs'`: A list of tuples each holding a name/value attribute
4897        pair for graph-level attributes.
4898    - `'subgraphs'`: A list of subgraphs (each a tuple with a subgraph
4899        name and then another dictionary in the same format as this
4900        one).
4901    """
4902    nodes: List[Tuple[int, List[Tuple[str, str]]]]
4903    edges: List[Tuple[int, int, List[Tuple[str, str]]]]
4904    attrs: List[Tuple[str, str]]
4905    subgraphs: List[Tuple[str, 'ParsedDotGraph']]

Represents a parsed graphviz dot-format graph consisting of nodes, edges, and subgraphs, with attributes attached to nodes and/or edges. An intermediate format during conversion to a full DecisionGraph. Includes the following slots:

  • 'nodes': A list of tuples each holding a node ID followed by a list of name/value attribute pairs.
  • 'edges': A list of tuples each holding a from-ID, a to-ID, and then a list of name/value attribute pairs.
  • 'attrs': A list of tuples each holding a name/value attribute pair for graph-level attributes.
  • 'subgraphs': A list of subgraphs (each a tuple with a subgraph name and then another dictionary in the same format as this one).
nodes: List[Tuple[int, List[Tuple[str, str]]]]
edges: List[Tuple[int, int, List[Tuple[str, str]]]]
attrs: List[Tuple[str, str]]
subgraphs: List[Tuple[str, ParsedDotGraph]]
def parseSimpleDotAttrs(fragment: str) -> List[Tuple[str, str]]:
4908def parseSimpleDotAttrs(fragment: str) -> List[Tuple[str, str]]:
4909    """
4910    Given a string fragment that starts with '[' and ends with ']',
4911    parses a simple attribute list in `graphviz` dot format from that
4912    fragment, returning a list of name/value attribute tuples. Raises a
4913    `DotParseError` if the fragment doesn't have the right format.
4914
4915    Examples:
4916
4917    >>> parseSimpleDotAttrs('[ name=value ]')
4918    [('name', 'value')]
4919    >>> parseSimpleDotAttrs('[ a=b c=d e=f ]')
4920    [('a', 'b'), ('c', 'd'), ('e', 'f')]
4921    >>> parseSimpleDotAttrs('[ a=b "c d"="e f" ]')
4922    [('a', 'b'), ('c d', 'e f')]
4923    >>> parseSimpleDotAttrs('[a=b "c d"="e f"]')
4924    [('a', 'b'), ('c d', 'e f')]
4925    >>> parseSimpleDotAttrs('[ a=b "c d"="e f"')
4926    Traceback (most recent call last):
4927    ...
4928    exploration.parsing.DotParseError...
4929    >>> parseSimpleDotAttrs('a=b "c d"="e f" ]')
4930    Traceback (most recent call last):
4931    ...
4932    exploration.parsing.DotParseError...
4933    >>> parseSimpleDotAttrs('[ a b=c ]')
4934    Traceback (most recent call last):
4935    ...
4936    exploration.parsing.DotParseError...
4937    >>> parseSimpleDotAttrs('[ a=b c ]')
4938    Traceback (most recent call last):
4939    ...
4940    exploration.parsing.DotParseError...
4941    >>> parseSimpleDotAttrs('[ name="value" ]')
4942    [('name', 'value')]
4943    >>> parseSimpleDotAttrs('[ name="\\\\"value\\\\"" ]')
4944    [('name', '"value"')]
4945    """
4946    if not fragment.startswith('[') or not fragment.endswith(']'):
4947        raise DotParseError(
4948            f"Simple attrs fragment missing delimiters:"
4949            f"\n  {repr(fragment)}"
4950        )
4951    result = []
4952    rest = fragment[1:-1].strip()
4953    while rest:
4954        # Get possibly-quoted attribute name:
4955        if rest.startswith('"'):
4956            try:
4957                aName, rest = utils.unquoted(rest)
4958            except ValueError:
4959                raise DotParseError(
4960                    f"Malformed quoted attribute name in"
4961                    f" fragment:\n  {repr(fragment)}"
4962                )
4963            rest = rest.lstrip()
4964            if not rest.startswith('='):
4965                raise DotParseError(
4966                    f"Missing '=' in attribute block in"
4967                    f" fragment:\n  {repr(fragment)}"
4968                )
4969            rest = rest[1:].lstrip()
4970        else:
4971            try:
4972                eqInd = rest.index('=')
4973            except ValueError:
4974                raise DotParseError(
4975                    f"Missing '=' in attribute block in"
4976                    f" fragment:\n  {repr(fragment)}"
4977                )
4978            aName = rest[:eqInd]
4979            if ' ' in aName:
4980                raise DotParseError(
4981                    f"Malformed unquoted attribute name"
4982                    f" {repr(aName)} in fragment:"
4983                    f"\n  {repr(fragment)}"
4984                )
4985            rest = rest[eqInd + 1:].lstrip()
4986
4987        # Get possibly-quoted attribute value:
4988        if rest.startswith('"'):
4989            try:
4990                aVal, rest = utils.unquoted(rest)
4991            except ValueError:
4992                raise DotParseError(
4993                    f"Malformed quoted attribute value in"
4994                    f" fragment:\n  {repr(fragment)}"
4995                )
4996            rest = rest.lstrip()
4997        else:
4998            try:
4999                spInd = rest.index(' ')
5000            except ValueError:
5001                spInd = len(rest)
5002            aVal = rest[:spInd]
5003            rest = rest[spInd:].lstrip()
5004
5005        # Append this attribute pair and continue parsing
5006        result.append((aName, aVal))
5007
5008    return result

Given a string fragment that starts with '[' and ends with ']', parses a simple attribute list in graphviz dot format from that fragment, returning a list of name/value attribute tuples. Raises a DotParseError if the fragment doesn't have the right format.

Examples:

>>> parseSimpleDotAttrs('[ name=value ]')
[('name', 'value')]
>>> parseSimpleDotAttrs('[ a=b c=d e=f ]')
[('a', 'b'), ('c', 'd'), ('e', 'f')]
>>> parseSimpleDotAttrs('[ a=b "c d"="e f" ]')
[('a', 'b'), ('c d', 'e f')]
>>> parseSimpleDotAttrs('[a=b "c d"="e f"]')
[('a', 'b'), ('c d', 'e f')]
>>> parseSimpleDotAttrs('[ a=b "c d"="e f"')
Traceback (most recent call last):
...
DotParseError...
>>> parseSimpleDotAttrs('a=b "c d"="e f" ]')
Traceback (most recent call last):
...
DotParseError...
>>> parseSimpleDotAttrs('[ a b=c ]')
Traceback (most recent call last):
...
DotParseError...
>>> parseSimpleDotAttrs('[ a=b c ]')
Traceback (most recent call last):
...
DotParseError...
>>> parseSimpleDotAttrs('[ name="value" ]')
[('name', 'value')]
>>> parseSimpleDotAttrs('[ name="\\"value\\"" ]')
[('name', '"value"')]
def parseDotNode(nodeLine: str) -> Tuple[int, Union[bool, List[Tuple[str, str]]]]:
5011def parseDotNode(
5012    nodeLine: str
5013) -> Tuple[int, Union[bool, List[Tuple[str, str]]]]:
5014    """
5015    Given a line of text from a `graphviz` dot-format graph
5016    (possibly ending in an '[' to indicate attributes to follow, or
5017    possible including a '[ ... ]' block with attributes in-line),
5018    parses it as a node declaration, returning the ID of the node,
5019    along with a boolean indicating whether attributes follow or
5020    not. If an inline attribute block is present, the second member
5021    of the tuple will be a list of attribute name/value pairs. In
5022    that case, all attribute names and values must either be quoted
5023    or not include spaces.
5024    Examples:
5025
5026    >>> parseDotNode('1')
5027    (1, False)
5028    >>> parseDotNode(' 1 [ ')
5029    (1, True)
5030    >>> parseDotNode(' 1 [ a=b "c d"="e f" ] ')
5031    (1, [('a', 'b'), ('c d', 'e f')])
5032    >>> parseDotNode(' 3 [ name="A = \\\\"grate:open\\\\"" ]')
5033    (3, [('name', 'A = "grate:open"')])
5034    >>> parseDotNode('  "1"[')
5035    (1, True)
5036    >>> parseDotNode('  100[')
5037    (100, True)
5038    >>> parseDotNode('  1 2')
5039    Traceback (most recent call last):
5040    ...
5041    exploration.parsing.DotParseError...
5042    >>> parseDotNode('  1 [ 2')
5043    Traceback (most recent call last):
5044    ...
5045    exploration.parsing.DotParseError...
5046    >>> parseDotNode('  1 2')
5047    Traceback (most recent call last):
5048    ...
5049    exploration.parsing.DotParseError...
5050    >>> parseDotNode('  1 [ junk not=attrs ]')
5051    Traceback (most recent call last):
5052    ...
5053    exploration.parsing.DotParseError...
5054    >>> parseDotNode('  \\n')
5055    Traceback (most recent call last):
5056    ...
5057    exploration.parsing.DotParseError...
5058    """
5059    stripped = nodeLine.strip()
5060    if len(stripped) == 0:
5061        raise DotParseError(
5062            "Empty node in dot graph on line:\n  {repr(nodeLine)}"
5063        )
5064    hasAttrs: Union[bool, List[Tuple[str, str]]] = False
5065    if stripped.startswith('"'):
5066        nodeName, rest = utils.unquoted(stripped)
5067        rest = rest.strip()
5068        if rest == '[':
5069            hasAttrs = True
5070        elif rest.startswith('[') and rest.endswith(']'):
5071            hasAttrs = parseSimpleDotAttrs(rest)
5072        elif rest:
5073            raise DotParseError(
5074                f"Extra junk {repr(rest)} after node on line:"
5075                f"\n {repr(nodeLine)}"
5076            )
5077
5078    else:
5079        if stripped.endswith('['):
5080            hasAttrs = True
5081            stripped = stripped[:-1].rstrip()
5082        elif stripped.endswith(']'):
5083            try:
5084                # TODO: Why did this used to be rindex? Was that
5085                # important in some case? (That doesn't work since the
5086                # value may contain a quoted open bracket).
5087                attrStart = stripped.index('[')
5088            except ValueError:
5089                raise DotParseError(
5090                    f"Unmatched ']' on line:\n  {repr(nodeLine)}"
5091                )
5092            hasAttrs = parseSimpleDotAttrs(
5093                stripped[attrStart:]
5094            )
5095            stripped = stripped[:attrStart].rstrip()
5096
5097        if ' ' in stripped:
5098            raise DotParseError(
5099                f"Unquoted multi-word node on line:\n  {repr(nodeLine)}"
5100            )
5101        else:
5102            nodeName = stripped
5103
5104    try:
5105        nodeID = int(nodeName)
5106    except ValueError:
5107        raise DotParseError(
5108            f"Node name f{repr(nodeName)} is not an integer on"
5109            f" line:\n {repr(nodeLine)}"
5110        )
5111
5112    return (nodeID, hasAttrs)

Given a line of text from a graphviz dot-format graph (possibly ending in an '[' to indicate attributes to follow, or possible including a '[ ... ]' block with attributes in-line), parses it as a node declaration, returning the ID of the node, along with a boolean indicating whether attributes follow or not. If an inline attribute block is present, the second member of the tuple will be a list of attribute name/value pairs. In that case, all attribute names and values must either be quoted or not include spaces. Examples:

>>> parseDotNode('1')
(1, False)
>>> parseDotNode(' 1 [ ')
(1, True)
>>> parseDotNode(' 1 [ a=b "c d"="e f" ] ')
(1, [('a', 'b'), ('c d', 'e f')])
>>> parseDotNode(' 3 [ name="A = \\"grate:open\\"" ]')
(3, [('name', 'A = "grate:open"')])
>>> parseDotNode('  "1"[')
(1, True)
>>> parseDotNode('  100[')
(100, True)
>>> parseDotNode('  1 2')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotNode('  1 [ 2')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotNode('  1 2')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotNode('  1 [ junk not=attrs ]')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotNode('  \n')
Traceback (most recent call last):
...
DotParseError...
def parseDotAttr(attrLine: str) -> Tuple[str, str]:
5115def parseDotAttr(attrLine: str) -> Tuple[str, str]:
5116    """
5117    Given a line of text from a `graphviz` dot-format graph, parses
5118    it as an attribute (maybe-quoted-attr-name =
5119    maybe-quoted-attr-value). Returns the (maybe-unquoted) attr-name
5120    and the (maybe-unquoted) attr-value as a pair of strings. Raises
5121    a `DotParseError` if the line cannot be parsed as an attribute.
5122    Examples:
5123
5124    >>> parseDotAttr("a=b")
5125    ('a', 'b')
5126    >>> parseDotAttr("  a = b ")
5127    ('a', 'b')
5128    >>> parseDotAttr('"a" = "b"')
5129    ('a', 'b')
5130    >>> parseDotAttr('"a" -> "b"')
5131    Traceback (most recent call last):
5132    ...
5133    exploration.parsing.DotParseError...
5134    >>> parseDotAttr('"a" = "b" c')
5135    Traceback (most recent call last):
5136    ...
5137    exploration.parsing.DotParseError...
5138    >>> parseDotAttr('a')
5139    Traceback (most recent call last):
5140    ...
5141    exploration.parsing.DotParseError...
5142    >>> parseDotAttr('')
5143    Traceback (most recent call last):
5144    ...
5145    exploration.parsing.DotParseError...
5146    >>> parseDotAttr('0 [ name="A" ]')
5147    Traceback (most recent call last):
5148    ...
5149    exploration.parsing.DotParseError...
5150    """
5151    stripped = attrLine.lstrip()
5152    if len(stripped) == 0:
5153        raise DotParseError(
5154            "Empty attribute in dot graph on line:\n  {repr(attrLine)}"
5155        )
5156    if stripped.endswith(']') or stripped.endswith('['):
5157        raise DotParseError(
5158            f"Node attribute ends in '[' or ']' on line:"
5159            f"\n  {repr(attrLine)}"
5160        )
5161    if stripped.startswith('"'):
5162        try:
5163            attrName, rest = utils.unquoted(stripped)
5164        except ValueError:
5165            raise DotParseError(
5166                f"Unmatched quotes in line:\n  {repr(attrLine)}"
5167            )
5168        rest = rest.lstrip()
5169        if len(rest) == 0 or rest[0] != '=':
5170            raise DotParseError(
5171                f"No equals sign following attribute name on"
5172                f" line:\n  {repr(attrLine)}"
5173            )
5174        rest = rest[1:].lstrip()
5175    else:
5176        try:
5177            eqInd = stripped.index('=')
5178        except ValueError:
5179            raise DotParseError(
5180                f"No equals sign in attribute line:"
5181                f"\n  {repr(attrLine)}"
5182            )
5183        attrName = stripped[:eqInd].rstrip()
5184        rest = stripped[eqInd + 1:].lstrip()
5185
5186    if rest[0] == '"':
5187        try:
5188            attrVal, rest = utils.unquoted(rest)
5189        except ValueError:
5190            raise DotParseError(
5191                f"Unmatched quotes in line:\n  {repr(attrLine)}"
5192            )
5193        if rest.strip():
5194            raise DotParseError(
5195                f"Junk after attribute on line:"
5196                f"\n  {repr(attrLine)}"
5197            )
5198    else:
5199        attrVal = rest.rstrip()
5200
5201    return attrName, attrVal

Given a line of text from a graphviz dot-format graph, parses it as an attribute (maybe-quoted-attr-name = maybe-quoted-attr-value). Returns the (maybe-unquoted) attr-name and the (maybe-unquoted) attr-value as a pair of strings. Raises a DotParseError if the line cannot be parsed as an attribute. Examples:

>>> parseDotAttr("a=b")
('a', 'b')
>>> parseDotAttr("  a = b ")
('a', 'b')
>>> parseDotAttr('"a" = "b"')
('a', 'b')
>>> parseDotAttr('"a" -> "b"')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotAttr('"a" = "b" c')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotAttr('a')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotAttr('')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotAttr('0 [ name="A" ]')
Traceback (most recent call last):
...
DotParseError...
def parseDotEdge(edgeLine: str) -> Tuple[int, int, bool]:
5204def parseDotEdge(edgeLine: str) -> Tuple[int, int, bool]:
5205    """
5206    Given a line of text from a `graphviz` dot-format graph, parses
5207    it as an edge (fromID -> toID). Returns a tuple containing the
5208    from ID, the to ID, and a boolean indicating whether attributes
5209    follow the edge on subsequent lines (true if the line ends with
5210    '['). Raises a `DotParseError` if the line cannot be parsed as
5211    an edge pair. Examples:
5212
5213    >>> parseDotEdge("1 -> 2")
5214    (1, 2, False)
5215    >>> parseDotEdge("  1 -> 2 ")
5216    (1, 2, False)
5217    >>> parseDotEdge('"1" -> "2"')
5218    (1, 2, False)
5219    >>> parseDotEdge('"1" -> "2" [')
5220    (1, 2, True)
5221    >>> parseDotEdge("a -> b")
5222    Traceback (most recent call last):
5223    ...
5224    exploration.parsing.DotParseError...
5225    >>> parseDotEdge('"1" = "1"')
5226    Traceback (most recent call last):
5227    ...
5228    exploration.parsing.DotParseError...
5229    >>> parseDotEdge('"1" -> "2" c')
5230    Traceback (most recent call last):
5231    ...
5232    exploration.parsing.DotParseError...
5233    >>> parseDotEdge('1')
5234    Traceback (most recent call last):
5235    ...
5236    exploration.parsing.DotParseError...
5237    >>> parseDotEdge('')
5238    Traceback (most recent call last):
5239    ...
5240    exploration.parsing.DotParseError...
5241    """
5242    stripped = edgeLine.lstrip()
5243    if len(stripped) == 0:
5244        raise DotParseError(
5245            "Empty edge in dot graph on line:\n  {repr(edgeLine)}"
5246        )
5247    if stripped.startswith('"'):
5248        try:
5249            fromStr, rest = utils.unquoted(stripped)
5250        except ValueError:
5251            raise DotParseError(
5252                f"Unmatched quotes in line:\n  {repr(edgeLine)}"
5253            )
5254        rest = rest.lstrip()
5255        if rest[:2] != '->':
5256            raise DotParseError(
5257                f"No arrow sign following source name on"
5258                f" line:\n  {repr(edgeLine)}"
5259            )
5260        rest = rest[2:].lstrip()
5261    else:
5262        try:
5263            arrowInd = stripped.index('->')
5264        except ValueError:
5265            raise DotParseError(
5266                f"No arrow in edge line:"
5267                f"\n  {repr(edgeLine)}"
5268            )
5269        fromStr = stripped[:arrowInd].rstrip()
5270        rest = stripped[arrowInd + 2:].lstrip()
5271        if ' ' in fromStr:
5272            raise DotParseError(
5273                f"Unquoted multi-word edge source on line:"
5274                f"\n  {repr(edgeLine)}"
5275            )
5276
5277    hasAttrs = False
5278    if rest[0] == '"':
5279        try:
5280            toStr, rest = utils.unquoted(rest)
5281        except ValueError:
5282            raise DotParseError(
5283                f"Unmatched quotes in line:\n  {repr(edgeLine)}"
5284            )
5285        stripped = rest.strip()
5286        if stripped == '[':
5287            hasAttrs = True
5288        elif stripped:
5289            raise DotParseError(
5290                f"Junk after edge on line:"
5291                f"\n  {repr(edgeLine)}"
5292            )
5293    else:
5294        toStr = rest.rstrip()
5295        if toStr.endswith('['):
5296            toStr = toStr[:-1].rstrip()
5297            hasAttrs = True
5298        if ' ' in toStr:
5299            raise DotParseError(
5300                f"Unquoted multi-word edge destination on line:"
5301                f"\n  {repr(edgeLine)}"
5302            )
5303
5304    try:
5305        fromID = int(fromStr)
5306    except ValueError:
5307        raise DotParseError(
5308            f"Invalid 'from' ID: {repr(fromStr)} on line:"
5309            f"\n  {repr(edgeLine)}"
5310        )
5311
5312    try:
5313        toID = int(toStr)
5314    except ValueError:
5315        raise DotParseError(
5316            f"Invalid 'to' ID: {repr(toStr)} on line:"
5317            f"\n  {repr(edgeLine)}"
5318        )
5319
5320    return (fromID, toID, hasAttrs)

Given a line of text from a graphviz dot-format graph, parses it as an edge (fromID -> toID). Returns a tuple containing the from ID, the to ID, and a boolean indicating whether attributes follow the edge on subsequent lines (true if the line ends with '['). Raises a DotParseError if the line cannot be parsed as an edge pair. Examples:

>>> parseDotEdge("1 -> 2")
(1, 2, False)
>>> parseDotEdge("  1 -> 2 ")
(1, 2, False)
>>> parseDotEdge('"1" -> "2"')
(1, 2, False)
>>> parseDotEdge('"1" -> "2" [')
(1, 2, True)
>>> parseDotEdge("a -> b")
Traceback (most recent call last):
...
DotParseError...
>>> parseDotEdge('"1" = "1"')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotEdge('"1" -> "2" c')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotEdge('1')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotEdge('')
Traceback (most recent call last):
...
DotParseError...
def parseDotAttrList(lines: List[str]) -> Tuple[List[Tuple[str, str]], List[str]]:
5323def parseDotAttrList(
5324    lines: List[str]
5325) -> Tuple[List[Tuple[str, str]], List[str]]:
5326    """
5327    Given a list of lines of text from a `graphviz` dot-format
5328    graph which starts with an attribute line, parses multiple
5329    attribute lines until a line containing just ']' is found.
5330    Returns a list of the parsed name/value attribute pair tuples,
5331    along with a list of remaining unparsed strings (not counting
5332    the closing ']' line). Raises a `DotParseError` if it finds a
5333    non-attribute line or if it fails to find a closing ']' line.
5334    Examples:
5335
5336    >>> parseDotAttrList([
5337    ...     'a=b\\n',
5338    ...     'c=d\\n',
5339    ...     ']\\n',
5340    ... ])
5341    ([('a', 'b'), ('c', 'd')], [])
5342    >>> parseDotAttrList([
5343    ...     'a=b',
5344    ...     'c=d',
5345    ...     '  ]',
5346    ...     'more',
5347    ...     'lines',
5348    ... ])
5349    ([('a', 'b'), ('c', 'd')], ['more', 'lines'])
5350    >>> parseDotAttrList([
5351    ...     'a=b',
5352    ...     'c=d',
5353    ... ])
5354    Traceback (most recent call last):
5355    ...
5356    exploration.parsing.DotParseError...
5357    """
5358    index = 0
5359    found = []
5360    while index < len(lines):
5361        thisLine = lines[index]
5362        try:
5363            found.append(parseDotAttr(thisLine))
5364        except DotParseError:
5365            if thisLine.strip() == ']':
5366                return (found, lines[index + 1:])
5367            else:
5368                raise DotParseError(
5369                    f"Could not parse attribute from line:"
5370                    f"\n  {repr(thisLine)}"
5371                    f"\nAttributes block starts on line:"
5372                    f"\n  {repr(lines[0])}"
5373                )
5374        index += 1
5375
5376    raise DotParseError(
5377        f"No list terminator (']') for attributes starting on line:"
5378        f"\n  {repr(lines[0])}"
5379    )

Given a list of lines of text from a graphviz dot-format graph which starts with an attribute line, parses multiple attribute lines until a line containing just ']' is found. Returns a list of the parsed name/value attribute pair tuples, along with a list of remaining unparsed strings (not counting the closing ']' line). Raises a DotParseError if it finds a non-attribute line or if it fails to find a closing ']' line. Examples:

>>> parseDotAttrList([
...     'a=b\n',
...     'c=d\n',
...     ']\n',
... ])
([('a', 'b'), ('c', 'd')], [])
>>> parseDotAttrList([
...     'a=b',
...     'c=d',
...     '  ]',
...     'more',
...     'lines',
... ])
([('a', 'b'), ('c', 'd')], ['more', 'lines'])
>>> parseDotAttrList([
...     'a=b',
...     'c=d',
... ])
Traceback (most recent call last):
...
DotParseError...
def parseDotSubgraphStart(line: str) -> str:
5382def parseDotSubgraphStart(line: str) -> str:
5383    """
5384    Parses the start of a subgraph from a line of a graph file. The
5385    line must start with the word 'subgraph' and then have a name,
5386    followed by a '{' at the end of the line. Raises a
5387    `DotParseError` if this format doesn't match. Examples:
5388
5389    >>> parseDotSubgraphStart('subgraph A {')
5390    'A'
5391    >>> parseDotSubgraphStart('subgraph A B {')
5392    Traceback (most recent call last):
5393    ...
5394    exploration.parsing.DotParseError...
5395    >>> parseDotSubgraphStart('subgraph "A B" {')
5396    'A B'
5397    >>> parseDotSubgraphStart('subgraph A')
5398    Traceback (most recent call last):
5399    ...
5400    exploration.parsing.DotParseError...
5401    """
5402    stripped = line.strip()
5403    if len(stripped) == 0:
5404        raise DotParseError(
5405            f"Empty line where subgraph was expected:"
5406            f"\n  {repr(line)}"
5407        )
5408
5409    if not stripped.startswith('subgraph '):
5410        raise DotParseError(
5411            f"Subgraph doesn't start with 'subgraph' on line:"
5412            f"\n  {repr(line)}"
5413        )
5414
5415    stripped = stripped[9:]
5416    if stripped.startswith('"'):
5417        try:
5418            name, rest = utils.unquoted(stripped)
5419        except ValueError:
5420            raise DotParseError(
5421                f"Malformed quotes on subgraph line:\n {repr(line)}"
5422            )
5423        if rest.strip() != '{':
5424            raise DotParseError(
5425                f"Junk or missing '{{' on subgraph line:\n {repr(line)}"
5426            )
5427    else:
5428        parts = stripped.split()
5429        if len(parts) != 2 or parts[1] != '{':
5430            raise DotParseError(
5431                f"Junk or missing '{{' on subgraph line:\n {repr(line)}"
5432            )
5433        name, _ = parts
5434
5435    return name

Parses the start of a subgraph from a line of a graph file. The line must start with the word 'subgraph' and then have a name, followed by a '{' at the end of the line. Raises a DotParseError if this format doesn't match. Examples:

>>> parseDotSubgraphStart('subgraph A {')
'A'
>>> parseDotSubgraphStart('subgraph A B {')
Traceback (most recent call last):
...
DotParseError...
>>> parseDotSubgraphStart('subgraph "A B" {')
'A B'
>>> parseDotSubgraphStart('subgraph A')
Traceback (most recent call last):
...
DotParseError...
def parseDotGraphContents(lines: List[str]) -> Tuple[ParsedDotGraph, List[str]]:
5438def parseDotGraphContents(
5439    lines: List[str]
5440) -> Tuple[ParsedDotGraph, List[str]]:
5441    """
5442    Given a list of lines from a `graphviz` dot-format string,
5443    parses the list as the contents of a graph (or subgraph),
5444    stopping when it reaches a line that just contains '}'. Raises a
5445    `DotParseError` if it cannot do so or if the terminator is
5446    missing. Returns a tuple containing the parsed graph data (see
5447    `ParsedDotGraph` and the list of remaining lines after the
5448    terminator. Recursively parses subgraphs. Example:
5449
5450    >>> bits = parseDotGraphContents([
5451    ...     '"graph attr"=1',
5452    ...     '1 [',
5453    ...     '  attr=value',
5454    ...     ']',
5455    ...     '1 -> 2 [',
5456    ...     '  fullLabel="to_B"',
5457    ...     '  quality=number',
5458    ...     ']',
5459    ...     'subgraph name {',
5460    ...     '  300',
5461    ...     '  400',
5462    ...     '  300 -> 400 [',
5463    ...     '    fullLabel=forward',
5464    ...     '  ]',
5465    ...     '}',
5466    ...     '}',
5467    ... ])
5468    >>> len(bits)
5469    2
5470    >>> g = bits[0]
5471    >>> bits[1]
5472    []
5473    >>> sorted(g.keys())
5474    ['attrs', 'edges', 'nodes', 'subgraphs']
5475    >>> g['nodes']
5476    [(1, [('attr', 'value')])]
5477    >>> g['edges']
5478    [(1, 2, [('fullLabel', 'to_B'), ('quality', 'number')])]
5479    >>> g['attrs']
5480    [('graph attr', '1')]
5481    >>> sgs = g['subgraphs']
5482    >>> len(sgs)
5483    1
5484    >>> len(sgs[0])
5485    2
5486    >>> sgs[0][0]
5487    'name'
5488    >>> sg = sgs[0][1]
5489    >>> sorted(sg.keys())
5490    ['attrs', 'edges', 'nodes', 'subgraphs']
5491    >>> sg["nodes"]
5492    [(300, []), (400, [])]
5493    >>> sg["edges"]
5494    [(300, 400, [('fullLabel', 'forward')])]
5495    >>> sg["attrs"]
5496    []
5497    >>> sg["subgraphs"]
5498    []
5499    """
5500    result: ParsedDotGraph = {
5501        'nodes': [],
5502        'edges': [],
5503        'attrs': [],
5504        'subgraphs': [],
5505    }
5506    index = 0
5507    remainder = None
5508    # Consider each line:
5509    while index < len(lines):
5510        # Grab line and pre-increment index
5511        thisLine = lines[index]
5512        index += 1
5513
5514        # Check for } first because it could be parsed as a node
5515        stripped = thisLine.strip()
5516        if stripped == '}':
5517            remainder = lines[index:]
5518            break
5519        elif stripped == '':  # ignore blank lines
5520            continue
5521
5522        # Cascading parsing attempts, since the possibilities are
5523        # mostly mutually exclusive.
5524        # TODO: Node/attr confusion with = in a node name?
5525        try:
5526            attrName, attrVal = parseDotAttr(thisLine)
5527            result['attrs'].append((attrName, attrVal))
5528        except DotParseError:
5529            try:
5530                fromNode, toNode, hasEAttrs = parseDotEdge(
5531                    thisLine
5532                )
5533                if hasEAttrs:
5534                    attrs, rest = parseDotAttrList(
5535                        lines[index:]
5536                    )
5537                    # Restart to process rest
5538                    lines = rest
5539                    index = 0
5540                else:
5541                    attrs = []
5542                result['edges'].append((fromNode, toNode, attrs))
5543            except DotParseError:
5544                try:
5545                    nodeName, hasNAttrs = parseDotNode(
5546                        thisLine
5547                    )
5548                    if hasNAttrs is True:
5549                        attrs, rest = parseDotAttrList(
5550                            lines[index:]
5551                        )
5552                        # Restart to process rest
5553                        lines = rest
5554                        index = 0
5555                    elif hasNAttrs:
5556                        attrs = hasNAttrs
5557                    else:
5558                        attrs = []
5559                    result['nodes'].append((nodeName, attrs))
5560                except DotParseError:
5561                    try:
5562                        subName = parseDotSubgraphStart(
5563                            thisLine
5564                        )
5565                        subStuff, rest = \
5566                            parseDotGraphContents(
5567                                lines[index:]
5568                            )
5569                        result['subgraphs'].append((subName, subStuff))
5570                        # Restart to process rest
5571                        lines = rest
5572                        index = 0
5573                    except DotParseError:
5574                        raise DotParseError(
5575                            f"Unrecognizable graph line (possibly"
5576                            f" beginning of unfinished structure):"
5577                            f"\n  {repr(thisLine)}"
5578                        )
5579    if remainder is None:
5580        raise DotParseError(
5581            f"Graph (or subgraph) is missing closing '}}'. Starts"
5582            f" on line:\n  {repr(lines[0])}"
5583        )
5584    else:
5585        return (result, remainder)

Given a list of lines from a graphviz dot-format string, parses the list as the contents of a graph (or subgraph), stopping when it reaches a line that just contains '}'. Raises a DotParseError if it cannot do so or if the terminator is missing. Returns a tuple containing the parsed graph data (see ParsedDotGraph and the list of remaining lines after the terminator. Recursively parses subgraphs. Example:

>>> bits = parseDotGraphContents([
...     '"graph attr"=1',
...     '1 [',
...     '  attr=value',
...     ']',
...     '1 -> 2 [',
...     '  fullLabel="to_B"',
...     '  quality=number',
...     ']',
...     'subgraph name {',
...     '  300',
...     '  400',
...     '  300 -> 400 [',
...     '    fullLabel=forward',
...     '  ]',
...     '}',
...     '}',
... ])
>>> len(bits)
2
>>> g = bits[0]
>>> bits[1]
[]
>>> sorted(g.keys())
['attrs', 'edges', 'nodes', 'subgraphs']
>>> g['nodes']
[(1, [('attr', 'value')])]
>>> g['edges']
[(1, 2, [('fullLabel', 'to_B'), ('quality', 'number')])]
>>> g['attrs']
[('graph attr', '1')]
>>> sgs = g['subgraphs']
>>> len(sgs)
1
>>> len(sgs[0])
2
>>> sgs[0][0]
'name'
>>> sg = sgs[0][1]
>>> sorted(sg.keys())
['attrs', 'edges', 'nodes', 'subgraphs']
>>> sg["nodes"]
[(300, []), (400, [])]
>>> sg["edges"]
[(300, 400, [('fullLabel', 'forward')])]
>>> sg["attrs"]
[]
>>> sg["subgraphs"]
[]
def parseDot( dotStr: str, parseFormat: ParseFormat = <ParseFormat object>) -> exploration.core.DecisionGraph:
5588def parseDot(
5589    dotStr: str,
5590    parseFormat: ParseFormat = ParseFormat()
5591) -> core.DecisionGraph:
5592    """
5593    Converts a `graphviz` dot-format string into a `core.DecisionGraph`.
5594    A custom `ParseFormat` may be specified if desired; the default
5595    `ParseFormat` is used if not. Note that this relies on specific
5596    indentation schemes used by `toDot` so a hand-edited dot-format
5597    graph will probably not work. A `DotParseError` is raised if the
5598    provided string can't be parsed. Example
5599
5600    >>> parseDotNode(' 3 [ label="A = \\\\"grate:open\\\\"" ]')
5601    (3, [('label', 'A = "grate:open"')])
5602    >>> sg = '''\
5603    ... subgraph __requirements__ {
5604    ...   3 [ label="A = \\\\"grate:open\\\\"" ]
5605    ...   4 [ label="B = \\\\"!(helmet)\\\\"" ]
5606    ...   5 [ label="C = \\\\"helmet\\\\"" ]
5607    ... }'''
5608    >>> parseDotGraphContents(sg.splitlines()[1:])
5609    ({'nodes': [(3, [('label', 'A = "grate:open"')]),\
5610 (4, [('label', 'B = "!(helmet)"')]), (5, [('label', 'C = "helmet"')])],\
5611 'edges': [], 'attrs': [], 'subgraphs': []}, [])
5612    >>> from . import core
5613    >>> dg = core.DecisionGraph.example('simple')
5614    >>> encoded = toDot(dg)
5615    >>> reconstructed = parseDot(encoded)
5616    >>> for diff in dg.listDifferences(reconstructed):
5617    ...     print(diff)
5618    >>> reconstructed == dg
5619    True
5620    >>> dg = core.DecisionGraph.example('abc')
5621    >>> encoded = toDot(dg)
5622    >>> reconstructed = parseDot(encoded)
5623    >>> for diff in dg.listDifferences(reconstructed):
5624    ...     print(diff)
5625    >>> reconstructed == dg
5626    True
5627    >>> tg = core.DecisionGraph()
5628    >>> tg.addDecision('A')
5629    0
5630    >>> tg.addDecision('B')
5631    1
5632    >>> tg.addTransition('A', 'up', 'B', 'down')
5633    >>> same = parseDot('''
5634    ... digraph {
5635    ...     0 [ name=A label=A ]
5636    ...       0 -> 1 [
5637    ...         label=up
5638    ...         fullLabel=up
5639    ...         reciprocal=down
5640    ...       ]
5641    ...     1 [ name=B label=B ]
5642    ...       1 -> 0 [
5643    ...         label=down
5644    ...         fullLabel=down
5645    ...         reciprocal=up
5646    ...       ]
5647    ... }''')
5648    >>> for diff in tg.listDifferences(same):
5649    ...     print(diff)
5650    >>> same == tg
5651    True
5652    >>> pf = ParseFormat()
5653    >>> tg.setTransitionRequirement('A', 'up', pf.parseRequirement('one|two'))
5654    >>> tg.setConsequence(
5655    ...     'B',
5656    ...     'down',
5657    ...     [base.effect(gain="one")]
5658    ... )
5659    >>> test = parseDot('''
5660    ...   digraph {
5661    ...     0 [ name="A = \\\\"one|two\\\\"" label="A = \\\\"one|two\\\\"" ]
5662    ...   }
5663    ... ''')
5664    >>> list(test.nodes)
5665    [0]
5666    >>> test.nodes[0]['name']
5667    'A = "one|two"'
5668    >>> eff = (
5669    ...   r'"A = \\"[{\\\\\\"type\\\\\\": \\\\\\"gain\\\\\\",'
5670    ...   r' \\\\\\"applyTo\\\\\\": \\\\\\"active\\\\\\",'
5671    ...   r' \\\\\\"value\\\\\\": \\\\\\"one\\\\\\",'
5672    ...   r' \\\\\\"charges\\\\\\": null, \\\\\\"hidden\\\\\\": false,'
5673    ...   r' \\\\\\"delay\\\\\\": null}]\\""'
5674    ... )
5675    >>> utils.unquoted(eff)[1]
5676    ''
5677    >>> test2 = parseDot(
5678    ...     'digraph {\\n 0 [ name=' + eff + ' label=' + eff + ' ]\\n}'
5679    ... )
5680    >>> s = test2.nodes[0]['name']
5681    >>> s[:25]
5682    'A = "[{\\\\"type\\\\": \\\\"gain\\\\"'
5683    >>> s[25:50]
5684    ', \\\\"applyTo\\\\": \\\\"active\\\\"'
5685    >>> s[50:70]
5686    ', \\\\"value\\\\": \\\\"one\\\\"'
5687    >>> s[70:89]
5688    ', \\\\"charges\\\\": null'
5689    >>> s[89:108]
5690    ', \\\\"hidden\\\\": false'
5691    >>> s[108:]
5692    ', \\\\"delay\\\\": null}]"'
5693    >>> ae = s[s.index('=') + 1:].strip()
5694    >>> uq, after = utils.unquoted(ae)
5695    >>> after
5696    ''
5697    >>> fromJSON(uq) == [base.effect(gain="one")]
5698    True
5699    >>> same = parseDot('''
5700    ... digraph {
5701    ...   0 [ name=A label=A ]
5702    ...     0 -> 1 [
5703    ...       label=up
5704    ...       fullLabel=up
5705    ...       reciprocal=down
5706    ...       req=A
5707    ...     ]
5708    ...   1 [ name=B label=B ]
5709    ...     1 -> 0 [
5710    ...       label=down
5711    ...       fullLabel=down
5712    ...       reciprocal=up
5713    ...       consequence=A
5714    ...     ]
5715    ...   subgraph __requirements__ {
5716    ...     2 [ label="A = \\\\"one|two\\\\"" ]
5717    ...   }
5718    ...   subgraph __consequences__ {
5719    ...     3 [ label=''' + eff + ''' ]
5720    ...   }
5721    ... }''')
5722    >>> c = {'tags': {}, 'annotations': [], 'reciprocal': 'up', 'consequence': [{'type': 'gain', 'applyTo': 'active', 'value': 'one', 'delay': None, 'charges': None}]}['consequence']  # noqa
5723
5724    >>> for diff in tg.listDifferences(same):
5725    ...     print(diff)
5726    >>> same == tg
5727    True
5728    """
5729    lines = dotStr.splitlines()
5730    while lines[0].strip() == '':
5731        lines.pop(0)
5732    if lines.pop(0).strip() != "digraph {":
5733        raise DotParseError("Input doesn't begin with 'digraph {'.")
5734
5735    # Create our result
5736    result = core.DecisionGraph()
5737
5738    # Parse to intermediate graph data structure
5739    graphStuff, remaining = parseDotGraphContents(lines)
5740    if remaining:
5741        if len(remaining) <= 4:
5742            junk = '\n  '.join(repr(line) for line in remaining)
5743        else:
5744            junk = '\n  '.join(repr(line) for line in remaining[:4])
5745            junk += '\n  ...'
5746        raise DotParseError("Extra junk after graph:\n  {junk}")
5747
5748    # Sort out subgraphs to find legends
5749    zoneSubs = []
5750    reqLegend = None
5751    consequenceLegend = None
5752    mechanismLegend = None
5753    for sub in graphStuff['subgraphs']:
5754        if sub[0] == '__requirements__':
5755            reqLegend = sub[1]
5756        elif sub[0] == '__consequences__':
5757            consequenceLegend = sub[1]
5758        elif sub[0] == '__mechanisms__':
5759            mechanismLegend = sub[1]
5760        else:
5761            zoneSubs.append(sub)
5762
5763    # Build out our mapping from requirement abbreviations to actual
5764    # requirement objects
5765    reqMap: Dict[str, base.Requirement] = {}
5766    if reqLegend is not None:
5767        if reqLegend['edges']:
5768            raise DotParseError(
5769                f"Requirements legend subgraph has edges:"
5770                f"\n  {repr(reqLegend['edges'])}"
5771                f"\n(It should only have nodes.)"
5772            )
5773        if reqLegend['attrs']:
5774            raise DotParseError(
5775                f"Requirements legend subgraph has attributes:"
5776                f"\n  {repr(reqLegend['attrs'])}"
5777                f"\n(It should only have nodes.)"
5778            )
5779        if reqLegend['subgraphs']:
5780            raise DotParseError(
5781                f"Requirements legend subgraph has subgraphs:"
5782                f"\n  {repr(reqLegend['subgraphs'])}"
5783                f"\n(It should only have nodes.)"
5784            )
5785        for node, attrs in reqLegend['nodes']:
5786            if not attrs:
5787                raise DotParseError(
5788                    f"Node in requirements legend missing attributes:"
5789                    f"\n  {repr(attrs)}"
5790                )
5791            if len(attrs) != 1:
5792                raise DotParseError(
5793                    f"Node in requirements legend has multiple"
5794                    f" attributes:\n  {repr(attrs)}"
5795                )
5796            reqStr = attrs[0][1]
5797            try:
5798                eqInd = reqStr.index('=')
5799            except ValueError:
5800                raise DotParseError(
5801                    f"Missing '=' in requirement specifier:"
5802                    f"\n  {repr(reqStr)}"
5803                )
5804            ab = reqStr[:eqInd].rstrip()
5805            encoded = reqStr[eqInd + 1:].lstrip()
5806            try:
5807                encVal, empty = utils.unquoted(encoded)
5808            except ValueError:
5809                raise DotParseError(
5810                    f"Invalid quoted requirement value:"
5811                    f"\n  {repr(encoded)}"
5812                )
5813            if empty.strip():
5814                raise DotParseError(
5815                    f"Extra junk after requirement value:"
5816                    f"\n  {repr(empty)}"
5817                )
5818            try:
5819                req = parseFormat.parseRequirement(encVal)
5820            except ValueError:
5821                raise DotParseError(
5822                    f"Invalid encoded requirement in requirements"
5823                    f" legend:\n  {repr(encVal)}"
5824                )
5825            if ab in reqMap:
5826                raise DotParseError(
5827                    f"Abbreviation '{ab}' was defined multiple"
5828                    f" times in requirements legend."
5829                )
5830            reqMap[ab] = req
5831
5832    # Build out our mapping from consequence abbreviations to actual
5833    # consequence lists
5834    consequenceMap: Dict[str, base.Consequence] = {}
5835    if consequenceLegend is not None:
5836        if consequenceLegend['edges']:
5837            raise DotParseError(
5838                f"Consequences legend subgraph has edges:"
5839                f"\n  {repr(consequenceLegend['edges'])}"
5840                f"\n(It should only have nodes.)"
5841            )
5842        if consequenceLegend['attrs']:
5843            raise DotParseError(
5844                f"Consequences legend subgraph has attributes:"
5845                f"\n  {repr(consequenceLegend['attrs'])}"
5846                f"\n(It should only have nodes.)"
5847            )
5848        if consequenceLegend['subgraphs']:
5849            raise DotParseError(
5850                f"Consequences legend subgraph has subgraphs:"
5851                f"\n  {repr(consequenceLegend['subgraphs'])}"
5852                f"\n(It should only have nodes.)"
5853            )
5854        for node, attrs in consequenceLegend['nodes']:
5855            if not attrs:
5856                raise DotParseError(
5857                    f"Node in consequence legend missing attributes:"
5858                    f"\n  {repr(attrs)}"
5859                )
5860            if len(attrs) != 1:
5861                raise DotParseError(
5862                    f"Node in consequences legend has multiple"
5863                    f" attributes:\n  {repr(attrs)}"
5864                )
5865            consStr = attrs[0][1]
5866            try:
5867                eqInd = consStr.index('=')
5868            except ValueError:
5869                raise DotParseError(
5870                    f"Missing '=' in consequence string:"
5871                    f"\n  {repr(consStr)}"
5872                )
5873            ab = consStr[:eqInd].rstrip()
5874            encoded = consStr[eqInd + 1:].lstrip()
5875            try:
5876                encVal, empty = utils.unquoted(encoded)
5877            except ValueError:
5878                raise DotParseError(
5879                    f"Invalid quoted consequence value:"
5880                    f"\n  {repr(encoded)}"
5881                )
5882            if empty.strip():
5883                raise DotParseError(
5884                    f"Extra junk after consequence value:"
5885                    f"\n  {repr(empty)}"
5886                )
5887            try:
5888                consequences = fromJSON(encVal)
5889            except json.decoder.JSONDecodeError:
5890                raise DotParseError(
5891                    f"Invalid encoded consequence in requirements"
5892                    f" legend:\n  {repr(encVal)}"
5893                )
5894            if ab in consequenceMap:
5895                raise DotParseError(
5896                    f"Abbreviation '{ab}' was defined multiple"
5897                    f" times in effects legend."
5898                )
5899            consequenceMap[ab] = consequences
5900
5901    # Reconstruct mechanisms
5902    if mechanismLegend is not None:
5903        if mechanismLegend['edges']:
5904            raise DotParseError(
5905                f"Mechanisms legend subgraph has edges:"
5906                f"\n  {repr(mechanismLegend['edges'])}"
5907                f"\n(It should only have nodes.)"
5908            )
5909        if mechanismLegend['attrs']:
5910            raise DotParseError(
5911                f"Mechanisms legend subgraph has attributes:"
5912                f"\n  {repr(mechanismLegend['attrs'])}"
5913                f"\n(It should only have nodes.)"
5914            )
5915        if mechanismLegend['subgraphs']:
5916            raise DotParseError(
5917                f"Mechanisms legend subgraph has subgraphs:"
5918                f"\n  {repr(mechanismLegend['subgraphs'])}"
5919                f"\n(It should only have nodes.)"
5920            )
5921        for node, attrs in mechanismLegend['nodes']:
5922            if not attrs:
5923                raise DotParseError(
5924                    f"Node in mechanisms legend missing attributes:"
5925                    f"\n  {repr(attrs)}"
5926                )
5927            if len(attrs) != 1:
5928                raise DotParseError(
5929                    f"Node in mechanisms legend has multiple"
5930                    f" attributes:\n  {repr(attrs)}"
5931                )
5932            mechStr = attrs[0][1]
5933            try:
5934                atInd = mechStr.index('@')
5935                colonInd = mechStr.index(':')
5936            except ValueError:
5937                raise DotParseError(
5938                    f"Missing '@' or ':' in mechanism string:"
5939                    f"\n  {repr(mechStr)}"
5940                )
5941            if atInd > colonInd:
5942                raise DotParseError(
5943                    f"':' after '@' in mechanism string:"
5944                    f"\n  {repr(mechStr)}"
5945                )
5946            mID: base.MechanismID
5947            where: Optional[base.DecisionID]
5948            mName: base.MechanismName
5949            try:
5950                mID = int(mechStr[:atInd].rstrip())
5951            except ValueError:
5952                raise DotParseError(
5953                    f"Invalid mechanism ID in mechanism string:"
5954                    f"\n  {repr(mechStr)}"
5955                )
5956            try:
5957                whereStr = mechStr[atInd + 1:colonInd].strip()
5958                if whereStr == "None":
5959                    where = None
5960                else:
5961                    where = int(whereStr)
5962            except ValueError:
5963                raise DotParseError(
5964                    f"Invalid mechanism location in mechanism string:"
5965                    f"\n  {repr(mechStr)}"
5966                )
5967            mName, rest = utils.unquoted(mechStr[colonInd + 1:].lstrip())
5968            if rest.strip():
5969                raise DotParseError(
5970                    f"Junk after mechanism name in mechanism string:"
5971                    f"\n  {repr(mechStr)}"
5972                )
5973            result.mechanisms[mID] = (where, mName)
5974            if where is None:
5975                result.globalMechanisms[mName] = mID
5976
5977    # Add zones to the graph based on parent info
5978    # Map from zones to children we should add to them once all
5979    # zones are created:
5980    zoneChildMap: Dict[str, List[str]] = {}
5981    for prefixedName, graphData in zoneSubs:
5982        # Chop off cluster_ or _ prefix:
5983        zoneName = prefixedName[prefixedName.index('_') + 1:]
5984        if graphData['edges']:
5985            raise DotParseError(
5986                f"Zone subgraph for zone {repr(zoneName)} has edges:"
5987                f"\n  {repr(graphData['edges'])}"
5988                f"\n(It should only have nodes and attributes.)"
5989            )
5990        if graphData['subgraphs']:
5991            raise DotParseError(
5992                f"Zone subgraph for zone {repr(zoneName)} has"
5993                f" subgraphs:"
5994                f"\n  {repr(graphData['subgraphs'])}"
5995                f"\n(It should only have nodes and attributes.)"
5996            )
5997        # Note: we ignore nodes as that info is used for
5998        # visualization but is redundant with the zone parent info
5999        # stored in nodes, and it would be tricky to tease apart
6000        # direct vs. indirect relationships from merged info.
6001        parents = None
6002        level = None
6003        for attr, aVal in graphData['attrs']:
6004            if attr == 'parents':
6005                try:
6006                    parents = set(fromJSON(aVal))
6007                except json.decoder.JSONDecodeError:
6008                    raise DotParseError(
6009                        f"Invalid parents JSON in zone subgraph for"
6010                        f" zone '{zoneName}':\n  {repr(aVal)}"
6011                    )
6012            elif attr == 'level':
6013                try:
6014                    level = int(aVal)
6015                except ValueError:
6016                    raise DotParseError(
6017                        f"Invalid level in zone subgraph for"
6018                        f" zone '{zoneName}':\n  {repr(aVal)}"
6019                    )
6020            elif attr == 'label':
6021                pass  # name already extracted from the subgraph name
6022
6023            else:
6024                raise DotParseError(
6025                    f"Unexpected attribute '{attr}' in zone"
6026                    f" subgraph for zone '{zoneName}'"
6027                )
6028        if parents is None:
6029            raise DotParseError(
6030                f"No parents attribute for zone '{zoneName}'."
6031                f" Graph is:\n  {repr(graphData)}"
6032            )
6033        if level is None:
6034            raise DotParseError(
6035                f"No level attribute for zone '{zoneName}'."
6036                f" Graph is:\n  {repr(graphData)}"
6037            )
6038
6039        # Add ourself to our parents in the child map
6040        for parent in parents:
6041            zoneChildMap.setdefault(parent, []).append(zoneName)
6042
6043        # Create this zone
6044        result.createZone(zoneName, level)
6045
6046    # Add zone parent/child relationships
6047    for parent, children in zoneChildMap.items():
6048        for child in children:
6049            result.addZoneToZone(child, parent)
6050
6051    # Add nodes to the graph
6052    for (node, attrs) in graphStuff['nodes']:
6053        name: Optional[str] = None
6054        annotations = []
6055        tags: Dict[base.Tag, base.TagValue] = {}
6056        zones = []
6057        for attr, aVal in attrs:
6058            if attr == 'name':  # it's the name
6059                name = aVal
6060            elif attr == 'label':  # zone + name; redundant
6061                pass
6062            elif attr.startswith('t_'):  # it's a tag
6063                tagName = attr[2:]
6064                try:
6065                    tagAny = fromJSON(aVal)
6066                except json.decoder.JSONDecodeError:
6067                    raise DotParseError(
6068                        f"Error in JSON for tag attr '{attr}' of node"
6069                        f" '{node}'"
6070                    )
6071                if isinstance(tagAny, base.TagValueTypes):
6072                    tagVal: base.TagValue = cast(base.TagValue, tagAny)
6073                else:
6074                    raise DotParseError(
6075                        f"JSON for tag value encodes disallowed tag"
6076                        f" value of type {type(tagAny)}. Value is:"
6077                        f"\n  {repr(tagAny)}"
6078                    )
6079                tags[tagName] = tagVal
6080            elif attr.startswith('z_'):  # it's a zone
6081                zones.append(attr[2:])
6082            elif attr == 'annotations':  # It's the annotations
6083                try:
6084                    annotations = fromJSON(aVal)
6085                except json.decoder.JSONDecodeError:
6086                    raise DotParseError(
6087                        f"Bad JSON in attribute '{attr}' of node"
6088                        f" '{node}'"
6089                    )
6090            else:
6091                raise DotParseError(
6092                    f"Unrecognized node attribute '{attr}' for node"
6093                    f" '{node}'"
6094                )
6095
6096        # TODO: Domains here?
6097        if name is None:
6098            raise DotParseError(f"Node '{node}' does not have a name.")
6099
6100        result.addIdentifiedDecision(
6101            node,
6102            name,
6103            tags=tags,
6104            annotations=annotations
6105        )
6106        for zone in zones:
6107            try:
6108                result.addDecisionToZone(node, zone)
6109            except core.MissingZoneError:
6110                raise DotParseError(
6111                    f"Zone '{zone}' for node {node} does not"
6112                    f" exist."
6113                )
6114
6115    # Add mechanisms to each node:
6116    for (mID, (where, mName)) in result.mechanisms.items():
6117        mPool = result.nodes[where].setdefault('mechanisms', {})
6118        if mName in mPool:
6119            raise DotParseError(
6120                f"Multiple mechanisms named {mName!r} at"
6121                f" decision {where}."
6122            )
6123        mPool[mName] = mID
6124
6125    # Reciprocals to double-check once all edges are added
6126    recipChecks: Dict[
6127        Tuple[base.DecisionID, base.Transition],
6128        base.Transition
6129    ] = {}
6130
6131    # Add each edge
6132    for (source, dest, attrs) in graphStuff['edges']:
6133        annotations = []
6134        tags = {}
6135        label = None
6136        requirements = None
6137        consequence = None
6138        reciprocal = None
6139        for attr, aVal in attrs:
6140            if attr.startswith('t_'):
6141                try:
6142                    tags[attr[2:]] = fromJSON(aVal)
6143                except json.decoder.JSONDecodeError:
6144                    raise DotParseError(
6145                        f"Invalid JSON in edge tag '{attr}' for edge"
6146                        f"from '{source}' to '{dest}':"
6147                        f"\n  {repr(aVal)}"
6148                    )
6149            elif attr == "label":  # We ignore the short-label
6150                pass
6151            elif attr == "fullLabel":  # This is our transition name
6152                label = aVal
6153            elif attr == "reciprocal":
6154                reciprocal = aVal
6155            elif attr == "req":
6156                reqAbbr = aVal
6157                if reqAbbr not in reqMap:
6158                    raise DotParseError(
6159                        f"Edge from '{source}' to '{dest}' has"
6160                        f" requirement abbreviation '{reqAbbr}'"
6161                        f" but that abbreviation was not listed"
6162                        f" in the '__requirements__' subgraph."
6163                    )
6164                requirements = reqMap[reqAbbr]
6165            elif attr == "consequence":
6166                consequenceAbbr = aVal
6167                if consequenceAbbr not in reqMap:
6168                    raise DotParseError(
6169                        f"Edge from '{source}' to '{dest}' has"
6170                        f" consequence abbreviation"
6171                        f" '{consequenceAbbr}' but that"
6172                        f" abbreviation was not listed in the"
6173                        f" '__consequences__' subgraph."
6174                    )
6175                consequence = consequenceMap[consequenceAbbr]
6176            elif attr == "annotations":
6177                try:
6178                    annotations = fromJSON(aVal)
6179                except json.decoder.JSONDecodeError:
6180                    raise DotParseError(
6181                        f"Invalid JSON in edge annotations for"
6182                        f" edge from '{source}' to '{dest}':"
6183                        f"\n  {repr(aVal)}"
6184                    )
6185            else:
6186                raise DotParseError(
6187                    f"Unrecognized edge attribute '{attr}' for edge"
6188                    f" from '{source}' to '{dest}'"
6189                )
6190
6191        if label is None:
6192            raise DotParseError(
6193                f"Edge from '{source}' to '{dest}' is missing"
6194                f" a 'fullLabel' attribute."
6195            )
6196
6197        # Add the requested transition
6198        result.addTransition(
6199            source,
6200            label,
6201            dest,
6202            tags=tags,
6203            annotations=annotations,
6204            requires=requirements,  # None works here
6205            consequence=consequence  # None works here
6206        )
6207        # Either we're first or our reciprocal is, so this will only
6208        # trigger for one of the pair
6209        if reciprocal is not None:
6210            recipDest = result.getDestination(dest, reciprocal)
6211            if recipDest is None:
6212                recipChecks[(source, label)] = reciprocal
6213                # we'll get set as a reciprocal when that edge is
6214                # instantiated, we hope, but let's check that later
6215            elif recipDest != source:
6216                raise DotParseError(
6217                    f"Transition '{label}' from '{source}' to"
6218                    f" '{dest}' lists reciprocal '{reciprocal}'"
6219                    f" but that transition from '{dest}' goes to"
6220                    f" '{recipDest}', not '{source}'."
6221                )
6222            else:
6223                # At this point we know the reciprocal edge exists
6224                # and has the appropriate destination (our source).
6225                # No need to check for a pre-existing reciprocal as
6226                # this edge is newly created and cannot already have
6227                # a reciprocal assigned.
6228                result.setReciprocal(source, label, reciprocal)
6229
6230    # Double-check skipped reciprocals
6231    for ((source, transition), reciprocal) in recipChecks.items():
6232        actual = result.getReciprocal(source, transition)
6233        if actual != reciprocal:
6234            raise DotParseError(
6235                f"Transition '{transition}' from '{source}' was"
6236                f" expecting to have reciprocal '{reciprocal}' but"
6237                f" all edges have been processed and its reciprocal"
6238                f" is {repr(actual)}."
6239            )
6240
6241    # Finally get graph-level attribute values
6242    for (name, value) in graphStuff['attrs']:
6243        if name == "unknownCount":
6244            try:
6245                result.unknownCount = int(value)
6246            except ValueError:
6247                raise DotParseError(
6248                    f"Invalid 'unknownCount' value {repr(value)}."
6249                )
6250        elif name == "nextID":
6251            try:
6252                result.nextID = int(value)
6253            except ValueError:
6254                raise DotParseError(
6255                    f"Invalid 'nextID' value:"
6256                    f"\n  {repr(value)}"
6257                )
6258            collisionCourse = [x for x in result if x >= result.nextID]
6259            if len(collisionCourse) > 0:
6260                raise DotParseError(
6261                    f"Next ID {value} is wrong because the graph"
6262                    f" already contains one or more node(s) with"
6263                    f" ID(s) that is/are at least that large:"
6264                    f" {collisionCourse}"
6265                )
6266        elif name == "nextMechanismID":
6267            try:
6268                result.nextMechanismID = int(value)
6269            except ValueError:
6270                raise DotParseError(
6271                    f"Invalid 'nextMechanismID' value:"
6272                    f"\n  {repr(value)}"
6273                )
6274        elif name in (
6275            "equivalences",
6276            "reversionTypes",
6277            "mechanisms",
6278            "globalMechanisms",
6279            "nameLookup"
6280        ):
6281            try:
6282                setattr(result, name, fromJSON(value))
6283            except json.decoder.JSONDecodeError:
6284                raise DotParseError(
6285                    f"Invalid JSON in '{name}' attribute:"
6286                    f"\n  {repr(value)}"
6287                )
6288        else:
6289            raise DotParseError(
6290                f"Graph has unexpected attribute '{name}'."
6291            )
6292
6293    # Final check for mechanism ID value after both mechanism ID and
6294    # mechanisms dictionary have been parsed:
6295    leftBehind = [
6296        x
6297        for x in result.mechanisms
6298        if x >= result.nextMechanismID
6299    ]
6300    if len(leftBehind) > 0:
6301        raise DotParseError(
6302            f"Next mechanism ID {value} is wrong because"
6303            f" the graph already contains one or more"
6304            f" node(s) with ID(s) that is/are at least that"
6305            f" large: {leftBehind}"
6306        )
6307
6308    # And we're done!
6309    return result

Converts a graphviz dot-format string into a core.DecisionGraph. A custom ParseFormat may be specified if desired; the default ParseFormat is used if not. Note that this relies on specific indentation schemes used by toDot so a hand-edited dot-format graph will probably not work. A DotParseError is raised if the provided string can't be parsed. Example

>>> parseDotNode(' 3 [ label="A = \\"grate:open\\"" ]')
(3, [('label', 'A = "grate:open"')])
>>> sg = '''    ... subgraph __requirements__ {
...   3 [ label="A = \\"grate:open\\"" ]
...   4 [ label="B = \\"!(helmet)\\"" ]
...   5 [ label="C = \\"helmet\\"" ]
... }'''
>>> parseDotGraphContents(sg.splitlines()[1:])
({'nodes': [(3, [('label', 'A = "grate:open"')]), (4, [('label', 'B = "!(helmet)"')]), (5, [('label', 'C = "helmet"')])], 'edges': [], 'attrs': [], 'subgraphs': []}, [])
>>> from . import core
>>> dg = core.DecisionGraph.example('simple')
>>> encoded = toDot(dg)
>>> reconstructed = parseDot(encoded)
>>> for diff in dg.listDifferences(reconstructed):
...     print(diff)
>>> reconstructed == dg
True
>>> dg = core.DecisionGraph.example('abc')
>>> encoded = toDot(dg)
>>> reconstructed = parseDot(encoded)
>>> for diff in dg.listDifferences(reconstructed):
...     print(diff)
>>> reconstructed == dg
True
>>> tg = core.DecisionGraph()
>>> tg.addDecision('A')
0
>>> tg.addDecision('B')
1
>>> tg.addTransition('A', 'up', 'B', 'down')
>>> same = parseDot('''
... digraph {
...     0 [ name=A label=A ]
...       0 -> 1 [
...         label=up
...         fullLabel=up
...         reciprocal=down
...       ]
...     1 [ name=B label=B ]
...       1 -> 0 [
...         label=down
...         fullLabel=down
...         reciprocal=up
...       ]
... }''')
>>> for diff in tg.listDifferences(same):
...     print(diff)
>>> same == tg
True
>>> pf = ParseFormat()
>>> tg.setTransitionRequirement('A', 'up', pf.parseRequirement('one|two'))
>>> tg.setConsequence(
...     'B',
...     'down',
...     [base.effect(gain="one")]
... )
>>> test = parseDot('''
...   digraph {
...     0 [ name="A = \\"one|two\\"" label="A = \\"one|two\\"" ]
...   }
... ''')
>>> list(test.nodes)
[0]
>>> test.nodes[0]['name']
'A = "one|two"'
>>> eff = (
...   r'"A = \"[{\\\"type\\\": \\\"gain\\\",'
...   r' \\\"applyTo\\\": \\\"active\\\",'
...   r' \\\"value\\\": \\\"one\\\",'
...   r' \\\"charges\\\": null, \\\"hidden\\\": false,'
...   r' \\\"delay\\\": null}]\""'
... )
>>> utils.unquoted(eff)[1]
''
>>> test2 = parseDot(
...     'digraph {\n 0 [ name=' + eff + ' label=' + eff + ' ]\n}'
... )
>>> s = test2.nodes[0]['name']
>>> s[:25]
'A = "[{\\"type\\": \\"gain\\"'
>>> s[25:50]
', \\"applyTo\\": \\"active\\"'
>>> s[50:70]
', \\"value\\": \\"one\\"'
>>> s[70:89]
', \\"charges\\": null'
>>> s[89:108]
', \\"hidden\\": false'
>>> s[108:]
', \\"delay\\": null}]"'
>>> ae = s[s.index('=') + 1:].strip()
>>> uq, after = utils.unquoted(ae)
>>> after
''
>>> fromJSON(uq) == [base.effect(gain="one")]
True
>>> same = parseDot('''
... digraph {
...   0 [ name=A label=A ]
...     0 -> 1 [
...       label=up
...       fullLabel=up
...       reciprocal=down
...       req=A
...     ]
...   1 [ name=B label=B ]
...     1 -> 0 [
...       label=down
...       fullLabel=down
...       reciprocal=up
...       consequence=A
...     ]
...   subgraph __requirements__ {
...     2 [ label="A = \\"one|two\\"" ]
...   }
...   subgraph __consequences__ {
...     3 [ label=''' + eff + ''' ]
...   }
... }''')
>>> c = {'tags': {}, 'annotations': [], 'reciprocal': 'up', 'consequence': [{'type': 'gain', 'applyTo': 'active', 'value': 'one', 'delay': None, 'charges': None}]}['consequence']  # noqa
>>> for diff in tg.listDifferences(same):
...     print(diff)
>>> same == tg
True
def toDot( graph: exploration.core.DecisionGraph, clusterLevels: Union[str, List[int]] = [0]) -> str:
6312def toDot(
6313    graph: core.DecisionGraph,
6314    clusterLevels: Union[str, List[int]] = [0]
6315) -> str:
6316    """
6317    Converts the decision graph into a "dot"-format string suitable
6318    for processing by `graphviz`.
6319
6320    See [the dot language
6321    specification](https://graphviz.org/doc/info/lang.html) for more
6322    detail on the syntax we convert to.
6323
6324    If `clusterLevels` is given, it should be either the string '*',
6325    or a list of integers. '*' means that all zone levels should be
6326    cluster-style subgraphs, while a list of integers specifies that
6327    zones at those levels should be cluster-style subgraphs. This
6328    will prefix the subgraph names with 'cluster_' instead of just
6329    '_'.
6330
6331    TODO: Check edge cases for quotes in capability names, tag names,
6332    transition names, annotations, etc.
6333
6334    TODO: At least colons not allowed in tag names!
6335
6336    TODO: Spaces in decision/transition names? Other special
6337    characters in those names?
6338    """
6339    # Set up result including unknownCount and nextID
6340    result = (
6341        f"digraph {{"
6342        f"\n  unknownCount={graph.unknownCount}"
6343        f"\n  nextID={graph.nextID}"
6344        f"\n  nextMechanismID={graph.nextMechanismID}"
6345        f"\n"
6346    )
6347
6348    # Dictionaries for using letters to substitute for unique
6349    # requirements/consequences found throughout the graph. Keys are
6350    # quoted requirement or consequence reprs, and values are
6351    # abbreviation strings for them.
6352    currentReqKey = utils.nextAbbrKey(None)
6353    currentEffectKey = utils.nextAbbrKey(None)
6354    reqKeys: Dict[str, str] = {}
6355    consequenceKeys: Dict[str, str] = {}
6356
6357    # Add all decision and transition info
6358    decision: base.DecisionID  # TODO: Fix Multidigraph type stubs
6359    for decision in graph.nodes:
6360        nodeInfo = graph.nodes[decision]
6361        tags = nodeInfo.get('tags', {})
6362        annotations = toJSON(nodeInfo.get('annotations', []))
6363        zones = nodeInfo.get('zones', set())
6364        nodeAttrs = f"\n    name={utils.quoted(nodeInfo['name'])}"
6365        immediateZones = [z for z in zones if graph.zoneHierarchyLevel(z) == 0]
6366        if len(immediateZones) > 0:
6367            useZone = sorted(immediateZones)[0]
6368            # TODO: Don't hardcode :: here?
6369            withZone = useZone + "::" + nodeInfo['name']
6370            nodeAttrs += f"\n    label={utils.quoted(withZone)}"
6371        else:
6372            nodeAttrs += f"\n    label={utils.quoted(nodeInfo['name'])}"
6373        for tag, value in tags.items():
6374            rep = utils.quoted(toJSON(value))
6375            nodeAttrs += f"\n    t_{tag}={rep}"
6376        for z in sorted(zones):
6377            nodeAttrs += f"\n    z_{z}=1"
6378        if annotations:
6379            nodeAttrs += '\n    annotations=' + utils.quoted(annotations)
6380
6381        result += f'\n  {decision} [{nodeAttrs}\n  ]'
6382
6383        for (transition, destination) in graph._byEdge[decision].items():
6384            edgeAttrs = (
6385                '\n      label='
6386              + utils.quoted(utils.abbr(transition))
6387            )
6388            edgeAttrs += (
6389                '\n      fullLabel='
6390              + utils.quoted(transition)
6391            )
6392            reciprocal = graph.getReciprocal(decision, transition)
6393            if reciprocal is not None:
6394                edgeAttrs += (
6395                    '\n      reciprocal='
6396                  + utils.quoted(reciprocal)
6397                )
6398            info = graph.edges[
6399                decision,  # type:ignore
6400                destination,
6401                transition
6402            ]
6403            if 'requirement' in info:
6404                # Get string rep for requirement
6405                rep = utils.quoted(info['requirement'].unparse())
6406                # Get assigned abbreviation or assign one
6407                if rep in reqKeys:
6408                    ab = reqKeys[rep]
6409                else:
6410                    ab = currentReqKey
6411                    reqKeys[rep] = ab
6412                    currentReqKey = utils.nextAbbrKey(currentReqKey)
6413                # Add abbreviation as edge attribute
6414                edgeAttrs += f'\n      req={ab}'
6415            if 'consequence' in info:
6416                # Get string representation of consequences
6417                rep = utils.quoted(
6418                    toJSON(info['consequence'])
6419                )
6420                # Get abbreviation for that or assign one:
6421                if rep in consequenceKeys:
6422                    ab = consequenceKeys[rep]
6423                else:
6424                    ab = currentEffectKey
6425                    consequenceKeys[rep] = ab
6426                    currentEffectKey = utils.nextAbbrKey(
6427                        currentEffectKey
6428                    )
6429                # Add abbreviation as an edge attribute
6430                edgeAttrs += f'\n      consequence={ab}'
6431            for (tag, value) in info["tags"].items():
6432                # Get string representation of tag value
6433                rep = utils.quoted(toJSON(value))
6434                # Add edge attribute for tag
6435                edgeAttrs += f'\n      t_{tag}={rep}'
6436            if 'annotations' in info:
6437                edgeAttrs += (
6438                    '\n      annotations='
6439                  + utils.quoted(toJSON(info['annotations']))
6440                )
6441            result += f'\n    {decision} -> {destination}'
6442            result += f' [{edgeAttrs}\n    ]'
6443
6444    # Add zone info as subgraph structure
6445    for z, zinfo in graph.zones.items():
6446        parents = utils.quoted(toJSON(sorted(zinfo.parents)))
6447        if clusterLevels == '*' or zinfo.level in clusterLevels:
6448            zName = "cluster_" + z
6449        else:
6450            zName = '_' + z
6451        zoneSubgraph = f'\n  subgraph {utils.quoted(zName)} {{'
6452        zoneSubgraph += f'\n    label={z}'
6453        zoneSubgraph += f'\n    level={zinfo.level}'
6454        zoneSubgraph += f'\n    parents={parents}'
6455        for decision in sorted(graph.allDecisionsInZone(z)):
6456            zoneSubgraph += f'\n    {decision}'
6457        zoneSubgraph += '\n  }'
6458        result += zoneSubgraph
6459
6460    # Add equivalences, mechanisms, etc.
6461    for attr in [
6462        "equivalences",
6463        "reversionTypes",
6464        "mechanisms",
6465        "globalMechanisms",
6466        "nameLookup"
6467    ]:
6468        aRep = utils.quoted(toJSON(getattr(graph, attr)))
6469        result += f'\n  {attr}={aRep}'
6470
6471    # Add legend subgraphs to represent abbreviations
6472    useID = graph.nextID
6473    if reqKeys:
6474        result += '\n  subgraph __requirements__ {'
6475        for rrepr, ab in reqKeys.items():
6476            nStr = utils.quoted(ab + ' = ' + rrepr)
6477            result += (
6478                f"\n    {useID} [ label={nStr} ]"
6479            )
6480            useID += 1
6481        result += '\n  }'
6482
6483    if consequenceKeys:
6484        result += '\n  subgraph __consequences__ {'
6485        for erepr, ab in consequenceKeys.items():
6486            nStr = utils.quoted(ab + ' = ' + erepr)
6487            result += (
6488                f"\n    {useID} [ label={nStr} ]"
6489            )
6490            useID += 1
6491        result += '\n  }'
6492
6493    if graph.mechanisms:
6494        result += '\n  subgraph __mechanisms__ {'
6495        mID: base.MechanismID
6496        mWhere: Optional[base.DecisionID]
6497        mName: base.MechanismName
6498        for (mID, (mWhere, mName)) in graph.mechanisms.items():
6499            qName = utils.quoted(mName)
6500            nStr = utils.quoted(f"{mID}@{mWhere}:{qName}")
6501            result += (
6502                f"\n    {useID} [ label={nStr} ]"
6503            )
6504            useID += 1
6505        result += '\n  }'
6506
6507    result += "\n}\n"
6508    return result

Converts the decision graph into a "dot"-format string suitable for processing by graphviz.

See the dot language specification for more detail on the syntax we convert to.

If clusterLevels is given, it should be either the string '', or a list of integers. '' means that all zone levels should be cluster-style subgraphs, while a list of integers specifies that zones at those levels should be cluster-style subgraphs. This will prefix the subgraph names with 'cluster_' instead of just '_'.

TODO: Check edge cases for quotes in capability names, tag names, transition names, annotations, etc.

TODO: At least colons not allowed in tag names!

TODO: Spaces in decision/transition names? Other special characters in those names?

T = ~T

Type var for loadCustom.

def loadCustom(stream: <class 'TextIO'>, loadAs: Type[~T]) -> ~T:
6519def loadCustom(stream: TextIO, loadAs: Type[T]) -> T:
6520    """
6521    Loads a new JSON-encodable object from the JSON data in the
6522    given text stream (e.g., a file open in read mode). See
6523    `CustomJSONDecoder` for details on the format and which object types
6524    are supported.
6525
6526    This casts the result to the specified type, but errors out with a
6527    `TypeError` if it doesn't match.
6528    """
6529    result = json.load(stream, cls=CustomJSONDecoder)
6530    if isinstance(result, loadAs):
6531        return result
6532    else:
6533        raise TypeError(
6534            f"Expected to load a {loadAs} but got a {type(result)}."
6535        )

Loads a new JSON-encodable object from the JSON data in the given text stream (e.g., a file open in read mode). See CustomJSONDecoder for details on the format and which object types are supported.

This casts the result to the specified type, but errors out with a TypeError if it doesn't match.

def saveCustom( toSave: Union[exploration.base.MetricSpace, exploration.core.DecisionGraph, exploration.core.DiscreteExploration], stream: <class 'TextIO'>) -> None:
6538def saveCustom(
6539    toSave: Union[  # TODO: More in this union?
6540        base.MetricSpace,
6541        core.DecisionGraph,
6542        core.DiscreteExploration,
6543    ],
6544    stream: TextIO
6545) -> None:
6546    """
6547    Saves a JSON-encodable object as JSON into the given text stream
6548    (e.g., a file open in writing mode). See `CustomJSONEncoder` for
6549    details on the format and which types are supported..
6550    """
6551    json.dump(toSave, stream, cls=CustomJSONEncoder)

Saves a JSON-encodable object as JSON into the given text stream (e.g., a file open in writing mode). See CustomJSONEncoder for details on the format and which types are supported..

def toJSON(obj: Any) -> str:
6554def toJSON(obj: Any) -> str:
6555    """
6556    Defines the standard object -> JSON operation using the
6557    `CustomJSONEncoder` as well as not using `sort_keys`.
6558    """
6559    return CustomJSONEncoder(sort_keys=False).encode(obj)

Defines the standard object -> JSON operation using the CustomJSONEncoder as well as not using sort_keys.

def fromJSON(encoded: str) -> Any:
6562def fromJSON(encoded: str) -> Any:
6563    """
6564    Defines the standard JSON -> object operation using
6565    `CustomJSONDecoder`.
6566    """
6567    return json.loads(encoded, cls=CustomJSONDecoder)

Defines the standard JSON -> object operation using CustomJSONDecoder.

class CustomJSONEncoder(json.encoder.JSONEncoder):
6570class CustomJSONEncoder(json.JSONEncoder):
6571    """
6572    A custom JSON encoder that has special protocols for handling the
6573    smae objects that `CustomJSONDecoder` decodes. It handles these
6574    objects specially so that they can be decoded back to their original
6575    form.
6576
6577    Examples:
6578
6579    >>> from . import core
6580    >>> tupList = [(1, 1), (2, 2)]
6581    >>> encTup = toJSON(tupList)
6582    >>> encTup
6583    '[{"^^d": "t", "values": [1, 1]}, {"^^d": "t", "values": [2, 2]}]'
6584    >>> fromJSON(encTup) == tupList
6585    True
6586    >>> dg = core.DecisionGraph.example('simple')
6587    >>> fromJSON(toJSON(dg)) == dg
6588    True
6589    >>> dg = core.DecisionGraph.example('abc')
6590    >>> zi = dg.getZoneInfo('upZone')
6591    >>> zi
6592    ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={},\
6593 annotations=[])
6594    >>> zj = toJSON(zi)
6595    >>> zj
6596    '{"^^d": "nt", "name": "ZoneInfo", "values":\
6597 {"level": 1, "parents": {"^^d": "s", "values": []},\
6598 "contents": {"^^d": "s", "values": ["zoneA"]}, "tags": {},\
6599 "annotations": []}}'
6600    >>> fromJSON(toJSON(zi))
6601    ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={},\
6602 annotations=[])
6603    >>> fromJSON(toJSON(zi)) == zi
6604    True
6605    >>> toJSON({'a': 'b', 1: 2})
6606    '{"^^d": "d", "items": [["a", "b"], [1, 2]]}'
6607    >>> toJSON(((1, 2), (3, 4)))
6608    '{"^^d": "t", "values": [{"^^d": "t", "values": [1, 2]},\
6609 {"^^d": "t", "values": [3, 4]}]}'
6610    >>> toJSON(base.effect(set=('grate', 'open')))
6611    '{"type": "set", "applyTo": "active",\
6612 "value": {"^^d": "t",\
6613 "values": [{"^^d": "nt", "name": "MechanismSpecifier",\
6614 "values": {"domain": null, "zone": null, "decision": null, "name": "grate"}},\
6615 "open"]}, "delay": null, "charges": null, "hidden": false}'
6616    >>> j = toJSON(dg)
6617    >>> expected = (
6618    ... '{"^^d": "DG",'
6619    ... ' "props": {},'
6620    ... ' "node_links": {"directed": true,'
6621    ... ' "multigraph": true,'
6622    ... ' "graph": {},'
6623    ... ' "nodes": ['
6624    ... '{"name": "A", "domain": "main", "tags": {},'
6625    ... ' "annotations": ["This is a multi-word \\\\"annotation.\\\\""],'
6626    ... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
6627    ... ' "mechanisms": {"grate": 0},'
6628    ... ' "id": 0'
6629    ... '},'
6630    ... ' {'
6631    ... '"name": "B",'
6632    ... ' "domain": "main",'
6633    ... ' "tags": {"b": 1, "tag2": "\\\\"value\\\\""},'
6634    ... ' "annotations": [],'
6635    ... ' "zones": {"^^d": "s", "values": ["zoneB"]},'
6636    ... ' "id": 1'
6637    ... '},'
6638    ... ' {'
6639    ... '"name": "C",'
6640    ... ' "domain": "main",'
6641    ... ' "tags": {"aw\\\\"ful": "ha\\'ha"},'
6642    ... ' "annotations": [],'
6643    ... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
6644    ... ' "id": 2'
6645    ... '}'
6646    ... '],'
6647    ... ' "links": ['
6648    ... '{'
6649    ... '"tags": {},'
6650    ... ' "annotations": [],'
6651    ... ' "reciprocal": "right",'
6652    ... ' "source": 0,'
6653    ... ' "target": 1,'
6654    ... ' "key": "left"'
6655    ... '},'
6656    ... ' {'
6657    ... '"tags": {},'
6658    ... ' "annotations": [],'
6659    ... ' "reciprocal": "up_right",'
6660    ... ' "requirement": {"^^d": "R", "value": "grate:open"},'
6661    ... ' "source": 0,'
6662    ... ' "target": 1,'
6663    ... ' "key": "up_left"'
6664    ... '},'
6665    ... ' {'
6666    ... '"tags": {},'
6667    ... ' "annotations": ["Transition \\'annotation.\\'"],'
6668    ... ' "reciprocal": "up",'
6669    ... ' "source": 0,'
6670    ... ' "target": 2,'
6671    ... ' "key": "down"'
6672    ... '},'
6673    ... ' {'
6674    ... '"tags": {},'
6675    ... ' "annotations": [],'
6676    ... ' "reciprocal": "left",'
6677    ... ' "source": 1,'
6678    ... ' "target": 0,'
6679    ... ' "key": "right"'
6680    ... '},'
6681    ... ' {'
6682    ... '"tags": {},'
6683    ... ' "annotations": [],'
6684    ... ' "reciprocal": "up_left",'
6685    ... ' "requirement": {"^^d": "R", "value": "grate:open"},'
6686    ... ' "source": 1,'
6687    ... ' "target": 0,'
6688    ... ' "key": "up_right"'
6689    ... '},'
6690    ... ' {'
6691    ... '"tags": {"fast": 1},'
6692    ... ' "annotations": [],'
6693    ... ' "reciprocal": "down",'
6694    ... ' "source": 2,'
6695    ... ' "target": 0,'
6696    ... ' "key": "up"'
6697    ... '},'
6698    ... ' {'
6699    ... '"tags": {},'
6700    ... ' "annotations": [],'
6701    ... ' "requirement": {"^^d": "R", "value": "!(helmet)"},'
6702    ... ' "consequence": ['
6703    ... '{'
6704    ... '"type": "gain", "applyTo": "active", "value": "helmet",'
6705    ... ' "delay": null, "charges": null, "hidden": false'
6706    ... '},'
6707    ... ' {'
6708    ... '"type": "deactivate",'
6709    ... ' "applyTo": "active", "value": null,'
6710    ... ' "delay": 3, "charges": null, "hidden": false'
6711    ... '}'
6712    ... '],'
6713    ... ' "source": 2,'
6714    ... ' "target": 2,'
6715    ... ' "key": "grab_helmet"'
6716    ... '},'
6717    ... ' {'
6718    ... '"tags": {},'
6719    ... ' "annotations": [],'
6720    ... ' "requirement": {"^^d": "R", "value": "helmet"},'
6721    ... ' "consequence": ['
6722    ... '{"type": "lose", "applyTo": "active", "value": "helmet",'
6723    ... ' "delay": null, "charges": null, "hidden": false},'
6724    ... ' {"type": "gain", "applyTo": "active",'
6725    ... ' "value": {"^^d": "t", "values": ["token", 1]},'
6726    ... ' "delay": null, "charges": null, "hidden": false'
6727    ... '},'
6728    ... ' {"condition":'
6729    ... ' {"^^d": "R", "value": "token*2"},'
6730    ... ' "consequence": ['
6731    ... '{"type": "set", "applyTo": "active",'
6732    ... ' "value": {"^^d": "t", "values": ['
6733    ... '{"^^d": "nt", "name": "MechanismSpecifier",'
6734    ... ' "values": {"domain": null, "zone": null, "decision": null,'
6735    ... ' "name": "grate"}}, "open"]},'
6736    ... ' "delay": null, "charges": null, "hidden": false'
6737    ... '},'
6738    ... ' {"type": "deactivate", "applyTo": "active", "value": null,'
6739    ... ' "delay": null, "charges": null, "hidden": false'
6740    ... '}'
6741    ... '],'
6742    ... ' "alternative": []'
6743    ... '}'
6744    ... '],'
6745    ... ' "source": 2,'
6746    ... ' "target": 2,'
6747    ... ' "key": "pull_lever"'
6748    ... '}'
6749    ... ']'
6750    ... '},'
6751    ... ' "_byEdge": {"^^d": "d", "items":'
6752    ... ' [[0, {"left": 1, "up_left": 1, "down": 2}],'
6753    ... ' [1, {"right": 0, "up_right": 0}],'
6754    ... ' [2, {"up": 0, "grab_helmet": 2, "pull_lever": 2}]]},'
6755    ... ' "zones": {"zoneA":'
6756    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6757    ... ' "values": {'
6758    ... '"level": 0,'
6759    ... ' "parents": {"^^d": "s", "values": ["upZone"]},'
6760    ... ' "contents": {"^^d": "s", "values": [0, 2]},'
6761    ... ' "tags": {},'
6762    ... ' "annotations": []'
6763    ... '}'
6764    ... '},'
6765    ... ' "zoneB":'
6766    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6767    ... ' "values": {'
6768    ... '"level": 0,'
6769    ... ' "parents": {"^^d": "s", "values": []},'
6770    ... ' "contents": {"^^d": "s", "values": [1]},'
6771    ... ' "tags": {},'
6772    ... ' "annotations": []'
6773    ... '}'
6774    ... '},'
6775    ... ' "upZone":'
6776    ... ' {"^^d": "nt", "name": "ZoneInfo",'
6777    ... ' "values": {'
6778    ... '"level": 1,'
6779    ... ' "parents": {"^^d": "s", "values": []},'
6780    ... ' "contents": {"^^d": "s", "values": ["zoneA"]},'
6781    ... ' "tags": {},'
6782    ... ' "annotations": []'
6783    ... '}'
6784    ... '}'
6785    ... '},'
6786    ... ' "unknownCount": 0,'
6787    ... ' "equivalences": {"^^d": "d", "items": ['
6788    ... '[{"^^d": "t", "values": [0, "open"]},'
6789    ... ' {"^^d": "s", "values": ['
6790    ... '{"^^d": "R", "value": "helmet"}]}]'
6791    ... ']},'
6792    ... ' "reversionTypes": {},'
6793    ... ' "nextID": 3,'
6794    ... ' "nextMechanismID": 1,'
6795    ... ' "mechanisms": {"^^d": "d", "items": ['
6796    ... '[0, {"^^d": "t", "values": [0, "grate"]}]]},'
6797    ... ' "globalMechanisms": {},'
6798    ... ' "nameLookup": {"A": [0], "B": [1], "C": [2]}'
6799    ... '}'
6800    ... )
6801    >>> for i in range(len(j)):
6802    ...     if j[i] != expected[i:i+1]:
6803    ...         print(
6804    ...             'exp: ' + expected[i-10:i+50] + '\\ngot: ' + j[i-10:i+50]
6805    ...         )
6806    ...         break
6807    >>> j == expected
6808    True
6809    >>> rec = fromJSON(j)
6810    >>> rec.nodes == dg.nodes
6811    True
6812    >>> rec.edges == dg.edges
6813    True
6814    >>> rec.unknownCount == dg.unknownCount
6815    True
6816    >>> rec.equivalences == dg.equivalences
6817    True
6818    >>> rec.reversionTypes == dg.reversionTypes
6819    True
6820    >>> rec._byEdge == dg._byEdge
6821    True
6822    >>> rec.zones == dg.zones
6823    True
6824    >>> for diff in dg.listDifferences(rec):
6825    ...     print(diff)
6826    >>> rec == dg
6827    True
6828
6829    `base.MetricSpace` example:
6830
6831    >>> ms = base.MetricSpace("test")
6832    >>> ms.addPoint([2, 3])
6833    0
6834    >>> ms.addPoint([2, 7, 0])
6835    1
6836    >>> ms.addPoint([2, 7])
6837    2
6838    >>> toJSON(ms)
6839    '{"^^d": "MS", "name": "test",\
6840 "points": {"^^d": "d", "items": [[0, [2, 3]], [1, [2, 7,\
6841 0]], [2, [2, 7]]]}, "lastID": 2}'
6842    >>> ms.removePoint(0)
6843    >>> ms.removePoint(1)
6844    >>> ms.removePoint(2)
6845    >>> toJSON(ms)
6846    '{"^^d": "MS", "name": "test", "points": {}, "lastID": 2}'
6847    >>> ms.addPoint([5, 6])
6848    3
6849    >>> ms.addPoint([7, 8])
6850    4
6851    >>> toJSON(ms)
6852    '{"^^d": "MS", "name": "test",\
6853 "points": {"^^d": "d", "items": [[3, [5, 6]], [4, [7, 8]]]}, "lastID": 4}'
6854
6855    # TODO: more examples, including one for a DiscreteExploration
6856    """
6857
6858    def default(self, o: Any) -> Any:
6859        """
6860        Re-writes objects for encoding. We re-write the following
6861        objects:
6862
6863        - `set`
6864        - `dict` (if the keys aren't all strings)
6865        - `tuple`/`namedtuple`
6866        - `ZoneInfo`
6867        - `Requirement`
6868        - `SkillCombination`
6869        - `DecisionGraph`
6870        - `DiscreteExploration`
6871        - `MetricSpace`
6872
6873        TODO: FeatureGraph...
6874        """
6875        if isinstance(o, list):
6876            return [self.default(x) for x in o]
6877
6878        elif isinstance(o, set):
6879            return {
6880                '^^d': 's',
6881                'values': sorted(
6882                    [self.default(e) for e in o],
6883                    key=lambda x: str(x)
6884                )
6885            }
6886
6887        elif isinstance(o, dict):
6888            if all(isinstance(k, str) for k in o):
6889                return {
6890                    k: self.default(v)
6891                    for k, v in o.items()
6892                }
6893            else:
6894                return {
6895                    '^^d': 'd',
6896                    'items': [
6897                        [self.default(k), self.default(v)]
6898                        for (k, v) in o.items()
6899                    ]
6900                }
6901
6902        elif isinstance(o, tuple):
6903            if hasattr(o, '_fields') and hasattr(o, '_asdict'):
6904                # Named tuple
6905                return {
6906                    '^^d': 'nt',
6907                    'name': o.__class__.__name__,
6908                    'values': {
6909                        k: self.default(v)
6910                        for k, v in o._asdict().items()
6911                    }
6912                }
6913            else:
6914                # Normal tuple
6915                return {
6916                    '^^d': 't',
6917                    "values": [self.default(e) for e in o]
6918                }
6919
6920        elif isinstance(o, base.Requirement):
6921            return {
6922                '^^d': 'R',
6923                'value': o.unparse()
6924            }
6925
6926        elif isinstance(o, base.SkillCombination):
6927            return {
6928                '^^d': 'SC',
6929                'value': o.unparse()
6930            }
6931        # TODO: Consequence, Condition, Challenge, and Effect here?
6932
6933        elif isinstance(o, core.DecisionGraph):
6934            return {
6935                '^^d': 'DG',
6936                'props': self.default(o.graph),  # type:ignore [attr-defined]
6937                'node_links': self.default(
6938                    networkx.node_link_data(o, edges="links") # type: ignore
6939                    # TODO: Fix networkx stubs
6940                ),
6941                '_byEdge': self.default(o._byEdge),
6942                'zones': self.default(o.zones),
6943                'unknownCount': o.unknownCount,
6944                'equivalences': self.default(o.equivalences),
6945                'reversionTypes': self.default(o.reversionTypes),
6946                'nextID': o.nextID,
6947                'nextMechanismID': o.nextMechanismID,
6948                'mechanisms': self.default(o.mechanisms),
6949                'globalMechanisms': self.default(o.globalMechanisms),
6950                'nameLookup': self.default(o.nameLookup)
6951            }
6952
6953        elif isinstance(o, core.DiscreteExploration):
6954            return {
6955                '^^d': 'DE',
6956                'situations': self.default(o.situations)
6957            }
6958
6959        elif isinstance(o, base.MetricSpace):
6960            return {
6961                '^^d': 'MS',
6962                'name': o.name,
6963                'points': self.default(o.points),
6964                'lastID': o.lastID()
6965            }
6966
6967        else:
6968            return o
6969
6970    def encode(self, o: Any) -> str:
6971        """
6972        Custom encode function since we need to override behavior for
6973        tuples and dicts.
6974        """
6975        if isinstance(o, (tuple, dict, set)):
6976            o = self.default(o)
6977        elif isinstance(o, list):
6978            o = [self.default(x) for x in o]
6979
6980        try:
6981            return super().encode(o)
6982        except TypeError:
6983            return super().encode(self.default(o))
6984
6985    def iterencode(
6986        self,
6987        o: Any,
6988        _one_shot: bool = False
6989    ) -> Generator[str, None, None]:
6990        """
6991        Custom iterencode function since we need to override behavior for
6992        tuples and dicts.
6993        """
6994        if isinstance(o, (tuple, dict)):
6995            o = self.default(o)
6996
6997        yield from super().iterencode(o, _one_shot=_one_shot)

A custom JSON encoder that has special protocols for handling the smae objects that CustomJSONDecoder decodes. It handles these objects specially so that they can be decoded back to their original form.

Examples:

>>> from . import core
>>> tupList = [(1, 1), (2, 2)]
>>> encTup = toJSON(tupList)
>>> encTup
'[{"^^d": "t", "values": [1, 1]}, {"^^d": "t", "values": [2, 2]}]'
>>> fromJSON(encTup) == tupList
True
>>> dg = core.DecisionGraph.example('simple')
>>> fromJSON(toJSON(dg)) == dg
True
>>> dg = core.DecisionGraph.example('abc')
>>> zi = dg.getZoneInfo('upZone')
>>> zi
ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={}, annotations=[])
>>> zj = toJSON(zi)
>>> zj
'{"^^d": "nt", "name": "ZoneInfo", "values": {"level": 1, "parents": {"^^d": "s", "values": []}, "contents": {"^^d": "s", "values": ["zoneA"]}, "tags": {}, "annotations": []}}'
>>> fromJSON(toJSON(zi))
ZoneInfo(level=1, parents=set(), contents={'zoneA'}, tags={}, annotations=[])
>>> fromJSON(toJSON(zi)) == zi
True
>>> toJSON({'a': 'b', 1: 2})
'{"^^d": "d", "items": [["a", "b"], [1, 2]]}'
>>> toJSON(((1, 2), (3, 4)))
'{"^^d": "t", "values": [{"^^d": "t", "values": [1, 2]}, {"^^d": "t", "values": [3, 4]}]}'
>>> toJSON(base.effect(set=('grate', 'open')))
'{"type": "set", "applyTo": "active", "value": {"^^d": "t", "values": [{"^^d": "nt", "name": "MechanismSpecifier", "values": {"domain": null, "zone": null, "decision": null, "name": "grate"}}, "open"]}, "delay": null, "charges": null, "hidden": false}'
>>> j = toJSON(dg)
>>> expected = (
... '{"^^d": "DG",'
... ' "props": {},'
... ' "node_links": {"directed": true,'
... ' "multigraph": true,'
... ' "graph": {},'
... ' "nodes": ['
... '{"name": "A", "domain": "main", "tags": {},'
... ' "annotations": ["This is a multi-word \\"annotation.\\""],'
... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
... ' "mechanisms": {"grate": 0},'
... ' "id": 0'
... '},'
... ' {'
... '"name": "B",'
... ' "domain": "main",'
... ' "tags": {"b": 1, "tag2": "\\"value\\""},'
... ' "annotations": [],'
... ' "zones": {"^^d": "s", "values": ["zoneB"]},'
... ' "id": 1'
... '},'
... ' {'
... '"name": "C",'
... ' "domain": "main",'
... ' "tags": {"aw\\"ful": "ha\'ha"},'
... ' "annotations": [],'
... ' "zones": {"^^d": "s", "values": ["zoneA"]},'
... ' "id": 2'
... '}'
... '],'
... ' "links": ['
... '{'
... '"tags": {},'
... ' "annotations": [],'
... ' "reciprocal": "right",'
... ' "source": 0,'
... ' "target": 1,'
... ' "key": "left"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": [],'
... ' "reciprocal": "up_right",'
... ' "requirement": {"^^d": "R", "value": "grate:open"},'
... ' "source": 0,'
... ' "target": 1,'
... ' "key": "up_left"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": ["Transition \'annotation.\'"],'
... ' "reciprocal": "up",'
... ' "source": 0,'
... ' "target": 2,'
... ' "key": "down"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": [],'
... ' "reciprocal": "left",'
... ' "source": 1,'
... ' "target": 0,'
... ' "key": "right"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": [],'
... ' "reciprocal": "up_left",'
... ' "requirement": {"^^d": "R", "value": "grate:open"},'
... ' "source": 1,'
... ' "target": 0,'
... ' "key": "up_right"'
... '},'
... ' {'
... '"tags": {"fast": 1},'
... ' "annotations": [],'
... ' "reciprocal": "down",'
... ' "source": 2,'
... ' "target": 0,'
... ' "key": "up"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": [],'
... ' "requirement": {"^^d": "R", "value": "!(helmet)"},'
... ' "consequence": ['
... '{'
... '"type": "gain", "applyTo": "active", "value": "helmet",'
... ' "delay": null, "charges": null, "hidden": false'
... '},'
... ' {'
... '"type": "deactivate",'
... ' "applyTo": "active", "value": null,'
... ' "delay": 3, "charges": null, "hidden": false'
... '}'
... '],'
... ' "source": 2,'
... ' "target": 2,'
... ' "key": "grab_helmet"'
... '},'
... ' {'
... '"tags": {},'
... ' "annotations": [],'
... ' "requirement": {"^^d": "R", "value": "helmet"},'
... ' "consequence": ['
... '{"type": "lose", "applyTo": "active", "value": "helmet",'
... ' "delay": null, "charges": null, "hidden": false},'
... ' {"type": "gain", "applyTo": "active",'
... ' "value": {"^^d": "t", "values": ["token", 1]},'
... ' "delay": null, "charges": null, "hidden": false'
... '},'
... ' {"condition":'
... ' {"^^d": "R", "value": "token*2"},'
... ' "consequence": ['
... '{"type": "set", "applyTo": "active",'
... ' "value": {"^^d": "t", "values": ['
... '{"^^d": "nt", "name": "MechanismSpecifier",'
... ' "values": {"domain": null, "zone": null, "decision": null,'
... ' "name": "grate"}}, "open"]},'
... ' "delay": null, "charges": null, "hidden": false'
... '},'
... ' {"type": "deactivate", "applyTo": "active", "value": null,'
... ' "delay": null, "charges": null, "hidden": false'
... '}'
... '],'
... ' "alternative": []'
... '}'
... '],'
... ' "source": 2,'
... ' "target": 2,'
... ' "key": "pull_lever"'
... '}'
... ']'
... '},'
... ' "_byEdge": {"^^d": "d", "items":'
... ' [[0, {"left": 1, "up_left": 1, "down": 2}],'
... ' [1, {"right": 0, "up_right": 0}],'
... ' [2, {"up": 0, "grab_helmet": 2, "pull_lever": 2}]]},'
... ' "zones": {"zoneA":'
... ' {"^^d": "nt", "name": "ZoneInfo",'
... ' "values": {'
... '"level": 0,'
... ' "parents": {"^^d": "s", "values": ["upZone"]},'
... ' "contents": {"^^d": "s", "values": [0, 2]},'
... ' "tags": {},'
... ' "annotations": []'
... '}'
... '},'
... ' "zoneB":'
... ' {"^^d": "nt", "name": "ZoneInfo",'
... ' "values": {'
... '"level": 0,'
... ' "parents": {"^^d": "s", "values": []},'
... ' "contents": {"^^d": "s", "values": [1]},'
... ' "tags": {},'
... ' "annotations": []'
... '}'
... '},'
... ' "upZone":'
... ' {"^^d": "nt", "name": "ZoneInfo",'
... ' "values": {'
... '"level": 1,'
... ' "parents": {"^^d": "s", "values": []},'
... ' "contents": {"^^d": "s", "values": ["zoneA"]},'
... ' "tags": {},'
... ' "annotations": []'
... '}'
... '}'
... '},'
... ' "unknownCount": 0,'
... ' "equivalences": {"^^d": "d", "items": ['
... '[{"^^d": "t", "values": [0, "open"]},'
... ' {"^^d": "s", "values": ['
... '{"^^d": "R", "value": "helmet"}]}]'
... ']},'
... ' "reversionTypes": {},'
... ' "nextID": 3,'
... ' "nextMechanismID": 1,'
... ' "mechanisms": {"^^d": "d", "items": ['
... '[0, {"^^d": "t", "values": [0, "grate"]}]]},'
... ' "globalMechanisms": {},'
... ' "nameLookup": {"A": [0], "B": [1], "C": [2]}'
... '}'
... )
>>> for i in range(len(j)):
...     if j[i] != expected[i:i+1]:
...         print(
...             'exp: ' + expected[i-10:i+50] + '\ngot: ' + j[i-10:i+50]
...         )
...         break
>>> j == expected
True
>>> rec = fromJSON(j)
>>> rec.nodes == dg.nodes
True
>>> rec.edges == dg.edges
True
>>> rec.unknownCount == dg.unknownCount
True
>>> rec.equivalences == dg.equivalences
True
>>> rec.reversionTypes == dg.reversionTypes
True
>>> rec._byEdge == dg._byEdge
True
>>> rec.zones == dg.zones
True
>>> for diff in dg.listDifferences(rec):
...     print(diff)
>>> rec == dg
True

base.MetricSpace example:

>>> ms = base.MetricSpace("test")
>>> ms.addPoint([2, 3])
0
>>> ms.addPoint([2, 7, 0])
1
>>> ms.addPoint([2, 7])
2
>>> toJSON(ms)
'{"^^d": "MS", "name": "test", "points": {"^^d": "d", "items": [[0, [2, 3]], [1, [2, 7, 0]], [2, [2, 7]]]}, "lastID": 2}'
>>> ms.removePoint(0)
>>> ms.removePoint(1)
>>> ms.removePoint(2)
>>> toJSON(ms)
'{"^^d": "MS", "name": "test", "points": {}, "lastID": 2}'
>>> ms.addPoint([5, 6])
3
>>> ms.addPoint([7, 8])
4
>>> toJSON(ms)
'{"^^d": "MS", "name": "test", "points": {"^^d": "d", "items": [[3, [5, 6]], [4, [7, 8]]]}, "lastID": 4}'

TODO: more examples, including one for a DiscreteExploration

def default(self, o: Any) -> Any:
6858    def default(self, o: Any) -> Any:
6859        """
6860        Re-writes objects for encoding. We re-write the following
6861        objects:
6862
6863        - `set`
6864        - `dict` (if the keys aren't all strings)
6865        - `tuple`/`namedtuple`
6866        - `ZoneInfo`
6867        - `Requirement`
6868        - `SkillCombination`
6869        - `DecisionGraph`
6870        - `DiscreteExploration`
6871        - `MetricSpace`
6872
6873        TODO: FeatureGraph...
6874        """
6875        if isinstance(o, list):
6876            return [self.default(x) for x in o]
6877
6878        elif isinstance(o, set):
6879            return {
6880                '^^d': 's',
6881                'values': sorted(
6882                    [self.default(e) for e in o],
6883                    key=lambda x: str(x)
6884                )
6885            }
6886
6887        elif isinstance(o, dict):
6888            if all(isinstance(k, str) for k in o):
6889                return {
6890                    k: self.default(v)
6891                    for k, v in o.items()
6892                }
6893            else:
6894                return {
6895                    '^^d': 'd',
6896                    'items': [
6897                        [self.default(k), self.default(v)]
6898                        for (k, v) in o.items()
6899                    ]
6900                }
6901
6902        elif isinstance(o, tuple):
6903            if hasattr(o, '_fields') and hasattr(o, '_asdict'):
6904                # Named tuple
6905                return {
6906                    '^^d': 'nt',
6907                    'name': o.__class__.__name__,
6908                    'values': {
6909                        k: self.default(v)
6910                        for k, v in o._asdict().items()
6911                    }
6912                }
6913            else:
6914                # Normal tuple
6915                return {
6916                    '^^d': 't',
6917                    "values": [self.default(e) for e in o]
6918                }
6919
6920        elif isinstance(o, base.Requirement):
6921            return {
6922                '^^d': 'R',
6923                'value': o.unparse()
6924            }
6925
6926        elif isinstance(o, base.SkillCombination):
6927            return {
6928                '^^d': 'SC',
6929                'value': o.unparse()
6930            }
6931        # TODO: Consequence, Condition, Challenge, and Effect here?
6932
6933        elif isinstance(o, core.DecisionGraph):
6934            return {
6935                '^^d': 'DG',
6936                'props': self.default(o.graph),  # type:ignore [attr-defined]
6937                'node_links': self.default(
6938                    networkx.node_link_data(o, edges="links") # type: ignore
6939                    # TODO: Fix networkx stubs
6940                ),
6941                '_byEdge': self.default(o._byEdge),
6942                'zones': self.default(o.zones),
6943                'unknownCount': o.unknownCount,
6944                'equivalences': self.default(o.equivalences),
6945                'reversionTypes': self.default(o.reversionTypes),
6946                'nextID': o.nextID,
6947                'nextMechanismID': o.nextMechanismID,
6948                'mechanisms': self.default(o.mechanisms),
6949                'globalMechanisms': self.default(o.globalMechanisms),
6950                'nameLookup': self.default(o.nameLookup)
6951            }
6952
6953        elif isinstance(o, core.DiscreteExploration):
6954            return {
6955                '^^d': 'DE',
6956                'situations': self.default(o.situations)
6957            }
6958
6959        elif isinstance(o, base.MetricSpace):
6960            return {
6961                '^^d': 'MS',
6962                'name': o.name,
6963                'points': self.default(o.points),
6964                'lastID': o.lastID()
6965            }
6966
6967        else:
6968            return o

Re-writes objects for encoding. We re-write the following objects:

  • set
  • dict (if the keys aren't all strings)
  • tuple/namedtuple
  • ZoneInfo
  • Requirement
  • SkillCombination
  • DecisionGraph
  • DiscreteExploration
  • MetricSpace

TODO: FeatureGraph...

def encode(self, o: Any) -> str:
6970    def encode(self, o: Any) -> str:
6971        """
6972        Custom encode function since we need to override behavior for
6973        tuples and dicts.
6974        """
6975        if isinstance(o, (tuple, dict, set)):
6976            o = self.default(o)
6977        elif isinstance(o, list):
6978            o = [self.default(x) for x in o]
6979
6980        try:
6981            return super().encode(o)
6982        except TypeError:
6983            return super().encode(self.default(o))

Custom encode function since we need to override behavior for tuples and dicts.

def iterencode( self, o: Any, _one_shot: bool = False) -> Generator[str, NoneType, NoneType]:
6985    def iterencode(
6986        self,
6987        o: Any,
6988        _one_shot: bool = False
6989    ) -> Generator[str, None, None]:
6990        """
6991        Custom iterencode function since we need to override behavior for
6992        tuples and dicts.
6993        """
6994        if isinstance(o, (tuple, dict)):
6995            o = self.default(o)
6996
6997        yield from super().iterencode(o, _one_shot=_one_shot)

Custom iterencode function since we need to override behavior for tuples and dicts.

Inherited Members
json.encoder.JSONEncoder
JSONEncoder
item_separator
key_separator
skipkeys
ensure_ascii
check_circular
allow_nan
sort_keys
indent
class CustomJSONDecoder(json.decoder.JSONDecoder):
7000class CustomJSONDecoder(json.JSONDecoder):
7001    """
7002    A custom JSON decoder that has special protocols for handling
7003    several types, including:
7004
7005    - `set`
7006    - `tuple` & `namedtuple`
7007    - `dict` (where keys aren't all strings)
7008    - `Requirement`
7009    - `SkillCombination`
7010    - `DecisionGraph`
7011    - `DiscreteExploration`
7012    - `MetricSpace`
7013
7014    Used by `toJSON`
7015
7016    When initializing it, you can st a custom parse format by supplying
7017    a 'parseFormat' keyword argument; by default a standard
7018    `ParseFormat` will be used.
7019
7020    Examples:
7021
7022    >>> r = base.ReqAny([
7023    ...     base.ReqCapability('power'),
7024    ...     base.ReqTokens('money', 5)
7025    ... ])
7026    >>> s = toJSON(r)
7027    >>> s
7028    '{"^^d": "R", "value": "(power|money*5)"}'
7029    >>> l = fromJSON(s)
7030    >>> r == l
7031    True
7032    >>> o = {1, 2, 'hi'}
7033    >>> s = toJSON(o)
7034    >>> s
7035    '{"^^d": "s", "values": [1, 2, "hi"]}'
7036    >>> l = fromJSON(s)
7037    >>> o == l
7038    True
7039    >>> zi = base.ZoneInfo(1, set(), set(), {}, [])
7040    >>> s = toJSON(zi)
7041    >>> c = (
7042    ... '{"^^d": "nt", "name": "ZoneInfo", "values": {'
7043    ... '"level": 1,'
7044    ... ' "parents": {"^^d": "s", "values": []},'
7045    ... ' "contents": {"^^d": "s", "values": []},'
7046    ... ' "tags": {},'
7047    ... ' "annotations": []'
7048    ... '}}'
7049    ... )
7050    >>> s == c
7051    True
7052    >>> setm = base.effect(set=("door", "open"))
7053    >>> s = toJSON(setm)
7054    >>> f = fromJSON(s)
7055    >>> f == setm
7056    True
7057    >>> pf = ParseFormat()
7058    >>> pf.unparseEffect(f)
7059    'set door:open'
7060    >>> pf.unparseEffect(f) == pf.unparseEffect(setm)
7061    True
7062    >>> g = core.DecisionGraph()
7063    >>> g.addDecision('A')
7064    0
7065    >>> g.addDecision('B')
7066    1
7067    >>> g.addTransition('A', 'up', 'B', 'down')
7068    >>> g2 = fromJSON(toJSON(g))
7069    >>> g2.destinationsFrom('A')
7070    {'up': 1}
7071    >>> g2.destinationsFrom('B')
7072    {'down': 0}
7073    >>> g2.addDecision('C')
7074    2
7075    >>> g2.addTransition('A', 'right', 'C', 'left')
7076    >>> g2.destinationsFrom('A')
7077    {'up': 1, 'right': 2}
7078    >>> g2.destinationsFrom('C')
7079    {'left': 0}
7080
7081    TODO: SkillCombination example
7082    """
7083    def __init__(self, *args, **kwargs):
7084        if 'object_hook' in kwargs:
7085            outerHook = kwargs['object_hook']
7086            kwargs['object_hook'] = (
7087                lambda o: outerHook(self.unpack(o))
7088            )
7089            # TODO: What if it's a positional argument? :(
7090        else:
7091            kwargs['object_hook'] = lambda o: self.unpack(o)
7092
7093        if 'parseFormat' in kwargs:
7094            self.parseFormat = kwargs['parseFormat']
7095            del kwargs['parseFormat']
7096        else:
7097            self.parseFormat = ParseFormat()
7098
7099        super().__init__(*args, **kwargs)
7100
7101    def unpack(self, obj: Any) -> Any:
7102        """
7103        Unpacks an object; used as the `object_hook` for decoding.
7104        """
7105        if '^^d' in obj:
7106            asType = obj['^^d']
7107            if asType == 't':
7108                return tuple(obj['values'])
7109
7110            elif asType == 'nt':
7111                g = globals()
7112                name = obj['name']
7113                values = obj['values']
7114                # Use an existing global namedtuple class if there is
7115                # one that goes by the specified name, so that we don't
7116                # create too many spurious equivalent namedtuple
7117                # classes. But fall back on creating a new namedtuple
7118                # class if we need to:
7119                ntClass = g.get(name)
7120                if (
7121                    ntClass is None
7122                 or not issubclass(ntClass, tuple)
7123                 or not hasattr(ntClass, '_asdict')
7124                ):
7125                    # Now try again specifically in the base module where
7126                    # most of our nametuples are defined (TODO: NOT this
7127                    # hack..., but it does make isinstance work...)
7128                    ntClass = getattr(base, name, None)
7129                    if (
7130                        ntClass is None
7131                     or not issubclass(ntClass, tuple)
7132                     or not hasattr(ntClass, '_asdict')
7133                    ):
7134                        # TODO: cache these...
7135                        ntClass = collections.namedtuple(  # type: ignore
7136                            name,
7137                            values.keys()
7138                        )
7139                ntClass = cast(Callable, ntClass)
7140                return ntClass(**values)
7141
7142            elif asType == 's':
7143                return set(obj['values'])
7144
7145            elif asType == 'd':
7146                return dict(obj['items'])
7147
7148            elif asType == 'R':
7149                return self.parseFormat.parseRequirement(obj['value'])
7150
7151            elif asType == 'SC':
7152                return self.parseFormat.parseSkillCombination(obj['value'])
7153
7154            elif asType == 'E':
7155                return self.parseFormat.parseEffect(obj['value'])
7156
7157            elif asType == 'Ch':
7158                return self.parseFormat.parseChallenge(obj['value'])
7159
7160            elif asType == 'Cd':
7161                return self.parseFormat.parseCondition(obj['value'])
7162
7163            elif asType == 'Cq':
7164                return self.parseFormat.parseConsequence(obj['value'])
7165
7166            elif asType == 'DG':
7167                baseGraph: networkx.MultiDiGraph = networkx.node_link_graph(
7168                    obj['node_links'],
7169                    edges="links"
7170                )  # type: ignore
7171                # TODO: Fix networkx stubs
7172                graphResult = core.DecisionGraph()
7173                # Copy over instance attributes minus internals
7174                # TODO: Do we need internals?
7175                for (attr, val) in baseGraph.__dict__.items():
7176                    # Note: __dict__ over dir() here to avoid attributes
7177                    # of type and get just attributes of instance
7178                    if attr == "name":  # name will get copied below
7179                        continue
7180                    if not attr.startswith('__') or not attr.endswith('__'):
7181                        setattr(
7182                            graphResult,
7183                            attr,
7184                            copy.deepcopy(val)
7185                            # TODO: Does this copying disentangle too
7186                            # much? Which values even get copied this
7187                            # way?
7188                        )
7189
7190                if baseGraph.name != '':
7191                    graphResult.name = baseGraph.name
7192                graphResult.graph.update(obj['props'])  # type:ignore [attr-defined]  # noqa
7193                storedByEdge = obj['_byEdge']
7194                graphResult._byEdge = {
7195                    int(k): storedByEdge[k]
7196                    for k in storedByEdge
7197                }
7198                graphResult.zones = obj['zones']
7199                graphResult.unknownCount = obj['unknownCount']
7200                graphResult.equivalences = obj['equivalences']
7201                graphResult.reversionTypes = obj['reversionTypes']
7202                graphResult.nextID = obj.get('nextID')
7203                # Old code didn't store nextID; we extrapolate if necessary
7204                if graphResult.nextID is None:
7205                    graphResult.nextID = max(graphResult.nodes) + 1
7206                graphResult.nextMechanismID = obj['nextMechanismID']
7207                graphResult.mechanisms = {
7208                    int(k): v
7209                    for k, v in
7210                    obj['mechanisms'].items()
7211                }
7212                graphResult.globalMechanisms = obj['globalMechanisms']
7213                graphResult.nameLookup = obj['nameLookup']
7214                return graphResult
7215
7216            elif asType == 'DE':
7217                exResult = core.DiscreteExploration()
7218                exResult.situations = obj['situations']
7219                return exResult
7220
7221            elif asType == 'MS':
7222                msResult = base.MetricSpace(obj['name'])
7223                msResult.points = obj['points']
7224                msResult.nextID = obj['lastID'] + 1
7225                return msResult
7226
7227            else:
7228                raise NotImplementedError(
7229                    f"No special handling has been defined for"
7230                    f" decoding type '{asType}'."
7231                )
7232
7233        else:
7234            return obj

A custom JSON decoder that has special protocols for handling several types, including:

  • set
  • tuple & namedtuple
  • dict (where keys aren't all strings)
  • Requirement
  • SkillCombination
  • DecisionGraph
  • DiscreteExploration
  • MetricSpace

Used by toJSON

When initializing it, you can st a custom parse format by supplying a 'parseFormat' keyword argument; by default a standard ParseFormat will be used.

Examples:

>>> r = base.ReqAny([
...     base.ReqCapability('power'),
...     base.ReqTokens('money', 5)
... ])
>>> s = toJSON(r)
>>> s
'{"^^d": "R", "value": "(power|money*5)"}'
>>> l = fromJSON(s)
>>> r == l
True
>>> o = {1, 2, 'hi'}
>>> s = toJSON(o)
>>> s
'{"^^d": "s", "values": [1, 2, "hi"]}'
>>> l = fromJSON(s)
>>> o == l
True
>>> zi = base.ZoneInfo(1, set(), set(), {}, [])
>>> s = toJSON(zi)
>>> c = (
... '{"^^d": "nt", "name": "ZoneInfo", "values": {'
... '"level": 1,'
... ' "parents": {"^^d": "s", "values": []},'
... ' "contents": {"^^d": "s", "values": []},'
... ' "tags": {},'
... ' "annotations": []'
... '}}'
... )
>>> s == c
True
>>> setm = base.effect(set=("door", "open"))
>>> s = toJSON(setm)
>>> f = fromJSON(s)
>>> f == setm
True
>>> pf = ParseFormat()
>>> pf.unparseEffect(f)
'set door:open'
>>> pf.unparseEffect(f) == pf.unparseEffect(setm)
True
>>> g = core.DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g2 = fromJSON(toJSON(g))
>>> g2.destinationsFrom('A')
{'up': 1}
>>> g2.destinationsFrom('B')
{'down': 0}
>>> g2.addDecision('C')
2
>>> g2.addTransition('A', 'right', 'C', 'left')
>>> g2.destinationsFrom('A')
{'up': 1, 'right': 2}
>>> g2.destinationsFrom('C')
{'left': 0}

TODO: SkillCombination example

CustomJSONDecoder(*args, **kwargs)
7083    def __init__(self, *args, **kwargs):
7084        if 'object_hook' in kwargs:
7085            outerHook = kwargs['object_hook']
7086            kwargs['object_hook'] = (
7087                lambda o: outerHook(self.unpack(o))
7088            )
7089            # TODO: What if it's a positional argument? :(
7090        else:
7091            kwargs['object_hook'] = lambda o: self.unpack(o)
7092
7093        if 'parseFormat' in kwargs:
7094            self.parseFormat = kwargs['parseFormat']
7095            del kwargs['parseFormat']
7096        else:
7097            self.parseFormat = ParseFormat()
7098
7099        super().__init__(*args, **kwargs)

object_hook, if specified, will be called with the result of every JSON object decoded and its return value will be used in place of the given dict. This can be used to provide custom deserializations (e.g. to support JSON-RPC class hinting).

object_pairs_hook, if specified will be called with the result of every JSON object decoded with an ordered list of pairs. The return value of object_pairs_hook will be used instead of the dict. This feature can be used to implement custom decoders. If object_hook is also defined, the object_pairs_hook takes priority.

parse_float, if specified, will be called with the string of every JSON float to be decoded. By default this is equivalent to float(num_str). This can be used to use another datatype or parser for JSON floats (e.g. decimal.Decimal).

parse_int, if specified, will be called with the string of every JSON int to be decoded. By default this is equivalent to int(num_str). This can be used to use another datatype or parser for JSON integers (e.g. float).

parse_constant, if specified, will be called with one of the following strings: -Infinity, Infinity, NaN. This can be used to raise an exception if invalid JSON numbers are encountered.

If strict is false (true is the default), then control characters will be allowed inside strings. Control characters in this context are those with character codes in the 0-31 range, including '\t' (tab), '\n', '\r' and '\0'.

def unpack(self, obj: Any) -> Any:
7101    def unpack(self, obj: Any) -> Any:
7102        """
7103        Unpacks an object; used as the `object_hook` for decoding.
7104        """
7105        if '^^d' in obj:
7106            asType = obj['^^d']
7107            if asType == 't':
7108                return tuple(obj['values'])
7109
7110            elif asType == 'nt':
7111                g = globals()
7112                name = obj['name']
7113                values = obj['values']
7114                # Use an existing global namedtuple class if there is
7115                # one that goes by the specified name, so that we don't
7116                # create too many spurious equivalent namedtuple
7117                # classes. But fall back on creating a new namedtuple
7118                # class if we need to:
7119                ntClass = g.get(name)
7120                if (
7121                    ntClass is None
7122                 or not issubclass(ntClass, tuple)
7123                 or not hasattr(ntClass, '_asdict')
7124                ):
7125                    # Now try again specifically in the base module where
7126                    # most of our nametuples are defined (TODO: NOT this
7127                    # hack..., but it does make isinstance work...)
7128                    ntClass = getattr(base, name, None)
7129                    if (
7130                        ntClass is None
7131                     or not issubclass(ntClass, tuple)
7132                     or not hasattr(ntClass, '_asdict')
7133                    ):
7134                        # TODO: cache these...
7135                        ntClass = collections.namedtuple(  # type: ignore
7136                            name,
7137                            values.keys()
7138                        )
7139                ntClass = cast(Callable, ntClass)
7140                return ntClass(**values)
7141
7142            elif asType == 's':
7143                return set(obj['values'])
7144
7145            elif asType == 'd':
7146                return dict(obj['items'])
7147
7148            elif asType == 'R':
7149                return self.parseFormat.parseRequirement(obj['value'])
7150
7151            elif asType == 'SC':
7152                return self.parseFormat.parseSkillCombination(obj['value'])
7153
7154            elif asType == 'E':
7155                return self.parseFormat.parseEffect(obj['value'])
7156
7157            elif asType == 'Ch':
7158                return self.parseFormat.parseChallenge(obj['value'])
7159
7160            elif asType == 'Cd':
7161                return self.parseFormat.parseCondition(obj['value'])
7162
7163            elif asType == 'Cq':
7164                return self.parseFormat.parseConsequence(obj['value'])
7165
7166            elif asType == 'DG':
7167                baseGraph: networkx.MultiDiGraph = networkx.node_link_graph(
7168                    obj['node_links'],
7169                    edges="links"
7170                )  # type: ignore
7171                # TODO: Fix networkx stubs
7172                graphResult = core.DecisionGraph()
7173                # Copy over instance attributes minus internals
7174                # TODO: Do we need internals?
7175                for (attr, val) in baseGraph.__dict__.items():
7176                    # Note: __dict__ over dir() here to avoid attributes
7177                    # of type and get just attributes of instance
7178                    if attr == "name":  # name will get copied below
7179                        continue
7180                    if not attr.startswith('__') or not attr.endswith('__'):
7181                        setattr(
7182                            graphResult,
7183                            attr,
7184                            copy.deepcopy(val)
7185                            # TODO: Does this copying disentangle too
7186                            # much? Which values even get copied this
7187                            # way?
7188                        )
7189
7190                if baseGraph.name != '':
7191                    graphResult.name = baseGraph.name
7192                graphResult.graph.update(obj['props'])  # type:ignore [attr-defined]  # noqa
7193                storedByEdge = obj['_byEdge']
7194                graphResult._byEdge = {
7195                    int(k): storedByEdge[k]
7196                    for k in storedByEdge
7197                }
7198                graphResult.zones = obj['zones']
7199                graphResult.unknownCount = obj['unknownCount']
7200                graphResult.equivalences = obj['equivalences']
7201                graphResult.reversionTypes = obj['reversionTypes']
7202                graphResult.nextID = obj.get('nextID')
7203                # Old code didn't store nextID; we extrapolate if necessary
7204                if graphResult.nextID is None:
7205                    graphResult.nextID = max(graphResult.nodes) + 1
7206                graphResult.nextMechanismID = obj['nextMechanismID']
7207                graphResult.mechanisms = {
7208                    int(k): v
7209                    for k, v in
7210                    obj['mechanisms'].items()
7211                }
7212                graphResult.globalMechanisms = obj['globalMechanisms']
7213                graphResult.nameLookup = obj['nameLookup']
7214                return graphResult
7215
7216            elif asType == 'DE':
7217                exResult = core.DiscreteExploration()
7218                exResult.situations = obj['situations']
7219                return exResult
7220
7221            elif asType == 'MS':
7222                msResult = base.MetricSpace(obj['name'])
7223                msResult.points = obj['points']
7224                msResult.nextID = obj['lastID'] + 1
7225                return msResult
7226
7227            else:
7228                raise NotImplementedError(
7229                    f"No special handling has been defined for"
7230                    f" decoding type '{asType}'."
7231                )
7232
7233        else:
7234            return obj

Unpacks an object; used as the object_hook for decoding.

Inherited Members
json.decoder.JSONDecoder
object_hook
parse_float
parse_int
parse_constant
strict
object_pairs_hook
parse_object
parse_array
parse_string
memo
scan_once
decode
raw_decode