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
21 changes: 18 additions & 3 deletions src/azure_functions_openapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ def handle_generate(args: argparse.Namespace) -> int:
# are imported in one process. Left None for the default shared-global flow.
active_registry: OpenAPIRegistry | None = None
isolate = getattr(args, "isolate_app", False) is True
# Fail closed: an explicit --isolate-app that cannot be honored must not
# silently produce a non-isolated spec with a success exit code (#391).
if isolate and not getattr(args, "app", None):
print(
"Error: --isolate-app requires --app 'module:variable' to scope "
"the spec to a single FunctionApp, but no --app was given.",
file=sys.stderr,
)
return 1
# Import user module first so @openapi decorators populate the registry.
# When an explicit ``module:variable`` is given, resolve the FunctionApp
# object and run endpoint-metadata discovery so producers that register
Expand All @@ -229,12 +238,18 @@ def handle_generate(args: argparse.Namespace) -> int:
)
else:
if isolate:
# Fail closed: honoring --isolate-app is impossible without a
# resolvable FunctionApp variable, and silently falling back
# to the shared global registry would emit a non-isolated
# spec with a success exit code (#391).
print(
"Note: --isolate-app ignored — it requires --app "
f"'module:variable', but {args.app!r} has no ':variable'. "
"Falling back to the shared global registry.",
"Error: --isolate-app cannot be honored — it requires "
f"--app 'module:variable', but {args.app!r} has no "
"':variable'. Refusing to fall back to the shared global "
"registry (pass e.g. 'function_app:app').",
file=sys.stderr,
)
return 1
print(
"Note: metadata discovery skipped — no ':variable' given in "
f"--app {args.app!r}. Only @openapi-decorated routes were "
Expand Down
36 changes: 36 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,3 +835,39 @@ def _capture(*args: object, **_kw: object) -> None:
assert rc == 0
joined = "\n".join(captured)
assert "Spec for the Users API" in joined


class TestIsolateAppFailClosed:
"""#391: --isolate-app must fail closed when it cannot be honored, rather
than silently falling back to the shared global registry with rc==0."""

@staticmethod
def _isolate_args(app: str | None) -> mock.Mock:
args = mock.Mock()
args.isolate_app = True
args.app = app
return args

def test_isolate_without_app_exits_non_zero(self) -> None:
args = self._isolate_args(None)
with mock.patch("builtins.print") as mock_print:
result = handle_generate(args)
assert result == 1
message = " ".join(str(c.args[0]) for c in mock_print.call_args_list)
assert "--isolate-app" in message
assert "--app" in message

def test_isolate_with_module_without_variable_exits_non_zero(self) -> None:
args = self._isolate_args("function_app")
# A bare module resolves an app object but reports no ':variable', so
# isolation cannot be scoped to a single FunctionApp.
with mock.patch(
"azure_functions_openapi.cli._import_app_module",
return_value=(mock.Mock(), False),
):
with mock.patch("builtins.print") as mock_print:
result = handle_generate(args)
assert result == 1
message = " ".join(str(c.args[0]) for c in mock_print.call_args_list)
assert "--isolate-app" in message
assert "cannot be honored" in message
11 changes: 6 additions & 5 deletions tests/test_spec_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,14 +716,15 @@ def handler(req: Any) -> Any:


class TestCliIsolateApp:
def test_isolate_app_ignored_without_variable(
def test_isolate_app_fails_closed_without_variable(
self, capsys: pytest.CaptureFixture[str]
) -> None:
# --isolate-app requires 'module:variable'. With a bare module it is a
# no-op that warns and falls back to the global registry.
# #391: --isolate-app requires 'module:variable'. With a bare module it
# cannot be honored, so the CLI fails closed (rc==1) rather than
# silently falling back to the shared global registry.
rc = handle_generate(_args(app="os", isolate_app=True))
assert rc == 0
assert "--isolate-app ignored" in capsys.readouterr().err
assert rc == 1
assert "--isolate-app cannot be honored" in capsys.readouterr().err

def test_isolate_app_scopes_spec_to_selected_app(
self, tmp_path: Any, monkeypatch: pytest.MonkeyPatch
Expand Down
Loading