From 6fae1c087d859b947cf3babd4e450867dbe39d60 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 3 May 2026 10:05:27 -0700 Subject: [PATCH] Add support for reading --cli-input-json and --cli-input-yaml from stdin Add support for passing '-' as the value of --cli-input-json and --cli-input-yaml to read the input from standard input. This is consistent with the existing 'aws s3 cp -' convention and enables clean pipeline usage such as: cat params.json | aws s3api head-object --cli-input-json - The change is scoped to CliInputArgument._get_arg_value() to avoid conflicts with interactive mode in other parameters. Stdin is read exactly once, avoiding the double-read problem that affects file:///dev/stdin workarounds. Fixes #5982 Fixes #7850 --- .changes/next-release/cli-input-stdin.json | 7 ++ awscli/customizations/cliinput.py | 12 +++- tests/functional/test_cliinput.py | 38 ++++++++++- tests/unit/customizations/test_cliinput.py | 74 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 .changes/next-release/cli-input-stdin.json diff --git a/.changes/next-release/cli-input-stdin.json b/.changes/next-release/cli-input-stdin.json new file mode 100644 index 000000000000..1d9d25aeea16 --- /dev/null +++ b/.changes/next-release/cli-input-stdin.json @@ -0,0 +1,7 @@ +[ + { + "category": "``cli``", + "description": "Add support for reading ``--cli-input-json`` and ``--cli-input-yaml`` from standard input by passing ``-`` as the value (e.g. ``aws s3api head-object --cli-input-json -``). This is consistent with the existing ``aws s3 cp -`` convention.", + "type": "feature" + } +] \ No newline at end of file diff --git a/awscli/customizations/cliinput.py b/awscli/customizations/cliinput.py index d145e967eac2..d8a1f82f76fc 100644 --- a/awscli/customizations/cliinput.py +++ b/awscli/customizations/cliinput.py @@ -11,6 +11,7 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import json +import sys from ruamel.yaml import YAML from ruamel.yaml.error import YAMLError @@ -85,6 +86,11 @@ def _get_arg_value(self, parsed_args): 'Only one --cli-input- parameter may be specified.' ) + # Read from stdin when '-' is passed, consistent with + # 'aws s3 cp - s3://bucket/key'. + if arg_value == '-': + return sys.stdin.read() + # If the value starts with file:// or fileb://, return the contents # from the file. paramfile_data = get_paramfile(arg_value, LOCAL_PREFIX_MAP) @@ -122,7 +128,8 @@ class CliInputJSONArgument(CliInputArgument): 'will override the JSON-provided values. It is not possible to ' 'pass arbitrary binary values using a JSON-provided value as the ' 'string will be taken literally. This may not be specified along ' - 'with ``--cli-input-yaml``.' + 'with ``--cli-input-yaml``. If the value is ``-``, the JSON ' + 'string will be read from standard input.' ), } @@ -143,7 +150,8 @@ class CliInputYAMLArgument(CliInputArgument): '``--generate-cli-skeleton yaml-input``. ' 'If other arguments are provided on the command line, those ' 'values will override the YAML-provided values. This may not be ' - 'specified along with ``--cli-input-json``.' + 'specified along with ``--cli-input-json``. If the value is ' + '``-``, the YAML string will be read from standard input.' ), } diff --git a/tests/functional/test_cliinput.py b/tests/functional/test_cliinput.py index 3527aed45b2d..9a07b0732cdb 100644 --- a/tests/functional/test_cliinput.py +++ b/tests/functional/test_cliinput.py @@ -10,9 +10,10 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import io import os -from awscli.testutils import BaseAWSCommandParamsTest, FileCreator +from awscli.testutils import BaseAWSCommandParamsTest, FileCreator, mock from awscli.utils import dump_yaml_to_str @@ -77,6 +78,26 @@ def test_cli_input_json_has_extra_unknown_args(self): cmdline, expected_rc=252, stderr_contains='Unknown' ) + def test_cli_input_json_from_stdin(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(self.input_json), + ): + cmdline = 's3api head-object --cli-input-json -' + self.assert_params_for_cmd( + cmdline, params={'Bucket': 'bucket', 'Key': 'key'} + ) + + def test_cli_input_json_stdin_can_override(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(self.input_json), + ): + cmdline = 's3api head-object --key bar --cli-input-json -' + self.assert_params_for_cmd( + cmdline, params={'Bucket': 'bucket', 'Key': 'bar'} + ) + class TestCLIInputYAML(BaseCLIInputArgumentTest): def test_input_yaml(self): @@ -148,6 +169,21 @@ def test_input_yaml_ignores_comments(self): command, {'Bucket': 'test-bucket', 'EncodingType': 'url'} ) + def test_input_yaml_from_stdin(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO('Bucket: test-bucket\nEncodingType: url'), + ): + command = [ + 's3api', + 'list-objects-v2', + '--cli-input-yaml', + '-', + ] + self.assert_params_for_cmd( + command, {'Bucket': 'test-bucket', 'EncodingType': 'url'} + ) + def test_errors_when_both_yaml_and_json_provided(self): command = [ 's3api', diff --git a/tests/unit/customizations/test_cliinput.py b/tests/unit/customizations/test_cliinput.py index a4b629ad7722..1369d5da478d 100644 --- a/tests/unit/customizations/test_cliinput.py +++ b/tests/unit/customizations/test_cliinput.py @@ -10,6 +10,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import io import os import shutil import tempfile @@ -122,6 +123,64 @@ def test_input_is_not_a_map(self): parsed_globals=None, ) + def test_add_to_call_parameters_from_stdin(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(self.input_json), + ): + parsed_args = self.create_args('-') + call_parameters = {} + self.argument.add_to_call_parameters( + service_operation=None, + call_parameters=call_parameters, + parsed_args=parsed_args, + parsed_globals=None, + ) + self.assertEqual(call_parameters, {'A': 'foo', 'B': 'bar'}) + + def test_stdin_does_not_clobber(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(self.input_json), + ): + parsed_args = self.create_args('-') + call_parameters = {'A': 'baz'} + self.argument.add_to_call_parameters( + service_operation=None, + call_parameters=call_parameters, + parsed_args=parsed_args, + parsed_globals=None, + ) + self.assertEqual(call_parameters, {'A': 'baz', 'B': 'bar'}) + + def test_stdin_bad_json(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO('not valid json'), + ): + parsed_args = self.create_args('-') + with self.assertRaises(ParamError): + self.argument.add_to_call_parameters( + service_operation=None, + call_parameters={}, + parsed_args=parsed_args, + parsed_globals=None, + ) + + def test_stdin_empty(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(''), + ): + parsed_args = self.create_args('-') + with self.assertRaises(ParamError): + self.argument.add_to_call_parameters( + service_operation=None, + call_parameters={}, + parsed_args=parsed_args, + parsed_globals=None, + ) + class TestCliInputYAMLArgument(TestCliInputJSONArgument): def setUp(self): @@ -191,3 +250,18 @@ def test_yaml_does_not_overwrite(self): parsed_globals=None, ) self.assertEqual(call_parameters, {'A': 'baz', 'B': 'bar'}) + + def test_input_yaml_from_stdin(self): + with mock.patch( + 'awscli.customizations.cliinput.sys.stdin', + io.StringIO(self.input_yaml), + ): + parsed_args = self.create_args('-') + call_parameters = {} + self.argument.add_to_call_parameters( + service_operation=None, + call_parameters=call_parameters, + parsed_args=parsed_args, + parsed_globals=None, + ) + self.assertEqual(call_parameters, {'A': 'foo', 'B': 'bar'})