From 1669c7e7ccbf494baa3df0ce682443031661ca51 Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Wed, 29 Jul 2026 17:03:46 +0530 Subject: [PATCH 1/8] fix: Updated projects-list.json in order to display newly added projects Signed-off-by: Aditya Patil --- sdk/python/feast/ui_server.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 948d1ea3742..e84da2c9e5e 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -889,9 +889,11 @@ def get_app( ui_dir_ref = importlib_resources.files(__spec__.parent) / "ui/build/" # type: ignore[name-defined, arg-type] with importlib_resources.as_file(ui_dir_ref) as ui_dir: - projects_dict = _build_projects_list(store, project_id, root_path) - with ui_dir.joinpath("projects-list.json").open(mode="w") as f: - f.write(json.dumps(projects_dict)) + pass + + @app.get("/projects-list.json") + def get_projects_list(): + return _build_projects_list(store, project_id, root_path) @app.get("/api/mlflow-runs") def get_mlflow_runs(max_results: int = 50): From df39c55e3c6fb96b2f2c03ceb389241a7fc9cc5e Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Wed, 29 Jul 2026 20:53:17 +0530 Subject: [PATCH 2/8] fix: Bypass registry cache for projects-list endpoint Signed-off-by: Aditya Patil --- sdk/python/feast/ui_server.py | 2 +- sdk/python/tests/unit/test_ui_server.py | 63 +++++++++++++++++++------ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index e84da2c9e5e..88ffb386c27 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -62,7 +62,7 @@ def _build_projects_list( registry_path_template = f"{root_path}/api/v1" try: - projects = store.registry.list_projects(allow_cache=True) + projects = store.registry.list_projects(allow_cache=False) for proj in projects: discovered_projects.append( { diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index d878a3f1a20..abb128125ea 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -148,11 +148,12 @@ def test_catch_all_route(ui_app_with_registry): # ---------- projects-list.json tests ---------- -def _read_projects_list(temp_dir): - """Read the projects-list.json written by get_app via the mock (ui_dir = temp_dir).""" - projects_file = os.path.join(temp_dir, "projects-list.json") - with open(projects_file) as f: - return json.load(f) +def _get_projects_list(app): + """Fetch the dynamic projects-list.json endpoint.""" + client = TestClient(app) + resp = client.get("/projects-list.json") + assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) + return resp.json() def test_projects_list_registry_path(mock_feature_store): @@ -165,9 +166,9 @@ def test_projects_list_registry_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _get_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1") @@ -181,13 +182,13 @@ def test_projects_list_with_root_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app( + app = get_app( mock_feature_store, TEST_PROJECT_NAME, root_path="/feast", ) - data = _read_projects_list(temp_dir) + data = _get_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to( "/feast/api/v1" ) @@ -206,9 +207,9 @@ def test_projects_list_multiple_projects(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _get_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(3) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all") assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("project_alpha") @@ -225,9 +226,9 @@ def test_projects_list_fallback_on_empty(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _get_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) @@ -242,8 +243,40 @@ def test_projects_list_fallback_on_exception(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _get_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) + + +def test_projects_list_dynamic_refresh(mock_feature_store): + """New projects appear without restarting the server.""" + mock_registry = MagicMock() + mock_registry.list_projects.return_value = [ + _make_project_mock("picked_elk"), + ] + mock_feature_store.registry = mock_registry + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app) + + data = client.get("/projects-list.json").json() + assertpy.assert_that(len(data["projects"])).is_equal_to(1) + assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("picked_elk") + + mock_registry.list_projects.return_value = [ + _make_project_mock("picked_elk"), + _make_project_mock("picked_elk2"), + ] + + data = client.get("/projects-list.json").json() + assertpy.assert_that(len(data["projects"])).is_equal_to(3) + assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all") + assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("picked_elk") + assertpy.assert_that(data["projects"][2]["id"]).is_equal_to("picked_elk2") From 86a8dca03515e01653f1ad82a3f4cf0e51978e97 Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Thu, 30 Jul 2026 15:34:41 +0530 Subject: [PATCH 3/8] feat: Added registry refresh button in UI Signed-off-by: Aditya Patil --- .../online-server-performance-tuning.md | 9 ++++ sdk/python/feast/ui_server.py | 7 ++- sdk/python/tests/unit/test_ui_server.py | 16 ++++++ ui/src/FeastUISansProviders.tsx | 2 + ui/src/contexts/ProjectListContext.ts | 1 + ui/src/pages/RootProjectSelectionPage.tsx | 50 +++++++++++++++++-- 6 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/how-to-guides/online-server-performance-tuning.md b/docs/how-to-guides/online-server-performance-tuning.md index f10d7bff8c8..7e56c28d4d3 100644 --- a/docs/how-to-guides/online-server-performance-tuning.md +++ b/docs/how-to-guides/online-server-performance-tuning.md @@ -266,6 +266,15 @@ The `registryTTLSeconds` field on the Operator CR (or `--registry_ttl_sec` CLI f | Production (low-latency) | `thread` | 300 | | Production (frequent schema changes) | `thread` | 60 | +### UI refresh + +The Feast UI caches the project list using the same registry cache. After running `feast apply` to add a new project, it may take up to `cache_ttl_seconds` before the project appears in the UI. + +To see new projects faster: + +- **Lower the TTL**: Set `cache_ttl_seconds: 10` (or similar) in your `feature_store.yaml` registry config. This makes all registry consumers — including the UI — pick up changes within 10 seconds. +- **Refresh on demand**: The project selection page has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/registry/refresh`) and reloads the project list without a full page refresh. + --- ## Online store selection diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 88ffb386c27..d2173dd8c40 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -62,7 +62,7 @@ def _build_projects_list( registry_path_template = f"{root_path}/api/v1" try: - projects = store.registry.list_projects(allow_cache=False) + projects = store.registry.list_projects(allow_cache=True) for proj in projects: discovered_projects.append( { @@ -895,6 +895,11 @@ def get_app( def get_projects_list(): return _build_projects_list(store, project_id, root_path) + @app.post("/api/registry/refresh") + def refresh_registry(): + store.refresh_registry() + return Response(status_code=status.HTTP_200_OK) + @app.get("/api/mlflow-runs") def get_mlflow_runs(max_results: int = 50): """Return MLflow runs linked to this Feast project via auto-logging.""" diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index abb128125ea..c11ed257286 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -280,3 +280,19 @@ def test_projects_list_dynamic_refresh(mock_feature_store): assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all") assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("picked_elk") assertpy.assert_that(data["projects"][2]["id"]).is_equal_to("picked_elk2") + + +def test_registry_refresh_endpoint(mock_feature_store): + """POST /api/registry/refresh calls store.refresh_registry().""" + mock_feature_store.refresh_registry = MagicMock() + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app) + resp = client.post("/api/registry/refresh") + assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) + mock_feature_store.refresh_registry.assert_called_once() diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 1f032e3bc05..60a02aa1d56 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -79,10 +79,12 @@ const FeastUISansProviders = ({ ? { projectsListPromise: feastUIConfigs?.projectListPromise, isCustom: true, + basename, } : { projectsListPromise: defaultProjectListPromise(basename), isCustom: false, + basename, }; return ( diff --git a/ui/src/contexts/ProjectListContext.ts b/ui/src/contexts/ProjectListContext.ts index a230300be3b..c0c24840efb 100644 --- a/ui/src/contexts/ProjectListContext.ts +++ b/ui/src/contexts/ProjectListContext.ts @@ -20,6 +20,7 @@ type ProjectsListType = z.infer; interface ProjectsListContextInterface { projectsListPromise: Promise; isCustom: boolean; + basename?: string; } const ProjectListContext = React.createContext< diff --git a/ui/src/pages/RootProjectSelectionPage.tsx b/ui/src/pages/RootProjectSelectionPage.tsx index fb488e714bc..616ff0333a9 100644 --- a/ui/src/pages/RootProjectSelectionPage.tsx +++ b/ui/src/pages/RootProjectSelectionPage.tsx @@ -1,7 +1,9 @@ -import React, { useEffect } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { + EuiButtonIcon, EuiCard, EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSkeletonText, @@ -10,13 +12,22 @@ import { EuiTitle, EuiHorizontalRule, } from "@elastic/eui"; -import { useLoadProjectsList } from "../contexts/ProjectListContext"; +import { + useLoadProjectsList, + ProjectListContext, + ProjectsListSchema, +} from "../contexts/ProjectListContext"; import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "react-query"; import FeastIconBlue from "../graphics/FeastIconBlue"; const RootProjectSelectionPage = () => { const { isLoading, isSuccess, data } = useLoadProjectsList(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const projectListCtx = useContext(ProjectListContext); + const basename = projectListCtx?.basename || ""; + const [refreshing, setRefreshing] = useState(false); useEffect(() => { if (data && data.default) { @@ -30,6 +41,21 @@ const RootProjectSelectionPage = () => { } }, [data, navigate]); + const handleRefresh = async () => { + setRefreshing(true); + try { + await fetch(`${basename}/api/registry/refresh`, { method: "POST" }); + const res = await fetch(`${basename}/projects-list.json`, { + headers: { "Content-Type": "application/json" }, + }); + const json = await res.json(); + const parsed = ProjectsListSchema.parse(json); + queryClient.setQueryData("feast-projects-list", parsed); + } finally { + setRefreshing(false); + } + }; + const projectCards = data?.projects.map((item, index) => { return ( @@ -48,9 +74,23 @@ const RootProjectSelectionPage = () => { return ( - -

Welcome to Feast

-
+ + + +

Welcome to Feast

+
+
+ + + +

Select one of the projects.

From d24c95fc73e0351b774e9902a25849d3e92a2ce7 Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Thu, 30 Jul 2026 22:00:37 +0530 Subject: [PATCH 4/8] fix: Move UI registry refresh button to layout header Signed-off-by: Aditya Patil --- .../online-server-performance-tuning.md | 9 ---- docs/reference/alpha-web-ui.md | 9 ++++ sdk/python/feast/ui_server.py | 14 +++--- sdk/python/tests/unit/test_ui_server.py | 4 +- ui/src/pages/Layout.tsx | 40 ++++++++++++++- ui/src/pages/RootProjectSelectionPage.tsx | 50 ++----------------- 6 files changed, 62 insertions(+), 64 deletions(-) diff --git a/docs/how-to-guides/online-server-performance-tuning.md b/docs/how-to-guides/online-server-performance-tuning.md index 7e56c28d4d3..f10d7bff8c8 100644 --- a/docs/how-to-guides/online-server-performance-tuning.md +++ b/docs/how-to-guides/online-server-performance-tuning.md @@ -266,15 +266,6 @@ The `registryTTLSeconds` field on the Operator CR (or `--registry_ttl_sec` CLI f | Production (low-latency) | `thread` | 300 | | Production (frequent schema changes) | `thread` | 60 | -### UI refresh - -The Feast UI caches the project list using the same registry cache. After running `feast apply` to add a new project, it may take up to `cache_ttl_seconds` before the project appears in the UI. - -To see new projects faster: - -- **Lower the TTL**: Set `cache_ttl_seconds: 10` (or similar) in your `feature_store.yaml` registry config. This makes all registry consumers — including the UI — pick up changes within 10 seconds. -- **Refresh on demand**: The project selection page has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/registry/refresh`) and reloads the project list without a full page refresh. - --- ## Online store selection diff --git a/docs/reference/alpha-web-ui.md b/docs/reference/alpha-web-ui.md index 0556482fcf8..7c2551dab40 100644 --- a/docs/reference/alpha-web-ui.md +++ b/docs/reference/alpha-web-ui.md @@ -153,3 +153,12 @@ const tabsRegistry = { ``` Examples of custom tabs can be found in the `ui/custom-tabs` folder. + +## Refreshing the project list + +The Feast UI caches the project list using the same registry cache. After running `feast apply` to add a new project, it may take up to `cache_ttl_seconds` before the project appears in the UI. + +To see new projects faster: + +- **Lower the TTL**: Set `cache_ttl_seconds: 10` (or similar) in your `feature_store.yaml` registry config. This makes all registry consumers — including the UI — pick up changes within 10 seconds. +- **Refresh on demand**: The project selection page has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/v1/registry/refresh`) and reloads the project list without a full page refresh. diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index d2173dd8c40..e4e65846fd3 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -158,6 +158,11 @@ async def feast_object_not_found_handler( register_all_routes(rest_app, grpc_handler, store=store) + @rest_app.post("/registry/refresh") + def refresh_registry(): + store.refresh_registry() + return Response(status_code=status.HTTP_200_OK) + class PushRequest(BaseModel): push_source_name: str df: Dict[str, List] @@ -889,17 +894,14 @@ def get_app( ui_dir_ref = importlib_resources.files(__spec__.parent) / "ui/build/" # type: ignore[name-defined, arg-type] with importlib_resources.as_file(ui_dir_ref) as ui_dir: - pass + projects_dict = _build_projects_list(store, project_id, root_path) + with ui_dir.joinpath("projects-list.json").open(mode="w") as f: + f.write(json.dumps(projects_dict)) @app.get("/projects-list.json") def get_projects_list(): return _build_projects_list(store, project_id, root_path) - @app.post("/api/registry/refresh") - def refresh_registry(): - store.refresh_registry() - return Response(status_code=status.HTTP_200_OK) - @app.get("/api/mlflow-runs") def get_mlflow_runs(max_results: int = 50): """Return MLflow runs linked to this Feast project via auto-logging.""" diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index c11ed257286..189e39f9fe2 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -283,7 +283,7 @@ def test_projects_list_dynamic_refresh(mock_feature_store): def test_registry_refresh_endpoint(mock_feature_store): - """POST /api/registry/refresh calls store.refresh_registry().""" + """POST /api/v1/registry/refresh calls store.refresh_registry().""" mock_feature_store.refresh_registry = MagicMock() with tempfile.TemporaryDirectory() as temp_dir: @@ -293,6 +293,6 @@ def test_registry_refresh_endpoint(mock_feature_store): app = get_app(mock_feature_store, TEST_PROJECT_NAME) client = TestClient(app) - resp = client.post("/api/registry/refresh") + resp = client.post("/api/v1/registry/refresh") assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) mock_feature_store.refresh_registry.assert_called_once() diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index 6df1b8d4d77..e4796090ee6 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -1,6 +1,7 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useContext } from "react"; import { + EuiButton, EuiPage, EuiPageSidebar, EuiPageBody, @@ -18,10 +19,15 @@ import { EuiIcon, } from "@elastic/eui"; import { Outlet } from "react-router-dom"; +import { useQueryClient } from "react-query"; import RegistryPathContext from "../contexts/RegistryPathContext"; import { useParams } from "react-router-dom"; -import { useLoadProjectsList } from "../contexts/ProjectListContext"; +import { + useLoadProjectsList, + ProjectListContext, + ProjectsListSchema, +} from "../contexts/ProjectListContext"; import useLoadRegistry from "../queries/useLoadRegistry"; import ProjectSelector from "../components/ProjectSelector"; @@ -39,8 +45,12 @@ const Layout = () => { let { projectName } = useParams(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [refreshing, setRefreshing] = useState(false); const searchRef = useRef(null); const { user, logout, isAuthEnabled } = useAuth(); + const queryClient = useQueryClient(); + const projectListCtx = useContext(ProjectListContext); + const basename = projectListCtx?.basename || ""; const { data: projectsData } = useLoadProjectsList(); @@ -146,6 +156,21 @@ const Layout = () => { ] : []; + const handleRefresh = async () => { + setRefreshing(true); + try { + await fetch(`${basename}/api/v1/registry/refresh`, { method: "POST" }); + const res = await fetch(`${basename}/projects-list.json`, { + headers: { "Content-Type": "application/json" }, + }); + const json = await res.json(); + const parsed = ProjectsListSchema.parse(json); + queryClient.setQueryData("feast-projects-list", parsed); + } finally { + setRefreshing(false); + } + }; + const handleSearchOpen = () => { setIsCommandPaletteOpen(true); }; @@ -242,6 +267,17 @@ const Layout = () => { )} {!data && } + + + Refresh Registry + + + {isAuthEnabled && user && ( { const { isLoading, isSuccess, data } = useLoadProjectsList(); const navigate = useNavigate(); - const queryClient = useQueryClient(); - const projectListCtx = useContext(ProjectListContext); - const basename = projectListCtx?.basename || ""; - const [refreshing, setRefreshing] = useState(false); useEffect(() => { if (data && data.default) { @@ -41,21 +30,6 @@ const RootProjectSelectionPage = () => { } }, [data, navigate]); - const handleRefresh = async () => { - setRefreshing(true); - try { - await fetch(`${basename}/api/registry/refresh`, { method: "POST" }); - const res = await fetch(`${basename}/projects-list.json`, { - headers: { "Content-Type": "application/json" }, - }); - const json = await res.json(); - const parsed = ProjectsListSchema.parse(json); - queryClient.setQueryData("feast-projects-list", parsed); - } finally { - setRefreshing(false); - } - }; - const projectCards = data?.projects.map((item, index) => { return ( @@ -74,23 +48,9 @@ const RootProjectSelectionPage = () => { return ( - - - -

Welcome to Feast

-
-
- - - -
+ +

Welcome to Feast

+

Select one of the projects.

From 0fe4725fb2b620b31843bb880d5fd4e368d4bd07 Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Fri, 31 Jul 2026 15:17:40 +0530 Subject: [PATCH 5/8] fix: Moved registry refresh endpoint to REST and refactored UI refresh Signed-off-by: Aditya Patil --- docs/reference/alpha-web-ui.md | 8 +- .../feast/api/registry/rest/__init__.py | 17 +- sdk/python/feast/ui_server.py | 5 - sdk/python/tests/unit/test_ui_server.py | 18 + ui/src/contexts/RegistryRefreshContext.ts | 22 + ui/src/hooks/useRegistryRefresh.ts | 72 +++ ui/src/pages/Layout.tsx | 472 +++++++++--------- ui/src/pages/RootProjectSelectionPage.tsx | 31 +- 8 files changed, 389 insertions(+), 256 deletions(-) create mode 100644 ui/src/contexts/RegistryRefreshContext.ts create mode 100644 ui/src/hooks/useRegistryRefresh.ts diff --git a/docs/reference/alpha-web-ui.md b/docs/reference/alpha-web-ui.md index 7c2551dab40..3fe8ce052a8 100644 --- a/docs/reference/alpha-web-ui.md +++ b/docs/reference/alpha-web-ui.md @@ -154,11 +154,11 @@ const tabsRegistry = { Examples of custom tabs can be found in the `ui/custom-tabs` folder. -## Refreshing the project list +## Refreshing the registry -The Feast UI caches the project list using the same registry cache. After running `feast apply` to add a new project, it may take up to `cache_ttl_seconds` before the project appears in the UI. +The Feast UI caches registry data (projects, feature views, entities, etc.) using the registry cache. After running `feast apply` to make changes, it may take up to `cache_ttl_seconds` before the updates appear in the UI. -To see new projects faster: +To see changes faster: - **Lower the TTL**: Set `cache_ttl_seconds: 10` (or similar) in your `feature_store.yaml` registry config. This makes all registry consumers — including the UI — pick up changes within 10 seconds. -- **Refresh on demand**: The project selection page has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/v1/registry/refresh`) and reloads the project list without a full page refresh. +- **Refresh on demand**: The UI has a **Refresh** button that explicitly invalidates the server-side registry cache (`POST /api/v1/registry/refresh`) and reloads the UI without a full page refresh. diff --git a/sdk/python/feast/api/registry/rest/__init__.py b/sdk/python/feast/api/registry/rest/__init__.py index 899829bf927..1db83d783e2 100644 --- a/sdk/python/feast/api/registry/rest/__init__.py +++ b/sdk/python/feast/api/registry/rest/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, Optional -from fastapi import FastAPI +from fastapi import FastAPI, Response, status from feast.api.registry.rest.compute_engines import get_compute_engine_router from feast.api.registry.rest.data_sources import get_data_source_router @@ -44,6 +44,21 @@ def register_all_routes(app: FastAPI, grpc_handler, server=None, store=None): app.include_router(get_monitoring_router(grpc_handler, store=resolved_store)) app.include_router(get_compute_engine_router(grpc_handler, store=resolved_store)) + if resolved_store: + + @app.post("/registry/refresh") + def refresh_registry(): + try: + resolved_store.refresh_registry() + return Response(status_code=status.HTTP_200_OK) + except Exception: + logger.exception("Registry refresh failed") + return Response( + content='{"detail":"Registry refresh failed. Check server logs for details."}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + media_type="application/json", + ) + _register_openlineage_consumer(app, resolved_store) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index e4e65846fd3..767bff5ca9f 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -158,11 +158,6 @@ async def feast_object_not_found_handler( register_all_routes(rest_app, grpc_handler, store=store) - @rest_app.post("/registry/refresh") - def refresh_registry(): - store.refresh_registry() - return Response(status_code=status.HTTP_200_OK) - class PushRequest(BaseModel): push_source_name: str df: Dict[str, List] diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index 189e39f9fe2..1e846365175 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -296,3 +296,21 @@ def test_registry_refresh_endpoint(mock_feature_store): resp = client.post("/api/v1/registry/refresh") assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) mock_feature_store.refresh_registry.assert_called_once() + + +def test_registry_refresh_endpoint_error(mock_feature_store): + """POST /api/v1/registry/refresh returns 500 when refresh_registry raises.""" + mock_feature_store.refresh_registry = MagicMock( + side_effect=Exception("registry unreachable") + ) + + with tempfile.TemporaryDirectory() as temp_dir: + _create_mock_ui_files(temp_dir) + + with _setup_importlib_mocks(temp_dir): + app = get_app(mock_feature_store, TEST_PROJECT_NAME) + + client = TestClient(app) + resp = client.post("/api/v1/registry/refresh") + assertpy.assert_that(resp.status_code).is_equal_to(500) + assertpy.assert_that(resp.json()["detail"]).contains("Registry refresh failed") diff --git a/ui/src/contexts/RegistryRefreshContext.ts b/ui/src/contexts/RegistryRefreshContext.ts new file mode 100644 index 00000000000..12be5977d88 --- /dev/null +++ b/ui/src/contexts/RegistryRefreshContext.ts @@ -0,0 +1,22 @@ +import React, { useContext } from "react"; + +interface RegistryRefreshContextInterface { + refreshing: boolean; + handleRefresh: () => Promise; +} + +const RegistryRefreshContext = React.createContext< + RegistryRefreshContextInterface | undefined +>(undefined); + +const useRegistryRefreshContext = () => { + const ctx = useContext(RegistryRefreshContext); + if (!ctx) { + throw new Error( + "useRegistryRefreshContext must be used within RegistryRefreshContext.Provider", + ); + } + return ctx; +}; + +export { RegistryRefreshContext, useRegistryRefreshContext }; diff --git a/ui/src/hooks/useRegistryRefresh.ts b/ui/src/hooks/useRegistryRefresh.ts new file mode 100644 index 00000000000..72087a0594e --- /dev/null +++ b/ui/src/hooks/useRegistryRefresh.ts @@ -0,0 +1,72 @@ +import { useCallback, useContext, useState } from "react"; +import { useQueryClient } from "react-query"; +import { + ProjectListContext, + ProjectsListSchema, +} from "../contexts/ProjectListContext"; + +interface Toast { + id: string; + title: string; + color: "success" | "danger"; + iconType: string; +} + +const useRegistryRefresh = () => { + const [refreshing, setRefreshing] = useState(false); + const [toasts, setToasts] = useState([]); + const queryClient = useQueryClient(); + const projectListCtx = useContext(ProjectListContext); + const basename = projectListCtx?.basename || ""; + + const removeToast = useCallback((removedToast: { id: string }) => { + setToasts((prev) => prev.filter((t) => t.id !== removedToast.id)); + }, []); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + const refreshRes = await fetch(`${basename}/api/v1/registry/refresh`, { + method: "POST", + }); + if (!refreshRes.ok) { + throw new Error(`Registry refresh failed (${refreshRes.status})`); + } + const res = await fetch(`${basename}/projects-list.json`, { + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + throw new Error(`Failed to fetch project list (${res.status})`); + } + const json = await res.json(); + const parsed = ProjectsListSchema.parse(json); + queryClient.setQueryData("feast-projects-list", parsed); + await queryClient.invalidateQueries("registry-rest-bulk"); + setToasts((prev) => [ + ...prev, + { + id: String(Date.now()), + title: "Refresh successful", + color: "success" as const, + iconType: "check", + }, + ]); + } catch { + setToasts((prev) => [ + ...prev, + { + id: String(Date.now()), + title: "Refresh failed", + color: "danger" as const, + iconType: "alert", + }, + ]); + } finally { + setRefreshing(false); + } + }, [basename, queryClient]); + + return { refreshing, toasts, handleRefresh, removeToast }; +}; + +export default useRegistryRefresh; diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index e4796090ee6..a951b9a2649 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -1,7 +1,7 @@ -import React, { useState, useRef, useEffect, useContext } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { - EuiButton, + EuiGlobalToastList, EuiPage, EuiPageSidebar, EuiPageBody, @@ -19,15 +19,10 @@ import { EuiIcon, } from "@elastic/eui"; import { Outlet } from "react-router-dom"; -import { useQueryClient } from "react-query"; import RegistryPathContext from "../contexts/RegistryPathContext"; import { useParams } from "react-router-dom"; -import { - useLoadProjectsList, - ProjectListContext, - ProjectsListSchema, -} from "../contexts/ProjectListContext"; +import { useLoadProjectsList } from "../contexts/ProjectListContext"; import useLoadRegistry from "../queries/useLoadRegistry"; import ProjectSelector from "../components/ProjectSelector"; @@ -40,17 +35,17 @@ import RegistrySearch, { import GlobalSearchShortcut from "../components/GlobalSearchShortcut"; import CommandPalette from "../components/CommandPalette"; import { useAuth } from "../contexts/AuthContext"; +import { RegistryRefreshContext } from "../contexts/RegistryRefreshContext"; +import useRegistryRefresh from "../hooks/useRegistryRefresh"; const Layout = () => { let { projectName } = useParams(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const [refreshing, setRefreshing] = useState(false); const searchRef = useRef(null); const { user, logout, isAuthEnabled } = useAuth(); - const queryClient = useQueryClient(); - const projectListCtx = useContext(ProjectListContext); - const basename = projectListCtx?.basename || ""; + const { refreshing, toasts, handleRefresh, removeToast } = + useRegistryRefresh(); const { data: projectsData } = useLoadProjectsList(); @@ -156,21 +151,6 @@ const Layout = () => { ] : []; - const handleRefresh = async () => { - setRefreshing(true); - try { - await fetch(`${basename}/api/v1/registry/refresh`, { method: "POST" }); - const res = await fetch(`${basename}/projects-list.json`, { - headers: { "Content-Type": "application/json" }, - }); - const json = await res.json(); - const parsed = ProjectsListSchema.parse(json); - queryClient.setQueryData("feast-projects-list", parsed); - } finally { - setRefreshing(false); - } - }; - const handleSearchOpen = () => { setIsCommandPaletteOpen(true); }; @@ -191,238 +171,250 @@ const Layout = () => { }, []); return ( - - - setIsCommandPaletteOpen(false)} - categories={globalCategories} - /> - - - - - - {registryPath && ( - - - - - -
- -
-
- )} -
+ + + + setIsCommandPaletteOpen(false)} + categories={globalCategories} + /> + + + + + + {registryPath && ( + + + + + +
+ +
+
+ )} +
- - -
+ +
- - {data && ( - -
- -
-
- )} - {!data && } +
+ + {data && ( + +
+ +
+
+ )} + {!data && } - - - Refresh Registry - - + {projectName && ( + + + Refresh + + + )} - {isAuthEnabled && user && ( - - setIsUserMenuOpen((v) => !v)} - style={{ - display: "flex", - alignItems: "center", - gap: 8, - background: "none", - border: "none", - cursor: "pointer", - padding: "4px 8px", - borderRadius: 6, - }} - aria-label="User menu" - > - - - {user.username} - - - - } - isOpen={isUserMenuOpen} - closePopover={() => setIsUserMenuOpen(false)} - anchorPosition="downRight" - panelPaddingSize="m" - > -
- - + {isAuthEnabled && user && ( + + setIsUserMenuOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + background: "none", + border: "none", + cursor: "pointer", + padding: "4px 8px", + borderRadius: 6, + }} + aria-label="User menu" + > - - - + {user.username} - {user.email && ( + + + } + isOpen={isUserMenuOpen} + closePopover={() => setIsUserMenuOpen(false)} + anchorPosition="downRight" + panelPaddingSize="m" + > +
+ + + + + + + {user.username} + + {user.email && ( + + {user.email} + + )} + + + + {user.roles.length > 0 && ( + <> + - {user.email} + Roles - )} - - + +
+ {user.roles + .filter( + (r) => + ![ + "default-roles-feast", + "offline_access", + "uma_authorization", + ].includes(r), + ) + .map((role) => ( + + + {role} + + + ))} +
+ + )} - {user.roles.length > 0 && ( - <> - - - Roles - - -
- {user.roles - .filter( - (r) => - ![ - "default-roles-feast", - "offline_access", - "uma_authorization", - ].includes(r), - ) - .map((role) => ( - - {role} - + {user.groups.length > 0 && ( + <> + + + Groups + + +
+ {user.groups.map((group) => ( + + {group} + ))} -
- - )} +
+ + )} - {user.groups.length > 0 && ( - <> - - - Groups - - -
- {user.groups.map((group) => ( - - {group} - - ))} -
- - )} - - - - Sign out - -
- -
- )} -
-
-
- + + + Sign out + +
+
+
+ )} +
+
+
+ +
-
-
-
-
-
+ + +
+ +
+ ); }; diff --git a/ui/src/pages/RootProjectSelectionPage.tsx b/ui/src/pages/RootProjectSelectionPage.tsx index fb488e714bc..6740e266f2a 100644 --- a/ui/src/pages/RootProjectSelectionPage.tsx +++ b/ui/src/pages/RootProjectSelectionPage.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from "react"; import { + EuiButtonEmpty, EuiCard, EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSkeletonText, @@ -13,10 +15,12 @@ import { import { useLoadProjectsList } from "../contexts/ProjectListContext"; import { useNavigate } from "react-router-dom"; import FeastIconBlue from "../graphics/FeastIconBlue"; +import { useRegistryRefreshContext } from "../contexts/RegistryRefreshContext"; const RootProjectSelectionPage = () => { const { isLoading, isSuccess, data } = useLoadProjectsList(); const navigate = useNavigate(); + const { refreshing, handleRefresh } = useRegistryRefreshContext(); useEffect(() => { if (data && data.default) { @@ -48,12 +52,27 @@ const RootProjectSelectionPage = () => { return ( - -

Welcome to Feast

-
- -

Select one of the projects.

-
+ + + +

Welcome to Feast

+
+ +

Select one of the projects.

+
+
+ + + Refresh + + +
{isLoading && } {isSuccess && data?.projects && ( From a740aa233fa6366bb261bf92b8769e23d2be15dc Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Fri, 31 Jul 2026 17:05:17 +0530 Subject: [PATCH 6/8] fix: Enforced UPDATE permission on REST registry refresh Signed-off-by: Aditya Patil --- .../feast/api/registry/rest/__init__.py | 8 ++++ .../tests/unit/api/test_api_rest_registry.py | 43 +++++++++++++++++++ sdk/python/tests/unit/test_ui_server.py | 31 +++++++------ ui/src/hooks/useRegistryRefresh.ts | 12 +++++- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/sdk/python/feast/api/registry/rest/__init__.py b/sdk/python/feast/api/registry/rest/__init__.py index 1db83d783e2..bd039badde6 100644 --- a/sdk/python/feast/api/registry/rest/__init__.py +++ b/sdk/python/feast/api/registry/rest/__init__.py @@ -49,6 +49,14 @@ def register_all_routes(app: FastAPI, grpc_handler, server=None, store=None): @app.post("/registry/refresh") def refresh_registry(): try: + from feast.permissions.action import AuthzedAction + from feast.permissions.security_manager import assert_permissions + + project = resolved_store.registry.get_project( + name=resolved_store.project, allow_cache=True + ) + assert_permissions(resource=project, actions=[AuthzedAction.UPDATE]) + resolved_store.refresh_registry() return Response(status_code=status.HTTP_200_OK) except Exception: diff --git a/sdk/python/tests/unit/api/test_api_rest_registry.py b/sdk/python/tests/unit/api/test_api_rest_registry.py index 60aff9aed52..842aee34a28 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -2286,3 +2286,46 @@ def test_metrics_resource_counts_nonexistent_project(fastapi_test_app): assert data["featureServices"] == [] assert data["featureViews"] == [] assert "registryLastUpdated" in data + + +def test_registry_refresh_via_rest(fastapi_test_app): + """POST /registry/refresh invalidates the registry cache and returns 200.""" + response = fastapi_test_app.post("/registry/refresh") + assert response.status_code == 200 + + +def test_registry_refresh_via_rest_error(): + """POST /registry/refresh returns 500 when refresh fails.""" + from unittest.mock import patch + + from feast.api.registry.rest import register_all_routes + from feast.registry_server import RegistryServer + + tmp_dir = tempfile.TemporaryDirectory() + registry_path = os.path.join(tmp_dir.name, "registry.db") + + config = { + "registry": registry_path, + "project": "demo_project", + "provider": "local", + "offline_store": {"type": "file"}, + "online_store": {"type": "sqlite", "path": ":memory:"}, + } + from fastapi import FastAPI + + from feast.repo_config import RepoConfig + + store = FeatureStore(config=RepoConfig.model_validate(config)) + store.apply([]) + + app = FastAPI() + grpc_handler = RegistryServer(store.registry, store=store) + register_all_routes(app, grpc_handler, store=store) + + with patch.object(store, "refresh_registry", side_effect=Exception("db error")): + client = TestClient(app) + response = client.post("/registry/refresh") + assert response.status_code == 500 + assert "Registry refresh failed" in response.json()["detail"] + + tmp_dir.cleanup() diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index 1e846365175..adc6096af1e 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -148,12 +148,11 @@ def test_catch_all_route(ui_app_with_registry): # ---------- projects-list.json tests ---------- -def _get_projects_list(app): - """Fetch the dynamic projects-list.json endpoint.""" - client = TestClient(app) - resp = client.get("/projects-list.json") - assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) - return resp.json() +def _read_projects_list(temp_dir): + """Read the projects-list.json written by get_app via the mock (ui_dir = temp_dir).""" + projects_file = os.path.join(temp_dir, "projects-list.json") + with open(projects_file) as f: + return json.load(f) def test_projects_list_registry_path(mock_feature_store): @@ -166,9 +165,9 @@ def test_projects_list_registry_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - app = get_app(mock_feature_store, TEST_PROJECT_NAME) + get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _get_projects_list(app) + data = _read_projects_list(temp_dir) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1") @@ -182,13 +181,13 @@ def test_projects_list_with_root_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - app = get_app( + get_app( mock_feature_store, TEST_PROJECT_NAME, root_path="/feast", ) - data = _get_projects_list(app) + data = _read_projects_list(temp_dir) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to( "/feast/api/v1" ) @@ -207,9 +206,9 @@ def test_projects_list_multiple_projects(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - app = get_app(mock_feature_store, TEST_PROJECT_NAME) + get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _get_projects_list(app) + data = _read_projects_list(temp_dir) assertpy.assert_that(len(data["projects"])).is_equal_to(3) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all") assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("project_alpha") @@ -226,9 +225,9 @@ def test_projects_list_fallback_on_empty(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - app = get_app(mock_feature_store, TEST_PROJECT_NAME) + get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _get_projects_list(app) + data = _read_projects_list(temp_dir) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) @@ -243,9 +242,9 @@ def test_projects_list_fallback_on_exception(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - app = get_app(mock_feature_store, TEST_PROJECT_NAME) + get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _get_projects_list(app) + data = _read_projects_list(temp_dir) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) diff --git a/ui/src/hooks/useRegistryRefresh.ts b/ui/src/hooks/useRegistryRefresh.ts index 72087a0594e..cb684cf5636 100644 --- a/ui/src/hooks/useRegistryRefresh.ts +++ b/ui/src/hooks/useRegistryRefresh.ts @@ -4,6 +4,7 @@ import { ProjectListContext, ProjectsListSchema, } from "../contexts/ProjectListContext"; +import { useDataMode } from "../contexts/DataModeContext"; interface Toast { id: string; @@ -18,6 +19,7 @@ const useRegistryRefresh = () => { const queryClient = useQueryClient(); const projectListCtx = useContext(ProjectListContext); const basename = projectListCtx?.basename || ""; + const { fetchOptions } = useDataMode(); const removeToast = useCallback((removedToast: { id: string }) => { setToasts((prev) => prev.filter((t) => t.id !== removedToast.id)); @@ -28,12 +30,18 @@ const useRegistryRefresh = () => { try { const refreshRes = await fetch(`${basename}/api/v1/registry/refresh`, { method: "POST", + headers: { ...fetchOptions?.headers }, + credentials: fetchOptions?.credentials, }); if (!refreshRes.ok) { throw new Error(`Registry refresh failed (${refreshRes.status})`); } const res = await fetch(`${basename}/projects-list.json`, { - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...fetchOptions?.headers, + }, + credentials: fetchOptions?.credentials, }); if (!res.ok) { throw new Error(`Failed to fetch project list (${res.status})`); @@ -64,7 +72,7 @@ const useRegistryRefresh = () => { } finally { setRefreshing(false); } - }, [basename, queryClient]); + }, [basename, queryClient, fetchOptions]); return { refreshing, toasts, handleRefresh, removeToast }; }; From 6e87a2259cc8dba6d877a743fa7654707dc5896d Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Fri, 31 Jul 2026 18:07:38 +0530 Subject: [PATCH 7/8] fix: Moved registry refresh endpoint to dedicated router Signed-off-by: Aditya Patil --- .../feast/api/registry/rest/__init__.py | 26 +++---------------- .../feast/api/registry/rest/projects.py | 18 ++++++++++++- .../tests/unit/api/test_api_rest_registry.py | 3 +-- sdk/python/tests/unit/test_ui_server.py | 3 +-- 4 files changed, 22 insertions(+), 28 deletions(-) diff --git a/sdk/python/feast/api/registry/rest/__init__.py b/sdk/python/feast/api/registry/rest/__init__.py index bd039badde6..4f5712e1493 100644 --- a/sdk/python/feast/api/registry/rest/__init__.py +++ b/sdk/python/feast/api/registry/rest/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, Optional -from fastapi import FastAPI, Response, status +from fastapi import FastAPI from feast.api.registry.rest.compute_engines import get_compute_engine_router from feast.api.registry.rest.data_sources import get_data_source_router @@ -14,7 +14,7 @@ from feast.api.registry.rest.metrics import get_metrics_router from feast.api.registry.rest.monitoring import get_monitoring_router from feast.api.registry.rest.permissions import get_permission_router -from feast.api.registry.rest.projects import get_project_router +from feast.api.registry.rest.projects import get_project_router, get_registry_router from feast.api.registry.rest.saved_datasets import get_saved_dataset_router from feast.api.registry.rest.search import get_search_router @@ -45,27 +45,7 @@ def register_all_routes(app: FastAPI, grpc_handler, server=None, store=None): app.include_router(get_compute_engine_router(grpc_handler, store=resolved_store)) if resolved_store: - - @app.post("/registry/refresh") - def refresh_registry(): - try: - from feast.permissions.action import AuthzedAction - from feast.permissions.security_manager import assert_permissions - - project = resolved_store.registry.get_project( - name=resolved_store.project, allow_cache=True - ) - assert_permissions(resource=project, actions=[AuthzedAction.UPDATE]) - - resolved_store.refresh_registry() - return Response(status_code=status.HTTP_200_OK) - except Exception: - logger.exception("Registry refresh failed") - return Response( - content='{"detail":"Registry refresh failed. Check server logs for details."}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - media_type="application/json", - ) + app.include_router(get_registry_router(resolved_store)) _register_openlineage_consumer(app, resolved_store) diff --git a/sdk/python/feast/api/registry/rest/projects.py b/sdk/python/feast/api/registry/rest/projects.py index 659fb22dc8f..2047c8717c9 100644 --- a/sdk/python/feast/api/registry/rest/projects.py +++ b/sdk/python/feast/api/registry/rest/projects.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Response, status from feast.api.registry.rest.rest_utils import ( get_pagination_params, @@ -50,3 +50,19 @@ def list_projects( } return router + + +def get_registry_router(store) -> APIRouter: + router = APIRouter() + + @router.post("/registry/refresh") + def refresh_registry(): + from feast.permissions.action import AuthzedAction + from feast.permissions.security_manager import assert_permissions + + project = store.registry.get_project(name=store.project, allow_cache=True) + assert_permissions(resource=project, actions=[AuthzedAction.UPDATE]) + store.refresh_registry() + return Response(status_code=status.HTTP_200_OK) + + return router diff --git a/sdk/python/tests/unit/api/test_api_rest_registry.py b/sdk/python/tests/unit/api/test_api_rest_registry.py index 842aee34a28..fe67ff34ebe 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -2323,9 +2323,8 @@ def test_registry_refresh_via_rest_error(): register_all_routes(app, grpc_handler, store=store) with patch.object(store, "refresh_registry", side_effect=Exception("db error")): - client = TestClient(app) + client = TestClient(app, raise_server_exceptions=False) response = client.post("/registry/refresh") assert response.status_code == 500 - assert "Registry refresh failed" in response.json()["detail"] tmp_dir.cleanup() diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index adc6096af1e..212aebc5e89 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -309,7 +309,6 @@ def test_registry_refresh_endpoint_error(mock_feature_store): with _setup_importlib_mocks(temp_dir): app = get_app(mock_feature_store, TEST_PROJECT_NAME) - client = TestClient(app) + client = TestClient(app, raise_server_exceptions=False) resp = client.post("/api/v1/registry/refresh") assertpy.assert_that(resp.status_code).is_equal_to(500) - assertpy.assert_that(resp.json()["detail"]).contains("Registry refresh failed") From a55de3c3f8c328f05899e4f4ebf9d5da748bd0cc Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Fri, 31 Jul 2026 18:54:56 +0530 Subject: [PATCH 8/8] fix: Serve projects-list.json dynamically instead of writing static file at startup Signed-off-by: Aditya Patil --- sdk/python/feast/ui_server.py | 550 +++++++++--------- .../unit/api/test_api_rest_registry_server.py | 2 +- sdk/python/tests/unit/test_ui_server.py | 31 +- 3 files changed, 293 insertions(+), 290 deletions(-) diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 767bff5ca9f..574aeacc257 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -889,308 +889,310 @@ def get_app( ui_dir_ref = importlib_resources.files(__spec__.parent) / "ui/build/" # type: ignore[name-defined, arg-type] with importlib_resources.as_file(ui_dir_ref) as ui_dir: - projects_dict = _build_projects_list(store, project_id, root_path) - with ui_dir.joinpath("projects-list.json").open(mode="w") as f: - f.write(json.dumps(projects_dict)) - @app.get("/projects-list.json") - def get_projects_list(): - return _build_projects_list(store, project_id, root_path) + @app.get("/projects-list.json") + def get_projects_list(): + return _build_projects_list(store, project_id, root_path) - @app.get("/api/mlflow-runs") - def get_mlflow_runs(max_results: int = 50): - """Return MLflow runs linked to this Feast project via auto-logging.""" - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"runs": [], "mlflow_uri": None} + @app.get("/api/mlflow-runs") + def get_mlflow_runs(max_results: int = 50): + """Return MLflow runs linked to this Feast project via auto-logging.""" + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"runs": [], "mlflow_uri": None} - try: - import mlflow - - tracking_uri = mlflow_cfg.get_tracking_uri() - mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - - project_name = store.config.project - experiment = client.get_experiment_by_name(project_name) - if experiment is None: - return {"runs": [], "mlflow_uri": mlflow_ui_base or None} - experiment_ids = [experiment.experiment_id] - - safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") - filter_str = ( - f"tags.`feast.project` = '{safe_project}' " - f"AND tags.`feast.retrieval_type` != ''" - ) - - max_results = min(max(max_results, 1), 200) - runs = client.search_runs( - experiment_ids=experiment_ids, - filter_string=filter_str, - max_results=max_results, - order_by=["start_time DESC"], - ) - - run_id_to_models: Dict[str, List[dict]] = {} try: - for rm in client.search_registered_models(): - for mv in rm.latest_versions or []: - if mv.run_id: - run_id_to_models.setdefault(mv.run_id, []).append( - { - "model_name": rm.name, - "version": mv.version, - "stage": mv.current_stage, - "mlflow_url": ( - f"{mlflow_ui_base}/#/models/" - f"{rm.name}/versions/{mv.version}" - ), - } - ) - except Exception: - pass - - result = [] - for run in runs: - run_tags = run.data.tags - run_params = run.data.params - fv_raw = run_tags.get("feast.feature_views", "") - refs_raw = run_tags.get( - "feast.feature_refs", - run_params.get("feast.feature_refs", ""), + import mlflow + + tracking_uri = mlflow_cfg.get_tracking_uri() + mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + + project_name = store.config.project + experiment = client.get_experiment_by_name(project_name) + if experiment is None: + return {"runs": [], "mlflow_uri": mlflow_ui_base or None} + experiment_ids = [experiment.experiment_id] + + safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") + filter_str = ( + f"tags.`feast.project` = '{safe_project}' " + f"AND tags.`feast.retrieval_type` != ''" ) - result.append( - { - "run_id": run.info.run_id, - "run_name": run.info.run_name, - "status": run.info.status, - "start_time": run.info.start_time, - "feature_service": run_tags.get("feast.feature_service"), - "feature_views": [v for v in fv_raw.split(",") if v], - "feature_refs": [v for v in refs_raw.split(",") if v], - "retrieval_type": run_tags.get("feast.retrieval_type"), - "entity_count": run_tags.get( - "feast.entity_count", - run_params.get("feast.entity_count"), - ), - "mlflow_url": ( - f"{mlflow_ui_base}/#/experiments/" - f"{run.info.experiment_id}/runs/{run.info.run_id}" - ), - "registered_models": run_id_to_models.get(run.info.run_id, []), - } + + max_results = min(max(max_results, 1), 200) + runs = client.search_runs( + experiment_ids=experiment_ids, + filter_string=filter_str, + max_results=max_results, + order_by=["start_time DESC"], ) - return {"runs": result, "mlflow_uri": mlflow_ui_base or None} - except ImportError: - return { - "runs": [], - "mlflow_uri": None, - "error": "mlflow is not installed", - } - except Exception: - return { - "runs": [], - "mlflow_uri": None, - "error": "Failed to fetch MLflow runs", - } + run_id_to_models: Dict[str, List[dict]] = {} + try: + for rm in client.search_registered_models(): + for mv in rm.latest_versions or []: + if mv.run_id: + run_id_to_models.setdefault(mv.run_id, []).append( + { + "model_name": rm.name, + "version": mv.version, + "stage": mv.current_stage, + "mlflow_url": ( + f"{mlflow_ui_base}/#/models/" + f"{rm.name}/versions/{mv.version}" + ), + } + ) + except Exception: + pass - _feature_usage_cache: Dict = {"data": None, "timestamp": 0.0} - _FEATURE_USAGE_TTL_SECONDS = 300 + result = [] + for run in runs: + run_tags = run.data.tags + run_params = run.data.params + fv_raw = run_tags.get("feast.feature_views", "") + refs_raw = run_tags.get( + "feast.feature_refs", + run_params.get("feast.feature_refs", ""), + ) + result.append( + { + "run_id": run.info.run_id, + "run_name": run.info.run_name, + "status": run.info.status, + "start_time": run.info.start_time, + "feature_service": run_tags.get("feast.feature_service"), + "feature_views": [v for v in fv_raw.split(",") if v], + "feature_refs": [v for v in refs_raw.split(",") if v], + "retrieval_type": run_tags.get("feast.retrieval_type"), + "entity_count": run_tags.get( + "feast.entity_count", + run_params.get("feast.entity_count"), + ), + "mlflow_url": ( + f"{mlflow_ui_base}/#/experiments/" + f"{run.info.experiment_id}/runs/{run.info.run_id}" + ), + "registered_models": run_id_to_models.get( + run.info.run_id, [] + ), + } + ) - @app.get("/api/mlflow-feature-usage") - def get_mlflow_feature_usage(): - """Return per-feature-view usage stats aggregated from MLflow runs. + return {"runs": result, "mlflow_uri": mlflow_ui_base or None} + except ImportError: + return { + "runs": [], + "mlflow_uri": None, + "error": "mlflow is not installed", + } + except Exception: + return { + "runs": [], + "mlflow_uri": None, + "error": "Failed to fetch MLflow runs", + } - Caches results for 5 minutes to avoid hammering the MLflow server. - """ - import time as _time + _feature_usage_cache: Dict = {"data": None, "timestamp": 0.0} + _FEATURE_USAGE_TTL_SECONDS = 300 - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"feature_usage": {}, "mlflow_enabled": False} + @app.get("/api/mlflow-feature-usage") + def get_mlflow_feature_usage(): + """Return per-feature-view usage stats aggregated from MLflow runs. - now = _time.monotonic() - if ( - _feature_usage_cache["data"] is not None - and (now - _feature_usage_cache["timestamp"]) < _FEATURE_USAGE_TTL_SECONDS - ): - return _feature_usage_cache["data"] + Caches results for 5 minutes to avoid hammering the MLflow server. + """ + import time as _time - try: - import mlflow + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"feature_usage": {}, "mlflow_enabled": False} - tracking_uri = mlflow_cfg.get_tracking_uri() - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - project_name = store.config.project + now = _time.monotonic() + if ( + _feature_usage_cache["data"] is not None + and (now - _feature_usage_cache["timestamp"]) + < _FEATURE_USAGE_TTL_SECONDS + ): + return _feature_usage_cache["data"] + + try: + import mlflow + + tracking_uri = mlflow_cfg.get_tracking_uri() + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + project_name = store.config.project + + experiment = client.get_experiment_by_name(project_name) + if experiment is None: + result = {"feature_usage": {}, "mlflow_enabled": True} + _feature_usage_cache["data"] = result + _feature_usage_cache["timestamp"] = now + return result + + safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") + filter_str = ( + f"tags.`feast.project` = '{safe_project}' " + f"AND tags.`feast.retrieval_type` != ''" + ) + runs = client.search_runs( + experiment_ids=[experiment.experiment_id], + filter_string=filter_str, + max_results=200, + order_by=["start_time DESC"], + ) - experiment = client.get_experiment_by_name(project_name) - if experiment is None: - result = {"feature_usage": {}, "mlflow_enabled": True} + run_id_to_models: Dict[str, List[str]] = {} + try: + for rm in client.search_registered_models(): + for mv in rm.latest_versions or []: + if mv.run_id: + run_id_to_models.setdefault(mv.run_id, []).append( + rm.name + ) + except Exception: + pass + + usage: Dict[str, dict] = {} + for run in runs: + refs_raw = run.data.tags.get("feast.feature_refs", "") + fv_names = set() + for ref in refs_raw.split(","): + ref = ref.strip() + if ":" in ref: + fv_names.add(ref.split(":")[0]) + + run_models = run_id_to_models.get(run.info.run_id, []) + + for fv_name in fv_names: + if fv_name not in usage: + usage[fv_name] = { + "run_count": 0, + "last_used": None, + "models": [], + } + usage[fv_name]["run_count"] += 1 + run_ts = run.info.start_time + if usage[fv_name]["last_used"] is None or ( + run_ts and run_ts > usage[fv_name]["last_used"] + ): + usage[fv_name]["last_used"] = run_ts + for m in run_models: + if m not in usage[fv_name]["models"]: + usage[fv_name]["models"].append(m) + + result = {"feature_usage": usage, "mlflow_enabled": True} _feature_usage_cache["data"] = result _feature_usage_cache["timestamp"] = now return result + except ImportError: + return { + "feature_usage": {}, + "mlflow_enabled": False, + "error": "mlflow is not installed", + } + except Exception as e: + logger.debug("Failed to fetch feature usage: %s", e) + return { + "feature_usage": {}, + "mlflow_enabled": True, + "error": "Failed to fetch usage data", + } - safe_project = project_name.replace("\\", "\\\\").replace("'", "\\'") - filter_str = ( - f"tags.`feast.project` = '{safe_project}' " - f"AND tags.`feast.retrieval_type` != ''" - ) - runs = client.search_runs( - experiment_ids=[experiment.experiment_id], - filter_string=filter_str, - max_results=200, - order_by=["start_time DESC"], - ) + @app.get("/api/mlflow-feature-models") + def get_mlflow_feature_models(): + """Return a mapping of feature_ref -> registered models that use it. - run_id_to_models: Dict[str, List[str]] = {} - try: - for rm in client.search_registered_models(): - for mv in rm.latest_versions or []: - if mv.run_id: - run_id_to_models.setdefault(mv.run_id, []).append(rm.name) - except Exception: - pass + Walks the MLflow Model Registry, inspects the training run for each + model's latest version(s), reads the ``feast.feature_refs`` tag, and + inverts it into a reverse index so the UI can show which registered + models depend on a given feature. + """ + mlflow_cfg = getattr(store.config, "mlflow", None) + if not mlflow_cfg or not mlflow_cfg.enabled: + return {"feature_models": {}} - usage: Dict[str, dict] = {} - for run in runs: - refs_raw = run.data.tags.get("feast.feature_refs", "") - fv_names = set() - for ref in refs_raw.split(","): - ref = ref.strip() - if ":" in ref: - fv_names.add(ref.split(":")[0]) - - run_models = run_id_to_models.get(run.info.run_id, []) - - for fv_name in fv_names: - if fv_name not in usage: - usage[fv_name] = { - "run_count": 0, - "last_used": None, - "models": [], - } - usage[fv_name]["run_count"] += 1 - run_ts = run.info.start_time - if usage[fv_name]["last_used"] is None or ( - run_ts and run_ts > usage[fv_name]["last_used"] - ): - usage[fv_name]["last_used"] = run_ts - for m in run_models: - if m not in usage[fv_name]["models"]: - usage[fv_name]["models"].append(m) - - result = {"feature_usage": usage, "mlflow_enabled": True} - _feature_usage_cache["data"] = result - _feature_usage_cache["timestamp"] = now - return result - except ImportError: - return { - "feature_usage": {}, - "mlflow_enabled": False, - "error": "mlflow is not installed", - } - except Exception as e: - logger.debug("Failed to fetch feature usage: %s", e) - return { - "feature_usage": {}, - "mlflow_enabled": True, - "error": "Failed to fetch usage data", - } + try: + import mlflow - @app.get("/api/mlflow-feature-models") - def get_mlflow_feature_models(): - """Return a mapping of feature_ref -> registered models that use it. + tracking_uri = mlflow_cfg.get_tracking_uri() + mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" + client = mlflow.MlflowClient(tracking_uri=tracking_uri) + project_name = store.config.project - Walks the MLflow Model Registry, inspects the training run for each - model's latest version(s), reads the ``feast.feature_refs`` tag, and - inverts it into a reverse index so the UI can show which registered - models depend on a given feature. - """ - mlflow_cfg = getattr(store.config, "mlflow", None) - if not mlflow_cfg or not mlflow_cfg.enabled: - return {"feature_models": {}} + feature_models: Dict[str, List[dict]] = {} - try: - import mlflow - - tracking_uri = mlflow_cfg.get_tracking_uri() - mlflow_ui_base = tracking_uri or mlflow.get_tracking_uri() or "" - client = mlflow.MlflowClient(tracking_uri=tracking_uri) - project_name = store.config.project - - feature_models: Dict[str, List[dict]] = {} - - for rm in client.search_registered_models(): - model_name = rm.name - latest_versions = rm.latest_versions or [] - for mv in latest_versions: - if not mv.run_id: - continue - try: - run = client.get_run(mv.run_id) - except Exception: - continue - - tags = run.data.tags - if tags.get("feast.project") != project_name: - continue - - refs_raw = tags.get("feast.feature_refs", "") - feature_refs = [r for r in refs_raw.split(",") if r] - - model_info = { - "model_name": model_name, - "version": mv.version, - "stage": mv.current_stage, - "mlflow_url": ( - f"{mlflow_ui_base}/#/models/" - f"{model_name}/versions/{mv.version}" - ), - } + for rm in client.search_registered_models(): + model_name = rm.name + latest_versions = rm.latest_versions or [] + for mv in latest_versions: + if not mv.run_id: + continue + try: + run = client.get_run(mv.run_id) + except Exception: + continue + + tags = run.data.tags + if tags.get("feast.project") != project_name: + continue + + refs_raw = tags.get("feast.feature_refs", "") + feature_refs = [r for r in refs_raw.split(",") if r] + + model_info = { + "model_name": model_name, + "version": mv.version, + "stage": mv.current_stage, + "mlflow_url": ( + f"{mlflow_ui_base}/#/models/" + f"{model_name}/versions/{mv.version}" + ), + } - for ref in feature_refs: - feature_models.setdefault(ref, []).append(model_info) + for ref in feature_refs: + feature_models.setdefault(ref, []).append(model_info) - return {"feature_models": feature_models} - except ImportError: - return { - "feature_models": {}, - "error": "mlflow is not installed", - } - except Exception as e: - logger.debug("Failed to fetch MLflow feature-model mapping: %s", e) - return { - "feature_models": {}, - "error": "Failed to fetch model data", - } + return {"feature_models": feature_models} + except ImportError: + return { + "feature_models": {}, + "error": "mlflow is not installed", + } + except Exception as e: + logger.debug("Failed to fetch MLflow feature-model mapping: %s", e) + return { + "feature_models": {}, + "error": "Failed to fetch model data", + } - auth_config_json = _build_auth_config_json(store) - - def _serve_index(): - filename = ui_dir.joinpath("index.html") - with open(filename) as f: - content = f.read() - if auth_config_json: - tag = f'' - content = content.replace("", f"{tag}\n", 1) - return Response(content, media_type="text/html") - - @app.get("/") - def serve_root(): - return _serve_index() - - @app.api_route("/p/{path_name:path}", methods=["GET"]) - def catch_all(): - return _serve_index() - - app.mount( - "/", - StaticFiles(directory=ui_dir, html=True), - name="site", - ) + auth_config_json = _build_auth_config_json(store) + + def _serve_index(): + filename = ui_dir.joinpath("index.html") + with open(filename) as f: + content = f.read() + if auth_config_json: + tag = f'' + content = content.replace("", f"{tag}\n", 1) + return Response(content, media_type="text/html") + + @app.get("/") + def serve_root(): + return _serve_index() + + @app.api_route("/p/{path_name:path}", methods=["GET"]) + def catch_all(): + return _serve_index() + + app.mount( + "/", + StaticFiles(directory=ui_dir, html=True), + name="site", + ) - return app + return app def start_server( diff --git a/sdk/python/tests/unit/api/test_api_rest_registry_server.py b/sdk/python/tests/unit/api/test_api_rest_registry_server.py index 464e033a27e..c2fe987ab07 100644 --- a/sdk/python/tests/unit/api/test_api_rest_registry_server.py +++ b/sdk/python/tests/unit/api/test_api_rest_registry_server.py @@ -75,4 +75,4 @@ def test_routes_registered_in_app(): server = MagicMock() register_all_routes(app, grpc_handler, server) - assert app.include_router.call_count == 14 + assert app.include_router.call_count == 15 diff --git a/sdk/python/tests/unit/test_ui_server.py b/sdk/python/tests/unit/test_ui_server.py index 212aebc5e89..8bf01ac5f24 100644 --- a/sdk/python/tests/unit/test_ui_server.py +++ b/sdk/python/tests/unit/test_ui_server.py @@ -148,11 +148,12 @@ def test_catch_all_route(ui_app_with_registry): # ---------- projects-list.json tests ---------- -def _read_projects_list(temp_dir): - """Read the projects-list.json written by get_app via the mock (ui_dir = temp_dir).""" - projects_file = os.path.join(temp_dir, "projects-list.json") - with open(projects_file) as f: - return json.load(f) +def _read_projects_list(app): + """Fetch the projects-list.json from the dynamic endpoint.""" + client = TestClient(app) + resp = client.get("/projects-list.json") + assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS) + return resp.json() def test_projects_list_registry_path(mock_feature_store): @@ -165,9 +166,9 @@ def test_projects_list_registry_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1") @@ -181,13 +182,13 @@ def test_projects_list_with_root_path(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app( + app = get_app( mock_feature_store, TEST_PROJECT_NAME, root_path="/feast", ) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to( "/feast/api/v1" ) @@ -206,9 +207,9 @@ def test_projects_list_multiple_projects(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(3) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all") assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("project_alpha") @@ -225,9 +226,9 @@ def test_projects_list_fallback_on_empty(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME) @@ -242,9 +243,9 @@ def test_projects_list_fallback_on_exception(mock_feature_store): _create_mock_ui_files(temp_dir) with _setup_importlib_mocks(temp_dir): - get_app(mock_feature_store, TEST_PROJECT_NAME) + app = get_app(mock_feature_store, TEST_PROJECT_NAME) - data = _read_projects_list(temp_dir) + data = _read_projects_list(app) assertpy.assert_that(len(data["projects"])).is_equal_to(1) assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME)