diff --git a/iris/api/routes/config.py b/iris/api/routes/config.py index ff5e5103..b2925534 100644 --- a/iris/api/routes/config.py +++ b/iris/api/routes/config.py @@ -79,6 +79,11 @@ def get_project_config(): 'name': project.config.get('name'), 'host': project.config.get('host', '127.0.0.1'), 'port': project.config.get('port', 5000), + # Expose whether the server/project was started in debug mode. + # This is set by start_server(..., debug=True) and stored on the + # project object, so the frontend can decide to enable verbose + # debug behavior such as pretty-printing the saved config. + 'debug': getattr(project, 'debug', False), 'images': { 'path': deepcopy(project.config['images']['path']), 'shape': deepcopy(project.config['images']['shape']), @@ -320,6 +325,10 @@ def validate_project_config(): elif isinstance(config_data['images']['path'], str): if '{id}' not in config_data['images']['path'] and config_data['images']['path'] != '': warnings.append('images.path should contain {id} placeholder') + elif isinstance(config_data['images']['path'], dict): + for k, v in config_data['images']['path'].items(): + if '{id}' not in v and v != '': + warnings.append(f'images.path {k} should contain {{id}} placeholder') if 'shape' not in config_data['images']: errors.append('images.shape is required') diff --git a/iris/cli.py b/iris/cli.py index d6223d76..9026fd16 100644 --- a/iris/cli.py +++ b/iris/cli.py @@ -85,6 +85,8 @@ def label( @app.command() def launch( folder: Annotated[str, typer.Argument(help="Project folder name to create or launch")], + debug: Annotated[bool, typer.Option("--debug", "-d", help="Start in debug mode")] = False, + production: Annotated[bool, typer.Option("--production", "-p", help="Use production WSGI server")] = False, ): """ Create a new project from the demo template or launch an existing one. @@ -101,7 +103,7 @@ def launch( try: config_file = handle_launch_command(folder) typer.echo(f"Launching project '{folder}' with config '{config_file}'...") - start_server(project_file=str(config_file), debug=False, production=False) + start_server(project_file=str(config_file), debug=debug, production=production) except (ValueError, FileNotFoundError, RuntimeError) as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(code=1) diff --git a/iris/project.py b/iris/project.py index fdd7e050..015c1359 100644 --- a/iris/project.py +++ b/iris/project.py @@ -67,7 +67,6 @@ def load_from(self, filename): self.config['images']['path'] = { 'pictures': self.config['images']['path'] } except Exception: # Non-fatal; leave config as-is on error - logger.warning(f"Failed to normalize images.path: {e}") pass self._init_paths_and_files(filename) @@ -172,35 +171,39 @@ def _init_paths_and_files(self, filename): ) if isinstance(self['images']['path'], dict): - image_paths = list(self['images']['path'].values())[0] + image_paths = list(self['images']['path'].values()) else: - image_paths = self['images']['path'] - - # We will need to extract the image id by using regex. Compile it here - # to get a better perfomance: - before, id_str, after = image_paths.partition("{id}") - if not id_str: - raise Exception('[CONFIG] images:path must contain exactly one placeholder "{id}"!') - escaped_path = re.escape(before) - escaped_path += "(?P.+)" - escaped_path += re.escape(after) - regex_images = re.compile(escaped_path) - - images = glob(image_paths.format(id="*")) - if not images: - raise Exception( - f"[CONFIG] No images found in '{image_paths.format(id='*')}'.\n" - "Did you set images:path to a valid, existing path?") + image_paths = [self['images']['path']] + + for ip_idx, image_path in enumerate(image_paths): + # We will need to extract the image id by using regex. Compile it here + # to get a better performance: + before, id_str, after = image_path.partition("{id}") + if not id_str: + raise Exception(f'[CONFIG] images:path {ip_idx+1} must contain exactly one placeholder "{{id}}"!') + escaped_path = re.escape(before) + escaped_path += "(?P.+)" + escaped_path += re.escape(after) + regex_images = re.compile(escaped_path) + + images = glob(image_path.format(id="*")) + if not images: + raise Exception( + f"[CONFIG] No images found in '{image_path.format(id='*')}'.\n" + "Did you set images:path to a valid, existing path?") - try: - self.image_ids = list(sorted([ - regex_images.match(image_path).groups()[0] - for image_path in images - ])) - except Exception as error: - raise Exception( - f'[ERROR] Could not extract id\nfrom path"{image_paths}"\nwith regex "{regex_images}"!' - ) + try: + image_ids = list(sorted([ + regex_images.match(image_path).groups()[0] + for image_path in images + ])) + except Exception as error: + raise Exception( + f'[ERROR] Could not extract id\nfrom path"{image_path}"\nwith regex "{regex_images}"!' + ) + else: + if ip_idx == 0: + self.image_ids = image_ids def make_absolute(self, path): diff --git a/iris/tests/test_api_config.py b/iris/tests/test_api_config.py index c40b0f03..bfe49fc5 100644 --- a/iris/tests/test_api_config.py +++ b/iris/tests/test_api_config.py @@ -400,6 +400,17 @@ def test_put_config_requires_admin(app, client): assert response.status_code == 403 +def test_api_project_includes_debug(logged_in_admin): + """API should return a 'debug' flag reflecting server start mode""" + response = logged_in_admin.get('/api/config/project') + assert response.status_code == 200 + data = response.json + assert 'config' in data + config = data['config'] + assert 'debug' in config, "Expected 'debug' key in config payload" + assert isinstance(config['debug'], bool) + + def test_load_from_normalizes_images_path(tmp_path, sample_valid_config): """Ensure Project.load_from() converts single-string images.path into a dict""" # Prepare a temporary project directory with an images subfolder and a dummy file diff --git a/iris/tests/test_api_config_validation.py b/iris/tests/test_api_config_validation.py index 65753dd0..2196dcfa 100644 --- a/iris/tests/test_api_config_validation.py +++ b/iris/tests/test_api_config_validation.py @@ -132,6 +132,25 @@ def test_validate_warns_missing_id_placeholder(logged_in_admin, sample_valid_con assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings']) +def test_validate_warns_missing_id_placeholder_dict(logged_in_admin, sample_valid_config): + """Test that validation warns when images.path lacks {id} placeholder""" + sample_valid_config['images']['path'] = { + 'Sent1': 'test/{id}/image.tif', + 'Sent2': 'test/image.tif', # No {id} + } + + response = logged_in_admin.post('/api/config/project/validate', + json=sample_valid_config, + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json + # Should be valid but with warning + assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings']) + assert any('sent2' in warn.lower() for warn in data['warnings']) + + def test_validate_detects_colour_value_out_of_range(logged_in_admin, sample_valid_config): """Test that validation detects colour values outside 0-255 range""" sample_valid_config['classes'][0]['colour'] = [256, 0, 0, 255] # 256 is too high diff --git a/src/components/preferences/ProjectConfigTab.tsx b/src/components/preferences/ProjectConfigTab.tsx index aaa9cbff..632f0f55 100644 --- a/src/components/preferences/ProjectConfigTab.tsx +++ b/src/components/preferences/ProjectConfigTab.tsx @@ -264,10 +264,20 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) => const validationResult = await validateProjectConfig(config); if (!validationResult.valid) { - setError(`Validation failed: ${validationResult.errors.join(', ')}`); + const msg = `Validation failed: ${validationResult.errors.join('\n')}`; + setError(msg); return; } + // Only log the full configuration if the project was started in debug mode + if (loadedConfig && (loadedConfig as any).debug) { + try { + console.log('Save Complete Configuration - full config:', JSON.stringify(config, null, 2)); + } catch (e) { + console.log('Save Complete Configuration - full config (object):', config); + } + } + // Save to backend const response = await updateProjectConfig(config); @@ -281,7 +291,8 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) => } catch (err: any) { console.error('[ProjectConfigTab] Failed to save configuration:', err); - setError(err.message || 'Failed to save configuration'); + const msg = err?.message || 'Failed to save configuration'; + setError(msg); } finally { setSaving(false); } @@ -336,6 +347,19 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) =>
+ {/* Duplicate error banner near the Save button for better visibility */} + {error && ( +
+ Error: {error} +
+ )}