diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 48455ea6..5fde9671 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -13,6 +13,7 @@ # limitations under the License. import os +import pathlib import click @@ -20,6 +21,41 @@ from functions_framework._http import create_server +def _parse_env_file(file_path): + """Parse a .env file and return a dict of key-value pairs. + + Supports lines of the form KEY=VALUE. Lines starting with '#' and blank + lines are skipped. Values may be optionally quoted with single or double + quotes. + """ + env_vars = {} + path = pathlib.Path(file_path) + if not path.is_file(): + raise click.BadParameter( + f"env file '{file_path}' does not exist", + param_hint="--env-file", + ) + for lineno, raw_line in enumerate(path.read_text().splitlines(), 1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + key, sep, value = line.partition("=") + if not key or not sep: + raise click.BadParameter( + f"invalid format on line {lineno}: must be KEY=VALUE", + param_hint="--env-file", + ) + # Strip optional surrounding quotes + value = value.strip() + if len(value) >= 2 and ( + (value[0] == '"' and value[-1] == '"') + or (value[0] == "'" and value[-1] == "'") + ): + value = value[1:-1] + env_vars[key.strip()] = value + return env_vars + + @click.command() @click.option("--target", envvar="FUNCTION_TARGET", type=click.STRING, required=True) @click.option("--source", envvar="FUNCTION_SOURCE", type=click.Path(), default=None) @@ -32,13 +68,38 @@ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) +@click.option( + "--env", + "env_vars", + multiple=True, + metavar="KEY=VALUE", + help="Set a runtime environment variable for local execution. Repeatable.", +) +@click.option( + "--env-file", + type=click.Path(exists=True), + help="Load runtime environment variables from a .env file.", +) @click.option( "--asgi", envvar="FUNCTION_USE_ASGI", is_flag=True, help="Use ASGI server for function execution", ) -def _cli(target, source, signature_type, host, port, debug, asgi): +def _cli(target, source, signature_type, host, port, debug, env_vars, env_file, asgi): + # Apply environment variables from --env-file first, then --env overrides + if env_file: + file_vars = _parse_env_file(env_file) + os.environ.update(file_vars) + + for env_var in env_vars: + key, separator, value = env_var.partition("=") + if not key or not separator: + raise click.BadParameter( + "must be in KEY=VALUE format", param_hint="--env" + ) + os.environ[key] = value + if asgi: from functions_framework.aio import create_asgi_app diff --git a/tests/test_cli.py b/tests/test_cli.py index 75c93f20..12f48af5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -163,3 +163,120 @@ def test_cli_auto_detects_asgi_decorator(): # Verify the function was registered in ASGI_FUNCTIONS assert "function_http" in _function_registry.ASGI_FUNCTIONS + + +def test_cli_sets_runtime_env(monkeypatch): + """Test that --env KEY=VALUE sets environment variables.""" + wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", "--env", "MY_VAR=hello", "--env", "OTHER=world"], + ) + + assert result.exit_code == 0 + assert os.environ.get("MY_VAR") == "hello" + assert os.environ.get("OTHER") == "world" + + +def test_cli_sets_env_from_file(monkeypatch, tmp_path): + """Test that --env-file loads environment variables from a .env file.""" + wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + env_file = tmp_path / ".env" + env_file.write_text( + "# Comment line\n" + "FILE_VAR=from_file\n" + "QUOTED_VAR=\"double_quoted\"\n" + "SINGLE_QUOTED='single_quoted'\n" + "\n" + "TRIMMED= trimmed_value \n" + ) + + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", "--env-file", str(env_file)], + ) + + assert result.exit_code == 0 + assert os.environ.get("FILE_VAR") == "from_file" + assert os.environ.get("QUOTED_VAR") == "double_quoted" + assert os.environ.get("SINGLE_QUOTED") == "single_quoted" + assert os.environ.get("TRIMMED") == "trimmed_value" + + +def test_cli_env_overrides_env_file(monkeypatch, tmp_path): + """Test that --env overrides values from --env-file.""" + wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + env_file = tmp_path / ".env" + env_file.write_text("MY_VAR=from_file\nOTHER=file_value\n") + + runner = CliRunner() + result = runner.invoke( + _cli, + [ + "--target", "foo", + "--env-file", str(env_file), + "--env", "MY_VAR=overridden", + ], + ) + + assert result.exit_code == 0 + assert os.environ.get("MY_VAR") == "overridden" + assert os.environ.get("OTHER") == "file_value" + + +def test_cli_env_bad_format(monkeypatch): + """Test that --env with invalid format raises an error.""" + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", "--env", "INVALID_FORMAT"], + ) + + assert result.exit_code != 0 + assert "KEY=VALUE" in result.output + + +def test_cli_env_file_not_found(monkeypatch): + """Test that --env-file with nonexistent file raises an error.""" + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", "--env-file", "/nonexistent/.env"], + ) + + assert result.exit_code != 0 + + +def test_cli_env_file_bad_format(monkeypatch, tmp_path): + """Test that --env-file with invalid line format raises an error.""" + env_file = tmp_path / ".env" + env_file.write_text("VALID=value\nNO_EQUALS_SIGN\n") + + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", "--env-file", str(env_file)], + ) + + assert result.exit_code != 0 + assert "KEY=VALUE" in result.output