22
33from __future__ import annotations
44
5+ import json
56import logging
67import os
78import re
2526aws_endpoint_url = "https://s3.amazonaws.com"
2627gcp_endpoint_url = "https://storage.googleapis.com"
2728asset_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
2931logging .basicConfig (format = "%(asctime)s - %(message)s" , level = logging .INFO )
3032logger = 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