Skip to content

Commit e8b42b4

Browse files
authored
Merge pull request #198 from ImagingDataCommons/copilot/support-discovery-index-tables
Support discovery of index tables available from idc-index-data
2 parents 4058250 + 8774cb6 commit e8b42b4

2 files changed

Lines changed: 377 additions & 40 deletions

File tree

idc_index/index.py

Lines changed: 314 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
import logging
67
import os
78
import re
@@ -25,6 +26,7 @@
2526
aws_endpoint_url = "https://s3.amazonaws.com"
2627
gcp_endpoint_url = "https://storage.googleapis.com"
2728
asset_endpoint_url = f"https://github.com/ImagingDataCommons/idc-index-data/releases/download/{idc_index_data.__version__}"
29+
github_api_url = f"https://api.github.com/repos/ImagingDataCommons/idc-index-data/releases/tags/{idc_index_data.__version__}"
2830

2931
logging.basicConfig(format="%(asctime)s - %(message)s", level=logging.INFO)
3032
logger = logging.getLogger(__name__)
@@ -125,40 +127,14 @@ def __init__(self):
125127
)
126128
self.clinical_data_dir = None
127129

128-
self.indices_overview = {
129-
"index": {
130-
"description": "Main index containing one row per DICOM series.",
131-
"installed": True,
132-
"url": None,
133-
"file_path": idc_index_data.IDC_INDEX_PARQUET_FILEPATH,
134-
},
135-
"prior_versions_index": {
136-
"description": "index containing one row per DICOM series from all previous IDC versions that are not in current version.",
137-
"installed": True,
138-
"url": None,
139-
"file_path": idc_index_data.PRIOR_VERSIONS_INDEX_PARQUET_FILEPATH,
140-
},
141-
"sm_index": {
142-
"description": "DICOM Slide Microscopy series-level index.",
143-
"installed": False,
144-
"url": f"{asset_endpoint_url}/sm_index.parquet",
145-
"file_path": None,
146-
},
147-
"sm_instance_index": {
148-
"description": "DICOM Slide Microscopy instance-level index.",
149-
"installed": False,
150-
"url": f"{asset_endpoint_url}/sm_instance_index.parquet",
151-
"file_path": None,
152-
},
153-
"clinical_index": {
154-
"description": "Index of clinical data accompanying the available images.",
155-
"installed": False,
156-
"url": f"{asset_endpoint_url}/clinical_index.parquet",
157-
"file_path": None,
158-
},
159-
}
130+
# Cache for index schemas fetched from release assets
131+
self._index_schemas: dict = {}
132+
133+
# Initialize indices overview with automatic discovery
134+
self.indices_overview = self._discover_available_indices()
160135

161-
# these will point to the dataframes containing the respective indices, once installed
136+
# These will point to the dataframes containing the respective indices, once installed
137+
# Initialize as None to allow checking before they are fetched
162138
self.sm_index = None
163139
self.sm_instance_index = None
164140
self.clinical_index = None
@@ -182,6 +158,284 @@ def __init__(self):
182158
# ... and check it can be executed
183159
subprocess.check_call([self.s5cmdPath, "--help"], stdout=subprocess.DEVNULL)
184160

161+
def _discover_available_indices(self, refresh: bool = False) -> dict:
162+
"""Discover available index tables from the idc-index-data GitHub release assets.
163+
164+
This method discovers available index parquet files by querying the GitHub
165+
releases API to dynamically find all available indices. Descriptions are
166+
populated from the accompanying JSON schema files.
167+
168+
Schemas are cached to disk in the indices_data_dir. On subsequent calls,
169+
schemas are loaded from disk unless the idc-index-data version changes or
170+
refresh is requested.
171+
172+
Args:
173+
refresh: If True, forces a refresh of the cached index list and schemas.
174+
If False, loads from disk cache if available.
175+
176+
Returns:
177+
dict: A dictionary of available indices with their descriptions, URLs,
178+
installation status, and file paths.
179+
"""
180+
# Return cached data if available and refresh is not requested
181+
if (
182+
not refresh
183+
and hasattr(self, "indices_overview")
184+
and self.indices_overview is not None
185+
):
186+
return self.indices_overview
187+
188+
# Try to load from disk cache first (if not forcing refresh)
189+
if not refresh:
190+
cached_data = self._load_indices_cache_from_disk()
191+
if cached_data:
192+
logger.debug("Loaded indices overview from disk cache")
193+
# Populate the in-memory schema cache
194+
if "schemas" in cached_data:
195+
self._index_schemas = cached_data["schemas"]
196+
return cached_data["indices"]
197+
198+
# Mapping of asset filenames to canonical index names for bundled indices
199+
bundled_indices = {
200+
"idc_index": {
201+
"canonical_name": "index",
202+
"file_path": idc_index_data.IDC_INDEX_PARQUET_FILEPATH,
203+
},
204+
"prior_versions_index": {
205+
"canonical_name": "prior_versions_index",
206+
"file_path": idc_index_data.PRIOR_VERSIONS_INDEX_PARQUET_FILEPATH,
207+
},
208+
}
209+
210+
indices = {}
211+
212+
# Discover indices from the GitHub release API
213+
try:
214+
response = requests.get(github_api_url, timeout=30)
215+
if response.status_code == 200:
216+
release_data = response.json()
217+
assets = release_data.get("assets", [])
218+
219+
# Find all parquet files in the release assets
220+
parquet_assets = {
221+
a["name"]: a["browser_download_url"]
222+
for a in assets
223+
if a["name"].endswith(".parquet")
224+
}
225+
226+
# Find all json schema files in the release assets
227+
json_assets = {
228+
a["name"]: a["browser_download_url"]
229+
for a in assets
230+
if a["name"].endswith(".json")
231+
}
232+
233+
# Process all discovered parquet files
234+
for parquet_name, parquet_url in parquet_assets.items():
235+
# Extract index name from filename (e.g., "sm_index.parquet" -> "sm_index")
236+
asset_index_name = parquet_name.replace(".parquet", "")
237+
238+
# Check if this is a bundled index
239+
if asset_index_name in bundled_indices:
240+
bundled_info = bundled_indices[asset_index_name]
241+
index_name = bundled_info["canonical_name"]
242+
installed = True
243+
file_path = bundled_info["file_path"]
244+
url = None # Bundled indices don't need URL
245+
else:
246+
index_name = asset_index_name
247+
local_path = os.path.join(
248+
self.indices_data_dir, f"{index_name}.parquet"
249+
)
250+
installed = os.path.exists(local_path)
251+
file_path = local_path if installed else None
252+
url = parquet_url
253+
254+
# Determine description from schema file and cache the full schema
255+
description = ""
256+
schema_name = f"{asset_index_name}.json"
257+
if schema_name in json_assets:
258+
schema = self._fetch_index_schema_from_url(
259+
json_assets[schema_name]
260+
)
261+
if schema:
262+
description = schema.get("table_description", "")
263+
# Cache the full schema in memory
264+
self._index_schemas[index_name] = schema
265+
266+
indices[index_name] = {
267+
"description": description,
268+
"installed": installed,
269+
"url": url,
270+
"file_path": str(file_path) if file_path else None,
271+
}
272+
273+
else:
274+
logger.warning(
275+
f"GitHub API returned status {response.status_code}. "
276+
"Unable to discover available indices."
277+
)
278+
except requests.exceptions.RequestException as e:
279+
logger.warning(
280+
f"GitHub API request failed: {e}. Unable to discover available indices."
281+
)
282+
283+
# If no indices were discovered, add at least the bundled indices with default descriptions
284+
if not indices:
285+
indices = {
286+
"index": {
287+
"description": "Main index containing one row per DICOM series.",
288+
"installed": True,
289+
"url": None,
290+
"file_path": str(idc_index_data.IDC_INDEX_PARQUET_FILEPATH),
291+
},
292+
"prior_versions_index": {
293+
"description": "Index containing one row per DICOM series from all previous IDC versions that are not in current version.",
294+
"installed": True,
295+
"url": None,
296+
"file_path": str(
297+
idc_index_data.PRIOR_VERSIONS_INDEX_PARQUET_FILEPATH
298+
),
299+
},
300+
}
301+
302+
# Try to fetch schemas for bundled indices even when API fails
303+
for index_name, schema_filename in [
304+
("index", "idc_index.json"),
305+
("prior_versions_index", "prior_versions_index.json"),
306+
]:
307+
schema_url = f"{asset_endpoint_url}/{schema_filename}"
308+
schema = self._fetch_index_schema_from_url(schema_url)
309+
if schema:
310+
indices[index_name]["description"] = schema.get(
311+
"table_description", indices[index_name]["description"]
312+
)
313+
self._index_schemas[index_name] = schema
314+
315+
# Save to disk cache
316+
self._save_indices_cache_to_disk(indices, self._index_schemas)
317+
318+
return indices
319+
320+
def _load_indices_cache_from_disk(self) -> dict | None:
321+
"""Load cached indices overview and schemas from disk.
322+
323+
Returns:
324+
dict or None: Dictionary containing 'indices' and 'schemas' if cache is valid,
325+
None otherwise.
326+
"""
327+
cache_file = os.path.join(self.indices_data_dir, "indices_cache.json")
328+
329+
if not os.path.exists(cache_file):
330+
return None
331+
332+
try:
333+
with open(cache_file) as f:
334+
cache_data = json.load(f)
335+
336+
# Verify cache is for current version
337+
if cache_data.get("version") != idc_index_data.__version__:
338+
logger.debug(
339+
f"Cache version mismatch: {cache_data.get('version')} != {idc_index_data.__version__}"
340+
)
341+
return None
342+
343+
return {
344+
"indices": cache_data.get("indices", {}),
345+
"schemas": cache_data.get("schemas", {}),
346+
}
347+
except (json.JSONDecodeError, OSError) as e:
348+
logger.debug(f"Failed to load indices cache from disk: {e}")
349+
return None
350+
351+
def _save_indices_cache_to_disk(self, indices: dict, schemas: dict) -> None:
352+
"""Save indices overview and schemas to disk cache.
353+
354+
Args:
355+
indices: Dictionary of indices overview
356+
schemas: Dictionary of index schemas
357+
"""
358+
cache_file = os.path.join(self.indices_data_dir, "indices_cache.json")
359+
360+
try:
361+
os.makedirs(self.indices_data_dir, exist_ok=True)
362+
363+
cache_data = {
364+
"version": idc_index_data.__version__,
365+
"indices": indices,
366+
"schemas": schemas,
367+
}
368+
369+
with open(cache_file, "w") as f:
370+
json.dump(cache_data, f, indent=2)
371+
372+
logger.debug(f"Saved indices cache to {cache_file}")
373+
except (OSError, TypeError) as e:
374+
logger.warning(f"Failed to save indices cache to disk: {e}")
375+
376+
def _fetch_index_schema_from_url(self, url: str) -> dict | None:
377+
"""Fetch an index schema JSON from a URL.
378+
379+
Args:
380+
url: The URL to fetch the schema from.
381+
382+
Returns:
383+
dict or None: The parsed schema dictionary, or None if fetching fails.
384+
"""
385+
try:
386+
response = requests.get(url, timeout=30)
387+
if response.status_code == 200:
388+
return response.json()
389+
except (requests.exceptions.RequestException, json.JSONDecodeError) as e:
390+
logger.debug(f"Failed to fetch schema from {url}: {e}")
391+
return None
392+
393+
def refresh_indices_overview(self) -> dict:
394+
"""Refresh the list of available indices by re-querying the GitHub release.
395+
396+
This method forces a refresh of the indices_overview dictionary by querying
397+
the GitHub releases API again, even if a cached version is available.
398+
399+
Returns:
400+
dict: The refreshed indices_overview dictionary.
401+
"""
402+
self.indices_overview = self._discover_available_indices(refresh=True)
403+
return self.indices_overview
404+
405+
def get_index_schema(self, index_name: str, refresh: bool = False) -> dict | None:
406+
"""Get the full schema for an index, including column definitions.
407+
408+
This method returns the JSON schema for the specified index. The schema
409+
includes table_description and column definitions with name, type, mode,
410+
and description. Schemas are cached in memory and on disk during discovery.
411+
412+
Args:
413+
index_name: The name of the index to get the schema for.
414+
refresh: If True, forces a refresh by re-discovering all indices.
415+
416+
Returns:
417+
dict or None: The schema dictionary containing 'table_description' and
418+
'columns', or None if the schema is not available.
419+
"""
420+
if index_name not in self.indices_overview:
421+
logger.error(f"Index {index_name} is not available.")
422+
return None
423+
424+
# If refresh is requested, re-discover indices to refresh all schemas
425+
if refresh:
426+
self.indices_overview = self._discover_available_indices(refresh=True)
427+
428+
# Return cached schema if available
429+
if index_name in self._index_schemas:
430+
return self._index_schemas[index_name]
431+
432+
# Schema was not cached during discovery (shouldn't happen in normal operation)
433+
logger.warning(
434+
f"Schema for {index_name} not available in cache. "
435+
"This may indicate the index was discovered but schema fetch failed."
436+
)
437+
return None
438+
185439
@staticmethod
186440
def _replace_aws_with_gcp_buckets(dataframe, column_name):
187441
# mapping from AWS to GCS buckets is fixed
@@ -368,11 +622,33 @@ def fetch_index(self, index_name) -> None:
368622
"""
369623
if index_name not in self.indices_overview:
370624
logger.error(f"Index {index_name} is not available and can not be fetched.")
371-
elif self.indices_overview[index_name]["installed"]:
372-
logger.warning(
373-
f"Index {index_name} already installed and will not be fetched again."
374-
)
375-
else:
625+
return
626+
if self.indices_overview[index_name]["installed"]:
627+
# Index is already installed, load it from disk if not already loaded
628+
if not hasattr(self, index_name) or getattr(self, index_name) is None:
629+
filepath = self.indices_overview[index_name]["file_path"]
630+
if filepath and os.path.exists(filepath):
631+
logger.info(
632+
f"Index {index_name} already installed, loading from {filepath}"
633+
)
634+
index_table = pd.read_parquet(filepath)
635+
setattr(self, index_name, index_table)
636+
else:
637+
logger.warning(
638+
f"Index {index_name} marked as installed but file not found. Re-downloading."
639+
)
640+
# Reset installed status to allow download
641+
self.indices_overview[index_name]["installed"] = False
642+
self.indices_overview[index_name]["file_path"] = None
643+
# Fall through to the download logic below instead of recursive call
644+
else:
645+
logger.warning(
646+
f"Index {index_name} already installed and will not be fetched again."
647+
)
648+
return
649+
650+
# Download the index if not installed
651+
if not self.indices_overview[index_name]["installed"]:
376652
logger.info("Fetching index %s", index_name)
377653
response = requests.get(
378654
self.indices_overview[index_name]["url"], timeout=30

0 commit comments

Comments
 (0)