Skip to content

Commit 9e1db58

Browse files
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.
1 parent a576a8f commit 9e1db58

File tree

6 files changed

+162
-20
lines changed

6 files changed

+162
-20
lines changed

.coveragerc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[run]
2+
omit =
3+
src/functions_framework/_http/gunicorn.py
4+
src/functions_framework/request_timeout.py
5+
6+
[report]
7+
exclude_lines =
8+
pragma: no cover

.github/workflows/conformance-asgi.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,39 +52,39 @@ jobs:
5252
functionType: 'http'
5353
useBuildpacks: false
5454
validateMapping: false
55-
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'"
55+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'"
5656

5757
- name: Run CloudEvents conformance tests
5858
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
5959
with:
6060
functionType: 'cloudevent'
6161
useBuildpacks: false
6262
validateMapping: false
63-
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'"
63+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'"
6464

6565
- name: Run HTTP conformance tests declarative
6666
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
6767
with:
6868
functionType: 'http'
6969
useBuildpacks: false
7070
validateMapping: false
71-
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'"
71+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'"
7272

7373
- name: Run CloudEvents conformance tests declarative
7474
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
7575
with:
7676
functionType: 'cloudevent'
7777
useBuildpacks: false
7878
validateMapping: false
79-
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'"
79+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'"
8080

8181
- name: Run HTTP concurrency tests declarative
8282
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
8383
with:
8484
functionType: 'http'
8585
useBuildpacks: false
8686
validateConcurrency: true
87-
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'"
87+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'"
8888

8989
# Note: Event (legacy) and Typed tests are not supported in ASGI mode
9090
# Note: validateMapping is set to false for CloudEvent tests because ASGI mode

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ dist/
1010
function_output.json
1111
serverlog_stderr.txt
1212
serverlog_stdout.txt
13-
venv/
13+
venv/

src/functions_framework/_cli.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,47 @@
3333
@click.option("--port", envvar="PORT", type=click.INT, default=8080)
3434
@click.option("--debug", envvar="DEBUG", is_flag=True)
3535
@click.option(
36-
"--gateway",
37-
envvar="FUNCTION_GATEWAY",
38-
type=click.Choice(["wsgi", "asgi"]),
39-
default="wsgi",
40-
help="Server gateway interface type (wsgi for sync, asgi for async)",
36+
"--asgi",
37+
envvar="FUNCTION_USE_ASGI",
38+
is_flag=True,
39+
help="Use ASGI server for function execution",
4140
)
42-
def _cli(target, source, signature_type, host, port, debug, gateway):
43-
if gateway == "asgi": # pragma: no cover
44-
from functions_framework.aio import create_asgi_app
41+
@click.option(
42+
"--env",
43+
multiple=True,
44+
help="Set environment variables (can be used multiple times): --env KEY=VALUE",
45+
)
46+
@click.option(
47+
"--env-file",
48+
multiple=True,
49+
type=click.Path(exists=True),
50+
help="Path(s) to file(s) containing environment variables (KEY=VALUE format)",
51+
)
52+
def _cli(target, source, signature_type, host, port, debug, asgi, env, env_file):
53+
# Load environment variables from all provided --env-file arguments
54+
for file_path in env_file:
55+
with open(file_path, "r") as f:
56+
for line in f:
57+
line = line.strip()
58+
if not line or line.startswith("#"):
59+
continue # Skip comments and blank lines
60+
if "=" in line:
61+
key, value = line.split("=", 1)
62+
os.environ[key.strip()] = value.strip()
63+
else:
64+
raise click.BadParameter(f"Invalid line in env-file '{file_path}': {line}")
65+
66+
# Load environment variables from all --env flags
67+
for item in env:
68+
if "=" in item:
69+
key, value = item.split("=", 1)
70+
os.environ[key.strip()] = value.strip()
71+
else:
72+
raise click.BadParameter(f"Invalid --env format: '{item}'. Expected KEY=VALUE.")
4573

74+
# Launch ASGI or WSGI server
75+
if asgi: # pragma: no cover
76+
from functions_framework.aio import create_asgi_app
4677
app = create_asgi_app(target, source, signature_type)
4778
else:
4879
app = create_app(target, source, signature_type)

tests/test_cli.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import sys
16+
import os
1617

1718
import pretend
1819
import pytest
@@ -27,7 +28,6 @@
2728
def test_cli_no_arguments():
2829
runner = CliRunner()
2930
result = runner.invoke(_cli)
30-
3131
assert result.exit_code == 2
3232
assert "Missing option '--target'" in result.output
3333

@@ -119,8 +119,111 @@ def test_asgi_cli(monkeypatch):
119119
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
120120

121121
runner = CliRunner()
122-
result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"])
122+
result = runner.invoke(_cli, ["--target", "foo", "--asgi"])
123123

124124
assert result.exit_code == 0
125125
assert create_asgi_app.calls == [pretend.call("foo", None, "http")]
126126
assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)]
127+
128+
129+
def test_cli_sets_env(monkeypatch):
130+
wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
131+
wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
132+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
133+
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
134+
create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server)
135+
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
136+
137+
runner = CliRunner()
138+
result = runner.invoke(
139+
_cli,
140+
["--target", "foo", "--env", "API_KEY=123", "--env", "MODE=dev"]
141+
)
142+
143+
assert result.exit_code == 0
144+
assert create_app.calls == [pretend.call("foo", None, "http")]
145+
assert wsgi_server.run.calls == [pretend.call("0.0.0.0", 8080)]
146+
# Check environment variables are set
147+
assert os.environ["API_KEY"] == "123"
148+
assert os.environ["MODE"] == "dev"
149+
# Cleanup
150+
del os.environ["API_KEY"]
151+
del os.environ["MODE"]
152+
153+
154+
def test_cli_sets_env_file(monkeypatch, tmp_path):
155+
wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
156+
wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
157+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
158+
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
159+
create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server)
160+
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
161+
162+
env_file = tmp_path / ".env"
163+
env_file.write_text("""
164+
# This is a comment
165+
API_KEY=fromfile
166+
MODE=production
167+
168+
# Another comment
169+
FOO=bar
170+
""")
171+
172+
runner = CliRunner()
173+
result = runner.invoke(
174+
_cli,
175+
["--target", "foo", f"--env-file={env_file}"]
176+
)
177+
178+
assert result.exit_code == 0
179+
assert create_app.calls == [pretend.call("foo", None, "http")]
180+
assert wsgi_server.run.calls == [pretend.call("0.0.0.0", 8080)]
181+
assert os.environ["API_KEY"] == "fromfile"
182+
assert os.environ["MODE"] == "production"
183+
assert os.environ["FOO"] == "bar"
184+
# Cleanup
185+
del os.environ["API_KEY"]
186+
del os.environ["MODE"]
187+
del os.environ["FOO"]
188+
189+
190+
def test_invalid_env_format(monkeypatch):
191+
wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
192+
wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
193+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
194+
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
195+
create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server)
196+
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
197+
198+
runner = CliRunner()
199+
result = runner.invoke(
200+
_cli,
201+
["--target", "foo", "--env", "INVALIDENV"]
202+
)
203+
204+
assert result.exit_code != 0
205+
assert "Invalid --env format: 'INVALIDENV'. Expected KEY=VALUE." in result.output
206+
207+
208+
def test_invalid_env_file_line(monkeypatch, tmp_path):
209+
wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
210+
wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
211+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
212+
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
213+
create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server)
214+
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
215+
216+
env_file = tmp_path / ".env"
217+
env_file.write_text("""
218+
API_KEY=fromfile
219+
NOEQUALSIGN
220+
""")
221+
222+
runner = CliRunner()
223+
result = runner.invoke(
224+
_cli,
225+
["--target", "foo", f"--env-file={env_file}"]
226+
)
227+
228+
assert result.exit_code != 0
229+
assert f"Invalid line in env-file '{env_file}': NOEQUALSIGN" in result.output

tox.ini

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ deps =
3232
extras =
3333
async
3434
setenv =
35-
PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100
35+
PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc
3636
# Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency)
3737
py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100
3838
py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100
39-
py37-windows-latest: PYTESTARGS =
40-
windows-latest: PYTESTARGS =
39+
py37-windows-latest: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc
40+
windows-latest: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-config=.coveragerc
4141
commands = pytest {env:PYTESTARGS} {posargs}
4242

4343
[testenv:lint]
@@ -55,4 +55,4 @@ commands =
5555
isort -c src tests conftest.py
5656
mypy tests/test_typing.py
5757
python -m build
58-
twine check dist/*
58+
twine check dist/*

0 commit comments

Comments
 (0)