Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ azure-functions-openapi generate --app function_app:app --output openapi.json
build-time-safe defaults, for example by guarding external initialization
with an environment flag when generating a spec in CI.

For example, guard the initialization that needs live credentials or network
access behind a flag that only the spec-generation job sets:

```python
# function_app.py
import os

import azure.functions as func
from azure.servicebus import ServiceBusClient

app = func.FunctionApp()

if not os.environ.get("SKIP_EXTERNAL_INIT"):
service_bus = ServiceBusClient.from_connection_string(os.environ["SERVICE_BUS_CONNECTION"])
```

```bash
SKIP_EXTERNAL_INIT=1 azure-functions-openapi generate --app function_app:app --output openapi.json
```

If the module still raises while it is imported, the CLI exits with code `1` and
names the `--app` target and the exception type, for example
`Error: Could not import module from --app 'function_app:app': KeyError: 'SERVICE_BUS_CONNECTION'`.

Discovery semantics:

- **`--app function_app`** (module only): decorators fire, but binding discovery
Expand Down
7 changes: 5 additions & 2 deletions src/azure_functions_openapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,12 @@ def handle_generate(args: argparse.Namespace) -> int:
if getattr(args, "app", None):
try:
resolved_app, variable_given = _import_app_module(args.app)
except (ImportError, ValueError, AttributeError) as e:
except Exception as e:
# Module-level user code runs on import and can raise anything, so
# report the failure with the --app target and the exception type.
print(
f"Error: Could not import module from --app {args.app!r}: {e}",
f"Error: Could not import module from --app {args.app!r}: "
f"{type(e).__name__}: {e}",
file=sys.stderr,
)
return 1
Expand Down
63 changes: 63 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,69 @@ def test_no_app_option_skips_import(self) -> None:
mock_import.assert_not_called()


class TestAppImportFailureMessage:
"""--app import failures name the target and the exception type (#860)."""

@staticmethod
def _generate_with_app(monkeypatch: pytest.MonkeyPatch, project: Path, app: str) -> int:
monkeypatch.chdir(project)
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(sys, "argv", ["azure-functions-openapi", "generate", "--app", app])
return main()

def test_exception_from_module_level_code_names_target_and_type(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Given: an app module that reads a missing environment variable at import time.
(tmp_path / "import_time_failure_app.py").write_text(
"import os\nSECRET = os.environ['OPENAPI_TEST_MISSING_SECRET']\n", encoding="utf-8"
)
monkeypatch.delenv("OPENAPI_TEST_MISSING_SECRET", raising=False)

# When: the CLI imports that module through --app.
exit_code = self._generate_with_app(monkeypatch, tmp_path, "import_time_failure_app:app")

# Then: it fails with exit code 1 and the message traces the failure to the target.
captured = capsys.readouterr()
assert exit_code == 1
assert captured.out == ""
assert "--app 'import_time_failure_app:app'" in captured.err
assert "KeyError" in captured.err
assert "OPENAPI_TEST_MISSING_SECRET" in captured.err

@pytest.mark.parametrize(
("app", "exception_type", "detail"),
[
("nonexistent_module_xyz_12345", "ModuleNotFoundError", "nonexistent_module_xyz_12345"),
(":app", "ValueError", "Invalid --app value"),
("json:no_such_variable", "AttributeError", "no attribute 'no_such_variable'"),
],
)
def test_designed_errors_keep_their_message_and_gain_the_type(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
app: str,
exception_type: str,
detail: str,
) -> None:
# Given: an --app value that fails in one of the CLI's own designed ways.
# When: the CLI tries to import it.
exit_code = self._generate_with_app(monkeypatch, tmp_path, app)

# Then: the original message is still reported, now with the exception type.
captured = capsys.readouterr()
assert exit_code == 1
assert captured.out == ""
assert f"--app {app!r}" in captured.err
assert exception_type in captured.err
assert detail in captured.err


class TestEmptyPathsWarning:
"""Tests for the empty-paths guard warning."""

Expand Down
Loading