This is an automated email from the ASF dual-hosted git repository. vatsrahul1001 pushed a commit to branch v3-4-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit ac324964dea01cbb51a2e536bbbeab1cb4339aee Author: Pierre Jeambrun <[email protected]> AuthorDate: Fri Nov 6 09:28:45 2026 +0100 [v3-4-test] Make `JWTRefreshMiddleware` extensible via `BaseAuthManager.get_jwt_refresh_middleware()` (#75868) * [v3-4-test] Keep release-candidate provider pins until each is released or yanked (#75919) (#75924) Constraints for an Airflow release candidate pin the provider wave being voted on at its rc versions, and commit them onto the shared constraints-X-Y branch. The next regular generation for the vX-Y-test branch resolves without pre-releases, so it saw the older finals as a provider downgrade and failed - the v3-4-test canary has been red since 3.4.0rc1 for exactly that reason, although nothing was yanked. The final release has the opposite gap: it ignored the candidates, so a provider rc that was neither released nor yanked was silently passed over and the released constraints never recorded that decision. It now has to be made, and the run says which providers it is still waiting on. The alert that should have reported the canary failure never reached Slack because the action was not told which API method to call, and the twice-daily CI notification announced "All passing" because it read a run that was still in progress, whose missing conclusion looked like no failures. (cherry picked from commit eeae004bd25e02c696f619f61ab67a3fb671c81c) Generated-by: Claude Opus 5 Co-authored-by: Jarek Potiuk <[email protected]> * Make JWTRefreshMiddleware extensible via BaseAuthManager.get_jwt_refresh_middleware() (#70783) Co-authored-by: Stephen Bracken <email-protected> (cherry picked from commit e5d8cbbe67931dd13a027ed3386ddcc1877800cd) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jarek Potiuk <[email protected]> Co-authored-by: stephen-bracken <[email protected]> --- airflow-core/src/airflow/api_fastapi/app.py | 18 +++++ .../api_fastapi/auth/managers/base_auth_manager.py | 14 ++++ .../api_fastapi/auth/middlewares/refresh_token.py | 77 +++++++++++++++----- .../src/airflow/api_fastapi/core_api/app.py | 8 ++- .../auth/managers/test_base_auth_manager.py | 10 +++ .../auth/middlewares/test_refresh_token.py | 81 +++++++++++++++++++--- .../core_api/routes/public/test_auth.py | 4 +- .../tests/unit/api_fastapi/core_api/test_app.py | 29 ++++++++ airflow-core/tests/unit/api_fastapi/test_app.py | 16 +++++ 9 files changed, 226 insertions(+), 31 deletions(-) diff --git a/airflow-core/src/airflow/api_fastapi/app.py b/airflow-core/src/airflow/api_fastapi/app.py index bf8f28b4b5d..f52d34a4fde 100644 --- a/airflow-core/src/airflow/api_fastapi/app.py +++ b/airflow-core/src/airflow/api_fastapi/app.py @@ -44,6 +44,8 @@ from airflow.exceptions import AirflowConfigException from airflow.utils.providers_configuration_loader import providers_configuration_loaded if TYPE_CHECKING: + from fastapi import Request + from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager API_BASE_URL = conf.get("api", "base_url", fallback="") @@ -64,6 +66,22 @@ def get_cookie_path() -> str: return API_ROOT_PATH or "/" +def request_cookie_is_secure(request: Request) -> bool: + """ + Return whether a FastAPI request cookie should be tagged as HTTP secure. + + :param request: FastAPI Request object + + usage: + ```python + secure = request_cookie_is_secure(request) + + response.set_cookie("mycookie", "myvalue", path=get_cookie_path(), secure=secure, httponly=True) + ``` + """ + return request.base_url.scheme == "https" or bool(conf.get("api", "ssl_cert", fallback="")) + + # Fast API apps mounted under these prefixes are not allowed RESERVED_URL_PREFIXES = ["/api/v2", "/ui", "/execution", "/auth", "/pluginsv2"] diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py index 94b96ba5af2..8b2302393a0 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py @@ -185,6 +185,20 @@ class BaseAuthManager(Generic[T], LoggingMixin, metaclass=ABCMeta): """ return [] + def get_jwt_refresh_middleware(self) -> list[tuple[_MiddlewareFactory[Any], dict[str, Any]]]: + """ + Return the JWTRefreshMiddleware to refresh the Airflow JWT token. + + Middlewares added by this method will be initialised before middleware from + ``get_fastapi_middlewares()``. To disable JWT refresh return an empty list. + To add more middleware, simply add more tuples here in the same format as + ``get_fastapi_middlewares()``. + """ + from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware + from airflow.api_fastapi.core_api.app import _STATIC_URL_PATH + + return [(JWTRefreshMiddleware, {"skip_path_prefixes": (f"{_STATIC_URL_PATH}/",)})] + def generate_jwt( self, user: T, *, expiration_time_in_seconds: int = conf.getint("api_auth", "jwt_expiration_time") ) -> str: diff --git a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py b/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py index 37186d9be53..01aa96ffe79 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py +++ b/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py @@ -24,19 +24,19 @@ from fastapi.responses import JSONResponse from starlette.concurrency import run_in_threadpool from starlette.datastructures import MutableHeaders -from airflow.api_fastapi.app import get_auth_manager, get_cookie_path +from airflow.api_fastapi.app import get_auth_manager, get_cookie_path, request_cookie_is_secure from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN from airflow.api_fastapi.auth.managers.exceptions import AuthManagerRefreshTokenExpiredException -from airflow.api_fastapi.auth.managers.models.base_user import BaseUser from airflow.api_fastapi.core_api.security import ( USER_INJECTED_BY_TRUSTED_MIDDLEWARE, resolve_user_from_token, ) -from airflow.configuration import conf if TYPE_CHECKING: from starlette.types import ASGIApp, Message, Receive, Scope, Send + from airflow.api_fastapi.auth.managers.models.base_user import BaseUser + # Unauthenticated endpoints that never read the user, so resolving it there would only cost a # JWT decode and a revocation lookup on every health check and UI boot. UNAUTHENTICATED_PATHS = frozenset(["/api/v2/monitor/health", "/api/v2/version"]) @@ -72,27 +72,29 @@ class JWTRefreshMiddleware: await self.app(scope, receive, send) return - new_token = None + new_user = None + session_invalidated = False try: - new_user, current_user = await self._refresh_user(current_token) + new_user, current_user = await self.refresh_user(request) if user := (new_user or current_user): # Stamp the trust sentinel alongside the user so `get_user()` # can distinguish this trusted assignment from a stray write # by unrelated middleware. request.state.user = user request.state.user_authenticated_via = USER_INJECTED_BY_TRUSTED_MIDDLEWARE - if new_user: - # If we created a new user, serialize it and set it as a cookie - new_token = get_auth_manager().generate_jwt(new_user) except (HTTPException, AuthManagerRefreshTokenExpiredException): # Receive a HTTPException when the Airflow token is expired # Receive a AuthManagerRefreshTokenExpiredException when the potential underlying refresh # token used by the auth manager is expired - new_token = "" + session_invalidated = True - token_cookie, cleanup_cookies = ( - self._build_cookie_headers(request, new_token) if new_token is not None else (None, []) - ) + if session_invalidated or new_user is not None: + extra_cookies = self.get_extra_cookies(request, new_user) + token_cookie, cleanup_cookies = self.build_cookie_headers( + request, await self.generate_new_user_token(request, new_user), extra_cookies + ) + else: + token_cookie, cleanup_cookies = (None, []) response_started = False async def send_with_cookies(message: Message) -> None: @@ -124,10 +126,34 @@ class JWTRefreshMiddleware: return path in UNAUTHENTICATED_PATHS or path.startswith(self.skip_path_prefixes) @staticmethod - def _build_cookie_headers(request: Request, new_token: str) -> tuple[str, list[str]]: - """Return the ``Set-Cookie`` value of the token and those clearing stale token cookies.""" + def get_extra_cookies(request: Request, new_user: BaseUser | None) -> dict[str, str]: + """ + Get extra cookies from the request context to set in a new token response. + + Setting a cookie to "" will cause it to be cleared by build_cookie_headers() + """ + return {} + + @staticmethod + async def generate_new_user_token(request: Request, new_user: BaseUser | None) -> str: + """Generate a new Airflow JWT token from the user model.""" + return get_auth_manager().generate_jwt(new_user) if new_user else "" + + @staticmethod + def build_cookie_headers( + request: Request, new_token: str, extra_cookies: dict[str, str] + ) -> tuple[str, list[str]]: + """ + Return the ``Set-Cookie`` value of the token and those clearing stale token cookies. + + Any cookies with a nil value ("") will be cleared with Max-Age=0 + + :param request: FastAPI Request + :param new_token: New Airflow JWT token + :param extra_cookies: Additional cookie values to set + """ cookie_path = get_cookie_path() - secure = request.base_url.scheme == "https" or bool(conf.get("api", "ssl_cert", fallback="")) + secure = request_cookie_is_secure(request) # Only used to render the Set-Cookie header values response = Response() response.set_cookie( @@ -151,13 +177,32 @@ class JWTRefreshMiddleware: secure=secure, samesite="lax", ) + # Add any additional cookies required + for cookie, value in extra_cookies.items(): + response.set_cookie( + cookie, + value, + path=cookie_path, + httponly=True, + secure=secure, + samesite="lax", + max_age=0 if value == "" else None, + ) token_cookie, *cleanup_cookies = ( value.decode("latin-1") for key, value in response.raw_headers if key == b"set-cookie" ) return token_cookie, cleanup_cookies @staticmethod - async def _refresh_user(current_token: str) -> tuple[BaseUser | None, BaseUser | None]: + async def refresh_user(request: Request) -> tuple[BaseUser | None, BaseUser | None]: + """ + Refresh the logged in user using the current JWT Token. + + If the user is not authenticated, return ``None, None`` + + :param request: FastAPI Request + """ + current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN) user = await resolve_user_from_token(current_token) # Auth managers may do blocking I/O here (e.g. calling an identity provider) return await run_in_threadpool(get_auth_manager().refresh_user, user=user), user diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py b/airflow-core/src/airflow/api_fastapi/core_api/app.py index 02f7151ab55..ccd1c6745dd 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/app.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py @@ -304,11 +304,13 @@ def init_config(app: FastAPI) -> None: def init_middlewares(app: FastAPI) -> None: from airflow.api_fastapi.app import get_auth_manager - from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware - app.add_middleware(JWTRefreshMiddleware, skip_path_prefixes=(f"{_STATIC_URL_PATH}/",)) + auth_manager = get_auth_manager() - for middleware_cls, middleware_kwargs in get_auth_manager().get_fastapi_middlewares(): + for middleware_cls, middleware_kwargs in ( + *auth_manager.get_jwt_refresh_middleware(), + *auth_manager.get_fastapi_middlewares(), + ): app.add_middleware(middleware_cls, **middleware_kwargs) # GZipMiddleware must be inside HttpAccessLogMiddleware so that access logs capture diff --git a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py index 18a9ceeecd2..e433c1223da 100644 --- a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py +++ b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py @@ -36,8 +36,10 @@ from airflow.api_fastapi.auth.managers.models.resource_details import ( TeamDetails, VariableDetails, ) +from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware from airflow.api_fastapi.auth.tokens import JWTGenerator, JWTValidator from airflow.api_fastapi.common.types import MenuItem +from airflow.api_fastapi.core_api.app import _STATIC_URL_PATH from airflow.exceptions import RemovedInAirflow4Warning from airflow.models.pool import Pool from airflow.models.team import Team @@ -932,3 +934,11 @@ class TestBaseAuthManager: user = BaseAuthManagerUserTest(name=user_id) result = auth_manager.is_authorized_hitl_task(assigned_users=assigned_users, user=user) assert result == expected + + def test_get_jwt_refresh_middleware(self, auth_manager): + assert auth_manager.get_jwt_refresh_middleware() == [ + (JWTRefreshMiddleware, {"skip_path_prefixes": (f"{_STATIC_URL_PATH}/",)}) + ] + + def test_get_fastapi_middlewares(self, auth_manager): + assert auth_manager.get_fastapi_middlewares() == [] diff --git a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py b/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py index a601a0417d1..39aa7216b9d 100644 --- a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py +++ b/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py @@ -32,6 +32,8 @@ from airflow.api_fastapi.auth.middlewares import refresh_token from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware from airflow.api_fastapi.core_api.security import USER_INJECTED_BY_TRUSTED_MIDDLEWARE +from tests_common.test_utils.config import conf_vars + MODULE = "airflow.api_fastapi.auth.middlewares.refresh_token" @@ -97,9 +99,8 @@ def mock_resolve_user(): @pytest.fixture(autouse=True) def no_ssl_cert(): - with patch(f"{MODULE}.conf", autospec=True) as mock_conf: - mock_conf.get.return_value = "" - yield mock_conf + with conf_vars({("api", "ssl_cert"): ""}): + yield class TestJWTRefreshMiddleware: @@ -272,18 +273,18 @@ class TestJWTRefreshMiddleware: ], ) def test_cookie_secure_flag( - self, mock_resolve_user, mock_auth_manager, no_ssl_cert, scheme, ssl_cert, expected_secure + self, mock_resolve_user, mock_auth_manager, scheme, ssl_cert, expected_secure ): """The cookie Secure flag must follow the request scheme as well as the local ssl_cert.""" mock_auth_manager.refresh_user.return_value = MagicMock(spec=BaseUser) mock_auth_manager.generate_jwt.return_value = "new_token" - no_ssl_cert.get.return_value = ssl_cert - app, _ = _build_app() - client = TestClient( - app, base_url=f"{scheme}://testserver", cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"} - ) + with conf_vars({("api", "ssl_cert"): ssl_cert}): + app, _ = _build_app() + client = TestClient( + app, base_url=f"{scheme}://testserver", cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"} + ) - response = client.get("/api/v2/dags") + response = client.get("/api/v2/dags") assert ("Secure" in response.headers["set-cookie"]) is expected_secure @@ -314,3 +315,63 @@ class TestJWTRefreshMiddleware: assert any( "Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h for h in set_cookie_headers ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("new_user", [(MagicMock(),), (None,)]) + async def test_generate_new_user_token(self, mock_auth_manager, new_user): + mock_auth_manager.generate_jwt.return_value = "new_token" + app, seen = _build_app() + middleware = JWTRefreshMiddleware(app) + new_token = await middleware.generate_new_user_token(MagicMock(), new_user) + if new_user is None: + assert new_token == "" + else: + assert new_token == "new_token" + + @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies") + def test_refresh_user_with_extra_cookies( + self, mock_get_extra_cookies: MagicMock, mock_resolve_user, mock_auth_manager + ): + mock_get_extra_cookies.return_value = {"foo": "bar"} + refreshed_user = MagicMock(spec=BaseUser) + mock_auth_manager.refresh_user.return_value = refreshed_user + mock_auth_manager.generate_jwt.return_value = "new_token" + app, seen = _build_app() + + response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"}).get("/api/v2/dags") + + # JWTRefreshMiddleware must stamp the trust sentinel so `get_user()` accepts + # the cached user. Without the marker, the auth dependency falls through + # to a fresh JWT validation, defeating the refresh. + assert seen["user"] is refreshed_user + assert seen["authenticated_via"] is USER_INJECTED_BY_TRUSTED_MIDDLEWARE + mock_auth_manager.refresh_user.assert_called_once_with(user=mock_resolve_user.return_value) + mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user) + assert ( + response.headers["set-cookie"] + == "_token=new_token; HttpOnly; Path=/; SameSite=lax, foo=bar; HttpOnly; Path=/; SameSite=lax" + ) + + @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies") + def test_refresh_user_clear_extra_cookies( + self, mock_get_extra_cookies: MagicMock, mock_resolve_user, mock_auth_manager + ): + mock_get_extra_cookies.return_value = {"foo": ""} + refreshed_user = MagicMock(spec=BaseUser) + mock_auth_manager.refresh_user.return_value = refreshed_user + mock_auth_manager.generate_jwt.return_value = "new_token" + app, seen = _build_app() + + response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"}).get("/api/v2/dags") + + # JWTRefreshMiddleware must stamp the trust sentinel so `get_user()` accepts + # the cached user. Without the marker, the auth dependency falls through + # to a fresh JWT validation, defeating the refresh. + assert seen["user"] is refreshed_user + assert seen["authenticated_via"] is USER_INJECTED_BY_TRUSTED_MIDDLEWARE + mock_auth_manager.refresh_user.assert_called_once_with(user=mock_resolve_user.return_value) + mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user) + assert ( + response.headers["set-cookie"] + == '_token=new_token; HttpOnly; Path=/; SameSite=lax, foo=""; HttpOnly; Max-Age=0; Path=/; SameSite=lax' + ) diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py index fc4e84621e4..6fd0143388f 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py @@ -367,7 +367,7 @@ class TestLogoutTokenRevocation: # The refresh middleware is stubbed out so that only the logout route can clear the cookie. with ( patch( - "airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware._refresh_user", + "airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware.refresh_user", new=AsyncMock(return_value=(None, None)), ), patch.object(auth_manager, "get_url_logout", return_value="http://external/logout"), @@ -389,7 +389,7 @@ class TestLogoutTokenRevocation: logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, token_str) with ( patch( - "airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware._refresh_user", + "airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware.refresh_user", new=AsyncMock(return_value=(None, None)), ), patch.object(auth_manager, "get_url_logout", return_value="http://external/logout"), diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py index 3d26d632639..ae765987760 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py @@ -20,10 +20,12 @@ import contextlib import inspect import json import typing +from unittest.mock import MagicMock, Mock, patch import pytest from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.params import Depends as DependsClass from fastapi.responses import StreamingResponse from fastapi.templating import Jinja2Templates @@ -31,9 +33,11 @@ from starlette.routing import Mount from starlette.testclient import TestClient from airflow.api_fastapi.app import create_app +from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager from airflow.api_fastapi.core_api.app import ( base_react_app_bundle_urls, init_config, + init_middlewares, init_ui_translation_views, ) from airflow.api_fastapi.core_api.routes.public import authenticated_router @@ -384,3 +388,28 @@ class TestBaseReactAppPreload: html = self._render("index.html", ['/x.js" onload="alert(1)']) assert 'onload="alert(1)"' not in html + + +class TestMiddlewareInitialisation: + @patch("airflow.api_fastapi.app.get_auth_manager") + def test_jwt_refresh_middleware_init(self, mock_get_auth_manager): + app = FastAPI() + auth_manager = MagicMock(spec=BaseAuthManager) + mock_jwt_middleware = Mock() + auth_manager.get_jwt_refresh_middleware.return_value = [(mock_jwt_middleware, {})] + auth_manager.get_fastapi_middlewares.return_value = [] + mock_get_auth_manager.return_value = auth_manager + init_middlewares(app) + auth_manager.get_jwt_refresh_middleware.assert_called_once() + assert [m.cls for m in app.user_middleware] == [GZipMiddleware, mock_jwt_middleware] + + @patch("airflow.api_fastapi.app.get_auth_manager") + def test_no_jwt_refresh_middleware_init(self, mock_get_auth_manager): + app = FastAPI() + auth_manager = MagicMock(spec=BaseAuthManager) + auth_manager.get_jwt_refresh_middleware.return_value = [] + auth_manager.get_fastapi_middlewares.return_value = [] + mock_get_auth_manager.return_value = auth_manager + init_middlewares(app) + auth_manager.get_jwt_refresh_middleware.assert_called_once() + assert [m.cls for m in app.user_middleware] == [GZipMiddleware] diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py b/airflow-core/tests/unit/api_fastapi/test_app.py index 82e0674bafe..e0fb816c8c5 100644 --- a/airflow-core/tests/unit/api_fastapi/test_app.py +++ b/airflow-core/tests/unit/api_fastapi/test_app.py @@ -347,6 +347,22 @@ class TestGetCookiePath: assert app_module.get_cookie_path() == "/org/team-a/airflow/" [email protected]( + ("scheme", "ssl_cert", "exp"), + [ + ("http", "", False), + ("https", "", True), + ("http", "/foo/bar/cert.crt", True), + ("https", "/foo/bar/cert/crt", True), + ], +) +def test_request_cookie_is_secure(scheme, ssl_cert, exp): + request = mock.Mock() + request.base_url.scheme = scheme + with conf_vars({("api", "ssl_cert"): ssl_cert}): + assert app_module.request_cookie_is_secure(request) == exp + + def test_create_auth_manager_thread_safety(): """Concurrent calls to create_auth_manager must return the same singleton instance.""" call_count = 0
