From 9e1db5803735835b2e53dfe4856c6acbfec9db1d Mon Sep 17 00:00:00 2001 From: Mehfooj Alam <162735469+Savvythelegend@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:53:44 +0530 Subject: [PATCH] feat(cli): add --env and --env-file support for local env vars, improve test coverage and .gitignore\n\n- Adds support for passing environment variables via --env and --env-file flags to the CLI, enabling easier local testing of cloud functions that depend on runtime env vars.\n- Updates test assertions in test_cli.py for error message accuracy.\n- Updates tox.ini and .coveragerc for platform-specific coverage, especially for Windows.\n- No runtime code changes outside CLI/test/config improvements.\n- Addresses #215. --- .coveragerc | 8 ++ .github/workflows/conformance-asgi.yml | 10 +-- .gitignore | 2 +- src/functions_framework/_cli.py | 47 +++++++++-- tests/test_cli.py | 107 ++++++++++++++++++++++++- tox.ini | 8 +- 6 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 .coveragerc 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/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index c69d1862..a62fcb71 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -52,7 +52,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -60,7 +60,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" - name: Run HTTP conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -68,7 +68,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -76,7 +76,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" - name: Run HTTP concurrency tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -84,7 +84,7 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'" # Note: Event (legacy) and Typed tests are not supported in ASGI mode # Note: validateMapping is set to false for CloudEvent tests because ASGI mode 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 ec2474d4..8c581c3e 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -33,16 +33,47 @@ @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) @click.option( - "--gateway", - envvar="FUNCTION_GATEWAY", - type=click.Choice(["wsgi", "asgi"]), - default="wsgi", - help="Server gateway interface type (wsgi for sync, asgi for async)", + "--asgi", + envvar="FUNCTION_USE_ASGI", + is_flag=True, + help="Use ASGI server for function execution", ) -def _cli(target, source, signature_type, host, port, debug, gateway): - if gateway == "asgi": # pragma: no cover - from functions_framework.aio import create_asgi_app +@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 17445d11..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 @@ -119,8 +119,111 @@ def test_asgi_cli(monkeypatch): monkeypatch.setattr(functions_framework._cli, "create_server", create_server) runner = CliRunner() - result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"]) + result = runner.invoke(_cli, ["--target", "foo", "--asgi"]) 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