diff --git a/api/routers/specs.py b/api/routers/specs.py index 874e670297..7574033410 100644 --- a/api/routers/specs.py +++ b/api/routers/specs.py @@ -117,14 +117,14 @@ async def _build_spec_detail(db: AsyncSession, spec_id: str) -> SpecDetailRespon ) -async def _build_impl_code(db: AsyncSession, spec_id: str, library: str) -> dict: +async def _build_impl_code(db: AsyncSession, spec_id: str, library: str, language: str = "python") -> dict: repo = ImplRepository(db) - impl = await repo.get_code(spec_id, library) + impl = await repo.get_code(spec_id, library, language) if not impl or not impl.code: - raise_not_found("Implementation code", f"{spec_id}/{library}") + raise_not_found("Implementation code", f"{spec_id}/{language}/{library}") - return {"spec_id": spec_id, "library": library, "code": strip_noqa_comments(impl.code)} + return {"spec_id": spec_id, "library": library, "language": language, "code": strip_noqa_comments(impl.code)} async def _build_spec_images(db: AsyncSession, spec_id: str) -> dict: @@ -196,18 +196,25 @@ async def _refresh() -> SpecDetailResponse: @router.get("/specs/{spec_id}/{library}/code") -async def get_impl_code(spec_id: str, library: str, db: AsyncSession = Depends(require_db)): - """Get implementation code for a specific spec + library (code field deferred in main query).""" +async def get_impl_code(spec_id: str, library: str, language: str = "python", db: AsyncSession = Depends(require_db)): + """Get implementation code for a specific spec + library + language. + + Code field is deferred in the main `/specs/{id}` query so it must be + fetched here on-demand. `language` disambiguates when the same library_id + could exist for multiple languages (today only ggplot2 is R; everything + else is Python). Defaults to python for backwards compat with older + clients that don't send the param. + """ async def _fetch() -> dict: - return await _build_impl_code(db, spec_id, library) + return await _build_impl_code(db, spec_id, library, language) async def _refresh() -> dict: async with get_db_context() as fresh_db: - return await _build_impl_code(fresh_db, spec_id, library) + return await _build_impl_code(fresh_db, spec_id, library, language) return await get_or_set_cache( - cache_key("impl_code", spec_id, library), + cache_key("impl_code", spec_id, language, library), _fetch, refresh_after=settings.cache_refresh_after, refresh_factory=_refresh, diff --git a/api/routers/stats.py b/api/routers/stats.py index 63eb20538c..278faecc85 100644 --- a/api/routers/stats.py +++ b/api/routers/stats.py @@ -7,7 +7,7 @@ from api.dependencies import optional_db from api.schemas import StatsResponse from core.config import settings -from core.constants import LIBRARIES_METADATA +from core.constants import LANGUAGES_METADATA, LIBRARIES_METADATA from core.database import ImplRepository, LibraryRepository, SpecRepository from core.database.connection import get_db_context @@ -27,8 +27,13 @@ async def _refresh_stats() -> StatsResponse: specs_with_impls = [s for s in specs if s.impls] total_impls = sum(len(s.impls) for s in specs) + languages = len({lib.language_id for lib in libraries}) or len(LANGUAGES_METADATA) return StatsResponse( - specs=len(specs_with_impls), plots=total_impls, libraries=len(libraries), lines_of_code=total_loc + specs=len(specs_with_impls), + plots=total_impls, + libraries=len(libraries), + languages=languages, + lines_of_code=total_loc, ) @@ -37,10 +42,10 @@ async def get_stats(db: AsyncSession | None = Depends(optional_db)): """ Get platform statistics. - Returns counts of specs, implementations (plots), and libraries. + Returns counts of specs, implementations (plots), libraries, and languages. """ if db is None: - return StatsResponse(specs=0, plots=0, libraries=len(LIBRARIES_METADATA)) + return StatsResponse(specs=0, plots=0, libraries=len(LIBRARIES_METADATA), languages=len(LANGUAGES_METADATA)) async def _fetch() -> StatsResponse: spec_repo = SpecRepository(db) @@ -52,8 +57,13 @@ async def _fetch() -> StatsResponse: specs_with_impls = [s for s in specs if s.impls] total_impls = sum(len(s.impls) for s in specs) + languages = len({lib.language_id for lib in libraries}) or len(LANGUAGES_METADATA) return StatsResponse( - specs=len(specs_with_impls), plots=total_impls, libraries=len(libraries), lines_of_code=total_loc + specs=len(specs_with_impls), + plots=total_impls, + libraries=len(libraries), + languages=languages, + lines_of_code=total_loc, ) return await get_or_set_cache( diff --git a/api/schemas.py b/api/schemas.py index ba64a9cb0c..674a940f8d 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -150,4 +150,5 @@ class StatsResponse(BaseModel): specs: int plots: int libraries: int + languages: int = 0 lines_of_code: int = 0 diff --git a/app/src/components/NumbersStrip.tsx b/app/src/components/NumbersStrip.tsx index 421d79b310..d9de66aa68 100644 --- a/app/src/components/NumbersStrip.tsx +++ b/app/src/components/NumbersStrip.tsx @@ -2,7 +2,7 @@ import Box from '@mui/material/Box'; import { typography } from '../theme'; interface NumbersStripProps { - stats: { specs: number; plots: number; libraries: number; lines_of_code?: number } | null; + stats: { specs: number; plots: number; libraries: number; languages?: number; lines_of_code?: number } | null; } function formatLoc(n: number | undefined): string { @@ -13,7 +13,7 @@ function formatLoc(n: number | undefined): string { export function NumbersStrip({ stats }: NumbersStripProps) { const items = [ - { value: '1', label: 'languages' }, + { value: stats?.languages ? String(stats.languages) : '—', label: 'languages' }, { value: stats ? String(stats.libraries) : '—', label: 'libraries' }, { value: stats ? String(stats.specs) : '—', label: 'specifications' }, { value: stats ? stats.plots.toLocaleString() : '—', label: 'implementations' }, diff --git a/app/src/constants/index.ts b/app/src/constants/index.ts index 70172c00d2..8e72ef139d 100644 --- a/app/src/constants/index.ts +++ b/app/src/constants/index.ts @@ -6,7 +6,7 @@ export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; // sent with fetch. Falls back to API_URL locally where there's no Worker. export const DEBUG_API_URL = import.meta.env.VITE_DEBUG_API_URL || API_URL; export const GITHUB_URL = 'https://github.com/MarkusNeusinger/anyplot'; -export const LIBRARIES = ['altair', 'bokeh', 'highcharts', 'letsplot', 'matplotlib', 'plotly', 'plotnine', 'pygal', 'seaborn']; +export const LIBRARIES = ['altair', 'bokeh', 'ggplot2', 'highcharts', 'letsplot', 'matplotlib', 'plotly', 'plotnine', 'pygal', 'seaborn']; export const BATCH_SIZE = 36; // Image size: 'normal' or 'compact' (half size) @@ -23,4 +23,5 @@ export const LIB_ABBREV: Record = { pygal: 'pyg', highcharts: 'hc', letsplot: 'lp', + ggplot2: 'gg', }; diff --git a/app/src/hooks/useCodeFetch.test.ts b/app/src/hooks/useCodeFetch.test.ts index ddaac0f54c..9c9bdc4a4b 100644 --- a/app/src/hooks/useCodeFetch.test.ts +++ b/app/src/hooks/useCodeFetch.test.ts @@ -159,5 +159,44 @@ describe('useCodeFetch', () => { expect(snsResult).toContain('seaborn'); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); + + it('appends ?language= for non-python languages', async () => { + const ggCode = 'library(ggplot2)'; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ code: ggCode }), + }); + + const { result } = renderHook(() => useCodeFetch()); + let code: string | null = null; + await act(async () => { + code = await result.current.fetchCode('scatter-basic', 'ggplot2', 'r'); + }); + + expect(code).toBe(ggCode); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/specs/scatter-basic/ggplot2/code?language=r') + ); + }); + + it('caches python and r impls under separate keys for the same library_id', async () => { + const pyCode = 'import matplotlib'; + const rCode = 'library(matplotlib)'; + globalThis.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ code: pyCode }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ code: rCode }) }); + + const { result } = renderHook(() => useCodeFetch()); + let py: string | null = null; + let r: string | null = null; + await act(async () => { + py = await result.current.fetchCode('hypothetical', 'matplotlib'); + r = await result.current.fetchCode('hypothetical', 'matplotlib', 'r'); + }); + + expect(py).toBe(pyCode); + expect(r).toBe(rCode); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/app/src/hooks/useCodeFetch.ts b/app/src/hooks/useCodeFetch.ts index cdd9bdad53..2b5e7707b5 100644 --- a/app/src/hooks/useCodeFetch.ts +++ b/app/src/hooks/useCodeFetch.ts @@ -9,27 +9,31 @@ import { useState, useCallback, useRef } from 'react'; import { API_URL } from '../constants'; interface CodeCache { - [key: string]: string | null; // key: `${spec_id}:${library}` + [key: string]: string | null; // key: `${spec_id}:${language}:${library}` } interface UseCodeFetchReturn { - fetchCode: (specId: string, library: string) => Promise; - getCode: (specId: string, library: string) => string | null; + fetchCode: (specId: string, library: string, language?: string) => Promise; + getCode: (specId: string, library: string, language?: string) => string | null; isLoading: boolean; } +// Default language matches the API's own default so old call sites — and the +// majority of (Python) impls — keep producing the same URL and cache key. +const cacheKey = (specId: string, library: string, language: string) => + `${specId}:${language}:${library}`; + export function useCodeFetch(): UseCodeFetchReturn { const [isLoading, setIsLoading] = useState(false); const cacheRef = useRef({}); const pendingRef = useRef>>(new Map()); - const getCode = useCallback((specId: string, library: string): string | null => { - const key = `${specId}:${library}`; - return cacheRef.current[key] ?? null; + const getCode = useCallback((specId: string, library: string, language: string = 'python'): string | null => { + return cacheRef.current[cacheKey(specId, library, language)] ?? null; }, []); - const fetchCode = useCallback(async (specId: string, library: string): Promise => { - const key = `${specId}:${library}`; + const fetchCode = useCallback(async (specId: string, library: string, language: string = 'python'): Promise => { + const key = cacheKey(specId, library, language); // Check cache first if (key in cacheRef.current) { @@ -42,11 +46,16 @@ export function useCodeFetch(): UseCodeFetchReturn { return pending; } - // Fetch from lightweight code endpoint + // Only append the language query param when it diverges from the API + // default — keeps URLs for the common Python case unchanged. + const url = language === 'python' + ? `${API_URL}/specs/${specId}/${library}/code` + : `${API_URL}/specs/${specId}/${library}/code?language=${encodeURIComponent(language)}`; + setIsLoading(true); const promise = (async () => { try { - const response = await fetch(`${API_URL}/specs/${specId}/${library}/code`); + const response = await fetch(url); if (!response.ok) { cacheRef.current[key] = null; return null; diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index de50197b3f..fa4708d8b9 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -131,14 +131,16 @@ export function SpecPage() { return specData.implementations.find((impl) => impl.library_id === selectedLibrary) || null; }, [specData, selectedLibrary]); - // Prefetch code in background when impl detail page opens + // Prefetch code in background when impl detail page opens. Pass urlLanguage + // so R impls (ggplot2 today) hit the right DB row — the API defaults to + // python and would 404 on an R artifact otherwise. useEffect(() => { if (specId && selectedLibrary) { - fetchCode(specId, selectedLibrary); + fetchCode(specId, selectedLibrary, urlLanguage); } - }, [specId, selectedLibrary, fetchCode]); + }, [specId, selectedLibrary, urlLanguage, fetchCode]); - const currentCode = specId && selectedLibrary ? getCode(specId, selectedLibrary) : null; + const currentCode = specId && selectedLibrary ? getCode(specId, selectedLibrary, urlLanguage) : null; // Handle library switch (in detail mode) const handleLibrarySelect = useCallback( @@ -213,7 +215,7 @@ export function SpecPage() { const handleCopyCode = useCallback( async (impl: Implementation) => { try { - const code = impl.code || (specId ? await fetchCode(specId, impl.library_id) : null); + const code = impl.code || (specId ? await fetchCode(specId, impl.library_id, impl.language) : null); if (!code) return; await navigator.clipboard.writeText(code); setCodeCopied(impl.library_id); diff --git a/tests/unit/api/test_schemas.py b/tests/unit/api/test_schemas.py index 68bbe9a1fa..b61e6ac91c 100644 --- a/tests/unit/api/test_schemas.py +++ b/tests/unit/api/test_schemas.py @@ -203,4 +203,8 @@ def test_creation(self) -> None: def test_serialization(self) -> None: stats = StatsResponse(specs=10, plots=50, libraries=9) data = stats.model_dump() - assert data == {"specs": 10, "plots": 50, "libraries": 9, "lines_of_code": 0} + assert data == {"specs": 10, "plots": 50, "libraries": 9, "languages": 0, "lines_of_code": 0} + + def test_languages_field(self) -> None: + stats = StatsResponse(specs=10, plots=50, libraries=10, languages=2) + assert stats.languages == 2 diff --git a/tests/unit/api/test_stats.py b/tests/unit/api/test_stats.py index b7a81c7ff8..64ed785f31 100644 --- a/tests/unit/api/test_stats.py +++ b/tests/unit/api/test_stats.py @@ -90,6 +90,7 @@ async def test_refresh_stats_queries_db(self) -> None: assert result.specs == 1 # Only spec with impls assert result.plots == 1 assert result.libraries == 1 + assert result.languages == 1 # Single mock library → one distinct language_id assert result.lines_of_code == 42