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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions api/routers/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 15 additions & 5 deletions api/routers/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
)


Expand All @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,5 @@ class StatsResponse(BaseModel):
specs: int
plots: int
libraries: int
languages: int = 0
lines_of_code: int = 0
4 changes: 2 additions & 2 deletions app/src/components/NumbersStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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' },
Expand Down
3 changes: 2 additions & 1 deletion app/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -23,4 +23,5 @@ export const LIB_ABBREV: Record<string, string> = {
pygal: 'pyg',
highcharts: 'hc',
letsplot: 'lp',
ggplot2: 'gg',
};
39 changes: 39 additions & 0 deletions app/src/hooks/useCodeFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
29 changes: 19 additions & 10 deletions app/src/hooks/useCodeFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;
getCode: (specId: string, library: string) => string | null;
fetchCode: (specId: string, library: string, language?: string) => Promise<string | null>;
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<CodeCache>({});
const pendingRef = useRef<Map<string, Promise<string | null>>>(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<string | null> => {
const key = `${specId}:${library}`;
const fetchCode = useCallback(async (specId: string, library: string, language: string = 'python'): Promise<string | null> => {
const key = cacheKey(specId, library, language);

// Check cache first
if (key in cacheRef.current) {
Expand All @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions app/src/pages/SpecPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/api/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/unit/api/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down