diff options
author | Georgy Frolov <gosha@fro.lv> | 2021-01-17 15:13:27 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-17 15:13:27 +0300 |
commit | 1dc5b237f008746657b13185f42e8551ddaa0b9b (patch) | |
tree | 1708cbcc9d8b82e49ed8f220c463ddbd51e15b55 | |
parent | 5a096020f157462cf0250d3232ac4461c66c7ded (diff) | |
parent | 53c83ce5376a962a19ab04c9457c28d6dac90d29 (diff) |
Merge branch 'master' into add-shortcut-for-login-path
29 files changed, 427 insertions, 110 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..413b749 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: mycli + +on: + pull_request: + paths-ignore: + - '**.md' + +jobs: + linux: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Start MySQL + run: | + sudo /etc/init.d/mysql start + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + pip install --no-cache-dir -e . + + - name: Wait for MySQL connection + run: | + while ! mysqladmin ping --host=localhost --port=3306 --user=root --password=root --silent; do + sleep 5 + done + + - name: Pytest / behave + env: + PYTEST_PASSWORD: root + run: | + ./setup.py test --pytest-args="--cov-report= --cov=mycli" + + - name: Lint + run: | + ./setup.py lint --branch=HEAD + + - name: Coverage + run: | + coverage combine + coverage report + codecov diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0afb5cc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" - -matrix: - include: - - python: 3.7 - dist: xenial - sudo: true - -install: - - pip install -r requirements-dev.txt - - pip install -e . - - sudo rm -f /etc/mysql/conf.d/performance-schema.cnf - - sudo service mysql restart - -script: - - ./setup.py test --pytest-args="--cov-report= --cov=mycli" - - coverage combine - - coverage report - - ./setup.py lint --branch=$TRAVIS_BRANCH - -after_success: - - codecov - -notifications: - webhooks: - urls: - - YOUR_WEBHOOK_URL - on_success: change # options: [always|never|change] default: always - on_failure: always # options: [always|never|change] default: always @@ -63,8 +63,8 @@ $ sudo apt-get install mycli # Only on debian or ubuntu --ssh-password TEXT Password to connect to ssh server. --ssh-key-filename TEXT Private key filename (identify file) for the ssh connection. - --ssh-config-path TEXT Path to ssh configuation. - --ssh-config-host TEXT Host for ssh server in ssh configuations (requires paramiko). + --ssh-config-path TEXT Path to ssh configuration. + --ssh-config-host TEXT Host for ssh server in ssh configurations (requires paramiko). --ssl-ca PATH CA file in PEM format. --ssl-capath TEXT CA directory. --ssl-cert PATH X509 cert in PEM format. @@ -97,6 +97,7 @@ $ sudo apt-get install mycli # Only on debian or ubuntu --login-path TEXT Read this path from the login file. -e, --execute TEXT Execute command and quit. --init-command TEXT SQL statement to execute after connecting. + --charset TEXT Character set for MySQL session. --help Show this message and exit. Features @@ -113,7 +114,7 @@ Features * Support for multiline queries. * Favorite queries with optional positional parameters. Save a query using `\fs alias query` and execute it with `\f alias` whenever you need. -* Timing of sql statments and table rendering. +* Timing of sql statements and table rendering. * Config file is automatically created at ``~/.myclirc`` at first launch. * Log every query and its results to a file (disabled by default). * Pretty prints tabular data (with colors!) diff --git a/changelog.md b/changelog.md index 14cdaab..3df6b86 100644 --- a/changelog.md +++ b/changelog.md @@ -1,11 +1,55 @@ -TBD +TODO === Features: --------- +* Add `-g` shortcut to option `--login-path`. + + +1.23.2 +=== + +Bug Fixes: +---------- +* Ensure `--port` is always an int. + +1.23.1 +=== + +Bug Fixes: +---------- +* Allow `--host` without `--port` to make a TCP connection. + +1.23.0 +=== + +Bug Fixes: +---------- +* Fix config file include logic + +Features: +--------- * Add an option `--init-command` to execute SQL after connecting (Thanks: [KITAGAWA Yasutaka]). -* Add `-g` shortcut to option `--login-path`. +* Use InputMode.REPLACE_SINGLE +* Add support for ANSI escape sequences for coloring the prompt. +* Allow customization of Pygments SQL syntax-highlighting styles. +* Add a `\clip` special command to copy queries to the system clipboard. +* Add a special command `\pipe_once` to pipe output to a subprocess. +* Add an option `--charset` to set the default charset when connect database. + +Bug Fixes: +---------- +* Fixed compatibility with sqlparse 0.4 (Thanks: [mtorromeo]). +* Fixed iPython magic (Thanks: [mwcm]). +* Send "Connecting to socket" message to the standard error. +* Respect empty string for prompt_continuation via `prompt_continuation = ''` in `.myclirc` +* Fix \once -o to overwrite output whole, instead of line-by-line. +* Dispatch lines ending with `\e` or `\clip` on return, even in multiline mode. +* Restore working local `--socket=<UDS>` (Thanks: [xeron]). +* Allow backtick quoting around the database argument to the `use` command. +* Avoid opening `/dev/tty` when `--no-warn` is given. +* Fixed some typo errors in `README.md`. 1.22.2 ====== @@ -786,3 +830,6 @@ Bug Fixes: [Georgy Frolov]: https://github.com/pasenor [Zach DeCook]: https://zachdecook.com [laixintao]: https://github.com/laixintao +[mtorromeo]: https://github.com/mtorromeo +[mwcm]: https://github.com/mwcm +[xeron]: https://github.com/xeron diff --git a/mycli/AUTHORS b/mycli/AUTHORS index 7702651..c871f51 100644 --- a/mycli/AUTHORS +++ b/mycli/AUTHORS @@ -76,6 +76,14 @@ Contributors: * kevinhwang91 * KITAGAWA Yasutaka * Nicolas Palumbo + * Andy Teijelo PĂ©rez + * bitkeen + * Morgan Mitchell + * Massimiliano Torromeo + * Roland Walker + * xeron + * 0xflotus + * Seamile Creator: -------- diff --git a/mycli/__init__.py b/mycli/__init__.py index 53bfe2e..375471f 100644 --- a/mycli/__init__.py +++ b/mycli/__init__.py @@ -1 +1 @@ -__version__ = '1.22.2' +__version__ = '1.23.2' diff --git a/mycli/clibuffer.py b/mycli/clibuffer.py index c9d29d1..c0cb5c1 100644 --- a/mycli/clibuffer.py +++ b/mycli/clibuffer.py @@ -39,6 +39,8 @@ def _multiline_exception(text): text.endswith('\\g') or text.endswith('\\G') or + text.endswith(r'\e') or + text.endswith(r'\clip') or # Exit doesn't need semi-column` (text == 'exit') or diff --git a/mycli/clistyle.py b/mycli/clistyle.py index 293f0f4..b0ac992 100644 --- a/mycli/clistyle.py +++ b/mycli/clistyle.py @@ -44,6 +44,36 @@ PROMPT_STYLE_TO_TOKEN = { v: k for k, v in TOKEN_TO_PROMPT_STYLE.items() } +# all tokens that the Pygments MySQL lexer can produce +OVERRIDE_STYLE_TO_TOKEN = { + 'sql.comment': Token.Comment, + 'sql.comment.multi-line': Token.Comment.Multiline, + 'sql.comment.single-line': Token.Comment.Single, + 'sql.comment.optimizer-hint': Token.Comment.Special, + 'sql.escape': Token.Error, + 'sql.keyword': Token.Keyword, + 'sql.datatype': Token.Keyword.Type, + 'sql.literal': Token.Literal, + 'sql.literal.date': Token.Literal.Date, + 'sql.symbol': Token.Name, + 'sql.quoted-schema-object': Token.Name.Quoted, + 'sql.quoted-schema-object.escape': Token.Name.Quoted.Escape, + 'sql.constant': Token.Name.Constant, + 'sql.function': Token.Name.Function, + 'sql.variable': Token.Name.Variable, + 'sql.number': Token.Number, + 'sql.number.binary': Token.Number.Bin, + 'sql.number.float': Token.Number.Float, + 'sql.number.hex': Token.Number.Hex, + 'sql.number.integer': Token.Number.Integer, + 'sql.operator': Token.Operator, + 'sql.punctuation': Token.Punctuation, + 'sql.string': Token.String, + 'sql.string.double-quouted': Token.String.Double, + 'sql.string.escape': Token.String.Escape, + 'sql.string.single-quoted': Token.String.Single, + 'sql.whitespace': Token.Text, +} def parse_pygments_style(token_name, style_object, style_dict): """Parse token type and style string. @@ -108,6 +138,9 @@ def style_factory_output(name, cli_style): elif token in PROMPT_STYLE_TO_TOKEN: token_type = PROMPT_STYLE_TO_TOKEN[token] style.update({token_type: cli_style[token]}) + elif token in OVERRIDE_STYLE_TO_TOKEN: + token_type = OVERRIDE_STYLE_TO_TOKEN[token] + style.update({token_type: cli_style[token]}) else: # TODO: cli helpers will have to switch to ptk.Style logger.error('Unhandled style / class name: %s', token) diff --git a/mycli/clitoolbar.py b/mycli/clitoolbar.py index e03e182..eec2978 100644 --- a/mycli/clitoolbar.py +++ b/mycli/clitoolbar.py @@ -48,5 +48,6 @@ def _get_vi_mode(): InputMode.INSERT: 'I', InputMode.NAVIGATION: 'N', InputMode.REPLACE: 'R', + InputMode.REPLACE_SINGLE: 'R', InputMode.INSERT_MULTIPLE: 'M', }[get_app().vi_state.input_mode] diff --git a/mycli/config.py b/mycli/config.py index e0f2d1f..9c592fb 100644 --- a/mycli/config.py +++ b/mycli/config.py @@ -244,7 +244,7 @@ def str_to_bool(s): elif s.lower() in false_values: return False else: - raise ValueError('not a recognized boolean value: %s'.format(s)) + raise ValueError('not a recognized boolean value: {0}'.format(s)) def strip_matching_quotes(s): diff --git a/mycli/magic.py b/mycli/magic.py index 5527f72..b1a3268 100644 --- a/mycli/magic.py +++ b/mycli/magic.py @@ -19,7 +19,7 @@ def load_ipython_extension(ipython): def mycli_line_magic(line): _logger.debug('mycli magic called: %r', line) parsed = sql.parse.parse(line, {}) - conn = sql.connection.Connection.get(parsed['connection']) + conn = sql.connection.Connection(parsed['connection']) try: # A corresponding mycli object already exists diff --git a/mycli/main.py b/mycli/main.py index 4200c5a..210e3d9 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -21,13 +21,14 @@ from cli_helpers.tabular_output import preprocessors from cli_helpers.utils import strip_ansi import click import sqlparse -from mycli.packages.parseutils import is_dropping_database +from mycli.packages.parseutils import is_dropping_database, is_destructive from prompt_toolkit.completion import DynamicCompleter from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode from prompt_toolkit.key_binding.bindings.named_commands import register as prompt_register from prompt_toolkit.shortcuts import PromptSession, CompleteStyle from prompt_toolkit.document import Document from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.layout.processors import (HighlightMatchingBracketProcessor, ConditionalProcessor) from prompt_toolkit.lexers import PygmentsLexer @@ -88,7 +89,7 @@ class MyCli(object): '/etc/my.cnf', '/etc/mysql/my.cnf', '/usr/local/etc/my.cnf', - '~/.my.cnf' + os.path.expanduser('~/.my.cnf'), ] # check XDG_CONFIG_HOME exists and not an empty string @@ -152,7 +153,7 @@ class MyCli(object): c['main'].as_bool('auto_vertical_output') # Write user config if system config wasn't the last config loaded. - if c.filename not in self.system_config_files: + if c.filename not in self.system_config_files and not os.path.exists(myclirc): write_default_config(self.default_config_file, myclirc) # audit log @@ -238,6 +239,9 @@ class MyCli(object): ) return + if arg.startswith('`') and arg.endswith('`'): + arg = re.sub(r'^`(.*)`$', r'\1', arg) + arg = re.sub(r'``', r'`', arg) self.sqlexecute.change_db(arg) yield (None, None, None, 'You are now connected to database "%s" as ' @@ -387,13 +391,13 @@ class MyCli(object): database = database or cnf['database'] # Socket interface not supported for SSH connections - if port or host or ssh_host or ssh_port: + if port or (host and host != 'localhost') or (ssh_host and ssh_port): socket = '' else: socket = socket or cnf['socket'] or guess_socket_location() user = user or cnf['user'] or os.getenv('USER') host = host or cnf['host'] - port = port or cnf['port'] + port = int(port or cnf['port'] or 3306) ssl = ssl or {} passwd = passwd if isinstance(passwd, str) else cnf['password'] @@ -438,7 +442,7 @@ class MyCli(object): if not WIN and socket: socket_owner = getpwuid(os.stat(socket).st_uid).pw_name self.echo( - f"Connecting to socket {socket}, owned by user {socket_owner}") + f"Connecting to socket {socket}, owned by user {socket_owner}", err=True) try: _connect() except OperationalError as e: @@ -481,7 +485,7 @@ class MyCli(object): exit(1) def handle_editor_command(self, text): - """Editor command is any query that is prefixed or suffixed by a '\e'. + r"""Editor command is any query that is prefixed or suffixed by a '\e'. The reason for a while loop is because a user might edit a query multiple times. For eg: @@ -511,6 +515,24 @@ class MyCli(object): continue return text + def handle_clip_command(self, text): + r"""A clip command is any query that is prefixed or suffixed by a + '\clip'. + + :param text: Document + :return: Boolean + + """ + + if special.clip_command(text): + query = (special.get_clip_query(text) or + self.get_last_query()) + message = special.copy_query_to_clipboard(sql=query) + if message: + raise RuntimeError(message) + return True + return False + def run_cli(self): iterations = 0 sqlexecute = self.sqlexecute @@ -548,10 +570,13 @@ class MyCli(object): prompt = self.get_prompt(self.prompt_format) if self.prompt_format == self.default_prompt and len(prompt) > self.max_len_prompt: prompt = self.get_prompt('\\d> ') - return [('class:prompt', prompt)] + prompt = prompt.replace("\\x1b", "\x1b") + return ANSI(prompt) def get_continuation(width, *_): - if self.multiline_continuation_char: + if self.multiline_continuation_char == '': + continuation = '' + elif self.multiline_continuation_char: left_padding = width - len(self.multiline_continuation_char) continuation = " " * \ max((left_padding - 1), 0) + \ @@ -580,6 +605,15 @@ class MyCli(object): self.echo(str(e), err=True, fg='red') return + try: + if self.handle_clip_command(text): + return + except RuntimeError as e: + logger.error("sql: %r, error: %r", text, e) + logger.error("traceback: %r", traceback.format_exc()) + self.echo(str(e), err=True, fg='red') + return + if not text.strip(): return @@ -654,6 +688,7 @@ class MyCli(object): result_count += 1 mutating = mutating or destroy or is_mutating(status) special.unset_once_if_written() + special.unset_pipe_once_if_written() except EOFError as e: raise e except KeyboardInterrupt: @@ -814,6 +849,7 @@ class MyCli(object): self.log_output(line) special.write_tee(line) special.write_once(line) + special.write_pipe_once(line) if fits or output_via_pager: # buffering @@ -1053,6 +1089,8 @@ class MyCli(object): help='Execute command and quit.') @click.option('--init-command', type=str, help='SQL statement to execute after connecting.') +@click.option('--charset', type=str, + help='Character set for MySQL session.') @click.argument('database', default='', nargs=1) def cli(database, user, host, port, socket, password, dbname, version, verbose, prompt, logfile, defaults_group_suffix, @@ -1061,7 +1099,7 @@ def cli(database, user, host, port, socket, password, dbname, ssl_verify_server_cert, table, csv, warn, execute, myclirc, dsn, list_dsn, ssh_user, ssh_host, ssh_port, ssh_password, ssh_key_filename, list_ssh_config, ssh_config_path, ssh_config_host, - init_command): + init_command, charset): """A MySQL terminal client with auto-completion and syntax highlighting. \b @@ -1186,7 +1224,8 @@ def cli(database, user, host, port, socket, password, dbname, ssh_port=ssh_port, ssh_password=ssh_password, ssh_key_filename=ssh_key_filename, - init_command=init_command + init_command=init_command, + charset=charset ) mycli.logger.debug('Launch Params: \n' @@ -1221,14 +1260,15 @@ def cli(database, user, host, port, socket, password, dbname, click.secho('Sorry... :(', err=True, fg='red') exit(1) - try: - sys.stdin = open('/dev/tty') - except (IOError, OSError): - mycli.logger.warning('Unable to open TTY as stdin.') + if mycli.destructive_warning and is_destructive(stdin_text): + try: + sys.stdin = open('/dev/tty') + warn_confirmed = confirm_destructive_query(stdin_text) + except (IOError, OSError): + mycli.logger.warning('Unable to open TTY as stdin.') + if not warn_confirmed: + exit(0) - if (mycli.destructive_warning and - confirm_destructive_query(stdin_text) is False): - exit(0) try: new_line = True @@ -1291,7 +1331,7 @@ def is_select(status): def thanks_picker(files=()): contents = [] for line in fileinput.input(files=files): - m = re.match('^ *\* (.*)', line) + m = re.match(r'^ *\* (.*)', line) if m: contents.append(m.group(1)) return choice(contents) diff --git a/mycli/myclirc b/mycli/myclirc index ba3ea1e..0bde200 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -41,6 +41,7 @@ table_format = ascii # friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default, # fruity. # Screenshots at http://mycli.net/syntax +# Can be further modified in [colors] syntax_style = default # Keybindings: Possible values: emacs, vi. @@ -65,6 +66,7 @@ wider_completion_menu = False # \t - Product type (Percona, MySQL, MariaDB) # \A - DSN alias name (from the [alias_dsn] section) # \u - Username +# \x1b[...m - insert ANSI escape sequence prompt = '\t \u@\h:\d> ' prompt_continuation = '->' @@ -113,6 +115,35 @@ output.odd-row = "" output.even-row = "" output.null = "#808080" +# SQL syntax highlighting overrides +# sql.comment = 'italic #408080' +# sql.comment.multi-line = '' +# sql.comment.single-line = '' +# sql.comment.optimizer-hint = '' +# sql.escape = 'border:#FF0000' +# sql.keyword = 'bold #008000' +# sql.datatype = 'nobold #B00040' +# sql.literal = '' +# sql.literal.date = '' +# sql.symbol = '' +# sql.quoted-schema-object = '' +# sql.quoted-schema-object.escape = '' +# sql.constant = '#880000' +# sql.function = '#0000FF' +# sql.variable = '#19177C' +# sql.number = '#666666' +# sql.number.binary = '' +# sql.number.float = '' +# sql.number.hex = '' +# sql.number.integer = '' +# sql.operator = '#666666' +# sql.punctuation = '' +# sql.string = '#BA2121' +# sql.string.double-quouted = '' +# sql.string.escape = 'bold #BB6622' +# sql.string.single-quoted = '' +# sql.whitespace = '' + # Favorite queries. [favorite_queries] diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 2b19c32..3cff2cc 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -2,7 +2,6 @@ import os import sys import sqlparse from sqlparse.sql import Comparison, Identifier, Where -from sqlparse.compat import text_type from .parseutils import last_word, extract_tables, find_prev_keyword from .special import parse_special_command @@ -55,7 +54,7 @@ def suggest_type(full_text, text_before_cursor): stmt_start, stmt_end = 0, 0 for statement in parsed: - stmt_len = len(text_type(statement)) + stmt_len = len(str(statement)) stmt_start, stmt_end = stmt_end, stmt_end + stmt_len if stmt_end >= current_pos: diff --git a/mycli/packages/parseutils.py b/mycli/packages/parseutils.py index e3b383e..268e04e 100644 --- a/mycli/packages/parseutils.py +++ b/mycli/packages/parseutils.py @@ -11,11 +11,11 @@ cleanup_regex = { # This matches everything except spaces, parens, colon, comma, and period 'most_punctuations': re.compile(r'([^\.():,\s]+)$'), # This matches everything except a space. - 'all_punctuations': re.compile('([^\s]+)$'), + 'all_punctuations': re.compile(r'([^\s]+)$'), } def last_word(text, include='alphanum_underscore'): - """ + r""" Find the last word in a sentence. >>> last_word('abc') diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 11dca8d..58066b8 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -8,6 +8,7 @@ from io import open from time import sleep import click +import pyperclip import sqlparse from . import export @@ -23,6 +24,8 @@ PAGER_ENABLED = True tee_file = None once_file = None written_to_once_file = False +pipe_once_process = None +written_to_pipe_once_process = False delimiter_command = DelimiterCommand() @@ -115,7 +118,7 @@ def get_editor_query(sql): # The reason we can't simply do .strip('\e') is that it strips characters, # not a substring. So it'll strip "e" in the end of the sql also! # Ex: "select * from style\e" -> "select * from styl". - pattern = re.compile('(^\\\e|\\\e$)') + pattern = re.compile(r'(^\\e|\\e$)') while pattern.search(sql): sql = pattern.sub('', sql) @@ -159,6 +162,47 @@ def open_external_editor(filename=None, sql=None): return (query, message) +@export +def clip_command(command): + """Is this a clip command? + + :param command: string + + """ + # It is possible to have `\clip` or `SELECT * FROM \clip`. So we check + # for both conditions. + return command.strip().endswith('\\clip') or command.strip().startswith('\\clip') + + +@export +def get_clip_query(sql): + """Get the query part of a clip command.""" + sql = sql.strip() + + # The reason we can't simply do .strip('\clip') is that it strips characters, + # not a substring. So it'll strip "c" in the end of the sql also! + pattern = re.compile(r'(^\\clip|\\clip$)') + while pattern.search(sql): + sql = pattern.sub('', sql) + + return sql + + +@export +def copy_query_to_clipboard(sql=None): + """Send query to the clipboard.""" + + sql = sql or '' + message = None + + try: + pyperclip.copy(u'{sql}'.format(sql=sql)) + except RuntimeError as e: + message = 'Error clipping query: %s.' % e.strerror + + return message + + @special_command('\\f', '\\f [name [args..]]', 'List or execute favorite queries.', arg_type=PARSED_QUERY, case_sensitive=True) def execute_favorite_query(cur, arg, **_): """Returns (title, rows, headers, status)""" @@ -213,7 +257,7 @@ def subst_favorite_query_args(query, args): query = query.replace(subst_var, val) - match = re.search('\\$\d+', query) + match = re.search(r'\$\d+', query) if match: return[None, 'missing substitution for ' + match.group(0) + ' in query:\n ' + query] @@ -337,7 +381,11 @@ def write_tee(output): def set_once(arg, **_): global once_file, written_to_once_file - once_file = parseargfile(arg) + try: + once_file = open(**parseargfile(arg)) + except (IOError, OSError) as e: + raise OSError("Cannot write to file '{}': {}".format( + e.filename, e.strerror)) written_to_once_file = False return [(None, None, None, "")] @@ -347,26 +395,68 @@ def set_once(arg, **_): def write_once(output): global once_file, written_to_once_file if output and once_file: - try: - f = open(**once_file) - except (IOError, OSError) as e: - once_file = None - raise OSError("Cannot write to file '{}': {}".format( - e.filename, e.strerror)) - with f: - click.echo(output, file=f, nl=False) - click.echo(u"\n", file=f, nl=False) + click.echo(output, file=once_file, nl=False) + click.echo(u"\n", file=once_file, nl=False) + once_file.flush() written_to_once_file = True @export def unset_once_if_written(): """Unset the once file, if it has been written to.""" - global once_file - if written_to_once_file: + global once_file, written_to_once_file + if written_to_once_fil |