1"""An interactive command-line shell interpreter for the Beancount Query Language. 2""" 3__copyright__ = "Copyright (C) 2014-2016 Martin Blais" 4__license__ = "GNU GPLv2" 5 6import atexit 7import cmd 8import codecs 9import io 10import logging 11import os 12import re 13import sys 14import shlex 15import textwrap 16import traceback 17from os import path 18 19try: 20 import readline 21except ImportError: 22 readline = None 23 24from beancount.query import query_parser 25from beancount.query import query_compile 26from beancount.query import query_env 27from beancount.query import query_execute 28from beancount.query import query_render 29from beancount.query import numberify 30from beancount.parser import printer 31from beancount.core import data 32from beancount.utils import misc_utils 33from beancount.utils import pager 34from beancount.parser import version 35from beancount import loader 36 37 38HISTORY_FILENAME = "~/.bean-shell-history" 39 40 41def load_history(filename): 42 """Load the shell's past history. 43 44 Args: 45 filename: A string, the name of the file containing the shell history. 46 """ 47 readline.parse_and_bind("tab:complete") 48 if hasattr(readline, "read_history_file"): 49 try: 50 readline.read_history_file(filename) 51 except IOError: 52 # Don't error on absent file. 53 pass 54 atexit.register(save_history, filename) 55 56 57def save_history(filename): 58 """Save the shell history. This should be invoked on exit. 59 60 Args: 61 filename: A string, the name of the file to save the history to. 62 """ 63 readline.write_history_file(filename) 64 65 66def get_history(max_entries): 67 """Return the history in the readline buffer. 68 69 Args: 70 max_entries: An integer, the maximum number of entries to return. 71 Returns: 72 A list of string, the previous history of commands. 73 """ 74 num_entries = readline.get_current_history_length() 75 assert num_entries >= 0 76 start = max(0, num_entries - max_entries) 77 return [readline.get_history_item(index+1) 78 for index in range(start, num_entries)] 79 80 81def convert_bool(string): 82 """Convert a string to a boolean. 83 84 Args: 85 string: A string representing a boolean. 86 Returns: 87 The corresponding boolean. 88 """ 89 return not string.lower() in ('f', 'false', '0') 90 91 92class DispatchingShell(cmd.Cmd): 93 """A usable convenient shell for interpreting commands, with history.""" 94 95 # The maximum number of entries. 96 max_entries = 64 97 98 # Header for parsed commands. 99 doc_header = "Shell utility commands (type help <topic>):" 100 misc_header = "Beancount query commands:" 101 102 def __init__(self, is_interactive, parser, outfile, default_format, do_numberify): 103 """Create a shell with history. 104 105 Args: 106 is_interactive: A boolean, true if this serves an interactive tty. 107 parser: A command parser. 108 outfile: An output file object to write communications to. 109 default_format: A string, the default output format. 110 """ 111 super().__init__() 112 if is_interactive and readline is not None: 113 load_history(path.expanduser(HISTORY_FILENAME)) 114 self.is_interactive = is_interactive 115 self.parser = parser 116 self.initialize_vars(default_format, do_numberify) 117 self.add_help() 118 self.outfile = outfile 119 120 def initialize_vars(self, default_format, do_numberify): 121 """Initialize the setting variables of the interactive shell.""" 122 self.vars_types = { 123 'pager': str, 124 'format': str, 125 'boxed': convert_bool, 126 'spaced': convert_bool, 127 'expand': convert_bool, 128 'numberify': convert_bool, 129 } 130 self.vars = { 131 'pager': os.environ.get('PAGER', None), 132 'format': default_format, 133 'boxed': False, 134 'spaced': False, 135 'expand': False, 136 'numberify': do_numberify, 137 } 138 139 def add_help(self): 140 "Attach help functions for each of the parsed token handlers." 141 for attrname, func in list(self.__class__.__dict__.items()): 142 match = re.match('on_(.*)', attrname) 143 if not match: 144 continue 145 command_name = match.group(1) 146 setattr(self.__class__, 'help_{}'.format(command_name.lower()), 147 lambda _, fun=func: print(textwrap.dedent(fun.__doc__).strip(), 148 file=self.outfile)) 149 150 def get_pager(self): 151 """Create and return a context manager to write to, a pager subprocess if required. 152 153 Returns: 154 A pair of a file object to write to, and a pipe object to wait on (or 155 None if not necessary to wait). 156 """ 157 if self.is_interactive: 158 return pager.ConditionalPager(self.vars.get('pager', None), 159 minlines=misc_utils.get_screen_height()) 160 else: 161 file = (codecs.getwriter("utf-8")(sys.stdout.buffer) 162 if hasattr(sys.stdout, 'buffer') else 163 sys.stdout) 164 return pager.flush_only(file) 165 166 def cmdloop(self): 167 """Override cmdloop to handle keyboard interrupts.""" 168 while True: 169 try: 170 super().cmdloop() 171 break 172 except KeyboardInterrupt: 173 print('\n(Interrupted)', file=self.outfile) 174 175 def do_help(self, command): 176 """Strip superfluous semicolon.""" 177 super().do_help(command.rstrip('; \t')) 178 179 def do_history(self, _): 180 "Print the command-line history statement." 181 if readline is not None: 182 for index, line in enumerate(get_history(self.max_entries)): 183 print(line, file=self.outfile) 184 185 def do_clear(self, _): 186 "Clear the history." 187 readline.clear_history() 188 189 def do_set(self, line): 190 "Get/set shell settings variables." 191 if not line: 192 for varname, value in sorted(self.vars.items()): 193 print('{}: {}'.format(varname, value), file=self.outfile) 194 else: 195 components = shlex.split(line) 196 varname = components[0] 197 if len(components) == 1: 198 try: 199 value = self.vars[varname] 200 print('{}: {}'.format(varname, value), file=self.outfile) 201 except KeyError: 202 print("Variable '{}' does not exist.".format(varname), 203 file=self.outfile) 204 elif len(components) == 2: 205 value = components[1] 206 try: 207 converted_value = self.vars_types[varname](value) 208 self.vars[varname] = converted_value 209 print('{}: {}'.format(varname, converted_value), file=self.outfile) 210 except KeyError: 211 print("Variable '{}' does not exist.".format(varname), 212 file=self.outfile) 213 else: 214 print("Invalid number of arguments.", file=self.outfile) 215 216 def do_lex(self, line): 217 "Just run the lexer on the following command and print the output." 218 try: 219 self.parser.tokenize(line) 220 except query_parser.ParseError as exc: 221 print(exc, file=self.outfile) 222 223 do_tokenize = do_lex 224 225 def do_parse(self, line): 226 "Just run the parser on the following command and print the output." 227 print("INPUT: {}".format(repr(line)), file=self.outfile) 228 try: 229 statement = self.parser.parse(line, True) 230 print(statement, file=self.outfile) 231 except (query_parser.ParseError, 232 query_compile.CompilationError) as exc: 233 print(exc, file=self.outfile) 234 except Exception as exc: 235 traceback.print_exc(file=self.outfile) 236 237 def dispatch(self, statement): 238 """Dispatch the given statement to a suitable method. 239 240 Args: 241 statement: An instance provided by the parser. 242 Returns: 243 Whatever the invoked method happens to return. 244 """ 245 try: 246 method = getattr(self, 'on_{}'.format(type(statement).__name__)) 247 except AttributeError: 248 print("Internal error: statement '{}' is unsupported.".format(statement), 249 file=self.outfile) 250 else: 251 return method(statement) 252 253 def default(self, line): 254 """Default handling of lines which aren't recognized as native shell commands. 255 256 Args: 257 line: The string to be parsed. 258 """ 259 self.run_parser(line) 260 261 def run_parser(self, line, default_close_date=None): 262 """Handle statements via our parser instance and dispatch to appropriate methods. 263 264 Args: 265 line: The string to be parsed. 266 default_close_date: A datetimed.date instance, the default close date. 267 """ 268 try: 269 statement = self.parser.parse(line, 270 default_close_date=default_close_date) 271 self.dispatch(statement) 272 except query_parser.ParseError as exc: 273 print(exc, file=self.outfile) 274 except Exception as exc: 275 traceback.print_exc(file=self.outfile) 276 277 def emptyline(self): 278 """Do nothing on an empty line.""" 279 280 def exit(self, _): 281 """Exit the parser.""" 282 print('exit', file=self.outfile) 283 return 1 284 285 # Commands to exit. 286 do_exit = exit 287 do_quit = exit 288 do_EOF = exit 289 290 291class BQLShell(DispatchingShell): 292 """An interactive shell interpreter for the Beancount query language. 293 """ 294 prompt = 'beancount> ' 295 296 def __init__(self, is_interactive, loadfun, outfile, 297 default_format='text', do_numberify=False): 298 super().__init__(is_interactive, query_parser.Parser(), outfile, 299 default_format, do_numberify) 300 301 self.loadfun = loadfun 302 self.entries = None 303 self.errors = None 304 self.options_map = None 305 306 self.env_targets = query_env.TargetsEnvironment() 307 self.env_entries = query_env.FilterEntriesEnvironment() 308 self.env_postings = query_env.FilterPostingsEnvironment() 309 310 def on_Reload(self, unused_statement=None): 311 """ 312 Reload the input file without restarting the shell. 313 """ 314 self.entries, self.errors, self.options_map = self.loadfun() 315 if self.is_interactive: 316 print_statistics(self.entries, self.options_map, self.outfile) 317 318 def on_Errors(self, errors_statement): 319 """ 320 Print the errors that occurred during parsing. 321 """ 322 if self.errors: 323 printer.print_errors(self.errors) 324 else: 325 print('(No errors)', file=self.outfile) 326 327 def on_Print(self, print_stmt): 328 """ 329 Print entries in Beancount format. 330 331 The general form of a PRINT statement includes an SQL-like FROM 332 selector: 333 334 PRINT [FROM <from_expr> ...] 335 336 Where: 337 338 from_expr: A logical expression that matches on the attributes of 339 the directives. See SELECT command for details (this FROM expression 340 supports all the same expressions including its OPEN, CLOSE and 341 CLEAR operations). 342 343 """ 344 # Compile the print statement. 345 try: 346 c_print = query_compile.compile(print_stmt, 347 self.env_targets, 348 self.env_postings, 349 self.env_entries) 350 except query_compile.CompilationError as exc: 351 print('ERROR: {}.'.format(str(exc).rstrip('.')), file=self.outfile) 352 return 353 354 if self.outfile is sys.stdout: 355 query_execute.execute_print(c_print, self.entries, self.options_map, 356 file=self.outfile) 357 else: 358 with self.get_pager() as file: 359 query_execute.execute_print(c_print, self.entries, self.options_map, file) 360 361 def on_Select(self, statement): 362 """ 363 Extract data from a query on the postings. 364 365 The general form of a SELECT statement loosely follows SQL syntax, with 366 some mild and idiomatic extensions: 367 368 SELECT [DISTINCT] [<targets>|*] 369 [FROM <from_expr> [OPEN ON <date>] [CLOSE [ON <date>]] [CLEAR]] 370 [WHERE <where_expr>] 371 [GROUP BY <groups>] 372 [ORDER BY <groups> [ASC|DESC]] 373 [LIMIT num] 374 375 Where: 376 377 targets: A list of desired output attributes from the postings, and 378 expressions on them. Some of the attributes of the parent transaction 379 directive are made available in this context as well. Simple functions 380 (that return a single value per row) and aggregation functions (that 381 return a single value per group) are available. For the complete 382 list of supported columns and functions, see help on "targets". 383 You can also provide a wildcard here, which will select a reasonable 384 default set of columns for rendering a journal. 385 386 from_expr: A logical expression that matches on the attributes of 387 the directives (not postings). This allows you to select a subset of 388 transactions, so the accounting equation is respected for balance 389 reports. For the complete list of supported columns and functions, 390 see help on "from". 391 392 where_expr: A logical expression that matches on the attributes of 393 postings. The available columns are similar to those in the targets 394 clause, without the aggregation functions. 395 396 OPEN clause: replace all the transactions before the given date by 397 summarizing entries and transfer Income and Expenses balances to 398 Equity. 399 400 CLOSE clause: Remove all the transactions after the given date and 401 402 CLEAR: Transfer final Income and Expenses balances to Equity. 403 404 """ 405 # Compile the SELECT statement. 406 try: 407 c_query = query_compile.compile(statement, 408 self.env_targets, 409 self.env_postings, 410 self.env_entries) 411 except query_compile.CompilationError as exc: 412 print('ERROR: {}.'.format(str(exc).rstrip('.')), file=self.outfile) 413 return 414 415 # Execute it to obtain the result rows. 416 rtypes, rrows = query_execute.execute_query(c_query, 417 self.entries, 418 self.options_map) 419 420 # Output the resulting rows. 421 if not rrows: 422 print("(empty)", file=self.outfile) 423 else: 424 output_format = self.vars['format'] 425 if output_format == 'text': 426 kwds = dict(boxed=self.vars['boxed'], 427 spaced=self.vars['spaced'], 428 expand=self.vars['expand']) 429 if self.outfile is sys.stdout: 430 with self.get_pager() as file: 431 query_render.render_text(rtypes, rrows, 432 self.options_map['dcontext'], 433 file, 434 **kwds) 435 else: 436 query_render.render_text(rtypes, rrows, 437 self.options_map['dcontext'], 438 self.outfile, 439 **kwds) 440 441 elif output_format == 'csv': 442 # Numberify CSV output if requested. 443 if self.vars['numberify']: 444 dformat = self.options_map['dcontext'].build() 445 rtypes, rrows = numberify.numberify_results(rtypes, rrows, dformat) 446 447 query_render.render_csv(rtypes, rrows, 448 self.options_map['dcontext'], 449 self.outfile, 450 expand=self.vars['expand']) 451 452 else: 453 assert output_format not in _SUPPORTED_FORMATS 454 print("Unsupported output format: '{}'.".format(output_format), 455 file=self.outfile) 456 457 458 def on_Journal(self, journal): 459 """ 460 Select a journal of some subset of postings. This command is a 461 convenience and converts into an equivalent Select statement, designed 462 to extract the most sensible list of columns for the register of a list 463 of entries as a table. 464 465 The general form of a JOURNAL statement loosely follows SQL syntax: 466 467 JOURNAL <account-regexp> [FROM_CLAUSE] 468 469 See the SELECT query help for more details on the FROM clause. 470 """ 471 return self.on_Select(journal) 472 473 def on_Balances(self, balance): 474 """ 475 Select balances of some subset of postings. This command is a 476 convenience and converts into an equivalent Select statement, designed 477 to extract the most sensible list of columns for the register of a list 478 of entries as a table. 479 480 The general form of a JOURNAL statement loosely follows SQL syntax: 481 482 BALANCE [FROM_CLAUSE] 483 484 See the SELECT query help for more details on the FROM clause. 485 """ 486 return self.on_Select(balance) 487 488 def on_Explain(self, explain): 489 """ 490 Compile and print a compiled statement for debugging. 491 """ 492 pr = lambda *args: print(*args, file=self.outfile) 493 pr("Parsed statement:") 494 pr(" {}".format(explain.statement)) 495 pr() 496 497 # Compile the select statement and print it uot. 498 try: 499 query = query_compile.compile(explain.statement, 500 self.env_targets, 501 self.env_postings, 502 self.env_entries) 503 except query_compile.CompilationError as exc: 504 pr(str(exc).rstrip('.')) 505 return 506 507 pr("Compiled query:") 508 pr(" {}".format(query)) 509 pr() 510 pr("Targets:") 511 for c_target in query.c_targets: 512 pr(" '{}'{}: {}".format( 513 c_target.name or '(invisible)', 514 ' (aggregate)' if query_compile.is_aggregate(c_target.c_expr) else '', 515 c_target.c_expr.dtype.__name__)) 516 pr() 517 518 def on_RunCustom(self, run_stmt): 519 """ 520 Run a custom query instead of a SQL command. 521 522 RUN <custom-query-name> 523 524 Where: 525 526 custom-query-name: Should be the name of a custom query to be defined 527 in the Beancount input file. 528 529 """ 530 custom_query_map = create_custom_query_map(self.entries) 531 name = run_stmt.query_name 532 if name is None: 533 # List the available queries. 534 for name in sorted(custom_query_map): 535 print(name) 536 elif name == "*": 537 for name, query in sorted(custom_query_map.items()): 538 print('{}:'.format(name)) 539 self.run_parser(query.query_string, default_close_date=query.date) 540 print() 541 print() 542 else: 543 query = None 544 if name in custom_query_map: 545 query = custom_query_map[name] 546 else: # lookup best query match using name as prefix 547 queries = [q for q in custom_query_map if q.startswith(name)] 548 if len(queries) == 1: 549 name = queries[0] 550 query = custom_query_map[name] 551 if query: 552 statement = self.parser.parse(query.query_string) 553 self.dispatch(statement) 554 else: 555 print("ERROR: Query '{}' not found".format(name)) 556 557 def help_targets(self): 558 template = textwrap.dedent(""" 559 560 The list of comma-separated target expressions may consist of columns, 561 simple functions and aggregate functions. If you use any aggregate 562 function, you must also provide a GROUP-BY clause. 563 564 Available columns: 565 {columns} 566 567 Simple functions: 568 {functions} 569 570 Aggregate functions: 571 {aggregates} 572 573 """).strip() 574 print(template.format(**generate_env_attribute_list(self.env_targets)), 575 file=self.outfile) 576 577 def help_from(self): 578 template = textwrap.dedent(""" 579 580 A logical expression that consist of columns on directives (mostly 581 transactions) and simple functions. 582 583 Available columns: 584 {columns} 585 586 Simple functions: 587 {functions} 588 589 """).strip() 590 print(template.format(**generate_env_attribute_list(self.env_entries)), 591 file=self.outfile) 592 593 def help_where(self): 594 template = textwrap.dedent(""" 595 596 A logical expression that consist of columns on postings and simple 597 functions. 598 599 Available columns: 600 {columns} 601 602 Simple functions: 603 {functions} 604 605 """).strip() 606 print(template.format(**generate_env_attribute_list(self.env_postings)), 607 file=self.outfile) 608 609 def help_attributes(self): 610 template = textwrap.dedent(""" 611 612 The attribute names on postings and directives equivalent to the names 613 of columns that we make available for query. 614 615 Entries: 616 {entry_attributes} 617 618 Postings: 619 {posting_attributes} 620 621 """).strip() 622 623 entry_pairs = sorted( 624 (getattr(column_cls, '__equivalent__', '-'), name) 625 for name, column_cls in sorted(self.env_entries.columns.items())) 626 627 posting_pairs = sorted( 628 (getattr(column_cls, '__equivalent__', '-'), name) 629 for name, column_cls in sorted(self.env_postings.columns.items())) 630 631 # pylint: disable=possibly-unused-variable 632 entry_attributes = ''.join( 633 " {:40}: {}\n".format(*pair) for pair in entry_pairs) 634 posting_attributes = ''.join( 635 " {:40}: {}\n".format(*pair) for pair in posting_pairs) 636 print(template.format(**locals()), file=self.outfile) 637 638 639def generate_env_attribute_list(env): 640 """Generate a dictionary of rendered attribute lists for help. 641 642 Args: 643 env: An instance of an environment. 644 Returns: 645 A dict with keys 'columns', 'functions' and 'aggregates' to rendered 646 and formatted strings. 647 """ 648 wrapper = textwrap.TextWrapper(initial_indent=' ', 649 subsequent_indent=' ', 650 drop_whitespace=True, 651 width=80) 652 653 str_columns = generate_env_attributes( 654 wrapper, env.columns) 655 str_simple = generate_env_attributes( 656 wrapper, env.functions, 657 lambda node: not issubclass(node, query_compile.EvalAggregator)) 658 str_aggregate = generate_env_attributes( 659 wrapper, env.functions, 660 lambda node: issubclass(node, query_compile.EvalAggregator)) 661 662 return dict(columns=str_columns, 663 functions=str_simple, 664 aggregates=str_aggregate) 665 666 667def generate_env_attributes(wrapper, field_dict, filter_pred=None): 668 """Generate a string of all the help functions of the attributes. 669 670 Args: 671 wrapper: A TextWrapper instance to format the paragraphs. 672 field_dict: A dict of the field-names to the node instances, fetch from an 673 environment. 674 filter_pred: A predicate to filter the desired columns. This is applied to 675 the evaluator node instances. 676 Returns: 677 A formatted multiline string, ready for insertion in a help text. 678 """ 679 # Expand the name if its key has argument types. 680 # 681 # FIXME: Render the __intypes__ here nicely instead of the key. 682 flat_items = [] 683 for name, column_cls in field_dict.items(): 684 if isinstance(name, tuple): 685 name = name[0] 686 687 if issubclass(column_cls, query_compile.EvalFunction): 688 name = name.upper() 689 args = [] 690 for dtypes in column_cls.__intypes__: 691 if isinstance(dtypes, (tuple, list)): 692 arg = '|'.join(dtype.__name__ for dtype in dtypes) 693 else: 694 arg = dtypes.__name__ 695 args.append(arg) 696 name = "{}({})".format(name, ','.join(args)) 697 698 flat_items.append((name, column_cls)) 699 700 # Render each of the attributes. 701 oss = io.StringIO() 702 for name, column_cls in sorted(flat_items): 703 if filter_pred and not filter_pred(column_cls): 704 continue 705 docstring = column_cls.__doc__ or "[See class {}]".format(column_cls.__name__) 706 if issubclass(column_cls, query_compile.EvalColumn): 707 docstring += " Type: {}.".format(column_cls().dtype.__name__) 708 # if hasattr(column_cls, '__equivalent__'): 709 # docstring += " Attribute:{}.".format(column_cls.__equivalent__) 710 711 text = re.sub('[ \t]+', ' ', docstring.strip().replace('\n', ' ')) 712 doc = "'{}': {}".format(name, text) 713 oss.write(wrapper.fill(doc)) 714 oss.write('\n') 715 716 return oss.getvalue().rstrip() 717 718 719def summary_statistics(entries): 720 """Calculate basic summary statistics to output a brief welcome message. 721 722 Args: 723 entries: A list of directives. 724 Returns: 725 A tuple of three integers, the total number of directives parsed, the total number 726 of transactions and the total number of postings there in. 727 """ 728 num_directives = len(entries) 729 num_transactions = 0 730 num_postings = 0 731 for entry in entries: 732 if isinstance(entry, data.Transaction): 733 num_transactions += 1 734 num_postings += len(entry.postings) 735 return (num_directives, num_transactions, num_postings) 736 737 738def print_statistics(entries, options_map, outfile): 739 """Print summary statistics to stdout. 740 741 Args: 742 entries: A list of directives. 743 options_map: An options map. as produced by the parser. 744 outfile: A file object to write to. 745 """ 746 num_directives, num_transactions, num_postings = summary_statistics(entries) 747 if 'title' in options_map: 748 print('Input file: "{}"'.format(options_map['title']), file=outfile) 749 print("Ready with {} directives ({} postings in {} transactions).".format( 750 num_directives, num_postings, num_transactions), 751 file=outfile) 752 753 754def create_custom_query_map(entries): 755 """Extract a mapping of the custom queries from the list of entries. 756 757 Args: 758 entries: A list of entries. 759 Returns: 760 A map of query-name strings to Query directives. 761 """ 762 query_map = {} 763 for entry in entries: 764 if not isinstance(entry, data.Query): 765 continue 766 if entry.name in query_map: 767 logging.warning("Duplicate query: %s", entry.name) 768 query_map[entry.name] = entry 769 return query_map 770 771 772_SUPPORTED_FORMATS = ('text', 'csv') 773 774 775def main(): 776 parser = version.ArgumentParser(description=__doc__) 777 778 parser.add_argument('-f', '--format', action='store', default=_SUPPORTED_FORMATS[0], 779 choices=_SUPPORTED_FORMATS, # 'html', 'htmldiv', 'beancount', 'xls', 780 help="Output format.") 781 782 parser.add_argument('-m', '--numberify', action='store_true', default=False, 783 help="Numberify the output, removing the currencies.") 784 785 parser.add_argument('-o', '--output', action='store', 786 help=("Output filename. If not specified, the output goes " 787 "to stdout. The filename is inspected to select a " 788 "sensible default format, if one is not requested.")) 789 790 parser.add_argument('-q', '--no-errors', action='store_true', 791 help='Do not report errors') 792 793 parser.add_argument('filename', metavar='FILENAME.beancount', 794 help='The Beancount input filename to load') 795 796 parser.add_argument('query', nargs='*', 797 help='A query to run directly') 798 799 args = parser.parse_args() 800 801 # Parse the input file. 802 def load(): 803 errors_file = None if args.no_errors else sys.stderr 804 with misc_utils.log_time('beancount.loader (total)', logging.info): 805 return loader.load_file(args.filename, 806 log_timings=logging.info, 807 log_errors=errors_file) 808 809 # Create a receiver for output. 810 outfile = sys.stdout if args.output is None else open(args.output, 'w') 811 812 # Create the shell. 813 is_interactive = sys.stdin.isatty() and not args.query 814 shell_obj = BQLShell(is_interactive, load, outfile, args.format, args.numberify) 815 shell_obj.on_Reload() 816 817 # Run interactively if we're a TTY and no query is supplied. 818 if is_interactive: 819 try: 820 shell_obj.cmdloop() 821 except KeyboardInterrupt: 822 print('\nExit') 823 else: 824 # Run in batch mode (Non-interactive). 825 if args.query: 826 # We have a query to run. 827 query = ' '.join(args.query) 828 else: 829 # If we have no query and we're not a TTY, read the BQL command from 830 # standard input. 831 query = sys.stdin.read() 832 833 shell_obj.onecmd(query) 834 835 return 0 836 837 838if __name__ == '__main__': 839 main() 840