Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build_scripts/windows/scripts/az
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#!/usr/bin/env bash

# AZ_LAUNCHER=bash makes stdout default to LF line endings (core.output_newline), so $(az ...) in WSL or
# Git Bash has no trailing CR. WSL only passes variables listed in WSLENV to Windows programs.
export AZ_LAUNCHER=bash WSLENV="${WSLENV:+$WSLENV:}AZ_LAUNCHER/w"

AZ_INSTALLER=MSI "$(dirname "${BASH_SOURCE[0]}")/../python.exe" -IBm azure.cli "$@"
1 change: 1 addition & 0 deletions src/azure-cli-core/azure/cli/core/_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


_ENV_AZ_INSTALLER = 'AZ_INSTALLER'
_ENV_AZ_LAUNCHER = 'AZ_LAUNCHER'
_ENV_AZ_BICEP_GLOBALIZATION_INVARIANT = 'AZ_BICEP_GLOBALIZATION_INVARIANT'


Expand Down
61 changes: 61 additions & 0 deletions src/azure-cli-core/azure/cli/core/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,24 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import configparser
import os
import sys

import knack.output
from knack.log import get_logger

from azure.cli.core._environment import _ENV_AZ_LAUNCHER

logger = get_logger(__name__)

_OUTPUT_NEWLINE_NATIVE = 'native'
_OUTPUT_NEWLINE_LF = 'lf'
# core.output_newline value -> newline for stdout.reconfigure(); None leaves stdout untouched.
_OUTPUT_NEWLINES = {
_OUTPUT_NEWLINE_NATIVE: None,
_OUTPUT_NEWLINE_LF: '\n',
}


class AzOutputProducer(knack.output.OutputProducer):
Expand All @@ -19,3 +36,47 @@ def get_output_format(cli_ctx):
def set_output_format(cli_ctx, desired_format):
if cli_ctx.output.check_valid_format_type(desired_format):
cli_ctx.invocation.data["output"] = desired_format


def get_output_newline(cli_ctx):
"""Resolve `core.output_newline`: AZURE_CORE_OUTPUT_NEWLINE, then global config, then launcher default.

An empty value selects the default rather than falling back to the next source. The bash launcher shipped
with the Windows MSI (used by WSL and Git Bash) sets AZ_LAUNCHER=bash, which defaults to LF so
`$(az ... -o tsv)` does not capture a trailing carriage return.
"""
value = (cli_ctx.config.get('core', 'output_newline', fallback=None) or '').strip().lower()
if not value:
return _OUTPUT_NEWLINE_LF if os.environ.get(_ENV_AZ_LAUNCHER) == 'bash' else _OUTPUT_NEWLINE_NATIVE
return value


def apply_output_newline(cli_ctx, **_):
"""Stop Windows from translating '\\n' to '\\r\\n' in all text written to stdout (not just `-o` results)
when `core.output_newline` is `lf`.

Registered for EVENT_CLI_PRE_EXECUTE: logging is configured by then and no command output has been
written yet. Streams are reconfigured in place because knack keeps a reference to sys.stdout as `out_file`.
"""
try:
value = get_output_newline(cli_ctx)
except configparser.Error as ex:
logger.warning("Ignoring unreadable core.output_newline value: %s", ex)
return
if value not in _OUTPUT_NEWLINES:
logger.warning("Ignoring invalid core.output_newline value '%s'. Allowed values: %s",
value, ', '.join(_OUTPUT_NEWLINES))
return
newline = _OUTPUT_NEWLINES[value]
if newline is None:
return
streams = []
for stream in (getattr(cli_ctx, 'out_file', None), sys.stdout):
if stream is not None and hasattr(stream, 'reconfigure') and all(stream is not s for s in streams):
streams.append(stream)
for stream in streams:
try:
stream.reconfigure(newline=newline)
except (OSError, ValueError) as ex:
# Line endings are cosmetic, so never fail the command (e.g. stdout closed or detached).
logger.debug("Unable to set stdout newline to %r: %s", newline, ex)
124 changes: 124 additions & 0 deletions src/azure-cli-core/azure/cli/core/tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import os
import unittest
from io import BytesIO, TextIOWrapper
from types import SimpleNamespace
from unittest import mock


class TestCoreCLIOutput(unittest.TestCase):
Expand Down Expand Up @@ -42,5 +46,125 @@ def test_yaml_output_with_ordered_dict(self):
self.assertEqual(account_dict, yaml.safe_load(yaml_output))


class TestOutputNewline(unittest.TestCase):

def setUp(self):
import shutil
import tempfile
from knack.config import CLIConfig
self.config_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.config_dir, True)
env = mock.patch.dict(os.environ)
env.start()
self.addCleanup(env.stop)
for name in ('AZURE_CORE_OUTPUT_NEWLINE', 'AZURE_CONFIG_DIR', 'AZ_LAUNCHER'):
os.environ.pop(name, None)
self.cli = SimpleNamespace(config=CLIConfig(config_dir=self.config_dir, config_env_var_prefix='AZURE'),
out_file=None)

@staticmethod
def _windows_like_stream():
return TextIOWrapper(BytesIO(), encoding='utf-8', newline='\r\n')

@staticmethod
def _written(stream, text='a\r\nb\nc\n'):
stream.write(text)
stream.flush()
return stream.buffer.getvalue()

def test_output_newline_defaults_to_native(self):
from azure.cli.core._output import get_output_newline
self.assertEqual('native', get_output_newline(self.cli))

def test_output_newline_bash_launcher_defaults_to_lf(self):
from azure.cli.core._output import get_output_newline
os.environ['AZ_LAUNCHER'] = 'bash'
self.assertEqual('lf', get_output_newline(self.cli))

def test_output_newline_config_overrides_launcher(self):
from azure.cli.core._output import get_output_newline
os.environ['AZ_LAUNCHER'] = 'bash'
self.cli.config.set_value('core', 'output_newline', 'native')
self.assertEqual('native', get_output_newline(self.cli))

def test_output_newline_env_overrides_config(self):
from azure.cli.core._output import get_output_newline
self.cli.config.set_value('core', 'output_newline', 'native')
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = ' LF '
self.assertEqual('lf', get_output_newline(self.cli))

def test_output_newline_empty_value_selects_default(self):
from azure.cli.core._output import get_output_newline
os.environ['AZ_LAUNCHER'] = 'bash'
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = ''
self.assertEqual('lf', get_output_newline(self.cli))
# An empty env value selects the default; it does not fall back to the config file.
self.cli.config.set_value('core', 'output_newline', 'native')
self.assertEqual('lf', get_output_newline(self.cli))
del os.environ['AZURE_CORE_OUTPUT_NEWLINE']
del os.environ['AZ_LAUNCHER']
self.cli.config.set_value('core', 'output_newline', ' ')
self.assertEqual('native', get_output_newline(self.cli))

def test_apply_output_newline_lf_writes_data_unchanged(self):
from azure.cli.core._output import apply_output_newline
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = 'lf'
self.cli.out_file = self._windows_like_stream()
stdout = self._windows_like_stream()
with mock.patch('sys.stdout', stdout):
apply_output_newline(self.cli)
# LF stays LF, and CRLF already in the data is not doubled to '\r\r\n'.
self.assertEqual(b'a\r\nb\nc\n', self._written(self.cli.out_file))
self.assertEqual(b'a\r\nb\nc\n', self._written(stdout))

def test_apply_output_newline_native_leaves_stream_untouched(self):
from azure.cli.core._output import apply_output_newline
self.cli.out_file = self._windows_like_stream()
with mock.patch('sys.stdout', self.cli.out_file):
apply_output_newline(self.cli)
self.assertEqual(b'a\r\r\nb\r\nc\r\n', self._written(self.cli.out_file))

def test_apply_output_newline_invalid_value_warns(self):
from azure.cli.core import _output
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = 'crlf'
self.cli.out_file = self._windows_like_stream()
with mock.patch('sys.stdout', self.cli.out_file), mock.patch.object(_output.logger, 'warning') as warning:
_output.apply_output_newline(self.cli)
warning.assert_called_once()
self.assertIn('crlf', warning.call_args[0])
self.assertEqual(b'a\r\r\nb\r\nc\r\n', self._written(self.cli.out_file))

def test_apply_output_newline_unreadable_config_warns_instead_of_raising(self):
from azure.cli.core import _output
self.cli.config.set_value('core', 'output_newline', '%(missing)s')
self.cli.out_file = self._windows_like_stream()
with mock.patch('sys.stdout', self.cli.out_file), mock.patch.object(_output.logger, 'warning') as warning:
_output.apply_output_newline(self.cli)
warning.assert_called_once()
self.assertEqual(b'a\r\r\nb\r\nc\r\n', self._written(self.cli.out_file))

def test_apply_output_newline_does_not_raise_for_closed_stream(self):
from azure.cli.core._output import apply_output_newline
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = 'lf'
closed = self._windows_like_stream()
closed.close()
self.cli.out_file = closed
stdout = self._windows_like_stream()
with mock.patch('sys.stdout', stdout):
apply_output_newline(self.cli)
# The other stream is still reconfigured.
self.assertEqual(b'a\r\nb\nc\n', self._written(stdout))

def test_apply_output_newline_skips_streams_without_reconfigure(self):
from io import StringIO
from azure.cli.core._output import apply_output_newline
os.environ['AZURE_CORE_OUTPUT_NEWLINE'] = 'lf'
self.cli.out_file = StringIO()
with mock.patch('sys.stdout', self.cli.out_file):
apply_output_newline(self.cli)
self.cli.out_file.write('a\n')
self.assertEqual('a\n', self.cli.out_file.getvalue())


if __name__ == '__main__':
unittest.main()
4 changes: 4 additions & 0 deletions src/azure-cli/azure/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

from azure.cli.core import telemetry
from azure.cli.core import get_default_cli
from azure.cli.core._output import apply_output_newline
from azure.cli.core.intercept_survey import prompt_survey_message
from knack.completion import ARGCOMPLETE_ENV_NAME
from knack.events import EVENT_CLI_PRE_EXECUTE
from knack.log import get_logger

__author__ = "Microsoft Corporation <python@microsoft.com>"
Expand All @@ -29,6 +31,8 @@ def cli_main(cli, args):

az_cli = get_default_cli()

az_cli.register_event(EVENT_CLI_PRE_EXECUTE, apply_output_newline)

telemetry.set_application(az_cli, ARGCOMPLETE_ENV_NAME)

# Log the init finish time
Expand Down
4 changes: 4 additions & 0 deletions src/azure-cli/azure/cli/command_modules/config/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
text: az config set core.no_color=true
- name: Hide warnings and only show errors with `core.only_show_errors`.
text: az config set core.only_show_errors=true
- name: Write LF line endings to stdout in every shell, including cmd and PowerShell (the default under WSL and Git Bash).
text: az config set core.output_newline=lf
- name: Keep the platform's default line endings (CRLF on Windows), even when called from WSL or Git Bash.
text: az config set core.output_newline=native
- name: Turn on client-side telemetry.
text: az config set core.collect_telemetry=true
- name: Turn on file logging and set its location.
Expand Down
Loading