From 0f99058f5c4a5574ed341051ce56c8fab950d425 Mon Sep 17 00:00:00 2001 From: maks Date: Fri, 27 Mar 2026 11:05:49 +0100 Subject: [PATCH] feat: Add support for environment variable configuration (#634) Allows configuration values to be read from environment variables using the FIRE_ naming convention. Supports positional arguments, keyword-only arguments, and **kwargs. CLI arguments continue to take precedence over environment variables. Includes documentation and tests. --- docs/guide.md | 36 ++++++++++++++ docs/using-cli.md | 17 +++++++ fire/core.py | 37 +++++++++++++-- fire/env_config_test.py | 102 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 fire/env_config_test.py diff --git a/docs/guide.md b/docs/guide.md index 444a76ff..f0f2f235 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -736,6 +736,42 @@ example.py -- --help` or `python example.py --help` (or even `python example.py The complete set of flags available is shown below, in the reference section. +### Environment Variable Configuration + +Python Fire supports reading configuration values from environment variables. +You can specify a value for any parameter using the `FIRE_` +naming convention, where `` is the uppercase name of the parameter. +For example, if you have a parameter named `name`, you can set its value using +the `FIRE_NAME` environment variable. + +Environment variables can be used for positional arguments, named arguments, and +even arguments that are captured by `**kwargs`. + +Values provided via the command line (either positionally or as flags) take +precedence over values provided via environment variables. + +Example: +```bash +$ export FIRE_NAME=World +$ python example.py hello +Hello World! +$ python example.py hello --name=Fire +Hello Fire! +``` + +Values in environment variables are parsed as Python literals, just like command +line arguments. +```bash +$ export FIRE_COUNT=5 +$ python example.py repeat "Hello" +Hello +Hello +Hello +Hello +Hello +``` + + ### Reference | Setup | Command | Notes diff --git a/docs/using-cli.md b/docs/using-cli.md index bdfcb7db..22b52d2b 100644 --- a/docs/using-cli.md +++ b/docs/using-cli.md @@ -97,6 +97,23 @@ by name, using the flags syntax. See the section on Similarly, when passing arguments to a callable object (an object with a custom `__call__` function), those arguments must be passed using flags syntax. + +### Environment Variable Configuration + +Python Fire supports reading configuration values from environment variables. +Any parameter can be specified using the `FIRE_` naming convention, +where `` is the uppercase name of the parameter. + +For example, if a function has a parameter named `name`, its value can be set +using the `FIRE_NAME` environment variable. + +Values provided via the command line (either positionally or as flags) take +precedence over values provided via environment variables. + +Values in environment variables are parsed as Python literals, just like command +line arguments. + + ## Using Flags with Fire CLIs Command line arguments to a Fire CLI are normally consumed by Fire, as described diff --git a/fire/core.py b/fire/core.py index 8e23e76b..aa6714d4 100644 --- a/fire/core.py +++ b/fire/core.py @@ -725,6 +725,21 @@ def _ParseFn(args): """Parses the list of `args` into (varargs, kwargs), remaining_args.""" kwargs, remaining_kwargs, remaining_args = _ParseKeywordArgs(args, fn_spec) + # Read configuration values from environment variables for kwonlyargs. + for arg_name in fn_spec.kwonlyargs: + if arg_name not in kwargs: + env_var = 'FIRE_{name}'.format(name=arg_name.upper()) + if env_var in os.environ: + kwargs[arg_name] = os.environ[env_var] + + # If varkw is present, also read other FIRE_ environment variables. + if fn_spec.varkw: + for env_name, env_val in os.environ.items(): + if env_name.startswith('FIRE_'): + param_name = env_name[5:].lower() + if param_name not in kwargs and param_name not in fn_spec.args: + kwargs[param_name] = env_val + # Note: _ParseArgs modifies kwargs. parsed_args, kwargs, remaining_args, capacity = _ParseArgs( fn_spec.args, fn_spec.defaults, num_required_args, kwargs, @@ -804,14 +819,26 @@ def _ParseArgs(fn_args, fn_defaults, num_required_args, kwargs, value = _ParseValue(value, index, arg, metadata) parsed_args.append(value) elif index < num_required_args: - raise FireError( - 'The function received no value for the required argument:', arg) + env_var = 'FIRE_{name}'.format(name=arg.upper()) + if env_var in os.environ: + value = os.environ[env_var] + value = _ParseValue(value, index, arg, metadata) + parsed_args.append(value) + else: + raise FireError( + 'The function received no value for the required argument:', arg) else: # We're past the args for which there's no default value. # There's a default value for this arg. - capacity = True - default_index = index - num_required_args # index into the defaults. - parsed_args.append(fn_defaults[default_index]) + env_var = 'FIRE_{name}'.format(name=arg.upper()) + if env_var in os.environ: + value = os.environ[env_var] + value = _ParseValue(value, index, arg, metadata) + parsed_args.append(value) + else: + capacity = True + default_index = index - num_required_args # index into the defaults. + parsed_args.append(fn_defaults[default_index]) for key, value in kwargs.items(): kwargs[key] = _ParseValue(value, None, key, metadata) diff --git a/fire/env_config_test.py b/fire/env_config_test.py new file mode 100644 index 00000000..7e9c4fb9 --- /dev/null +++ b/fire/env_config_test.py @@ -0,0 +1,102 @@ +# Copyright (C) 2018 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is 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. + +"""Tests for environment variable configuration support.""" + +import os +from unittest import mock + +from fire import core +from fire import testutils + + +class EnvVarTest(testutils.BaseTestCase): + + def testEnvVarConfig(self): + def fn(arg1, arg2=10): + return arg1, arg2 + + # Without env vars, it should fail if required arg is missing. + with self.assertRaisesFireExit(2, 'The function received no value for the required argument: arg1'): + core.Fire(fn, command=[]) + + # With env var for arg1. + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'hello'}): + self.assertEqual(core.Fire(fn, command=[]), ('hello', 10)) + + # With env var for arg2. + with mock.patch.dict(os.environ, {'FIRE_ARG2': '20'}): + # Note: it will still fail for arg1 if missing. + with self.assertRaisesFireExit(2, 'The function received no value for the required argument: arg1'): + core.Fire(fn, command=[]) + + # Now provide arg1 on CLI. + self.assertEqual(core.Fire(fn, command=['hi']), ('hi', 20)) + + # Env var for both. + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'val1', 'FIRE_ARG2': 'val2'}): + self.assertEqual(core.Fire(fn, command=[]), ('val1', 'val2')) + + # Command line overrides env var. + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'env1', 'FIRE_ARG2': 'env2'}): + self.assertEqual(core.Fire(fn, command=['cli1']), ('cli1', 'env2')) + self.assertEqual(core.Fire(fn, command=['--arg2=cli2']), ('env1', 'cli2')) + + def testEnvVarWithUnderscores(self): + def fn(my_arg, another_arg=1): + return my_arg, another_arg + + with mock.patch.dict(os.environ, {'FIRE_MY_ARG': 'val1', 'FIRE_ANOTHER_ARG': 'val2'}): + self.assertEqual(core.Fire(fn, command=[]), ('val1', 'val2')) + + def testEnvVarWithKwonly(self): + def fn(*, arg1, arg2=10): + return arg1, arg2 + + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'hello'}): + self.assertEqual(core.Fire(fn, command=[]), ('hello', 10)) + + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'v1', 'FIRE_ARG2': 'v2'}): + self.assertEqual(core.Fire(fn, command=[]), ('v1', 'v2')) + self.assertEqual(core.Fire(fn, command=['--arg2=v3']), ('v1', 'v3')) + + def testEnvVarWithVarkw(self): + def fn(**kwargs): + return kwargs + + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'val1', 'FIRE_ARG2': 'val2'}): + self.assertEqual(core.Fire(fn, command=[]), {'arg1': 'val1', 'arg2': 'val2'}) + + def testEnvVarTypes(self): + def fn(arg1): + return arg1, type(arg1) + + # Boolean + with mock.patch.dict(os.environ, {'FIRE_ARG1': 'True'}): + self.assertEqual(core.Fire(fn, command=[]), (True, bool)) + + # Integer + with mock.patch.dict(os.environ, {'FIRE_ARG1': '123'}): + self.assertEqual(core.Fire(fn, command=[]), (123, int)) + + # List + with mock.patch.dict(os.environ, {'FIRE_ARG1': '[1, 2, 3]'}): + self.assertEqual(core.Fire(fn, command=[]), ([1, 2, 3], list)) + + # Dict + with mock.patch.dict(os.environ, {'FIRE_ARG1': '{"a": 1}'}): + self.assertEqual(core.Fire(fn, command=[]), ({'a': 1}, dict)) + +if __name__ == '__main__': + testutils.main()