From fe5545273c0e70f48e6dc8f6a718feb8a4cedde0 Mon Sep 17 00:00:00 2001 From: Phil Sphicas <46357247+philsphicas@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:59:53 -0700 Subject: [PATCH 1/2] [Core] Add `core.output_newline` and default to LF when launched from bash on Windows On Windows, Python's text-mode stdout translates every "\n" to "\r\n", so `FOO=$(az ... -o tsv)` from WSL or Git Bash captures a trailing "\r", and "\r\n" already present in data is written as "\r\r\n". - New config `core.output_newline` (`native` | `lf`, env AZURE_CORE_OUTPUT_NEWLINE). `lf` reconfigures stdout in place with newline="\n"; `native` keeps today's behavior. - The MSI bash launcher (wbin/az, used by WSL and Git Bash) sets AZ_LAUNCHER=bash, which defaults the setting to `lf`. Explicit env/config always wins. - The launcher adds AZ_LAUNCHER to WSLENV so it reaches the Windows process under WSL. - cmd/PowerShell behavior is unchanged unless the setting is configured. Fixes #33385 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8fa45af-9457-40bb-b8c7-3b159937f2e4 --- build_scripts/windows/scripts/az | 4 + .../azure/cli/core/_environment.py | 1 + src/azure-cli-core/azure/cli/core/_output.py | 57 +++++++++ .../azure/cli/core/tests/test_output.py | 112 ++++++++++++++++++ src/azure-cli/azure/cli/__main__.py | 4 + 5 files changed, 178 insertions(+) diff --git a/build_scripts/windows/scripts/az b/build_scripts/windows/scripts/az index 6600548847e..9fbb9b8caee 100644 --- a/build_scripts/windows/scripts/az +++ b/build_scripts/windows/scripts/az @@ -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 "$@" diff --git a/src/azure-cli-core/azure/cli/core/_environment.py b/src/azure-cli-core/azure/cli/core/_environment.py index 98d70a95947..edee3d764d2 100644 --- a/src/azure-cli-core/azure/cli/core/_environment.py +++ b/src/azure-cli-core/azure/cli/core/_environment.py @@ -5,6 +5,7 @@ _ENV_AZ_INSTALLER = 'AZ_INSTALLER' +_ENV_AZ_LAUNCHER = 'AZ_LAUNCHER' _ENV_AZ_BICEP_GLOBALIZATION_INVARIANT = 'AZ_BICEP_GLOBALIZATION_INVARIANT' diff --git a/src/azure-cli-core/azure/cli/core/_output.py b/src/azure-cli-core/azure/cli/core/_output.py index 5cf98b38d8b..1ebfd2182d0 100644 --- a/src/azure-cli-core/azure/cli/core/_output.py +++ b/src/azure-cli-core/azure/cli/core/_output.py @@ -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): @@ -19,3 +36,43 @@ 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: + stream.reconfigure(newline=newline) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_output.py b/src/azure-cli-core/azure/cli/core/tests/test_output.py index b114e888ff2..e8fa530e652 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_output.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_output.py @@ -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): @@ -42,5 +46,113 @@ 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_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() diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index 06dfee5f7f8..c258f6dc706 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -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 " @@ -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 From 270ef4cfd0e3793f51dcd8361a1da92254ed4839 Mon Sep 17 00:00:00 2001 From: Phil Sphicas <46357247+philsphicas@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:12:36 -0700 Subject: [PATCH 2/2] [Core] Never fail a command when stdout newline reconfiguration fails; add `az config set` examples - Catch OSError/ValueError from stream.reconfigure() (e.g. closed or detached stdout) and log at debug level: line endings are cosmetic. - Add `core.output_newline` examples (`lf`, `native`) to `az config set` help. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8fa45af-9457-40bb-b8c7-3b159937f2e4 --- src/azure-cli-core/azure/cli/core/_output.py | 6 +++++- .../azure/cli/core/tests/test_output.py | 12 ++++++++++++ .../azure/cli/command_modules/config/_help.py | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/_output.py b/src/azure-cli-core/azure/cli/core/_output.py index 1ebfd2182d0..7c3e215890d 100644 --- a/src/azure-cli-core/azure/cli/core/_output.py +++ b/src/azure-cli-core/azure/cli/core/_output.py @@ -75,4 +75,8 @@ def apply_output_newline(cli_ctx, **_): 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: - stream.reconfigure(newline=newline) + 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) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_output.py b/src/azure-cli-core/azure/cli/core/tests/test_output.py index e8fa530e652..e91b3094016 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_output.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_output.py @@ -143,6 +143,18 @@ def test_apply_output_newline_unreadable_config_warns_instead_of_raising(self): 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 diff --git a/src/azure-cli/azure/cli/command_modules/config/_help.py b/src/azure-cli/azure/cli/command_modules/config/_help.py index b81afd07eb5..f5b57548d98 100644 --- a/src/azure-cli/azure/cli/command_modules/config/_help.py +++ b/src/azure-cli/azure/cli/command_modules/config/_help.py @@ -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.