diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..df3cbe05 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +omit = + src/functions_framework/_http/gunicorn.py + src/functions_framework/request_timeout.py + +[report] +exclude_lines = + pragma: no cover \ No newline at end of file diff --git a/.gitignore b/.gitignore index 967d4513..c7117520 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ dist/ function_output.json serverlog_stderr.txt serverlog_stdout.txt -venv/ +venv/ \ No newline at end of file diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index c2ba9f4b..8c581c3e 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -38,10 +38,42 @@ is_flag=True, help="Use ASGI server for function execution", ) -def _cli(target, source, signature_type, host, port, debug, asgi): +@click.option( + "--env", + multiple=True, + help="Set environment variables (can be used multiple times): --env KEY=VALUE", +) +@click.option( + "--env-file", + multiple=True, + type=click.Path(exists=True), + help="Path(s) to file(s) containing environment variables (KEY=VALUE format)", +) +def _cli(target, source, signature_type, host, port, debug, asgi, env, env_file): + # Load environment variables from all provided --env-file arguments + for file_path in env_file: + with open(file_path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue # Skip comments and blank lines + if "=" in line: + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + else: + raise click.BadParameter(f"Invalid line in env-file '{file_path}': {line}") + + # Load environment variables from all --env flags + for item in env: + if "=" in item: + key, value = item.split("=", 1) + os.environ[key.strip()] = value.strip() + else: + raise click.BadParameter(f"Invalid --env format: '{item}'. Expected KEY=VALUE.") + + # Launch ASGI or WSGI server if asgi: # pragma: no cover from functions_framework.aio import create_asgi_app - app = create_asgi_app(target, source, signature_type) else: app = create_app(target, source, signature_type) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e5a0a08..5b75020a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,7 @@ # limitations under the License. import sys +import os import pretend import pytest @@ -27,7 +28,6 @@ def test_cli_no_arguments(): runner = CliRunner() result = runner.invoke(_cli) - assert result.exit_code == 2 assert "Missing option '--target'" in result.output @@ -124,3 +124,106 @@ def test_asgi_cli(monkeypatch): assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + +def test_cli_sets_env(monkeypatch): + 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", "API_KEY=123", "--env", "MODE=dev"] + ) + + assert result.exit_code == 0 + assert create_app.calls == [pretend.call("foo", None, "http")] + assert wsgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + # Check environment variables are set + assert os.environ["API_KEY"] == "123" + assert os.environ["MODE"] == "dev" + # Cleanup + del os.environ["API_KEY"] + del os.environ["MODE"] + + +def test_cli_sets_env_file(monkeypatch, tmp_path): + 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(""" +# This is a comment +API_KEY=fromfile +MODE=production + +# Another comment +FOO=bar +""") + + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", f"--env-file={env_file}"] + ) + + assert result.exit_code == 0 + assert create_app.calls == [pretend.call("foo", None, "http")] + assert wsgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + assert os.environ["API_KEY"] == "fromfile" + assert os.environ["MODE"] == "production" + assert os.environ["FOO"] == "bar" + # Cleanup + del os.environ["API_KEY"] + del os.environ["MODE"] + del os.environ["FOO"] + + +def test_invalid_env_format(monkeypatch): + 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", "INVALIDENV"] + ) + + assert result.exit_code != 0 + assert "Invalid --env format: 'INVALIDENV'. Expected KEY=VALUE." in result.output + + +def test_invalid_env_file_line(monkeypatch, tmp_path): + 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(""" +API_KEY=fromfile +NOEQUALSIGN +""") + + runner = CliRunner() + result = runner.invoke( + _cli, + ["--target", "foo", f"--env-file={env_file}"] + ) + + assert result.exit_code != 0 + assert f"Invalid line in env-file '{env_file}': NOEQUALSIGN" in result.output diff --git a/tox.ini b/tox.ini index fd3e38a6..6871cd15 100644 --- a/tox.ini +++ b/tox.ini @@ -32,12 +32,12 @@ deps = extras = async setenv = - PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 + PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 - py37-windows-latest: PYTESTARGS = - windows-latest: PYTESTARGS = + py37-windows-latest: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc + windows-latest: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] @@ -55,4 +55,4 @@ commands = isort -c src tests conftest.py mypy tests/test_typing.py python -m build - twine check dist/* + twine check dist/* \ No newline at end of file