exploration.commands
- Authors: Peter Mawhorter
- Consulted:
- Date: 2023-12-27
- Purpose: The
Command
type which implements a mini-DSL for graph editing. Command lists can be embedded as effects in a graph to give ultimate flexibility in defining graphs that modify themselves in complex ways.
Commands represent a simplified mini-programming-language for editing a graph and/or exploration. The language stores a single 'current value' which many effects set or operate on (and which can be referred to as '$_' where variable names are used) The previous 'current value' is also stored in '$__' for convenience. It also allows the definition of arbitrary variables, and calling graph/exploration methods works mainly via keyword arguments pulled automatically from the set of defined variables. This allows each command to have a fixed number of arguments. The following definitions specify the different types of command, each as a named tuple with a 'command' slot in the first position that names the command.
1""" 2- Authors: Peter Mawhorter 3- Consulted: 4- Date: 2023-12-27 5- Purpose: The `Command` type which implements a mini-DSL for graph 6 editing. Command lists can be embedded as effects in a graph to give 7 ultimate flexibility in defining graphs that modify themselves in 8 complex ways. 9 10Commands represent a simplified mini-programming-language for editing a 11graph and/or exploration. The language stores a single 'current value' 12which many effects set or operate on (and which can be referred to as 13'$_' where variable names are used) The previous 'current value' is also 14stored in '$__' for convenience. It also allows the definition of 15arbitrary variables, and calling graph/exploration methods works mainly 16via keyword arguments pulled automatically from the set of defined 17variables. This allows each command to have a fixed number of arguments. 18The following definitions specify the different types of command, each 19as a named tuple with a 'command' slot in the first position that names 20the command. 21""" 22 23from typing import ( 24 Tuple, Literal, TypeAlias, Dict, Callable, Union, Any, Optional, 25 List, Collection, get_args 26) 27 28import collections 29import math 30import copy 31import logging 32import re 33 34#--------# 35# Errors # 36#--------# 37 38 39class CommandError(Exception): 40 """ 41 TODO: This? 42 An error raised during command execution will be converted to one of 43 the subtypes of this class. Stores the underlying error as `cause`, 44 and also stores the `command` and `line` where the error occurred. 45 """ 46 def __init__( 47 self, 48 command: 'Command', 49 line: int, 50 cause: Exception 51 ) -> None: 52 self.command = command 53 self.line = line 54 self.cause = cause 55 56 def __str__(self): 57 return ( 58 f"\n Command block, line {self.line}, running command:" 59 f"\n {self.command!r}" 60 f"\n{type(self.cause).__name__}: {self.cause}" 61 ) 62 63 64class CommandValueError(CommandError, ValueError): 65 "A `ValueError` encountered during command execution." 66 pass 67 68 69class CommandTypeError(CommandError, TypeError): 70 "A `TypeError` encountered during command execution." 71 pass 72 73 74class CommandIndexError(CommandError, IndexError): 75 "A `IndexError` encountered during command execution." 76 pass 77 78 79class CommandKeyError(CommandError, KeyError): 80 "A `KeyError` encountered during command execution." 81 pass 82 83 84class CommandOtherError(CommandError): 85 """ 86 Any error other than a `ValueError`, `TypeError`, `IndexError`, or 87 `KeyError` that's encountered during command execution. You can use 88 the `.cause` field to figure out what the type of the underlying 89 error was. 90 """ 91 pass 92 93 94#----------# 95# Commands # 96#----------# 97 98LiteralValue: Tuple[Literal['val'], str] = collections.namedtuple( 99 'LiteralValue', 100 ['command', 'value'] 101) 102""" 103A command that replaces the current value with a specific literal value. 104The values allowed are `None`, `True`, `False`, integers, floating-point 105numbers, and quoted strings (single or double quotes only). Note that 106lists, tuples, dictionaries, sets, and other complex data structures 107cannot be created this way. 108""" 109 110EstablishCollection: Tuple[ 111 Literal['empty'], 112 Literal['list', 'tuple', 'set', 'dict'] 113] = collections.namedtuple( 114 'EstablishCollection', 115 ['command', 'collection'] 116) 117""" 118A command which replaces the current value with an empty collection. The 119collection type must be one of 'list', 'tuple', 'set', or 'dict'. 120""" 121 122AppendValue: Tuple[Literal['append'], str] = collections.namedtuple( 123 'AppendValue', 124 ['command', 'value'] 125) 126""" 127A command which appends/adds a specific value (either a literal value 128that could be used with `LiteralValue` or a variable reference starting 129with '$') to the current value, which must be a list, tuple, or set. 130""" 131 132SetValue: Tuple[Literal['set'], str, str] = collections.namedtuple( 133 'SetValue', 134 ['command', 'location', 'value'] 135) 136""" 137A command which sets the value for a specific key in a dictionary stored 138as the current value, or for at a specific index in a tuple or list. 139Both the key and the value may be either a literal that could be used 140with `LiteralValue` or a variable reference starting with '$'. 141 142When used with a set, if the value is truthy the location is added to 143the set, and otherwise the location is removed from the set. 144""" 145 146PopValue: Tuple[Literal['pop']] = collections.namedtuple( 147 'PopValue', 148 ['command'] 149) 150""" 151A command which pops the last value in the tuple or list which is stored 152as the current value, setting the value it pops as the new current value. 153 154Does not work with sets or dictionaries. 155""" 156 157GetValue: Tuple[Literal['get'], str] = collections.namedtuple( 158 'GetValue', 159 ['command', 'location'] 160) 161""" 162A command which reads a value at a particular index within the current 163value and sets that as the new current value. For lists or tuples, the 164index must be convertible to an integer (but can also be a variable 165reference storing such a value). For sets, if the listed value is in the 166set the result will be `True` and otherwise it will be `False`. For 167dictionaries, it looks up a value under that key. 168 169For all other kinds of values, it looks for an attribute with the same 170name as the string specified and returns the value of that attribute 171(it's an error to specify a non-string value in this case). 172""" 173 174RemoveValue: Tuple[Literal['remove'], str] = collections.namedtuple( 175 'RemoveValue', 176 ['command', 'location'] 177) 178""" 179A command which removes an item from a tuple, list, set, or dictionary 180which is stored as the current value. The value should be an integer (or 181a variable holding one) if the current value is a tuple or list. Unlike 182python's `.remove` method, this removes a single item at a particular 183index/under a particular key, not all copies of a particular value. 184""" 185 186BinaryOperator: 'TypeAlias' = Literal[ 187 '+', '-', '*', '/', '//', '**', '%', '^', '|', '&', 'and', 'or', 188 '<', '>', '<=', '>=', '==', 'is' 189] 190""" 191The supported binary operators for commands. 192""" 193 194UnaryOperator: 'TypeAlias' = Literal['-', '~', 'not'] 195""" 196The supported binary operators for commands. 197""" 198 199ApplyOperator: Tuple[ 200 Literal['op'], 201 BinaryOperator, 202 str, 203 str 204] = collections.namedtuple( 205 'ApplyOperator', 206 ['command', 'op', 'left', 'right'] 207) 208""" 209A command which establishes a new current value based on the result of an 210operator. See `BinaryOperator` for the list of supported operators. The 211two operand may be literals as accepted by `LiteralValue` or variable 212references starting with '$'. 213""" 214 215ApplyUnary: Tuple[ 216 Literal['unary'], 217 UnaryOperator, 218 str 219] = collections.namedtuple( 220 'ApplyUnary', 221 ['command', 'op', 'value'] 222) 223""" 224The unary version of `ApplyOperator`. See `UnaryOperator` for the list 225of supported operators. 226""" 227 228VariableAssignment: Tuple[ 229 Literal['assign'], 230 str, 231 str 232] = collections.namedtuple( 233 'VariableAssignment', 234 ['command', 'varname', 'value'] 235) 236""" 237Assigns the specified value (may be a variable reference) into a named 238variable. The variable name should not start with '$', which is used when 239referencing variables. If it does, variable substitution will be 240performed to compute the name of the variable being created. 241""" 242 243VariableDeletion: Tuple[Literal['delete'], str] = collections.namedtuple( 244 'VariableDeletion', 245 ['command', 'varname'] 246) 247""" 248Deletes the variable with the given name. Doesn't actually delete the 249stored object if it's reference elsewhere. Useful for unspecifying 250arguments for a 'call' command. 251""" 252 253LoadVariable: Tuple[Literal['load'], str] = collections.namedtuple( 254 'LoadVariable', 255 ['command', 'varname'] 256) 257""" 258Loads the named variable as the current value, replacing the old current 259value. The variable name should normally be specified without the '$', 260with '$' variable substitution will take place and the resulting string 261will be used as the name of the variable to load. 262""" 263 264CallType: 'TypeAlias' = Literal[ 265 'builtin', 266 'stored', 267 'graph', 268 'exploration' 269] 270""" 271Types of function calls available via the 'call' command. 272""" 273 274FunctionCall: Tuple[ 275 Literal['call'], 276 CallType, 277 str 278] = collections.namedtuple( 279 'FunctionCall', 280 ['command', 'target', 'function'] 281) 282""" 283A command which calls a function or method. IF the target is 'builtin', 284one of the `COMMAND_BUILTINS` will be called. If the target is 'graph' or 285'exploration' then a method of the current graph or exploration will be 286called. If the target is 'stored', then the function part will be 287treated as a variable reference and the function stored in that variable 288will be called. 289 290For builtins, the current value will be used as the only argument. There 291are two special cases: for `round`, if an 'ndigits' variable is defined 292its value will be used for the optional second argument, and for `range`, 293if the current value is `None`, then the values of the 'start', 'stop', 294and/or 'step' variables are used for its arguments, with a default start 295of 0 and a default step of 1 (there is no default stop; it's an error if 296you don't supply one). If the current value is not `None`, `range` just 297gets called with the current value as its only argument. 298 299For graph/exploration methods, the current value is ignored and each 300listed parameter is sourced from a defined variable of that name, with 301parameters for which there is no defined variable going unsupplied 302(which might be okay if they're optional). For varargs parameters, the 303value of the associated variable will be converted to a tuple and that 304will be supplied as if using '*'; for kwargs parameters the value of the 305associated variable must be a dictionary, and it will be applied as if 306using '**' (except that duplicate arguments will not cause an error; 307instead those coming fro the dictionary value will override any already 308supplied). 309""" 310 311COMMAND_BUILTINS: Dict[str, Callable] = { 312 'len': len, 313 'min': min, 314 'max': max, 315 'round': round, # 'ndigits' may be specified via the environment 316 'ceil': math.ceil, 317 'floor': math.floor, 318 'int': int, 319 'float': float, 320 'str': str, 321 'list': list, 322 'tuple': tuple, 323 'dict': dict, 324 'set': set, 325 'copy': copy.copy, 326 'deepcopy': copy.deepcopy, 327 'range': range, # parameter names are 'start', 'stop', and 'step' 328 'reversed': reversed, 329 'sorted': sorted, # cannot use key= or reverse= 330 'print': print, # prints just one value, ignores sep= and end= 331 'warning': logging.warning, # just one argument 332} 333""" 334The mapping from names to built-in functions usable in commands. Each is 335available for use with the 'call' command when 'builtin' is used as the 336target. See `FunctionCall` for more details. 337""" 338 339SkipCommands: Tuple[ 340 Literal['skip'], 341 str, 342 str 343] = collections.namedtuple( 344 'SkipCommands', 345 ['command', 'condition', 'amount'] 346) 347""" 348A command which skips forward or backward within the command list it's 349included in, but only if a condition value is True. A skip amount of 0 350just continues execution as normal. Negative amounts jump to previous 351commands (so e.g., -2 will re-execute the two commands above the skip 352command), while positive amounts skip over subsequent commands (so e.g., 3531 will skip over one command after this one, resuming execution with the 354second command after the skip). 355 356If the condition is False, execution continues with the subsequent 357command as normal. 358 359If the distance value is a string instead of an integer, the skip will 360redirect execution to the label that uses that name. Note that the 361distance value may be a variable reference, in which case the integer or 362string inside the reference will determine where to skip to. 363""" 364 365Label: Tuple[Literal['label'], str] = collections.namedtuple( 366 'Label', 367 ['command', 'name'] 368) 369""" 370Has no effect, but establishes a label that can be skipped to using the 371'skip' command. Note that instead of just a fixed label, a variable name 372can be used and variable substitution will determine the label name in 373that case, BUT there are two restrictions: the value must be a string, 374and you cannot execute a forward-skip to a label which has not already 375been evaluated, since the value isn't known when the skip occurs. If you 376use a literal label name instead of a variable, you will be able to skip 377down to that label from above. 378 379When multiple labels with the same name occur, a skip command will go to 380the last label with that name before the skip, only considering labels 381after the skip if there are no labels with that name beforehand (and 382skipping to the first available label in that case). 383""" 384 385Command: 'TypeAlias' = Union[ 386 LiteralValue, 387 EstablishCollection, 388 AppendValue, 389 SetValue, 390 PopValue, 391 GetValue, 392 RemoveValue, 393 ApplyOperator, 394 ApplyUnary, 395 VariableAssignment, 396 VariableDeletion, 397 LoadVariable, 398 FunctionCall, 399 SkipCommands, 400 Label 401] 402""" 403The union type for any kind of command. Note that these are all tuples, 404all of their members are strings in all cases, and their first member 405(named 'command') is always a string that uniquely identifies the type of 406the command. Use the `command` function to get some type-checking while 407constructing them. 408""" 409 410Scope: 'TypeAlias' = Dict[str, Any] 411""" 412A scope holds variables defined during the execution of a sequence of 413commands. Variable names (sans the '$' sign) are mapped to arbitrary 414Python values. 415""" 416 417CommandResult: 'TypeAlias' = Tuple[ 418 Scope, 419 Union[int, str, None], 420 Optional[str] 421] 422""" 423The main result of a command is an updated scope (usually but not 424necessarily the same scope object that was used to execute the command). 425Additionally, there may be a skip integer that indicates how many 426commands should be skipped (if positive) or repeated (if negative) as a 427result of the command just executed. This value may also be a string to 428skip to a label. There may also be a label value which indicates that 429the command that was executed defines that label. 430""" 431 432 433def isSimpleValue(valStr: str) -> bool: 434 """ 435 Returns `True` if the given string is a valid simple value for use 436 with a command. Simple values are strings that represent `None`, 437 `True`, `False`, integers, floating-point numbers, and quoted 438 strings (single or double quotes only). Numbers themselves are not 439 simple values. 440 441 Examples: 442 443 >>> isSimpleValue('None') 444 True 445 >>> isSimpleValue('True') 446 True 447 >>> isSimpleValue('False') 448 True 449 >>> isSimpleValue('none') 450 False 451 >>> isSimpleValue('12') 452 True 453 >>> isSimpleValue('5.6') 454 True 455 >>> isSimpleValue('3.2e-10') 456 True 457 >>> isSimpleValue('2 + 3j') # ba-dump tsss 458 False 459 >>> isSimpleValue('hello') 460 False 461 >>> isSimpleValue('"hello"') 462 True 463 >>> isSimpleValue('"hel"lo"') 464 False 465 >>> isSimpleValue('"hel\\\\"lo"') # note we're in a docstring here 466 True 467 >>> isSimpleValue("'hi'") 468 True 469 >>> isSimpleValue("'don\\\\'t'") 470 True 471 >>> isSimpleValue("") 472 False 473 >>> isSimpleValue(3) 474 False 475 >>> isSimpleValue(3.5) 476 False 477 """ 478 if not isinstance(valStr, str): 479 return False 480 if valStr in ('None', 'True', 'False'): 481 return True 482 else: 483 try: 484 _ = int(valStr) 485 return True 486 except ValueError: 487 pass 488 489 try: 490 _ = float(valStr) 491 return True 492 except ValueError: 493 pass 494 495 if ( 496 len(valStr) >= 2 497 and valStr.startswith("'") or valStr.startswith('"') 498 ): 499 quote = valStr[0] 500 ends = valStr.endswith(quote) 501 mismatched = re.search(r'[^\\]' + quote, valStr[1:-1]) 502 return ends and mismatched is None 503 else: 504 return False 505 506 507def resolveValue(valStr: str, context: Scope) -> Any: 508 """ 509 Given a value string which could be a literal value or a variable 510 reference, returns the value of that expression. Note that operators 511 are not handled: only variable substitution is done. 512 """ 513 if isVariableReference(valStr): 514 varName = valStr[1:] 515 if varName not in context: 516 raise NameError(f"Variable '{varName}' is not defined.") 517 return context[varName] 518 elif not isSimpleValue(valStr): 519 raise ValueError( 520 f"{valStr!r} is not a valid value (perhaps you need to add" 521 f" quotes to get a string, or '$' to reference a variable?)" 522 ) 523 else: 524 if valStr == "True": 525 return True 526 elif valStr == "False": 527 return False 528 elif valStr == "None": 529 return None 530 elif valStr.startswith('"') or valStr.startswith("'"): 531 return valStr[1:-1] 532 else: 533 try: 534 return int(valStr) 535 except ValueError: 536 pass 537 538 try: 539 return float(valStr) 540 except ValueError: 541 pass 542 543 raise RuntimeError( 544 f"Validated value {valStr!r} is not a string, a number," 545 f" or a recognized keyword type." 546 ) 547 548 549def isVariableReference(value: str) -> bool: 550 """ 551 Returns `True` if the given value is a variable reference. Variable 552 references start with '$' and the rest of the reference must be a 553 valid python identifier (i.e., a sequence of alphabetic characters, 554 digits, and/or underscores which does not start with a digit). 555 556 There is one other possibility: references that start with '$@' 557 possibly followed by an identifier. 558 559 Examples: 560 561 >>> isVariableReference('$hi') 562 True 563 >>> isVariableReference('$good bye') 564 False 565 >>> isVariableReference('$_') 566 True 567 >>> isVariableReference('$123') 568 False 569 >>> isVariableReference('$1ab') 570 False 571 >>> isVariableReference('$ab1') 572 True 573 >>> isVariableReference('hi') 574 False 575 >>> isVariableReference('') 576 False 577 >>> isVariableReference('$@') 578 True 579 >>> isVariableReference('$@a') 580 True 581 >>> isVariableReference('$@1') 582 False 583 """ 584 if len(value) < 2: 585 return False 586 elif value[0] != '$': 587 return False 588 elif len(value) == 2: 589 return value[1] == '@' or value[1].isidentifier() 590 else: 591 return ( 592 value[1:].isidentifier() 593 ) or ( 594 value[1] == '@' 595 and value[2:].isidentifier() 596 ) 597 598 599def resolveVarName(name: str, scope: Scope) -> str: 600 """ 601 Resolves a variable name as either a literal name, or if the name 602 starts with '$', via a variable reference in the given scope whose 603 value must be a string. 604 """ 605 if name.startswith('$'): 606 result = scope[name[1:]] 607 if not isinstance(result, str): 608 raise TypeError( 609 f"Variable '{name[1:]}' cannot be referenced as a" 610 f" variable name because it does not hold a string (its" 611 f" value is: {result!r}" 612 ) 613 return result 614 else: 615 return name 616 617 618def fixArgs(command: str, requires: int, args: List[str]) -> List[str]: 619 """ 620 Checks that the proper number of arguments has been supplied, using 621 the command name as part of the message for a `ValueError` if not. 622 This will fill in '$_' and '$__' for the first two missing arguments 623 instead of generating an error, and returns the possibly modified 624 argument list. 625 """ 626 if not (requires - 2 <= len(args) <= requires): 627 raise ValueError( 628 f"Command '{command}' requires {requires} argument(s) but" 629 f" you provided {len(args)}." 630 ) 631 return (args + ['$_', '$__'])[:requires] 632 633 634def requiresValue(command: str, argDesc: str, arg: str) -> str: 635 """ 636 Checks that the given argument is a simple value, and raises a 637 `ValueError` if it's not. Otherwise just returns. The given command 638 name and argument description are used in the error message. The 639 `argDesc` should be an adjectival phrase, like 'first'. 640 641 Returns the argument given to it. 642 """ 643 if not isSimpleValue(arg): 644 raise ValueError( 645 f"The {argDesc} argument to '{command}' must be a simple" 646 f" value string (got {arg!r})." 647 ) 648 return arg 649 650 651def requiresLiteralOrVariable( 652 command: str, 653 argDesc: str, 654 options: Collection[str], 655 arg: str 656) -> str: 657 """ 658 Like `requiresValue` but only allows variable references or one of a 659 collection of specific strings as the argument. 660 """ 661 if not isVariableReference(arg) and arg not in options: 662 raise ValueError( 663 ( 664 f"The {argDesc} argument to '{command}' must be either" 665 f" a variable reference or one of the following strings" 666 f" (got {arg!r}):\n " 667 ) + '\n '.join(options) 668 ) 669 return arg 670 671 672def requiresValueOrVariable(command: str, argDesc: str, arg: str) -> str: 673 """ 674 Like `requiresValue` but allows variable references as well as 675 simple values. 676 """ 677 if not (isSimpleValue(arg) or isVariableReference(arg)): 678 raise ValueError( 679 f"The {argDesc} argument to '{command}' must be a simple" 680 f" value or a variable reference (got {arg!r})." 681 ) 682 return arg 683 684 685def requiresVariableName(command: str, argDesc: str, arg: str) -> str: 686 """ 687 Like `requiresValue` but allows only variable names, with or without 688 the leading '$'. 689 """ 690 if not (isVariableReference(arg) or isVariableReference('$' + arg)): 691 raise ValueError( 692 f"The {argDesc} argument to '{command}' must be a variable" 693 f" name without the '$' or a variable reference (got" 694 f" {arg!r})." 695 ) 696 return arg 697 698 699COMMAND_SETUP: Dict[ 700 str, 701 Tuple[ 702 type[Command], 703 int, 704 List[ 705 Union[ 706 Literal[ 707 "requiresValue", 708 "requiresVariableName", 709 "requiresValueOrVariable" 710 ], 711 Tuple[ 712 Literal["requiresLiteralOrVariable"], 713 Collection[str] 714 ] 715 ] 716 ] 717 ] 718] = { 719 'val': (LiteralValue, 1, ["requiresValue"]), 720 'empty': ( 721 EstablishCollection, 722 1, 723 [("requiresLiteralOrVariable", {'list', 'tuple', 'set', 'dict'})] 724 ), 725 'append': (AppendValue, 1, ["requiresValueOrVariable"]), 726 'set': ( 727 SetValue, 728 2, 729 ["requiresValueOrVariable", "requiresValueOrVariable"] 730 ), 731 'pop': (PopValue, 0, []), 732 'get': (GetValue, 1, ["requiresValueOrVariable"]), 733 'remove': (RemoveValue, 1, ["requiresValueOrVariable"]), 734 'op': ( 735 ApplyOperator, 736 3, 737 [ 738 ("requiresLiteralOrVariable", get_args(BinaryOperator)), 739 "requiresValueOrVariable", 740 "requiresValueOrVariable" 741 ] 742 ), 743 'unary': ( 744 ApplyUnary, 745 2, 746 [ 747 ("requiresLiteralOrVariable", get_args(UnaryOperator)), 748 "requiresValueOrVariable" 749 ] 750 ), 751 'assign': ( 752 VariableAssignment, 753 2, 754 ["requiresVariableName", "requiresValueOrVariable"] 755 ), 756 'delete': (VariableDeletion, 1, ["requiresVariableName"]), 757 'load': (LoadVariable, 1, ["requiresVariableName"]), 758 'call': ( 759 FunctionCall, 760 2, 761 [ 762 ("requiresLiteralOrVariable", get_args(CallType)), 763 "requiresVariableName" 764 ] 765 ), 766 'skip': ( 767 SkipCommands, 768 2, 769 [ 770 "requiresValueOrVariable", 771 "requiresValueOrVariable" 772 ] 773 ), 774 'label': (Label, 1, ["requiresVariableName"]), 775} 776 777 778def command(commandType: str, *_args: str) -> Command: 779 """ 780 A convenience function for constructing a command tuple which 781 type-checks the arguments a bit. Raises a `ValueError` if invalid 782 information is supplied; otherwise it returns a `Command` tuple. 783 784 Up to two missing arguments will be replaced automatically with '$_' 785 and '$__' respectively in most cases. 786 787 Examples: 788 789 >>> command('val', '5') 790 LiteralValue(command='val', value='5') 791 >>> command('val', '"5"') 792 LiteralValue(command='val', value='"5"') 793 >>> command('val') 794 Traceback (most recent call last): 795 ... 796 ValueError... 797 >>> command('empty') 798 EstablishCollection(command='empty', collection='$_') 799 >>> command('empty', 'list') 800 EstablishCollection(command='empty', collection='list') 801 >>> command('empty', '$ref') 802 EstablishCollection(command='empty', collection='$ref') 803 >>> command('empty', 'invalid') # invalid argument 804 Traceback (most recent call last): 805 ... 806 ValueError... 807 >>> command('empty', 'list', 'dict') # too many arguments 808 Traceback (most recent call last): 809 ... 810 ValueError... 811 >>> command('append', '5') 812 AppendValue(command='append', value='5') 813 """ 814 args = list(_args) 815 816 spec = COMMAND_SETUP.get(commandType) 817 if spec is None: 818 raise ValueError( 819 f"Command type '{commandType}' cannot be constructed by" 820 f" assembleSimpleCommand (try the command function" 821 f" instead)." 822 ) 823 824 commandVariant, nArgs, checkers = spec 825 826 args = fixArgs(commandType, nArgs, args) 827 828 checkedArgs = [] 829 for i, (checker, arg) in enumerate(zip(checkers, args)): 830 argDesc = str(i + 1) 831 if argDesc.endswith('1') and nArgs != 11: 832 argDesc += 'st' 833 elif argDesc.endswith('2') and nArgs != 12: 834 argDesc += 'nd' 835 elif argDesc.endswith('3') and nArgs != 13: 836 argDesc += 'rd' 837 else: 838 argDesc += 'th' 839 840 if isinstance(checker, tuple): 841 checkName, allowed = checker 842 checkFn = globals()[checkName] 843 checkedArgs.append( 844 checkFn(commandType, argDesc, allowed, arg) 845 ) 846 else: 847 checkFn = globals()[checker] 848 checkedArgs.append(checkFn(commandType, argDesc, arg)) 849 850 return commandVariant( 851 commandType, 852 *checkedArgs 853 ) 854 855 856def pushCurrentValue(scope: Scope, value: Any) -> None: 857 """ 858 Pushes the given value as the 'current value' for the given scope, 859 storing it in the '_' variable. Stores the old '_' value into '__' 860 if there was one. 861 """ 862 if '_' in scope: 863 scope['__'] = scope['_'] 864 scope['_'] = value
40class CommandError(Exception): 41 """ 42 TODO: This? 43 An error raised during command execution will be converted to one of 44 the subtypes of this class. Stores the underlying error as `cause`, 45 and also stores the `command` and `line` where the error occurred. 46 """ 47 def __init__( 48 self, 49 command: 'Command', 50 line: int, 51 cause: Exception 52 ) -> None: 53 self.command = command 54 self.line = line 55 self.cause = cause 56 57 def __str__(self): 58 return ( 59 f"\n Command block, line {self.line}, running command:" 60 f"\n {self.command!r}" 61 f"\n{type(self.cause).__name__}: {self.cause}" 62 )
TODO: This?
An error raised during command execution will be converted to one of
the subtypes of this class. Stores the underlying error as cause
,
and also stores the command
and line
where the error occurred.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
65class CommandValueError(CommandError, ValueError): 66 "A `ValueError` encountered during command execution." 67 pass
A ValueError
encountered during command execution.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
70class CommandTypeError(CommandError, TypeError): 71 "A `TypeError` encountered during command execution." 72 pass
A TypeError
encountered during command execution.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
75class CommandIndexError(CommandError, IndexError): 76 "A `IndexError` encountered during command execution." 77 pass
A IndexError
encountered during command execution.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
80class CommandKeyError(CommandError, KeyError): 81 "A `KeyError` encountered during command execution." 82 pass
A KeyError
encountered during command execution.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
85class CommandOtherError(CommandError): 86 """ 87 Any error other than a `ValueError`, `TypeError`, `IndexError`, or 88 `KeyError` that's encountered during command execution. You can use 89 the `.cause` field to figure out what the type of the underlying 90 error was. 91 """ 92 pass
Any error other than a ValueError
, TypeError
, IndexError
, or
KeyError
that's encountered during command execution. You can use
the .cause
field to figure out what the type of the underlying
error was.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
- args
A command that replaces the current value with a specific literal value.
The values allowed are None
, True
, False
, integers, floating-point
numbers, and quoted strings (single or double quotes only). Note that
lists, tuples, dictionaries, sets, and other complex data structures
cannot be created this way.
Inherited Members
- builtins.tuple
- index
- count
A command which replaces the current value with an empty collection. The collection type must be one of 'list', 'tuple', 'set', or 'dict'.
Create new instance of EstablishCollection(command, collection)
Inherited Members
- builtins.tuple
- index
- count
A command which appends/adds a specific value (either a literal value
that could be used with LiteralValue
or a variable reference starting
with '$') to the current value, which must be a list, tuple, or set.
Inherited Members
- builtins.tuple
- index
- count
A command which sets the value for a specific key in a dictionary stored
as the current value, or for at a specific index in a tuple or list.
Both the key and the value may be either a literal that could be used
with LiteralValue
or a variable reference starting with '$'.
When used with a set, if the value is truthy the location is added to the set, and otherwise the location is removed from the set.
Inherited Members
- builtins.tuple
- index
- count
A command which pops the last value in the tuple or list which is stored as the current value, setting the value it pops as the new current value.
Does not work with sets or dictionaries.
Inherited Members
- builtins.tuple
- index
- count
A command which reads a value at a particular index within the current
value and sets that as the new current value. For lists or tuples, the
index must be convertible to an integer (but can also be a variable
reference storing such a value). For sets, if the listed value is in the
set the result will be True
and otherwise it will be False
. For
dictionaries, it looks up a value under that key.
For all other kinds of values, it looks for an attribute with the same name as the string specified and returns the value of that attribute (it's an error to specify a non-string value in this case).
Inherited Members
- builtins.tuple
- index
- count
A command which removes an item from a tuple, list, set, or dictionary
which is stored as the current value. The value should be an integer (or
a variable holding one) if the current value is a tuple or list. Unlike
python's .remove
method, this removes a single item at a particular
index/under a particular key, not all copies of a particular value.
Inherited Members
- builtins.tuple
- index
- count
The supported binary operators for commands.
The supported binary operators for commands.
A command which establishes a new current value based on the result of an
operator. See BinaryOperator
for the list of supported operators. The
two operand may be literals as accepted by LiteralValue
or variable
references starting with '$'.
Create new instance of ApplyOperator(command, op, left, right)
Inherited Members
- builtins.tuple
- index
- count
The unary version of ApplyOperator
. See UnaryOperator
for the list
of supported operators.
Inherited Members
- builtins.tuple
- index
- count
Assigns the specified value (may be a variable reference) into a named variable. The variable name should not start with '$', which is used when referencing variables. If it does, variable substitution will be performed to compute the name of the variable being created.
Create new instance of VariableAssignment(command, varname, value)
Inherited Members
- builtins.tuple
- index
- count
Deletes the variable with the given name. Doesn't actually delete the stored object if it's reference elsewhere. Useful for unspecifying arguments for a 'call' command.
Inherited Members
- builtins.tuple
- index
- count
Loads the named variable as the current value, replacing the old current value. The variable name should normally be specified without the '$', with '$' variable substitution will take place and the resulting string will be used as the name of the variable to load.
Inherited Members
- builtins.tuple
- index
- count
Types of function calls available via the 'call' command.
A command which calls a function or method. IF the target is 'builtin',
one of the COMMAND_BUILTINS
will be called. If the target is 'graph' or
'exploration' then a method of the current graph or exploration will be
called. If the target is 'stored', then the function part will be
treated as a variable reference and the function stored in that variable
will be called.
For builtins, the current value will be used as the only argument. There
are two special cases: for round
, if an 'ndigits' variable is defined
its value will be used for the optional second argument, and for range
,
if the current value is None
, then the values of the 'start', 'stop',
and/or 'step' variables are used for its arguments, with a default start
of 0 and a default step of 1 (there is no default stop; it's an error if
you don't supply one). If the current value is not None
, range
just
gets called with the current value as its only argument.
For graph/exploration methods, the current value is ignored and each listed parameter is sourced from a defined variable of that name, with parameters for which there is no defined variable going unsupplied (which might be okay if they're optional). For varargs parameters, the value of the associated variable will be converted to a tuple and that will be supplied as if using ''; for kwargs parameters the value of the associated variable must be a dictionary, and it will be applied as if using '*' (except that duplicate arguments will not cause an error; instead those coming fro the dictionary value will override any already supplied).
Create new instance of FunctionCall(command, target, function)
Inherited Members
- builtins.tuple
- index
- count
The mapping from names to built-in functions usable in commands. Each is
available for use with the 'call' command when 'builtin' is used as the
target. See FunctionCall
for more details.
A command which skips forward or backward within the command list it's included in, but only if a condition value is True. A skip amount of 0 just continues execution as normal. Negative amounts jump to previous commands (so e.g., -2 will re-execute the two commands above the skip command), while positive amounts skip over subsequent commands (so e.g., 1 will skip over one command after this one, resuming execution with the second command after the skip).
If the condition is False, execution continues with the subsequent command as normal.
If the distance value is a string instead of an integer, the skip will redirect execution to the label that uses that name. Note that the distance value may be a variable reference, in which case the integer or string inside the reference will determine where to skip to.
Create new instance of SkipCommands(command, condition, amount)
Inherited Members
- builtins.tuple
- index
- count
Has no effect, but establishes a label that can be skipped to using the 'skip' command. Note that instead of just a fixed label, a variable name can be used and variable substitution will determine the label name in that case, BUT there are two restrictions: the value must be a string, and you cannot execute a forward-skip to a label which has not already been evaluated, since the value isn't known when the skip occurs. If you use a literal label name instead of a variable, you will be able to skip down to that label from above.
When multiple labels with the same name occur, a skip command will go to the last label with that name before the skip, only considering labels after the skip if there are no labels with that name beforehand (and skipping to the first available label in that case).
Inherited Members
- builtins.tuple
- index
- count
The union type for any kind of command. Note that these are all tuples,
all of their members are strings in all cases, and their first member
(named 'command') is always a string that uniquely identifies the type of
the command. Use the command
function to get some type-checking while
constructing them.
A scope holds variables defined during the execution of a sequence of commands. Variable names (sans the '$' sign) are mapped to arbitrary Python values.
The main result of a command is an updated scope (usually but not necessarily the same scope object that was used to execute the command). Additionally, there may be a skip integer that indicates how many commands should be skipped (if positive) or repeated (if negative) as a result of the command just executed. This value may also be a string to skip to a label. There may also be a label value which indicates that the command that was executed defines that label.
434def isSimpleValue(valStr: str) -> bool: 435 """ 436 Returns `True` if the given string is a valid simple value for use 437 with a command. Simple values are strings that represent `None`, 438 `True`, `False`, integers, floating-point numbers, and quoted 439 strings (single or double quotes only). Numbers themselves are not 440 simple values. 441 442 Examples: 443 444 >>> isSimpleValue('None') 445 True 446 >>> isSimpleValue('True') 447 True 448 >>> isSimpleValue('False') 449 True 450 >>> isSimpleValue('none') 451 False 452 >>> isSimpleValue('12') 453 True 454 >>> isSimpleValue('5.6') 455 True 456 >>> isSimpleValue('3.2e-10') 457 True 458 >>> isSimpleValue('2 + 3j') # ba-dump tsss 459 False 460 >>> isSimpleValue('hello') 461 False 462 >>> isSimpleValue('"hello"') 463 True 464 >>> isSimpleValue('"hel"lo"') 465 False 466 >>> isSimpleValue('"hel\\\\"lo"') # note we're in a docstring here 467 True 468 >>> isSimpleValue("'hi'") 469 True 470 >>> isSimpleValue("'don\\\\'t'") 471 True 472 >>> isSimpleValue("") 473 False 474 >>> isSimpleValue(3) 475 False 476 >>> isSimpleValue(3.5) 477 False 478 """ 479 if not isinstance(valStr, str): 480 return False 481 if valStr in ('None', 'True', 'False'): 482 return True 483 else: 484 try: 485 _ = int(valStr) 486 return True 487 except ValueError: 488 pass 489 490 try: 491 _ = float(valStr) 492 return True 493 except ValueError: 494 pass 495 496 if ( 497 len(valStr) >= 2 498 and valStr.startswith("'") or valStr.startswith('"') 499 ): 500 quote = valStr[0] 501 ends = valStr.endswith(quote) 502 mismatched = re.search(r'[^\\]' + quote, valStr[1:-1]) 503 return ends and mismatched is None 504 else: 505 return False
Returns True
if the given string is a valid simple value for use
with a command. Simple values are strings that represent None
,
True
, False
, integers, floating-point numbers, and quoted
strings (single or double quotes only). Numbers themselves are not
simple values.
Examples:
>>> isSimpleValue('None')
True
>>> isSimpleValue('True')
True
>>> isSimpleValue('False')
True
>>> isSimpleValue('none')
False
>>> isSimpleValue('12')
True
>>> isSimpleValue('5.6')
True
>>> isSimpleValue('3.2e-10')
True
>>> isSimpleValue('2 + 3j') # ba-dump tsss
False
>>> isSimpleValue('hello')
False
>>> isSimpleValue('"hello"')
True
>>> isSimpleValue('"hel"lo"')
False
>>> isSimpleValue('"hel\\"lo"') # note we're in a docstring here
True
>>> isSimpleValue("'hi'")
True
>>> isSimpleValue("'don\\'t'")
True
>>> isSimpleValue("")
False
>>> isSimpleValue(3)
False
>>> isSimpleValue(3.5)
False
508def resolveValue(valStr: str, context: Scope) -> Any: 509 """ 510 Given a value string which could be a literal value or a variable 511 reference, returns the value of that expression. Note that operators 512 are not handled: only variable substitution is done. 513 """ 514 if isVariableReference(valStr): 515 varName = valStr[1:] 516 if varName not in context: 517 raise NameError(f"Variable '{varName}' is not defined.") 518 return context[varName] 519 elif not isSimpleValue(valStr): 520 raise ValueError( 521 f"{valStr!r} is not a valid value (perhaps you need to add" 522 f" quotes to get a string, or '$' to reference a variable?)" 523 ) 524 else: 525 if valStr == "True": 526 return True 527 elif valStr == "False": 528 return False 529 elif valStr == "None": 530 return None 531 elif valStr.startswith('"') or valStr.startswith("'"): 532 return valStr[1:-1] 533 else: 534 try: 535 return int(valStr) 536 except ValueError: 537 pass 538 539 try: 540 return float(valStr) 541 except ValueError: 542 pass 543 544 raise RuntimeError( 545 f"Validated value {valStr!r} is not a string, a number," 546 f" or a recognized keyword type." 547 )
Given a value string which could be a literal value or a variable reference, returns the value of that expression. Note that operators are not handled: only variable substitution is done.
550def isVariableReference(value: str) -> bool: 551 """ 552 Returns `True` if the given value is a variable reference. Variable 553 references start with '$' and the rest of the reference must be a 554 valid python identifier (i.e., a sequence of alphabetic characters, 555 digits, and/or underscores which does not start with a digit). 556 557 There is one other possibility: references that start with '$@' 558 possibly followed by an identifier. 559 560 Examples: 561 562 >>> isVariableReference('$hi') 563 True 564 >>> isVariableReference('$good bye') 565 False 566 >>> isVariableReference('$_') 567 True 568 >>> isVariableReference('$123') 569 False 570 >>> isVariableReference('$1ab') 571 False 572 >>> isVariableReference('$ab1') 573 True 574 >>> isVariableReference('hi') 575 False 576 >>> isVariableReference('') 577 False 578 >>> isVariableReference('$@') 579 True 580 >>> isVariableReference('$@a') 581 True 582 >>> isVariableReference('$@1') 583 False 584 """ 585 if len(value) < 2: 586 return False 587 elif value[0] != '$': 588 return False 589 elif len(value) == 2: 590 return value[1] == '@' or value[1].isidentifier() 591 else: 592 return ( 593 value[1:].isidentifier() 594 ) or ( 595 value[1] == '@' 596 and value[2:].isidentifier() 597 )
Returns True
if the given value is a variable reference. Variable
references start with '$' and the rest of the reference must be a
valid python identifier (i.e., a sequence of alphabetic characters,
digits, and/or underscores which does not start with a digit).
There is one other possibility: references that start with '$@' possibly followed by an identifier.
Examples:
>>> isVariableReference('$hi')
True
>>> isVariableReference('$good bye')
False
>>> isVariableReference('$_')
True
>>> isVariableReference('$123')
False
>>> isVariableReference('$1ab')
False
>>> isVariableReference('$ab1')
True
>>> isVariableReference('hi')
False
>>> isVariableReference('')
False
>>> isVariableReference('$@')
True
>>> isVariableReference('$@a')
True
>>> isVariableReference('$@1')
False
600def resolveVarName(name: str, scope: Scope) -> str: 601 """ 602 Resolves a variable name as either a literal name, or if the name 603 starts with '$', via a variable reference in the given scope whose 604 value must be a string. 605 """ 606 if name.startswith('$'): 607 result = scope[name[1:]] 608 if not isinstance(result, str): 609 raise TypeError( 610 f"Variable '{name[1:]}' cannot be referenced as a" 611 f" variable name because it does not hold a string (its" 612 f" value is: {result!r}" 613 ) 614 return result 615 else: 616 return name
Resolves a variable name as either a literal name, or if the name starts with '$', via a variable reference in the given scope whose value must be a string.
619def fixArgs(command: str, requires: int, args: List[str]) -> List[str]: 620 """ 621 Checks that the proper number of arguments has been supplied, using 622 the command name as part of the message for a `ValueError` if not. 623 This will fill in '$_' and '$__' for the first two missing arguments 624 instead of generating an error, and returns the possibly modified 625 argument list. 626 """ 627 if not (requires - 2 <= len(args) <= requires): 628 raise ValueError( 629 f"Command '{command}' requires {requires} argument(s) but" 630 f" you provided {len(args)}." 631 ) 632 return (args + ['$_', '$__'])[:requires]
Checks that the proper number of arguments has been supplied, using
the command name as part of the message for a ValueError
if not.
This will fill in '$_' and '$__' for the first two missing arguments
instead of generating an error, and returns the possibly modified
argument list.
635def requiresValue(command: str, argDesc: str, arg: str) -> str: 636 """ 637 Checks that the given argument is a simple value, and raises a 638 `ValueError` if it's not. Otherwise just returns. The given command 639 name and argument description are used in the error message. The 640 `argDesc` should be an adjectival phrase, like 'first'. 641 642 Returns the argument given to it. 643 """ 644 if not isSimpleValue(arg): 645 raise ValueError( 646 f"The {argDesc} argument to '{command}' must be a simple" 647 f" value string (got {arg!r})." 648 ) 649 return arg
Checks that the given argument is a simple value, and raises a
ValueError
if it's not. Otherwise just returns. The given command
name and argument description are used in the error message. The
argDesc
should be an adjectival phrase, like 'first'.
Returns the argument given to it.
652def requiresLiteralOrVariable( 653 command: str, 654 argDesc: str, 655 options: Collection[str], 656 arg: str 657) -> str: 658 """ 659 Like `requiresValue` but only allows variable references or one of a 660 collection of specific strings as the argument. 661 """ 662 if not isVariableReference(arg) and arg not in options: 663 raise ValueError( 664 ( 665 f"The {argDesc} argument to '{command}' must be either" 666 f" a variable reference or one of the following strings" 667 f" (got {arg!r}):\n " 668 ) + '\n '.join(options) 669 ) 670 return arg
Like requiresValue
but only allows variable references or one of a
collection of specific strings as the argument.
673def requiresValueOrVariable(command: str, argDesc: str, arg: str) -> str: 674 """ 675 Like `requiresValue` but allows variable references as well as 676 simple values. 677 """ 678 if not (isSimpleValue(arg) or isVariableReference(arg)): 679 raise ValueError( 680 f"The {argDesc} argument to '{command}' must be a simple" 681 f" value or a variable reference (got {arg!r})." 682 ) 683 return arg
Like requiresValue
but allows variable references as well as
simple values.
686def requiresVariableName(command: str, argDesc: str, arg: str) -> str: 687 """ 688 Like `requiresValue` but allows only variable names, with or without 689 the leading '$'. 690 """ 691 if not (isVariableReference(arg) or isVariableReference('$' + arg)): 692 raise ValueError( 693 f"The {argDesc} argument to '{command}' must be a variable" 694 f" name without the '$' or a variable reference (got" 695 f" {arg!r})." 696 ) 697 return arg
Like requiresValue
but allows only variable names, with or without
the leading '$'.
779def command(commandType: str, *_args: str) -> Command: 780 """ 781 A convenience function for constructing a command tuple which 782 type-checks the arguments a bit. Raises a `ValueError` if invalid 783 information is supplied; otherwise it returns a `Command` tuple. 784 785 Up to two missing arguments will be replaced automatically with '$_' 786 and '$__' respectively in most cases. 787 788 Examples: 789 790 >>> command('val', '5') 791 LiteralValue(command='val', value='5') 792 >>> command('val', '"5"') 793 LiteralValue(command='val', value='"5"') 794 >>> command('val') 795 Traceback (most recent call last): 796 ... 797 ValueError... 798 >>> command('empty') 799 EstablishCollection(command='empty', collection='$_') 800 >>> command('empty', 'list') 801 EstablishCollection(command='empty', collection='list') 802 >>> command('empty', '$ref') 803 EstablishCollection(command='empty', collection='$ref') 804 >>> command('empty', 'invalid') # invalid argument 805 Traceback (most recent call last): 806 ... 807 ValueError... 808 >>> command('empty', 'list', 'dict') # too many arguments 809 Traceback (most recent call last): 810 ... 811 ValueError... 812 >>> command('append', '5') 813 AppendValue(command='append', value='5') 814 """ 815 args = list(_args) 816 817 spec = COMMAND_SETUP.get(commandType) 818 if spec is None: 819 raise ValueError( 820 f"Command type '{commandType}' cannot be constructed by" 821 f" assembleSimpleCommand (try the command function" 822 f" instead)." 823 ) 824 825 commandVariant, nArgs, checkers = spec 826 827 args = fixArgs(commandType, nArgs, args) 828 829 checkedArgs = [] 830 for i, (checker, arg) in enumerate(zip(checkers, args)): 831 argDesc = str(i + 1) 832 if argDesc.endswith('1') and nArgs != 11: 833 argDesc += 'st' 834 elif argDesc.endswith('2') and nArgs != 12: 835 argDesc += 'nd' 836 elif argDesc.endswith('3') and nArgs != 13: 837 argDesc += 'rd' 838 else: 839 argDesc += 'th' 840 841 if isinstance(checker, tuple): 842 checkName, allowed = checker 843 checkFn = globals()[checkName] 844 checkedArgs.append( 845 checkFn(commandType, argDesc, allowed, arg) 846 ) 847 else: 848 checkFn = globals()[checker] 849 checkedArgs.append(checkFn(commandType, argDesc, arg)) 850 851 return commandVariant( 852 commandType, 853 *checkedArgs 854 )
A convenience function for constructing a command tuple which
type-checks the arguments a bit. Raises a ValueError
if invalid
information is supplied; otherwise it returns a Command
tuple.
Up to two missing arguments will be replaced automatically with '$_' and '$__' respectively in most cases.
Examples:
>>> command('val', '5')
LiteralValue(command='val', value='5')
>>> command('val', '"5"')
LiteralValue(command='val', value='"5"')
>>> command('val')
Traceback (most recent call last):
...
ValueError...
>>> command('empty')
EstablishCollection(command='empty', collection='$_')
>>> command('empty', 'list')
EstablishCollection(command='empty', collection='list')
>>> command('empty', '$ref')
EstablishCollection(command='empty', collection='$ref')
>>> command('empty', 'invalid') # invalid argument
Traceback (most recent call last):
...
ValueError...
>>> command('empty', 'list', 'dict') # too many arguments
Traceback (most recent call last):
...
ValueError...
>>> command('append', '5')
AppendValue(command='append', value='5')
857def pushCurrentValue(scope: Scope, value: Any) -> None: 858 """ 859 Pushes the given value as the 'current value' for the given scope, 860 storing it in the '_' variable. Stores the old '_' value into '__' 861 if there was one. 862 """ 863 if '_' in scope: 864 scope['__'] = scope['_'] 865 scope['_'] = value
Pushes the given value as the 'current value' for the given scope, storing it in the '_' variable. Stores the old '_' value into '__' if there was one.